408 lines
29 KiB
TypeScript
408 lines
29 KiB
TypeScript
import { expect, test as base, type APIRequestContext, type APIResponse, type Page, type TestInfo } from '@playwright/test';
|
|
import AxeBuilder from '@axe-core/playwright';
|
|
import { translations } from '../src/i18n/translations';
|
|
import type { StructuredCvProfile } from '../src/profileCv';
|
|
|
|
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 CareerReadback = { profile: StructuredCvProfile; completeness: unknown; cvText: string | null };
|
|
|
|
async function json<T>(response: APIResponse, status = 200): Promise<T> {
|
|
expect(response.status(), `${response.url()} must return ${status}`).toBe(status);
|
|
return response.json();
|
|
}
|
|
|
|
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 } };
|
|
}
|
|
|
|
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 = `p7-career-editor-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
|
expect((await admin.post(`${apiUrl}/users`, { headers: adminOwner.headers, data: { email, password, roles: [] } })).status()).toBe(200);
|
|
const owner = await login(page.request, email);
|
|
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();
|
|
});
|
|
await provide(owner);
|
|
} finally { await admin.dispose(); }
|
|
},
|
|
});
|
|
|
|
async function seed(page: Page, owner: Owner) {
|
|
const profile = {
|
|
version: '1',
|
|
metadata: { fields: { 'contact.fullName': { confidence: null, sourcePage: null, method: 'manual',
|
|
sourceSnippet: 'A long owner-reviewed source note about recorded work and qualifications, retained without adding a new claim.', reviewState: 'reviewed' } } },
|
|
contact: { fullName: 'Åse Nordlys — owner-reviewed professional identity with an intentionally long recorded name',
|
|
headline: 'Experienced software engineer with recorded delivery, collaboration and professional responsibility across Norwegian organisations',
|
|
email: 'owner-reviewed-professional-contact@example.test', phone: '+47 900 00 001', location: 'Trondheim, Trøndelag, Norge — remote and hybrid work',
|
|
website: 'https://example.test/portfolio/recorded-professional-work', linkedIn: 'https://www.linkedin.com/in/reviewed-professional-identity',
|
|
gitHub: 'https://github.com/reviewed-professional-identity', links: [{ label: 'Detailed portfolio of recorded work and owner-reviewed outcomes',
|
|
url: 'https://example.test/recorded-work/long-reviewed-professional-source-reference' }] },
|
|
summary: ['Owner-reviewed professional summary with specific recorded delivery and collaboration.', 'Another factual summary line, kept separate.'],
|
|
jobs: [{ id: 'editor-job-source', title: 'Senior software engineer with responsibility for recorded platform delivery and professional collaboration',
|
|
company: 'Nordlys programvare og dokumenterte tjenester AS', location: 'Trondheim and remote collaboration across Norway',
|
|
start: 'Jan 2020', end: 'Dec 2022', bullets: ['Recorded delivery with owner-reviewed implementation details.', 'Recorded collaboration and operational responsibility.'], skills: ['C#', 'React', 'Norsk'] }],
|
|
education: [{ id: 'editor-education-source', qualification: 'Computer science and software engineering with a recorded specialisation in reliable services',
|
|
qualificationLevel: 'Bachelor', institution: 'Recorded university department for computer science and software engineering',
|
|
location: 'Trondheim, Norge', start: 'Aug 2016', end: 'Jun 2019', details: ['Recorded qualification and reviewed project work.'] }],
|
|
projects: [{ id: 'editor-project-source', name: 'Recorded professional project', role: 'Developer', start: '2023-01', end: '2023-12',
|
|
bullets: ['Recorded project evidence.'], skills: ['React'] }],
|
|
certifications: [{ id: 'editor-certification-source', name: 'Recorded certification', issuer: 'Fixture Academy', date: 'Jun 2022', details: ['Reviewed certification detail.'] }],
|
|
skills: ['C#', 'React', 'SQL', 'Norsk'], interests: ['Recorded community participation'],
|
|
languages: [{ name: 'Norwegian Sign Language — recorded professional communication experience', level: 'Professional working proficiency with reviewed workplace communication',
|
|
notes: 'An owner-reviewed language note retained with the source and kept separate from CV-specific presentation.' }],
|
|
awards: ['Recorded award'], publications: ['Recorded publication'], organisations: ['Recorded organisation'], references: ['Available by owner agreement'],
|
|
otherSections: [{ title: 'Owner-reviewed custom career information with a deliberately long descriptive section title',
|
|
items: ['Recorded custom fact with relevant professional context.', 'Another recorded custom fact, preserved during unrelated contact editing.'] }],
|
|
};
|
|
const raw = 'Exact original career source — æøå\nRecorded work and qualifications.\n';
|
|
await json<CareerReadback>(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile, cvText: raw } }));
|
|
return { canonical: await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`)),
|
|
history: await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`)) };
|
|
}
|
|
|
|
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.addInitScript(({ language, theme }) => {
|
|
localStorage.setItem('uiLanguage', language);
|
|
localStorage.setItem('jobtracker.themeMode', theme);
|
|
}, sample);
|
|
await page.setViewportSize({ width: sample.width, height: sample.height });
|
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
await page.goto('/career?section=profile&source=phase7-editor#preserved');
|
|
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.getByLabel(translations[sample.language].profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 001');
|
|
}
|
|
|
|
async function navigateSection(page: Page, language: Sample['language'], section: 'profile' | 'import') {
|
|
const t = translations[language];
|
|
const label = section === 'profile' ? t.careerWorkspaceProfileTab : t.careerWorkspaceImportTab;
|
|
const selector = page.getByRole('combobox', { name: t.careerWorkspaceNavigation, exact: true });
|
|
if (await selector.isVisible()) {
|
|
await selector.click();
|
|
await page.getByRole('option', { name: label, exact: true }).click();
|
|
} else await page.getByRole('tab', { name: label, exact: true }).click();
|
|
const url = new URL(page.url());
|
|
expect(url.searchParams.get('section')).toBe(section);
|
|
expect(url.searchParams.get('source')).toBe('phase7-editor');
|
|
expect(url.hash).toBe('#preserved');
|
|
}
|
|
|
|
function escaped(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
|
|
async function openRaw(page: Page, language: Sample['language']) {
|
|
const field = page.getByLabel(translations[language].profileCvTextLabel, { exact: true });
|
|
const disclosure = page.getByRole('button', { name: new RegExp(escaped(translations[language].profileCvRawPanelTitle)) });
|
|
if (await disclosure.getAttribute('aria-expanded') !== 'true') await disclosure.click();
|
|
await expect(disclosure).toHaveAttribute('aria-expanded', 'true');
|
|
await expect(field).toBeVisible();
|
|
await field.scrollIntoViewIfNeeded();
|
|
await expect(field).toBeInViewport({ ratio: 0.5 });
|
|
await expect(disclosure).toHaveAttribute('aria-expanded', 'true');
|
|
return field;
|
|
}
|
|
|
|
async function settle(page: Page) {
|
|
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
|
|
}
|
|
|
|
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
|
|
await settle(page);
|
|
const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight,
|
|
rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
|
|
overflowElements: [...document.querySelectorAll('body *')]
|
|
.filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden' && !element.closest('[hidden],[aria-hidden="true"]'))
|
|
.map(element => {
|
|
const rect = element.getBoundingClientRect();
|
|
const style = getComputedStyle(element);
|
|
return { tag: element.tagName.toLowerCase(), id: element.id || null, class: element.getAttribute('class'),
|
|
text: (element.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120), left: rect.left, right: rect.right, width: rect.width,
|
|
minWidth: style.minWidth, display: style.display, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth };
|
|
})
|
|
.filter(element => element.width > 0 && (element.right > innerWidth + 1 || element.left < -1))
|
|
.slice(0, 20),
|
|
visibleFields: [...document.querySelectorAll('#career-profile-editor input, #career-profile-editor textarea, #career-cv-import input, #career-cv-import textarea')]
|
|
.filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden' && element.getAttribute('aria-hidden') !== 'true')
|
|
.map(element => { const rect = element.getBoundingClientRect(); return { name: element.getAttribute('aria-label') || (element as HTMLInputElement).labels?.[0]?.textContent,
|
|
left: rect.left, right: rect.right, width: rect.width }; }),
|
|
}));
|
|
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
|
|
const name = `p7-career-editor-${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 plus target-size; complete findings retained', violations: axe.violations, incomplete: axe.incomplete,
|
|
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
|
|
qualifications: ['Automated Chromium evidence only; no human screen reader or physical touch/software keyboard',
|
|
'No actual browser zoom, production font/photo, PDF, model/provider or production acceptance'],
|
|
}), contentType: 'application/json' });
|
|
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
|
expect(geometry.rootOverflow).toBe(0);
|
|
expect(geometry.visibleFields.length).toBeGreaterThan(0);
|
|
if (state === 'held-save-raw-import') expect(geometry.visibleFields.some(field => field.name === translations[sample.language].profileCvTextLabel)).toBe(true);
|
|
expect(geometry.visibleFields.every(field => field.left >= 0 && field.right <= geometry.width && field.width > 0)).toBe(true);
|
|
expect(axe.violations).toEqual([]);
|
|
return axe;
|
|
}
|
|
|
|
test('[P7-03 PRES-C01 C02 E08] structured and raw drafts survive English to Bokmål without canonical refetch or write', async ({ page, owner }, info) => {
|
|
test.setTimeout(90_000);
|
|
const before = await seed(page, owner);
|
|
const sample: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
|
|
let profileReads = 0;
|
|
const readCompletions: Promise<void>[] = [];
|
|
await page.route('**/api/career/profile', async route => {
|
|
if (route.request().method() !== 'GET') return route.continue();
|
|
profileReads++;
|
|
const completed = (async () => { const response = await route.fetch(); await route.fulfill({ response }); })();
|
|
readCompletions.push(completed);
|
|
await completed;
|
|
});
|
|
const canonicalWrites: unknown[] = [];
|
|
page.on('request', request => { if (request.url() === `${apiUrl}/career/profile` && request.method() === 'PUT') canonicalWrites.push(request.postDataJSON()); });
|
|
await enter(page, owner, sample);
|
|
const draftPhone = '+47 900 00 099';
|
|
const draftRaw = 'Unsaved owner-reviewed raw draft — æøå\nThis text is not persisted yet.\n';
|
|
await page.getByLabel(translations.en.profileCvContactPhone, { exact: true }).fill(draftPhone);
|
|
await navigateSection(page, 'en', 'import');
|
|
await (await openRaw(page, 'en')).fill(draftRaw);
|
|
await navigateSection(page, 'en', 'profile');
|
|
await expect(page.getByLabel(translations.en.profileCvContactPhone, { exact: true })).toHaveValue(draftPhone);
|
|
const readsBeforeLocale = profileReads;
|
|
const preferenceSaved = page.waitForResponse(response => response.url() === `${apiUrl}/auth/preferences/language` && response.request().method() === 'PUT');
|
|
await page.locator('header').getByRole('button', { name: 'Norsk', exact: true }).click();
|
|
expect((await preferenceSaved).status()).toBe(204);
|
|
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
|
|
await expect(page.getByLabel(translations.nb.profileCvContactPhone, { exact: true })).toBeVisible();
|
|
await settle(page);
|
|
await Promise.all(readCompletions);
|
|
await settle(page);
|
|
const observedPhone = await page.getByLabel(translations.nb.profileCvContactPhone, { exact: true }).inputValue();
|
|
await navigateSection(page, 'nb', 'import');
|
|
const observedRaw = await (await openRaw(page, 'nb')).inputValue();
|
|
const after = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
|
|
const historyAfter = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
|
|
await info.attach('native-career-editor-locale-drafts', { body: JSON.stringify({ readsBeforeLocale, readsAfterLocale: profileReads,
|
|
expected: { phone: draftPhone, raw: draftRaw }, observed: { phone: observedPhone, raw: observedRaw }, canonicalWrites, before, after, historyAfter,
|
|
qualification: 'Preference PUT204 is real; canonical GETs are proxied to actual HTTP for completion tracking. No source response is fabricated.' }), contentType: 'application/json' });
|
|
await capture(page, info, { ...sample, language: 'nb' }, 'locale-retained-raw-draft');
|
|
expect(readsBeforeLocale).toBeGreaterThan(0);
|
|
expect(profileReads).toBe(readsBeforeLocale);
|
|
expect(observedPhone).toBe(draftPhone);
|
|
expect(observedRaw).toBe(draftRaw);
|
|
expect(canonicalWrites).toEqual([]);
|
|
expect(after).toEqual(before.canonical);
|
|
expect(historyAfter).toEqual(before.history);
|
|
});
|
|
|
|
test('[P7-03 PRES-C01 C02 E08] held save locks edits and duplicate writes; failed save retains its snapshot for explicit identical-body retry', async ({ page, owner }, info) => {
|
|
test.setTimeout(120_000);
|
|
const before = await seed(page, owner);
|
|
const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 };
|
|
const t = translations.en;
|
|
const payloads: unknown[] = [];
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
await page.route('**/api/career/profile', async route => {
|
|
if (route.request().method() !== 'PUT') return route.continue();
|
|
payloads.push(route.request().postDataJSON());
|
|
if (payloads.length === 1) {
|
|
await held;
|
|
return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled career save unavailable' }) });
|
|
}
|
|
return route.continue();
|
|
});
|
|
await enter(page, owner, sample);
|
|
const draftPhone = '+47 900 00 088';
|
|
const draftSummary = 'Owner-reviewed pending summary.';
|
|
const draftRaw = 'Pending owner raw source — æøå\nRetain this exact draft after failure.\n';
|
|
await page.getByLabel(t.profileCvContactPhone, { exact: true }).fill(draftPhone);
|
|
const summary = page.getByLabel(t.profileCvStructuredSummary, { exact: true });
|
|
await summary.fill(draftSummary);
|
|
await navigateSection(page, 'en', 'import');
|
|
await (await openRaw(page, 'en')).fill(draftRaw);
|
|
await navigateSection(page, 'en', 'profile');
|
|
await summary.evaluate((element: HTMLTextAreaElement) => element.setSelectionRange(0, 5));
|
|
const save = page.getByRole('button', { name: new RegExp(`^(?:${escaped(t.profileSaveChanges)}|${escaped(t.saving)})$`) });
|
|
const firstResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
|
|
let pending!: { fields: Array<{ name: string | null; disabled: boolean }>; actions: Array<{ name: string; disabled: boolean }>;
|
|
rawDisabled: boolean; saveDisabled: boolean; savingLabelCount: number; writeCount: number; summaryAfterAttempt: string };
|
|
try {
|
|
await save.focus();
|
|
await page.keyboard.press('Enter');
|
|
await expect.poll(() => payloads.length).toBe(1);
|
|
await settle(page);
|
|
const editor = page.locator('#career-profile-editor');
|
|
const actionNames = [t.profileCvAddLink, t.profileCvStructuredAddLanguage, t.profileCvStructuredAddJob, t.profileCvStructuredAddEducation,
|
|
t.profileCvStructuredAddOtherSection, t.profileCvStructuredRemove, t.profileCvDeleteCustomLink.replace('{index}', '1'),
|
|
t.careerProfileRemoveJob.replace('{index}', '1'), t.careerProfileRemoveEducation.replace('{index}', '1'),
|
|
t.careerProfileRemoveLanguage.replace('{index}', '1'), t.careerProfileRemoveOtherSection.replace('{index}', '1'),
|
|
t.richTextBold, t.richTextItalic, t.richTextUnderline, t.richTextLink];
|
|
const fields = await editor.locator('input:not([type="file"]),textarea:not([aria-hidden="true"])').evaluateAll(elements => elements
|
|
.filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden')
|
|
.map(element => ({ name: element.getAttribute('aria-label') || (element as HTMLInputElement).labels?.[0]?.textContent || null,
|
|
disabled: (element as HTMLInputElement).disabled })));
|
|
const actions = (await editor.getByRole('button').evaluateAll(elements => elements.map(element => ({
|
|
name: element.getAttribute('aria-label') || (element.textContent ?? '').replace(/\s+/g, ' ').trim(), disabled: (element as HTMLButtonElement).disabled,
|
|
})))).filter(action => actionNames.includes(action.name));
|
|
const rawDisabled = await page.getByLabel(t.profileCvTextLabel, { exact: true }).isDisabled();
|
|
const saveDisabled = await save.isDisabled();
|
|
const savingLabelCount = await page.getByRole('button', { name: t.saving, exact: true }).count();
|
|
// A native disabled button ignores click(); an enabled formatting button would mutate
|
|
// the held snapshot's draft. These attempts remain local to this disposable owner.
|
|
await save.evaluate((element: HTMLButtonElement) => element.click());
|
|
await editor.getByRole('button', { name: t.richTextBold, exact: true }).first().evaluate((element: HTMLButtonElement) => element.click());
|
|
await settle(page);
|
|
pending = { fields, actions, rawDisabled, saveDisabled, savingLabelCount, writeCount: payloads.length, summaryAfterAttempt: await summary.inputValue() };
|
|
await info.attach('native-career-editor-pending-lock', { body: JSON.stringify({ pending, heldBody: payloads[0] }), contentType: 'application/json' });
|
|
await capture(page, info, sample, 'held-save');
|
|
await navigateSection(page, 'en', 'import');
|
|
await expect(await openRaw(page, 'en')).toBeDisabled();
|
|
try { await capture(page, info, sample, 'held-save-raw-import'); }
|
|
finally { await navigateSection(page, 'en', 'profile'); }
|
|
} finally { release(); }
|
|
expect((await firstResponse).status()).toBe(500);
|
|
await expect(page.getByRole('button', { name: t.profileSaveChanges, exact: true })).toBeEnabled();
|
|
const failedPhone = await page.getByLabel(t.profileCvContactPhone, { exact: true }).inputValue();
|
|
const failedSummary = await summary.inputValue();
|
|
const failedRaw = await page.getByLabel(t.profileCvTextLabel, { exact: true }).inputValue();
|
|
const failureAlerts = await page.getByRole('main').getByRole('alert').allTextContents();
|
|
const afterFailure = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
|
|
const historyAfterFailure = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
|
|
await capture(page, info, sample, 'failed-save-retained-draft');
|
|
const retryResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
|
|
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus();
|
|
await page.keyboard.press('Enter');
|
|
expect((await retryResponse).status()).toBe(200);
|
|
const afterRetry = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
|
|
const historyAfterRetry = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
|
|
await info.attach('native-career-editor-save-retry', { body: JSON.stringify({ pending: pending!, payloads, failedDraft: { phone: failedPhone, summary: failedSummary, raw: failedRaw },
|
|
failureAlerts, before, afterFailure, historyAfterFailure, afterRetry, historyAfterRetry,
|
|
qualification: 'First500 is controlled transport failure; retry200 and canonical/history readbacks use actual SQLite HTTP. No canonical stale-write protection is claimed.' }), contentType: 'application/json' });
|
|
expect(pending!.fields.length).toBeGreaterThan(20);
|
|
expect(pending!.fields.every(field => field.disabled)).toBe(true);
|
|
expect(pending!.actions.length).toBeGreaterThanOrEqual(26);
|
|
expect(pending!.actions.every(action => action.disabled)).toBe(true);
|
|
expect(pending!.rawDisabled).toBe(true);
|
|
expect(pending!.saveDisabled).toBe(true);
|
|
expect(pending!.savingLabelCount).toBe(1);
|
|
expect(pending!.writeCount).toBe(1);
|
|
expect(pending!.summaryAfterAttempt).toBe(draftSummary);
|
|
expect(failedPhone).toBe(draftPhone);
|
|
expect(failedSummary).toBe(draftSummary);
|
|
expect(failedRaw).toBe(draftRaw);
|
|
expect(failureAlerts.some(alert => alert.includes(t.profileUpdateFailed) && alert.includes('Controlled career save unavailable'))).toBe(true);
|
|
expect(afterFailure).toEqual(before.canonical);
|
|
expect(historyAfterFailure).toEqual(before.history);
|
|
expect(payloads).toHaveLength(2);
|
|
expect(payloads[1]).toEqual(payloads[0]);
|
|
expect(afterRetry.profile.contact.phone).toBe(draftPhone);
|
|
expect(afterRetry.profile.summary).toEqual([draftSummary]);
|
|
expect(afterRetry.cvText).toBe(draftRaw);
|
|
expect(historyAfterRetry).toHaveLength(before.history.length + 1);
|
|
await page.reload();
|
|
await expect(page.getByLabel(t.profileCvContactPhone, { exact: true })).toHaveValue(draftPhone);
|
|
await expect(page.getByLabel(t.profileCvStructuredSummary, { exact: true })).toHaveValue(draftSummary);
|
|
});
|
|
|
|
test('[P7-03 PRES-C01 C02 E08] actual validation400 retains draft and source; corrected explicit save persists once', async ({ page, owner }, info) => {
|
|
test.setTimeout(90_000);
|
|
const before = await seed(page, owner);
|
|
const sample: Sample = { language: 'nb', theme: 'dark', width: 390, height: 844 };
|
|
const t = translations.nb;
|
|
const payloads: unknown[] = [];
|
|
page.on('request', request => { if (request.url() === `${apiUrl}/career/profile` && request.method() === 'PUT') payloads.push(request.postDataJSON()); });
|
|
await enter(page, owner, sample);
|
|
const website = page.getByLabel(t.profileCvContactWebsite, { exact: true });
|
|
const prefix = 'https://example.test/';
|
|
const overLimit = prefix + 'a'.repeat(501 - prefix.length);
|
|
await website.fill(overLimit);
|
|
const rejectedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
|
|
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus();
|
|
await page.keyboard.press('Enter');
|
|
const rejected = await rejectedResponse;
|
|
const rejectedBody = await rejected.text();
|
|
expect(rejected.status()).toBe(400);
|
|
await expect(page.getByRole('button', { name: t.profileSaveChanges, exact: true })).toBeEnabled();
|
|
const retainedWebsite = await website.inputValue();
|
|
const failureAlerts = await page.getByRole('main').getByRole('alert').allTextContents();
|
|
const afterFailure = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
|
|
const historyAfterFailure = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
|
|
await capture(page, info, sample, 'actual-validation400-retained-draft');
|
|
const corrected = 'https://example.test/recorded-work/corrected-reviewed-reference';
|
|
await website.fill(corrected);
|
|
const acceptedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
|
|
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus();
|
|
await page.keyboard.press('Enter');
|
|
expect((await acceptedResponse).status()).toBe(200);
|
|
const afterSave = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
|
|
const historyAfterSave = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
|
|
await info.attach('native-career-editor-real-validation', { body: JSON.stringify({ rejectedStatus: rejected.status(), rejectedBody,
|
|
requestedLength: overLimit.length, retainedWebsite, failureAlerts, payloads, before, afterFailure, historyAfterFailure, afterSave, historyAfterSave,
|
|
qualification: 'The501-character website is rejected by the actual existing500-character short-field limit before persistence. No transport fault substitutes for this validation response.' }), contentType: 'application/json' });
|
|
expect(rejectedBody).toContain('A contact field exceeds the allowed length.');
|
|
expect(retainedWebsite).toBe(overLimit);
|
|
expect(failureAlerts.some(alert => alert.includes(t.profileUpdateFailed) && alert.includes('A contact field exceeds the allowed length.'))).toBe(true);
|
|
expect(afterFailure).toEqual(before.canonical);
|
|
expect(historyAfterFailure).toEqual(before.history);
|
|
expect(payloads).toHaveLength(2);
|
|
expect(afterSave.profile.contact.website).toBe(corrected);
|
|
expect(afterSave.cvText).toBe(before.canonical.cvText);
|
|
expect(historyAfterSave).toHaveLength(before.history.length + 1);
|
|
await page.reload();
|
|
await expect(page.getByLabel(t.profileCvContactWebsite, { exact: true })).toHaveValue(corrected);
|
|
});
|
|
|
|
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-03 PRES-C01 C02 E08] structured editor headings, contextual actions and geometry ${sample.language}/${sample.theme}/${sample.width}x${sample.height}`, async ({ page, owner }, info) => {
|
|
test.setTimeout(90_000);
|
|
const before = await seed(page, owner);
|
|
await enter(page, owner, sample);
|
|
const t = translations[sample.language];
|
|
const writes: string[] = [];
|
|
page.on('request', request => { if (request.url().startsWith(apiUrl) && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) writes.push(request.method() + ' ' + request.url()); });
|
|
// Capture the existing rendered baseline even when a new semantic requirement
|
|
// fails in RED. This retains actual geometry, axe results and pixels for review.
|
|
const axe = await capture(page, info, sample, 'structured-editor');
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
|
|
await expect(page.getByRole('heading', { level: 2, name: t.profileCvStructuredEditor, exact: true })).toBeVisible();
|
|
for (const label of [t.careerProfileContactSection, t.profileCvStructuredLanguages, t.profileCvStructuredJobs, t.profileCvStructuredEducation, t.profileCvStructuredOtherSections]) {
|
|
await expect(page.getByRole('heading', { level: 3, name: label, exact: true })).toBeVisible();
|
|
}
|
|
for (const label of [t.careerProfileRemoveLanguage, t.careerProfileRemoveJob, t.careerProfileRemoveEducation, t.careerProfileRemoveOtherSection]) {
|
|
const remove = page.getByRole('button', { name: label.replace('{index}', '1'), exact: true });
|
|
await expect(remove).toHaveCount(1);
|
|
await remove.focus();
|
|
await expect(remove).toBeFocused();
|
|
}
|
|
expect(axe.violations).toEqual([]);
|
|
expect(await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`))).toEqual(before.canonical);
|
|
expect(await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`))).toEqual(before.history);
|
|
expect(writes).toEqual([]);
|
|
});
|
|
}
|