Compare commits

..

2 Commits

Author SHA1 Message Date
cesnimda fa85fcdd8c test(discovery): reconcile native journeys with filter drawer 2026-11-11 07:56:57 +01:00
cesnimda a584fc0b1b fix(dialog): recover initial focus from nested modal fallback 2026-11-11 07:45:12 +01:00
56 changed files with 950 additions and 616 deletions
-43
View File
@@ -1,43 +0,0 @@
# Date-picker calendar-day preservation
11 November 2026. Baseline `ec57a24d77854bb416c9ea02b8b371be3e5554c0`, isolated branch `codex/i18n-deadline-picker-date`.
The Add and Edit job dialogs passed date-only strings through `new Date(value)`, which interprets a literal `YYYY-MM-DD` at UTC midnight. In Los Angeles, opening 23 November displayed the 22nd. Their picker change handlers then used `toISOString().slice(0, 10)`. Selecting local midnight on 23 November in Oslo or Kiritimati submitted the 22nd.
The existing wrapper pairs now use installed date-fns `parseISO` for literal dates and `format(value, "yyyy-MM-dd")` for the picker's local Date output. Non-literal parsing retains `new Date(value)`. Empty and invalid picker values retain their existing clearing behaviour. Edit's separate legacy `toDateInputValue` loading policy is preserved.
The affected callers are Add's applied date and deadline, and Edit's applied date, status-change date, reply date, follow-up date and deadline. All seven controls are date pickers backed by date-only state and request values. Revision and submit handlers are retained. There is no backend, timestamp serialization, dependency or migration change.
## Verification
The new component regression drives the actual Add/Edit state and conversion handlers with a picker test double implementing MUI's local-Date callback contract. Each run starts a new Jest process with the recorded timezone. It checks EN/NB values, a full create/save payload, source revision, five Edit/two Add controls, clearing, invalid picker values and an offset timestamp using the existing legacy loading policy.
| Process timezone | Before fix | After fix |
| --- | --- | --- |
| America/Los_Angeles | 5 failed | 5 passed |
| Europe/Oslo | 4 failed, 1 passed | 5 passed |
| Pacific/Kiritimati | 4 failed, 1 passed | 5 passed |
These are the same five cases executed in three environments. The nearby run passed 21 cases across date-picker, edit-revision, quick-capture and native file-input suites, including those same five cases. TypeScript without emit, ESLint on the three changed files and tracked source whitespace checks passed. The cached evidence check flagged the original TypeScript receipt's blank line at EOF. That raw receipt is retained unchanged in `evidence/date-picker-calendar-day/`.
Actual MUI browser interaction, screenshots, native devices and provider/API server verification were not run in this isolated increment. The root integration owner will qualify the rendered picker separately. Final I18N, A3, security and release acceptance remain open.
## Other reviewed date contracts
`CalendarAgenda` validates its date-only response, parses it at UTC noon and explicitly formats in UTC. The reviewed 23 November value stayed on the 23rd in all three zones. Its backend groups by the stored calendar date and explicitly retains the legacy timezone/precision limitation.
`CompaniesTable` formats next-contact and relationship dates using the viewer's local timezone. Its edit form takes the raw first ten characters. Literal `2026-11-23` therefore displays as the 22nd in Los Angeles, while `2026-11-23T23:30:00Z` displays as the 24th in Oslo and opens as the 23rd in the editor. The backend fields remain legacy `DateTime?` with no precision discriminator. The current locale test covers a noon UTC timestamp. These display/loading cases require a qualified date-versus-timestamp contract before a broader change.
The Jobs table similarly formats a literal deadline through local `Date`, and its current test expects that expression. Edit's retained `toDateInputValue` can also move an unspecified midnight timestamp back a day in positive zones. These are separate legacy precision/loading findings. This fix does not claim their closure or reconstruct missing timezone metadata.
Rollback reverts the two wrapper changes and their regression package. Existing persisted dates are not rewritten.
## Follow-up local-today default
From isolated checkpoint `f7c3617e`, Add's `getTodayIso` still selected the UTC date for initialization and a successful form reset. It now uses the already imported `format` with the browser's local Date. No persisted record is rewritten.
Two new component cases fix the UTC clock at `2026-11-23T00:30:00Z` and `2026-11-23T23:30:00Z`. Each timezone's red run had one failure and one pass, with the earlier five cases skipped. The final full suite passed seven cases in each of Los Angeles, Oslo and Kiritimati. That is seven distinct cases repeated in three environments, including the five earlier picker cases. TypeScript and two-file lint passed. The earlier 21-case nearby run belongs to the preceding checkpoint and was not rerun for this one-line follow-up.
The new cases cover initial creation, Create and add another resetting the default, and closing/reopening the resulting draft. Closing and reopening the same mounted dialog preserves its existing step and draft. It does not reset every open. The initial test draft assumed otherwise and was corrected to exercise the actual successful reset path. The only write is the explicit mocked creation request, whose selected date is asserted. API server, actual browser, export build and native checks remain unexecuted.
Follow-up receipts are in `evidence/date-picker-local-today/`. The legacy loading and Companies contract findings above remain open.
@@ -1,9 +0,0 @@
> job-tracker-ui@0.1.0 test
> jest --runInBand job-date-picker-calendar.test.tsx
Test Suites: 1 passed, 1 total
Tests: 5 passed, 5 total
Snapshots: 0 total
Time: 6.688 s
Ran all test suites matching job-date-picker-calendar.test.tsx.
@@ -1,9 +0,0 @@
> job-tracker-ui@0.1.0 test
> jest --runInBand job-date-picker-calendar.test.tsx
Test Suites: 1 passed, 1 total
Tests: 5 passed, 5 total
Snapshots: 0 total
Time: 6.561 s
Ran all test suites matching job-date-picker-calendar.test.tsx.
@@ -1,9 +0,0 @@
> job-tracker-ui@0.1.0 test
> jest --runInBand job-date-picker-calendar.test.tsx
Test Suites: 1 passed, 1 total
Tests: 5 passed, 5 total
Snapshots: 0 total
Time: 7.132 s
Ran all test suites matching job-date-picker-calendar.test.tsx.
@@ -1,10 +0,0 @@
> job-tracker-ui@0.1.0 test
> jest --runInBand job-date-picker-calendar.test.tsx edit-job-revision.test.tsx quick-capture.test.tsx file-picker-activation.test.tsx
Test Suites: 4 passed, 4 total
Tests: 21 passed, 21 total
Snapshots: 0 total
Time: 15.02 s
Ran all test suites matching job-date-picker-calendar.test.tsx|edit-job-revision.test.tsx|quick-capture.test.tsx|file-picker-activation.test.tsx.
@@ -1,105 +0,0 @@
> job-tracker-ui@0.1.0 test
> jest --runInBand job-date-picker-calendar.test.tsx
FAIL src/job-date-picker-calendar.test.tsx
● en Add date-only picker round trip retains the selected day and existing creation payload
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-23
Received:
2026-11-22
39 | render(wrap(<AddJobModal open initialUrl="https://example.test/job" onClose={jest.fn()} onCreated={jest.fn()} />));
40 | await screen.findByDisplayValue("Teacher");
> 41 | expect(screen.getByLabelText(copy.addJobModalDeadline)).toHaveTextContent("2026-11-23");
| ^
42 | fireEvent.click(screen.getByRole("button", { name: `${copy.addJobModalDeadline} clear` }));
43 | expect(screen.getByLabelText(copy.addJobModalDeadline)).toBeEmptyDOMElement();
44 | fireEvent.click(screen.getByRole("button", { name: `${copy.addJobModalDeadline} invalid` }));
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:41:59)
● nb Add date-only picker round trip retains the selected day and existing creation payload
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-23
Received:
2026-11-22
39 | render(wrap(<AddJobModal open initialUrl="https://example.test/job" onClose={jest.fn()} onCreated={jest.fn()} />));
40 | await screen.findByDisplayValue("Teacher");
> 41 | expect(screen.getByLabelText(copy.addJobModalDeadline)).toHaveTextContent("2026-11-23");
| ^
42 | fireEvent.click(screen.getByRole("button", { name: `${copy.addJobModalDeadline} clear` }));
43 | expect(screen.getByLabelText(copy.addJobModalDeadline)).toBeEmptyDOMElement();
44 | fireEvent.click(screen.getByRole("button", { name: `${copy.addJobModalDeadline} invalid` }));
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:41:59)
● en Edit date-only picker round trip preserves all five date controls and revision payload
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-23
Received:
2026-11-22
59 | render(wrap(<EditJobDialog open jobId={1} onClose={jest.fn()} onSaved={jest.fn()} />));
60 | await screen.findByDisplayValue("Teacher");
> 61 | expect(screen.getByLabelText(copy.editJobDeadline)).toHaveTextContent("2026-11-23");
| ^
62 | fireEvent.click(screen.getByRole("button", { name: `${copy.editJobDeadline} clear` }));
63 | fireEvent.click(screen.getByRole("button", { name: `${copy.editJobDeadline} invalid` }));
64 | expect(screen.getByLabelText(copy.editJobDeadline)).toBeEmptyDOMElement();
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:61:55)
● nb Edit date-only picker round trip preserves all five date controls and revision payload
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-23
Received:
2026-11-22
59 | render(wrap(<EditJobDialog open jobId={1} onClose={jest.fn()} onSaved={jest.fn()} />));
60 | await screen.findByDisplayValue("Teacher");
> 61 | expect(screen.getByLabelText(copy.editJobDeadline)).toHaveTextContent("2026-11-23");
| ^
62 | fireEvent.click(screen.getByRole("button", { name: `${copy.editJobDeadline} clear` }));
63 | fireEvent.click(screen.getByRole("button", { name: `${copy.editJobDeadline} invalid` }));
64 | expect(screen.getByLabelText(copy.editJobDeadline)).toBeEmptyDOMElement();
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:61:55)
● Edit retains its existing UTC calendar-day loading policy for a legacy offset timestamp
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-24
Received:
2026-11-23
75 | render(wrap(<EditJobDialog open jobId={1} onClose={jest.fn()} onSaved={jest.fn()} />));
76 | await screen.findByDisplayValue("Teacher");
> 77 | expect(screen.getByLabelText(translations.en.editJobDeadline)).toHaveTextContent("2026-11-24");
| ^
78 | expect(client.put).not.toHaveBeenCalled();
79 | });
80 |
at Object.toHaveTextContent (src/job-date-picker-calendar.test.tsx:77:66)
Test Suites: 1 failed, 1 total
Tests: 5 failed, 5 total
Snapshots: 0 total
Time: 4.077 s
Ran all test suites matching job-date-picker-calendar.test.tsx.
@@ -1,86 +0,0 @@
> job-tracker-ui@0.1.0 test
> jest --runInBand job-date-picker-calendar.test.tsx
FAIL src/job-date-picker-calendar.test.tsx
● en Add date-only picker round trip retains the selected day and existing creation payload
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-23
Received:
2026-11-22
46 | for (const label of [copy.addJobModalDateApplied, copy.addJobModalDeadline]) {
47 | fireEvent.click(screen.getByRole("button", { name: `${label} choose` }));
> 48 | expect(screen.getByLabelText(label)).toHaveTextContent("2026-11-23");
| ^
49 | }
50 | fireEvent.click(screen.getByRole("button", { name: copy.continue }));
51 | for (let step = 0; step < 3; step++) fireEvent.click(screen.getByRole("button", { name: copy.skipAndContinue }));
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:48:42)
● nb Add date-only picker round trip retains the selected day and existing creation payload
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-23
Received:
2026-11-22
46 | for (const label of [copy.addJobModalDateApplied, copy.addJobModalDeadline]) {
47 | fireEvent.click(screen.getByRole("button", { name: `${label} choose` }));
> 48 | expect(screen.getByLabelText(label)).toHaveTextContent("2026-11-23");
| ^
49 | }
50 | fireEvent.click(screen.getByRole("button", { name: copy.continue }));
51 | for (let step = 0; step < 3; step++) fireEvent.click(screen.getByRole("button", { name: copy.skipAndContinue }));
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:48:42)
● en Edit date-only picker round trip preserves all five date controls and revision payload
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-23
Received:
2026-11-22
65 | for (const label of [copy.editJobAppliedOn, copy.editJobStatusChangedOn, copy.editJobReplyReceivedOn, copy.editJobFollowUpOn, copy.editJobDeadline]) {
66 | fireEvent.click(screen.getByRole("button", { name: `${label} choose` }));
> 67 | expect(screen.getByLabelText(label)).toHaveTextContent("2026-11-23");
| ^
68 | }
69 | fireEvent.click(screen.getByRole("button", { name: copy.save }));
70 | await waitFor(() => expect(client.put).toHaveBeenCalledWith("/jobapplications/1", expect.objectContaining({ expectedVersion: 7, dateApplied: "2026-11-23", deadline: "2026-11-23", followUpAt: "2026-11-23", responseDate: "2026-11-23" })));
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:67:42)
● nb Edit date-only picker round trip preserves all five date controls and revision payload
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-23
Received:
2026-11-22
65 | for (const label of [copy.editJobAppliedOn, copy.editJobStatusChangedOn, copy.editJobReplyReceivedOn, copy.editJobFollowUpOn, copy.editJobDeadline]) {
66 | fireEvent.click(screen.getByRole("button", { name: `${label} choose` }));
> 67 | expect(screen.getByLabelText(label)).toHaveTextContent("2026-11-23");
| ^
68 | }
69 | fireEvent.click(screen.getByRole("button", { name: copy.save }));
70 | await waitFor(() => expect(client.put).toHaveBeenCalledWith("/jobapplications/1", expect.objectContaining({ expectedVersion: 7, dateApplied: "2026-11-23", deadline: "2026-11-23", followUpAt: "2026-11-23", responseDate: "2026-11-23" })));
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:67:42)
Test Suites: 1 failed, 1 total
Tests: 4 failed, 1 passed, 5 total
Snapshots: 0 total
Time: 4.882 s
Ran all test suites matching job-date-picker-calendar.test.tsx.
@@ -1,86 +0,0 @@
> job-tracker-ui@0.1.0 test
> jest --runInBand job-date-picker-calendar.test.tsx
FAIL src/job-date-picker-calendar.test.tsx
● en Add date-only picker round trip retains the selected day and existing creation payload
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-23
Received:
2026-11-22
46 | for (const label of [copy.addJobModalDateApplied, copy.addJobModalDeadline]) {
47 | fireEvent.click(screen.getByRole("button", { name: `${label} choose` }));
> 48 | expect(screen.getByLabelText(label)).toHaveTextContent("2026-11-23");
| ^
49 | }
50 | fireEvent.click(screen.getByRole("button", { name: copy.continue }));
51 | for (let step = 0; step < 3; step++) fireEvent.click(screen.getByRole("button", { name: copy.skipAndContinue }));
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:48:42)
● nb Add date-only picker round trip retains the selected day and existing creation payload
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-23
Received:
2026-11-22
46 | for (const label of [copy.addJobModalDateApplied, copy.addJobModalDeadline]) {
47 | fireEvent.click(screen.getByRole("button", { name: `${label} choose` }));
> 48 | expect(screen.getByLabelText(label)).toHaveTextContent("2026-11-23");
| ^
49 | }
50 | fireEvent.click(screen.getByRole("button", { name: copy.continue }));
51 | for (let step = 0; step < 3; step++) fireEvent.click(screen.getByRole("button", { name: copy.skipAndContinue }));
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:48:42)
● en Edit date-only picker round trip preserves all five date controls and revision payload
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-23
Received:
2026-11-22
65 | for (const label of [copy.editJobAppliedOn, copy.editJobStatusChangedOn, copy.editJobReplyReceivedOn, copy.editJobFollowUpOn, copy.editJobDeadline]) {
66 | fireEvent.click(screen.getByRole("button", { name: `${label} choose` }));
> 67 | expect(screen.getByLabelText(label)).toHaveTextContent("2026-11-23");
| ^
68 | }
69 | fireEvent.click(screen.getByRole("button", { name: copy.save }));
70 | await waitFor(() => expect(client.put).toHaveBeenCalledWith("/jobapplications/1", expect.objectContaining({ expectedVersion: 7, dateApplied: "2026-11-23", deadline: "2026-11-23", followUpAt: "2026-11-23", responseDate: "2026-11-23" })));
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:67:42)
● nb Edit date-only picker round trip preserves all five date controls and revision payload
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-23
Received:
2026-11-22
65 | for (const label of [copy.editJobAppliedOn, copy.editJobStatusChangedOn, copy.editJobReplyReceivedOn, copy.editJobFollowUpOn, copy.editJobDeadline]) {
66 | fireEvent.click(screen.getByRole("button", { name: `${label} choose` }));
> 67 | expect(screen.getByLabelText(label)).toHaveTextContent("2026-11-23");
| ^
68 | }
69 | fireEvent.click(screen.getByRole("button", { name: copy.save }));
70 | await waitFor(() => expect(client.put).toHaveBeenCalledWith("/jobapplications/1", expect.objectContaining({ expectedVersion: 7, dateApplied: "2026-11-23", deadline: "2026-11-23", followUpAt: "2026-11-23", responseDate: "2026-11-23" })));
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:67:42)
Test Suites: 1 failed, 1 total
Tests: 4 failed, 1 passed, 5 total
Snapshots: 0 total
Time: 4.669 s
Ran all test suites matching job-date-picker-calendar.test.tsx.
@@ -1,4 +0,0 @@
> job-tracker-ui@0.1.0 typecheck
> tsc --noEmit --incremental false
@@ -1,9 +0,0 @@
> job-tracker-ui@0.1.0 test
> jest --runInBand job-date-picker-calendar.test.tsx
Test Suites: 1 passed, 1 total
Tests: 7 passed, 7 total
Snapshots: 0 total
Time: 8.432 s
Ran all test suites matching job-date-picker-calendar.test.tsx.
@@ -1,9 +0,0 @@
> job-tracker-ui@0.1.0 test
> jest --runInBand job-date-picker-calendar.test.tsx
Test Suites: 1 passed, 1 total
Tests: 7 passed, 7 total
Snapshots: 0 total
Time: 8.287 s
Ran all test suites matching job-date-picker-calendar.test.tsx.
@@ -1,9 +0,0 @@
> job-tracker-ui@0.1.0 test
> jest --runInBand job-date-picker-calendar.test.tsx
Test Suites: 1 passed, 1 total
Tests: 7 passed, 7 total
Snapshots: 0 total
Time: 8.955 s
Ran all test suites matching job-date-picker-calendar.test.tsx.
@@ -1,29 +0,0 @@
> job-tracker-ui@0.1.0 test
> jest --runInBand job-date-picker-calendar.test.tsx -t Add defaults and successful reset
FAIL src/job-date-picker-calendar.test.tsx
● Add defaults and successful reset use local today at 2026-11-23T00:30:00Z
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-22
Received:
2026-11-23
43 | const view = render(form(true));
44 | fireEvent.click(screen.getByRole("button", { name: translations.en.addJobModalEnterManually }));
> 45 | expect(screen.getByLabelText(translations.en.addJobModalDateApplied)).toHaveTextContent(expected);
| ^
46 | fireEvent.click(screen.getByRole("button", { name: `${translations.en.addJobModalDateApplied} choose` }));
47 | fireEvent.change(screen.getByRole("combobox", { name: translations.en.company }), { target: { value: "Synthetic school" } });
48 | fireEvent.change(screen.getByLabelText(translations.en.addJobModalJobTitle), { target: { value: "Teacher" } });
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:45:73)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 5 skipped, 1 passed, 7 total
Snapshots: 0 total
Time: 4.694 s
Ran all test suites matching job-date-picker-calendar.test.tsx with tests matching "Add defaults and successful reset".
@@ -1,29 +0,0 @@
> job-tracker-ui@0.1.0 test
> jest --runInBand job-date-picker-calendar.test.tsx -t Add defaults and successful reset
FAIL src/job-date-picker-calendar.test.tsx
● Add defaults and successful reset use local today at 2026-11-23T23:30:00Z
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-24
Received:
2026-11-23
43 | const view = render(form(true));
44 | fireEvent.click(screen.getByRole("button", { name: translations.en.addJobModalEnterManually }));
> 45 | expect(screen.getByLabelText(translations.en.addJobModalDateApplied)).toHaveTextContent(expected);
| ^
46 | fireEvent.click(screen.getByRole("button", { name: `${translations.en.addJobModalDateApplied} choose` }));
47 | fireEvent.change(screen.getByRole("combobox", { name: translations.en.company }), { target: { value: "Synthetic school" } });
48 | fireEvent.change(screen.getByLabelText(translations.en.addJobModalJobTitle), { target: { value: "Teacher" } });
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:45:73)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 5 skipped, 1 passed, 7 total
Snapshots: 0 total
Time: 4.977 s
Ran all test suites matching job-date-picker-calendar.test.tsx with tests matching "Add defaults and successful reset".
@@ -1,29 +0,0 @@
> job-tracker-ui@0.1.0 test
> jest --runInBand job-date-picker-calendar.test.tsx -t Add defaults and successful reset
FAIL src/job-date-picker-calendar.test.tsx
● Add defaults and successful reset use local today at 2026-11-23T23:30:00Z
expect(element).toHaveTextContent()
Expected element to have text content:
2026-11-24
Received:
2026-11-23
43 | const view = render(form(true));
44 | fireEvent.click(screen.getByRole("button", { name: translations.en.addJobModalEnterManually }));
> 45 | expect(screen.getByLabelText(translations.en.addJobModalDateApplied)).toHaveTextContent(expected);
| ^
46 | fireEvent.click(screen.getByRole("button", { name: `${translations.en.addJobModalDateApplied} choose` }));
47 | fireEvent.change(screen.getByRole("combobox", { name: translations.en.company }), { target: { value: "Synthetic school" } });
48 | fireEvent.change(screen.getByLabelText(translations.en.addJobModalJobTitle), { target: { value: "Teacher" } });
at toHaveTextContent (src/job-date-picker-calendar.test.tsx:45:73)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 5 skipped, 1 passed, 7 total
Snapshots: 0 total
Time: 4.542 s
Ran all test suites matching job-date-picker-calendar.test.tsx with tests matching "Add defaults and successful reset".
@@ -1,4 +0,0 @@
> job-tracker-ui@0.1.0 typecheck
> tsc --noEmit --incremental false
@@ -0,0 +1,52 @@
# Existing discovery browser tests: mobile drawer reconciliation
Status: **PASSED — all 11 existing tests passed together on the exact final test source, with no retries, skips or flaky outcomes.** This is bounded browser regression evidence, not full A3 or release acceptance.
Baseline: published A3 `ec57a24d77854bb416c9ea02b8b371be3e5554c0`. Isolated branch `codex/a3-discovery-native-drawer`; worktree `C:\Users\Connor\.codex\worktrees\a3-discovery-native-drawer\Job tracker`.
This package changes only `e2e/phase7-discovery.spec.ts`, `e2e/saved-search-repair.spec.ts` and bounded evidence. Final application source is the baseline plus the root-owned confirmation fix `df93bfb64eb80dbfa30f86c89b54a9021c43a9c6`, cherry-picked locally as `a584fc0b`. That separate source fix must not be applied twice when integrating this test/evidence package. The separate Add/Edit dates package is not included.
## Changes
Existing narrow journeys now explicitly open Search filters, edit drawer fields, close or jump to results, and reopen filters for saved-row actions/readback. The top-level query remains outside the modal; draft edits use the real visible controls in sequence. English/Bokmål drafts and native owner writes/readbacks remain asserted. Held requests continue checking disabled mounted controls, closed-drawer unavailability, one request per activation, progress announcements, stable retry context and focus. Busy assertions do not open a drawer or move focus away from the pending control. Drawer helpers use authoritative `aria-expanded` state and include hidden exiting dialog nodes when waiting for physical unmount, avoiding transition-time locator snapshots.
Confirmation locators identify their own title because the filter Drawer is also a dialog. Dismiss errors are scoped to the visible Results region, distinguishing its error from the retained hidden drawer copy. The result-region keyboard journey now asserts the new Return to search button before the existing Sort control. The desktop language test uses the current Norsk bokmål accessible name. The mobile user-moved-focus test uses the persistent header User control as its neutral focus destination.
No provider rights, routes, application behavior, persistence assertions, error/focus assertions or approval gates are relaxed. Only previously documented vacancy search/run output and explicitly injected faults/dismiss presentation use interception. Native saved-search listing/create/alert updates/delete and foreign/anonymous denials remain actual isolated API calls. The provider boundary remains recorded in each suite.
## Execution and evidence
All runs used one serialized Chromium worker, Playwright-managed API/Next servers, a unique disposable SQLite root, disabled model/import/alert workers and the installed .NET 9.0.317 executable. No reused server, external provider or model was used. The root's separate new drawer and controlled saved-search matrices were not repeated.
| Native run | Result | Scope |
| --- | --- | --- |
| pilot | 3 passed, 1 failed | Held saved B, failed saved-run retry, native keyboard create/alert/delete, native saved-list faults |
| remainder | 5 passed, 2 failed | Other seven existing cases; Dismiss and locale failed on obsolete selectors/tab assumptions |
| focused | 1 passed, 2 failed | Locale corrected; Dismiss reached new Return to search tab stop; confirmation focus diagnostic reproduced |
| dismiss | 3 passed | Corrected Dismiss plus held-B/retry; later helper review identified avoidable focus movement |
| focus-preserved | 2 passed, 1 failed | Busy checks preserved original focus; held-B exposed exiting-drawer locator timing |
| confirm-fix | 1 passed, 1 failed | Original native keyboard create/delete passed on root modal fix; held-B helper still transient |
| held-final | 1 failed | Isolated held-B helper race, retained as failed evidence |
| held-stable | 1 passed | Focus-preserving helper stabilization; later combined run exposed hidden-node timing |
| final11 | 10 passed, 1 failed | First combined run; only exiting-drawer locator timing remained |
| final-stable11 | **11 passed** | Exact final helpers/callers plus root modal fix; complete two-spec run |
These runs overlap and must not be summed. The final combined run alone passed **11 tests**, including the full native keyboard cancellation/held-delete journey, with **31 screenshots and 27 automated accessibility audits**. Early native-keyboard failures did not execute their subsequent steps; the final combined run did. The final raw receipt reports zero skipped, unexpected or flaky outcomes.
Exact native Playwright JSON/list outputs are gzip-preserved under [evidence/discovery-native-drawer](evidence/discovery-native-drawer/native-receipts.json). The manifest records raw/compressed byte lengths and SHA256 hashes. [Latest distinct outcomes](evidence/discovery-native-drawer/latest-distinct-outcomes.json) identifies the latest execution for every existing case. JSON retains screenshots/audits/request-boundary/focus attachments. These are actual local executions, not production or human/device acceptance.
- **PASSED:** Release API build: zero warnings/errors.
- **PASSED:** TypeScript and scoped ESLint.
- **PASSED:** `git diff --check`.
- **PASSED:** `graphify update .`, local AST-only graph refresh. Missing optional PDF/SQL extractors and files without extracted symbols remain graph coverage limits. Generated graph files remain local/untracked.
- **NOT RUN:** Hosted MariaDB, providers, live models, human accessibility/device qualification, production acceptance.
Startup logs contain existing SQLite migration and disposable Data Protection key warnings. The final combined run contains **zero UNIQUE-constraint errors**; no checklist recovery claim is needed for that run.
## Reproduced application issue and resolution
The Bokmål 360px native keyboard journey opens a saved-search Delete confirmation above the already open temporary Search filters Drawer. Before the root fix, Cancel had `autoFocus` but the actual active element was a `DIV` with `role="presentation"` containing the confirmation. The original Cancel-focus assertion reproduced the defect and remains intact.
The drawer was reopened after a manual Search had automatically returned to Results. [Failed active element](evidence/discovery-native-drawer/focused-confirmation-failure/active-element.json), native trace, screenshot and error context preserve the observation. The root fix recovers Cancel on transition completion only when focus remains exactly on the confirmation container, preserving intentional focus elsewhere. The single original case then passed; the final combined run passed its original focus trap, Cancel restoration, held native DELETE, request-count and persisted deletion checks. [Final pre-assertion focus snapshot](evidence/discovery-native-drawer/final-confirmation-focus.json) still observes the container before transition completion; the subsequent unchanged polling assertion establishes that Cancel receives focus afterward. No blanket focus-enforcement changes or relaxed assertions were introduced.
Rollback: revert this test/evidence package to restore the old callers. Application rollback is separately owned by the root confirmation fix. Publication/integration is left to the parent development lane. Hosted MariaDB, live providers/models, human/device accessibility and final integrated security/release gates remain separate.
@@ -0,0 +1 @@
{"state":"native-delete-confirm-initial","focused":{"tag":"DIV","id":"","role":"presentation","label":null,"text":"Slette lagret søk?Denne handlingen kan være vanskelig å angre.Slette «Gjennomgått utviklersøk Æ Ø Å» og historikken over viste og avviste st"},"qualification":"Actual document.activeElement in isolated Chromium, not a human screen-reader or physical-device acceptance claim."}
@@ -0,0 +1 @@
{"state":"native-delete-confirm-initial","focused":{"tag":"DIV","id":"","role":"presentation","label":null,"text":"Slette lagret søk?Denne handlingen kan være vanskelig å angre.Slette «Gjennomgått utviklersøk Æ Ø Å» og historikken over viste og avviste st"},"qualification":"Actual document.activeElement in isolated Chromium, not a human screen-reader or physical-device acceptance claim."}
@@ -0,0 +1,239 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: phase7-discovery.spec.ts >> [P7-05 Discovery] keyboard native create and alert updates persist; delete Cancel restores focus and held delete starts once
- Location: e2e\phase7-discovery.spec.ts:354:1
# Error details
```
Error: expect(locator).toBeFocused() failed
Locator: getByRole('dialog').filter({ has: getByRole('heading', { name: 'Slette lagret søk?', exact: true }) }).getByRole('button', { name: 'Avbryt', exact: true })
Expected: focused
Received: inactive
Timeout: 5000ms
Call log:
- Expect "toBeFocused" with timeout 5000ms
- waiting for getByRole('dialog').filter({ has: getByRole('heading', { name: 'Slette lagret søk?', exact: true }) }).getByRole('button', { name: 'Avbryt', exact: true })
14 × locator resolved to <button tabindex="0" type="button" class="MuiButtonBase-root MuiButton-root MuiButton-text MuiButton-textPrimary MuiButton-sizeMedium MuiButton-textSizeMedium MuiButton-colorPrimary MuiButton-root MuiButton-text MuiButton-textPrimary MuiButton-sizeMedium MuiButton-textSizeMedium MuiButton-colorPrimary css-v8jn2b-MuiButtonBase-root-MuiButton-root">…</button>
- unexpected value "inactive"
```
```yaml
- button "Avbryt"
```
# Test source
```ts
300 | const job = vacancy('mutation-current');
301 | await page.route(`${savedUrl}/${saved.id}/run`, async route => route.fulfill({ json: await runPayload(page, saved, [job]) }));
302 | let createFails = true; let alertFails = true; let deleteFails = true;
303 | await page.route(savedUrl, route => route.request().method() === 'POST' && createFails
304 | ? route.fulfill({ status: 500, json: { message: 'Controlled create failure' } }) : route.continue());
305 | await page.route(`${savedUrl}/${saved.id}`, route => (route.request().method() === 'PATCH' && alertFails) || (route.request().method() === 'DELETE' && deleteFails)
306 | ? route.fulfill({ status: 500, json: { message: 'Controlled mutation failure' } }) : route.continue());
307 | await enter(page, session.owner);
308 | await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
309 | await expect(article(page, job)).toBeVisible();
310 | await draftFilters(page, 'retained draft role', 'Ålesund');
311 | await page.getByLabel(t.jobDiscoverySavedName, { exact: true }).fill('Retained failed create draft');
312 | await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
313 | await expect(page.getByText(t.jobDiscoverySaveSearchFailed, { exact: true })).toBeVisible();
314 | await expect(page.getByLabel(t.jobDiscoverySavedName, { exact: true })).toHaveValue('Retained failed create draft');
315 | await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toHaveValue('retained draft role');
316 | await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
317 | expect(await nativeList(page.request)).toEqual([saved]);
318 | createFails = false;
319 | const createdResponse = response(page, 'POST', savedUrl);
320 | await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
321 | const created = await json<SavedSearch>(await createdResponse, 201);
322 | expect(created).toMatchObject({ name: 'Retained failed create draft', query: 'retained draft role', location: 'Ålesund' });
323 | await keyboardActivate(page, savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }), 'Space');
324 | await expect(page.getByText(t.jobDiscoveryUpdateAlertsFailed, { exact: true })).toBeVisible();
325 | await expect(savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true })).toBeChecked();
326 | await expect(article(page, job)).toBeVisible();
327 | await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
328 | alertFails = false;
329 | const updatedResponse = response(page, 'PATCH', `${savedUrl}/${saved.id}`);
330 | await keyboardActivate(page, savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }), 'Space');
331 | const updated = await json<SavedSearch>(await updatedResponse);
332 | expect(updated).toEqual({ ...saved, isActive: false });
333 | await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.adminUsersDelete, exact: true }));
334 | await keyboardActivate(page, page.getByRole('dialog').getByRole('button', { name: t.adminUsersDelete, exact: true }));
335 | await expect(page.getByText(t.jobDiscoveryDeleteSavedFailed, { exact: true })).toBeVisible();
336 | await expect(savedGroup(page, saved)).toBeVisible();
337 | await expect(article(page, job)).toBeVisible();
338 | await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
339 | // Native listing sorts by UpdatedAt, and pausing the older row updates that timestamp.
340 | // Compare every DTO value without inventing creation-order semantics for this readback.
341 | expect((await nativeList(page.request)).sort((left, right) => left.id - right.id)).toEqual([created, updated].sort((left, right) => left.id - right.id));
342 | expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
343 | await capture(page, info, desktop, 'mutation-failure-retains-context');
344 | deleteFails = false;
345 | const deletedResponse = response(page, 'DELETE', `${savedUrl}/${saved.id}`);
346 | await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.adminUsersDelete, exact: true }));
347 | await keyboardActivate(page, page.getByRole('dialog').getByRole('button', { name: t.adminUsersDelete, exact: true }));
348 | expect((await deletedResponse).status()).toBe(204);
349 | await expect(savedGroup(page, saved)).toHaveCount(0);
350 | expect(await nativeList(page.request)).toEqual([created]);
351 | expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
352 | });
353 |
354 | test('[P7-05 Discovery] keyboard native create and alert updates persist; delete Cancel restores focus and held delete starts once', async ({ page, session }, info) => {
355 | test.setTimeout(120_000);
356 | const t = translations.nb; const sample: Sample = { language: 'nb', theme: 'light', width: 360, height: 844 };
357 | const currentJob = vacancy('native-delete-current-manual');
358 | await page.route(`${searchUrl}**`, route => route.fulfill({ json: [currentJob] }));
359 | const createGate = hold(); const deleteGate = hold(); let creates = 0; let deletes = 0;
360 | await page.route(savedUrl, async route => {
361 | if (route.request().method() === 'POST') { creates++; await createGate.pending; }
362 | return route.continue();
363 | });
364 | await enter(page, session.owner, sample);
365 | await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toBeVisible();
366 | await draftFilters(page, ' utvikler ', ' Tromsø ', 'nb', ' Gjennomgått utviklersøk Æ Ø Å ');
367 | const createdResponse = response(page, 'POST', savedUrl);
368 | try {
369 | await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
370 | await expect.poll(() => creates).toBe(1);
371 | await locked(page, 'nb'); await page.keyboard.press('Enter'); expect(creates).toBe(1);
372 | await capture(page, info, sample, 'native-create-held');
373 | } finally { createGate.release(); }
374 | const saved = await json<SavedSearch>(await createdResponse, 201);
375 | expect(saved).toMatchObject({ name: 'Gjennomgått utviklersøk Æ Ø Å', query: 'utvikler', location: 'Tromsø', isActive: true, resultCount: 0, dismissedCount: 0 });
376 | await expect(savedGroup(page, saved)).toBeVisible();
377 | expect(await nativeList(page.request)).toEqual([saved]);
378 | await page.reload(); await openFilters(page, 'nb'); await expect(savedGroup(page, saved)).toBeVisible();
379 | for (const isActive of [false, true]) {
380 | const updatedResponse = response(page, 'PATCH', `${savedUrl}/${saved.id}`);
381 | await keyboardActivate(page, savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }), 'Space');
382 | expect(await json<SavedSearch>(await updatedResponse)).toEqual({ ...saved, isActive });
383 | expect(await nativeList(page.request)).toEqual([{ ...saved, isActive }]);
384 | await page.reload();
385 | await openFilters(page, 'nb');
386 | await expect(savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true })).toBeChecked({ checked: isActive });
387 | }
388 | // Keep a legitimate results control after deleting the saved row so the next Tab
389 | // has a concrete destination inside the stable region rather than browser chrome.
390 | await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySearchNav, exact: true }));
391 | await expect(article(page, currentJob)).toBeVisible();
392 | await openFilters(page, 'nb');
393 | const deleteButton = savedGroup(page, saved).getByRole('button', { name: t.adminUsersDelete, exact: true });
394 | await keyboardActivate(page, deleteButton);
395 | const dialog = page.getByRole('dialog').filter({ has: page.getByRole('heading', { name: t.jobDiscoveryDeleteSavedTitle, exact: true }) });
396 | await expect(dialog.getByRole('heading', { name: t.jobDiscoveryDeleteSavedTitle, exact: true })).toBeVisible();
397 | await expect(dialog.getByText(t.jobDiscoveryDeleteSavedConfirm.replace('{name}', saved.name), { exact: true })).toBeVisible();
398 | const cancel = dialog.getByRole('button', { name: t.cancel, exact: true });
399 | await recordFocus(page, info, 'native-delete-confirm-initial');
> 400 | await expect(cancel).toBeFocused();
| ^ Error: expect(locator).toBeFocused() failed
401 | await page.keyboard.press('Shift+Tab');
402 | const messageRegion = dialog.getByRole('region', { name: await dialog.getByRole('heading').innerText(), exact: true });
403 | await expect(messageRegion).toBeFocused();
404 | await messageRegion.press('Shift+Tab');
405 | await expect(dialog.getByRole('button', { name: t.adminUsersDelete, exact: true })).toBeFocused();
406 | await page.keyboard.press('Tab'); await expect(messageRegion).toBeFocused();
407 | await page.keyboard.press('Tab'); await expect(cancel).toBeFocused();
408 | await page.keyboard.press('Enter');
409 | await expect(dialog).toHaveCount(0); await expect(deleteButton).toBeFocused();
410 | expect(count(session.requests, 'DELETE', `/api/job-discovery/saved-searches/${saved.id}`)).toBe(0);
411 | expect(await nativeList(page.request)).toEqual([saved]);
412 | await page.route(`${savedUrl}/${saved.id}`, async route => {
413 | if (route.request().method() === 'DELETE') { deletes++; await deleteGate.pending; }
414 | return route.continue();
415 | });
416 | const deletedResponse = response(page, 'DELETE', `${savedUrl}/${saved.id}`);
417 | try {
418 | await keyboardActivate(page, deleteButton);
419 | await keyboardActivate(page, dialog.getByRole('button', { name: t.adminUsersDelete, exact: true }));
420 | await expect.poll(() => deletes).toBe(1);
421 | await expect(dialog).toHaveCount(0);
422 | await locked(page, 'nb'); await page.keyboard.press('Enter'); expect(deletes).toBe(1);
423 | await capture(page, info, sample, 'native-delete-held');
424 | } finally { deleteGate.release(); }
425 | expect((await deletedResponse).status()).toBe(204);
426 | await expect(savedGroup(page, saved)).toHaveCount(0);
427 | await closeFilters(page, 'nb', true);
428 | await recordFocus(page, info, 'native-delete-control-removed');
429 | await expect(results(page, 'nb')).toBeFocused();
430 | await page.keyboard.press('Tab');
431 | await recordFocus(page, info, 'native-delete-next-tab');
432 | await expect(page.getByLabel(t.jobDiscoverySort, { exact: true })).toBeFocused();
433 | await expect(article(page, currentJob)).toBeVisible();
434 | expect(await nativeList(page.request)).toEqual([]);
435 | await page.reload(); await openFilters(page, 'nb'); await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toBeVisible();
436 | expect(creates).toBe(1); expect(deletes).toBe(1);
437 | });
438 |
439 | test('[P7-05 Discovery] contextual Dismiss preserves a row on real missing-result denial and qualifies intercepted UI recovery', async ({ page, session }, info) => {
440 | test.setTimeout(90_000);
441 | const t = translations.en; const sample = { ...desktop, theme: 'dark' as const, width: 390, height: 844 };
442 | const saved = await createSaved(page, session.owner, 'Recorded dismiss owner context');
443 | const job = vacancy('p7-dismiss-encoded Æ Ø Å');
444 | const movedFocusJob = vacancy('p7-dismiss-second-moved-focus');
445 | await page.route(`${savedUrl}/${saved.id}/run`, async route => route.fulfill({ json: await runPayload(page, saved, [job, movedFocusJob]) }));
446 | await enter(page, session.owner, sample);
447 | await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
448 | await expect(article(page, job)).toBeVisible();
449 | const dismissUrl = `${savedUrl}/${saved.id}/results/${encodeURIComponent(job.id)}/dismiss`;
450 | const deniedResponse = response(page, 'PATCH', dismissUrl);
451 | await keyboardActivate(page, article(page, job).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
452 | expect((await deniedResponse).status()).toBe(404);
453 | await expect(results(page).getByText(t.jobDiscoveryDismissFailed, { exact: true })).toBeVisible();
454 | await expect(article(page, job)).toBeVisible();
455 | await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
456 | expect(await nativeList(page.request)).toEqual([saved]);
457 | await capture(page, info, sample, 'dismiss-native-missing-denied');
458 | const gate = hold(); let starts = 0;
459 | await page.route(dismissUrl, async route => {
460 | expect(route.request().method()).toBe('PATCH'); expect(route.request().postDataJSON()).toEqual({ isDismissed: true });
461 | starts++; await gate.pending; return route.fulfill({ status: 204 });
462 | });
463 | try {
464 | await keyboardActivate(page, article(page, job).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
465 | await expect.poll(() => starts).toBe(1); await locked(page); await page.keyboard.press('Enter'); expect(starts).toBe(1);
466 | } finally { gate.release(); }
467 | await expect(article(page, job)).toHaveCount(0);
468 | await expect(results(page).getByText(t.jobDiscoveryDismissFailed, { exact: true })).toHaveCount(0);
469 | await recordFocus(page, info, 'dismiss-control-removed');
470 | await expect(results(page)).toBeFocused();
471 | await page.keyboard.press('Tab');
472 | await recordFocus(page, info, 'dismiss-next-tab');
473 | await expect(page.getByLabel(t.jobDiscoverySort, { exact: true })).toBeFocused();
474 | await expect(article(page, movedFocusJob)).toBeVisible();
475 | const movedGate = hold();
476 | const movedUrl = `${savedUrl}/${saved.id}/results/${encodeURIComponent(movedFocusJob.id)}/dismiss`;
477 | let movedStarts = 0;
478 | await page.route(movedUrl, async route => {
479 | expect(route.request().method()).toBe('PATCH'); expect(route.request().postDataJSON()).toEqual({ isDismissed: true });
480 | movedStarts++; await movedGate.pending; return route.fulfill({ status: 204 });
481 | });
482 | const headerControl = page.locator('header').getByRole('button', { name: t.user, exact: true });
483 | try {
484 | await keyboardActivate(page, article(page, movedFocusJob).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
485 | await expect.poll(() => movedStarts).toBe(1); await locked(page);
486 | await expect(headerControl).toBeEnabled(); await headerControl.focus(); await expect(headerControl).toBeFocused();
487 | await recordFocus(page, info, 'held-dismiss-user-moved-focus');
488 | } finally { movedGate.release(); }
489 | await expect(article(page, movedFocusJob)).toHaveCount(0);
490 | await recordFocus(page, info, 'dismiss-complete-user-focus-preserved');
491 | await expect(headerControl).toBeFocused();
492 | expect(movedStarts).toBe(1);
493 | expect(await nativeList(page.request)).toEqual([saved]);
494 | expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
495 | const patches = session.requests.filter(request => request.path.endsWith('/dismiss'));
496 | expect(patches).toHaveLength(3);
497 | expect(patches.map(request => request.body)).toEqual([{ isDismissed: true }, { isDismissed: true }, { isDismissed: true }]);
498 | await capture(page, info, sample, 'dismiss-intercepted-ui-only');
499 | await page.reload();
500 | await openFilters(page);
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,46 @@
{
"[P7-05 Discovery] held saved B removes A rows, locks all mutations and starts one request": {
"status": "passed",
"run": "final-stable11"
},
"[P7-05 Discovery] failed saved-run Retry posts the same ID with stable keyboard focus and held progress": {
"status": "passed",
"run": "final-stable11"
},
"[P7-05 Discovery] keyboard native create and alert updates persist; delete Cancel restores focus and held delete starts once": {
"status": "passed",
"run": "final-stable11"
},
"[P5-15-01R-01] saved-list faults retry a native GET without resetting discovery across EN/Bokmål, themes and widths": {
"status": "passed",
"run": "final-stable11"
},
"[P7-05 Discovery] saved A to manual search clears saved-result context and preserves native saved rows": {
"status": "passed",
"run": "final-stable11"
},
"[P7-05 Discovery] mutation failures retain drafts and current rows without offering an unrelated manual-search retry": {
"status": "passed",
"run": "final-stable11"
},
"[P7-05 Discovery] contextual Dismiss preserves a row on real missing-result denial and qualifies intercepted UI recovery": {
"status": "passed",
"run": "final-stable11"
},
"[P7-05 Discovery] eight finite responsive locale/theme states retain source labels and all-enabled accessibility vectors": {
"status": "passed",
"run": "final-stable11"
},
"[P7-05 Discovery] header locale change retains drafts, selected saved source and rows without discovery refetch": {
"status": "passed",
"run": "final-stable11"
},
"[P5-15-01R-01] native saved NAV search survives Jobs navigation, reload, apply, pause/resume and delete": {
"status": "passed",
"run": "final-stable11"
},
"[P5-15-01R-01] native owner filters and crafted saved-search mutations deny foreign and anonymous access": {
"status": "passed",
"run": "final-stable11"
}
}
@@ -0,0 +1,236 @@
{
"baseline": "ec57a24d77854bb416c9ea02b8b371be3e5554c0",
"rootAppFix": "df93bfb64eb80dbfa30f86c89b54a9021c43a9c6",
"localAppFixCherryPick": "a584fc0b",
"qualification": "Final combined11 binds exact final test source. Intermediate runs overlap; do not sum them. Exact unmodified native Playwright JSON/list stdout compressed with gzip.",
"finalSourceSha256": {
"job-tracker-ui/e2e/phase7-discovery.spec.ts": "f200418dc84e99abbe0eb435e1356668ef568ff668d6179b748d927e4df846e6",
"job-tracker-ui/e2e/saved-search-repair.spec.ts": "d15f1736fca5f35e4b6ce2070314e3691bc0bd9ea9289c23cff4423a05a6ad61",
"job-tracker-ui/src/confirm.tsx": "add83991431cf3639acfcdbeb9a76eb24037477141153dca3a93082ed6cb1bb2",
"job-tracker-ui/src/confirm.test.tsx": "00d27162d74a405892e889dc3ce436104df48d2504d8ae554b3699e25a946d9d"
},
"runStats": {
"pilot": {
"startTime": "2026-11-11T06:34:24.274Z",
"duration": 82933.107,
"expected": 3,
"skipped": 0,
"unexpected": 1,
"flaky": 0
},
"remainder": {
"startTime": "2026-11-11T06:36:21.659Z",
"duration": 106519.323,
"expected": 5,
"skipped": 0,
"unexpected": 2,
"flaky": 0
},
"focused": {
"startTime": "2026-11-11T06:38:41.263Z",
"duration": 48073.14,
"expected": 1,
"skipped": 0,
"unexpected": 2,
"flaky": 0
},
"dismiss": {
"startTime": "2026-11-11T06:40:37.672Z",
"duration": 34489.103,
"expected": 3,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"focus-preserved": {
"startTime": "2026-11-11T06:44:04.390Z",
"duration": 36403.344000000005,
"expected": 2,
"skipped": 0,
"unexpected": 1,
"flaky": 0
},
"confirm-fix": {
"startTime": "2026-11-11T06:45:15.340Z",
"duration": 39776.71599999999,
"expected": 1,
"skipped": 0,
"unexpected": 1,
"flaky": 0
},
"held-final": {
"startTime": "2026-11-11T06:46:26.787Z",
"duration": 22094.513,
"expected": 0,
"skipped": 0,
"unexpected": 1,
"flaky": 0
},
"held-stable": {
"startTime": "2026-11-11T06:48:03.414Z",
"duration": 19931.281,
"expected": 1,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"final11": {
"startTime": "2026-11-11T06:48:46.652Z",
"duration": 151275.851,
"expected": 10,
"skipped": 0,
"unexpected": 1,
"flaky": 0
},
"final-stable11": {
"startTime": "2026-11-11T06:52:07.557Z",
"duration": 143011.842,
"expected": 11,
"skipped": 0,
"unexpected": 0,
"flaky": 0
}
},
"receipts": [
{
"file": "a3-discovery-native-confirm-fix.json.gz",
"rawBytes": 125386,
"rawSha256": "2bcdef8d0839552976b8d88cf28dd1fe1f0aacb1de5c8bb6a350e29c8a49c4b7",
"compressedBytes": 71828,
"compressedSha256": "44de61301ce4b46f7f9ef717ac03d8831d6596740599cc251cc0b5699c70ef2c"
},
{
"file": "a3-discovery-native-confirm-fix.txt.gz",
"rawBytes": 5561,
"rawSha256": "de74ee370d62bfbfe299921310f306fed0e3131a06143f275c7226825445105b",
"compressedBytes": 1637,
"compressedSha256": "3b7cc65423e552d35ea23455a48a3db8ca0cdc7273a5b51bf197fdbc5ef1a484"
},
{
"file": "a3-discovery-native-dismiss.json.gz",
"rawBytes": 360692,
"rawSha256": "03260dd567161339ca1f09f43e1c14ba5f21ade92b66b9c6e2f539c618fae319",
"compressedBytes": 225572,
"compressedSha256": "e09195b5cebbc325cfa54eaf0f9485bc1fd9f030499fdf5f0dd730c336b431f3"
},
{
"file": "a3-discovery-native-dismiss.txt.gz",
"rawBytes": 2351,
"rawSha256": "cf287911d67a050b00cc7102f0f27fc52538c63ddb348d660f8ee908a63b67e8",
"compressedBytes": 1064,
"compressedSha256": "4df447eb2bf08d56c54f851d6cea0acd5dfb1ef76677fa3ddaed16723f383259"
},
{
"file": "a3-discovery-native-final-stable11.json.gz",
"rawBytes": 2196635,
"rawSha256": "5b5958a516fd8fb22493ef982fee4b6ca8d512d78a460f0562c60755ab35b54c",
"compressedBytes": 1411952,
"compressedSha256": "106363b7f6e51914e947222608a2874903f03fd025e22bd7d91bcd38396193ad"
},
{
"file": "a3-discovery-native-final-stable11.txt.gz",
"rawBytes": 3831,
"rawSha256": "7d0e1b4c0cefb6fbf2ebf584ea90f35327d3618324f30ced0c1f102b7e2826cb",
"compressedBytes": 1536,
"compressedSha256": "4051bf8f98abd94f288b4c2e4e298e7aa20a1024a06d94b33eebaaed9ae0414f"
},
{
"file": "a3-discovery-native-final11.json.gz",
"rawBytes": 2152219,
"rawSha256": "8d4ab84e092066f7426bda9ebf4e2b519f94b06fe263874029afb0144ef83a70",
"compressedBytes": 1378115,
"compressedSha256": "df44a04ca26d7f089578655ebd35df19fd861b15eb9c8accaf9cc11bcd24081c"
},
{
"file": "a3-discovery-native-final11.txt.gz",
"rawBytes": 7208,
"rawSha256": "4d55fd6f94b1f8904f862d5cc16467e63e788531f6c85da06dbbb12293c7cee2",
"compressedBytes": 2185,
"compressedSha256": "706ba274f2e3fba37374e4386a72d9c845905d9782eac2b1202112c0e1aacb08"
},
{
"file": "a3-discovery-native-focus-preserved.json.gz",
"rawBytes": 318096,
"rawSha256": "6b45faa989b563911545cc02f314e1974d451a8ebab65dc57810da0c98252b03",
"compressedBytes": 195869,
"compressedSha256": "1b69b95028598529c62d437a56b3de3cfd0a81ec7599816eee0444a2612e3006"
},
{
"file": "a3-discovery-native-focus-preserved.txt.gz",
"rawBytes": 5727,
"rawSha256": "a4eae73ce086c281ed6eac7139dd565a1a865923654ec65f1a67ec21a7b7599e",
"compressedBytes": 1701,
"compressedSha256": "339f8035d58faecd6b636852fe49229646e008eca30edc4e24af13363364ab62"
},
{
"file": "a3-discovery-native-focused.json.gz",
"rawBytes": 353623,
"rawSha256": "98560a59a7b5691688207a5f85e9882a8394ed5a98809d86fd4bda6dd96bfa93",
"compressedBytes": 228545,
"compressedSha256": "201b1c076c9fc64857d76d084ae6155b6c2e343170457c158ed4f5d64930b40f"
},
{
"file": "a3-discovery-native-focused.txt.gz",
"rawBytes": 9509,
"rawSha256": "033d9699c6429416b3dfe35bc35afa56b0e3c7bef4f2a276aa1e6a6d3f85a298",
"compressedBytes": 2281,
"compressedSha256": "3de431d125ca055bc83e915aa858697b91a936d68a83e8b073d9b18cb94f9cdb"
},
{
"file": "a3-discovery-native-held-final.json.gz",
"rawBytes": 11327,
"rawSha256": "ff9bed8033718bac06e5de5753a5f6635bd497a1e988bee8f17b7858bdb010ee",
"compressedBytes": 3041,
"compressedSha256": "da36500a93e15a1df50dbeb5a3d4628c8eb83dc4a3f01074e63a61071e480274"
},
{
"file": "a3-discovery-native-held-final.txt.gz",
"rawBytes": 5178,
"rawSha256": "92c155554b30460916eb5f56362be08fdebf0924a272a10150fef3f11e3390a6",
"compressedBytes": 1547,
"compressedSha256": "ab599279a3857eb172395c0ddf36018368b7c96c9e37af26842978e9ee946a36"
},
{
"file": "a3-discovery-native-held-stable.json.gz",
"rawBytes": 53932,
"rawSha256": "91f44f31c4886cd690380cae1bc3d1084e59a9a00bc8dd48f09121c0bbf7c9bb",
"compressedBytes": 32756,
"compressedSha256": "40a43e88eb039f22d7c1740b2983f9acff25c139379c3dc764cf3f826fc32269"
},
{
"file": "a3-discovery-native-held-stable.txt.gz",
"rawBytes": 1987,
"rawSha256": "097b998c48681b4a5a1ffdd2a787a75bf3d53b08d702bceb7ee3e84ad97fdb5c",
"compressedBytes": 926,
"compressedSha256": "ab67b2a599a1f1bcc24634a290b986df9a4c009fa77862ecb495d27db2d4e45d"
},
{
"file": "a3-discovery-native-pilot.json.gz",
"rawBytes": 326700,
"rawSha256": "b364c617f90202d8071ea9a3af4386815ba3950378381c7031cae821884ed004",
"compressedBytes": 144420,
"compressedSha256": "d1bd1e7a4a41888134d580af9114095278fbd81301720dbd2a2eda17571da186"
},
{
"file": "a3-discovery-native-pilot.txt.gz",
"rawBytes": 6282,
"rawSha256": "4f936c3d29287363bfd113af20353e2e907615e7a678e5c08992149d3241306a",
"compressedBytes": 1956,
"compressedSha256": "009133f8d0677ee890b0f60cbe02a83285c347fa5188dad92f7367da8e6c78e4"
},
{
"file": "a3-discovery-native-remainder.json.gz",
"rawBytes": 1488998,
"rawSha256": "5c506906896c47efd8480aa6ed50737df05b4898a5a7ae84fd85c6f262862a21",
"compressedBytes": 1004042,
"compressedSha256": "aa38ca22efe7712c1fdca00a730e897147341bb922695f40825ee455811cdad5"
},
{
"file": "a3-discovery-native-remainder.txt.gz",
"rawBytes": 11442,
"rawSha256": "65fd7f3dd7e7929dd482ec9850bcc13907cf5cf7eb03e7844fb5f825a04849f3",
"compressedBytes": 2639,
"compressedSha256": "35ddf2e494f161177701f5ea6eb6f55124dd391a6e9a903bfc87102e0bce7526"
}
]
}
@@ -0,0 +1,239 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: phase7-discovery.spec.ts >> [P7-05 Discovery] keyboard native create and alert updates persist; delete Cancel restores focus and held delete starts once
- Location: e2e\phase7-discovery.spec.ts:354:1
# Error details
```
Error: expect(locator).toBeFocused() failed
Locator: getByRole('dialog').filter({ has: getByRole('heading', { name: 'Slette lagret søk?', exact: true }) }).getByRole('button', { name: 'Avbryt', exact: true })
Expected: focused
Received: inactive
Timeout: 5000ms
Call log:
- Expect "toBeFocused" with timeout 5000ms
- waiting for getByRole('dialog').filter({ has: getByRole('heading', { name: 'Slette lagret søk?', exact: true }) }).getByRole('button', { name: 'Avbryt', exact: true })
14 × locator resolved to <button tabindex="0" type="button" class="MuiButtonBase-root MuiButton-root MuiButton-text MuiButton-textPrimary MuiButton-sizeMedium MuiButton-textSizeMedium MuiButton-colorPrimary MuiButton-root MuiButton-text MuiButton-textPrimary MuiButton-sizeMedium MuiButton-textSizeMedium MuiButton-colorPrimary css-v8jn2b-MuiButtonBase-root-MuiButton-root">…</button>
- unexpected value "inactive"
```
```yaml
- button "Avbryt"
```
# Test source
```ts
299 | const saved = await createSaved(page, session.owner, 'Recorded mutation recovery');
300 | const job = vacancy('mutation-current');
301 | await page.route(`${savedUrl}/${saved.id}/run`, async route => route.fulfill({ json: await runPayload(page, saved, [job]) }));
302 | let createFails = true; let alertFails = true; let deleteFails = true;
303 | await page.route(savedUrl, route => route.request().method() === 'POST' && createFails
304 | ? route.fulfill({ status: 500, json: { message: 'Controlled create failure' } }) : route.continue());
305 | await page.route(`${savedUrl}/${saved.id}`, route => (route.request().method() === 'PATCH' && alertFails) || (route.request().method() === 'DELETE' && deleteFails)
306 | ? route.fulfill({ status: 500, json: { message: 'Controlled mutation failure' } }) : route.continue());
307 | await enter(page, session.owner);
308 | await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
309 | await expect(article(page, job)).toBeVisible();
310 | await draftFilters(page, 'retained draft role', 'Ålesund');
311 | await page.getByLabel(t.jobDiscoverySavedName, { exact: true }).fill('Retained failed create draft');
312 | await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
313 | await expect(page.getByText(t.jobDiscoverySaveSearchFailed, { exact: true })).toBeVisible();
314 | await expect(page.getByLabel(t.jobDiscoverySavedName, { exact: true })).toHaveValue('Retained failed create draft');
315 | await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toHaveValue('retained draft role');
316 | await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
317 | expect(await nativeList(page.request)).toEqual([saved]);
318 | createFails = false;
319 | const createdResponse = response(page, 'POST', savedUrl);
320 | await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
321 | const created = await json<SavedSearch>(await createdResponse, 201);
322 | expect(created).toMatchObject({ name: 'Retained failed create draft', query: 'retained draft role', location: 'Ålesund' });
323 | await keyboardActivate(page, savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }), 'Space');
324 | await expect(page.getByText(t.jobDiscoveryUpdateAlertsFailed, { exact: true })).toBeVisible();
325 | await expect(savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true })).toBeChecked();
326 | await expect(article(page, job)).toBeVisible();
327 | await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
328 | alertFails = false;
329 | const updatedResponse = response(page, 'PATCH', `${savedUrl}/${saved.id}`);
330 | await keyboardActivate(page, savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }), 'Space');
331 | const updated = await json<SavedSearch>(await updatedResponse);
332 | expect(updated).toEqual({ ...saved, isActive: false });
333 | await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.adminUsersDelete, exact: true }));
334 | await keyboardActivate(page, page.getByRole('dialog').getByRole('button', { name: t.adminUsersDelete, exact: true }));
335 | await expect(page.getByText(t.jobDiscoveryDeleteSavedFailed, { exact: true })).toBeVisible();
336 | await expect(savedGroup(page, saved)).toBeVisible();
337 | await expect(article(page, job)).toBeVisible();
338 | await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
339 | // Native listing sorts by UpdatedAt, and pausing the older row updates that timestamp.
340 | // Compare every DTO value without inventing creation-order semantics for this readback.
341 | expect((await nativeList(page.request)).sort((left, right) => left.id - right.id)).toEqual([created, updated].sort((left, right) => left.id - right.id));
342 | expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
343 | await capture(page, info, desktop, 'mutation-failure-retains-context');
344 | deleteFails = false;
345 | const deletedResponse = response(page, 'DELETE', `${savedUrl}/${saved.id}`);
346 | await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.adminUsersDelete, exact: true }));
347 | await keyboardActivate(page, page.getByRole('dialog').getByRole('button', { name: t.adminUsersDelete, exact: true }));
348 | expect((await deletedResponse).status()).toBe(204);
349 | await expect(savedGroup(page, saved)).toHaveCount(0);
350 | expect(await nativeList(page.request)).toEqual([created]);
351 | expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
352 | });
353 |
354 | test('[P7-05 Discovery] keyboard native create and alert updates persist; delete Cancel restores focus and held delete starts once', async ({ page, session }, info) => {
355 | test.setTimeout(120_000);
356 | const t = translations.nb; const sample: Sample = { language: 'nb', theme: 'light', width: 360, height: 844 };
357 | const currentJob = vacancy('native-delete-current-manual');
358 | await page.route(`${searchUrl}**`, route => route.fulfill({ json: [currentJob] }));
359 | const createGate = hold(); const deleteGate = hold(); let creates = 0; let deletes = 0;
360 | await page.route(savedUrl, async route => {
361 | if (route.request().method() === 'POST') { creates++; await createGate.pending; }
362 | return route.continue();
363 | });
364 | await enter(page, session.owner, sample);
365 | await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toBeVisible();
366 | await draftFilters(page, ' utvikler ', ' Tromsø ', 'nb', ' Gjennomgått utviklersøk Æ Ø Å ');
367 | const createdResponse = response(page, 'POST', savedUrl);
368 | try {
369 | await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
370 | await expect.poll(() => creates).toBe(1);
371 | await locked(page, 'nb'); await page.keyboard.press('Enter'); expect(creates).toBe(1);
372 | await capture(page, info, sample, 'native-create-held');
373 | } finally { createGate.release(); }
374 | const saved = await json<SavedSearch>(await createdResponse, 201);
375 | expect(saved).toMatchObject({ name: 'Gjennomgått utviklersøk Æ Ø Å', query: 'utvikler', location: 'Tromsø', isActive: true, resultCount: 0, dismissedCount: 0 });
376 | await expect(savedGroup(page, saved)).toBeVisible();
377 | expect(await nativeList(page.request)).toEqual([saved]);
378 | await page.reload(); await openFilters(page, 'nb'); await expect(savedGroup(page, saved)).toBeVisible();
379 | for (const isActive of [false, true]) {
380 | const updatedResponse = response(page, 'PATCH', `${savedUrl}/${saved.id}`);
381 | await keyboardActivate(page, savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }), 'Space');
382 | expect(await json<SavedSearch>(await updatedResponse)).toEqual({ ...saved, isActive });
383 | expect(await nativeList(page.request)).toEqual([{ ...saved, isActive }]);
384 | await page.reload();
385 | await openFilters(page, 'nb');
386 | await expect(savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true })).toBeChecked({ checked: isActive });
387 | }
388 | // Keep a legitimate results control after deleting the saved row so the next Tab
389 | // has a concrete destination inside the stable region rather than browser chrome.
390 | await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySearchNav, exact: true }));
391 | await expect(article(page, currentJob)).toBeVisible();
392 | await openFilters(page, 'nb');
393 | const deleteButton = savedGroup(page, saved).getByRole('button', { name: t.adminUsersDelete, exact: true });
394 | await keyboardActivate(page, deleteButton);
395 | const dialog = page.getByRole('dialog').filter({ has: page.getByRole('heading', { name: t.jobDiscoveryDeleteSavedTitle, exact: true }) });
396 | await expect(dialog.getByRole('heading', { name: t.jobDiscoveryDeleteSavedTitle, exact: true })).toBeVisible();
397 | await expect(dialog.getByText(t.jobDiscoveryDeleteSavedConfirm.replace('{name}', saved.name), { exact: true })).toBeVisible();
398 | const cancel = dialog.getByRole('button', { name: t.cancel, exact: true });
> 399 | await expect(cancel).toBeFocused();
| ^ Error: expect(locator).toBeFocused() failed
400 | await page.keyboard.press('Shift+Tab');
401 | const messageRegion = dialog.getByRole('region', { name: await dialog.getByRole('heading').innerText(), exact: true });
402 | await expect(messageRegion).toBeFocused();
403 | await messageRegion.press('Shift+Tab');
404 | await expect(dialog.getByRole('button', { name: t.adminUsersDelete, exact: true })).toBeFocused();
405 | await page.keyboard.press('Tab'); await expect(messageRegion).toBeFocused();
406 | await page.keyboard.press('Tab'); await expect(cancel).toBeFocused();
407 | await page.keyboard.press('Enter');
408 | await expect(dialog).toHaveCount(0); await expect(deleteButton).toBeFocused();
409 | expect(count(session.requests, 'DELETE', `/api/job-discovery/saved-searches/${saved.id}`)).toBe(0);
410 | expect(await nativeList(page.request)).toEqual([saved]);
411 | await page.route(`${savedUrl}/${saved.id}`, async route => {
412 | if (route.request().method() === 'DELETE') { deletes++; await deleteGate.pending; }
413 | return route.continue();
414 | });
415 | const deletedResponse = response(page, 'DELETE', `${savedUrl}/${saved.id}`);
416 | try {
417 | await keyboardActivate(page, deleteButton);
418 | await keyboardActivate(page, dialog.getByRole('button', { name: t.adminUsersDelete, exact: true }));
419 | await expect.poll(() => deletes).toBe(1);
420 | await expect(dialog).toHaveCount(0);
421 | await locked(page, 'nb'); await page.keyboard.press('Enter'); expect(deletes).toBe(1);
422 | await capture(page, info, sample, 'native-delete-held');
423 | } finally { deleteGate.release(); }
424 | expect((await deletedResponse).status()).toBe(204);
425 | await expect(savedGroup(page, saved)).toHaveCount(0);
426 | await closeFilters(page, 'nb', true);
427 | await recordFocus(page, info, 'native-delete-control-removed');
428 | await expect(results(page, 'nb')).toBeFocused();
429 | await page.keyboard.press('Tab');
430 | await recordFocus(page, info, 'native-delete-next-tab');
431 | await expect(page.getByLabel(t.jobDiscoverySort, { exact: true })).toBeFocused();
432 | await expect(article(page, currentJob)).toBeVisible();
433 | expect(await nativeList(page.request)).toEqual([]);
434 | await page.reload(); await openFilters(page, 'nb'); await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toBeVisible();
435 | expect(creates).toBe(1); expect(deletes).toBe(1);
436 | });
437 |
438 | test('[P7-05 Discovery] contextual Dismiss preserves a row on real missing-result denial and qualifies intercepted UI recovery', async ({ page, session }, info) => {
439 | test.setTimeout(90_000);
440 | const t = translations.en; const sample = { ...desktop, theme: 'dark' as const, width: 390, height: 844 };
441 | const saved = await createSaved(page, session.owner, 'Recorded dismiss owner context');
442 | const job = vacancy('p7-dismiss-encoded Æ Ø Å');
443 | const movedFocusJob = vacancy('p7-dismiss-second-moved-focus');
444 | await page.route(`${savedUrl}/${saved.id}/run`, async route => route.fulfill({ json: await runPayload(page, saved, [job, movedFocusJob]) }));
445 | await enter(page, session.owner, sample);
446 | await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
447 | await expect(article(page, job)).toBeVisible();
448 | const dismissUrl = `${savedUrl}/${saved.id}/results/${encodeURIComponent(job.id)}/dismiss`;
449 | const deniedResponse = response(page, 'PATCH', dismissUrl);
450 | await keyboardActivate(page, article(page, job).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
451 | expect((await deniedResponse).status()).toBe(404);
452 | await expect(page.getByText(t.jobDiscoveryDismissFailed, { exact: true })).toBeVisible();
453 | await expect(article(page, job)).toBeVisible();
454 | await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
455 | expect(await nativeList(page.request)).toEqual([saved]);
456 | await capture(page, info, sample, 'dismiss-native-missing-denied');
457 | const gate = hold(); let starts = 0;
458 | await page.route(dismissUrl, async route => {
459 | expect(route.request().method()).toBe('PATCH'); expect(route.request().postDataJSON()).toEqual({ isDismissed: true });
460 | starts++; await gate.pending; return route.fulfill({ status: 204 });
461 | });
462 | try {
463 | await keyboardActivate(page, article(page, job).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
464 | await expect.poll(() => starts).toBe(1); await locked(page); await page.keyboard.press('Enter'); expect(starts).toBe(1);
465 | } finally { gate.release(); }
466 | await expect(article(page, job)).toHaveCount(0);
467 | await expect(page.getByText(t.jobDiscoveryDismissFailed, { exact: true })).toHaveCount(0);
468 | await recordFocus(page, info, 'dismiss-control-removed');
469 | await expect(results(page)).toBeFocused();
470 | await page.keyboard.press('Tab');
471 | await recordFocus(page, info, 'dismiss-next-tab');
472 | await expect(page.getByLabel(t.jobDiscoverySort, { exact: true })).toBeFocused();
473 | await expect(article(page, movedFocusJob)).toBeVisible();
474 | const movedGate = hold();
475 | const movedUrl = `${savedUrl}/${saved.id}/results/${encodeURIComponent(movedFocusJob.id)}/dismiss`;
476 | let movedStarts = 0;
477 | await page.route(movedUrl, async route => {
478 | expect(route.request().method()).toBe('PATCH'); expect(route.request().postDataJSON()).toEqual({ isDismissed: true });
479 | movedStarts++; await movedGate.pending; return route.fulfill({ status: 204 });
480 | });
481 | const headerControl = page.locator('header').getByRole('button', { name: 'Norsk', exact: true });
482 | try {
483 | await keyboardActivate(page, article(page, movedFocusJob).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
484 | await expect.poll(() => movedStarts).toBe(1); await locked(page);
485 | await expect(headerControl).toBeEnabled(); await headerControl.focus(); await expect(headerControl).toBeFocused();
486 | await recordFocus(page, info, 'held-dismiss-user-moved-focus');
487 | } finally { movedGate.release(); }
488 | await expect(article(page, movedFocusJob)).toHaveCount(0);
489 | await recordFocus(page, info, 'dismiss-complete-user-focus-preserved');
490 | await expect(headerControl).toBeFocused();
491 | expect(movedStarts).toBe(1);
492 | expect(await nativeList(page.request)).toEqual([saved]);
493 | expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
494 | const patches = session.requests.filter(request => request.path.endsWith('/dismiss'));
495 | expect(patches).toHaveLength(3);
496 | expect(patches.map(request => request.body)).toEqual([{ isDismissed: true }, { isDismissed: true }, { isDismissed: true }]);
497 | await capture(page, info, sample, 'dismiss-intercepted-ui-only');
498 | await page.reload();
499 | await openFilters(page);
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+80 -24
View File
@@ -98,6 +98,38 @@ function response(page: Page, method: string, url: string) { return page.waitFor
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
async function keyboardActivate(page: Page, locator: Locator, key = 'Enter') { await locator.focus(); await expect(locator).toBeFocused(); await page.keyboard.press(key); }
function filterDialog(page: Page, language: Sample['language'] = 'en') {
return page.getByRole('dialog', { name: translations[language].jobDiscoveryFilters, exact: true, includeHidden: true });
}
async function openFilters(page: Page, language: Sample['language'] = 'en') {
if ((page.viewportSize()?.width ?? 1440) > 1000) return;
const dialog = filterDialog(page, language);
const trigger = page.getByRole('button', { name: translations[language].jobDiscoveryFilters, exact: true });
if (await page.getByRole('button', { name: translations[language].jobDiscoveryFilters, exact: true, includeHidden: true }).getAttribute('aria-expanded') !== 'true') await keyboardActivate(page, trigger);
await expect(dialog).toBeVisible();
}
async function closeFilters(page: Page, language: Sample['language'] = 'en', toResults = false) {
if ((page.viewportSize()?.width ?? 1440) > 1000) return;
const dialog = filterDialog(page, language);
const t = translations[language];
if (await page.getByRole('button', { name: t.jobDiscoveryFilters, exact: true, includeHidden: true }).getAttribute('aria-expanded') !== 'true') {
await expect(dialog).toHaveCount(0);
return;
}
await keyboardActivate(page, dialog.getByRole('button', { name: toResults ? t.jobDiscoveryJumpResults : t.close, exact: true }));
await expect(dialog).toBeHidden();
await expect(dialog).toHaveCount(0);
await expect(toResults ? results(page, language) : page.getByRole('button', { name: t.jobDiscoveryFilters, exact: true })).toBeFocused();
}
async function draftFilters(page: Page, query: string, location: string, language: Sample['language'] = 'en', name?: string) {
const t = translations[language];
await closeFilters(page, language);
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill(query);
await openFilters(page, language);
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill(location);
if (name !== undefined) await page.getByLabel(t.jobDiscoverySavedName, { exact: true }).fill(name);
}
async function recordFocus(page: Page, info: TestInfo, state: string) {
const focused = await page.evaluate(() => {
const element = document.activeElement;
@@ -128,15 +160,31 @@ async function enter(page: Page, owner: Owner, sample: Sample = desktop) {
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
await expect(page.getByRole('heading', { level: 1, name: translations[sample.language].discoverJobs, exact: true })).toBeVisible();
await expect(page.getByRole('status', { name: translations[sample.language].jobDiscoverySavedLoading, exact: true })).toHaveCount(0);
await openFilters(page, sample.language);
}
async function locked(page: Page, language: Sample['language'] = 'en') {
const t = translations[language];
for (const label of [t.jobDiscoveryRoleCompany, t.jobDiscoveryMunicipality, t.jobDiscoverySavedName]) await expect(page.getByLabel(label, { exact: true })).toBeDisabled();
for (const name of [t.jobDiscoverySearchNav, t.jobDiscoverySaveSearch, t.jobDiscoveryRunSaved, t.adminUsersDelete, t.jobDiscoveryDismiss]) {
for (const button of await page.getByRole('button', { name, exact: true }).all()) await expect(button).toBeDisabled();
// Wait for the pending operation's render before inspecting the same render's drawer state.
await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toBeDisabled();
const drawerClosed = (page.viewportSize()?.width ?? 1440) <= 1000
&& await page.getByRole('button', { name: t.jobDiscoveryFilters, exact: true, includeHidden: true }).getAttribute('aria-expanded') === 'false';
if (drawerClosed) {
// Inspect the current state without opening a modal and moving focus away from the
// pending Retry/Dismiss control: those completion-focus assertions must stay meaningful.
await expect(page.getByRole('button', { name: t.jobDiscoveryFilters, exact: true })).toHaveAttribute('aria-expanded', 'false');
await expect(filterDialog(page, language)).toBeHidden();
await expect(filterDialog(page, language)).toHaveCount(0);
}
for (const control of await page.getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }).all()) await expect(control).toBeDisabled();
for (const label of [t.jobDiscoveryMunicipality, t.jobDiscoverySavedName]) {
const controls = page.getByLabel(label, { exact: true });
if (!drawerClosed) await expect(controls).toHaveCount(1);
for (const control of await controls.all()) await expect(control).toBeDisabled();
}
for (const name of [t.jobDiscoverySearchNav, t.jobDiscoverySaveSearch, t.jobDiscoveryRunSaved, t.adminUsersDelete, t.jobDiscoveryDismiss]) {
for (const button of await page.getByRole('button', { name, exact: true, includeHidden: true }).all()) await expect(button).toBeDisabled();
}
for (const control of await page.getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true, includeHidden: true }).all()) await expect(control).toBeDisabled();
}
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
@@ -171,8 +219,7 @@ test('[P7-05 Discovery] saved A to manual search clears saved-result context and
await enter(page, session.owner);
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
await expect(article(page, savedJob).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true })).toBeVisible();
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('manual analyst');
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('Bergen');
await draftFilters(page, 'manual analyst', 'Bergen');
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySearchNav, exact: true }));
await expect(article(page, manualJob)).toBeVisible();
await expect(article(page, savedJob)).toHaveCount(0);
@@ -199,6 +246,7 @@ test('[P7-05 Discovery] held saved B removes A rows, locks all mutations and sta
await keyboardActivate(page, savedGroup(page, a).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
await expect(article(page, aJob)).toBeVisible();
try {
await openFilters(page);
await keyboardActivate(page, savedGroup(page, b).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
await expect.poll(() => starts).toBe(1);
await expect(article(page, aJob)).toHaveCount(0);
@@ -213,8 +261,10 @@ test('[P7-05 Discovery] held saved B removes A rows, locks all mutations and sta
} finally { gate.release(); }
await expect(article(page, bJob)).toBeVisible();
await expect(article(page, aJob)).toHaveCount(0);
await openFilters(page);
await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toHaveValue(b.query);
await expect(page.getByLabel(t.jobDiscoveryMunicipality, { exact: true })).toHaveValue(b.location);
await closeFilters(page, 'en', true);
await expect(article(page, bJob).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true })).toBeEnabled();
expect(starts).toBe(1);
expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
@@ -235,8 +285,8 @@ test('[P7-05 Discovery] failed saved-run Retry posts the same ID with stable key
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
await expect(results(page).getByText(t.jobDiscoveryUnavailable, { exact: true })).toBeVisible();
await expect(page.getByText(/Controlled provider read failure/)).toHaveCount(0);
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('unsubmitted different query');
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('unsubmitted Ålesund draft');
await draftFilters(page, 'unsubmitted different query', 'unsubmitted Ålesund draft');
await closeFilters(page, 'en', true);
try {
await keyboardActivate(page, results(page).getByRole('button', { name: t.retry, exact: true }));
await expect.poll(() => runs).toBe(2);
@@ -251,6 +301,7 @@ test('[P7-05 Discovery] failed saved-run Retry posts the same ID with stable key
await expect(article(page, job)).toBeVisible();
await expect(results(page)).toBeFocused();
await expect(results(page).getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
await openFilters(page);
await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toHaveValue('unsubmitted different query');
await expect(page.getByLabel(t.jobDiscoveryMunicipality, { exact: true })).toHaveValue('unsubmitted Ålesund draft');
expect(runs).toBe(2);
@@ -274,8 +325,7 @@ test('[P7-05 Discovery] mutation failures retain drafts and current rows without
await enter(page, session.owner);
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
await expect(article(page, job)).toBeVisible();
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('retained draft role');
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('Ålesund');
await draftFilters(page, 'retained draft role', 'Ålesund');
await page.getByLabel(t.jobDiscoverySavedName, { exact: true }).fill('Retained failed create draft');
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
await expect(page.getByText(t.jobDiscoverySaveSearchFailed, { exact: true })).toBeVisible();
@@ -331,9 +381,7 @@ test('[P7-05 Discovery] keyboard native create and alert updates persist; delete
});
await enter(page, session.owner, sample);
await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toBeVisible();
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill(' utvikler ');
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill(' Tromsø ');
await page.getByLabel(t.jobDiscoverySavedName, { exact: true }).fill(' Gjennomgått utviklersøk Æ Ø Å ');
await draftFilters(page, ' utvikler ', ' Tromsø ', 'nb', ' Gjennomgått utviklersøk Æ Ø Å ');
const createdResponse = response(page, 'POST', savedUrl);
try {
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
@@ -345,25 +393,28 @@ test('[P7-05 Discovery] keyboard native create and alert updates persist; delete
expect(saved).toMatchObject({ name: 'Gjennomgått utviklersøk Æ Ø Å', query: 'utvikler', location: 'Tromsø', isActive: true, resultCount: 0, dismissedCount: 0 });
await expect(savedGroup(page, saved)).toBeVisible();
expect(await nativeList(page.request)).toEqual([saved]);
await page.reload(); await expect(savedGroup(page, saved)).toBeVisible();
await page.reload(); await openFilters(page, 'nb'); await expect(savedGroup(page, saved)).toBeVisible();
for (const isActive of [false, true]) {
const updatedResponse = response(page, 'PATCH', `${savedUrl}/${saved.id}`);
await keyboardActivate(page, savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }), 'Space');
expect(await json<SavedSearch>(await updatedResponse)).toEqual({ ...saved, isActive });
expect(await nativeList(page.request)).toEqual([{ ...saved, isActive }]);
await page.reload();
await openFilters(page, 'nb');
await expect(savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true })).toBeChecked({ checked: isActive });
}
// Keep a legitimate results control after deleting the saved row so the next Tab
// has a concrete destination inside the stable region rather than browser chrome.
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySearchNav, exact: true }));
await expect(article(page, currentJob)).toBeVisible();
await openFilters(page, 'nb');
const deleteButton = savedGroup(page, saved).getByRole('button', { name: t.adminUsersDelete, exact: true });
await keyboardActivate(page, deleteButton);
const dialog = page.getByRole('dialog');
const dialog = page.getByRole('dialog').filter({ has: page.getByRole('heading', { name: t.jobDiscoveryDeleteSavedTitle, exact: true }) });
await expect(dialog.getByRole('heading', { name: t.jobDiscoveryDeleteSavedTitle, exact: true })).toBeVisible();
await expect(dialog.getByText(t.jobDiscoveryDeleteSavedConfirm.replace('{name}', saved.name), { exact: true })).toBeVisible();
const cancel = dialog.getByRole('button', { name: t.cancel, exact: true });
await recordFocus(page, info, 'native-delete-confirm-initial');
await expect(cancel).toBeFocused();
await page.keyboard.press('Shift+Tab');
const messageRegion = dialog.getByRole('region', { name: await dialog.getByRole('heading').innerText(), exact: true });
@@ -383,22 +434,25 @@ test('[P7-05 Discovery] keyboard native create and alert updates persist; delete
const deletedResponse = response(page, 'DELETE', `${savedUrl}/${saved.id}`);
try {
await keyboardActivate(page, deleteButton);
await keyboardActivate(page, page.getByRole('dialog').getByRole('button', { name: t.adminUsersDelete, exact: true }));
await keyboardActivate(page, dialog.getByRole('button', { name: t.adminUsersDelete, exact: true }));
await expect.poll(() => deletes).toBe(1);
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(dialog).toHaveCount(0);
await locked(page, 'nb'); await page.keyboard.press('Enter'); expect(deletes).toBe(1);
await capture(page, info, sample, 'native-delete-held');
} finally { deleteGate.release(); }
expect((await deletedResponse).status()).toBe(204);
await expect(savedGroup(page, saved)).toHaveCount(0);
await closeFilters(page, 'nb', true);
await recordFocus(page, info, 'native-delete-control-removed');
await expect(results(page, 'nb')).toBeFocused();
await page.keyboard.press('Tab');
await recordFocus(page, info, 'native-delete-next-tab');
await expect(results(page, 'nb').getByRole('button', { name: t.jobDiscoveryReturnFilters, exact: true })).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel(t.jobDiscoverySort, { exact: true })).toBeFocused();
await expect(article(page, currentJob)).toBeVisible();
expect(await nativeList(page.request)).toEqual([]);
await page.reload(); await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toBeVisible();
await page.reload(); await openFilters(page, 'nb'); await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toBeVisible();
expect(creates).toBe(1); expect(deletes).toBe(1);
});
@@ -416,7 +470,7 @@ test('[P7-05 Discovery] contextual Dismiss preserves a row on real missing-resul
const deniedResponse = response(page, 'PATCH', dismissUrl);
await keyboardActivate(page, article(page, job).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
expect((await deniedResponse).status()).toBe(404);
await expect(page.getByText(t.jobDiscoveryDismissFailed, { exact: true })).toBeVisible();
await expect(results(page).getByText(t.jobDiscoveryDismissFailed, { exact: true })).toBeVisible();
await expect(article(page, job)).toBeVisible();
await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
expect(await nativeList(page.request)).toEqual([saved]);
@@ -431,11 +485,13 @@ test('[P7-05 Discovery] contextual Dismiss preserves a row on real missing-resul
await expect.poll(() => starts).toBe(1); await locked(page); await page.keyboard.press('Enter'); expect(starts).toBe(1);
} finally { gate.release(); }
await expect(article(page, job)).toHaveCount(0);
await expect(page.getByText(t.jobDiscoveryDismissFailed, { exact: true })).toHaveCount(0);
await expect(results(page).getByText(t.jobDiscoveryDismissFailed, { exact: true })).toHaveCount(0);
await recordFocus(page, info, 'dismiss-control-removed');
await expect(results(page)).toBeFocused();
await page.keyboard.press('Tab');
await recordFocus(page, info, 'dismiss-next-tab');
await expect(results(page).getByRole('button', { name: t.jobDiscoveryReturnFilters, exact: true })).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel(t.jobDiscoverySort, { exact: true })).toBeFocused();
await expect(article(page, movedFocusJob)).toBeVisible();
const movedGate = hold();
@@ -445,7 +501,7 @@ test('[P7-05 Discovery] contextual Dismiss preserves a row on real missing-resul
expect(route.request().method()).toBe('PATCH'); expect(route.request().postDataJSON()).toEqual({ isDismissed: true });
movedStarts++; await movedGate.pending; return route.fulfill({ status: 204 });
});
const headerControl = page.locator('header').getByRole('button', { name: 'Norsk', exact: true });
const headerControl = page.locator('header').getByRole('button', { name: t.user, exact: true });
try {
await keyboardActivate(page, article(page, movedFocusJob).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
await expect.poll(() => movedStarts).toBe(1); await locked(page);
@@ -463,6 +519,7 @@ test('[P7-05 Discovery] contextual Dismiss preserves a row on real missing-resul
expect(patches.map(request => request.body)).toEqual([{ isDismissed: true }, { isDismissed: true }, { isDismissed: true }]);
await capture(page, info, sample, 'dismiss-intercepted-ui-only');
await page.reload();
await openFilters(page);
await expect(savedGroup(page, saved).getByText(t.jobDiscoveryTrackedCount.replace('{count}', '0'), { exact: true })).toBeVisible();
expect(await nativeList(page.request)).toEqual([saved]);
await info.attach('dismiss-persistence-qualification', { body: JSON.stringify({ savedSearchId: saved.id, externalId: job.id,
@@ -493,8 +550,7 @@ test('[P7-05 Discovery] eight finite responsive locale/theme states retain sourc
const t = translations[sample.language]; jobs = sample.empty ? [] : [longJob, shortJob];
await enter(page, session.owner, sample);
await expect(savedGroup(page, saved)).toBeVisible();
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('samfunnskritisk');
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('Trondheim');
await draftFilters(page, 'samfunnskritisk', 'Trondheim', sample.language);
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySearchNav, exact: true }));
if (sample.empty) await expect(results(page, sample.language).getByText(t.jobDiscoveryNoResults, { exact: true })).toBeVisible();
else {
@@ -530,7 +586,7 @@ test('[P7-05 Discovery] header locale change retains drafts, selected saved sour
await page.getByLabel(en.jobDiscoverySavedName, { exact: true }).fill('Unsubmitted saved-name draft');
const before = [...session.requests];
const savedPreference = response(page, 'PUT', `${apiUrl}/auth/preferences/language`);
await keyboardActivate(page, page.locator('header').getByRole('button', { name: 'Norsk', exact: true }));
await keyboardActivate(page, page.locator('header').getByRole('button', { name: en.languageNorwegianBokmal, exact: true }));
expect((await savedPreference).status()).toBe(204);
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
await expect(page.getByLabel(nb.jobDiscoveryRoleCompany, { exact: true })).toHaveValue('unsubmitted role Æ Ø Å');
@@ -59,6 +59,28 @@ function browserResponse(page: Page, method: string, url = savedUrl) {
return page.waitForResponse(response => response.url() === url && response.request().method() === method);
}
function filterDialog(page: Page, t: typeof translations.en) {
return page.getByRole('dialog', { name: t.jobDiscoveryFilters, exact: true, includeHidden: true });
}
async function openFilters(page: Page, t: typeof translations.en) {
if ((page.viewportSize()?.width ?? 1440) > 1000) return;
const dialog = filterDialog(page, t);
if (await page.getByRole('button', { name: t.jobDiscoveryFilters, exact: true, includeHidden: true }).getAttribute('aria-expanded') !== 'true') await page.getByRole('button', { name: t.jobDiscoveryFilters, exact: true }).click();
await expect(dialog).toBeVisible();
}
async function showResults(page: Page, t: typeof translations.en) {
if ((page.viewportSize()?.width ?? 1440) > 1000) return;
const dialog = filterDialog(page, t);
if (await page.getByRole('button', { name: t.jobDiscoveryFilters, exact: true, includeHidden: true }).getAttribute('aria-expanded') !== 'true') {
await expect(dialog).toHaveCount(0);
return;
}
await dialog.getByRole('button', { name: t.jobDiscoveryJumpResults, exact: true }).click();
await expect(dialog).toBeHidden();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole('region', { name: t.jobDiscoveryResults, exact: true })).toBeFocused();
}
async function capture(page: Page, info: TestInfo, name: string, scan = false) {
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), `${name} horizontal reflow`).toBe(true);
const path = info.outputPath(`${name}.png`);
@@ -254,14 +276,18 @@ test('[P5-15-01R-01] saved-list faults retry a native GET without resetting disc
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', variant.theme);
await expect(page.locator('html')).toHaveAttribute('lang', variant.language === 'nb' ? 'nb-NO' : 'en');
const alert = page.getByRole('alert').filter({ hasText: t.jobDiscoverySavedLoadFailed });
await openFilters(page, t);
await expect(alert).toBeVisible();
await expect(page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true })).toBeDisabled();
await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toHaveCount(0);
await expect(page.getByText(/Internal database stack trace|Invalid saved-search list fixture/)).toHaveCount(0);
await showResults(page, t);
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('developer draft');
await openFilters(page, t);
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('Bergen');
await page.getByRole('button', { name: t.jobDiscoverySearchNav, exact: true }).click();
await expect(page.getByText(providerJob.title, { exact: true })).toBeVisible();
await openFilters(page, t);
await capture(page, info, `${name}-load-error`, true);
injectFault = false;
const retried = browserResponse(page, 'GET');
@@ -274,7 +300,9 @@ test('[P5-15-01R-01] saved-list faults retry a native GET without resetting disc
await expect(page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true })).toBeEnabled();
await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toHaveValue('developer draft');
await expect(page.getByLabel(t.jobDiscoveryMunicipality, { exact: true })).toHaveValue('Bergen');
await showResults(page, t);
await expect(page.getByText(providerJob.title, { exact: true })).toBeVisible();
await openFilters(page, t);
await capture(page, info, `${name}-native-retry`, true);
await page.unroute(savedUrl, faultRoute);
@@ -286,6 +314,7 @@ test('[P5-15-01R-01] saved-list faults retry a native GET without resetting disc
const delayed = browserResponse(page, 'GET');
try {
await page.reload();
await openFilters(page, t);
await expect(page.getByRole('status', { name: t.jobDiscoverySavedLoading, exact: true })).toBeVisible();
await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toHaveCount(0);
await capture(page, info, `${name}-loading`);
@@ -1,7 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { format, parseISO } from "date-fns";
import {
Alert,
@@ -74,18 +73,18 @@ const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
function getTodayIso() {
return format(new Date(), "yyyy-MM-dd");
return new Date().toISOString().slice(0, 10);
}
function parsePickerDate(value?: string | null): Date | null {
if (!value) return null;
const parsed = /^\d{4}-\d{2}-\d{2}$/.test(value) ? parseISO(value) : new Date(value);
const parsed = new Date(value);
return Number.isNaN(+parsed) ? null : parsed;
}
function toPickerIso(value: Date | null) {
if (!value || Number.isNaN(+value)) return "";
return format(value, "yyyy-MM-dd");
return value.toISOString().slice(0, 10);
}
function emptyAttachmentBuckets(): AttachmentBuckets {
@@ -17,7 +17,6 @@ import {
Chip,
} from "@mui/material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { format, parseISO } from "date-fns";
import { api } from "../api";
import { Company, JobApplication } from "../types";
@@ -47,13 +46,13 @@ function toDateInputValue(isoLike?: string): string {
function parsePickerDate(value?: string | null): Date | null {
if (!value) return null;
const parsed = /^\d{4}-\d{2}-\d{2}$/.test(value) ? parseISO(value) : new Date(value);
const parsed = new Date(value);
return Number.isNaN(+parsed) ? null : parsed;
}
function toPickerIso(value: Date | null): string {
if (!value || Number.isNaN(+value)) return "";
return format(value, "yyyy-MM-dd");
return value.toISOString().slice(0, 10);
}
function parseTags(raw: any): string[] {
+16
View File
@@ -67,3 +67,19 @@ test('finishing the opening transition preserves focus already moved inside the
jest.useRealTimers();
}
});
test('finishing the opening transition recovers Cancel from the nested-modal container fallback', async () => {
jest.useFakeTimers();
try {
render(<ConfirmProvider><Demo /></ConfirmProvider>);
fireEvent.click(screen.getByRole('button', { name: /open confirm/i }));
const container = screen.getByRole('dialog', { name: 'Delete job' }).parentElement!;
expect(container).toHaveAttribute('role', 'presentation');
act(() => container.focus());
expect(container).toHaveFocus();
await act(async () => { jest.advanceTimersByTime(1000); });
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
} finally {
jest.useRealTimers();
}
});
+6 -1
View File
@@ -24,6 +24,7 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
const i18n = useOptionalI18n();
const messageId = useId();
const titleId = useId();
const cancelRef = useRef<HTMLButtonElement>(null);
const resolverRef = useRef<((value: boolean) => void) | null>(null);
const [state, setState] = useState<ConfirmState>({
open: false,
@@ -63,6 +64,10 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
<Dialog open={state.open} onClose={() => closeWith(false)} aria-labelledby={titleId} aria-describedby={messageId} fullWidth maxWidth="xs"
slotProps={{
paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } },
transition: { onEntered: (node) => {
// Nested modals can leave initial focus on the container; preserve deliberate focus inside it.
if (node.ownerDocument.activeElement === node) cancelRef.current?.focus();
} },
}}>
<DialogTitle id={titleId} sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
<DialogContent role="region" aria-labelledby={titleId} tabIndex={0}>
@@ -74,7 +79,7 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
<Typography id={messageId} sx={{ color: "text.secondary" }}>{state.message}</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
<Button autoFocus onClick={() => closeWith(false)}>{state.cancelLabel}</Button>
<Button ref={cancelRef} autoFocus onClick={() => closeWith(false)}>{state.cancelLabel}</Button>
<Button color={state.destructive ? "error" : "primary"} variant="contained" onClick={() => closeWith(true)}>
{state.confirmLabel}
</Button>
@@ -1,105 +0,0 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
import { ToastProvider } from "./toast";
import AddJobModal from "./components/AddJobModal";
import EditJobDialog from "./components/EditJobDialog";
// Exercise the component's real Date conversion around the picker's local-Date contract.
jest.mock("@mui/x-date-pickers/DatePicker", () => ({ DatePicker: ({ label, value, onChange, disabled }: any) => {
const day = value ? `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}` : "";
return <div><output aria-label={label}>{day}</output>
<button disabled={disabled} onClick={() => onChange(new Date(2026, 10, 23))}>{label} choose</button>
<button disabled={disabled} onClick={() => onChange(null)}>{label} clear</button>
<button disabled={disabled} onClick={() => onChange(new Date(NaN))}>{label} invalid</button>
</div>;
} }));
jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn(), put: jest.fn() }, getApiErrorMessage: (_: unknown, fallback: string) => fallback }));
const mockCompanies = [{ id: 2, name: "Synthetic school" }];
jest.mock("./hooks/useCompanies", () => ({
useCompanies: () => ({ companies: mockCompanies }), invalidateCompaniesCache: jest.fn(),
}));
const client = api as jest.Mocked<typeof api>;
const job = { id: 1, version: 7, jobTitle: "Teacher", company: { id: 2, name: "Synthetic school" }, status: "Applied", responseReceived: true, dateApplied: "2026-11-23", deadline: "2026-11-23" };
const wrap = (content: React.ReactNode) => <ToastProvider><I18nProvider>{content}</I18nProvider></ToastProvider>;
beforeEach(() => {
jest.resetAllMocks(); localStorage.clear();
client.get.mockResolvedValue({ data: job } as any);
client.put.mockResolvedValue({ data: {} } as any);
client.post.mockImplementation((url) => Promise.resolve({ data: String(url) === "/jobimport/preview"
? { success: true, title: "Teacher", company: "Synthetic school", deadline: "2026-11-23" } : { id: 1 } } as any));
});
afterEach(() => { jest.useRealTimers(); });
test.each(["2026-11-23T00:30:00Z", "2026-11-23T23:30:00Z"])("Add defaults and successful reset use local today at %s", async clock => {
jest.useFakeTimers();
jest.setSystemTime(new Date(clock));
const localNow = new Date(clock);
const expected = `${localNow.getFullYear()}-${String(localNow.getMonth() + 1).padStart(2, "0")}-${String(localNow.getDate()).padStart(2, "0")}`;
const form = (open: boolean) => wrap(<AddJobModal open={open} onClose={jest.fn()} onCreated={jest.fn()} />);
const view = render(form(true));
fireEvent.click(screen.getByRole("button", { name: translations.en.addJobModalEnterManually }));
expect(screen.getByLabelText(translations.en.addJobModalDateApplied)).toHaveTextContent(expected);
fireEvent.click(screen.getByRole("button", { name: `${translations.en.addJobModalDateApplied} choose` }));
fireEvent.change(screen.getByRole("combobox", { name: translations.en.company }), { target: { value: "Synthetic school" } });
fireEvent.change(screen.getByLabelText(translations.en.addJobModalJobTitle), { target: { value: "Teacher" } });
fireEvent.click(screen.getByRole("button", { name: translations.en.continue }));
for (let step = 0; step < 3; step++) fireEvent.click(screen.getByRole("button", { name: translations.en.skipAndContinue }));
fireEvent.click(screen.getByRole("button", { name: translations.en.createAndAddAnother }));
fireEvent.click(await screen.findByRole("button", { name: translations.en.addJobModalEnterManually }));
expect(screen.getByLabelText(translations.en.addJobModalDateApplied)).toHaveTextContent(expected);
view.rerender(form(false));
view.rerender(form(true));
expect(screen.getByLabelText(translations.en.addJobModalDateApplied)).toHaveTextContent(expected);
expect(client.post).toHaveBeenCalledTimes(1);
expect(client.post).toHaveBeenCalledWith("/jobapplications", expect.objectContaining({ dateApplied: "2026-11-23" }));
expect(client.put).not.toHaveBeenCalled();
});
test.each(["en", "nb"] as const)("%s Add date-only picker round trip retains the selected day and existing creation payload", async language => {
localStorage.setItem("uiLanguage", language);
const copy = translations[language];
render(wrap(<AddJobModal open initialUrl="https://example.test/job" onClose={jest.fn()} onCreated={jest.fn()} />));
await screen.findByDisplayValue("Teacher");
expect(screen.getByLabelText(copy.addJobModalDeadline)).toHaveTextContent("2026-11-23");
fireEvent.click(screen.getByRole("button", { name: `${copy.addJobModalDeadline} clear` }));
expect(screen.getByLabelText(copy.addJobModalDeadline)).toBeEmptyDOMElement();
fireEvent.click(screen.getByRole("button", { name: `${copy.addJobModalDeadline} invalid` }));
expect(screen.getByLabelText(copy.addJobModalDeadline)).toBeEmptyDOMElement();
for (const label of [copy.addJobModalDateApplied, copy.addJobModalDeadline]) {
fireEvent.click(screen.getByRole("button", { name: `${label} choose` }));
expect(screen.getByLabelText(label)).toHaveTextContent("2026-11-23");
}
fireEvent.click(screen.getByRole("button", { name: copy.continue }));
for (let step = 0; step < 3; step++) fireEvent.click(screen.getByRole("button", { name: copy.skipAndContinue }));
fireEvent.click(screen.getByRole("button", { name: copy.createJob }));
await waitFor(() => expect(client.post).toHaveBeenCalledWith("/jobapplications", expect.objectContaining({ dateApplied: "2026-11-23", deadline: "2026-11-23" })));
});
test.each(["en", "nb"] as const)("%s Edit date-only picker round trip preserves all five date controls and revision payload", async language => {
localStorage.setItem("uiLanguage", language);
const copy = translations[language];
render(wrap(<EditJobDialog open jobId={1} onClose={jest.fn()} onSaved={jest.fn()} />));
await screen.findByDisplayValue("Teacher");
expect(screen.getByLabelText(copy.editJobDeadline)).toHaveTextContent("2026-11-23");
fireEvent.click(screen.getByRole("button", { name: `${copy.editJobDeadline} clear` }));
fireEvent.click(screen.getByRole("button", { name: `${copy.editJobDeadline} invalid` }));
expect(screen.getByLabelText(copy.editJobDeadline)).toBeEmptyDOMElement();
for (const label of [copy.editJobAppliedOn, copy.editJobStatusChangedOn, copy.editJobReplyReceivedOn, copy.editJobFollowUpOn, copy.editJobDeadline]) {
fireEvent.click(screen.getByRole("button", { name: `${label} choose` }));
expect(screen.getByLabelText(label)).toHaveTextContent("2026-11-23");
}
fireEvent.click(screen.getByRole("button", { name: copy.save }));
await waitFor(() => expect(client.put).toHaveBeenCalledWith("/jobapplications/1", expect.objectContaining({ expectedVersion: 7, dateApplied: "2026-11-23", deadline: "2026-11-23", followUpAt: "2026-11-23", responseDate: "2026-11-23" })));
});
test("Edit retains its existing UTC calendar-day loading policy for a legacy offset timestamp", async () => {
client.get.mockResolvedValue({ data: { ...job, deadline: "2026-11-23T23:30:00-08:00" } } as any);
render(wrap(<EditJobDialog open jobId={1} onClose={jest.fn()} onSaved={jest.fn()} />));
await screen.findByDisplayValue("Teacher");
expect(screen.getByLabelText(translations.en.editJobDeadline)).toHaveTextContent("2026-11-24");
expect(client.put).not.toHaveBeenCalled();
});