Files
jobtrackingapp/job-tracker-ui/e2e/phase7-onboarding.spec.ts

284 lines
18 KiB
TypeScript

import AxeBuilder from '@axe-core/playwright';
import { expect, test as base, type APIRequestContext, type BrowserContext, type Page, type TestInfo } from '@playwright/test';
import { translations } from '../src/i18n/translations';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Owner = { email: string; id: string; userName: string; headers: Record<string, string> };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
async function login(api: APIRequestContext, email: string): Promise<Owner> {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const response = await api.get(`${apiUrl}/auth/me`);
expect(response.status()).toBe(200);
const me = await response.json();
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
expect(csrf).toBeDefined();
expect(me.sessionContext).toEqual(expect.any(String));
return { email, id: me.id, userName: me.userName, headers: {
'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext,
} };
}
async function createOwner(admin: APIRequestContext, adminHeaders: Record<string, string>, suffix: string) {
const email = `p7-setup-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
return email;
}
async function localOnly(context: BrowserContext) {
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
? route.continue() : route.abort();
});
}
const test = base.extend<{ owner: Owner }>({
owner: async ({ page, context, playwright }, provide) => {
const admin = await playwright.request.newContext();
try {
const adminOwner = await login(admin, 'e2e@example.test');
const email = await createOwner(admin, adminOwner.headers, 'a');
const owner = await login(page.request, email);
await localOnly(context);
await context.addInitScript(() => {
if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en');
if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light');
});
await provide(owner);
} finally { await admin.dispose(); }
},
});
async function enter(page: Page, owner: Owner, sample: Sample) {
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers,
data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } } )).status()).toBe(204);
await page.setViewportSize({ width: sample.width, height: sample.height });
await page.goto('/dashboard');
await expect(page.getByRole('main')).toBeVisible();
await page.evaluate(({ language, theme }) => {
localStorage.setItem('uiLanguage', language);
localStorage.setItem('jobtracker.themeMode', theme);
}, sample);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.reload();
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en');
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
}
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
const geometry = await page.evaluate(() => ({
rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
width: innerWidth, height: innerHeight,
}));
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
const name = `p7-setup-${sample.language}-${sample.theme}-${sample.width}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry,
ariaSnapshot: await page.getByRole('main').ariaSnapshot(),
axe: { scope: 'All enabled rules with explicit target-size; every violation/incomplete retained',
violations: axe.violations, incomplete: axe.incomplete,
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
limits: ['No human screen reader, physical touch or software keyboard', 'No real mailbox/provider or production qualification', 'Finite sample, no actual browser zoom'],
}), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
expect(geometry.rootOverflow).toBe(0);
}
async function assertDismissedAfterSettledNavigation(page: Page, navigate: () => Promise<void>) {
const statusPaths = ['/api/career/profile', '/api/gmail/status', '/api/microsoft-graph/status', '/api/imap/status'];
const reads: string[] = [];
const observe = (request: import('@playwright/test').Request) => {
const path = new URL(request.url()).pathname;
if (request.method() === 'GET' && statusPaths.includes(path)) reads.push(path);
};
page.on('request', observe);
try {
await navigate();
await expect(page.getByRole('heading', { name: translations.en.dashboardStartTitle, exact: true })).toBeVisible();
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
await expect(page.getByRole('region', { name: translations.en.onboardingTitle, exact: true })).toHaveCount(0);
expect(reads, 'a persisted dismissal skips checklist status reads after its private Dashboard has settled').toEqual([]);
} finally { page.off('request', observe); }
}
const matrix: Sample[] = [
{ language: 'nb', theme: 'dark', width: 320, height: 844 },
{ language: 'en', theme: 'light', width: 360, height: 800 },
{ language: 'nb', theme: 'light', width: 390, height: 844 },
{ language: 'en', theme: 'dark', width: 768, height: 1024 },
{ language: 'nb', theme: 'dark', width: 1024, height: 768 },
{ language: 'en', theme: 'light', width: 1280, height: 320 },
{ language: 'nb', theme: 'light', width: 1440, height: 900 },
{ language: 'en', theme: 'dark', width: 1920, height: 1080 },
];
for (const sample of matrix) {
test(`[P7-02 PRES-S02 C01 C02 E09] ${sample.language}/${sample.theme} ${sample.width}px setup counts saved manual career information without claiming CV import or email verification`, async ({ page, owner }, info) => {
test.setTimeout(120_000);
expect((await page.request.put(`${apiUrl}/auth/profile`, { headers: owner.headers, data: {
userName: owner.userName, firstName: 'Åse', lastName: 'Oppsett', displayName: 'Åse Oppsett',
} })).status()).toBe(204);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers,
data: { profile: { version: '1', contact: { fullName: 'Canonical career name' }, skills: ['Manually entered C#'] } } })).status()).toBe(200);
const company = await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Oppsett AS' } });
expect(company.status()).toBe(201);
expect((await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers,
data: { companyId: (await company.json()).id, jobTitle: 'Recorded first job', status: 'Saved' } })).status()).toBe(201);
const beforeCareer = await (await page.request.get(`${apiUrl}/career/profile`)).json();
expect(await (await page.request.get(`${apiUrl}/cv/variants`)).json()).toEqual([]);
await enter(page, owner, sample);
const t = translations[sample.language];
const region = page.getByRole('region', { name: t.onboardingTitle, exact: true });
await expect(region.getByRole('heading', { level: 2, name: t.onboardingTitle })).toBeVisible();
await expect(region.getByRole('progressbar', { name: t.checklistProgress.replace('{completed}', '3').replace('{total}', '4') })).toHaveAttribute('aria-valuenow', '75');
await expect(region.getByText(t.onboardingStepCv, { exact: true })).toBeVisible();
await expect(region.getByText(t.onboardingStepVerify, { exact: true })).toHaveCount(0);
await expect(region.getByText(t.onboardingStepSignup, { exact: true })).toHaveCount(0);
await expect(region.getByRole('button', { name: t.onboardingStepCvAction, exact: true })).toHaveCount(0);
await capture(page, info, sample, 'manual-data-3-of-4');
await page.reload();
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '75');
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(beforeCareer);
expect(await (await page.request.get(`${apiUrl}/cv/variants`)).json()).toEqual([]);
});
}
test('[P7-02 PRES-S02 E09] dismissal survives reload and remains isolated across actual A/B logout and login', async ({ page, owner, playwright }) => {
test.setTimeout(120_000);
const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 };
await enter(page, owner, sample);
const region = page.getByRole('region', { name: translations.en.onboardingTitle, exact: true });
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
await region.getByRole('button', { name: translations.en.onboardingDismiss }).focus();
await page.keyboard.press('Enter');
await expect(region).toHaveCount(0);
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: translations.en.dashboardCustomize, exact: true })).toBeFocused();
expect(await page.evaluate(id => localStorage.getItem(`onboardingDismissed:${id}`), owner.id)).toBe('true');
await assertDismissedAfterSettledNavigation(page, async () => { await page.reload(); });
const admin = await playwright.request.newContext();
let bEmail: string;
try {
const adminOwner = await login(admin, 'e2e@example.test');
bEmail = await createOwner(admin, adminOwner.headers, 'b');
} finally { await admin.dispose(); }
async function switchTo(email: string) {
await page.getByRole('button', { name: 'User', exact: true }).click();
await page.getByRole('menuitem', { name: 'Sign out', exact: true }).click();
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
await page.getByLabel('Username or email', { exact: true }).fill(email);
await page.getByLabel('Current password', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('main')).toBeVisible();
const response = await page.request.get(`${apiUrl}/auth/me`);
expect(response.status()).toBe(200);
const me = await response.json();
expect(me.email).toBe(email);
await expect.poll(() => page.evaluate(() => localStorage.getItem('authUserKey'))).toBe(me.id);
await expect(page.getByRole('heading', { name: translations.en.dashboardStartTitle, exact: true })).toBeVisible();
}
await switchTo(bEmail!);
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
await page.reload();
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
await assertDismissedAfterSettledNavigation(page, async () => { await switchTo(owner.email); });
expect(await page.evaluate(id => localStorage.getItem(`onboardingDismissed:${id}`), owner.id)).toBe('true');
});
test('[P7-02 PRES-S02 E08] a failed mailbox read cannot invent setup progress and retry only reads', async ({ page, owner }, info) => {
const sample: Sample = { language: 'nb', theme: 'dark', width: 320, height: 844 };
let failedReads = 0;
await page.route(`${apiUrl}/gmail/status`, route => { failedReads++; return route.fulfill({ status: 500, json: { message: 'Setup connection read unavailable' } }); });
await enter(page, owner, sample);
const t = translations.nb;
const region = page.getByRole('region', { name: t.onboardingTitle, exact: true });
await expect(region.getByRole('alert')).toBeVisible();
await expect(region.getByRole('progressbar')).toHaveCount(0);
await expect(region.getByRole('button', { name: t.onboardingStepEmailAction, exact: true })).toHaveCount(0);
expect(failedReads).toBeGreaterThan(0);
await capture(page, info, sample, 'failed-mailbox-read-unknown');
await page.unroute(`${apiUrl}/gmail/status`);
const writes: string[] = [];
page.on('request', request => { if (request.url().startsWith(apiUrl) && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) writes.push(request.method() + ' ' + new URL(request.url()).pathname); });
await region.getByRole('button', { name: t.retry, exact: true }).focus();
await page.keyboard.press('Enter');
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
await expect(region.getByRole('alert')).toHaveCount(0);
await expect(region.getByRole('button', { name: t.onboardingDismiss, exact: true })).toBeFocused();
expect(writes).toEqual([]);
await capture(page, info, sample, 'read-only-retry-known');
});
test('[P7-02 PRES-S02 C02 E07] keyboard setup actions retain their four destinations and browser Back context', async ({ page, owner }) => {
test.setTimeout(120_000);
await enter(page, owner, { language: 'en', theme: 'light', width: 390, height: 844 });
const region = page.getByRole('region', { name: translations.en.onboardingTitle, exact: true });
for (const [name, destination] of [
[translations.en.onboardingStepProfileAction, '/profile'],
[translations.en.onboardingStepCvAction, '/career'],
[translations.en.onboardingStepEmailAction, '/settings/connected-accounts'],
[translations.en.onboardingStepJobAction, '/jobs'],
]) {
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
await region.getByRole('button', { name, exact: true }).focus();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(new RegExp(destination.replaceAll('/', '\\/') + '$'));
await expect(page.getByRole('main')).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/\/dashboard$/);
}
});
test('[P7-02 PRES-S01 S02 E08] failed Dashboard summary keeps first-job guidance unknown until its existing read owner retries', async ({ page, owner }, info) => {
const sample: Sample = { language: 'en', theme: 'dark', width: 390, height: 844 };
await page.route(`${apiUrl}/jobapplications/stats`, route => route.fulfill({ status: 500, json: { message: 'Summary read unavailable' } }));
await enter(page, owner, sample);
const t = translations.en;
const region = page.getByRole('region', { name: t.onboardingTitle, exact: true });
await expect(region.getByText(t.onboardingJobStatusUnknown, { exact: true })).toBeVisible();
await expect(region.locator('[role="progressbar"][aria-valuenow]')).toHaveCount(0);
await expect(region.getByRole('button', { name: t.onboardingStepJobAction, exact: true })).toHaveCount(0);
await capture(page, info, sample, 'failed-summary-unknown');
await page.unroute(`${apiUrl}/jobapplications/stats`);
const summaryFailure = page.getByRole('main').getByRole('alert').filter({ hasText: t.dashboardSummaryUnavailableTitle });
await summaryFailure.getByRole('button', { name: t.retry, exact: true }).focus();
await page.keyboard.press('Enter');
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
await expect(region.getByText(t.onboardingJobStatusUnknown, { exact: true })).toHaveCount(0);
await capture(page, info, sample, 'summary-read-recovered');
});
test('[P7-02 PRES-S02 E08] recovery to four complete setup steps removes guidance while retaining the next keyboard action', async ({ page, owner }, info) => {
expect((await page.request.put(`${apiUrl}/auth/profile`, { headers: owner.headers, data: {
userName: owner.userName, firstName: 'Åse', lastName: 'Oppsett', displayName: 'Åse Oppsett',
} })).status()).toBe(204);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers,
data: { profile: { version: '1', skills: ['Manually entered SQL'] } } })).status()).toBe(200);
const company = await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Complete setup AS' } });
expect(company.status()).toBe(201);
expect((await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers,
data: { companyId: (await company.json()).id, jobTitle: 'Recorded job', status: 'Saved' } })).status()).toBe(201);
// Only the stored-connection response is controlled; no mailbox grant, connection or sync is attempted.
let failed = true;
await page.route(`${apiUrl}/gmail/status`, route => failed
? route.fulfill({ status: 500, json: { message: 'Connection status unavailable' } })
: route.fulfill({ json: { connected: true } }));
const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 };
await enter(page, owner, sample);
const region = page.getByRole('region', { name: translations.en.onboardingTitle, exact: true });
await expect(region.getByRole('alert')).toBeVisible();
failed = false;
const writes: string[] = [];
page.on('request', request => { if (request.url().startsWith(apiUrl) && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) writes.push(request.method()); });
await region.getByRole('button', { name: translations.en.retry, exact: true }).focus();
await page.keyboard.press('Enter');
await expect(region).toHaveCount(0);
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: translations.en.dashboardCustomize, exact: true })).toBeFocused();
expect(writes).toEqual([]);
await capture(page, info, sample, 'four-complete-recovery-next-keyboard-action');
});