test(a11y): audit responsive routes and native keyboard boundaries
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# P5-15-02 — Native audit coverage checkpoint
|
||||||
|
|
||||||
|
This is test/evidence work only. No application, renderer, API, schema, provider, production configuration or deployment behavior changes. Source `cf847e9b256d9aec733abf3f37c42852c9dd2421`; development branch `codex/ui-redesign-phase4`.
|
||||||
|
|
||||||
|
## New independently runnable coverage
|
||||||
|
|
||||||
|
| Test file / case | Contract exercised | Evidence limitation |
|
||||||
|
|---|---|---|
|
||||||
|
| `responsive-accessibility-audit.spec.ts`: thirteen-area collection | Synthetic owned native SQLite/API entries, loaded content, responsive geometry, actual ARIA tree, all enabled axe rules plus explicit WCAG2.2 target-size, screenshots | Collection success is not accessibility acceptance; rule findings are retained rather than asserted away |
|
||||||
|
| Reduced-motion mobile drawer | Keyboard opening, expected initial focus, Escape return; records active element before/after axe and optional Tab recovery | Real keyboard automation, not a human reader |
|
||||||
|
| Command/workspace/CV keyboard | Command focus/empty result/Escape, Add dialog, manual-activation Job tabs, CV section selection, persisted keyboard reorder, preview entry/end-scroll/exit | Finite paths, not every field/menu or reader announcement |
|
||||||
|
| Landscape text-spacing collection | Six areas at844×390 with WCAG text-spacing overrides | Parent app styles only; verified document iframe geometry is untouched |
|
||||||
|
| CV source action | Actual keyboard activation of Open Career Profile, destination and native source content | Does not replace source/override revision safety tests |
|
||||||
|
| Upload/import keyboard | Focus and Enter/Space on three real controls; positive pointer comparison to the same native file picker | No file data submitted; no synthetic success response |
|
||||||
|
| Emulated touch | CV edit/preview, portrait/landscape CSS viewport changes, unchanged persisted CV, drawer Escape | Chromium touch emulation; not native hardware, orientation sensor or software keyboard |
|
||||||
|
| `browser-zoom-audit.spec.ts`: English/light and Bokmål/dark | Actual Chromium tab zoom100/200/400%; asserts browser zoom, DPR and unchanged pinch scale;13 areas plus text-spacing geometry | Geometry collection is not proof every enabled action is reachable/readable; other browsers/manual vision and hardware remain open |
|
||||||
|
|
||||||
|
Nine ordinary browser cases are added (seven responsive/keyboard cases, two zoom cases). They are not skipped, quarantined, expected-failing or retried. Soft assertions keep known failures failing while collecting additional evidence. No provider requests leave loopback.
|
||||||
|
|
||||||
|
## Focused execution before the coverage commit
|
||||||
|
|
||||||
|
[Native proof](evidence/native-issues.json) retains source-report hashes, test statuses and actual observations. The six-case development audit after separating the drawer from other workflows had **2 passes /4 failures**; one failure was a test assumption: arrows move Job-tab focus, Enter activates. The corrected existing manual-activation contract then passed with command/dialog/CV keyboard checks in the three-case controls rerun (**1 pass /2 failures**).
|
||||||
|
|
||||||
|
The two remaining controls failures are genuine: source action goes to unsupported `/career/profile` (404), and all six Enter/Space picker attempts fail while all three pointer comparisons succeed. No upload/import data was written. The reduced-motion drawer evidence shows focus still on the hidden background trigger before and after axe; immediate Escape leaves the drawer open. Tab plus Escape can close it, but focus lands on another background button rather than returning to the navigation trigger. Emulated CV mode/portrait-landscape/readback succeeds before the same drawer-return assertion fails.
|
||||||
|
|
||||||
|
Genuine zoom collection **2/2 passes**,102 geometry observations. Earlier zoom harness attempts are retained locally: explicit `deviceScaleFactor:undefined` is required with `viewport:null`; Correspondence has two actual H1s, so readiness now identifies its intended inbox heading exactly while still recording both headings. Those harness corrections do not close product heading/manual-review issues. Earlier metadata incorrectly implied tag-only axe selection: installed builder `.options()` replaces earlier `.withTags()` options. The committed collector correctly declares **all enabled rules**, including best practices; per-rule tags distinguish them from WCAG failures.
|
||||||
|
|
||||||
|
App and E2E TypeScript, lint and whitespace checks pass. Complete ordinary regression and final rule/viewport/57-group reconciliation are separate pending audit evidence at this commit; neither the previous134 passes nor collection success is used to claim this audit green. Production remains NO-GO. P5-13 remains deferred, unqualified and Production-withheld; no UI/qualification/Docker work.
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
{
|
||||||
|
"scope": "Native synthetic owned SQLite/browser evidence; no human AT/device acceptance",
|
||||||
|
"reports": [
|
||||||
|
{
|
||||||
|
"source": ".artifacts/p5-15-02/browser/audit-attempt3.json",
|
||||||
|
"sha256": "89af6297f09ad03072e5a617e0e4b0dd55cd8ebe1a8b310872525a8d1d81319d",
|
||||||
|
"stats": {
|
||||||
|
"startTime": "2026-10-10T18:25:41.491Z",
|
||||||
|
"duration": 197611.17,
|
||||||
|
"expected": 2,
|
||||||
|
"skipped": 0,
|
||||||
|
"unexpected": 4,
|
||||||
|
"flaky": 0
|
||||||
|
},
|
||||||
|
"tests": [
|
||||||
|
{
|
||||||
|
"name": "[P5-15-02] reduced-motion mobile drawer enters focus and Escape returns it",
|
||||||
|
"status": "failed",
|
||||||
|
"errorMessages": [
|
||||||
|
"Error: \u001b[2mexpect(\u001b[22m\u001b[31mlocator\u001b[39m\u001b[2m).\u001b[22mtoBeFocused\u001b[2m(\u001b[22m\u001b[2m)\u001b[22m failed\n\nLocator: getByRole('dialog', { name: 'Navigation', exact: true }).getByRole('button', { name: 'Dashboard', exact: true })\nExpected: focused\nReceived: inactive\nTimeout: 5000ms\n\nCall log:\n\u001b[2m - Expect \"soft toBeFocused\" with timeout 5000ms\u001b[22m\n\u001b[2m - waiting for getByRole('dialog', { name: 'Navigation', exact: true }).getByRole('button', { name: 'Dashboard', exact: true })\u001b[22m\n\u001b[2m 12 × locator resolved to <div tabindex=\"0\" role=\"button\" aria-label=\"Dashboard\" class=\"MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters css-j56oom\">…</div>\u001b[22m\n\u001b[2m - unexpected value \"inactive\"\u001b[22m\n\n\n 189 | await navigation.focus(); await navigation.press('Enter');\n 190 | const drawer = page.getByRole('dialog', { name: t.navigation, exact: true });\n> 191 | await expect.soft(drawer.getByRole('button', { name: t.dashboard, exact: true })).toBeFocused();\n | ^\n 192 | const focus = () => page.evaluate(() => ({ active: document.activeElement?.outerHTML.slice(0, 500), focusedInDialog: !!document.activeElement?.closest('[role=dialog]') }));\n 193 | const beforeScan = await focus();\n 194 | await collect(page, info, sample, 'navigation-open');\n at E:\\Storage\\Projects\\Active\\Job tracker\\job-tracker-ui\\e2e\\responsive-accessibility-audit.spec.ts:191:85",
|
||||||
|
"Error: \u001b[2mexpect(\u001b[22m\u001b[31mlocator\u001b[39m\u001b[2m).\u001b[22mtoBeFocused\u001b[2m(\u001b[22m\u001b[2m)\u001b[22m failed\n\nLocator: getByRole('button', { name: 'Open navigation', exact: true })\nExpected: focused\nTimeout: 5000ms\nError: element(s) not found\n\nCall log:\n\u001b[2m - Expect \"soft toBeFocused\" with timeout 5000ms\u001b[22m\n\u001b[2m - waiting for getByRole('button', { name: 'Open navigation', exact: true })\u001b[22m\n\n\n 194 | await collect(page, info, sample, 'navigation-open');\n 195 | const afterScan = await focus();\n> 196 | await page.keyboard.press('Escape'); await expect.soft(navigation).toBeFocused();\n | ^\n 197 | const afterEscape = await focus();\n 198 | const stayedOpen = await drawer.isVisible();\n 199 | if (stayedOpen) {\n at E:\\Storage\\Projects\\Active\\Job tracker\\job-tracker-ui\\e2e\\responsive-accessibility-audit.spec.ts:196:70"
|
||||||
|
],
|
||||||
|
"observations": [
|
||||||
|
{
|
||||||
|
"name": "drawer-focus-recovery",
|
||||||
|
"value": {
|
||||||
|
"beforeScan": {
|
||||||
|
"active": "<button class=\"MuiButtonBase-root Mui-focusVisible MuiIconButton-root MuiIconButton-colorSecondary MuiIconButton-edgeStart MuiIconButton-sizeSmall css-1bif13m\" tabindex=\"0\" type=\"button\" aria-label=\"Open navigation\" aria-haspopup=\"dialog\" aria-expanded=\"true\"><svg class=\"MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-1pi2lzz\" focusable=\"false\" aria-hidden=\"true\" viewBox=\"0 0 24 24\"><path d=\"M3 18h18v-2H3zm0-5h18v-2H3zm0-7v2h18V6z\"></path></svg><span class=\"MuiTouchRipple-root css-4mb1j7\"><span cla",
|
||||||
|
"focusedInDialog": false
|
||||||
|
},
|
||||||
|
"afterScan": {
|
||||||
|
"active": "<button class=\"MuiButtonBase-root Mui-focusVisible MuiIconButton-root MuiIconButton-colorSecondary MuiIconButton-edgeStart MuiIconButton-sizeSmall css-1bif13m\" tabindex=\"0\" type=\"button\" aria-label=\"Open navigation\" aria-haspopup=\"dialog\" aria-expanded=\"true\"><svg class=\"MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-1pi2lzz\" focusable=\"false\" aria-hidden=\"true\" viewBox=\"0 0 24 24\"><path d=\"M3 18h18v-2H3zm0-5h18v-2H3zm0-7v2h18V6z\"></path></svg><span class=\"MuiTouchRipple-root css-4mb1j7\"><span cla",
|
||||||
|
"focusedInDialog": false
|
||||||
|
},
|
||||||
|
"afterEscape": {
|
||||||
|
"active": "<button class=\"MuiButtonBase-root Mui-focusVisible MuiIconButton-root MuiIconButton-colorSecondary MuiIconButton-edgeStart MuiIconButton-sizeSmall css-1bif13m\" tabindex=\"0\" type=\"button\" aria-label=\"Open navigation\" aria-haspopup=\"dialog\" aria-expanded=\"true\"><svg class=\"MuiSvgIcon-root MuiSvgIcon-fontSizeSmall css-1pi2lzz\" focusable=\"false\" aria-hidden=\"true\" viewBox=\"0 0 24 24\"><path d=\"M3 18h18v-2H3zm0-5h18v-2H3zm0-7v2h18V6z\"></path></svg><span class=\"MuiTouchRipple-root css-4mb1j7\"><span cla",
|
||||||
|
"focusedInDialog": false
|
||||||
|
},
|
||||||
|
"stayedOpen": true,
|
||||||
|
"afterTabAndEscape": {
|
||||||
|
"active": "<button class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeSmall MuiButton-containedSizeSmall MuiButton-colorPrimary Mui-focusVisible MuiButtonGroup-grouped MuiButtonGroup-groupedHorizontal MuiButtonGroup-groupedOutlined MuiButtonGroup-groupedOutlinedHorizontal MuiButtonGroup-groupedOutlinedPrimary MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeSmall MuiButton-containedSizeSmall MuiButton-colorPrimary MuiButtonGroup-",
|
||||||
|
"focusedInDialog": false
|
||||||
|
},
|
||||||
|
"drawerStillVisible": false,
|
||||||
|
"humanScreenReader": "NOT VERIFIED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "[P5-15-02 C06 C07] CV source action reaches the existing Career Profile workflow",
|
||||||
|
"status": "failed",
|
||||||
|
"errorMessages": [
|
||||||
|
"Error: \u001b[2mexpect(\u001b[22m\u001b[31mpage\u001b[39m\u001b[2m).\u001b[22mtoHaveURL\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m)\u001b[22m failed\n\nExpected: predicate to succeed\nReceived: \u001b[31m\"http://localhost:3300/career/profile\"\u001b[39m\nTimeout: 5000ms\n\n\n 272 | await expect(page.getByRole('main')).toBeVisible();\n 273 | await info.attach('cv-source-action-destination', { body: JSON.stringify({ href, destination: page.url(), expected: '/career or /career?section=profile; established canonical source workflow', observedText: await page.getByRole('main').innerText() }), contentType: 'application/json' });\n> 274 | await expect(page).toHaveURL(url => url.pathname === '/career');\n | ^\n 275 | await expect(page.getByLabel(t.profileCvContactFullName, { exact: true })).toHaveValue('Åse Revisjonseksempel');\n 276 | });\n 277 |\n at E:\\Storage\\Projects\\Active\\Job tracker\\job-tracker-ui\\e2e\\responsive-accessibility-audit.spec.ts:274:22"
|
||||||
|
],
|
||||||
|
"observations": [
|
||||||
|
{
|
||||||
|
"name": "cv-source-action-destination",
|
||||||
|
"value": {
|
||||||
|
"href": "/career/profile",
|
||||||
|
"destination": "http://localhost:3300/career/profile",
|
||||||
|
"expected": "/career or /career?section=profile; established canonical source workflow",
|
||||||
|
"observedText": "Home\n/\nCareer Workspace\n/\nCV Builder\nCV Builder\nThis CV only\nVersion 1\nSaved\nDownload PDF\nCV editing controls\nContent\nTemplate\nCustomize\nAI Tools\nHistory\nGo to section\n\nGo to section\nGo To Preview\nCareer Profile is your source. Editing wording, order or inclusion here changes only this CV.\nOpen Career Profile\nHeadline override\nHeadline override\n\nBlank uses your Career Profile headline. Changes here affect only this CV.\n\nSections\nDrag to reorder, or use the arrows. Omitted content stays editable.\nAdd Content\nLive preview\nFit width · 68%\n1-page CV\nGo To Editor\nUse Page Up/Down to scroll. Tab moves into or out of the preview.\nPage 1 of 1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "[P5-15-02] emulated taps preserve CV modes and navigation across portrait/landscape viewport changes",
|
||||||
|
"status": "failed",
|
||||||
|
"errorMessages": [
|
||||||
|
"Error: \u001b[2mexpect(\u001b[22m\u001b[31mlocator\u001b[39m\u001b[2m).\u001b[22mtoBeFocused\u001b[2m(\u001b[22m\u001b[2m)\u001b[22m failed\n\nLocator: getByRole('button', { name: 'Åpne navigasjon', exact: true })\nExpected: focused\nTimeout: 5000ms\nError: element(s) not found\n\nCall log:\n\u001b[2m - Expect \"soft toBeFocused\" with timeout 5000ms\u001b[22m\n\u001b[2m - waiting for getByRole('button', { name: 'Åpne navigasjon', exact: true })\u001b[22m\n\n\n 299 | outcomes.push({ route: path, label, key: 'pointer comparison', opened: !!pointerChooser });\n 300 | if (pointerChooser) await pointerChooser.setFiles([]);\n> 301 | expect.soft(!!pointerChooser, `${label} pointer comparison opens the same picker`).toBe(true);\n | ^\n 302 | }\n 303 | }\n 304 | await info.attach('native-keyboard-file-chooser', { body: JSON.stringify({ outcomes, fileDataWritten: false }), contentType: 'application/json' });\n at E:\\Storage\\Projects\\Active\\Job tracker\\job-tracker-ui\\e2e\\responsive-accessibility-audit.spec.ts:301:72"
|
||||||
|
],
|
||||||
|
"observations": [
|
||||||
|
{
|
||||||
|
"name": "emulated-touch-boundary",
|
||||||
|
"value": {
|
||||||
|
"hasTouch": true,
|
||||||
|
"input": "Playwright Chromium touch tap",
|
||||||
|
"portraitLandscape": "CSS viewport changes, not physical orientation sensor",
|
||||||
|
"physicalTouch": "NOT VERIFIED",
|
||||||
|
"nativeTouchDrag": "NOT VERIFIED",
|
||||||
|
"softwareKeyboard": "NOT VERIFIED",
|
||||||
|
"cvUnchanged": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": ".artifacts/p5-15-02/browser/audit-controls.json",
|
||||||
|
"sha256": "cc1e74867b5103ff214fd1355c3029a5f9027265bd978bacd5382574b7decb06",
|
||||||
|
"stats": {
|
||||||
|
"startTime": "2026-10-10T18:29:22.564Z",
|
||||||
|
"duration": 86727.949,
|
||||||
|
"expected": 1,
|
||||||
|
"skipped": 0,
|
||||||
|
"unexpected": 2,
|
||||||
|
"flaky": 0
|
||||||
|
},
|
||||||
|
"tests": [
|
||||||
|
{
|
||||||
|
"name": "[P5-15-02] keyboard command, dialog, section and document controls keep deliberate focus contracts",
|
||||||
|
"status": "passed",
|
||||||
|
"errorMessages": [],
|
||||||
|
"observations": [
|
||||||
|
{
|
||||||
|
"name": "native-keyboard-contracts",
|
||||||
|
"value": {
|
||||||
|
"commandEntryAndEscapeReturn": true,
|
||||||
|
"commandWorkflowDialog": true,
|
||||||
|
"workspaceArrowNavigation": true,
|
||||||
|
"cvKeyboardOrderPersisted": true,
|
||||||
|
"previewEntryScrollExit": true,
|
||||||
|
"evidence": "preceding hard assertions completed",
|
||||||
|
"humanScreenReader": "NOT VERIFIED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "[P5-15-02 C06 C07] CV source action reaches the existing Career Profile workflow",
|
||||||
|
"status": "failed",
|
||||||
|
"errorMessages": [
|
||||||
|
"Error: \u001b[2mexpect(\u001b[22m\u001b[31mpage\u001b[39m\u001b[2m).\u001b[22mtoHaveURL\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m)\u001b[22m failed\n\nExpected: predicate to succeed\nReceived: \u001b[31m\"http://localhost:3300/career/profile\"\u001b[39m\nTimeout: 5000ms\n\n\n 276 | await expect(page.getByRole('main')).toBeVisible();\n 277 | await info.attach('cv-source-action-destination', { body: JSON.stringify({ href, destination: page.url(), expected: '/career or /career?section=profile; established canonical source workflow', observedText: await page.getByRole('main').innerText() }), contentType: 'application/json' });\n> 278 | await expect(page).toHaveURL(url => url.pathname === '/career');\n | ^\n 279 | await expect(page.getByLabel(t.profileCvContactFullName, { exact: true })).toHaveValue('Åse Revisjonseksempel');\n 280 | });\n 281 |\n at E:\\Storage\\Projects\\Active\\Job tracker\\job-tracker-ui\\e2e\\responsive-accessibility-audit.spec.ts:278:22"
|
||||||
|
],
|
||||||
|
"observations": [
|
||||||
|
{
|
||||||
|
"name": "cv-source-action-destination",
|
||||||
|
"value": {
|
||||||
|
"href": "/career/profile",
|
||||||
|
"destination": "http://localhost:3300/career/profile",
|
||||||
|
"expected": "/career or /career?section=profile; established canonical source workflow",
|
||||||
|
"observedText": "Home\n/\nCareer Workspace\nCareer Workspace\n\n404\n\nPage not found\n\nThe page you were looking for does not exist or may have moved.\n\nGo Home\nGo Back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "[P5-15-02 W08 J10] keyboard upload and import controls open the native file chooser",
|
||||||
|
"status": "failed",
|
||||||
|
"errorMessages": [
|
||||||
|
"Error: Upload Enter opens the native picker\n\n\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32mtrue\u001b[39m\nReceived: \u001b[31mfalse\u001b[39m\n\n 298 | outcomes.push({ route: path, label, key, opened: !!chooser });\n 299 | if (chooser) await chooser.setFiles([]);\n> 300 | expect.soft(!!chooser, `${label} ${key} opens the native picker`).toBe(true);\n | ^\n 301 | }\n 302 | const [pointerChooser] = await Promise.all([page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null), control.click()]);\n 303 | outcomes.push({ route: path, label, key: 'pointer comparison', opened: !!pointerChooser });\n at E:\\Storage\\Projects\\Active\\Job tracker\\job-tracker-ui\\e2e\\responsive-accessibility-audit.spec.ts:300:75",
|
||||||
|
"Error: Upload Space opens the native picker\n\n\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32mtrue\u001b[39m\nReceived: \u001b[31mfalse\u001b[39m\n\n 298 | outcomes.push({ route: path, label, key, opened: !!chooser });\n 299 | if (chooser) await chooser.setFiles([]);\n> 300 | expect.soft(!!chooser, `${label} ${key} opens the native picker`).toBe(true);\n | ^\n 301 | }\n 302 | const [pointerChooser] = await Promise.all([page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null), control.click()]);\n 303 | outcomes.push({ route: path, label, key: 'pointer comparison', opened: !!pointerChooser });\n at E:\\Storage\\Projects\\Active\\Job tracker\\job-tracker-ui\\e2e\\responsive-accessibility-audit.spec.ts:300:75",
|
||||||
|
"Error: Import JSON Enter opens the native picker\n\n\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32mtrue\u001b[39m\nReceived: \u001b[31mfalse\u001b[39m\n\n 298 | outcomes.push({ route: path, label, key, opened: !!chooser });\n 299 | if (chooser) await chooser.setFiles([]);\n> 300 | expect.soft(!!chooser, `${label} ${key} opens the native picker`).toBe(true);\n | ^\n 301 | }\n 302 | const [pointerChooser] = await Promise.all([page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null), control.click()]);\n 303 | outcomes.push({ route: path, label, key: 'pointer comparison', opened: !!pointerChooser });\n at E:\\Storage\\Projects\\Active\\Job tracker\\job-tracker-ui\\e2e\\responsive-accessibility-audit.spec.ts:300:75",
|
||||||
|
"Error: Import JSON Space opens the native picker\n\n\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32mtrue\u001b[39m\nReceived: \u001b[31mfalse\u001b[39m\n\n 298 | outcomes.push({ route: path, label, key, opened: !!chooser });\n 299 | if (chooser) await chooser.setFiles([]);\n> 300 | expect.soft(!!chooser, `${label} ${key} opens the native picker`).toBe(true);\n | ^\n 301 | }\n 302 | const [pointerChooser] = await Promise.all([page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null), control.click()]);\n 303 | outcomes.push({ route: path, label, key: 'pointer comparison', opened: !!pointerChooser });\n at E:\\Storage\\Projects\\Active\\Job tracker\\job-tracker-ui\\e2e\\responsive-accessibility-audit.spec.ts:300:75",
|
||||||
|
"Error: Convert CSV to Import JSON Enter opens the native picker\n\n\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32mtrue\u001b[39m\nReceived: \u001b[31mfalse\u001b[39m\n\n 298 | outcomes.push({ route: path, label, key, opened: !!chooser });\n 299 | if (chooser) await chooser.setFiles([]);\n> 300 | expect.soft(!!chooser, `${label} ${key} opens the native picker`).toBe(true);\n | ^\n 301 | }\n 302 | const [pointerChooser] = await Promise.all([page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null), control.click()]);\n 303 | outcomes.push({ route: path, label, key: 'pointer comparison', opened: !!pointerChooser });\n at E:\\Storage\\Projects\\Active\\Job tracker\\job-tracker-ui\\e2e\\responsive-accessibility-audit.spec.ts:300:75",
|
||||||
|
"Error: Convert CSV to Import JSON Space opens the native picker\n\n\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32mtrue\u001b[39m\nReceived: \u001b[31mfalse\u001b[39m\n\n 298 | outcomes.push({ route: path, label, key, opened: !!chooser });\n 299 | if (chooser) await chooser.setFiles([]);\n> 300 | expect.soft(!!chooser, `${label} ${key} opens the native picker`).toBe(true);\n | ^\n 301 | }\n 302 | const [pointerChooser] = await Promise.all([page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null), control.click()]);\n 303 | outcomes.push({ route: path, label, key: 'pointer comparison', opened: !!pointerChooser });\n at E:\\Storage\\Projects\\Active\\Job tracker\\job-tracker-ui\\e2e\\responsive-accessibility-audit.spec.ts:300:75"
|
||||||
|
],
|
||||||
|
"observations": [
|
||||||
|
{
|
||||||
|
"name": "native-keyboard-file-chooser",
|
||||||
|
"value": {
|
||||||
|
"outcomes": [
|
||||||
|
{
|
||||||
|
"route": "/jobs/13?section=documents",
|
||||||
|
"label": "Upload",
|
||||||
|
"key": "Enter",
|
||||||
|
"opened": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/jobs/13?section=documents",
|
||||||
|
"label": "Upload",
|
||||||
|
"key": "Space",
|
||||||
|
"opened": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/jobs/13?section=documents",
|
||||||
|
"label": "Upload",
|
||||||
|
"key": "pointer comparison",
|
||||||
|
"opened": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/settings",
|
||||||
|
"label": "Import JSON",
|
||||||
|
"key": "Enter",
|
||||||
|
"opened": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/settings",
|
||||||
|
"label": "Import JSON",
|
||||||
|
"key": "Space",
|
||||||
|
"opened": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/settings",
|
||||||
|
"label": "Import JSON",
|
||||||
|
"key": "pointer comparison",
|
||||||
|
"opened": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/settings",
|
||||||
|
"label": "Convert CSV to Import JSON",
|
||||||
|
"key": "Enter",
|
||||||
|
"opened": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/settings",
|
||||||
|
"label": "Convert CSV to Import JSON",
|
||||||
|
"key": "Space",
|
||||||
|
"opened": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/settings",
|
||||||
|
"label": "Convert CSV to Import JSON",
|
||||||
|
"key": "pointer comparison",
|
||||||
|
"opened": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fileDataWritten": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": ".artifacts/p5-15-02/browser/zoom.json",
|
||||||
|
"sha256": "ec2688b919aac5eade1613c94405d889dacd649cfa932e83f9faea748edc4e1a",
|
||||||
|
"stats": {
|
||||||
|
"startTime": "2026-10-10T18:22:11.787Z",
|
||||||
|
"duration": 43911.678,
|
||||||
|
"expected": 2,
|
||||||
|
"skipped": 0,
|
||||||
|
"unexpected": 0,
|
||||||
|
"flaky": 0
|
||||||
|
},
|
||||||
|
"tests": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { translations } from '../src/i18n/translations';
|
||||||
|
|
||||||
|
const apiUrl = 'http://localhost:5302/api';
|
||||||
|
const password = 'E2ePassword123!';
|
||||||
|
|
||||||
|
async function login(api: APIRequestContext, email: string) {
|
||||||
|
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||||
|
const meResponse = await api.get(`${apiUrl}/auth/me`);
|
||||||
|
expect(meResponse.status()).toBe(200);
|
||||||
|
const me = await meResponse.json();
|
||||||
|
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
|
||||||
|
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function geometry(page: Page) {
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => resolve())));
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const main = document.querySelector('main');
|
||||||
|
const controls = Array.from((main ?? document.body).querySelectorAll('button,input,select,textarea,a[href],[role="button"],[role="tab"]'));
|
||||||
|
const shown = controls.filter(element => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0 && getComputedStyle(element).visibility !== 'hidden';
|
||||||
|
});
|
||||||
|
const bounds = shown.map(element => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
name: element.getAttribute('aria-label') ?? element.textContent?.trim().slice(0, 100) ?? '',
|
||||||
|
tag: element.tagName, role: element.getAttribute('role'),
|
||||||
|
x: rect.x, y: rect.y, width: rect.width, height: rect.height,
|
||||||
|
outsideHorizontalViewport: rect.left < -1 || rect.right > innerWidth + 1,
|
||||||
|
intentionalScroller: !!element.closest('[data-cv-page],[role="table"],table,[data-kanban-card-id]'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
innerWidth, innerHeight, outerWidth, outerHeight, devicePixelRatio,
|
||||||
|
visualScale: visualViewport?.scale,
|
||||||
|
pageHorizontalOverflow: document.documentElement.scrollWidth - innerWidth,
|
||||||
|
mainHorizontalOverflow: main ? main.scrollWidth - main.clientWidth : null,
|
||||||
|
headings: Array.from(document.querySelectorAll('h1,h2,h3')).map(element => ({ level: element.tagName, text: element.textContent?.trim() })),
|
||||||
|
controls: bounds,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [language, mode] of [['en', 'light'], ['nb', 'dark']] as const) {
|
||||||
|
test(`[P5-15-02 E08 C06] actual browser zoom ${language}/${mode}: collect 100/200/400 percent reflow without changing document geometry`, async ({ playwright }, info) => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
const extension = info.outputPath('local-zoom-extension');
|
||||||
|
await fs.mkdir(extension, { recursive: true });
|
||||||
|
await fs.writeFile(`${extension}/manifest.json`, JSON.stringify({
|
||||||
|
manifest_version: 3, name: 'Local audit browser zoom', version: '1.0',
|
||||||
|
permissions: ['tabs'], host_permissions: ['http://localhost:3300/*'],
|
||||||
|
background: { service_worker: 'background.js' },
|
||||||
|
}));
|
||||||
|
await fs.writeFile(`${extension}/background.js`, 'globalThis.auditZoomReady = true;');
|
||||||
|
const context = await playwright.chromium.launchPersistentContext(info.outputPath('local-browser-profile'), {
|
||||||
|
channel: 'chromium', headless: true, viewport: null, deviceScaleFactor: undefined,
|
||||||
|
args: ['--window-size=1280,900', `--disable-extensions-except=${extension}`, `--load-extension=${extension}`],
|
||||||
|
});
|
||||||
|
const admin = await playwright.request.newContext();
|
||||||
|
const records: unknown[] = [];
|
||||||
|
try {
|
||||||
|
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 context.addInitScript(({ language, mode }) => {
|
||||||
|
localStorage.setItem('uiLanguage', language);
|
||||||
|
localStorage.setItem('jobtracker.themeMode', mode);
|
||||||
|
}, { language, mode });
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
await page.goto('http://localhost:3300/login');
|
||||||
|
await expect(page.getByRole('heading', { name: translations[language].signInTitle, exact: true })).toBeVisible();
|
||||||
|
const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker');
|
||||||
|
const zoom = async (factor: number) => {
|
||||||
|
const result = await worker.evaluate(async factor => {
|
||||||
|
// The extension changes real tab zoom, rather than emulating a narrow viewport or pinch.
|
||||||
|
const api = (globalThis as unknown as { chrome: { tabs: {
|
||||||
|
query: (query: object) => Promise<Array<{ id: number; url?: string }>>;
|
||||||
|
setZoom: (id: number, factor: number) => Promise<void>;
|
||||||
|
getZoom: (id: number) => Promise<number>;
|
||||||
|
} } }).chrome.tabs;
|
||||||
|
const tab = (await api.query({})).find(tab => tab.url?.startsWith('http://localhost:3300/'));
|
||||||
|
if (!tab) throw new Error('Local application tab unavailable');
|
||||||
|
await api.setZoom(tab.id, factor);
|
||||||
|
return api.getZoom(tab.id);
|
||||||
|
}, factor);
|
||||||
|
expect(result).toBeCloseTo(factor, 5);
|
||||||
|
await expect.poll(() => page.evaluate(() => devicePixelRatio)).toBeCloseTo(factor, 2);
|
||||||
|
expect(await page.evaluate(() => visualViewport?.scale)).toBe(1);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
for (const factor of [1, 2, 4]) {
|
||||||
|
await zoom(factor);
|
||||||
|
records.push({ area: 'Authentication', route: '/login', language, mode, zoom: factor, geometry: await geometry(page) });
|
||||||
|
}
|
||||||
|
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||||
|
const email = `zoom-${language}-${Date.now()}@example.test`;
|
||||||
|
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: ['Admin'] } })).status()).toBe(200);
|
||||||
|
const headers = await login(page.request, email);
|
||||||
|
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||||
|
const profile = { version: '1', contact: { fullName: 'Åse Zoom Fixture', headline: 'Documented application workflows', location: 'Oslo' }, summary: ['Supported source summary.'], skills: ['C#', 'React'], jobs: [{ id: 'zoom-role', title: 'Engineer', company: 'Synthetic company', bullets: ['Built and maintained internal web applications.'] }] };
|
||||||
|
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
|
||||||
|
const source = await (await page.request.get(`${apiUrl}/career/profile`)).json();
|
||||||
|
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Representative Norwegian company — æøå and long title' } });
|
||||||
|
expect(company.status()).toBe(201);
|
||||||
|
const companyId = (await company.json()).id;
|
||||||
|
let jobId = 0;
|
||||||
|
for (const status of ['Saved', 'Applied', 'Interview', 'Faglig vurdering']) {
|
||||||
|
const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: `Long application title ${status}`, status } });
|
||||||
|
expect(job.status()).toBe(201);
|
||||||
|
jobId = (await job.json()).id;
|
||||||
|
}
|
||||||
|
const cvResponse = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Owned zoom CV', settings: { themeId: 'minimal', language } } });
|
||||||
|
expect(cvResponse.status()).toBe(200);
|
||||||
|
const cv = await cvResponse.json();
|
||||||
|
const routes = [
|
||||||
|
['Shell', '/dashboard', '/jobapplications/stats'], ['Jobs', '/jobs', '/jobapplications'], ['Board', '/jobs?view=board', '/jobapplications/board'],
|
||||||
|
['Dashboard', '/dashboard', '/jobapplications/stats'], ['Analytics', '/analytics?months=6', '/jobapplications/analytics'],
|
||||||
|
['Job workspace', `/jobs/${jobId}`, `/jobapplications/${jobId}/workspace`], ['Documents', `/jobs/${jobId}?section=documents`, `/jobapplications/${jobId}/workspace`],
|
||||||
|
['Career Profile', '/career?section=profile', '/career/profile'], ['CV Builder', `/career/builder/${cv.id}`, `/cv/variants/${cv.id}`],
|
||||||
|
['Settings', '/settings', '/notification-settings'], ['Administration', '/admin/users', '/users'], ['Correspondence', '/correspondence', '/correspondence/page'],
|
||||||
|
];
|
||||||
|
for (const [area, route, endpoint] of routes) {
|
||||||
|
const loaded = page.waitForResponse(response => new URL(response.url()).pathname === `/api${endpoint}` && response.request().method() === 'GET');
|
||||||
|
await page.goto(`http://localhost:3300${route}`);
|
||||||
|
expect((await loaded).status()).toBe(200);
|
||||||
|
await expect(page.getByRole('main')).toBeVisible();
|
||||||
|
const heading = area === 'Correspondence'
|
||||||
|
? page.getByRole('heading', { level: 1, name: translations[language].correspondenceInboxTitle, exact: true })
|
||||||
|
: page.getByRole('heading', { level: 1 });
|
||||||
|
await expect(heading).toBeVisible();
|
||||||
|
if (area === 'CV Builder') await expect(page.getByLabel(translations[language].cvEditorName, { exact: true })).toHaveValue(cv.name);
|
||||||
|
for (const factor of [1, 2, 4]) {
|
||||||
|
await zoom(factor);
|
||||||
|
const metrics = await geometry(page);
|
||||||
|
records.push({ area, route, language, mode, zoom: factor, geometry: metrics });
|
||||||
|
if (factor === 4 && ['Jobs', 'CV Builder', 'Settings'].includes(area)) {
|
||||||
|
const path = info.outputPath(`zoom-${language}-${mode}-${area.replaceAll(' ', '-').toLowerCase()}-400.png`);
|
||||||
|
await page.screenshot({ path, animations: 'disabled' });
|
||||||
|
await info.attach(`zoom-${area}-400`, { path, contentType: 'image/png' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await zoom(1);
|
||||||
|
// User stylesheet simulation for WCAG 1.4.12, explicitly separate from browser text-size preferences.
|
||||||
|
await page.addStyleTag({ content: '* { line-height: 1.5 !important; letter-spacing: .12em !important; word-spacing: .16em !important; } p { margin-bottom: 2em !important; }' });
|
||||||
|
records.push({ area, route, language, mode, zoom: 1, textSpacing: 'WCAG 1.4.12 user stylesheet simulation, iframe document unchanged', geometry: await geometry(page) });
|
||||||
|
}
|
||||||
|
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(source);
|
||||||
|
expect(await (await page.request.get(`${apiUrl}/cv/variants/${cv.id}`)).json()).toEqual(cv);
|
||||||
|
} finally {
|
||||||
|
await info.attach('actual-browser-zoom-observations', { body: JSON.stringify({ method: 'chrome.tabs.setZoom; viewport:null; dpr and actual zoom asserted; not human acceptance', browser: context.browser()?.version(), records }, null, 2), contentType: 'application/json' });
|
||||||
|
await admin.dispose();
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
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 Language = 'en' | 'nb';
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
type Area = 'authentication' | 'shell' | 'commands' | 'jobs' | 'board' | 'dashboard' | 'analytics' | 'workspace' | 'profile' | 'cv' | 'documents' | 'settings' | 'admin';
|
||||||
|
type Owner = { headers: Record<string, string>; email: string; jobId: number; cvId: number };
|
||||||
|
type Sample = { area: Area; path: string; width: number; height?: number; language: Language; theme: Theme; state?: string };
|
||||||
|
|
||||||
|
async function login(api: APIRequestContext, email: string) {
|
||||||
|
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||||
|
const me = await api.get(`${apiUrl}/auth/me`);
|
||||||
|
expect(me.status()).toBe(200);
|
||||||
|
return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value,
|
||||||
|
'X-Session-Context': (await me.json()).sessionContext as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 adminHeaders = await login(admin, 'e2e@example.test');
|
||||||
|
const email = `responsive-audit-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||||
|
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: ['Admin'] } })).status()).toBe(200);
|
||||||
|
const headers = 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'); });
|
||||||
|
const sentence = 'Documented accessible services and reliable user workflows. Dokumenterte tilgjengelige tjenester og pålitelige arbeidsflyter. ';
|
||||||
|
const profile = { version: '1', contact: { fullName: 'Åse Revisjonseksempel', headline: 'Recorded platform developer', location: 'Oslo, Norge' },
|
||||||
|
summary: [sentence], jobs: Array.from({ length: 3 }, (_, index) => ({ id: `audit-role-${index}`, title: `Platform developer ${index + 1}`, company: 'Nordlys Audit AS', startDate: '2020-01', endDate: '2025-12', bullets: Array.from({ length: 4 }, () => sentence.repeat(3)) })),
|
||||||
|
education: [{ id: 'audit-education', qualification: 'Computer science', institution: 'Fixture University', startDate: '2016-08', endDate: '2019-06' }],
|
||||||
|
projects: [{ id: 'audit-project', name: 'Recorded accessibility project', bullets: [sentence] }], skills: ['C#', 'React', 'SQL'], languages: [{ language: 'Norsk', proficiency: 'Fluent' }] };
|
||||||
|
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
|
||||||
|
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys samfunnskritiske plattformer og inkluderende digitale tjenester æøå' } });
|
||||||
|
expect(company.status()).toBe(201);
|
||||||
|
const companyId = (await company.json()).id;
|
||||||
|
let jobId = 0;
|
||||||
|
for (const [index, status] of ['Saved', 'Applied', 'Interview', 'Offer', 'Rejected', 'Faglig vurdering'].entries()) {
|
||||||
|
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: `Audit platform engineer ${index + 1} — langsiktig utvikling og inkluderende tjenester`, status,
|
||||||
|
description: 'Recorded C# and React service requirements.', dateApplied: status === 'Saved' ? null : '2026-09-15T12:00:00Z', location: 'Oslo, Norge', notes: 'Recorded personal notes, separate from application content.' } });
|
||||||
|
expect(created.status()).toBe(201);
|
||||||
|
if (index === 0) jobId = (await created.json()).id;
|
||||||
|
}
|
||||||
|
const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Audit CV — dokumenterte ferdigheter og erfaring æøå', settings: { themeId: 'minimal', language: 'en', headline: 'CV-specific platform headline',
|
||||||
|
sections: ['summary', 'experience', 'education', 'projects', 'skills', 'certifications', 'languages', 'awards', 'publications', 'organisations', 'interests', 'references'].map((key, index) => ({ key, hidden: index >= 5 })) } } });
|
||||||
|
expect(cv.status()).toBe(200);
|
||||||
|
const cvId = (await cv.json()).id;
|
||||||
|
expect((await page.request.post(`${apiUrl}/attachments`, { headers, multipart: { jobId: String(jobId), files: { name: 'Recorded application evidence — æøå.txt', mimeType: 'text/plain', buffer: Buffer.from('Synthetic recorded evidence — æøå') } } })).ok()).toBe(true);
|
||||||
|
await provide({ headers, email, jobId, cvId });
|
||||||
|
} finally { await admin.dispose(); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function enter(page: Page, owner: Owner, sample: Sample) {
|
||||||
|
const t = translations[sample.language];
|
||||||
|
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 ?? 900 });
|
||||||
|
if (!page.url().startsWith('http://localhost:3300')) { 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' });
|
||||||
|
const endpoint = sample.area === 'cv' ? `/cv/variants/${owner.cvId}` : sample.area === 'documents' ? `/attachments/${owner.jobId}`
|
||||||
|
: sample.area === 'workspace' ? `/jobapplications/${owner.jobId}/workspace` : sample.area === 'board' ? '/jobapplications/board'
|
||||||
|
: sample.area === 'profile' ? '/career/profile' : sample.area === 'admin' ? '/users'
|
||||||
|
: ['dashboard', 'analytics'].includes(sample.area) ? '/jobapplications/stats' : sample.area === 'settings' ? '/notification-settings' : '/jobapplications';
|
||||||
|
const loaded = page.waitForResponse(response => new URL(response.url()).pathname === `/api${endpoint}` && response.request().method() === 'GET');
|
||||||
|
await page.goto(sample.path);
|
||||||
|
expect((await loaded).status(), `${sample.area} native read`).toBe(200);
|
||||||
|
await expect(page.getByRole('main')).toBeVisible();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
|
||||||
|
if (sample.area === 'cv') {
|
||||||
|
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue('Audit CV — dokumenterte ferdigheter og erfaring æøå');
|
||||||
|
await expect(page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true })).toBeVisible();
|
||||||
|
await expect.poll(() => page.getByTitle(t.cvEditorPreviewTitle, { exact: true }).evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true');
|
||||||
|
} else if (sample.area === 'documents') await expect(page.getByText('Recorded application evidence — æøå.txt', { exact: true })).toBeVisible();
|
||||||
|
else if (sample.area === 'workspace') await expect(page.getByRole('heading', { name: /^Audit platform engineer 1/ })).toBeVisible();
|
||||||
|
else if (sample.area === 'profile') await expect(page.getByLabel(t.profileCvContactFullName, { exact: true })).toHaveValue('Åse Revisjonseksempel');
|
||||||
|
else if (sample.area === 'board') await expect(page.locator('[data-kanban-card-id]')).toHaveCount(6);
|
||||||
|
else if (sample.area === 'commands') {
|
||||||
|
await page.getByRole('button', { name: new RegExp(t.quickSearch) }).press('Enter');
|
||||||
|
await expect(page.getByRole('dialog', { name: t.quickSearch }).getByRole('textbox')).toBeFocused();
|
||||||
|
} else await expect(page.getByRole('heading', { level: 1, name: sample.area === 'admin' ? t.users : sample.area === 'settings' ? t.settings : sample.area === 'dashboard' ? t.dashboard : sample.area === 'analytics' ? t.analytics : t.jobApplications, exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collect(page: Page, info: TestInfo, sample: Sample, detail = '') {
|
||||||
|
await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); });
|
||||||
|
const state = `${sample.area}-${sample.width}x${sample.height ?? 900}-${sample.language}-${sample.theme}${detail ? `-${detail}` : ''}`;
|
||||||
|
const geometry = await page.evaluate(() => {
|
||||||
|
const label = (element: Element) => element.getAttribute('aria-label') || element.getAttribute('title') || element.textContent?.trim().slice(0, 100) || (element as HTMLInputElement).labels?.[0]?.textContent?.trim() || '';
|
||||||
|
const controls = Array.from(document.querySelectorAll('main button,main a[href],main input:not([type=hidden]),main select,main textarea,main [role=button],main [role=tab],header button,[role=dialog] button,[role=dialog] input'))
|
||||||
|
.filter(element => { const style = getComputedStyle(element); return element.getClientRects().length > 0 && style.visibility !== 'hidden'; })
|
||||||
|
.map(element => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||||
|
const centerInViewport = center.x >= 0 && center.x < innerWidth && center.y >= 0 && center.y < innerHeight;
|
||||||
|
const hit = centerInViewport ? document.elementFromPoint(center.x, center.y) : null;
|
||||||
|
const clippedBy: { role: string | null; label: string; overflowX: string; overflowY: string; horizontal: boolean; vertical: boolean }[] = [];
|
||||||
|
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
||||||
|
const style = getComputedStyle(parent); const bounds = parent.getBoundingClientRect();
|
||||||
|
const horizontal = ['auto', 'scroll', 'hidden', 'clip'].includes(style.overflowX) && (rect.left < bounds.left - 1 || rect.right > bounds.right + 1);
|
||||||
|
const vertical = ['auto', 'scroll', 'hidden', 'clip'].includes(style.overflowY) && (rect.top < bounds.top - 1 || rect.bottom > bounds.bottom + 1);
|
||||||
|
if (horizontal || vertical) clippedBy.push({ role: parent.getAttribute('role'), label: label(parent), overflowX: style.overflowX, overflowY: style.overflowY, horizontal, vertical });
|
||||||
|
}
|
||||||
|
return { tag: element.tagName.toLowerCase(), role: element.getAttribute('role'), labelApproximation: label(element), labelSource: 'DOM approximation; actual names are in the separate ARIA snapshot', disabled: element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true',
|
||||||
|
bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, horizontalOutsideViewport: rect.left < -1 || rect.right > innerWidth + 1,
|
||||||
|
centerObstructionChecked: centerInViewport, obstructedAtCenter: centerInViewport && !element.contains(hit) && !hit?.contains(element), obstruction: hit ? { tag: hit.tagName.toLowerCase(), role: hit.getAttribute('role'), labelApproximation: label(hit) } : null, clippedBy,
|
||||||
|
targetBelow24CssPx: rect.width < 24 || rect.height < 24 };
|
||||||
|
});
|
||||||
|
const ariaReferences = Array.from(document.querySelectorAll('[role=tab],[role=tabpanel],[role=dialog],[aria-labelledby],[aria-describedby],[aria-controls]')).map(element => ({ role: element.getAttribute('role'), id: element.id,
|
||||||
|
label: label(element), references: ['aria-labelledby', 'aria-describedby', 'aria-controls'].map(attribute => ({ attribute, value: element.getAttribute(attribute),
|
||||||
|
missingIds: (element.getAttribute(attribute) ?? '').split(/\s+/).filter(id => id && !document.getElementById(id)) })) }));
|
||||||
|
return { viewport: { width: innerWidth, height: innerHeight, devicePixelRatio }, pageHorizontalOverflow: Math.max(0, document.documentElement.scrollWidth - innerWidth), scroll: { x: scrollX, y: scrollY }, ariaReferences,
|
||||||
|
focused: { tag: document.activeElement?.tagName, label: document.activeElement ? label(document.activeElement) : null }, coarsePointer: matchMedia('(pointer:coarse)').matches,
|
||||||
|
reducedMotion: matchMedia('(prefers-reduced-motion:reduce)').matches, controls };
|
||||||
|
});
|
||||||
|
const snapshot = await page.locator('body').ariaSnapshot();
|
||||||
|
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
|
||||||
|
const incomplete = axe.incomplete.map(rule => ({ id: rule.id, impact: rule.impact, description: rule.description, help: rule.help, helpUrl: rule.helpUrl,
|
||||||
|
nodes: rule.nodes.map(node => ({ target: node.target, html: node.html, failureSummary: node.failureSummary,
|
||||||
|
checks: [...node.any, ...node.all, ...node.none].map(check => ({ id: check.id, impact: check.impact, message: check.message, data: check.data, relatedNodes: check.relatedNodes })) })) }));
|
||||||
|
const screenshot = info.outputPath(`${state}.png`);
|
||||||
|
await page.screenshot({ path: screenshot, animations: 'disabled' });
|
||||||
|
await info.attach(state, { path: screenshot, contentType: 'image/png' });
|
||||||
|
const evidence = { sample, state, url: page.url(), evidenceKind: 'automated collection; findings require triage, not product acceptance', geometry,
|
||||||
|
ariaSnapshot: snapshot, potentialUnnamedActionLines: snapshot.split('\n').filter(line => /^\s*- (button|link|textbox|combobox|checkbox|switch|tab)(:| \[)/.test(line)),
|
||||||
|
axe: { scope: 'All enabled axe rules, including best practices and WCAG 2.2 AA target-size', targetSizeExplicitlyEnabled: true, violations: axe.violations, incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })),
|
||||||
|
incompleteNodeOccurrences: incomplete.reduce((sum, rule) => sum + rule.nodes.length, 0) },
|
||||||
|
limitations: ['No human screen-reader verification', 'Center hit-testing and DOM clipping are triage signals, not proof an action is unusable', 'No physical touch or software keyboard', 'No actual browser zoom measurement'] };
|
||||||
|
await info.attach(`${state}-audit`, { body: JSON.stringify(evidence), contentType: 'application/json' });
|
||||||
|
return evidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('[P5-15-02 COLLECTION] thirteen areas retain native rendered entry across the distributed responsive/accessibility matrix', async ({ page, browser, owner }, info) => {
|
||||||
|
test.setTimeout(360_000);
|
||||||
|
await info.attach('synthetic-owned-fixture-identifiers', { body: JSON.stringify({ jobId: owner.jobId, cvId: owner.cvId, actor: 'synthetic administrator with private owned Jobs/CV', recordCount: 6, accountCredentials: 'not included' }), contentType: 'application/json' });
|
||||||
|
const anonymous = await browser.newContext({ viewport: { width: 320, height: 844 } });
|
||||||
|
try {
|
||||||
|
await localOnly(anonymous);
|
||||||
|
await anonymous.addInitScript(() => { localStorage.setItem('uiLanguage', 'en'); localStorage.setItem('jobtracker.themeMode', 'light'); });
|
||||||
|
const publicPage = await anonymous.newPage(); await publicPage.goto('http://localhost:3300/login');
|
||||||
|
await expect(publicPage.getByLabel(translations.en.usernameOrEmail, { exact: true })).toBeVisible();
|
||||||
|
await collect(publicPage, info, { area: 'authentication', path: '/login', width: 320, height: 844, language: 'en', theme: 'light' });
|
||||||
|
} finally { await anonymous.close(); }
|
||||||
|
const samples: Sample[] = [
|
||||||
|
...[767, 768, 899, 900, 1023, 1024].map(width => ({ area: 'shell' as const, path: '/jobs', width, language: 'nb' as const, theme: 'dark' as const })),
|
||||||
|
{ area: 'commands', path: '/jobs', width: 390, language: 'nb', theme: 'dark' },
|
||||||
|
{ area: 'jobs', path: '/jobs', width: 1440, language: 'en', theme: 'light' }, { area: 'jobs', path: '/jobs', width: 320, language: 'nb', theme: 'dark' },
|
||||||
|
{ area: 'board', path: '/jobs?view=board', width: 1280, language: 'nb', theme: 'dark' }, { area: 'board', path: '/jobs?view=board', width: 390, language: 'en', theme: 'light' },
|
||||||
|
{ area: 'dashboard', path: '/dashboard', width: 1440, language: 'en', theme: 'light' }, { area: 'dashboard', path: '/dashboard', width: 768, language: 'nb', theme: 'dark' },
|
||||||
|
{ area: 'analytics', path: '/analytics?months=6', width: 1024, language: 'nb', theme: 'dark' }, { area: 'analytics', path: '/analytics?months=6', width: 390, language: 'en', theme: 'light' },
|
||||||
|
{ area: 'workspace', path: `/jobs/${owner.jobId}?section=overview`, width: 768, language: 'nb', theme: 'dark' }, { area: 'workspace', path: `/jobs/${owner.jobId}?section=timeline`, width: 320, language: 'en', theme: 'light' },
|
||||||
|
{ area: 'profile', path: '/career?section=profile', width: 1440, language: 'en', theme: 'light' }, { area: 'profile', path: '/career?section=profile', width: 390, language: 'nb', theme: 'dark' },
|
||||||
|
{ area: 'cv', path: `/career/builder/${owner.cvId}`, width: 1440, language: 'en', theme: 'light' }, { area: 'cv', path: `/career/builder/${owner.cvId}`, width: 768, language: 'nb', theme: 'dark' }, { area: 'cv', path: `/career/builder/${owner.cvId}`, width: 390, language: 'en', theme: 'light' },
|
||||||
|
{ area: 'documents', path: `/jobs/${owner.jobId}?section=documents`, width: 1024, language: 'en', theme: 'light' }, { area: 'documents', path: `/jobs/${owner.jobId}?section=documents`, width: 320, language: 'nb', theme: 'dark' },
|
||||||
|
{ area: 'settings', path: '/settings', width: 1440, language: 'en', theme: 'light' }, { area: 'settings', path: '/settings', width: 320, language: 'nb', theme: 'dark' },
|
||||||
|
{ area: 'admin', path: '/admin/users', width: 1280, language: 'en', theme: 'light' }, { area: 'admin', path: '/admin/users', width: 768, language: 'nb', theme: 'dark' }, { area: 'admin', path: '/admin/users', width: 390, language: 'en', theme: 'light' },
|
||||||
|
];
|
||||||
|
const findings: Array<{ sample: Sample; overflow: number; violations: number; incompleteNodeOccurrences: number }> = [];
|
||||||
|
for (const sample of samples) {
|
||||||
|
await test.step(`${sample.area} ${sample.width} ${sample.language}/${sample.theme}`, async () => {
|
||||||
|
await enter(page, owner, sample);
|
||||||
|
if (sample.area === 'shell') {
|
||||||
|
const navigation = page.getByRole('button', { name: translations[sample.language].openNavigation, exact: true });
|
||||||
|
if (sample.width < 1024) await expect(navigation).toHaveCount(1); else await expect(navigation).toHaveCount(0);
|
||||||
|
}
|
||||||
|
const collected = await collect(page, info, sample);
|
||||||
|
findings.push({ sample, overflow: collected.geometry.pageHorizontalOverflow, violations: collected.axe.violations.length, incompleteNodeOccurrences: collected.axe.incompleteNodeOccurrences });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(new Set(['authentication', ...samples.map(sample => sample.area)]).size).toBe(13);
|
||||||
|
await info.attach('distributed-thirteen-area-matrix', { body: JSON.stringify({ collectionOnly: true, samples: findings, authenticationSamples: 1, findingsRequireAcceptanceTriage: true }), contentType: 'application/json' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P5-15-02] reduced-motion mobile drawer enters focus and Escape returns it', async ({ page, owner }, info) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const t = translations.en;
|
||||||
|
const sample: Sample = { area: 'shell', path: '/jobs', width: 390, language: 'en', theme: 'light' };
|
||||||
|
await enter(page, owner, sample);
|
||||||
|
const navigation = page.getByRole('button', { name: t.openNavigation, exact: true });
|
||||||
|
await navigation.focus(); await navigation.press('Enter');
|
||||||
|
const drawer = page.getByRole('dialog', { name: t.navigation, exact: true });
|
||||||
|
await expect.soft(drawer.getByRole('button', { name: t.dashboard, exact: true })).toBeFocused();
|
||||||
|
const focus = () => page.evaluate(() => ({ active: document.activeElement?.outerHTML.slice(0, 500), focusedInDialog: !!document.activeElement?.closest('[role=dialog]') }));
|
||||||
|
const beforeScan = await focus();
|
||||||
|
await collect(page, info, sample, 'navigation-open');
|
||||||
|
const afterScan = await focus();
|
||||||
|
await page.keyboard.press('Escape'); await expect.soft(navigation).toBeFocused();
|
||||||
|
const afterEscape = await focus();
|
||||||
|
const stayedOpen = await drawer.isVisible();
|
||||||
|
if (stayedOpen) {
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
}
|
||||||
|
await info.attach('drawer-focus-recovery', { body: JSON.stringify({ beforeScan, afterScan, afterEscape, stayedOpen, afterTabAndEscape: await focus(), drawerStillVisible: await drawer.isVisible(), humanScreenReader: 'NOT VERIFIED' }), contentType: 'application/json' });
|
||||||
|
await expect.soft(drawer).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P5-15-02] keyboard command, dialog, section and document controls keep deliberate focus contracts', async ({ page, owner }, info) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
const t = translations.en;
|
||||||
|
const sample: Sample = { area: 'shell', path: '/jobs', width: 390, language: 'en', theme: 'light' };
|
||||||
|
await enter(page, owner, sample);
|
||||||
|
const command = page.getByRole('button', { name: new RegExp(t.quickSearch) });
|
||||||
|
await command.focus(); await command.press('Enter');
|
||||||
|
const dialog = page.getByRole('dialog', { name: t.quickSearch, exact: true });
|
||||||
|
const input = dialog.getByRole('textbox', { name: t.quickSearch, exact: true });
|
||||||
|
await expect(input).toBeFocused(); await input.fill('no-such-audit-record');
|
||||||
|
await expect(dialog.getByText(t.noMatchingCommands, { exact: true })).toBeVisible();
|
||||||
|
await collect(page, info, { ...sample, area: 'commands' }, 'empty-keyboard');
|
||||||
|
await page.keyboard.press('Escape'); await expect(command).toBeFocused();
|
||||||
|
await command.press('Enter'); await input.fill('');
|
||||||
|
await dialog.getByRole('button', { name: new RegExp(t.addNewJob) }).press('Enter');
|
||||||
|
await expect(dialog).not.toBeVisible(); await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await collect(page, info, { ...sample, area: 'jobs' }, 'add-dialog-keyboard');
|
||||||
|
await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||||
|
|
||||||
|
await enter(page, owner, { ...sample, area: 'workspace', path: `/jobs/${owner.jobId}?section=overview`, width: 768 });
|
||||||
|
const overview = page.getByRole('tab', { name: t.workspaceOverview, exact: true });
|
||||||
|
await overview.focus(); await overview.press('ArrowRight');
|
||||||
|
const match = page.getByRole('tab', { name: t.workspaceMatch, exact: true });
|
||||||
|
await expect(match).toBeFocused();
|
||||||
|
await expect(overview).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await match.press('Enter');
|
||||||
|
await expect(match).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await collect(page, info, { ...sample, area: 'workspace', path: page.url(), width: 768 }, 'keyboard-section');
|
||||||
|
|
||||||
|
await enter(page, owner, { ...sample, area: 'cv', path: `/career/builder/${owner.cvId}`, width: 1440 });
|
||||||
|
const sectionSelect = page.getByRole('combobox', { name: new RegExp(`^${t.cvEditorGoToSection}`) });
|
||||||
|
await sectionSelect.focus(); await sectionSelect.press('Space'); await page.keyboard.press('Home');
|
||||||
|
for (let index = 0; index < 4; index++) await page.keyboard.press('ArrowDown');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page.getByRole('button', { name: t.cvEditorCollapseSection.replace('{section}', t.cvSectionSkills), exact: true })).toBeFocused();
|
||||||
|
await collect(page, info, { ...sample, area: 'cv', path: page.url(), width: 1440 }, 'keyboard-section-selector');
|
||||||
|
const move = page.getByRole('button', { name: t.cvEditorMoveSectionDown.replace('{section}', t.cvSectionExperience), exact: true });
|
||||||
|
await move.focus(); await move.press('Enter'); await expect(move).toBeFocused();
|
||||||
|
await expect.poll(async () => (await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json()).settings.sections.slice(0, 5).map((section: { key: string }) => section.key)).toEqual(['summary', 'education', 'experience', 'projects', 'skills']);
|
||||||
|
const preview = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true });
|
||||||
|
await page.getByRole('button', { name: t.cvEditorFocusPreview, exact: true }).press('Enter');
|
||||||
|
await expect(preview).toBeFocused();
|
||||||
|
await expect(preview).toHaveAttribute('aria-busy', 'false');
|
||||||
|
await page.keyboard.press('Control+End');
|
||||||
|
await expect.poll(() => preview.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3);
|
||||||
|
await collect(page, info, { ...sample, area: 'cv', path: page.url(), width: 1440 }, 'keyboard-preview-end');
|
||||||
|
await page.getByRole('button', { name: t.cvEditorFocusEditor, exact: true }).press('Enter');
|
||||||
|
await expect(page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true })).toBeFocused();
|
||||||
|
await info.attach('native-keyboard-contracts', { body: JSON.stringify({ commandEntryAndEscapeReturn: true, commandWorkflowDialog: true, workspaceArrowNavigation: true, cvKeyboardOrderPersisted: true, previewEntryScrollExit: true, evidence: 'preceding hard assertions completed', humanScreenReader: 'NOT VERIFIED' }), contentType: 'application/json' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P5-15-02 COLLECTION] text-spacing overrides and landscape reflow retain inspectable native controls, without claiming browser zoom', async ({ page, owner }, info) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
for (const [area, path] of [['dashboard', '/dashboard'], ['analytics', '/analytics?months=6'], ['jobs', '/jobs'], ['documents', `/jobs/${owner.jobId}?section=documents`], ['settings', '/settings'], ['cv', `/career/builder/${owner.cvId}`]] as const) {
|
||||||
|
const sample: Sample = { area, path, width: 844, height: 390, language: 'nb', theme: 'dark', state: 'landscape-text-spacing' };
|
||||||
|
await enter(page, owner, sample);
|
||||||
|
await page.addStyleTag({ content: 'body * { line-height: 1.5 !important; letter-spacing: .12em !important; word-spacing: .16em !important; } p { margin-block-end: 2em !important; }' });
|
||||||
|
const spacing = await page.locator('body').evaluate(node => ({ rootFont: getComputedStyle(node).fontSize, letterSpacing: getComputedStyle(node.querySelector('button') ?? node).letterSpacing }));
|
||||||
|
await collect(page, info, sample, 'text-spacing');
|
||||||
|
await info.attach(`${area}-text-spacing-method`, { body: JSON.stringify({ spacing, method: 'WCAG text-spacing CSS overrides in parent UI document; document renderer iframe is untouched', viewport: '844x390 landscape CSS viewport', browserZoom: 'NOT VERIFIED', pinchZoom: 'NOT VERIFIED', softwareKeyboard: 'NOT VERIFIED' }), contentType: 'application/json' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P5-15-02 C06 C07] CV source action reaches the existing Career Profile workflow', async ({ page, owner }, info) => {
|
||||||
|
const t = translations.en;
|
||||||
|
await enter(page, owner, { area: 'cv', path: `/career/builder/${owner.cvId}`, width: 1440, language: 'en', theme: 'light' });
|
||||||
|
const link = page.getByRole('link', { name: t.cvEditorEditMasterProfile, exact: true });
|
||||||
|
const href = await link.getAttribute('href');
|
||||||
|
await link.press('Enter');
|
||||||
|
await expect(page.getByRole('main')).toBeVisible();
|
||||||
|
await info.attach('cv-source-action-destination', { body: JSON.stringify({ href, destination: page.url(), expected: '/career or /career?section=profile; established canonical source workflow', observedText: await page.getByRole('main').innerText() }), contentType: 'application/json' });
|
||||||
|
await expect(page).toHaveURL(url => url.pathname === '/career');
|
||||||
|
await expect(page.getByLabel(t.profileCvContactFullName, { exact: true })).toHaveValue('Åse Revisjonseksempel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P5-15-02 W08 J10] keyboard upload and import controls open the native file chooser', async ({ page, owner }, info) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const t = translations.en;
|
||||||
|
const outcomes: Array<{ route: string; label: string; key: string; opened: boolean }> = [];
|
||||||
|
for (const [area, path, labels] of [
|
||||||
|
['documents', `/jobs/${owner.jobId}?section=documents`, [t.attachmentsUpload]],
|
||||||
|
['settings', '/settings', [t.importJson, t.convertCsvToImportJson]],
|
||||||
|
] as const) {
|
||||||
|
await enter(page, owner, { area, path, width: 768, language: 'en', theme: 'light' });
|
||||||
|
for (const label of labels) {
|
||||||
|
const control = page.getByRole('button', { name: label, exact: true });
|
||||||
|
await expect(control).toBeVisible();
|
||||||
|
for (const key of ['Enter', 'Space']) {
|
||||||
|
await control.focus();
|
||||||
|
await expect(control).toBeFocused();
|
||||||
|
const [chooser] = await Promise.all([page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null), control.press(key)]);
|
||||||
|
outcomes.push({ route: path, label, key, opened: !!chooser });
|
||||||
|
if (chooser) await chooser.setFiles([]);
|
||||||
|
expect.soft(!!chooser, `${label} ${key} opens the native picker`).toBe(true);
|
||||||
|
}
|
||||||
|
const [pointerChooser] = await Promise.all([page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null), control.click()]);
|
||||||
|
outcomes.push({ route: path, label, key: 'pointer comparison', opened: !!pointerChooser });
|
||||||
|
if (pointerChooser) await pointerChooser.setFiles([]);
|
||||||
|
expect.soft(!!pointerChooser, `${label} pointer comparison opens the same picker`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await info.attach('native-keyboard-file-chooser', { body: JSON.stringify({ outcomes, fileDataWritten: false }), contentType: 'application/json' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Chromium emulated touch; not physical-device acceptance', () => {
|
||||||
|
test.use({ hasTouch: true });
|
||||||
|
test('[P5-15-02] emulated taps preserve CV modes and navigation across portrait/landscape viewport changes', async ({ page, owner }, info) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const t = translations.nb;
|
||||||
|
const sample: Sample = { area: 'cv', path: `/career/builder/${owner.cvId}`, width: 390, height: 844, language: 'nb', theme: 'dark' };
|
||||||
|
await enter(page, owner, sample);
|
||||||
|
const original = await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json();
|
||||||
|
await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).tap();
|
||||||
|
const preview = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true });
|
||||||
|
await expect(preview).toBeVisible(); await expect(preview).toHaveAttribute('aria-busy', 'false');
|
||||||
|
await expect(page.getByRole('button', { name: t.cvEditorPreview, exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await collect(page, info, sample, 'emulated-touch-preview');
|
||||||
|
await page.setViewportSize({ width: 844, height: 390 });
|
||||||
|
await expect(preview).toBeVisible();
|
||||||
|
await collect(page, info, { ...sample, width: 844, height: 390 }, 'emulated-touch-landscape');
|
||||||
|
await page.getByRole('button', { name: t.cvEditorEdit, exact: true }).tap();
|
||||||
|
await expect(page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true })).toBeVisible();
|
||||||
|
expect(await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json()).toEqual(original);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const navigation = page.getByRole('button', { name: t.openNavigation, exact: true });
|
||||||
|
await navigation.tap(); await expect(page.getByRole('dialog', { name: t.navigation, exact: true })).toBeVisible();
|
||||||
|
await collect(page, info, { ...sample, area: 'shell' }, 'emulated-touch-navigation');
|
||||||
|
await page.keyboard.press('Escape'); await expect.soft(navigation).toBeFocused();
|
||||||
|
await info.attach('emulated-touch-boundary', { body: JSON.stringify({ hasTouch: true, input: 'Playwright Chromium touch tap', portraitLandscape: 'CSS viewport changes, not physical orientation sensor', physicalTouch: 'NOT VERIFIED', nativeTouchDrag: 'NOT VERIFIED', softwareKeyboard: 'NOT VERIFIED', cvUnchanged: true }), contentType: 'application/json' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user