feat(ui): simplify A3 workspace and refresh saved AI availability

This commit is contained in:
cesnimda
2026-11-04 00:21:04 +01:00
parent 7aa54bff1f
commit 2218659ecf
216 changed files with 7935 additions and 115 deletions
@@ -8,17 +8,25 @@ import path from 'node:path';
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const reviewOnly = Boolean(process.env.A3_AI_REVIEW_ONLY);
const out = path.resolve(import.meta.dirname, reviewOnly ? '../../docs/ui-modernisation/evidence/cross-app/browser-ai-review' : '../../docs/ui-modernisation/evidence/ai-settings/browser');
const visualMatrix = Boolean(process.env.A3_VISUAL_MATRIX);
assert.ok(!visualMatrix || reviewOnly, 'Visual matrix reuses read/review-only journeys.');
const out = path.resolve(import.meta.dirname, visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/cv' : reviewOnly ? '../../docs/ui-modernisation/evidence/cross-app/browser-ai-review' : '../../docs/ui-modernisation/evidence/ai-settings/browser');
fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], writes: [], proposals: [], unavailableReads: [], blockedExternal: [] };
const browser = await chromium.launch();
try {
const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : [320, 390, 768, 1440].flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode])));
const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : (visualMatrix ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440]).flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode])));
for (const [width, language, mode] of configurations) {
console.log(`Checking ${width}/${language}/${mode}`);
const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; };
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' });
const page = await context.newPage(); page.on('pageerror', error => report.errors.push(error.message));
const studioSection = async key => {
await expect(page.getByRole('region', { name: t('cvEditorEditingRegion'), exact: true })).toBeVisible();
const tab = page.getByRole('tab', { name: t(key), exact: true });
if (await tab.isVisible()) await tab.click();
else { await page.getByRole('combobox', { name: new RegExp(`^${t('cvEditorStudioNavigation')}`) }).click(); await page.getByRole('option', { name: t(key), exact: true }).click(); }
};
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
let notifications = { emailFollowUpRemindersEnabled: true };
let privacy = { enabled: true, externalProcessingAllowed: false, externalProcessingAvailable: false, effectiveExternalProcessing: false, provider: 'local' };
@@ -65,7 +73,12 @@ try {
const scan = async state => {
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `overflow:${state}`);
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
report.screens.push({ width, language, mode, state, violations: result.violations }); assert.deepEqual(result.violations, [], `${state} accessibility`);
const layout = visualMatrix ? await page.evaluate(() => {
const bounds = element => { const rect = element?.getBoundingClientRect(); return rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null; };
const studio = document.querySelector('[data-cv-studio-layout]');
return { header: bounds(document.querySelector('.MuiAppBar-root')), editor: bounds(studio?.firstElementChild), preview: bounds(studio?.lastElementChild), outlinedPapersInStudio: studio?.querySelectorAll('.MuiPaper-outlined').length ?? 0 };
}) : undefined;
report.screens.push({ width, language, mode, state, layout, violations: result.violations }); assert.deepEqual(result.violations, [], `${state} accessibility`);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0);
await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: width >= 768 });
@@ -82,27 +95,32 @@ try {
await page.getByRole('button', { name: t('settingsAutomationAi'), exact: true }).press('Enter'); await expect(page.locator('#settings-ai-controls')).toBeFocused();
await page.getByRole('switch', { name: t('settingsAiEnabled'), exact: true }).uncheck(); await page.getByRole('button', { name: t('settingsAiSave'), exact: true }).click(); await expect.poll(() => privacy.enabled).toBe(false); await scan('ai-disabled');
await page.goto('http://localhost:4338/career/builder/3');
await page.getByRole('tab', { name: t('cvEditorAiTools'), exact: true }).click();
await studioSection('cvEditorAiTools');
await expect(page.getByText(t('aiUnavailableTitle'), { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: t('cvDocumentAiReview'), exact: true }).first()).toBeDisabled();
await page.getByRole('tab', { name: t('cvEditorContent'), exact: true }).click();
await studioSection('cvEditorContent');
await page.getByRole('textbox', { name: t('cvEditorHeadlineOverride'), exact: true }).fill('Manual teacher headline'); await expect.poll(() => variant.version, { timeout: 15000 }).toBe(5); assert.equal(proposals.length, 0); await scan('manual-fallback');
await page.goto('http://localhost:4338/settings'); await page.getByRole('switch', { name: t('settingsAiEnabled'), exact: true }).check(); await page.getByRole('button', { name: t('settingsAiSave'), exact: true }).click(); await expect.poll(() => privacy.enabled).toBe(true);
}
await page.goto('http://localhost:4338/career/builder/3'); await page.getByRole('tab', { name: t('cvEditorAiTools'), exact: true }).click();
await page.goto('http://localhost:4338/career/builder/3'); await studioSection('cvEditorAiTools');
await page.getByRole('button', { name: t('cvDocumentAiReview'), exact: true }).first().click(); await expect(page.getByRole('button', { name: t('cvAiApply'), exact: true })).toBeVisible(); assert.equal(writes.length, reviewOnly ? 0 : 4);
await expect(page.locator('#cv-studio-panel-3').getByText(t('cvEditorHeadlineOverride'), { exact: true })).toBeVisible(); await scan('ai-review');
if (reviewOnly) {
if (visualMatrix) {
await studioSection('cvEditorContent'); await scan('content');
if (width < 768) await page.getByRole('button', { name: t('cvEditorPreview'), exact: true }).click();
await expect(page.getByRole('region', { name: t('cvEditorLivePreview'), exact: true })).toBeVisible(); await scan('preview');
}
assert.deepEqual(writes, []); assert.deepEqual(proposals, ['en']);
report.journeys.push({ width, language, mode, translatedReviewCaption: 'PASS', explicitReviewWithoutApply: 'PASS', uiLanguageDistinctFromOutputLanguage: 'PASS' }); await context.close(); continue;
}
await page.getByRole('combobox', { name: new RegExp(`^${t('cvAiLanguage')}`) }).click(); await page.getByRole('option', { name: t('languageNorwegianBokmal'), exact: true }).click();
await expect(page.getByRole('button', { name: t('cvAiApply'), exact: true })).toHaveCount(0); assert.equal(proposals.length, 1); assert.equal(variant.settings.headline, 'Manual teacher headline'); await scan('language-discard');
await page.getByRole('button', { name: t('cvDocumentAiReview'), exact: true }).first().click(); await page.getByRole('button', { name: t('cvAiApply'), exact: true }).click(); await expect.poll(() => variant.version).toBe(6);
await page.getByRole('tab', { name: t('cvEditorContent'), exact: true }).click(); await expect(page.getByRole('textbox', { name: t('cvEditorHeadlineOverride'), exact: true })).toHaveValue('Reviewed teacher headline'); await scan('explicit-apply');
await studioSection('cvEditorContent'); await expect(page.getByRole('textbox', { name: t('cvEditorHeadlineOverride'), exact: true })).toHaveValue('Reviewed teacher headline'); await scan('explicit-apply');
assert.deepEqual(writes, ['notificationPreference', 'aiPreference', 'manualCvSave', 'aiPreference', 'explicitAiApply']); assert.deepEqual(proposals, ['en', 'nb-NO']);
report.journeys.push({ width, language, mode, manualFallback: 'PASS', languageDiscardWithoutApply: 'PASS', explicitTokenApply: 'PASS', aiSettingsKeyboardFocus: 'PASS' }); await context.close();
}
assert.equal(report.screens.length, configurations.length * (reviewOnly ? 1 : 6)); assert.equal(report.journeys.length, configurations.length); assert.equal(report.writes.length, reviewOnly ? 0 : configurations.length * 5); assert.equal(report.proposals.length, configurations.length * (reviewOnly ? 1 : 2));
assert.equal(report.screens.length, configurations.length * (reviewOnly ? visualMatrix ? 3 : 1 : 6)); assert.equal(report.journeys.length, configurations.length); assert.equal(report.writes.length, reviewOnly ? 0 : configurations.length * 5); assert.equal(report.proposals.length, configurations.length * (reviewOnly ? 1 : 2));
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
console.log(`PASS:${report.journeys.length} controlled journeys,${report.screens.length} axe scans/captures.`);
} finally {
@@ -0,0 +1,103 @@
// Actual App/Settings/CV routing with intercepted preferences and identities; no model activation.
import { chromium, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import ts from 'typescript';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/entitlement-refresh/browser');
fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], preferenceWrites: [], wrongIdentities: [], malformedEntitlements: [], previewReads: 0, blockedExternal: [], unavailableReads: [] };
const browser = await chromium.launch();
try {
const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : [320, 390, 768, 1440].flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode])));
for (const [width, language, mode] of configurations) {
console.log(`Checking ${width}/${language}/${mode}`);
const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; };
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' });
const page = await context.newPage(); page.on('pageerror', error => report.errors.push(error.message));
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
let privacy = { enabled: true, externalProcessingAllowed: false, externalProcessingAvailable: false, effectiveExternalProcessing: false, provider: 'local' };
let writes = 0, wrongIdentityReturned = false, malformedReturned = false, refreshed = 0;
const variant = { id: 3, name: 'Teacher CV', version: 4, settings: { language: 'en', headline: 'Recorded teacher headline', themeId: 'modern', showPhoto: false, showPageNumbers: false, showIcons: true, sections: [], overrides: {}, customSections: [] }, isPublic: false, publicSlug: 'synthetic', jobApplicationId: null, updatedAtUtc: '2026-11-03T10:00:00Z' };
await page.route('**/*', route => {
const req = route.request(), url = new URL(req.url()), p = url.pathname;
if (url.origin !== 'http://localhost:4338' && !(url.origin === 'http://localhost:5202' && p.startsWith('/api/'))) { report.blockedExternal.push(url.origin); return route.abort(); }
if (!p.startsWith('/api/')) return route.continue();
const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
if (req.method() === 'PUT' && p === '/api/ai/settings') {
const body = req.postDataJSON(); assert.deepEqual(body, { enabled: writes !== 0 && writes !== 3, externalProcessingAllowed: false });
privacy = { ...privacy, ...body }; writes++; report.preferenceWrites.push({ width, language, mode, enabled: body.enabled }); return answer(privacy);
}
if (req.method() === 'POST' && p === '/api/cv/preview') { report.previewReads++; return answer({ html: '<!doctype html><html lang="en"><head><title>Controlled preview</title></head><body><main><h1>Alex Sample</h1><p>Synthetic teacher profile</p></main></body></html>' }); }
if (req.method() !== 'GET') { report.unexpectedWrites.push(p); return route.abort(); }
if (p === '/api/auth/me') {
const wrong = writes === 4 && !wrongIdentityReturned;
const malformed = writes === 2 && !malformedReturned;
if (wrong) { wrongIdentityReturned = true; report.wrongIdentities.push({ width, language, mode }); }
if (malformed) { malformedReturned = true; report.malformedEntitlements.push({ width, language, mode }); }
if (writes) refreshed++;
return answer({ id: wrong ? 'different-owner' : 'refresh-owner', sessionContext: 'refresh-session', provider: 'local', firstName: 'Alex', displayName: 'Alex Sample', roles: [], plan: 'pro', entitlements: { ai: malformed ? 'true' : privacy.enabled, proThemes: true }, uiLanguage: language === 'nb' ? 'nb-NO' : 'en' });
}
const fixtures = {
'/api/auth/config': { requireAuth: true }, '/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 }, '/api/notification-settings': { emailFollowUpRemindersEnabled: true }, '/api/ai/settings': privacy,
'/api/ai/usage': { currentMonth: { calls: 0, estimatedTokens: 0 }, plan: 'pro', monthlyCallLimit: 100, monthlyTokenLimit: 10000, storageUsedBytes: 0, storageLimitBytes: 250000000 }, '/api/billing/status': { enabled: false, canCheckout: false, canManage: false },
'/api/career/profile': { revision: 1, cvText: '', profile: { version: '1', metadata: { profileVersion: 1, fields: {} }, contact: { fullName: 'Alex Sample' }, summary: [], skills: [], languages: [], jobs: [], education: [], interests: [], otherSections: [], sections: [] }, completeness: { percent: 0, missing: [], sections: [] } },
'/api/career/profile/versions': [], '/api/career/evidence': [], '/api/profile-cv/runs': [],
'/api/cv/variants': [{ ...variant, themeId: 'modern', language: 'en' }], '/api/cv/variants/3': variant, '/api/cv/variants/3/versions': [], '/api/cv/outline': { fullName: 'Alex Sample', headline: 'Recorded teacher headline', sections: [] },
'/api/cv/themes': [{ id: 'modern', name: 'Modern', category: 'Professional', description: 'Synthetic template', layout: 'header-band', accent: '#3157d5', photoShape: 'none', supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ['#3157d5'] }],
};
if (Object.hasOwn(fixtures, p)) return answer(fixtures[p]);
report.unavailableReads.push(p); return route.fulfill({ status: 503, contentType: 'application/json', body: '{}' });
});
const scan = async state => {
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `overflow:${state}`);
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze(); report.screens.push({ width, language, mode, state, violations: result.violations }); assert.deepEqual(result.violations, [], state);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0);
await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: width >= 768 });
};
const section = async key => {
if (width >= 600) await page.getByRole('tab', { name: t(key), exact: true }).click();
else { await page.getByRole('combobox', { name: t('settingsNavigation'), exact: true }).click(); await page.getByRole('option', { name: t(key), exact: true }).click(); }
};
const studio = async () => {
if (width < 768) {
const primary = page.getByRole('navigation', { name: language === 'en' ? 'Primary navigation' : 'Hovednavigasjon', exact: true });
await primary.getByRole('button', { name: language === 'en' ? 'Career' : 'Karriere', exact: true }).click();
await primary.getByRole('button', { name: language === 'en' ? 'More' : 'Mer', exact: true }).click(); await page.getByRole('dialog').getByRole('button', { name: t('cvBuilder'), exact: true }).click();
} else {
const nav = page.getByRole('navigation', { name: language === 'en' ? 'Navigation' : 'Navigasjon', exact: true });
await nav.getByRole('button', { name: language === 'en' ? 'Career profile' : 'Karriereprofil', exact: true }).click(); await nav.getByRole('button', { name: t('cvBuilder'), exact: true }).click();
}
await page.getByRole('link', { name: t('cvDashboardOpenNamed').replace('{name}', variant.name), exact: true }).click();
await expect(page.getByRole('region', { name: t('cvEditorEditingRegion'), exact: true })).toBeVisible();
const tab = page.getByRole('tab', { name: t('cvEditorAiTools'), exact: true });
if (await tab.isVisible()) await tab.click();
else { await page.getByRole('combobox', { name: new RegExp(`^${t('cvEditorStudioNavigation')}`) }).click(); await page.getByRole('option', { name: t('cvEditorAiTools'), exact: true }).click(); }
assert.equal(await page.evaluate(() => window.__a3RefreshMarker), 'retained');
};
const openSettings = async () => {
if (width < 768) { await page.getByRole('button', { name: t('user'), exact: true }).click(); await page.getByRole('menuitem', { name: t('settingsMenu'), exact: true }).click(); }
else await page.getByRole('button', { name: t('settings'), exact: true }).first().click();
};
await page.goto('http://localhost:4338/settings'); await expect(page.getByRole('switch', { name: t('settingsAiEnabled'), exact: true })).toBeChecked();
await page.evaluate(() => { window.__a3RefreshMarker = 'retained'; window.__a3OriginalAiControl = document.querySelector('#settings-ai-controls input'); });
await section('settingsTabNotifications'); await page.getByRole('switch', { name: t('settingsNotificationsFollowUpReminders'), exact: true }).uncheck(); await section('settingsTabGeneral');
await page.getByRole('switch', { name: t('settingsAiEnabled'), exact: true }).uncheck(); await page.getByRole('button', { name: t('settingsAiSave'), exact: true }).click(); await expect.poll(() => refreshed).toBe(1);
await expect(page.getByRole('button', { name: t('settingsAiSave'), exact: true })).toBeEnabled(); assert.equal(await page.evaluate(() => window.__a3OriginalAiControl === document.querySelector('#settings-ai-controls input')), true);
await section('settingsTabNotifications'); await expect(page.getByRole('switch', { name: t('settingsNotificationsFollowUpReminders'), exact: true })).not.toBeChecked(); await section('settingsTabGeneral'); await scan('saved-without-unmount');
await page.getByRole('switch', { name: t('settingsAiEnabled'), exact: true }).check(); await page.getByRole('button', { name: t('settingsAiSave'), exact: true }).click(); await expect(page.getByText(t('settingsAiRefreshFailed'), { exact: true })).toBeVisible();
await studio(); await expect(page.getByRole('button', { name: t('cvDocumentAiReview'), exact: true }).first()).toBeDisabled(); await expect(page.getByText(t('aiUnavailableTitle'), { exact: true })).toBeVisible(); await scan('malformed-grant-rejected');
await openSettings(); await page.getByRole('button', { name: t('settingsAiSave'), exact: true }).click(); await expect.poll(() => writes).toBe(3); await expect(page.getByRole('button', { name: t('settingsAiSave'), exact: true })).toBeEnabled();
await studio(); await expect(page.getByRole('button', { name: t('cvDocumentAiReview'), exact: true }).first()).toBeEnabled(); await expect(page.getByText(t('aiUnavailableTitle'), { exact: true })).toHaveCount(0); await scan('same-owner-refreshed');
await openSettings(); await page.getByRole('switch', { name: t('settingsAiEnabled'), exact: true }).uncheck(); await page.getByRole('button', { name: t('settingsAiSave'), exact: true }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe('/login'); assert.equal(new URL(page.url()).searchParams.get('sessionChanged'), '1'); await expect(page.locator('#settings-ai-controls')).toHaveCount(0); await expect(page.getByRole('tab', { name: t('cvEditorAiTools'), exact: true })).toHaveCount(0); await scan('wrong-owner-session-reset');
assert.equal(writes, 4); assert.equal(wrongIdentityReturned, true); assert.equal(malformedReturned, true); report.journeys.push({ width, language, mode, formNodeAndDraftRetained: 'PASS', malformedGrantRejected: 'PASS', wrongIdentityResetsPrivateSession: 'PASS', validServerAvailabilityWithoutReload: 'PASS', noInference: 'PASS' }); await context.close();
}
assert.equal(report.screens.length, configurations.length * 4); assert.equal(report.journeys.length, configurations.length); assert.equal(report.preferenceWrites.length, configurations.length * 4); assert.equal(report.wrongIdentities.length, configurations.length); assert.equal(report.malformedEntitlements.length, configurations.length); assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
console.log(`PASS:${report.journeys.length} controlled journeys,${report.screens.length} axe scans/captures; no model requests.`);
} finally {
fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['Preferences, identity and preview responses are fixtures. No model/provider activation, real entitlement/security/production qualification.', 'Only the expected synthetic privacy PUTs are accepted; no generation/apply/document/canonical-profile writes.'] }, null, 2)); await browser.close();
}