test(dashboard): qualify responsive layout and preview semantics
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
# Phase 8 functionality preservation reconciliation
|
||||||
|
|
||||||
|
The exact 57 Phase 7 groups remain: 56 PARTIAL and PRES-C11 DEFERRED. These are retained finite-evidence/qualification statuses, not claims of 56 defects or whole-product acceptance. Original capabilities and remaining obligations are authoritative in [Phase 7](../phase-7/PRESERVATION.md). This table records Phase 8 impact without rewriting historical evidence. Final executed suite outcomes are in [acceptance](p8-05/FINAL_ACCEPTANCE.md).
|
||||||
|
|
||||||
|
| Group and protected capability | Retained status | Phase 8 treatment |
|
||||||
|
|---|---|---|
|
||||||
|
| **PRES-E01** Public product, Free/Pro comparison and honest unavailable billing | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-E02** Local login/register/remember, Turnstile, resend/reset/verify | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-E03** Google/Microsoft sign-in/link/relink, popup state/origin | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-E04** 2FA/recovery/setup/disable/trusted-device revocation | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-E05** Sessions/current-device/revoke/logout | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-E06** Avatar/account identity/email/password | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-E07** Expanded/rail/mobile navigation, active route, commands | PARTIAL | Existing shell/routes retained; draft route/unload warning and acknowledged-save navigation/focus added. |
|
||||||
|
| **PRES-E08** Themes, EN/NB hydration, shared toast/confirm/prompt/errors | PARTIAL | MUI/theme/EN-NB/confirmation/toast preserved; focused keyboard/reflow/axe evidence with human qualifications retained. |
|
||||||
|
| **PRES-E09** Account switch/stale read-write/BFCache/revalidation | PARTIAL | Identity generation/abort and owner-at-submit guards; late acknowledgements/account switch dispose layout state; server owner authorization. |
|
||||||
|
| **PRES-J01** NAV search/filter/sort/source/freshness/details | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-J02** Named saved searches/run/delete/alerts/results/dismiss | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-J03** Reviewed manual/URL/paste creation/company/tags/dates/duplicates | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-J04** Extension/bookmarklet/share capture and optional post-create artifacts | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-J05** Search/filter/sort/pages/columns/saved views/row-card actions | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-J06** Bulk status/delete/restore, soft-delete/Trash, edit | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-J07** Board groups/custom statuses/pointer-keyboard move/cancel/menu | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-J08** Company/contact/source/pipeline-date/history editing | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-J09** Recorded offer compensation/deadline/notes/source | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-J10** CSV/JSON export/import/conversion/counts | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-W01** Full job workspace/legacy links/optional overlay | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-W02** Details/status/next action/dates/contact/timeline | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-W03** Analysis/match/gaps/strategy/reviewed status suggestions | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-W04** Checklist CRUD/completion/order/next action | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-W05** Select/attach/create/tailor CV and source context | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-W06** Manual/AI letter review/reject/apply/history/dirty guard | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-W07** Drafts/frozen package versions/archived binaries | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-W08** Upload/drop/category/name/consent/preview/download/delete | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-W09** Interview categories/questions/answers/manual removal/AI review | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-W10** Debrief/follow-up content/date/readiness | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-W11** Reminders/rules/notifications/ICS/Google-Outlook events | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-W12** Async progress/failure/retry/cancel/notifications | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-C01** All canonical structured/long-tail/custom career fields | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-C02** Overview/completeness/import/raw copy/avatar/history restore | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-C03** Upload/extract/reprocess/diff/low-confidence select/accept/discard/retry | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-C04** Evidence sources/categories/tags/add/list/delete | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-C05** Library CRUD/gallery/share/publication/PDF | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-C06** Studio tabs/save states/debounce/pages/fit/mobile panes | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-C07** Visibility/order/custom sections/item-document overrides/reset | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-C08** Capability-aware typography/color/header/skills/sidebar/photo | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-C09** A4/Letter/density/margins/dates/document locale/section layout | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-C10** Undo/redo/save queue/dirty/history/conflict/preview lifecycle | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-C11** Section/document AI improve/translate/instruction/grammar review | DEFERRED | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-C12** Private PDF/public slug preview-PDF/long searchable output | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-M01** Job manual correspondence log/search/delete/paste | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-M02** Gmail candidates/thread import/sync/unlink/relink/move | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-M03** Global inbox filters/page/provider/link/direction/detail/attachments | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-M04** Compose/reply/draft revisions/confirmed send/uncertain attempt | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-M05** Gmail reviewed job/link/reject/keep/notes/sync/create | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-M06** Gmail/Graph/IMAP connect/status/disconnect | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-S01** Personal counts/activity/funnel/company/skills/stage/salary/next action | PARTIAL | Existing exact Dashboard/Analytics report values, source destinations, independent errors and periods retained; all nine existing sections preserved. |
|
||||||
|
| **PRES-S02** Five owner-local flags/onboarding/bell read-dismiss | PARTIAL | Five local flags migrate to the account layout on explicit Save; onboarding/bell retained; visibility/order/size/reset/cross-session/conflict tested. |
|
||||||
|
| **PRES-S03** Rules/notification/table/theme/language/account preferences | PARTIAL | Narrow account layout fields only; stale Identity writes cannot overwrite them; unrelated language/notification/profile/stamp fields preserved. |
|
||||||
|
| **PRES-S04** Privacy/AI usage/limits/server entitlement/billing availability | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-S05** Account ZIP/encrypted backup/lifecycle/deliberate delete | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||||
|
| **PRES-A01** Admin users/create/roles/reset/delete/last-admin/self guard | PARTIAL | Administration source unchanged; owner-only layout API cannot select another account and ordinary user cannot read admin statistics. |
|
||||||
|
| **PRES-A02** Audit detail/supported undo/job restore | PARTIAL | Audit source unchanged; existing regression remains required. |
|
||||||
|
| **PRES-A03** System/build/DB/AI/mail/SMTP/probe/benchmark/maintenance | PARTIAL | System/admin dashboard source unchanged; no administrative customisation or production operations. |
|
||||||
|
|
||||||
|
All route patterns in App.tsx match `ef94d57` exactly (including the approved Documents destination); no navigation pattern is removed. No renderer, provider, AI model, analytics aggregation, security permission, admin or production configuration is changed. Layout reset only submits layout metadata.
|
||||||
|
|
||||||
|
Performance boundary: report fetches and calculation owners remain the same; the focused ordering test asserts no report request on reorder. Rendering is bounded by nine catalogue entries. No production load, mobile hardware benchmark or claim of faster large-dataset aggregation is inferred. Existing O(N) Board scaling remains separately owned.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# P8-04 — Responsive and accessibility qualification
|
||||||
|
|
||||||
|
The final feature build uses the existing app theme, header measurements and MUI controls. No dependencies were added. The final acceptance report records the succeeding whole-browser run separately from the focused evidence below.
|
||||||
|
|
||||||
|
## Executed focused browser evidence
|
||||||
|
|
||||||
|
`browser-qualification.json` records **13 passed / 0 failed, skipped, flaky or retried** in93.929s: retained two Analytics journeys, retained two Dashboard journeys and nine Phase 8 cases. Actual local API/SQLite/accounts/session cookies/CSRF are used. Explicitly controlled GET/PUT503 and route-abort transport failures exercise recovery without implying a physical network outage. Fresh contexts and real logout/login prove account persistence across browser lifetimes; these are not physically separate devices.
|
||||||
|
|
||||||
|
Viewports320/390/768/1440px cover editor EN/Bokmål and light/dark; retained dashboard journeys also cover1024/1920px. Every measured root page has no horizontal overflow. Native selects keep long translated labels accessible; visual Size labels are short while complete widget-specific accessible names remain. Desktop pointer handle drag, keyboard Earlier/Later with live position/focus, emulated-touch button reordering and readback all pass. Summary stays full width; one logical DOM order becomes a single-column feed on small screens.
|
||||||
|
|
||||||
|
Forty-two initial Dashboard/editor PNGs were inspected through four contact sheets and representative full-size crops. Final desktop/light/English and320px/dark/Bokmål editor crops were separately inspected after the focus repair; final raw qualification contains60 distinct screenshots including Analytics. This does not claim an individual full-size inspection of every image. Exact metric/month/sample/source-link and persisted API assertions supply behavioral evidence beyond screenshots.
|
||||||
|
|
||||||
|
## Reproduced issues and repairs
|
||||||
|
|
||||||
|
- Edit entry focused the panel beneath the fixed app header. A browser geometry check first failed (`focus-red.json`); deliberate preventScroll focus, the existing `--app-shell-header-height` scroll margin and explicit scroll now pass at320/768/1440px. The final reviewed crops show the heading and Save/Cancel controls below the header.
|
||||||
|
- Pending-save route navigation offered a misleading discard promise. A deferred acknowledgement unit regression first failed, then passed; SPA navigation now waits with a localized status while saving. Browser unload warnings do not promise rollback of server writes.
|
||||||
|
- Axe's incomplete triage identified a labelled preview `div` without a valid role. A role-based test failed first (`preview-semantics-red.log`); the preview is now a named region. The new editor browser case explicitly rejects `aria-prohibited-attr` incompletes. Final scan totals are in final acceptance; the earlier ten scans had0 automated violations and68 incomplete nodes (61contrast,6preview semantics,1drawer focus), so those earlier results are not presented as the repaired totals.
|
||||||
|
- The Analytics all-hidden fixture still set only the original five local flags. Its failure remains in the interrupted full run. It now saves all nine hidden flags through the real owner endpoint, preserving the original Analytics exact-value/navigation/isolation assertions. The focused successor passes.
|
||||||
|
- The initial prebuilt browser run pointed to5202 rather than isolated5302; ten startup/data failures are retained. Rebuilt with explicit test API URL; no product default, API assertion or auth guard was weakened.
|
||||||
|
- Lint identified the Playwright fixture callback named `use` as a React hook; renamed the callback to `provide` without changing test behavior.
|
||||||
|
|
||||||
|
## Required manual qualification
|
||||||
|
|
||||||
|
Human screen readers, physical touch/software keyboard, complete actual browser zoom/text enlargement/reflow, other browsers, deployed Linux/fonts/photo/native print, manual layered-contrast review and retained drawer settled-state focus require real operators. Automated axe/emulation/screenshots do not satisfy them. No production or provider test is inferred. Existing vendor strings and Phase 7 accessibility/output conditions remain visible in [Phase 7 acceptance](../../phase-7/p7-12/FINAL_ACCEPTANCE.md).
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Phase 8 acceptance scenario mapping
|
||||||
|
|
||||||
|
This matrix maps every requested scenario to its actual test owner. The [final report](FINAL_ACCEPTANCE.md) supplies executed whole-suite results and qualifications; a test reference alone is not an execution claim. Backend tests are `DashboardPreferencesTests.cs`; frontend tests are `dashboardLayout.test.ts`, `hooks/useDashboardLayout.test.tsx`, `dashboard-customisation.test.tsx` and retained Dashboard/Analytics tests. Native cases are `phase8-dashboard-customisation.spec.ts` plus retained Dashboard/Analytics presentation cases.
|
||||||
|
|
||||||
|
| # | Required scenario | Evidence and boundary |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Useful defaults for a new user | Catalogue defaults, unsaved GET revision0, actual new-account Dashboard guidance; no fabricated metrics |
|
||||||
|
| 2 | Existing users retain settings | Legacy owner-local five flags migrate only before first Save; real browser case and migration defaults |
|
||||||
|
| 3 | Correct existing statistics | Retained exact monthly/count/denominator/custom-stage/company/source tests and native readback |
|
||||||
|
| 4 | Enter/exit customisation | Explicit editor region, Cancel/Save, Escape and focus restoration; component/native cases |
|
||||||
|
| 5 | Hide a widget | Draft checkbox updates preview; acknowledged-save HTTP readback |
|
||||||
|
| 6 | Restore hidden widget | All-hidden recovery native case restores pipeline without changing job data |
|
||||||
|
| 7 | Change order | Native drag and keyboard/touch buttons, live position, DOM order, real readback |
|
||||||
|
| 8 | Supported size | Native select, mixed full/standard grid, persisted pipeline full-width; cards full only |
|
||||||
|
| 9 | Save configuration | Real PUT acknowledgement and revision increment; disabled controls while pending |
|
||||||
|
| 10 | Reload saved layout | Browser refresh plus actual API GET equals acknowledged widgets |
|
||||||
|
| 11 | Logout/login | Real sign-out/sign-in journey retains server configuration |
|
||||||
|
| 12 | Another session/device | Fresh browser context/login reads the same account layout; no physical second-device claim |
|
||||||
|
| 13 | Cancel draft | Confirmation/no PUT, saved preview restored; reset/cancel server readback unchanged |
|
||||||
|
| 14 | Reset defaults | Confirmed draft replacement, user can cancel before Save; no unrelated account write |
|
||||||
|
| 15 | API error | Controlled503 keeps draft and localized error; retry persists exactly once after acknowledgement |
|
||||||
|
| 16 | Network failure | Controlled route abort simulates transport loss; draft retained and retry works; no physical outage claim |
|
||||||
|
| 17 | Obsolete widget | Actual backend stored-JSON GET ignores unknown IDs; frontend normalisation, no invalid renderer |
|
||||||
|
| 18 | Invalid size | Backend HTTP rejects invalid write, stored read normalises; frontend falls back to catalogue size |
|
||||||
|
| 19 | All hidden | Real nine-hidden saved layout, intentional empty state and accessible edit/restore |
|
||||||
|
| 20 | Two sessions edit | Real session conflict409, explicit reload, reapply and acknowledgement; atomic simultaneous first-save regression |
|
||||||
|
| 21 | Malformed/large payload | Actual Kestrel malformed/duplicate/unknown/oversized/chunked HTTP tests;16KB byte cap and32-entry maximum |
|
||||||
|
| 22 | Statistics failure | Retained independent summary/trends loading/503/retry native cases; no false zeros |
|
||||||
|
| 23 | New widgets/old layouts | Known missing entries appended hidden in saved revisions; future schema is read-only, downgrade denied |
|
||||||
|
| 24 | Historic save failure | WAL held-reader actual layout write plus simultaneous saves and stale Identity replay regression; no permanent contention claim |
|
||||||
|
| 25 | Cross-user access | Principal-derived GET/PUT, attempted query account override ignored, A/B persistence isolated; actual HTTP and cookie cases |
|
||||||
|
| 26 | Anonymous denied | Actual GET/PUT401 in backend and real app auth browser tests |
|
||||||
|
| 27 | Configuration cannot expose restricted data | Strict stable-ID/type/size/version allowlist; owner field never accepted; arbitrary query/HTML not supported |
|
||||||
|
| 28 | Admin data protected | Ordinary cookie-authenticated account receives403; admin source stays unchanged |
|
||||||
|
| 29 | Keyboard-only order | Native Enter on named move controls; live region and focus assertions |
|
||||||
|
| 30 | Predictable focus | Editor entry geometry, row stable keys, first-position alternate-button focus, Cancel return, route keep-editing |
|
||||||
|
| 31 | Responsive | Actual viewport matrices and no root horizontal overflow; preserved readable values tables and fluid chart |
|
||||||
|
| 32 | Touch/scoll | Emulated-touch tap alternative passes; dedicated drag handle avoids ordinary-content drag. Physical device/software keyboard outstanding |
|
||||||
|
| 33 | Bokmål | Localized editor/error/reset/control names; native EN/NB layouts. Human linguistic review remains outstanding |
|
||||||
|
| 34 | Themes | Native light/dark screenshots and computed scheme assertions |
|
||||||
|
| 35 | Reduced motion/focus | No custom motion; retained reduced-motion journeys and3px focus ring checks. Complete true zoom/manual contrast outstanding |
|
||||||
|
| 36 | Dashboard data contracts | Same report endpoints/DTO consumers; exact existing source assertions retained |
|
||||||
|
| 37 | Admin unchanged | No admin source edits; existing admin unit/native regression plus non-admin403 |
|
||||||
|
| 38 | Navigation/other pages | All33 route patterns unchanged; final ordinary browser/backend/frontend suites |
|
||||||
|
| 39 | Preference compatibility | Additive full migration chain/startup defaults, unrelated fields preserved, legacy flags and stale-session protections |
|
||||||
|
| 40 | No protected capability removed | Exact57-group reconciliation with56PARTIAL/C11DEFERRED retained; source/test inventory and final suites |
|
||||||
|
|
||||||
|
Additional regressions cover future-schema overwrite races during a paused request body, exact JavaScript revision limits, delayed acknowledgement after account disposal, malformed stored JSON and Identity's unrelated whole-user save. No production, model, provider grant, migration against live data or destructive operation was performed.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test, type APIRequestContext, type Page, type TestInfo } from '@playwright/test';
|
import { expect, test, type APIRequestContext, type Page, type TestInfo } from '@playwright/test';
|
||||||
import AxeBuilder from '@axe-core/playwright';
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
import { translations } from '../src/i18n/translations';
|
import { translations } from '../src/i18n/translations';
|
||||||
|
import { defaultDashboardLayout } from '../src/dashboardLayout';
|
||||||
|
|
||||||
const apiUrl = 'http://localhost:5302/api';
|
const apiUrl = 'http://localhost:5302/api';
|
||||||
const password = 'E2ePassword123!';
|
const password = 'E2ePassword123!';
|
||||||
@@ -79,8 +80,11 @@ test('[PRES-S01 S02 E07 E08 E09 J05 J09] Analytics native reports, direct entry,
|
|||||||
await page.getByRole('link', { name: 'Nordlys langsiktige karrieremuligheter æøå', exact: true }).click();
|
await page.getByRole('link', { name: 'Nordlys langsiktige karrieremuligheter æøå', exact: true }).click();
|
||||||
await expect(page).toHaveURL(new RegExp(`/jobs\\?companyId=${companyId}$`));
|
await expect(page).toHaveURL(new RegExp(`/jobs\\?companyId=${companyId}$`));
|
||||||
await expect(page.locator('[data-job-row-id]')).toHaveCount(6); await page.goBack();
|
await expect(page.locator('[data-job-row-id]')).toHaveCount(6); await page.goBack();
|
||||||
// Retained Dashboard flags must not remove the independent report destination.
|
// A fully hidden saved Dashboard must not remove the independent report destination.
|
||||||
await page.evaluate(owner => localStorage.setItem(`dashboardPrefs:${owner}`, JSON.stringify({ cards: false, activity: false, funnel: false, companies: false, skills: false })), me.id);
|
await page.evaluate(owner => localStorage.setItem(`dashboardPrefs:${owner}`, JSON.stringify({ cards: false, activity: false, funnel: false, companies: false, skills: false })), me.id);
|
||||||
|
const hidden = defaultDashboardLayout();
|
||||||
|
hidden.widgets.forEach(widget => { widget.visible = false; });
|
||||||
|
expect((await page.request.put(`${apiUrl}/auth/preferences/dashboard`, { headers, data: hidden })).status()).toBe(200);
|
||||||
await page.goto('/dashboard'); await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible();
|
await page.goto('/dashboard'); await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Analytics', exact: true }).click();
|
await page.getByRole('button', { name: 'Analytics', exact: true }).click();
|
||||||
await expect(activity.getByRole('table')).toBeVisible();
|
await expect(activity.getByRole('table')).toBeVisible();
|
||||||
|
|||||||
@@ -89,16 +89,18 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv
|
|||||||
}
|
}
|
||||||
const customize = page.getByRole('button', { name: t.dashboardCustomize, exact: true });
|
const customize = page.getByRole('button', { name: t.dashboardCustomize, exact: true });
|
||||||
await customize.focus(); await page.keyboard.press('Enter');
|
await customize.focus(); await page.keyboard.press('Enter');
|
||||||
await expect(page.getByRole('menuitemcheckbox').first()).toBeFocused();
|
const editor = page.getByRole('region', { name: t.dashboardEditing, exact: true });
|
||||||
|
await expect(editor).toBeVisible();
|
||||||
|
await expect(editor.locator('..')).toBeFocused();
|
||||||
|
await expect(editor.getByRole('list', { name: t.dashboardManageWidgets })).toBeVisible();
|
||||||
|
await expect(editor.getByRole('checkbox')).toHaveCount(9);
|
||||||
if (language === 'en') {
|
if (language === 'en') {
|
||||||
// Measure settled colors, not the menu's entry-opacity transition.
|
const editorScan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||||
await expect.poll(() => page.getByRole('menu').evaluate(menu => getComputedStyle(menu.parentElement!).opacity)).toBe('1');
|
await info.attach(`dashboard-${mode}-editor-axe`, { body: JSON.stringify({ violations: editorScan.violations, incomplete: editorScan.incomplete }), contentType: 'application/json' });
|
||||||
const menuScan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
expect(editorScan.violations).toEqual([]);
|
||||||
await info.attach(`dashboard-${mode}-menu-axe`, { body: JSON.stringify({ violations: menuScan.violations, incomplete: menuScan.incomplete }), contentType: 'application/json' });
|
|
||||||
expect(menuScan.violations).toEqual([]);
|
|
||||||
}
|
}
|
||||||
await page.keyboard.press('Escape'); await expect(customize).toBeFocused();
|
await page.keyboard.press('Escape'); await expect(customize).toBeFocused();
|
||||||
await expect(page.locator('[role="menuitemcheckbox"]')).toHaveCount(0);
|
await expect(editor).toHaveCount(0);
|
||||||
if (language === 'en') {
|
if (language === 'en') {
|
||||||
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||||
await info.attach(`dashboard-${mode}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' });
|
await info.attach(`dashboard-${mode}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' });
|
||||||
@@ -115,8 +117,9 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv
|
|||||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: 'en' } })).status()).toBe(204);
|
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: 'en' } })).status()).toBe(204);
|
||||||
await page.evaluate(() => localStorage.setItem('uiLanguage', 'en')); await page.reload();
|
await page.evaluate(() => localStorage.setItem('uiLanguage', 'en')); await page.reload();
|
||||||
await page.getByRole('button', { name: 'Customize dashboard' }).click();
|
await page.getByRole('button', { name: 'Customize dashboard' }).click();
|
||||||
for (const item of await page.getByRole('menuitemcheckbox').all()) await item.click();
|
for (const item of await page.getByRole('region', { name: 'Editing dashboard', exact: true }).getByRole('checkbox').all()) await item.uncheck();
|
||||||
await page.keyboard.press('Escape');
|
await page.getByRole('button', { name: 'Save layout', exact: true }).click();
|
||||||
|
await expect(page.getByRole('region', { name: 'Editing dashboard', exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible();
|
await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible();
|
await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible();
|
||||||
@@ -137,7 +140,9 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv
|
|||||||
await expect(page.getByText('Nordlys teknologi og fellestjenester æøå', { exact: true })).toHaveCount(0);
|
await expect(page.getByText('Nordlys teknologi og fellestjenester æøå', { exact: true })).toHaveCount(0);
|
||||||
await capture(page, info, 'dashboard-new-account');
|
await capture(page, info, 'dashboard-new-account');
|
||||||
await page.getByRole('button', { name: 'Customize dashboard' }).click();
|
await page.getByRole('button', { name: 'Customize dashboard' }).click();
|
||||||
for (const item of await page.getByRole('menuitemcheckbox').all()) await expect(item).toHaveAttribute('aria-checked', 'true');
|
const newAccountEditor = page.getByRole('region', { name: 'Editing dashboard', exact: true });
|
||||||
|
await expect(newAccountEditor.getByRole('checkbox')).toHaveCount(9);
|
||||||
|
for (const item of await newAccountEditor.getByRole('checkbox').all()) await expect(item).toBeChecked();
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
const b = await login(page.request, emailB);
|
const b = await login(page.request, emailB);
|
||||||
const bCompany = await (await page.request.post(`${apiUrl}/companies`, { headers: b.headers, data: { name: 'New search' } })).json();
|
const bCompany = await (await page.request.post(`${apiUrl}/companies`, { headers: b.headers, data: { name: 'New search' } })).json();
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { expect, test as base, type APIRequestContext, type BrowserContext, type Page, type TestInfo } from '@playwright/test';
|
||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import { translations } from '../src/i18n/translations';
|
||||||
|
import type { DashboardLayout } from '../src/dashboardLayout';
|
||||||
|
|
||||||
|
const apiUrl = 'http://localhost:5302/api';
|
||||||
|
const layoutUrl = `${apiUrl}/auth/preferences/dashboard`;
|
||||||
|
const password = 'E2ePassword123!';
|
||||||
|
const ids = ['cards', 'attention', 'funnel', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills'];
|
||||||
|
type Account = { email: string; headers: Record<string, string> };
|
||||||
|
async function login(api: APIRequestContext, email: string) {
|
||||||
|
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||||
|
const me = await (await api.get(`${apiUrl}/auth/me`)).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 localOnly(context: BrowserContext) {
|
||||||
|
await context.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
|
||||||
|
}
|
||||||
|
async function readLayout(api: APIRequestContext): Promise<DashboardLayout> {
|
||||||
|
const response = await api.get(layoutUrl);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
const editor = (page: Page) => page.getByRole('region', { name: 'Editing dashboard', exact: true });
|
||||||
|
async function openEditor(page: Page) {
|
||||||
|
await page.getByRole('button', { name: 'Customize dashboard', exact: true }).click();
|
||||||
|
await expect(editor(page).getByRole('checkbox')).toHaveCount(9);
|
||||||
|
}
|
||||||
|
async function save(page: Page) {
|
||||||
|
const response = page.waitForResponse(response => response.url() === layoutUrl && response.request().method() === 'PUT');
|
||||||
|
await editor(page).getByRole('button', { name: 'Save layout', exact: true }).click();
|
||||||
|
expect((await response).status()).toBe(200);
|
||||||
|
await expect(editor(page)).toHaveCount(0);
|
||||||
|
}
|
||||||
|
async function confirm(page: Page, name: string) {
|
||||||
|
await page.getByRole('dialog', { name, exact: true }).getByRole('button', { name, exact: true }).click();
|
||||||
|
}
|
||||||
|
async function capture(page: Page, info: TestInfo, name: string) {
|
||||||
|
const path = info.outputPath(`${name}.png`);
|
||||||
|
await page.screenshot({ path, fullPage: true });
|
||||||
|
await info.attach(name, { path, contentType: 'image/png' });
|
||||||
|
}
|
||||||
|
async function signOut(page: Page) {
|
||||||
|
await page.getByRole('button', { name: 'User', exact: true }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Sign out', exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
|
||||||
|
}
|
||||||
|
async function signIn(page: Page, email: string) {
|
||||||
|
await page.getByLabel('Username or email', { exact: true }).fill(email);
|
||||||
|
await page.getByLabel('Current password', { exact: true }).fill(password);
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
}
|
||||||
|
const test = base.extend<{ account: Account }>({
|
||||||
|
account: async ({ page, playwright }, provide) => {
|
||||||
|
const admin = await playwright.request.newContext();
|
||||||
|
try {
|
||||||
|
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||||
|
const email = `phase8-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||||
|
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||||
|
const headers = await login(page.request, email);
|
||||||
|
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Phase eight fixture' } });
|
||||||
|
expect(company.status()).toBe(201);
|
||||||
|
const companyId = (await company.json()).id;
|
||||||
|
expect((await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: 'Local fixture developer', status: 'Applied', dateApplied: new Date().toISOString(), tags: 'TypeScript', source: 'NAV' } })).status()).toBe(201);
|
||||||
|
await localOnly(page.context());
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page.getByRole('link', { name: 'Tracked jobs: 1', exact: true })).toBeVisible();
|
||||||
|
const dismiss = page.getByRole('button', { name: 'Dismiss setup checklist' });
|
||||||
|
if (await dismiss.isVisible()) await dismiss.click();
|
||||||
|
await provide({ email, headers });
|
||||||
|
} finally { await admin.dispose(); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P8] Saved order, size and visibility survive HTTP readback, a fresh browser and logout/login', async ({ page, browser, account }, info) => {
|
||||||
|
await openEditor(page);
|
||||||
|
await editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true }).click();
|
||||||
|
await editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full');
|
||||||
|
await editor(page).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck();
|
||||||
|
await save(page);
|
||||||
|
const saved = await readLayout(page.request);
|
||||||
|
expect(saved.version).toBe(1); expect(saved.revision).toBe(1);
|
||||||
|
expect(saved.widgets.map(widget => widget.id)).toEqual(['cards', 'funnel', 'attention', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']);
|
||||||
|
expect(saved.widgets.find(widget => widget.id === 'funnel')).toEqual({ id: 'funnel', visible: true, size: 'full' });
|
||||||
|
expect(saved.widgets.find(widget => widget.id === 'activity')?.visible).toBe(false);
|
||||||
|
const assertSaved = async (target: Page) => {
|
||||||
|
await expect(target.locator('[data-dashboard-widget="activity"]')).toHaveCount(0);
|
||||||
|
await expect(target.locator('[data-dashboard-widget="funnel"]')).toHaveAttribute('data-widget-size', 'full');
|
||||||
|
await expect.poll(() => target.locator('[data-dashboard-widget]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-dashboard-widget')))).toEqual(saved.widgets.filter(widget => widget.visible).map(widget => widget.id));
|
||||||
|
};
|
||||||
|
await page.reload(); await assertSaved(page);
|
||||||
|
const fresh = await browser.newContext({ baseURL: 'http://localhost:3300' });
|
||||||
|
try {
|
||||||
|
await localOnly(fresh);
|
||||||
|
await login(fresh.request, account.email);
|
||||||
|
const other = await fresh.newPage(); await other.goto('/dashboard'); await assertSaved(other);
|
||||||
|
expect(await readLayout(fresh.request)).toEqual(saved);
|
||||||
|
await capture(other, info, 'fresh-browser-server-layout');
|
||||||
|
} finally { await fresh.close(); }
|
||||||
|
await signOut(page); await signIn(page, account.email); await assertSaved(page);
|
||||||
|
expect(await readLayout(page.request)).toEqual(saved);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P8] Entering edit mode keeps its heading and save controls below the fixed app header', async ({ page, account }) => {
|
||||||
|
expect(account.email).toContain('phase8-');
|
||||||
|
for (const width of [320, 768, 1440]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await openEditor(page);
|
||||||
|
const heading = editor(page).getByRole('heading', { name: 'Editing dashboard', exact: true });
|
||||||
|
await expect.poll(() => heading.evaluate(element => {
|
||||||
|
const header = document.querySelector('.MuiAppBar-root');
|
||||||
|
return element.getBoundingClientRect().top >= (header?.getBoundingClientRect().bottom ?? 0);
|
||||||
|
})).toBe(true);
|
||||||
|
await editor(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P8] Cancel, rejected navigation and reset change only the draft until explicit Save', async ({ page, account }) => {
|
||||||
|
void account;
|
||||||
|
await openEditor(page);
|
||||||
|
await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck();
|
||||||
|
await save(page);
|
||||||
|
const before = await readLayout(page.request);
|
||||||
|
let writes = 0;
|
||||||
|
page.on('request', request => { if (request.url() === layoutUrl && request.method() === 'PUT') writes++; });
|
||||||
|
await openEditor(page);
|
||||||
|
await editor(page).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck();
|
||||||
|
await page.getByRole('link', { name: 'Tracked jobs: 1', exact: true }).click();
|
||||||
|
await page.getByRole('dialog', { name: 'Discard dashboard changes?', exact: true }).getByRole('button', { name: 'Keep editing', exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
await expect(editor(page).getByRole('checkbox', { name: 'Application activity', exact: true })).not.toBeChecked();
|
||||||
|
await editor(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await page.getByRole('dialog', { name: 'Discard dashboard changes?', exact: true }).getByRole('button', { name: 'Discard changes', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Customize dashboard', exact: true })).toBeFocused();
|
||||||
|
expect(await readLayout(page.request)).toEqual(before); expect(writes).toBe(0);
|
||||||
|
await openEditor(page);
|
||||||
|
await editor(page).getByRole('button', { name: 'Reset to default', exact: true }).click();
|
||||||
|
await page.getByRole('dialog', { name: 'Reset to default', exact: true }).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await expect(editor(page).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked();
|
||||||
|
await editor(page).getByRole('button', { name: 'Reset to default', exact: true }).click(); await confirm(page, 'Reset to default');
|
||||||
|
for (const checkbox of await editor(page).getByRole('checkbox').all()) await expect(checkbox).toBeChecked();
|
||||||
|
expect(await readLayout(page.request)).toEqual(before); expect(writes).toBe(0);
|
||||||
|
await save(page);
|
||||||
|
const reset = await readLayout(page.request);
|
||||||
|
expect(reset.revision).toBe(before.revision + 1); expect(reset.widgets.map(widget => widget.id)).toEqual(ids);
|
||||||
|
expect(reset.widgets.every(widget => widget.visible)).toBe(true); expect(writes).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P8] HTTP 503 and offline transport failure retain draft and support an acknowledged retry', async ({ page, account }, info) => {
|
||||||
|
void account;
|
||||||
|
const before = await readLayout(page.request);
|
||||||
|
await openEditor(page);
|
||||||
|
await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck();
|
||||||
|
await editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full');
|
||||||
|
let failure: '503' | 'offline' | null = '503';
|
||||||
|
await page.route(layoutUrl, route => route.request().method() !== 'PUT' || !failure ? route.continue()
|
||||||
|
: failure === '503' ? route.fulfill({ status: 503, json: { message: 'Controlled unavailable' } }) : route.abort('internetdisconnected'));
|
||||||
|
for (const mode of ['503', 'offline'] as const) {
|
||||||
|
failure = mode;
|
||||||
|
const failed = mode === '503'
|
||||||
|
? page.waitForResponse(response => response.url() === layoutUrl && response.status() === 503)
|
||||||
|
: page.waitForEvent('requestfailed', request => request.url() === layoutUrl && request.method() === 'PUT');
|
||||||
|
await editor(page).getByRole('button', { name: 'Save layout', exact: true }).click();
|
||||||
|
await failed;
|
||||||
|
await expect(editor(page).getByRole('alert')).toContainText('Your layout could not be saved');
|
||||||
|
await expect(editor(page).getByRole('button', { name: 'Save layout', exact: true })).toBeEnabled();
|
||||||
|
await expect(editor(page).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked();
|
||||||
|
await expect(editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true })).toHaveValue('full');
|
||||||
|
expect(await readLayout(page.request)).toEqual(before);
|
||||||
|
await capture(page, info, `draft-retained-${mode}`);
|
||||||
|
}
|
||||||
|
failure = null;
|
||||||
|
let release!: () => void;
|
||||||
|
const acknowledgement = new Promise<void>(resolve => { release = resolve; });
|
||||||
|
await page.route(layoutUrl, async route => {
|
||||||
|
if (route.request().method() === 'PUT') await acknowledgement;
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
const saving = save(page);
|
||||||
|
try {
|
||||||
|
await expect(editor(page).getByRole('button', { name: 'Saving layout…', exact: true })).toBeDisabled();
|
||||||
|
await expect(editor(page).getByRole('button', { name: 'Cancel', exact: true })).toBeDisabled();
|
||||||
|
for (const checkbox of await editor(page).getByRole('checkbox').all()) await expect(checkbox).toBeDisabled();
|
||||||
|
for (const select of await editor(page).getByRole('combobox').all()) await expect(select).toBeDisabled();
|
||||||
|
expect(await readLayout(page.request)).toEqual(before);
|
||||||
|
} finally { release(); }
|
||||||
|
await saving;
|
||||||
|
const saved = await readLayout(page.request);
|
||||||
|
expect(saved.revision).toBe(1); expect(saved.widgets.find(widget => widget.id === 'skills')?.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P8] Two authenticated sessions cannot overwrite a stale revision and explicitly reload before saving', async ({ page, browser, account }, info) => {
|
||||||
|
const second = await browser.newContext({ baseURL: 'http://localhost:3300' });
|
||||||
|
try {
|
||||||
|
await localOnly(second); await login(second.request, account.email);
|
||||||
|
const other = await second.newPage(); await other.goto('/dashboard');
|
||||||
|
await openEditor(page); await openEditor(other);
|
||||||
|
await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck(); await save(page);
|
||||||
|
const firstSaved = await readLayout(page.request);
|
||||||
|
await editor(other).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck();
|
||||||
|
const conflict = other.waitForResponse(response => response.url() === layoutUrl && response.request().method() === 'PUT');
|
||||||
|
await editor(other).getByRole('button', { name: 'Save layout', exact: true }).click();
|
||||||
|
expect((await conflict).status()).toBe(409);
|
||||||
|
await expect(editor(other).getByRole('button', { name: 'Reload latest layout', exact: true })).toBeVisible();
|
||||||
|
expect(await readLayout(second.request)).toEqual(firstSaved);
|
||||||
|
await capture(other, info, 'conflicting-layout-draft');
|
||||||
|
await editor(other).getByRole('button', { name: 'Reload latest layout', exact: true }).click(); await confirm(other, 'Reload latest layout');
|
||||||
|
await expect(editor(other).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked();
|
||||||
|
await expect(editor(other).getByRole('checkbox', { name: 'Application activity', exact: true })).toBeChecked();
|
||||||
|
await editor(other).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck(); await save(other);
|
||||||
|
const final = await readLayout(page.request);
|
||||||
|
expect(final.revision).toBe(2);
|
||||||
|
expect(final.widgets.filter(widget => !widget.visible).map(widget => widget.id)).toEqual(['activity', 'skills']);
|
||||||
|
} finally { await second.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P8] Anonymous requests fail, ordinary accounts stay independent and administrator data stays forbidden', async ({ page, playwright, account }) => {
|
||||||
|
const anonymous = await playwright.request.newContext();
|
||||||
|
const administrator = await playwright.request.newContext();
|
||||||
|
const other = await playwright.request.newContext();
|
||||||
|
try {
|
||||||
|
expect((await anonymous.get(layoutUrl)).status()).toBe(401);
|
||||||
|
expect((await anonymous.put(layoutUrl, { data: { version: 1, revision: 0, widgets: [] } })).status()).toBe(401);
|
||||||
|
const adminHeaders = await login(administrator, 'e2e@example.test');
|
||||||
|
const emailB = `phase8-b-${Date.now()}@example.test`;
|
||||||
|
expect((await administrator.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email: emailB, password, roles: [] } })).status()).toBe(200);
|
||||||
|
const headersB = await login(other, emailB);
|
||||||
|
const untouchedB = await readLayout(other);
|
||||||
|
expect(untouchedB).toEqual({ version: 1, revision: 0, widgets: [] });
|
||||||
|
await openEditor(page); await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck(); await save(page);
|
||||||
|
const savedA = await readLayout(page.request);
|
||||||
|
expect(await readLayout(other)).toEqual(untouchedB);
|
||||||
|
expect((await other.put(layoutUrl, { headers: headersB, data: { ...savedA, revision: 0, widgets: savedA.widgets.map(widget => ({ ...widget, visible: widget.id === 'cards' })) } })).status()).toBe(200);
|
||||||
|
expect(await readLayout(page.request)).toEqual(savedA);
|
||||||
|
expect((await page.request.get(`${apiUrl}/admin/system`)).status()).toBe(403);
|
||||||
|
expect((await other.get(`${apiUrl}/admin/system`)).status()).toBe(403);
|
||||||
|
await signOut(page); await signIn(page, emailB); await openEditor(page);
|
||||||
|
await expect(editor(page).getByRole('checkbox', { name: 'Summary cards', exact: true })).toBeChecked();
|
||||||
|
await expect(editor(page).getByRole('checkbox', { name: 'Current pipeline', exact: true })).not.toBeChecked();
|
||||||
|
await expect(editor(page).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked();
|
||||||
|
expect((await readLayout(page.request)).widgets.filter(widget => widget.visible).map(widget => widget.id)).toEqual(['cards']);
|
||||||
|
await editor(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await signOut(page); await signIn(page, account.email);
|
||||||
|
expect(await readLayout(page.request)).toEqual(savedA);
|
||||||
|
} finally { await anonymous.dispose(); await administrator.dispose(); await other.dispose(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P8] Five legacy hidden flags migrate only on explicit save; all nine hidden have recovery', async ({ page, account }, info) => {
|
||||||
|
void account;
|
||||||
|
expect(await readLayout(page.request)).toEqual({ version: 1, revision: 0, widgets: [] });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const owner = localStorage.getItem('authUserKey');
|
||||||
|
if (!owner || owner === 'anon') throw new Error('Expected authenticated per-account legacy owner');
|
||||||
|
localStorage.setItem(`dashboardPrefs:${owner}`, JSON.stringify({ cards: false, activity: false, funnel: false, companies: false, skills: false }));
|
||||||
|
});
|
||||||
|
await page.reload(); await openEditor(page);
|
||||||
|
for (const name of ['Summary cards', 'Application activity', 'Current pipeline', 'Top companies by activity', 'Job tags']) await expect(editor(page).getByRole('checkbox', { name, exact: true })).not.toBeChecked();
|
||||||
|
expect(await readLayout(page.request)).toEqual({ version: 1, revision: 0, widgets: [] });
|
||||||
|
await save(page);
|
||||||
|
expect((await readLayout(page.request)).widgets.filter(widget => !widget.visible).map(widget => widget.id)).toEqual(['cards', 'funnel', 'activity', 'companies', 'skills']);
|
||||||
|
await page.evaluate(() => { for (const key of Object.keys(localStorage)) if (key.startsWith('dashboardPrefs:')) localStorage.removeItem(key); });
|
||||||
|
await page.reload(); await openEditor(page);
|
||||||
|
await expect(editor(page).getByRole('checkbox', { name: 'Summary cards', exact: true })).not.toBeChecked();
|
||||||
|
for (const checkbox of await editor(page).getByRole('checkbox').all()) await checkbox.uncheck();
|
||||||
|
await save(page); await page.reload();
|
||||||
|
await expect(page.getByText('Your dashboard sections are hidden.', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.locator('[data-dashboard-widget]')).toHaveCount(0);
|
||||||
|
expect((await readLayout(page.request)).widgets.every(widget => !widget.visible)).toBe(true);
|
||||||
|
await capture(page, info, 'all-nine-hidden');
|
||||||
|
await openEditor(page); await editor(page).getByRole('checkbox', { name: 'Current pipeline', exact: true }).check(); await save(page);
|
||||||
|
await expect(page.locator('[data-dashboard-widget="funnel"]')).toBeVisible();
|
||||||
|
await expect(page.getByText('Your dashboard sections are hidden.', { exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P8] Editor reflows across EN/NB themes and desktop/tablet/mobile with native accessible controls', async ({ page, account }, info) => {
|
||||||
|
test.setTimeout(180000);
|
||||||
|
for (const language of ['en', 'nb'] as const) for (const mode of ['light', 'dark'] as const) {
|
||||||
|
const t = translations[language];
|
||||||
|
await page.evaluate(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
|
||||||
|
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: account.headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: t.dashboardCustomize, exact: true }).click();
|
||||||
|
const panel = page.getByRole('region', { name: t.dashboardEditing, exact: true });
|
||||||
|
await expect(panel.getByRole('list', { name: t.dashboardManageWidgets })).toBeVisible();
|
||||||
|
await expect(panel.getByRole('checkbox')).toHaveCount(9);
|
||||||
|
await expect(panel.getByRole('combobox')).toHaveCount(9);
|
||||||
|
await expect(panel.getByRole('combobox').first()).toBeDisabled();
|
||||||
|
expect(await panel.getByRole('combobox').evaluateAll(nodes => nodes.every(node => node.tagName === 'SELECT'))).toBe(true);
|
||||||
|
for (const [width, height] of [[1440, 900], [768, 1024], [390, 844], [320, 740]]) {
|
||||||
|
await page.setViewportSize({ width, height });
|
||||||
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||||
|
await capture(page, info, `editor-${language}-${mode}-${width}`);
|
||||||
|
}
|
||||||
|
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||||
|
await info.attach(`editor-${language}-${mode}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete }), contentType: 'application/json' });
|
||||||
|
expect(axe.violations).toEqual([]);
|
||||||
|
expect(axe.incomplete.filter(result => result.id === 'aria-prohibited-attr')).toEqual([]);
|
||||||
|
await panel.getByRole('button', { name: t.cancel, exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: t.dashboardCustomize, exact: true })).toBeFocused();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P8] Keyboard move focus/live status, pointer drag and emulated touch buttons preserve DOM order', async ({ page, browser, account }, info) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
|
await openEditor(page);
|
||||||
|
const earlier = editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true });
|
||||||
|
await earlier.focus(); await page.keyboard.press('Enter');
|
||||||
|
await expect(earlier).toBeFocused();
|
||||||
|
await expect(editor(page).getByRole('status')).toHaveText('Current pipeline moved to position 2 of 9.');
|
||||||
|
await expect.poll(() => editor(page).locator('[data-layout-row]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-layout-row')))).toEqual(['cards', 'funnel', 'attention', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']);
|
||||||
|
await editor(page).getByRole('button', { name: 'Drag Current pipeline to reorder', exact: true }).dragTo(editor(page).locator('[data-layout-row="cards"]'));
|
||||||
|
await expect(editor(page).getByRole('status')).toHaveText('Current pipeline moved to position 1 of 9.');
|
||||||
|
await expect(editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true })).toBeDisabled();
|
||||||
|
await save(page);
|
||||||
|
expect((await readLayout(page.request)).widgets[0].id).toBe('funnel');
|
||||||
|
const touch = await browser.newContext({ baseURL: 'http://localhost:3300', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
try {
|
||||||
|
await localOnly(touch); await login(touch.request, account.email);
|
||||||
|
const mobile = await touch.newPage(); await mobile.goto('/dashboard');
|
||||||
|
await mobile.getByRole('button', { name: 'Customize dashboard', exact: true }).tap();
|
||||||
|
await editor(mobile).getByRole('button', { name: 'Move Current pipeline later', exact: true }).tap();
|
||||||
|
await expect(editor(mobile).getByRole('status')).toHaveText('Current pipeline moved to position 2 of 9.');
|
||||||
|
await save(mobile);
|
||||||
|
expect((await readLayout(touch.request)).widgets.slice(0, 2).map(widget => widget.id)).toEqual(['cards', 'funnel']);
|
||||||
|
await capture(mobile, info, 'emulated-touch-button-order');
|
||||||
|
await info.attach('device-evidence-limit', { body: 'Chromium desktop pointer drag and emulated touch button input only. Physical touch, software keyboards and human screen reader acceptance remain untested. Axe incomplete items require review.', contentType: 'text/plain' });
|
||||||
|
} finally { await touch.close(); }
|
||||||
|
});
|
||||||
@@ -363,7 +363,7 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
|||||||
{summaryReady && hasJobs ? <Box sx={grid}>{reports.stageAge}{reports.sources}</Box> : null}
|
{summaryReady && hasJobs ? <Box sx={grid}>{reports.stageAge}{reports.sources}</Box> : null}
|
||||||
{reports.salary}
|
{reports.salary}
|
||||||
<Box sx={grid}>{reports.companies}{reports.skills}</Box>
|
<Box sx={grid}>{reports.companies}{reports.skills}</Box>
|
||||||
</> : <Box aria-label={layoutState.editing ? t("dashboardPreview") : undefined} sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 3, alignItems: "start" }}>
|
</> : <Box role={layoutState.editing ? "region" : undefined} aria-label={layoutState.editing ? t("dashboardPreview") : undefined} sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 3, alignItems: "start" }}>
|
||||||
{activeLayout.widgets.filter(widget => widget.visible).map(widget => {
|
{activeLayout.widgets.filter(widget => widget.visible).map(widget => {
|
||||||
const content = reports[widget.id];
|
const content = reports[widget.id];
|
||||||
const ready = widget.id === "activity" || widget.id === "skills" ? trendsReady : summaryReady;
|
const ready = widget.id === "activity" || widget.id === "skills" ? trendsReady : summaryReady;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ async function edit() { fireEvent.click(await screen.findByRole('button', { name
|
|||||||
test('visibility is a preview; cancel restores the saved layout without a write', async () => {
|
test('visibility is a preview; cancel restores the saved layout without a write', async () => {
|
||||||
open(); await screen.findByText('Tracked jobs');
|
open(); await screen.findByText('Tracked jobs');
|
||||||
const editor = await edit(); fireEvent.click(within(editor).getByRole('checkbox', { name: 'Summary cards' }));
|
const editor = await edit(); fireEvent.click(within(editor).getByRole('checkbox', { name: 'Summary cards' }));
|
||||||
|
expect(screen.getByRole('region', { name: 'Dashboard preview' })).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
|
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
|
||||||
fireEvent.click(within(editor).getByRole('button', { name: 'Cancel' }));
|
fireEvent.click(within(editor).getByRole('button', { name: 'Cancel' }));
|
||||||
const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: 'Discard changes' }));
|
const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: 'Discard changes' }));
|
||||||
|
|||||||
Reference in New Issue
Block a user