fix(ui): preserve application drafts and recovery states
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type Response, type TestInfo } from '@playwright/test';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
import { emptyStructuredCv } from '../src/profileCv';
|
||||
import type { CvVariant, CvVariantSummary } from '../src/cvBuilder';
|
||||
import type { ApplicationCv, CoverLetter, SubmittedPackage, SubmittedPackageDetail, WorkspaceOverview } from '../src/applicationWorkspace';
|
||||
import { removeApplicationAnswerDraft } from '../src/applicationDrafts';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
type Owner = { id: string; headers: Record<string, string> };
|
||||
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
|
||||
type Subsection = 'cv' | 'letter' | 'answers' | 'submission';
|
||||
type Job = { id: number; jobTitle: string; notes: string | null };
|
||||
type RequestRecord = { method: string; path: string; body: unknown };
|
||||
type Session = { owner: Owner; admin: APIRequestContext; administrator: Owner; requests: RequestRecord[] };
|
||||
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
|
||||
const boundary = 'Actual owner HTTP fixtures use disposable isolated SQLite and existing application/CV/attachment APIs. Free accounts disable AI; the serial runtime disables model, delivery and alert workers. No provider/model/generation/send endpoint, raw SQL, schema or production write. Controlled precommit HTTP failures and held requests qualify scoped presentation/recovery, not network-uncertain idempotency or production reliability. Request metadata includes only fixture application/CV/file paths and bodies; no auth/CSRF/session headers. Existing seven workspace sections, CV renderer, immutable packages, one-job CV association and append-only cover history are preserved. Existing P2-D01 remains a separately executed ordinary preservation case; this file supplements that corpus. Duplicate-for-job remains two writes; created-copy/failed-attach is explicitly qualified, with no automatic second duplicate. Current AttachmentsTable and its separate ID0-download debt are outside this slice.';
|
||||
function jobUrl(id: number) { return `${apiUrl}/jobapplications/${id}`; }
|
||||
function interpolate(template: string, params: Record<string, string | number>) { return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`)); }
|
||||
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
|
||||
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
|
||||
function response(page: Page, method: string, url: string) { return page.waitForResponse(value => value.url() === url && value.request().method() === method); }
|
||||
async function keyboard(page: Page, control: Locator) { await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
|
||||
function count(session: Session, method: string, path: string) { return session.requests.filter(value => value.method === method && value.path === path); }
|
||||
function mutations(session: Session) { return session.requests.filter(value => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(value.method)); }
|
||||
function assetReads(session: Session, id: number) {
|
||||
const start = `/api/jobapplications/${id}/`;
|
||||
return session.requests.filter(value => value.method === 'GET' && value.path.startsWith(start) &&
|
||||
['cv', 'tailoring', 'cover-letter', 'submitted-packages'].includes(value.path.slice(start.length).split('/')[0]));
|
||||
}
|
||||
function section(page: Page, title: string) { return page.getByRole('region', { name: title, exact: true }); }
|
||||
function childFocus(page: Page, title: string) { return page.getByRole('group', { name: title, exact: true }); }
|
||||
function letter(page: Page, language: Sample['language'] = 'en') { return page.getByRole('textbox', { name: translations[language].jobDetailsCoverLetter, exact: true }); }
|
||||
function attachedCv(page: Page, language: Sample['language'] = 'en') {
|
||||
const label = translations[language].assetsAttachedCv.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
// MUI names its Select from the field label and the current selected value.
|
||||
return page.getByRole('combobox', { name: new RegExp(`^${label}(?:\\s|$)`) });
|
||||
}
|
||||
function drafts(page: Page, language: Sample['language'] = 'en') { return {
|
||||
answer: page.getByRole('textbox', { name: translations[language].assetsApplicationAnswer, exact: true }),
|
||||
message: page.getByRole('textbox', { name: translations[language].assetsRecruiterMessage, exact: true }),
|
||||
}; }
|
||||
async function login(api: APIRequestContext, email: string): Promise<Owner> {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
|
||||
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(csrf).toBeDefined();
|
||||
return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
|
||||
}
|
||||
async function account(admin: APIRequestContext, owner: Owner) {
|
||||
const email = `p706-forms-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
return email;
|
||||
}
|
||||
const test = base.extend<{ session: Session }>({
|
||||
session: async ({ page, context, playwright }, provide, info) => {
|
||||
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true');
|
||||
expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
|
||||
const admin = await playwright.request.newContext(); const requests: RequestRecord[] = []; const blocked: string[] = [];
|
||||
page.on('request', request => {
|
||||
const url = new URL(request.url());
|
||||
if (!/^\/api\/(?:jobapplications(?:\/|$)|cv\/variants(?:\/|$)|attachments(?:\/|$))/.test(url.pathname)) return;
|
||||
requests.push({ method: request.method(), path: url.pathname, body: request.postData() ? request.postDataJSON() : null });
|
||||
});
|
||||
try {
|
||||
const administrator = await login(admin, 'e2e@example.test');
|
||||
const owner = await login(page.request, await account(admin, administrator));
|
||||
expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false);
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (['localhost', '127.0.0.1'].includes(url.hostname) || ['blob:', 'data:', 'about:'].includes(url.protocol)) return route.continue();
|
||||
blocked.push(url.origin + url.pathname); return route.abort();
|
||||
});
|
||||
await provide({ owner, admin, administrator, requests });
|
||||
} finally {
|
||||
await info.attach('application-forms-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalRequests: blocked }), contentType: 'application/json' });
|
||||
await admin.dispose();
|
||||
}
|
||||
},
|
||||
});
|
||||
function profile(headline = 'Original reviewed career evidence') {
|
||||
const empty = emptyStructuredCv();
|
||||
return { ...empty, contact: { ...empty.contact, fullName: 'Åse Recorded Application Identity', headline },
|
||||
summary: ['Canonical owner-reviewed evidence — æøå'], skills: ['C#', 'SQL'] };
|
||||
}
|
||||
async function source(api: APIRequestContext, owner: Owner, headline?: string) {
|
||||
return json(await api.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: profile(headline), cvText: 'Exact owner-reviewed source — æøå' } }));
|
||||
}
|
||||
async function createJob(api: APIRequestContext, owner: Owner, title = 'Recorded application material — æøå') {
|
||||
const company = await json<{ id: number }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded application company — æøå', source: 'User' } }), 201);
|
||||
return json<Job>(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: {
|
||||
companyId: company.id, jobTitle: title, status: 'Saved', notes: 'Human notes remain separate — æøå', description: 'Recorded C# and SQL role; fixture facts only.',
|
||||
} }), 201);
|
||||
}
|
||||
async function variant(api: APIRequestContext, owner: Owner, name: string) {
|
||||
return json<CvVariant>(await api.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: {
|
||||
name, settings: { themeId: 'minimal', language: 'nb-NO', showPhoto: false, showPageNumbers: false, showIcons: true, sections: [], overrides: {}, customSections: [] },
|
||||
} }));
|
||||
}
|
||||
async function saveLetter(api: APIRequestContext, owner: Owner, id: number, text: string) {
|
||||
return json<CoverLetter>(await api.put(`${jobUrl(id)}/cover-letter`, { headers: owner.headers, data: { text, source: 'manual' } }));
|
||||
}
|
||||
async function saveDrafts(api: APIRequestContext, owner: Owner, id: number, answer: string, message: string) {
|
||||
expect((await api.put(`${jobUrl(id)}/application-drafts`, { headers: owner.headers, data: { applicationAnswerDraft: answer, recruiterMessageDraft: message } })).status()).toBe(204);
|
||||
}
|
||||
async function upload(api: APIRequestContext, owner: Owner, id: number) {
|
||||
const bytes = Buffer.from('Exact archived application evidence — æøå\nOriginal bytes retained.\n', 'utf8');
|
||||
const name = 'Recorded application evidence — æøå.txt';
|
||||
expect((await api.post(`${apiUrl}/attachments`, { headers: owner.headers, multipart: { jobId: String(id), files: { name, mimeType: 'text/plain', buffer: bytes } } })).ok()).toBe(true);
|
||||
const [attachment] = await json<{ id: number; fileName: string }[]>(await api.get(`${apiUrl}/attachments/${id}`));
|
||||
expect(attachment.id).toBeGreaterThan(0); return { attachment, bytes, name };
|
||||
}
|
||||
async function fixture(page: Page, owner: Owner, long = false) {
|
||||
await source(page.request, owner);
|
||||
const job = await createJob(page.request, owner, long ? 'Langsiktig søknad om samfunnskritisk plattformutvikling med mange samarbeidspartnere og særnorske bokstaver Æ Ø Å' : undefined);
|
||||
const cv = await variant(page.request, owner, long ? 'CV med lang dokumenttittel for samfunnskritisk plattformutvikling og inkluderende arbeidsprosesser Æ Ø Å' : 'Recorded attached application CV');
|
||||
await json<ApplicationCv>(await page.request.put(`${jobUrl(job.id)}/cv`, { headers: owner.headers, data: { variantId: cv.id } }));
|
||||
const originalLetter = long ? ('Reviewed long cover letter — æøå. Existing storage remains plain markdown.\n\n').repeat(55).trim() : 'Original reviewed cover letter — æøå';
|
||||
const answer = long ? ('Recorded motivation and application answer — æøå.\n').repeat(24).trim() : 'Original application answer — æøå';
|
||||
const message = long ? ('Reviewed recruiter message draft; never sent — æøå.\n').repeat(14).trim() : 'Original recruiter message; never sent';
|
||||
await saveLetter(page.request, owner, job.id, originalLetter);
|
||||
await saveDrafts(page.request, owner, job.id, answer, message);
|
||||
return { job, cv, originalLetter, answer, message };
|
||||
}
|
||||
async function frozenFixture(page: Page, owner: Owner, long = false) {
|
||||
const seeded = await fixture(page, owner, long); const file = await upload(page.request, owner, seeded.job.id);
|
||||
const preserved = await json<SubmittedPackage>(await page.request.post(`${jobUrl(seeded.job.id)}/submitted-packages`, { headers: owner.headers }));
|
||||
const detail = await json<SubmittedPackageDetail>(await page.request.get(`${jobUrl(seeded.job.id)}/submitted-packages/${preserved.id}`));
|
||||
return { ...seeded, ...file, preserved, detail };
|
||||
}
|
||||
async function enter(page: Page, owner: Owner, job: Job, sub: Subsection, sample: Sample = desktop) {
|
||||
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.addInitScript(({ language, theme }) => {
|
||||
if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language);
|
||||
if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme);
|
||||
}, sample);
|
||||
await page.setViewportSize({ width: sample.width, height: sample.height }); await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/jobs/${job.id}?section=application&sub=${sub}&source=phase7-forms&followMode=waiting-update#materials`);
|
||||
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') }));
|
||||
if (stored.language !== sample.language || stored.theme !== sample.theme) {
|
||||
await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.reload();
|
||||
}
|
||||
const t = translations[sample.language];
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
|
||||
await expect(page.getByRole('heading', { level: 2, name: job.jobTitle, exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('tablist', { name: t.workspaceSections, exact: true }).getByRole('tab')).toHaveCount(7);
|
||||
await expect(page.getByRole('tablist', { name: t.workspaceApplicationSections, exact: true }).getByRole('tab')).toHaveCount(4);
|
||||
expect(new URL(page.url()).searchParams.get('followMode')).toBe('waiting-update');
|
||||
}
|
||||
async function selectCv(page: Page, value: CvVariant | null, language: Sample['language'] = 'en') {
|
||||
await keyboard(page, attachedCv(page, language));
|
||||
const option = value ? page.getByRole('option').filter({ hasText: value.name }) : page.getByRole('option', { name: translations[language].assetsNone, exact: true });
|
||||
await keyboard(page, option);
|
||||
}
|
||||
|
||||
test('[P7-06 PRES-W01 W04 W10] same-route job navigation waits for owned answers and preserves both jobs on save', async ({ page, session }, info) => {
|
||||
const first = await fixture(page, session.owner);
|
||||
const original = await json<{ companyId: number }>(await page.request.get(jobUrl(first.job.id)));
|
||||
const second = await json<Job>(await page.request.post(`${apiUrl}/jobapplications`, { headers: session.owner.headers, data: {
|
||||
companyId: original.companyId, jobTitle: 'Second independent application draft owner', status: 'Saved', notes: 'Independent second-job notes',
|
||||
} }), 201);
|
||||
const secondAnswer = 'Owned second-job answer — æøå';
|
||||
const secondMessage = 'Owned second-job recruiter message; never sent';
|
||||
await saveDrafts(page.request, session.owner, second.id, secondAnswer, secondMessage);
|
||||
await enter(page, session.owner, first.job, 'answers');
|
||||
await expect(drafts(page).answer).toHaveValue(first.answer);
|
||||
const origin = await page.evaluate(() => performance.timeOrigin);
|
||||
const held = hold(); const reached = hold();
|
||||
const workspaceUrl = `${jobUrl(second.id)}/workspace`;
|
||||
await page.route(workspaceUrl, async route => { reached.release(); await held.pending; await route.continue(); });
|
||||
try {
|
||||
await keyboard(page, page.getByRole('button', { name: new RegExp(translations.en.quickSearch) }));
|
||||
const commands = page.getByRole('dialog', { name: translations.en.quickSearch });
|
||||
await commands.getByRole('textbox', { name: translations.en.quickSearch }).fill(second.jobTitle);
|
||||
await keyboard(page, commands.getByRole('button').filter({ hasText: second.jobTitle }));
|
||||
await reached.pending;
|
||||
await expect(page).toHaveURL(new RegExp(`/jobs/${second.id}$`));
|
||||
expect(await page.evaluate(() => performance.timeOrigin)).toBe(origin);
|
||||
await keyboard(page, page.getByRole('tab', { name: translations.en.workspaceApplication, exact: true }));
|
||||
await keyboard(page, page.getByRole('tab', { name: translations.en.workspaceAnswers, exact: true }));
|
||||
await expect(drafts(page).answer).toHaveCount(0);
|
||||
await expect(drafts(page).message).toHaveCount(0);
|
||||
expect(count(session, 'PUT', `/api/jobapplications/${second.id}/application-drafts`)).toHaveLength(0);
|
||||
await capture(page, info, desktop, 'held-second-job-aggregate-no-foreign-drafts');
|
||||
const loaded = response(page, 'GET', workspaceUrl); held.release(); await loaded;
|
||||
await expect(drafts(page).answer).toHaveValue(secondAnswer);
|
||||
await expect(drafts(page).message).toHaveValue(secondMessage);
|
||||
const reviewed = 'Edited second-job answer only — æøå';
|
||||
await drafts(page).answer.fill(reviewed);
|
||||
const saved = response(page, 'PUT', `${jobUrl(second.id)}/application-drafts`);
|
||||
await keyboard(page, page.getByRole('button', { name: translations.en.assetsSaveDrafts, exact: true }));
|
||||
expect((await saved).status()).toBe(204);
|
||||
await expect(drafts(page).message).toHaveValue(secondMessage);
|
||||
const persistedSecond = await json<WorkspaceOverview>(await page.request.get(workspaceUrl));
|
||||
expect(persistedSecond.applicationAnswerDraft).toBe(reviewed);
|
||||
expect(persistedSecond.recruiterMessageDraft).toBe(secondMessage);
|
||||
const persistedFirst = await json<WorkspaceOverview>(await page.request.get(`${jobUrl(first.job.id)}/workspace`));
|
||||
expect(persistedFirst.applicationAnswerDraft).toBe(first.answer);
|
||||
expect(persistedFirst.recruiterMessageDraft).toBe(first.message);
|
||||
expect(count(session, 'PUT', `/api/jobapplications/${second.id}/application-drafts`)).toEqual([{ method: 'PUT', path: `/api/jobapplications/${second.id}/application-drafts`, body: { applicationAnswerDraft: reviewed, recruiterMessageDraft: secondMessage } }]);
|
||||
await capture(page, info, desktop, 'second-job-answer-saved-first-job-preserved');
|
||||
} finally { held.release(); await page.unroute(workspaceUrl); }
|
||||
});
|
||||
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(() => ({ width: innerWidth, height: innerHeight, rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
|
||||
focused: { tag: document.activeElement?.tagName, role: document.activeElement?.getAttribute('role'), label: document.activeElement?.getAttribute('aria-label') } }));
|
||||
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
|
||||
const name = `p706-forms-${sample.language}-${sample.theme}-${sample.width}-${state}`;
|
||||
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, boundary, ariaSnapshot: await page.locator('body').ariaSnapshot(),
|
||||
axe: { scope: 'All default-enabled rules plus target-size, no exclusions', violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
|
||||
qualifications: ['Finite Chromium states, not all combinations or physical devices.', 'All incomplete vectors retained for contextual triage; not human screen-reader, touch/software keyboard or real browser zoom acceptance.',
|
||||
'Full-page screenshots do not establish real viewport focus occlusion or every-pixel contrast.', 'Print iframe/canvas may scroll internally; outer app root reflow measured separately. Frozen HTML unchanged, not a substitute for existing full fidelity corpus.'],
|
||||
}), contentType: 'application/json' });
|
||||
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
||||
expect(geometry.rootOverflow).toBe(0); expect(axe.violations).toEqual([]);
|
||||
}
|
||||
|
||||
test('[P7-06 forms] held CV read Retry preserves its request and actual attach/replace/detach remain owner scoped', async ({ page, session, playwright }, info) => {
|
||||
test.setTimeout(120_000); await source(page.request, session.owner);
|
||||
const job = await createJob(page.request, session.owner); const first = await variant(page.request, session.owner, 'First reusable CV'); const second = await variant(page.request, session.owner, 'Second reusable CV');
|
||||
const gate = hold(); let reads = 0;
|
||||
await page.route(`${jobUrl(job.id)}/cv`, async route => {
|
||||
if (route.request().method() !== 'GET') return route.continue();
|
||||
reads += 1; if (reads === 1) { await gate.pending; await route.fulfill({ status: 503, json: {} }); } else return route.continue();
|
||||
});
|
||||
try {
|
||||
await enter(page, session.owner, job, 'cv'); await expect(section(page, translations.en.workspaceCv).getByRole('status')).toContainText(translations.en.loading);
|
||||
await capture(page, info, desktop, 'held-cv-read'); gate.release();
|
||||
await expect(section(page, translations.en.workspaceCv).getByRole('alert')).toContainText(translations.en.assetsLoadFailed);
|
||||
await keyboard(page, section(page, translations.en.workspaceCv).getByRole('button', { name: translations.en.retry, exact: true }));
|
||||
await expect(childFocus(page, translations.en.workspaceCv)).toBeFocused(); await expect.poll(() => reads).toBe(2);
|
||||
await expect(attachedCv(page)).toBeVisible();
|
||||
for (const current of [first, second, null]) {
|
||||
const written = response(page, 'PUT', `${jobUrl(job.id)}/cv`); await selectCv(page, current); expect((await written).status()).toBe(200);
|
||||
await expect(attachedCv(page)).toBeEnabled();
|
||||
expect((await json<ApplicationCv>(await page.request.get(`${jobUrl(job.id)}/cv`))).attachedVariantId).toBe(current?.id ?? null);
|
||||
}
|
||||
expect(count(session, 'PUT', `/api/jobapplications/${job.id}/cv`).map(value => value.body)).toEqual([{ variantId: first.id }, { variantId: second.id }, { variantId: null }]);
|
||||
const available = await json<CvVariantSummary[]>(await page.request.get(`${apiUrl}/cv/variants`));
|
||||
expect(available.map(value => value.id).sort((a, b) => a - b)).toEqual([first.id, second.id].sort((a, b) => a - b));
|
||||
expect(available.every(value => value.jobApplicationId === null)).toBe(true);
|
||||
const foreign = await playwright.request.newContext();
|
||||
try {
|
||||
const other = await login(foreign, await account(session.admin, session.administrator));
|
||||
const foreignVariant = await variant(foreign, other, 'Foreign private variant');
|
||||
expect((await page.request.put(`${jobUrl(job.id)}/cv`, { headers: session.owner.headers, data: { variantId: foreignVariant.id } })).status()).toBe(404);
|
||||
expect((await page.request.put(`${jobUrl(job.id)}/cv`, { headers: session.owner.headers, data: { variantId: 2147483647 } })).status()).toBe(404);
|
||||
for (const suffix of ['cv', 'cover-letter', 'submitted-packages']) expect((await foreign.get(`${jobUrl(job.id)}/${suffix}`)).status()).toBe(404);
|
||||
expect((await foreign.put(`${jobUrl(job.id)}/cv`, { headers: other.headers, data: { variantId: null } })).status()).toBe(404);
|
||||
expect((await json<ApplicationCv>(await page.request.get(`${jobUrl(job.id)}/cv`))).availableVariants.some(value => value.id === foreignVariant.id)).toBe(false);
|
||||
} finally { await foreign.dispose(); }
|
||||
await capture(page, info, desktop, 'cv-detached-preserved');
|
||||
} finally { gate.release(); }
|
||||
});
|
||||
|
||||
test('[P7-06 forms] long letter failed held Save retains snapshot and dirty restore Cancel writes nothing before additive explicit restore', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000); const seeded = await fixture(page, session.owner); const url = `${jobUrl(seeded.job.id)}/cover-letter`;
|
||||
await saveLetter(page.request, session.owner, seeded.job.id, 'Second reviewed letter — æøå');
|
||||
const before = await json<CoverLetter>(await page.request.get(url));
|
||||
await enter(page, session.owner, seeded.job, 'letter');
|
||||
const draft = ' ' + ('Long manually reviewed application letter — æøå. Preserve every sentence and markdown **evidence**.\n\n').repeat(60) + ' ';
|
||||
await letter(page).fill(draft); const gate = hold(); let saves = 0;
|
||||
await page.route(url, async route => {
|
||||
if (route.request().method() !== 'PUT') return route.continue();
|
||||
saves += 1; if (saves === 1) { await gate.pending; await route.fulfill({ status: 503, json: {} }); } else return route.continue();
|
||||
});
|
||||
try {
|
||||
const body = section(page, translations.en.workspaceCoverLetter);
|
||||
await keyboard(page, body.getByRole('button', { name: translations.en.save, exact: true })); await expect.poll(() => saves).toBe(1);
|
||||
await expect(letter(page)).toBeDisabled(); await expect(childFocus(page, translations.en.workspaceCoverLetter)).toBeFocused();
|
||||
await expect(body.getByRole('button', { name: translations.en.assetsDiscardChanges, exact: true })).toBeDisabled();
|
||||
expect(count(session, 'PUT', `/api/jobapplications/${seeded.job.id}/cover-letter`).map(value => value.body)).toEqual([{ text: draft, source: 'manual' }]);
|
||||
await capture(page, info, desktop, 'held-letter-save'); gate.release();
|
||||
await expect(body.getByRole('alert')).toContainText(translations.en.assetsCoverSaveFailed); await expect(letter(page)).toHaveValue(draft);
|
||||
expect(await json<CoverLetter>(await page.request.get(url))).toEqual(before); expect(saves).toBe(1);
|
||||
const saved = response(page, 'PUT', url); await keyboard(page, body.getByRole('button', { name: translations.en.save, exact: true }));
|
||||
const current = await json<CoverLetter>(await saved); expect(current.text).toBe(draft.trim()); expect(current.currentVersion).toBe(3);
|
||||
expect(current.versions.map(value => [value.version, value.source, value.aiAction])).toEqual([[3, 'manual', null], [2, 'manual', null], [1, 'manual', null]]);
|
||||
await expect(letter(page)).toHaveValue(draft.trim()); await letter(page).fill('Unsaved changes must survive Cancel — æøå');
|
||||
const restore = page.getByRole('button', { name: interpolate(translations.en.assetsRestoreVersionNumber, { version: 1 }), exact: true });
|
||||
await keyboard(page, restore);
|
||||
const dialog = page.getByRole('dialog', { name: translations.en.assetsRestoreDiscardTitle, exact: true });
|
||||
await expect(dialog).toContainText(translations.en.assetsRestoreDiscardMessage);
|
||||
await keyboard(page, dialog.getByRole('button', { name: translations.en.cancel, exact: true }));
|
||||
await expect(dialog).toHaveCount(0); await expect(restore).toBeFocused(); await expect(letter(page)).toHaveValue('Unsaved changes must survive Cancel — æøå');
|
||||
expect(count(session, 'POST', `/api/jobapplications/${seeded.job.id}/cover-letter/versions/1/restore`)).toHaveLength(0);
|
||||
expect(await json<CoverLetter>(await page.request.get(url))).toEqual(current);
|
||||
await keyboard(page, restore); const restored = response(page, 'POST', `${url}/versions/1/restore`);
|
||||
await keyboard(page, dialog.getByRole('button', { name: translations.en.assetsRestoreConfirm, exact: true }));
|
||||
const readback = await json<CoverLetter>(await restored); expect(readback.text).toBe(seeded.originalLetter); expect(readback.currentVersion).toBe(4);
|
||||
expect(readback.versions.map(value => [value.version, value.source, value.aiAction])).toEqual([[4, 'restore', null], [3, 'manual', null], [2, 'manual', null], [1, 'manual', null]]);
|
||||
await expect(letter(page)).toHaveValue(seeded.originalLetter); await capture(page, info, desktop, 'restored-additive-letter-history');
|
||||
} finally { gate.release(); }
|
||||
});
|
||||
|
||||
test('[P7-06 forms] failed held Answers save preserves both drafts and ordinary Notes through explicit recovery and reload', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000); const seeded = await fixture(page, session.owner); const url = `${jobUrl(seeded.job.id)}/application-drafts`;
|
||||
const original = await json<Job>(await page.request.get(jobUrl(seeded.job.id)));
|
||||
await enter(page, session.owner, seeded.job, 'answers'); const fields = drafts(page); const answer = 'Reviewed answer with **specific evidence** — æøå\nSecond line.'; const message = 'Reviewed recruiter note — æøå; draft only, never sent.';
|
||||
await fields.answer.fill(answer); await fields.message.fill(message);
|
||||
const gate = hold(); let writes = 0;
|
||||
await page.route(url, async route => { writes += 1; if (writes === 1) { await gate.pending; await route.fulfill({ status: 503, json: {} }); } else return route.continue(); });
|
||||
try {
|
||||
const body = section(page, translations.en.assetsApplicationDrafts);
|
||||
await keyboard(page, body.getByRole('button', { name: translations.en.assetsSaveDrafts, exact: true }));
|
||||
await expect.poll(() => writes).toBe(1); await expect(fields.answer).toBeDisabled(); await expect(fields.message).toBeDisabled(); await expect(childFocus(page, translations.en.assetsApplicationDrafts)).toBeFocused();
|
||||
expect(count(session, 'PUT', `/api/jobapplications/${seeded.job.id}/application-drafts`).map(value => value.body)).toEqual([{ applicationAnswerDraft: answer, recruiterMessageDraft: message }]);
|
||||
await capture(page, info, desktop, 'held-two-draft-save'); gate.release();
|
||||
await expect(body.getByRole('alert')).toContainText(translations.en.assetsDraftsSaveFailed); await expect(fields.answer).toHaveValue(answer); await expect(fields.message).toHaveValue(message);
|
||||
expect((await json<WorkspaceOverview>(await page.request.get(`${jobUrl(seeded.job.id)}/workspace`))).applicationAnswerDraft).toBe(seeded.answer); expect(writes).toBe(1);
|
||||
const saved = response(page, 'PUT', url); await keyboard(page, body.getByRole('button', { name: translations.en.assetsSaveDrafts, exact: true })); expect((await saved).status()).toBe(204);
|
||||
await expect(body.getByRole('button', { name: translations.en.assetsSaveDrafts, exact: true })).toBeDisabled(); await page.reload();
|
||||
await expect(drafts(page).answer).toHaveValue(answer); await expect(drafts(page).message).toHaveValue(message);
|
||||
const readback = await json<WorkspaceOverview>(await page.request.get(`${jobUrl(seeded.job.id)}/workspace`));
|
||||
expect(readback.applicationAnswerDraft).toBe(answer); expect(readback.recruiterMessageDraft).toBe(message);
|
||||
const stored = await json<Job>(await page.request.get(jobUrl(seeded.job.id))); expect(removeApplicationAnswerDraft(stored.notes)).toBe(removeApplicationAnswerDraft(original.notes));
|
||||
await keyboard(page, page.getByRole('tablist', { name: translations.en.workspaceSections, exact: true }).getByRole('tab', { name: translations.en.workspaceTasks, exact: true }));
|
||||
await expect(section(page, translations.en.workspaceNotes)).toContainText('Human notes remain separate — æøå'); await expect(section(page, translations.en.workspaceNotes)).not.toContainText(answer);
|
||||
expect(count(session, 'PUT', `/api/jobapplications/${seeded.job.id}/application-drafts`)).toHaveLength(2);
|
||||
await capture(page, info, desktop, 'drafts-saved-personal-notes-preserved');
|
||||
} finally { gate.release(); }
|
||||
});
|
||||
|
||||
test('[P7-06 forms] actual native capture freezes CV and exact archive bytes after current sources change without renderer mutation', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000); const seeded = await fixture(page, session.owner); const file = await upload(page.request, session.owner, seeded.job.id);
|
||||
await enter(page, session.owner, seeded.job, 'submission'); const captured = response(page, 'POST', `${jobUrl(seeded.job.id)}/submitted-packages`);
|
||||
await keyboard(page, section(page, translations.en.assetsSubmittedPackages).getByRole('button', { name: translations.en.assetsPackageCapture, exact: true }));
|
||||
const summary = await json<SubmittedPackage>(await captured); expect(summary.version).toBe(1); expect(summary.attachments).toHaveLength(1);
|
||||
const preservedUrl = `${jobUrl(seeded.job.id)}/submitted-packages/${summary.id}`; const before = await json<SubmittedPackageDetail>(await page.request.get(preservedUrl));
|
||||
expect(before.renderedCvHtml).toContain('Original reviewed career evidence'); expect(before.coverLetterText).toBe(seeded.originalLetter);
|
||||
expect(summary.cvVariantId).toBe(seeded.cv.id); expect(summary.cvVariantVersion).toBe(seeded.cv.version);
|
||||
expect(summary.attachments[0].sha256).toBe(createHash('sha256').update(file.bytes).digest('hex'));
|
||||
await saveLetter(page.request, session.owner, seeded.job.id, 'Later current letter — æøå');
|
||||
await saveDrafts(page.request, session.owner, seeded.job.id, 'Later answer', 'Later recruiter draft');
|
||||
const cv = await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${seeded.cv.id}`));
|
||||
const changedCv = await json<CvVariant>(await page.request.put(`${apiUrl}/cv/variants/${cv.id}`, { headers: session.owner.headers, data: { name: 'Later current CV', settings: { ...cv.settings, headline: 'Later CV-only headline' }, expectedVersion: cv.version, source: 'manual' } }));
|
||||
await source(page.request, session.owner, 'Later canonical headline');
|
||||
expect((await page.request.delete(`${apiUrl}/attachments/${file.attachment.id}`, { headers: session.owner.headers })).status()).toBe(204);
|
||||
expect(await json<SubmittedPackageDetail>(await page.request.get(preservedUrl))).toEqual(before);
|
||||
const archiveUrl = `${preservedUrl}/attachments/${summary.attachments[0].id}`; const archived = await page.request.get(archiveUrl); expect(archived.status()).toBe(200); expect(await archived.body()).toEqual(file.bytes);
|
||||
await page.reload(); const view = section(page, translations.en.assetsSubmittedPackages).getByRole('button', { name: translations.en.assetsPackageView, exact: true }); await keyboard(page, view);
|
||||
const dialog = page.getByRole('dialog', { name: interpolate(translations.en.assetsSubmittedPackageVersion, { version: 1 }), exact: true });
|
||||
await expect(dialog).toContainText(seeded.originalLetter); await expect(dialog).toContainText(seeded.answer); await expect(dialog).not.toContainText('Later current letter');
|
||||
const scrollRegion = dialog.getByRole('region', { name: interpolate(translations.en.assetsSubmittedPackageVersion, { version: 1 }), exact: true });
|
||||
await scrollRegion.focus(); await expect(scrollRegion).toBeFocused();
|
||||
const beforeScroll = await scrollRegion.evaluate(node => node.scrollTop); await page.keyboard.press('PageDown');
|
||||
await expect.poll(() => scrollRegion.evaluate(node => node.scrollTop)).toBeGreaterThan(beforeScroll);
|
||||
await page.keyboard.press('Home'); await expect.poll(() => scrollRegion.evaluate(node => node.scrollTop)).toBe(0);
|
||||
const iframe = dialog.getByTitle(translations.en.assetsPackageCvPreview, { exact: true });
|
||||
await expect(iframe).toHaveAttribute('sandbox', ''); await expect(iframe).toHaveAttribute('srcdoc', before.renderedCvHtml!);
|
||||
expect(await iframe.evaluate(node => getComputedStyle(node).backgroundColor)).toBe('rgb(255, 255, 255)');
|
||||
await capture(page, info, desktop, 'frozen-original-white-print-preview');
|
||||
await page.keyboard.press('Escape'); await expect(view).toBeFocused();
|
||||
const download = page.waitForEvent('download'); await keyboard(page, section(page, translations.en.assetsSubmittedPackages).getByRole('button').filter({ hasText: file.name }));
|
||||
const result = await download; expect(result.suggestedFilename()).toBe(file.name); const path = await result.path(); expect(path).not.toBeNull(); expect(await readFile(path!)).toEqual(file.bytes);
|
||||
expect(count(session, 'POST', `/api/jobapplications/${seeded.job.id}/submitted-packages`)).toHaveLength(1);
|
||||
expect(await json<SubmittedPackageDetail>(await page.request.get(preservedUrl))).toEqual(before);
|
||||
await info.attach('application-frozen-bytes', { body: JSON.stringify({ boundary, packageId: summary.id, recordedFileSha256: summary.attachments[0].sha256, downloadSha256: createHash('sha256').update(file.bytes).digest('hex'),
|
||||
preservedCvVersion: summary.cvVariantVersion, currentCvVersion: changedCv.version, qualification: 'Exact scoped real-HTTP current-source changes and archive-byte checks; existing ordinary P2-D01/full renderer fidelity runs remain required separately.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-06 forms] failed preserved detail and download retain history and Retry only the original owned read', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000); const seeded = await frozenFixture(page, session.owner); const detailUrl = `${jobUrl(seeded.job.id)}/submitted-packages/${seeded.preserved.id}`;
|
||||
const downloadUrl = `${detailUrl}/attachments/${seeded.preserved.attachments[0].id}`;
|
||||
await enter(page, session.owner, seeded.job, 'submission'); const body = section(page, translations.en.assetsSubmittedPackages); await expect(body.getByRole('button', { name: translations.en.assetsPackageView, exact: true })).toBeVisible();
|
||||
const gate = hold(); let details = 0; let downloads = 0;
|
||||
await page.route(detailUrl, async route => { details += 1; if (details === 1) await route.fulfill({ status: 503, json: {} }); else { await gate.pending; await route.continue(); } });
|
||||
await page.route(downloadUrl, async route => { downloads += 1; if (downloads === 1) await route.fulfill({ status: 503, json: {} }); else return route.continue(); });
|
||||
try {
|
||||
await keyboard(page, body.getByRole('button', { name: translations.en.assetsPackageView, exact: true }));
|
||||
await expect(body.getByRole('alert')).toContainText(translations.en.assetsPackageLoadFailed); await expect(body.getByText(interpolate(translations.en.assetsSubmittedPackageVersion, { version: 1 }), { exact: true })).toBeVisible();
|
||||
await expect(body.getByRole('button', { name: translations.en.assetsPackageView, exact: true })).toBeVisible(); expect(mutations(session)).toEqual([]);
|
||||
const baseline = assetReads(session, seeded.job.id).length;
|
||||
await keyboard(page, body.getByRole('button', { name: translations.en.retry, exact: true })); await expect.poll(() => details).toBe(2);
|
||||
await expect(childFocus(page, translations.en.assetsSubmittedPackages)).toBeFocused(); await capture(page, info, desktop, 'held-original-package-detail-retry'); gate.release();
|
||||
const dialog = page.getByRole('dialog'); await expect(dialog).toContainText(seeded.originalLetter); await page.keyboard.press('Escape');
|
||||
await expect(body.getByRole('button', { name: translations.en.assetsPackageView, exact: true })).toBeFocused();
|
||||
expect(assetReads(session, seeded.job.id)).toHaveLength(baseline + 1);
|
||||
await keyboard(page, body.getByRole('button').filter({ hasText: seeded.name }));
|
||||
await expect(body.getByRole('alert')).toContainText(translations.en.assetsPackageDownloadFailed); await expect(body.getByText(interpolate(translations.en.assetsSubmittedPackageVersion, { version: 1 }), { exact: true })).toBeVisible();
|
||||
const download = page.waitForEvent('download'); await keyboard(page, body.getByRole('button', { name: translations.en.retry, exact: true }));
|
||||
const result = await download; const path = await result.path(); expect(path).not.toBeNull(); expect(await readFile(path!)).toEqual(seeded.bytes);
|
||||
expect(details).toBe(2); expect(downloads).toBe(2); expect(mutations(session)).toEqual([]);
|
||||
expect(await json<SubmittedPackageDetail>(await page.request.get(detailUrl))).toEqual(seeded.detail);
|
||||
await capture(page, info, desktop, 'original-archive-retry-history-retained');
|
||||
} finally { gate.release(); }
|
||||
});
|
||||
|
||||
test('[P7-06 forms] live EN/Bokmal retains dirty letter and both answer drafts without asset GET or writes', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000); const seeded = await fixture(page, session.owner); await enter(page, session.owner, seeded.job, 'letter');
|
||||
await expect(letter(page)).toHaveValue(seeded.originalLetter); await letter(page).fill('Dirty owner text remains the same across UI locale — æøå');
|
||||
let reads = assetReads(session, seeded.job.id).length;
|
||||
const firstLocale = response(page, 'PUT', `${apiUrl}/auth/preferences/language`); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'Norsk', exact: true }));
|
||||
const firstLocaleResponse = await firstLocale;
|
||||
await info.attach('application-locale-first-http', { body: JSON.stringify({ status: firstLocaleResponse.status(), body: await firstLocaleResponse.text() }), contentType: 'application/json' });
|
||||
expect(firstLocaleResponse.status()).toBe(204);
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); await expect(letter(page, 'nb')).toHaveValue('Dirty owner text remains the same across UI locale — æøå');
|
||||
expect(assetReads(session, seeded.job.id)).toHaveLength(reads); expect(mutations(session)).toEqual([]);
|
||||
await capture(page, info, { ...desktop, language: 'nb' }, 'dirty-letter-live-locale');
|
||||
await keyboard(page, section(page, translations.nb.workspaceCoverLetter).getByRole('button', { name: translations.nb.assetsDiscardChanges, exact: true }));
|
||||
await keyboard(page, page.getByRole('tablist', { name: translations.nb.workspaceApplicationSections, exact: true }).getByRole('tab', { name: translations.nb.workspaceAnswers, exact: true }));
|
||||
const fields = drafts(page, 'nb'); await expect(fields.answer).toHaveValue(seeded.answer);
|
||||
await fields.answer.fill('Ulagret søknadssvar — æøå'); await fields.message.fill('Ulagret melding; ikke sendt — æøå'); reads = assetReads(session, seeded.job.id).length;
|
||||
const secondLocale = response(page, 'PUT', `${apiUrl}/auth/preferences/language`); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'English', exact: true }));
|
||||
const secondLocaleResponse = await secondLocale;
|
||||
await info.attach('application-locale-second-http', { body: JSON.stringify({ status: secondLocaleResponse.status(), body: await secondLocaleResponse.text() }), contentType: 'application/json' });
|
||||
expect(secondLocaleResponse.status()).toBe(204);
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'en'); await expect(drafts(page).answer).toHaveValue('Ulagret søknadssvar — æøå'); await expect(drafts(page).message).toHaveValue('Ulagret melding; ikke sendt — æøå');
|
||||
expect(assetReads(session, seeded.job.id)).toHaveLength(reads); expect(mutations(session)).toEqual([]);
|
||||
await capture(page, info, desktop, 'dirty-two-drafts-live-locale');
|
||||
await info.attach('application-locale-contract', { body: JSON.stringify({ boundary, assetGetDelta: 0, writes: 0, qualification: 'Assets only: existing workspace aggregate may independently reload on locale. Persisted document/draft language is not rewritten by UI language; no model calls.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-06 forms] real duplicate then failed attach preserves created copy and original without automatic duplicate retry', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000); const seeded = await fixture(page, session.owner);
|
||||
const original = await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${seeded.cv.id}`));
|
||||
await enter(page, session.owner, seeded.job, 'cv'); const attachUrl = `${jobUrl(seeded.job.id)}/cv`; let attaches = 0;
|
||||
await page.route(attachUrl, async route => { if (route.request().method() !== 'PUT') return route.continue(); attaches += 1; return attaches === 1 ? route.fulfill({ status: 503, json: {} }) : route.continue(); });
|
||||
const copyResponse = response(page, 'POST', `${apiUrl}/cv/variants/${seeded.cv.id}/duplicate`);
|
||||
await keyboard(page, section(page, translations.en.workspaceCv).getByRole('button', { name: translations.en.assetsDuplicateForJob, exact: true }));
|
||||
const copy = await json<CvVariant>(await copyResponse);
|
||||
await expect(section(page, translations.en.workspaceCv).getByRole('alert').filter({ hasText: translations.en.assetsAttachCvFailed })).toBeVisible();
|
||||
await expect(section(page, translations.en.workspaceCv).getByText(translations.en.assetsCopyCreatedHelp, { exact: true })).toBeVisible();
|
||||
await expect(attachedCv(page)).toContainText(seeded.cv.name);
|
||||
// DuplicateAsync preserves JobApplicationId before the second attach write. The
|
||||
// existing read projects the newest associated variant, even while this UI
|
||||
// still holds its pre-write selection. Explicit attach normalises cardinality.
|
||||
expect((await json<ApplicationCv>(await page.request.get(attachUrl))).attachedVariantId).toBe(copy.id);
|
||||
expect(await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${seeded.cv.id}`))).toEqual(original);
|
||||
const partial = await json<CvVariantSummary[]>(await page.request.get(`${apiUrl}/cv/variants`));
|
||||
expect(partial.map(value => value.id).sort((a, b) => a - b)).toEqual([seeded.cv.id, copy.id].sort((a, b) => a - b));
|
||||
expect(partial.filter(value => value.jobApplicationId === seeded.job.id).map(value => value.id).sort((a, b) => a - b)).toEqual([seeded.cv.id, copy.id].sort((a, b) => a - b));
|
||||
await expect(section(page, translations.en.workspaceCv).getByRole('button', { name: translations.en.retry, exact: true })).toHaveCount(0);
|
||||
await capture(page, info, desktop, 'created-copy-failed-attach-preserved');
|
||||
expect(count(session, 'POST', `/api/cv/variants/${seeded.cv.id}/duplicate`)).toHaveLength(1); expect(attaches).toBe(1);
|
||||
const recovery = response(page, 'PUT', attachUrl);
|
||||
const builderRead = response(page, 'GET', `${apiUrl}/cv/variants/${copy.id}`);
|
||||
await keyboard(page, section(page, translations.en.workspaceCv).getByRole('button', { name: translations.en.assetsAttachCreatedCopy, exact: true }));
|
||||
expect((await recovery).status()).toBe(200);
|
||||
await expect(page).toHaveURL(new RegExp(`/career/builder/${copy.id}$`));
|
||||
expect(await json<CvVariant>(await builderRead)).toMatchObject({ id: copy.id, name: copy.name });
|
||||
expect((await json<ApplicationCv>(await page.request.get(attachUrl))).attachedVariantId).toBe(copy.id);
|
||||
expect((await json<CvVariantSummary[]>(await page.request.get(`${apiUrl}/cv/variants`))).filter(value => value.jobApplicationId === seeded.job.id).map(value => value.id)).toEqual([copy.id]);
|
||||
expect(count(session, 'POST', `/api/cv/variants/${seeded.cv.id}/duplicate`)).toHaveLength(1); expect(attaches).toBe(2);
|
||||
await info.attach('application-duplicate-partial-write', { body: JSON.stringify({ boundary, originalId: seeded.cv.id, createdCopyId: copy.id, duplicatePostCount: 1, attachAttempts: 2,
|
||||
recovery: 'Explicit Attach created copy reuses its exact ID, normalises association cardinality and opens its existing builder; no second duplicate.',
|
||||
qualification: 'Existing duplicate retains JobApplicationId on both original and copy before explicit attach; newest-copy read projection can differ from stale UI. Two nontransactional HTTP writes remain, without rollback/idempotency/version guarantee.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-06 forms] eight finite bilingual theme widths keep long CV, letter, drafts and frozen preview usable with keyboard', async ({ page, session }, info) => {
|
||||
test.setTimeout(240_000); const seeded = await frozenFixture(page, session.owner, true);
|
||||
const samples: (Sample & { sub: Subsection })[] = [
|
||||
{ language: 'en', theme: 'light', width: 320, height: 740, sub: 'answers' }, { language: 'nb', theme: 'dark', width: 360, height: 844, sub: 'letter' },
|
||||
{ language: 'en', theme: 'dark', width: 390, height: 844, sub: 'submission' }, { language: 'nb', theme: 'light', width: 768, height: 1024, sub: 'cv' },
|
||||
{ language: 'en', theme: 'dark', width: 1024, height: 768, sub: 'answers' }, { language: 'nb', theme: 'light', width: 1280, height: 320, sub: 'letter' },
|
||||
{ language: 'en', theme: 'light', width: 1440, height: 900, sub: 'submission' }, { language: 'nb', theme: 'dark', width: 1920, height: 1080, sub: 'cv' },
|
||||
];
|
||||
for (const sample of samples) {
|
||||
await enter(page, session.owner, seeded.job, sample.sub, sample); const t = translations[sample.language];
|
||||
let letterHint: { anchor: Locator; hint: Locator; id: string } | null = null;
|
||||
const selected = page.getByRole('tablist', { name: t.workspaceApplicationSections, exact: true }).locator('[aria-selected=true]'); await selected.focus(); await expect(selected).toBeFocused();
|
||||
if (sample.sub === 'letter') {
|
||||
await expect(letter(page, sample.language)).toHaveValue(seeded.originalLetter); await letter(page, sample.language).focus(); await expect(letter(page, sample.language)).toBeFocused();
|
||||
for (const title of [t.richTextBold, t.richTextItalic, t.richTextUnderline, t.richTextLink]) { const button = section(page, t.workspaceCoverLetter).getByRole('button', { name: title, exact: true }); await button.focus(); await expect(button).toBeFocused(); }
|
||||
const anchor = section(page, t.workspaceCoverLetter).getByRole('button', { name: t.richTextLink, exact: true });
|
||||
await expect(anchor).toHaveAttribute('aria-label', t.richTextLink);
|
||||
const visibleHint = page.getByRole('tooltip', { name: t.richTextLink, exact: true }); await expect(visibleHint).toBeVisible();
|
||||
// Existing MUI Tooltip names the child; describeChild is false, so there
|
||||
// is no aria-describedby contract to invent for this toolbar button.
|
||||
const id = await visibleHint.getAttribute('id'); expect(id).not.toBeNull();
|
||||
const hint = page.locator(`[id="${id}"]`); await expect(hint).toHaveAttribute('role', 'tooltip'); await expect(hint).toBeVisible();
|
||||
letterHint = { anchor, hint, id: id! };
|
||||
} else if (sample.sub === 'answers') {
|
||||
const fields = drafts(page, sample.language); await expect(fields.answer).toHaveValue(seeded.answer); await expect(fields.message).toHaveValue(seeded.message);
|
||||
await fields.answer.focus(); await expect(fields.answer).toBeFocused(); await fields.message.focus(); await expect(fields.message).toBeFocused();
|
||||
} else if (sample.sub === 'cv') {
|
||||
const control = attachedCv(page, sample.language); await expect(control).toContainText(seeded.cv.name); await control.focus(); await expect(control).toBeFocused();
|
||||
await expect(page.getByRole('link', { name: t.assetsEditPreviewExport, exact: true })).toHaveAttribute('href', `/career/builder/${seeded.cv.id}`);
|
||||
} else {
|
||||
const view = section(page, t.assetsSubmittedPackages).getByRole('button', { name: t.assetsPackageView, exact: true }); await keyboard(page, view);
|
||||
const dialog = page.getByRole('dialog', { name: interpolate(t.assetsSubmittedPackageVersion, { version: 1 }), exact: true });
|
||||
await expect(dialog).toContainText(seeded.originalLetter); const iframe = dialog.getByTitle(t.assetsPackageCvPreview, { exact: true });
|
||||
await expect(iframe).toHaveAttribute('srcdoc', seeded.detail.renderedCvHtml!); await expect(iframe).toHaveAttribute('sandbox', '');
|
||||
expect(await iframe.evaluate(node => getComputedStyle(node).backgroundColor)).toBe('rgb(255, 255, 255)');
|
||||
const close = dialog.getByRole('button', { name: t.close, exact: true }); await close.focus(); await expect(close).toBeFocused();
|
||||
}
|
||||
await page.evaluate(() => scrollTo(0, 0));
|
||||
if (letterHint) {
|
||||
await expect(letterHint.anchor).toBeFocused();
|
||||
const geometry = await letterHint.anchor.evaluate(node => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right, viewportWidth: innerWidth, viewportHeight: innerHeight,
|
||||
whollyOutsideViewport: rect.bottom <= 0 || rect.top >= innerHeight || rect.right <= 0 || rect.left >= innerWidth };
|
||||
});
|
||||
await expect(letterHint.hint).toBeAttached();
|
||||
if (geometry.whollyOutsideViewport) await expect(letterHint.hint).toBeHidden();
|
||||
else await expect(letterHint.hint).toBeVisible();
|
||||
await info.attach(`application-letter-hint-${sample.language}-${sample.width}`, { body: JSON.stringify({ matchingTooltipId: letterHint.id, anchorAccessibleName: t.richTextLink, geometry,
|
||||
expectation: geometry.whollyOutsideViewport ? 'Focused anchor outside viewport: matching hint hidden' : 'Focused anchor intersects viewport: matching hint visible' }), contentType: 'application/json' });
|
||||
}
|
||||
await capture(page, info, sample, `long-${sample.sub}`);
|
||||
if (sample.sub === 'submission') { await page.keyboard.press('Escape'); await expect(section(page, t.assetsSubmittedPackages).getByRole('button', { name: t.assetsPackageView, exact: true })).toBeFocused(); }
|
||||
}
|
||||
expect(mutations(session)).toEqual([]);
|
||||
});
|
||||
|
||||
test('[P7-06 forms] controlled language HTTP503 preserves local dirty drafts and Retry only repeats the language request', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const seeded = await fixture(page, session.owner); await enter(page, session.owner, seeded.job, 'letter');
|
||||
await expect(letter(page)).toHaveValue(seeded.originalLetter);
|
||||
const persistedBefore = await json<{ uiLanguage: string }>(await page.request.get(`${apiUrl}/auth/me`)); expect(persistedBefore.uiLanguage).toBe('en');
|
||||
const preferenceUrl = `${apiUrl}/auth/preferences/language`;
|
||||
const preferenceAttempts: { method: string; body: unknown }[] = [];
|
||||
await page.route(preferenceUrl, async route => {
|
||||
if (route.request().method() !== 'PUT') return route.continue();
|
||||
preferenceAttempts.push({ method: route.request().method(), body: route.request().postDataJSON() });
|
||||
return route.fulfill({ status: 503, json: {} });
|
||||
});
|
||||
const dirtyLetter = 'Controlled-failure draft; retain my exact unsaved letter — æøå';
|
||||
await letter(page).fill(dirtyLetter); let reads = assetReads(session, seeded.job.id).length;
|
||||
const firstResponse = response(page, 'PUT', preferenceUrl);
|
||||
await keyboard(page, page.locator('header').first().getByRole('button', { name: 'Norsk', exact: true }));
|
||||
expect((await firstResponse).status()).toBe(503); await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
|
||||
await expect(letter(page, 'nb')).toHaveValue(dirtyLetter);
|
||||
const nbFeedback = page.getByRole('alert').filter({ hasText: translations.nb.settingsLanguageSaveFailed }); await expect(nbFeedback).toBeVisible();
|
||||
const firstRetry = response(page, 'PUT', preferenceUrl);
|
||||
await keyboard(page, nbFeedback.getByRole('button', { name: translations.nb.retry, exact: true })); expect((await firstRetry).status()).toBe(503);
|
||||
await expect(nbFeedback).toBeVisible(); await expect(letter(page, 'nb')).toHaveValue(dirtyLetter);
|
||||
expect(preferenceAttempts).toEqual([{ method: 'PUT', body: { language: 'nb-NO' } }, { method: 'PUT', body: { language: 'nb-NO' } }]);
|
||||
expect(assetReads(session, seeded.job.id)).toHaveLength(reads); expect(mutations(session)).toEqual([]);
|
||||
await capture(page, info, { ...desktop, language: 'nb' }, 'controlled-language503-dirty-letter');
|
||||
|
||||
await keyboard(page, section(page, translations.nb.workspaceCoverLetter).getByRole('button', { name: translations.nb.assetsDiscardChanges, exact: true }));
|
||||
await keyboard(page, page.getByRole('tablist', { name: translations.nb.workspaceApplicationSections, exact: true }).getByRole('tab', { name: translations.nb.workspaceAnswers, exact: true }));
|
||||
await expect(drafts(page, 'nb').answer).toHaveValue(seeded.answer);
|
||||
const dirtyAnswer = 'Ulagret søknadssvar under kontrollert språkfeil — æøå'; const dirtyMessage = 'Ulagret melding; ikke sendt under språkfeilen — æøå';
|
||||
await drafts(page, 'nb').answer.fill(dirtyAnswer); await drafts(page, 'nb').message.fill(dirtyMessage); reads = assetReads(session, seeded.job.id).length;
|
||||
const secondResponse = response(page, 'PUT', preferenceUrl);
|
||||
await keyboard(page, page.locator('header').first().getByRole('button', { name: 'English', exact: true }));
|
||||
expect((await secondResponse).status()).toBe(503); await expect(page.locator('html')).toHaveAttribute('lang', 'en');
|
||||
await expect(drafts(page).answer).toHaveValue(dirtyAnswer); await expect(drafts(page).message).toHaveValue(dirtyMessage);
|
||||
const enFeedback = page.getByRole('alert').filter({ hasText: translations.en.settingsLanguageSaveFailed }); await expect(enFeedback).toBeVisible();
|
||||
const secondRetry = response(page, 'PUT', preferenceUrl);
|
||||
await keyboard(page, enFeedback.getByRole('button', { name: translations.en.retry, exact: true })); expect((await secondRetry).status()).toBe(503);
|
||||
await expect(enFeedback).toBeVisible(); await expect(drafts(page).answer).toHaveValue(dirtyAnswer); await expect(drafts(page).message).toHaveValue(dirtyMessage);
|
||||
expect(preferenceAttempts).toEqual([{ method: 'PUT', body: { language: 'nb-NO' } }, { method: 'PUT', body: { language: 'nb-NO' } }, { method: 'PUT', body: { language: 'en' } }, { method: 'PUT', body: { language: 'en' } }]);
|
||||
expect(assetReads(session, seeded.job.id)).toHaveLength(reads); expect(mutations(session)).toEqual([]);
|
||||
const persistedAfter = await json<{ uiLanguage: string }>(await page.request.get(`${apiUrl}/auth/me`)); expect(persistedAfter.uiLanguage).toBe(persistedBefore.uiLanguage);
|
||||
expect((await json<CoverLetter>(await page.request.get(`${jobUrl(seeded.job.id)}/cover-letter`))).text).toBe(seeded.originalLetter);
|
||||
const workspace = await json<WorkspaceOverview>(await page.request.get(`${jobUrl(seeded.job.id)}/workspace`)); expect(workspace.applicationAnswerDraft).toBe(seeded.answer); expect(workspace.recruiterMessageDraft).toBe(seeded.message);
|
||||
await capture(page, info, desktop, 'controlled-language503-dirty-answer-drafts');
|
||||
await info.attach('application-controlled-language503-boundary', { body: JSON.stringify({ boundary, controlledResponseStatus: 503, preferenceAttempts, persistedBefore: persistedBefore.uiLanguage, persistedAfter: persistedAfter.uiLanguage,
|
||||
qualification: 'Page-only language PUT is deliberately fulfilled503 before server execution. Proves local presentation, retained drafts and exact Retry request without asset GET/job writes; it does not prove account preference persistence or close RD11. The separate ordinary real-server locale case keeps its strict204 assertions and observed500 failures.' }), contentType: 'application/json' });
|
||||
});
|
||||
Reference in New Issue
Block a user