fix(operations): preserve task state and accessible recovery
This commit is contained in:
@@ -122,3 +122,12 @@ P7-07 implemented scope is **CONDITIONAL GO and published**. UTC transport repai
|
||||
### P7-08 recent notifications — final bounded observation
|
||||
|
||||
[Recent report](p7-08/RECENT.md) and [portable results](p7-08/evidence/recent-results.json):49/6 affected frontend, four type/lint/build gates, native6+Documents8+CV4+ordinaryP2-D01=19/19 PASS on lIzSurm9WwMhPbE11MvC9. All21 finite inputs held; unchanged API/test DLLs. All16 final notification PNGs actually reviewed;14 all-enabled scans0 violations/102 contextually triaged incompletes. Meaningful REDs/failed external-font guard attempt retained; known font CSS remains aborted. +18frontend/0suites derives773/95; +6browser derives318; separate UTC Fact derives1054backend. Last whole573/89,1033,sidecar49 remain inherited. Implemented first presentation CONDITIONAL GO pending final staged hygiene/normal push; Operations second boundary remains. All57groups56PARTIAL/C11DEFERRED,33routes, P513withheld/followModedeferred/mainunchanged/productionNO-GO/Phase8notstarted.
|
||||
|
||||
|
||||
Recent-notification presentation committed and normally pushed **40cfe04df51654c5ab9a073c13c8d67e0d90dcae**; direct refs verified **2026-10-16T21:15:02.899052UTC**, clean HEAD=origin=live development and local/origin/live main27657f3 unchanged. First presentation boundary remains implemented **CONDITIONAL GO**; Operations is the next independent boundary. Earlier pending-push statements are chronological observations superseded by this receipt. No production or Phase8 action.
|
||||
|
||||
### P7-08 Operations — final bounded gate
|
||||
|
||||
[Operations report](p7-08/OPERATIONS.md) and [portable results](p7-08/evidence/operations-results.json) record fresh **77/7 frontend cases**, app/browser TypeScript, lint and build PASS on **YBJ6UzKUv_ZeDjZzgO3Gh**. Actual Operations5 + Notifications6 + ordinaryP2-D01 = **12/12 native PASS**; no skip/flaky/retry. All25 finite inputs held through2026-10-17T01:59:10.798235UTC; API/testDLLunchanged. All14 Operations PNGs plus one frozen-package PNG actually reviewed (15/31 final retained images); notification16 remain retained with their earlier review distinguished. Operations12 all-enabled axe scans0violations/3incompletes and notification14scans0/102 are contextually triaged, without human/device/zoom/font acceptance. Peer fixes remove the task UUID from its accessible name and restore focus only when an accepted poll removes the focused control. All ordinary REDs, candidate evidence and the test-only typing failure remain retained.
|
||||
|
||||
Eighteen new Operations cases derive **791/95 frontend**; five browser cases derive **323**. Backend stays **1054**, with last whole frontend573/89, backend1033 and sidecar49 inherited untilP7-12. BothP7-08 presentation boundaries are implemented **CONDITIONAL GO**, with Operations final staged hygiene/normal push pending. Recent40cfe04 is already pushed and directly verified. All57groups56PARTIAL/C11DEFERRED and33routes remain; F20/RD11/RD12/RD13/RD21/manual/provider/output/release debt stays. P513withheld/followModedeferred/mainunchanged/productionNO-GO/Phase8notstarted. P7-09 preferences/Connections then data/privacy/plan/backup follows the approved order.
|
||||
|
||||
@@ -212,3 +212,12 @@ P7-07 implemented scope is **CONDITIONAL GO and published**. UTC transport repai
|
||||
### P7-08 recent notifications — final bounded observation
|
||||
|
||||
[Recent report](p7-08/RECENT.md) and [portable results](p7-08/evidence/recent-results.json):49/6 affected frontend, four type/lint/build gates, native6+Documents8+CV4+ordinaryP2-D01=19/19 PASS on lIzSurm9WwMhPbE11MvC9. All21 finite inputs held; unchanged API/test DLLs. All16 final notification PNGs actually reviewed;14 all-enabled scans0 violations/102 contextually triaged incompletes. Meaningful REDs/failed external-font guard attempt retained; known font CSS remains aborted. +18frontend/0suites derives773/95; +6browser derives318; separate UTC Fact derives1054backend. Last whole573/89,1033,sidecar49 remain inherited. Implemented first presentation CONDITIONAL GO pending final staged hygiene/normal push; Operations second boundary remains. All57groups56PARTIAL/C11DEFERRED,33routes, P513withheld/followModedeferred/mainunchanged/productionNO-GO/Phase8notstarted.
|
||||
|
||||
|
||||
Recent-notification presentation committed and normally pushed **40cfe04df51654c5ab9a073c13c8d67e0d90dcae**; direct refs verified **2026-10-16T21:15:02.899052UTC**, clean HEAD=origin=live development and local/origin/live main27657f3 unchanged. First presentation boundary remains implemented **CONDITIONAL GO**; Operations is the next independent boundary. Earlier pending-push statements are chronological observations superseded by this receipt. No production or Phase8 action.
|
||||
|
||||
### P7-08 Operations — final bounded gate
|
||||
|
||||
[Operations report](p7-08/OPERATIONS.md) and [portable results](p7-08/evidence/operations-results.json) record fresh **77/7 frontend cases**, app/browser TypeScript, lint and build PASS on **YBJ6UzKUv_ZeDjZzgO3Gh**. Actual Operations5 + Notifications6 + ordinaryP2-D01 = **12/12 native PASS**; no skip/flaky/retry. All25 finite inputs held through2026-10-17T01:59:10.798235UTC; API/testDLLunchanged. All14 Operations PNGs plus one frozen-package PNG actually reviewed (15/31 final retained images); notification16 remain retained with their earlier review distinguished. Operations12 all-enabled axe scans0violations/3incompletes and notification14scans0/102 are contextually triaged, without human/device/zoom/font acceptance. Peer fixes remove the task UUID from its accessible name and restore focus only when an accepted poll removes the focused control. All ordinary REDs, candidate evidence and the test-only typing failure remain retained.
|
||||
|
||||
Eighteen new Operations cases derive **791/95 frontend**; five browser cases derive **323**. Backend stays **1054**, with last whole frontend573/89, backend1033 and sidecar49 inherited untilP7-12. BothP7-08 presentation boundaries are implemented **CONDITIONAL GO**, with Operations final staged hygiene/normal push pending. Recent40cfe04 is already pushed and directly verified. All57groups56PARTIAL/C11DEFERRED and33routes remain; F20/RD11/RD12/RD13/RD21/manual/provider/output/release debt stays. P513withheld/followModedeferred/mainunchanged/productionNO-GO/Phase8notstarted. P7-09 preferences/Connections then data/privacy/plan/backup follows the approved order.
|
||||
|
||||
@@ -220,3 +220,12 @@ P7-07 implemented scope is **CONDITIONAL GO and published**. UTC transport repai
|
||||
### P7-08 recent notifications — final bounded observation
|
||||
|
||||
[Recent report](p7-08/RECENT.md) and [portable results](p7-08/evidence/recent-results.json):49/6 affected frontend, four type/lint/build gates, native6+Documents8+CV4+ordinaryP2-D01=19/19 PASS on lIzSurm9WwMhPbE11MvC9. All21 finite inputs held; unchanged API/test DLLs. All16 final notification PNGs actually reviewed;14 all-enabled scans0 violations/102 contextually triaged incompletes. Meaningful REDs/failed external-font guard attempt retained; known font CSS remains aborted. +18frontend/0suites derives773/95; +6browser derives318; separate UTC Fact derives1054backend. Last whole573/89,1033,sidecar49 remain inherited. Implemented first presentation CONDITIONAL GO pending final staged hygiene/normal push; Operations second boundary remains. All57groups56PARTIAL/C11DEFERRED,33routes, P513withheld/followModedeferred/mainunchanged/productionNO-GO/Phase8notstarted.
|
||||
|
||||
|
||||
Recent-notification presentation committed and normally pushed **40cfe04df51654c5ab9a073c13c8d67e0d90dcae**; direct refs verified **2026-10-16T21:15:02.899052UTC**, clean HEAD=origin=live development and local/origin/live main27657f3 unchanged. First presentation boundary remains implemented **CONDITIONAL GO**; Operations is the next independent boundary. Earlier pending-push statements are chronological observations superseded by this receipt. No production or Phase8 action.
|
||||
|
||||
### P7-08 Operations — final bounded gate
|
||||
|
||||
[Operations report](p7-08/OPERATIONS.md) and [portable results](p7-08/evidence/operations-results.json) record fresh **77/7 frontend cases**, app/browser TypeScript, lint and build PASS on **YBJ6UzKUv_ZeDjZzgO3Gh**. Actual Operations5 + Notifications6 + ordinaryP2-D01 = **12/12 native PASS**; no skip/flaky/retry. All25 finite inputs held through2026-10-17T01:59:10.798235UTC; API/testDLLunchanged. All14 Operations PNGs plus one frozen-package PNG actually reviewed (15/31 final retained images); notification16 remain retained with their earlier review distinguished. Operations12 all-enabled axe scans0violations/3incompletes and notification14scans0/102 are contextually triaged, without human/device/zoom/font acceptance. Peer fixes remove the task UUID from its accessible name and restore focus only when an accepted poll removes the focused control. All ordinary REDs, candidate evidence and the test-only typing failure remain retained.
|
||||
|
||||
Eighteen new Operations cases derive **791/95 frontend**; five browser cases derive **323**. Backend stays **1054**, with last whole frontend573/89, backend1033 and sidecar49 inherited untilP7-12. BothP7-08 presentation boundaries are implemented **CONDITIONAL GO**, with Operations final staged hygiene/normal push pending. Recent40cfe04 is already pushed and directly verified. All57groups56PARTIAL/C11DEFERRED and33routes remain; F20/RD11/RD12/RD13/RD21/manual/provider/output/release debt stays. P513withheld/followModedeferred/mainunchanged/productionNO-GO/Phase8notstarted. P7-09 preferences/Connections then data/privacy/plan/backup follows the approved order.
|
||||
|
||||
@@ -17,7 +17,7 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede
|
||||
| [P7-06](p7-06/README.md) | Remaining Application forms and global correspondence | All three boundaries implemented CONDITIONAL GO: forms4c325/inboxa791 normally pushed; [compose](p7-06/COMPOSE.md) Compose affected52/5 and fresh backend25 PASS; native10+inbox8+ordinaryP2-D01=19 PASS on LFCrTZzDtX5hNBSRBj5Xx; app/browser TypeScript, lint and build PASS. Actual image review 47/47; all-enabled44scans0violations/330incompletes triaged, human/device/provider qualifications remain. +10front/+0suites,+10browser,+7backend derive739/94,301,1041 only; no integrated full-suite claim. Hygiene PASS and normal223dcf5 push directly verified11:03:15UTC;06Fdeferred/57groups32routes/main unchanged; P7-07 contract first next |
|
||||
| P7-06F | Deferred `followMode` reviewed composer | Optional separately reviewed mail slice; **DEFERRED** until mode→draft/review action and fallback are explicitly decided |
|
||||
| [P7-07](p7-07/README.md) | Documents owner-scoped index | Implemented CONDITIONAL GO; contract/UI published and directly verified through9dfea12 at21:01:11UTC. Final affected86/8, native8+CV4+ordinaryP2-D01/types/lint/build PASS;18 reviewed PNGs,15axe0/101 qualified. Original32+Documents=33routes; actual MariaDB/human/device/production qualifications remain |
|
||||
| [P7-08](p7-08/README.md) | Existing notifications and operations | UTC repair9dfea12 published/directly verified,4/4+73 backend PASS. Recent/count presentation final validation ongoing; Operations remains next. Existing store/routes only, F20/no new enabled producers retained |
|
||||
| [P7-08](p7-08/README.md) | Existing notifications and operations | Both presentation boundaries implemented CONDITIONAL GO. UTC9dfea/recent40cfe04 published; Operations77/7+native12/types/lint/build PASS,14Ops+1P2images reviewed,12Opsaxe0/3 and14notifaxe0/102 qualified. Final Operations hygiene/normal push pending;57groups33routes/F20 preserved. P7-09 next |
|
||||
| P7-09 | Remaining Settings, Connections and data lifecycle | Existing independent save scopes and sensitive-action guards; planned |
|
||||
| P7-10 | Admin operational presentation | Existing distinct Users/Audit/System routes and server authorization; planned |
|
||||
| P7-11 | Remaining Analytics/error/empty/dialog integration | Inventory-confirmed residual presentation only; existing reports remain; planned |
|
||||
@@ -98,3 +98,12 @@ P7-07 implemented scope is **CONDITIONAL GO and published**. UTC transport repai
|
||||
### P7-08 recent notifications — final bounded observation
|
||||
|
||||
[Recent report](p7-08/RECENT.md) and [portable results](p7-08/evidence/recent-results.json):49/6 affected frontend, four type/lint/build gates, native6+Documents8+CV4+ordinaryP2-D01=19/19 PASS on lIzSurm9WwMhPbE11MvC9. All21 finite inputs held; unchanged API/test DLLs. All16 final notification PNGs actually reviewed;14 all-enabled scans0 violations/102 contextually triaged incompletes. Meaningful REDs/failed external-font guard attempt retained; known font CSS remains aborted. +18frontend/0suites derives773/95; +6browser derives318; separate UTC Fact derives1054backend. Last whole573/89,1033,sidecar49 remain inherited. Implemented first presentation CONDITIONAL GO pending final staged hygiene/normal push; Operations second boundary remains. All57groups56PARTIAL/C11DEFERRED,33routes, P513withheld/followModedeferred/mainunchanged/productionNO-GO/Phase8notstarted.
|
||||
|
||||
|
||||
Recent-notification presentation committed and normally pushed **40cfe04df51654c5ab9a073c13c8d67e0d90dcae**; direct refs verified **2026-10-16T21:15:02.899052UTC**, clean HEAD=origin=live development and local/origin/live main27657f3 unchanged. First presentation boundary remains implemented **CONDITIONAL GO**; Operations is the next independent boundary. Earlier pending-push statements are chronological observations superseded by this receipt. No production or Phase8 action.
|
||||
|
||||
### P7-08 Operations — final bounded gate
|
||||
|
||||
[Operations report](p7-08/OPERATIONS.md) and [portable results](p7-08/evidence/operations-results.json) record fresh **77/7 frontend cases**, app/browser TypeScript, lint and build PASS on **YBJ6UzKUv_ZeDjZzgO3Gh**. Actual Operations5 + Notifications6 + ordinaryP2-D01 = **12/12 native PASS**; no skip/flaky/retry. All25 finite inputs held through2026-10-17T01:59:10.798235UTC; API/testDLLunchanged. All14 Operations PNGs plus one frozen-package PNG actually reviewed (15/31 final retained images); notification16 remain retained with their earlier review distinguished. Operations12 all-enabled axe scans0violations/3incompletes and notification14scans0/102 are contextually triaged, without human/device/zoom/font acceptance. Peer fixes remove the task UUID from its accessible name and restore focus only when an accepted poll removes the focused control. All ordinary REDs, candidate evidence and the test-only typing failure remain retained.
|
||||
|
||||
Eighteen new Operations cases derive **791/95 frontend**; five browser cases derive **323**. Backend stays **1054**, with last whole frontend573/89, backend1033 and sidecar49 inherited untilP7-12. BothP7-08 presentation boundaries are implemented **CONDITIONAL GO**, with Operations final staged hygiene/normal push pending. Recent40cfe04 is already pushed and directly verified. All57groups56PARTIAL/C11DEFERRED and33routes remain; F20/RD11/RD12/RD13/RD21/manual/provider/output/release debt stays. P513withheld/followModedeferred/mainunchanged/productionNO-GO/Phase8notstarted. P7-09 preferences/Connections then data/privacy/plan/backup follows the approved order.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# P7-08 — Operations page
|
||||
|
||||
This is the second presentation boundary in the [approved checkpoint](README.md). **Operations implemented scope: CONDITIONAL GO; final staged hygiene, commit and normal push remain pending.** The [recent-notification slice](RECENT.md) is committed and normally pushed as **`40cfe04df51654c5ab9a073c13c8d67e0d90dcae`**. Direct verification at **2026-10-16 21:15:02.899052 UTC** proves local HEAD = origin = live development, a clean worktree at entry and unchanged local/origin/live `main` (`27657f31131cd95d42f0e1290853ec9c9b3959a4`). The exact receipt is `.artifacts/phase7/p7-08/recent-push-receipt.json`; it does not publish later Operations work.
|
||||
|
||||
## Baseline and bounded scope
|
||||
|
||||
Fresh existing Operations tests pass **3 cases / 1 suite**, **21:15:20.042688–21:15:23.807296 UTC**, `p708-operations-entry-20261016T211520Z`. All 21 finite inputs match in `operations-entry-pre/source.json` and `operations-entry-post/source.json`, observed **21:15:19.506584→21:15:27.667333 UTC**. The [portable entry](evidence/operations-entry.json) retains the raw commands, three ordinary case names and source/push receipts. The prior recent slice's 49/6 and native19 remain separate evidence.
|
||||
|
||||
Keep the existing `/operations` route and shell H1, two list reads bounded at 50, 15-second polling, explicit Refresh, notification read/dismiss and server-admitted Cancel/Retry. Reuse shared section/theme/feedback primitives and the existing operation DTO. No API/schema/provider/model/worker/route owner changes belong to this boundary. Canonical Profile facts, CV overrides/revisions, frozen packages and original PDF geometry remain owned elsewhere.
|
||||
|
||||
## Confirmed source findings and required proof
|
||||
|
||||
| Finding | Bounded correction / required regression |
|
||||
|---|---|
|
||||
| Locale callback changes restart reads/polling and old responses are unsequenced | Keep the load owner stable, abort/invalidate obsolete reads, reject stale completions and dispose callbacks; locale changes format cached data without another GET |
|
||||
| State-only busy flag permits same-turn competing actions | Reserve writes synchronously; preserve server `canCancel` / `canRetry` authority and the captured operation ID |
|
||||
| A poll can overwrite a newer committed action | Invalidate the pre-write read and adopt the actual returned operation DTO or acknowledged notice change; reconcile with a new snapshot |
|
||||
| A committed write followed by failed refresh looks like an ordinary action error | Distinguish read recovery from write failure; Retry must perform GET only, with no replay or stale enabled action |
|
||||
| Initial read failure also shows empty lists | Establish emptiness only after a successful snapshot; retain known rows on failed refresh |
|
||||
| Removed/disabled initiating controls lose focus | Use a stable named activity region during the action and a section-heading fallback when removed; respect deliberate focus elsewhere |
|
||||
| Raw known status/task codes, created date labelled Started and faded read cards | Localize supported known codes, retain honest unknown metadata, label the actual created date and keep readable surfaces/semantic read state |
|
||||
| Real Cancel creates a notice but the shell event is omitted | Emit the existing change event after a committed mutation even when its follow-up read fails; preserve notification/task separation |
|
||||
|
||||
Add ordinary unit REDs before repair, affected consumer tests, actual local authenticated lifecycle/owner/recovery workflows and finite responsive/theme/language/keyboard/axe evidence. Controlled metadata/progress/link/error projections must remain labelled and must not imply worker or result-contract qualification. Never activate a handler to manufacture a working-state fixture.
|
||||
|
||||
## Ordinary regressions and bounded changes
|
||||
|
||||
Times in this section are **2026-10-16 UTC**. Raw commands and ordinary Jest reports remain under `.artifacts/phase7/current/unit/`; named source observations remain under `.artifacts/phase7/p7-08/`.
|
||||
|
||||
| Observation | Actual result and qualification |
|
||||
|---|---|
|
||||
| First Operations RED | **3 pass / 17 fail of 20**, **21:17:55.099112–21:18:04.281849**, `p708-operations-red-agent-20261016T211755Z`; ordinary runtime/DOM failures, with no pending/todo cases. All 21 inputs match in `operations-red-agent-pre/post/source.json` |
|
||||
| Unknown-metadata RED | **19 pass / 1 fail of 20**, **21:21:38.717718–21:21:44.554603**, `p708-operations-metadata-red-20261016T212138Z`; the `toString` task identifier exercises an inherited-property lookup rather than a supported task mapping |
|
||||
| Paired-read abort RED | **19 pass / 1 fail of 20**, **21:22:49.957266–21:22:55.672446**, `p708-operations-pair-red-20261016T212249Z`; initial failed-read recovery must abort its outstanding sibling read |
|
||||
| First affected candidate | **73 cases / 6 suites PASS**, **21:23:03.108059–21:23:18.069912**, `p708-operations-verified-green-20261016T212303Z`; this includes the ten sidebar-active-navigation cases |
|
||||
| Separate shell-navigation consumer | **3 cases / 1 suite PASS**, **21:24:10.940417–21:24:15.838318**, `p708-operations-navigation-consumer-20261016T212410Z`; the disjoint affected total is **76/7**, not 66 |
|
||||
| Peer-review RED | **19 pass / 2 fail of 21**, **21:27:51.031680–21:27:59.380417**, `p708-operations-peer-red-20261016T212751Z`; an existing assertion requires a human task name, and one new case demonstrates focus lost to BODY when an accepted poll removes the focused control. All 25 peer-RED inputs match |
|
||||
| Peer repair candidate | **74/6 PASS**, **21:28:36.894486–21:28:51.898001**, `p708-operations-peer-green-20261016T212836Z`, with all 25 peer-GREEN inputs held; separate shell navigation **3/1 PASS**, **21:29:39.234703–21:29:44.379120**, `p708-operations-final-navigation-20261016T212939Z`. The affected total is **77/7**; runtime success alone does not establish interface acceptance |
|
||||
|
||||
The presentation uses the existing shared page sections, density padding, theme and localization. Read ownership now reserves one active load, invalidates obsolete responses, aborts paired reads on failure and disposes polling on unmount. Language changes reformat cached rows. Actions reserve a synchronous guard, capture the original ID, retain server eligibility and invalidate earlier polls. Returned operation DTOs or acknowledged notice changes remain visible if the follow-up read fails; the resulting recovery performs GET only and blocks mutation replay. Successful mutations still emit the existing notification-change event.
|
||||
|
||||
Empty states require a successful snapshot. Known task/status mappings use own-property checks; unknown values remain visible. Dates describe creation, read notices retain readable surfaces, actions wrap with responsive target sizes, and the activity region supports stable focus. Peer corrections give operation articles a human task name while retaining `data-operation-id` for exact test identity, and restore the heading only when a poll removed the focused control and focus fell to BODY. Surviving controls and deliberate focus elsewhere keep focus. These changes do not alter backend admission, DTO fields, storage, workers or result contracts.
|
||||
|
||||
## First native candidate and its limits
|
||||
|
||||
The focused native RED uses the prior recent-slice export **`lIzSurm9WwMhPbE11MvC9`**, before Operations changes. It fails **1/1**, **21:22:15.983984–21:22:31.840570**, because the new named activity region is absent. Root's actual review of the failed PNG also observes an error and false empty state together. The test stops before the held-poll/cancellation assertions; this RED does **not** prove those later assertions failed. Capsule `.artifacts/phase7/p7-08/operations-native-red/results.json` has SHA256 `afd166cb00ecc656c28029ba01bfaa5652cd98c8d75a02244824c4362a5c8aa8`.
|
||||
|
||||
The first candidate capsule `.artifacts/phase7/p7-08/operations-candidate-verified/results.json`, SHA256 `734d0fecbeac66f977ced1aaf9d1ccc1d444d8d00637d49becac60ea01ebb370`, retains the **76/7** unit evidence, four passing gates and build **`U-xpRaXrf8JBPqE2QoR9B`**. Lint/app TypeScript/browser TypeScript/build pass **21:23:40.110553–21:24:42.415744**. Native Operations **5/5** passes **21:24:42.416270–21:25:38.901271**; notification consumers **6/6** pass **21:25:38.908326–21:26:22.058289**; ordinary **P2-D01 1/1** passes **21:26:22.064846–21:26:35.660951**. All 12 pass without skip, unexpected result, flaky result or retry.
|
||||
|
||||
That candidate records **12 Operations axe scans: 0 violations / 3 incomplete nodes**, and **14 notification scans: 0 / 102**. Its **31 retained images have not been accepted as an actual visual review**. All 25 inputs match from `operations-final-pre` through `operations-candidate-post`, observed through **21:26:53 UTC**. This evidence predates the peer corrections and cannot accept their later build. Controlled progress, unknown metadata, errors and navigation projections remain distinguished from real authenticated local mutations; no handler or provider/model is enabled to create fixture states.
|
||||
|
||||
## Later gate failure and pause
|
||||
|
||||
After the peer runtime pass, the next gate batch passes lint **21:29:28–21:29:56**, then fails app TypeScript **21:29:56–21:30:10**: test line 83 supplies `exact: true` to an installed `ByRoleOptions` type that does not support it. Capsule `.artifacts/phase7/p7-08/operations-peer-types-failed/results.json`, SHA256 `2d46184762d5712ae2bdf168962a1a536d4bc6d07d96058265d1d6183ca94d78`, freezes these actual gate receipts. No successful build/native run on the peer-corrected source follows from that batch. The later correction removes only the unsupported test option, preserving the literal name, explicit accessible-name assertion, data-ID checks and API assertions; production code remains unchanged.
|
||||
|
||||
All 25 frozen inputs remain unchanged across the pause through **2026-10-17 01:55:29.881309 UTC**. No execution during the pause is inferred. The resumed fetch succeeds at **01:55 UTC**, with HEAD/origin at the already published `40cfe04` and unchanged `main`; Operations presentation work remains uncommitted at that observation. Fresh post-correction proof follows.
|
||||
|
||||
## Final finite evidence — 2026-10-17 UTC
|
||||
|
||||
The [portable Operations results](evidence/operations-results.json) retain all ordinary history, the failed/superseded capsules and the final manifests. Final capsule `.artifacts/phase7/p7-08/operations-final-verified/results.json` has SHA256 `b65047355b75e12fc739e3a12c32b16448b01194f84eb1f06480d42198c914aa`; the separate consumer capsule `.artifacts/phase7/p7-08/operations-final-consumers/results.json` has SHA256 `dfafa88a02b9d33cc38ac1cf483631cc9748d9c72dbc81475d7c56ea16cf4c25`.
|
||||
|
||||
| Final command | Actual result and UTC interval |
|
||||
|---|---|
|
||||
| Affected frontend | **77 cases / 7 suites PASS**, **01:56:05.783220–01:56:21.237302**, zero failed/pending/todo |
|
||||
| Lint | Exit **0**, **01:56:15.965185–01:56:38.079229** |
|
||||
| App TypeScript | Exit **0**, **01:56:38.081256–01:56:48.967183** |
|
||||
| Browser TypeScript | Exit **0**, **01:56:48.970700–01:57:01.477286** |
|
||||
| Production build | Exit **0**, **01:57:01.479288–01:57:09.154781**; build **`YBJ6UzKUv_ZeDjZzgO3Gh`** |
|
||||
| Native Operations | **5/5 PASS**, **01:57:09.159317–01:58:02.566772** |
|
||||
| Native notification consumers | **6/6 PASS**, **01:58:02.572799–01:58:42.574608** |
|
||||
| Ordinary P2-D01 | **1/1 PASS**, **01:58:42.582126–01:58:55.731272** |
|
||||
|
||||
All **12 native cases** pass with zero skips, unexpected or flaky results and zero retries. All **25 finite source inputs** match from `operations-types-final-pre` at **01:56:05.282845** through `operations-types-final-post` at **01:59:10.798235**; API and test DLLs remain unchanged. The actual local Program/cookie journeys preserve same-ID cancel/retry and read/dismiss persistence, owner/anonymous boundaries and ordinary P2-D01. The held-poll, failed-read and long/unknown presentation projections remain explicitly controlled evidence, rather than provider/worker execution.
|
||||
|
||||
All **14 final Operations PNGs are actually reviewed**, seven by root and seven by peer; root also reviews the final P2-D01 image. Thus **15 of 31 retained final images** receive actual review. The other **16 notification-consumer images are retained**, without a new review claim; the prior recent-slice review remains separate. The finite Operations evidence covers responsive sizes, English/Bokmål, themes and keyboard behavior without establishing human assistive-technology or device acceptance.
|
||||
|
||||
Final Operations axe records **12 scans, 0 violations / 3 incomplete occurrences**: two short badge-text contrast contexts and one overlapping-background language-button context. Notification consumers independently record **14 scans, 0 / 102**: 14 modal-root focus contexts, four short badge-text contexts, 83 overlapping-background contexts and one partially obscured Dashboard label. All rule/node vectors remain retained; precise contrast and manual acceptance remain qualified. No rule is waived to produce the passing result.
|
||||
|
||||
The preliminary [static-check receipt](evidence/operations-static-checks.json) passes at **02:02:20.371383 UTC**: 23 documents, 566 local links, 57 preservation groups, 33 routes and four secret/whitespace commands exiting 0. An earlier preliminary documentation check correctly failed while this named manifest did not yet exist; creating the required manifest resolves the missing target without weakening checks. Final staged hygiene and canonical source comparison, commit, normal development push and direct remote verification remain pending.
|
||||
|
||||
## Preservation and current decision
|
||||
|
||||
Operations implemented scope is **CONDITIONAL GO**, pending final staged hygiene, commit and verified normal publication. Both P7-08 presentation boundaries are now implemented conditionally; the recent slice is already published. The 21 Operations cases include **18 additions** over its three-case entry, with no new frontend suite: the derived inventory becomes **frontend 791/95** from the recent slice's 773/95, and five new browser cases derive **323** from 318. Backend remains **1,054** from the separate UTC repair. These are inventories, not fresh integrated runs or publication evidence; full frontend 573/89, backend 1,033 and sidecar 49 remain inherited until P7-12.
|
||||
|
||||
All 57 groups remain **56 PARTIAL / C11 DEFERRED**, with 33 current route patterns. F20 and existing reliability debt, human screen-reader, real zoom, physical touch/software keyboard, production font/photo/print, actual MariaDB/provider execution and PDF-corpus qualifications remain open. P5-13 is withheld with zero qualification calls; followMode is deferred. Production is **NO-GO**, main is unchanged and Phase 8 implementation has not started. Settings follows this boundary in the approved sequence.
|
||||
|
||||
Final staged hygiene passes **2026-10-17T02:03:25.900686UTC**: four secret/whitespace gates, 23 documents/568 local links, 57 groups and 33 routes. All 25 staged canonical-LF hashes match final as-run sources. Operations commit and normal development push follow; no production action.
|
||||
@@ -1,6 +1,6 @@
|
||||
# P7-08 — Existing notifications and operations
|
||||
|
||||
**Status: UTC repair published; recent popover/shell slice implemented CONDITIONAL GO with final staged hygiene, commit and its own normal push pending. Operations migration remains pending.** This checkpoint migrates existing notification and operation interfaces under the [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries) and [implementation plan](../../IMPLEMENTATION_PLAN.md). It preserves the existing stores, routes, permissions and operation lifecycle.
|
||||
**Status: both presentation boundaries implemented CONDITIONAL GO. UTC repair and the recent popover/shell slice are published; Operations final staged hygiene, commit and normal push remain pending.** This checkpoint migrates existing notification and operation interfaces under the [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries) and [implementation plan](../../IMPLEMENTATION_PLAN.md). It preserves the existing stores, routes, permissions and operation lifecycle.
|
||||
|
||||
## Entry and publication qualification
|
||||
|
||||
@@ -33,13 +33,13 @@ All times below are UTC on **2026-10-16**. Exact commands/reports remain under `
|
||||
| Expanded focus/GET RED | **12 pass / 17 fail of 29**, **16:35:03.956545–16:35:15.226833**; four new ordinary focus/GET cases add one passing and three failing observations. The 13 captured frontend inputs match. The broader 18-input snapshot includes a separate backend DTO edit after this command ended; do not claim all 18 were held |
|
||||
| First presentation candidate | **48/6 PASS**, **16:38:36.709407–16:38:50.647886**; affected popover, shell, Operations, navigation, quick-command and i18n cases, zero failed/pending/todo. All 18 candidate inputs are held before/after; subsequent additional GET/Retry focus coverage remains pending |
|
||||
| GET/Retry focus RED | **20 pass / 1 fail of 21**, **20:53:41.526191–20:53:48.271873**; the new ordinary popover case observes meaningful focus loss to BODY after GET recovery. All 18 captured inputs match before/after |
|
||||
| Latest presentation candidate | **49/6 PASS**, **20:54:23.214108–20:54:37.797995**; the added focus case and existing affected suites pass, zero failed/pending/todo. All 18 inputs in `retry-focus-green-pre/post` are held; actual native/visual evidence remains pending |
|
||||
| Retry-focus presentation candidate | **49/6 PASS**, **20:54:23.214108–20:54:37.797995**; the added focus case and existing affected suites pass, zero failed/pending/todo. All 18 inputs in `retry-focus-green-pre/post` are held; native/visual evidence is pending at this observation |
|
||||
|
||||
The first RED adds **13 ordinary cases**: ten popover and three shell cases. An existing shell count assertion is also strengthened; its failure does not represent an extra test. Four later focus/GET cases and the final Retry-focus case bring the candidate's additions to 18. The small recovery correction remembers the initiating Retry focus, waits for loading completion and preserves its original target while using the stable fallback. Retain every RED; a focused GREEN is not final interface acceptance.
|
||||
|
||||
The first candidate's app/browser TypeScript, lint and production build pass between **16:38:46 and 16:39:49 UTC**, with observed build identity **`33Og4QvWi-VvlxXRjUnvY`**. Old command/log receipts remain under `.artifacts/phase7/current/ui/attempts/`; that full static export was not separately copied. These are historical candidate gates, not native/visual or later-source acceptance.
|
||||
|
||||
Latest lint **20:54:33.381477–20:55:00.917580**, app TypeScript **20:55:00.919594–20:55:14.103408**, browser TypeScript **20:55:14.105415–20:55:20.863722** and build **20:55:20.865736–20:55:28.793130** all exit 0. Current build identity is **`-A-T31QuUMNqWVSxMqUu7`**. Frozen-source candidate checks do not close actual HTTP, native journeys, screenshots or final presentation acceptance.
|
||||
The Retry-focus candidate's lint **20:54:33.381477–20:55:00.917580**, app TypeScript **20:55:00.919594–20:55:14.103408**, browser TypeScript **20:55:14.105415–20:55:20.863722** and build **20:55:20.865736–20:55:28.793130** all exit 0, with build identity **`-A-T31QuUMNqWVSxMqUu7`**. These historical gates do not close actual HTTP, native journeys, screenshots or final presentation acceptance.
|
||||
|
||||
## Findings and bounded repair requirements
|
||||
|
||||
@@ -52,9 +52,9 @@ Latest lint **20:54:33.381477–20:55:00.917580**, app TypeScript **20:55:00.919
|
||||
| Shell count starts at 0 and maps read failure to 0 | Distinguish known positive count, explicit known zero and unknown/unavailable count; do not announce a fabricated zero |
|
||||
| Shell interval/event reads have no ordering guard | A stale completion cannot overwrite the latest count or publish after unmount/account change; prove bounded single-flight/recovery behavior |
|
||||
|
||||
The notification trigger keeps its accessible name **“Notifications” / “Varsler”**. Count availability belongs in a localized `aria-describedby` description: known 3, known 0 and absent/unknown are distinct states. Preserve keyboard activation, close/focus restoration and the existing navigation/dirty guards. Latest candidate regressions include the GET/Retry focus repair; actual native evidence is still required.
|
||||
The notification trigger keeps its accessible name **“Notifications” / “Varsler”**. Count availability belongs in a localized `aria-describedby` description: known 3, known 0 and absent/unknown are distinct states. Preserve keyboard activation, close/focus restoration and the existing navigation/dirty guards. Candidate regressions include the GET/Retry focus repair; the later final native evidence is recorded below.
|
||||
|
||||
Operations inspection records additional review targets: polling and list reads depend on translation callbacks; busy protection is state-only; two resources share a load/error path; task/status/failure values are displayed from raw identifiers. These are source findings to validate in the second boundary, not claims that an Operations migration or repair has executed.
|
||||
Operations inspection records additional review targets: polling and list reads depend on translation callbacks; busy protection is state-only; two resources share a load/error path; task/status/failure values are displayed from raw identifiers. The [Operations report](OPERATIONS.md) records the separate regressions, bounded changes and final conditional acceptance.
|
||||
|
||||
## Separate UTC repair and native evidence plan
|
||||
|
||||
@@ -70,7 +70,7 @@ All **57 preservation groups remain 56 PARTIAL / C11 DEFERRED**; [preservation](
|
||||
|
||||
Entry inventory is **frontend 755/95, browser 312, backend 1,053** from P7-07. One new UTC Fact derives backend **1,054**; 18 new frontend cases with no new suites derive **773/95**, and six new native cases derive browser **318**. Earlier 772/95 precedes the final Retry-focus case. These are inventories, not fresh integrated counts: last full frontend **573/89**, backend **1,033** and sidecar **49** remain inherited. Preserve all ordinary failures without skip/expected-failure masking. P7-12 owns fresh integrated regression.
|
||||
|
||||
UTC repair hygiene/commit/publication are complete. Recent-slice images are reviewed; finish staged hygiene and its separate commit/normal push, retaining contextual/manual qualifications, then implement the distinct Operations boundary. Remote recovery of earlier commits does not publish presentation WIP. Each later logical boundary still needs its own evidence, preservation reconciliation and verified publication.
|
||||
UTC repair and recent-slice hygiene/commit/publication are complete. Operations final runtime/visual evidence is complete within its qualifications; staged hygiene, commit and its own verified normal publication remain. Earlier pushes do not publish later presentation WIP.
|
||||
|
||||
## Recent popover and shell — final finite evidence
|
||||
|
||||
@@ -78,6 +78,25 @@ UTC repair hygiene/commit/publication are complete. Recent-slice images are revi
|
||||
|
||||
Final consumers independently pass **Documents 8/8**, **21:08:28.263615–21:09:23.407862**; **CV frame 4/4**, **21:09:23.415937–21:10:54.533727**; **ordinary P2-D01 1/1**, **21:10:54.543299–21:11:08.177981**. Consumer capsule `recent-final-consumers/results.json` SHA256 is `d651e02c416023bccb84ab00d6cfe1222865819899f14417577f946667a1632c`. All 21 captured sources hold **21:06:33.992833→21:11:29.899599**; API/test DLLs remain unchanged.
|
||||
|
||||
All **16 final notification PNGs** are actually reviewed, eight by root and eight by peer. The **14 enabled axe/target-size scans record 0 violations / 102 incomplete nodes**, retained for contextual/manual qualification. Preliminary hygiene passes at **21:12:44 UTC**: 22 documents, 538 local links, all 57 groups and 33 routes. Final staged hygiene, commit and normal push remain pending. Real local API transitions and controlled projections remain distinguished; no all-seven-date hosted, human screen-reader, physical-device or production qualification follows. **First presentation slice: implemented CONDITIONAL GO, pending staged hygiene/commit/push. Whole P7-08: ongoing; Operations migration remains pending.**
|
||||
All **16 final notification PNGs** are actually reviewed, eight by root and eight by peer. The **14 enabled axe/target-size scans record 0 violations / 102 incomplete nodes**, retained for contextual/manual qualification. Preliminary hygiene passes at **21:12:44 UTC**: 22 documents, 538 local links, all 57 groups and 33 routes. Staged hygiene, commit and normal push are still pending at that observation and are subsequently completed as recorded below. Real local API transitions and controlled projections remain distinguished; no all-seven-date hosted, human screen-reader, physical-device or production qualification follows. **First presentation slice: implemented CONDITIONAL GO. Whole P7-08 remains ongoing.**
|
||||
|
||||
Human screen-reader, real zoom, physical touch/software keyboard, production font/photo/print, actual MariaDB/provider execution and existing reliability debt remain qualified. P5-13 stays withheld with zero model qualification calls; followMode stays deferred. Production ingress, backup/restore, schema/configuration and tested rollback remain open release gates. **P7-08 acceptance: PENDING. Production: NO-GO; main not pushed. Phase 8: no implementation.**
|
||||
Human screen-reader, real zoom, physical touch/software keyboard, production font/photo/print, actual MariaDB/provider execution and existing reliability debt remain qualified. P5-13 stays withheld with zero model qualification calls; followMode stays deferred. Production ingress, backup/restore, schema/configuration and tested rollback remain open release gates. **P7-08 implemented scope: CONDITIONAL GO, pending Operations staged hygiene and publication. Production: NO-GO; main not pushed. Phase 8: no implementation.**
|
||||
|
||||
|
||||
Recent-notification presentation is committed and normally pushed as **`40cfe04df51654c5ab9a073c13c8d67e0d90dcae`**. Direct refs verified **2026-10-16 21:15:02.899052 UTC** prove clean HEAD = origin = live development and unchanged local/origin/live `main` (`27657f31131cd95d42f0e1290853ec9c9b3959a4`). Receipt `.artifacts/phase7/p7-08/recent-push-receipt.json` supersedes the earlier pending-publication observations for that first presentation boundary. No production or Phase 8 action follows.
|
||||
|
||||
## Operations — separate boundary and final finite evidence
|
||||
|
||||
The [Operations report](OPERATIONS.md) and [portable entry](evidence/operations-entry.json) establish a fresh **3 cases / 1 suite PASS** baseline at **21:15:20.042688–21:15:23.807296 UTC**, with all 21 entry inputs held. The ordinary Operations RED then records **3 pass / 17 fail of 20**, **21:17:55.099112–21:18:04.281849**, with those 21 inputs held. Further ordinary REDs retain the unknown/prototype metadata mapping failure and paired-read abort failure, each **19 pass / 1 fail of 20**. None is hidden with pending, skip or expected-failure markers.
|
||||
|
||||
The first candidate records **73/6** affected cases plus a disjoint **3/1** shell-navigation consumer, totaling **76/7**; the 73 already include ten sidebar-navigation cases. Four gates pass and build **`U-xpRaXrf8JBPqE2QoR9B`** passes native Operations **5/5**, notifications **6/6** and ordinary P2-D01 **1/1**, without retry/skip/flaky results. The retained capsule is `.artifacts/phase7/p7-08/operations-candidate-verified/results.json`, SHA256 `734d0fecbeac66f977ced1aaf9d1ccc1d444d8d00637d49becac60ea01ebb370`. Its 12 Operations axe scans record **0 violations / 3 incomplete nodes**; 14 notification scans record **0 / 102**. Its 31 retained images are not claimed as an actual visual review. These results precede the later peer corrections.
|
||||
|
||||
The focused native RED against the prior published export fails before its poll/cancellation race assertions because the new named activity region is absent. Root actually observes simultaneous error/false-empty presentation in its failure PNG. Peer review later demonstrates an operation article using its identifier as an accessible name and focus lost when an accepted poll removes the active control: **19 pass / 2 fail of 21**, **21:27:51.031680–21:27:59.380417**. The bounded corrections use a human task name plus a separate data-ID, and a heading fallback only after removed focus falls to BODY, preserving surviving or deliberately moved focus.
|
||||
|
||||
Peer runtime repairs pass **74/6**, **21:28:36.894486–21:28:51.898001**, plus separate navigation **3/1**, **21:29:39.234703–21:29:44.379120**: **77/7** affected cases. The following batch passes lint but fails app TypeScript **21:29:56–21:30:10** on one unsupported test-only `ByRoleOptions.exact` option. It supplies no passing peer-build/native evidence. All 25 frozen inputs remain held through **2026-10-17 01:55:29.881309 UTC**, with no execution inferred during the pause. The resumed fetch succeeds with HEAD/origin still at published `40cfe04` and `main` unchanged. The narrow correction removes only that unsupported option, preserving strict name/data-ID/API assertions and production code.
|
||||
|
||||
Fresh **2026-10-17 UTC** evidence in the [portable Operations results](evidence/operations-results.json) records **77/7 PASS**, **01:56:05.783220–01:56:21.237302**. Lint, app/browser TypeScript and build all pass **01:56:15.965185–01:57:09.154781**, with build **`YBJ6UzKUv_ZeDjZzgO3Gh`**. Native Operations **5/5** passes **01:57:09.159317–01:58:02.566772**; notifications **6/6** pass **01:58:02.572799–01:58:42.574608**; ordinary P2-D01 **1/1** passes **01:58:42.582126–01:58:55.731272**. All 12 native cases have zero skip/unexpected/flaky results and retries. All 25 inputs match **01:56:05.282845→01:59:10.798235**, and API/test DLLs remain unchanged. Final manifests and raw capsule hashes are retained in the linked report.
|
||||
|
||||
All **14 final Operations PNGs are actually reviewed** by root/peer, plus root's final P2-D01 image: **15 of 31 final images reviewed**. The 16 notification-consumer images are retained without a fresh review claim. Operations axe records **12 scans, 0 violations / 3 incomplete occurrences** (two short badge-text and one overlapping-background language-button contrast context); notification consumers independently record **14 scans, 0 / 102**, all retained and qualified. These finite checks establish no human screen-reader, physical-device, precise incomplete-contrast or production acceptance. The preliminary [static-check receipt](evidence/operations-static-checks.json) passes at **02:02:20.371383 UTC**: 23 documents, 566 local links, 57 groups, 33 routes and four secret/whitespace commands exiting 0. Final staged hygiene/canonical comparison, commit and verified normal push remain pending.
|
||||
|
||||
The 21 Operations cases add **18** over its three-case baseline without a new suite, deriving frontend **791/95** from 773/95. Five new browser cases derive **323** from 318; backend remains **1,054**. These are inventories, not fresh integrated counts or proof of publication; full frontend **573/89**, backend **1,033** and sidecar **49** remain inherited until P7-12. All 57 preservation statuses and 33 routes remain unchanged. **Both P7-08 presentation boundaries: implemented CONDITIONAL GO, with Operations staged hygiene/commit/publication pending.** Settings remains the next approved checkpoint, and Phase 8 remains separate.
|
||||
|
||||
@@ -37,3 +37,6 @@ The added 15 popover and three shell cases derive frontend **773/95** from 755/9
|
||||
Operations presentation still requires its separate implementation, regression, native/visual evidence and push. F20, RD11/RD21, existing reliability/manual/provider/output debts, withheld P5-13 and deferred followMode remain open. Production is **NO-GO**; `main` is unchanged. Phase 8 implementation has not started.
|
||||
|
||||
Final staged hygiene passes at **21:14:03.863578 UTC**: secret and working/staged/whole-phase whitespace checks, 22 documents/550 local links, 57 groups and 33 routes. All 21 staged canonical-LF source hashes match the final as-run capture. Normal development push remains the next step.
|
||||
|
||||
|
||||
Recent-notification presentation committed and normally pushed **40cfe04df51654c5ab9a073c13c8d67e0d90dcae**; direct refs verified **2026-10-16T21:15:02.899052UTC**, clean HEAD=origin=live development and local/origin/live main27657f3 unchanged. First presentation boundary remains implemented **CONDITIONAL GO**; Operations is the next independent boundary. Earlier pending-push statements are chronological observations superseded by this receipt. No production or Phase8 action.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "P7-08",
|
||||
"slice": "Operations presentation entry",
|
||||
"recordedUtc": "2026-10-16T21:17:47.457308+00:00",
|
||||
"precedingPush": {
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"push",
|
||||
"origin",
|
||||
"HEAD:refs/heads/codex/ui-redesign-phase4"
|
||||
],
|
||||
"startedUtc": "2026-10-16T21:15:00.935670+00:00",
|
||||
"finishedUtc": "2026-10-16T21:15:02.680152+00:00",
|
||||
"exitCode": 0,
|
||||
"head": "40cfe04df51654c5ab9a073c13c8d67e0d90dcae",
|
||||
"log": ".artifacts/phase7/p7-08/recent-push-receipt.log",
|
||||
"logSha256": "4a63523f136df7e831dc5a9cb0f3787b3069c71e252d8ec03c2df965a3a48388",
|
||||
"observedUtc": "2026-10-16T21:15:02.899052+00:00",
|
||||
"originDevelopment": "40cfe04df51654c5ab9a073c13c8d67e0d90dcae",
|
||||
"liveDevelopment": "40cfe04df51654c5ab9a073c13c8d67e0d90dcae",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"pendingPaths": [],
|
||||
"workingTreeClean": true,
|
||||
"raw": {
|
||||
"path": ".artifacts/phase7/p7-08/recent-push-receipt.json",
|
||||
"bytes": 857,
|
||||
"sha256": "d618bb702a6eac60257ad0f8107daaaf7f8eb5ab1e9579fc83deff6e35c82616"
|
||||
}
|
||||
},
|
||||
"baseline": {
|
||||
"command": {
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"npm.cmd",
|
||||
"test",
|
||||
"--",
|
||||
"--runInBand",
|
||||
"--watchAll=false",
|
||||
"--json",
|
||||
"--outputFile=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\unit\\p708-operations-entry-20261016T211520Z.json",
|
||||
"src/operations-page.test.tsx"
|
||||
],
|
||||
"startedUtc": "2026-10-16T21:15:20.042688+00:00",
|
||||
"finishedUtc": "2026-10-16T21:15:23.807296+00:00",
|
||||
"exitCode": 0,
|
||||
"counts": {
|
||||
"numPassedTests": 3,
|
||||
"numFailedTests": 0,
|
||||
"numPendingTests": 0,
|
||||
"numTodoTests": 0,
|
||||
"numPassedTestSuites": 1,
|
||||
"numFailedTestSuites": 0
|
||||
}
|
||||
},
|
||||
"commandRaw": {
|
||||
"path": ".artifacts/phase7/current/unit/p708-operations-entry-20261016T211520Z-command.json",
|
||||
"bytes": 643,
|
||||
"sha256": "d989b50af6ba3969248902c687b216c6b24c1b7ec6b3da183d4fc72fc535657a"
|
||||
},
|
||||
"reportRaw": {
|
||||
"path": ".artifacts/phase7/current/unit/p708-operations-entry-20261016T211520Z.json",
|
||||
"bytes": 1819,
|
||||
"sha256": "0afc9e0d6ba0a2367d95c7f80dacb7160d7b8363fcb8fcf0ad9439924398d425"
|
||||
},
|
||||
"logRaw": {
|
||||
"path": ".artifacts/phase7/current/unit/p708-operations-entry-20261016T211520Z.log",
|
||||
"bytes": 573,
|
||||
"sha256": "9fc0f70b98dd7e0057f2a69b010033a91149e0e49ecbcba59fcc51b8042e888c"
|
||||
},
|
||||
"counts": {
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"pending": 0,
|
||||
"suites": 1
|
||||
},
|
||||
"cases": [
|
||||
"shows persistent operation and notification states with accessible actions",
|
||||
"shows honest empty and failure states",
|
||||
"localizes operation controls and empty states in Norwegian Bokmål"
|
||||
]
|
||||
},
|
||||
"sourceBoundary": {
|
||||
"before": {
|
||||
"path": ".artifacts/phase7/p7-08/operations-entry-pre/source.json",
|
||||
"bytes": 5785,
|
||||
"sha256": "3580808f4f8780b2328511bc666e621dc2ac4f43622f8269bc98d87f04b8e48d"
|
||||
},
|
||||
"after": {
|
||||
"path": ".artifacts/phase7/p7-08/operations-entry-post/source.json",
|
||||
"bytes": 5786,
|
||||
"sha256": "6a58f1734209c043bf6ac2c09ca7f83e2386e5c85514d634c7a458280f1d78f6"
|
||||
},
|
||||
"inputs": 21,
|
||||
"unchanged": true,
|
||||
"beforeUtc": "2026-10-16T21:15:19.506584+00:00",
|
||||
"afterUtc": "2026-10-16T21:15:27.667333+00:00"
|
||||
},
|
||||
"decisions": {
|
||||
"operations": "PENDING_IMPLEMENTATION_VALIDATION",
|
||||
"recent": "CONDITIONAL GO_AND_PUSHED",
|
||||
"production": "NO-GO",
|
||||
"phase8": "NOT_STARTED"
|
||||
},
|
||||
"preservation": {
|
||||
"groups": 57,
|
||||
"partial": 56,
|
||||
"deferred": "C11",
|
||||
"routes": 33
|
||||
},
|
||||
"limits": [
|
||||
"Baseline3 passing tests do not demonstrate absence of race/focus/error defects; new ordinary regressions required.",
|
||||
"No new handler,producer,model,provider,API,schema or result contract.",
|
||||
"No manual/human/device/production acceptance."
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "p7-08",
|
||||
"status": "PASS",
|
||||
"checkedUtc": "2026-10-17T02:03:26.305734+00:00",
|
||||
"commands": [
|
||||
{
|
||||
"name": "secret-scan",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
|
||||
"-X",
|
||||
"utf8",
|
||||
"scripts/supply-chain.py",
|
||||
"scan-secrets"
|
||||
],
|
||||
"startedUtc": "2026-10-17T02:03:22.823819+00:00",
|
||||
"finishedUtc": "2026-10-17T02:03:25.545150+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261017T020322Z/secret-scan.log",
|
||||
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
|
||||
},
|
||||
{
|
||||
"name": "working-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-17T02:03:25.545643+00:00",
|
||||
"finishedUtc": "2026-10-17T02:03:25.632560+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261017T020322Z/working-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "staged-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-17T02:03:25.633067+00:00",
|
||||
"finishedUtc": "2026-10-17T02:03:25.731553+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261017T020322Z/staged-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "phase7-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||
],
|
||||
"startedUtc": "2026-10-17T02:03:25.732058+00:00",
|
||||
"finishedUtc": "2026-10-17T02:03:25.900686+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261017T020322Z/phase7-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
}
|
||||
],
|
||||
"untrackedSecretScan": {
|
||||
"files": 0,
|
||||
"findings": 0
|
||||
},
|
||||
"documentation": {
|
||||
"files": 23,
|
||||
"relativeLinks": 568,
|
||||
"errors": []
|
||||
},
|
||||
"preservation": {
|
||||
"groups": 57,
|
||||
"statuses": {
|
||||
"PARTIAL": 56,
|
||||
"PASS": 0,
|
||||
"FAIL": 0,
|
||||
"DEFERRED": 1
|
||||
},
|
||||
"originalRoutesPreserved": true,
|
||||
"currentConcreteRoutes": 33
|
||||
},
|
||||
"production": {
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"status": "NO-GO",
|
||||
"qualification": "Local fetched refs; direct push observation recorded separately."
|
||||
},
|
||||
"hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.",
|
||||
"limits": [
|
||||
"Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.",
|
||||
"Local links check target existence, not URL/heading anchors.",
|
||||
"Static evidence is not runtime/manual/device/provider/production acceptance."
|
||||
]
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"slice": "Recent notifications and shell count",
|
||||
"recordedUtc": "2026-10-16T21:12:40.988987+00:00",
|
||||
"decisions": {
|
||||
"implementedScope": "CONDITIONAL GO; final runtime,visual,peer and staged hygiene PASS; normal push pending",
|
||||
"implementedScope": "CONDITIONAL GO; final runtime/visual/peer/staged hygiene PASS, normally pushed and directly verified",
|
||||
"wholeCheckpoint": "ONGOING: separate Operations presentation remains",
|
||||
"phase7": "ONGOING",
|
||||
"production": "NO-GO",
|
||||
@@ -4818,8 +4818,7 @@
|
||||
"All axe incomplete human context retained",
|
||||
"F20/RD11/RD21 and existing reliability debt",
|
||||
"P5-13 withheld zero qualification calls/human reviews;followMode deferred",
|
||||
"Production ingress/backup-restore/schema-config/rollback/CI/smoke gates",
|
||||
"Normal presentation push after final staged hygiene"
|
||||
"Production ingress/backup-restore/schema-config/rollback/CI/smoke gates"
|
||||
],
|
||||
"staticEvidence": "docs/redesign/phase-7/p7-08/evidence/recent-static-checks.json",
|
||||
"stagedSourceReconciliation": {
|
||||
@@ -4828,5 +4827,34 @@
|
||||
"sha256": "de761b3175b476565dae21a381e4dee5309a960c333aa11b8e1bcc20a75a5075",
|
||||
"inputs": 21,
|
||||
"canonicalLfMatch": true
|
||||
},
|
||||
"pushReceipt": {
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"push",
|
||||
"origin",
|
||||
"HEAD:refs/heads/codex/ui-redesign-phase4"
|
||||
],
|
||||
"startedUtc": "2026-10-16T21:15:00.935670+00:00",
|
||||
"finishedUtc": "2026-10-16T21:15:02.680152+00:00",
|
||||
"exitCode": 0,
|
||||
"head": "40cfe04df51654c5ab9a073c13c8d67e0d90dcae",
|
||||
"log": ".artifacts/phase7/p7-08/recent-push-receipt.log",
|
||||
"logSha256": "4a63523f136df7e831dc5a9cb0f3787b3069c71e252d8ec03c2df965a3a48388",
|
||||
"observedUtc": "2026-10-16T21:15:02.899052+00:00",
|
||||
"originDevelopment": "40cfe04df51654c5ab9a073c13c8d67e0d90dcae",
|
||||
"liveDevelopment": "40cfe04df51654c5ab9a073c13c8d67e0d90dcae",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"pendingPaths": [],
|
||||
"workingTreeClean": true,
|
||||
"raw": {
|
||||
"path": ".artifacts/phase7/p7-08/recent-push-receipt.json",
|
||||
"bytes": 857,
|
||||
"sha256": "d618bb702a6eac60257ad0f8107daaaf7f8eb5ab1e9579fc83deff6e35c82616"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m
|
||||
- [x] P7-06 [remaining Application forms and global correspondence](../redesign/phase-7/p7-06/README.md): all three mandatoryboundaries implemented CONDITIONAL GO; forms4c325/inboxa791 pushed, compose final hygiene/normal push pending. Compose affected52/5 and fresh backend25 PASS; native10+inbox8+ordinaryP2-D01=19 PASS on LFCrTZzDtX5hNBSRBj5Xx; app/browser TypeScript, lint and build PASS. Actual image review 47/47; all-enabled44scans0violations/330incompletes triaged, human/device/provider qualifications remain. +10front/+0suites,+10browser,+7backend derive739/94,301,1041 only; no integrated full-suite claim. Preserve57/32,RD11/RD21/manual/provider/output/release debt,06Fdeferred/P513withheld/mainunchanged. NextP7-07backend contract first.
|
||||
- [ ] P7-06F optional reviewed followMode composer: retain deferral unless its required action/fallback decision is established.
|
||||
- [x] P7-07 [owner-scoped Documents index](../redesign/phase-7/p7-07/README.md): implemented CONDITIONAL GO, final hygiene PASS; UI5a67ec3 and evidence84350b7 published through9dfea12, directly verified21:01:11UTC. Finite86/8+native13/types/lint/build/18images/15axe0/101qualified;33routes/57statuses retained. MariaDB/human/device/production qualifications remain.
|
||||
- [ ] P7-08 [existing notifications and operations](../redesign/phase-7/p7-08/README.md): UTCrepair9dfea published; firstpresentation49/6+native19+types/lint/build+16actualPNGreviews/14axe0/102qualified PASS, implementedCONDITIONALGO finalstagedhygiene/normalpushpending. Operationssecondboundarynext.773/95frontend318browser1054backend derivedonly; mainunchanged/productionNO-GO.
|
||||
- [x] P7-08 [existing notifications and operations](../redesign/phase-7/p7-08/README.md): bothpresentationboundariesimplementedCONDITIONALGO. UTC9dfea/recent40cfe04 published; Operations77/7+native12+types/lint/build/14Ops+1P2actualimages PASS;12Opsaxe0/3+14notifaxe0/102qualified. FinalOpshygiene/normalpushpending.791/95frontend323browser1054backend derivedonly;57groups33routes/mainunchanged/productionNO-GO. P7-09next.
|
||||
- [ ] P7-09 remaining Settings, Connections and data lifecycle presentation.
|
||||
- [ ] P7-10 Users, Audit and System administration presentation.
|
||||
- [ ] P7-11 residual Analytics/error/empty/dialog integration.
|
||||
@@ -120,3 +120,6 @@ Documents UI is locally committed as **5a67ec3d3d5dc27298814b0a3f46540cb54ea3a3*
|
||||
Normal push **21:00:51.343381–21:00:53.589287 UTC** succeeds. Direct ref verification at **21:01:11.788733 UTC** proves local HEAD = origin = live development **9dfea1226f57ba12b944e05e0e83fb2fea846131**, including Documents UI **5a67ec3d3d5dc27298814b0a3f46540cb54ea3a3** and publication evidence **84350b71a67400cd62730e46bf05f06176bcd733**. Local/origin/live main stays **27657f31131cd95d42f0e1290853ec9c9b3959a4**. The exact receipt is `.artifacts/phase7/p7-08/utc-push-receipt.json`, SHA256 `12f339239dfb6ef458d31ca6918555e4eb4ce35bc5dacfa3acd33cd34ebffd65`. The earlier connection failures remain historical evidence, superseded for current publication. Eight known notification presentation WIP paths are dirty at this observation; ref equality does not imply a clean worktree.
|
||||
|
||||
P7-07 implemented scope is **CONDITIONAL GO and published**. UTC transport repair is separately published with 4/4 affected and 73/73 related backend cases. The first P7-08 presentation boundary is still under final validation; Operations remains the second boundary. All 57 groups remain 56 PARTIAL/C11 DEFERRED and all 33 current route patterns remain. Main is unchanged, production **NO-GO**, P5-13 withheld and Phase 8 not started.
|
||||
|
||||
|
||||
Recent-notification presentation committed and normally pushed **40cfe04df51654c5ab9a073c13c8d67e0d90dcae**; direct refs verified **2026-10-16T21:15:02.899052UTC**, clean HEAD=origin=live development and local/origin/live main27657f3 unchanged. First presentation boundary remains implemented **CONDITIONAL GO**; Operations is the next independent boundary. Earlier pending-push statements are chronological observations superseded by this receipt. No production or Phase8 action.
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type Response, type TestInfo } from '@playwright/test';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
import type { UserNotification } from '../src/notifications';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
type Owner = { id: string; email: string; headers: Record<string, string> };
|
||||
type Operation = { id: string; taskType: string; status: string; subjectType: string | null; createdAtUtc: string; startedAtUtc: string | null; completedAtUtc: string | null; deadlineAtUtc: string | null; cancellationRequestedAtUtc: string | null; progressStage: string | null; progressPercent: number | null; failureCategory: string | null; canCancel: boolean; canRetry: boolean };
|
||||
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
|
||||
type RequestRecord = { method: string; path: string; query: string };
|
||||
type Session = { owner: Owner; administrator: Owner; admin: APIRequestContext; requests: RequestRecord[] };
|
||||
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
|
||||
const boundary = 'Operations-page native workflows use the actual Program, local Identity cookies/CSRF, free owner accounts and isolated temporary SQLite. A guarded test-only helper inserts exactly one inert queued native-offline-fixture task; no registered producer/handler, AI entitlement, subject, provider/model, lease, usage or result is created. Actual cancel/retry/read/dismiss endpoints own every recorded persistent transition. Actual terminal notices have operationId and linkPath=null: no result/Open capability is inferred. Explicitly labelled controlled GET projections qualify only long-text and internal-link presentation. Controlled precommit503/held GET responses qualify UI recovery/race handling, not uncertain delivery. Every external request is aborted. Only existing HTTPS GET stylesheet requests to fonts.googleapis.com/css2 are classified separately as blocked font resources; every occurrence is retained, no font is fetched, and other external/provider/model requests remain an ordinary failing assertion. Font-resource counts are separate from forbidden provider/model counts; these captures do not qualify production fonts. No production/main/deployment, worker activation, Docker/model qualification, provider grant/send/sync, renderer or Phase8 action. Finite local Chromium/axe evidence does not establish human screen-reader, physical touch/software keyboard, actual zoom, live MariaDB, real worker execution, production font/photo or production readiness.';
|
||||
function copy(key: string, language: Sample['language'] = 'en', params: Record<string, string | number> = {}) { return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`)); }
|
||||
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
|
||||
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
|
||||
function waitResponse(page: Page, method: string, suffix: string) { return page.waitForResponse(response => response.request().method() === method && new URL(response.url()).pathname === `/api${suffix}`); }
|
||||
function count(session: Session, method: string, suffix: string) { return session.requests.filter(request => request.method === method && request.path === `/api${suffix}`); }
|
||||
function panel(page: Page, language: Sample['language'] = 'en') { return page.getByRole('dialog', { name: copy('notificationsPanel', language), exact: true }); }
|
||||
function bell(page: Page, language: Sample['language'] = 'en') { return page.locator('header').getByRole('button', { name: copy('notifications', language), exact: true }); }
|
||||
async function keyboard(page: Page, control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
|
||||
function utcFields(value: Record<string, unknown>) { for (const [key, date] of Object.entries(value)) if (key.endsWith('AtUtc') && date !== null && date !== undefined) expect(date, key).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/); }
|
||||
async function operation(api: APIRequestContext, id: string) { const value = await json<Operation>(await api.get(`${apiUrl}/operations/${id}`)); utcFields(value); expect(value.id).toBe(id); expect(Object.keys(value).sort()).toEqual(['id', 'taskType', 'status', 'subjectType', 'createdAtUtc', 'startedAtUtc', 'completedAtUtc', 'deadlineAtUtc', 'cancellationRequestedAtUtc', 'progressStage', 'progressPercent', 'failureCategory', 'canCancel', 'canRetry'].sort()); return value; }
|
||||
async function notices(api: APIRequestContext) { const values = await json<UserNotification[]>(await api.get(`${apiUrl}/notifications?limit=100`)); for (const value of values) utcFields(value); return values; }
|
||||
async function login(api: APIRequestContext, email: string): Promise<Owner> { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`)); const token = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(token).toBeDefined(); return { id: me.id, email, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } }; }
|
||||
async function account(admin: APIRequestContext, owner: Owner) { const email = `p708-operations-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200); return email; }
|
||||
const test = base.extend<{ session: Session }>({
|
||||
session: async ({ page, context, playwright }, provide, info) => {
|
||||
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String)); const admin = await playwright.request.newContext(); const requests: RequestRecord[] = []; const blocked: string[] = []; const blockedKnownFontStylesheets: { url: string; resourceType: string }[] = [];
|
||||
page.on('request', request => { const url = new URL(request.url()); if (/^\/api\/(?:operations|notifications|auth\/logout)(?:\/|$)/.test(url.pathname)) requests.push({ method: request.method(), path: url.pathname, query: url.search }); });
|
||||
await context.route('**/*', route => { const request = route.request(); const url = new URL(request.url()); if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { blockedKnownFontStylesheets.push({ url: url.href, resourceType: request.resourceType() }); return route.abort(); } if (/^\/api\/(?:ai|email\/send|gmail)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { blocked.push(url.origin + url.pathname); return route.abort(); } return route.continue(); });
|
||||
try { const administrator = await login(admin, 'e2e@example.test'); const owner = await login(page.request, await account(admin, administrator)); expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false); await provide({ owner, administrator, admin, requests }); expect(blocked).toEqual([]); }
|
||||
finally { await info.attach('operations-native-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalOrModelDeliveryRequests: blocked, blockedKnownFontStylesheets, fontQualification: 'All recorded font stylesheet requests were aborted without external access. Production font acceptance remains pending; no font-resource count is included in provider/model request counts.' }), contentType: 'application/json' }); await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
function seedQueued(info: TestInfo, owner: Owner) {
|
||||
const metadataRoot = info.config.metadata.isolatedDataRoot;
|
||||
if (typeof metadataRoot !== 'string' || process.env.PLAYWRIGHT_REUSE_SERVERS === 'true') throw new Error('Operation seeding requires explicit isolated non-reused dataRoot');
|
||||
expect(path.isAbsolute(metadataRoot)).toBe(true); const dataRoot = realpathSync(metadataRoot); expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir())); expect(path.basename(dataRoot)).toMatch(/^jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*$/);
|
||||
const result = spawnSync('rtk', ['proxy', process.env.PLAYWRIGHT_PYTHON_PATH || 'python', '-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 });
|
||||
expect(result.error, 'guarded helper must start').toBeUndefined(); expect(result.status, result.stderr).toBe(0); const value = JSON.parse(result.stdout) as { operationId: string }; expect(Object.keys(value)).toEqual(['operationId']); expect(value.operationId).toMatch(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/); return value.operationId;
|
||||
}
|
||||
async function cancel(api: APIRequestContext, owner: Owner, id: string) { const value = await json<Operation>(await api.post(`${apiUrl}/operations/${id}/cancel`, { headers: owner.headers })); utcFields(value); expect(value).toMatchObject({ id, status: 'cancelled', canCancel: false, canRetry: true }); const notice = (await notices(api)).find(item => item.operationId === id)!; expect(notice).toBeDefined(); expect(notice).toMatchObject({ operationId: id, kind: 'operation_cancelled', linkPath: null, readAtUtc: null }); expect(notice.createdAtUtc).toBe(value.completedAtUtc); return { value, notice }; }
|
||||
async function terminal(page: Page, owner: Owner, info: TestInfo) { const id = seedQueued(info, owner); const queued = await operation(page.request, id); expect(queued).toMatchObject({ taskType: 'native-offline-fixture', status: 'queued', subjectType: null, canCancel: true, canRetry: false }); const listed = await json<Operation[]>(await page.request.get(`${apiUrl}/operations?limit=100`)); expect(listed.find(item => item.id === id)).toEqual(queued); return { id, queued, ...await cancel(page.request, owner, id) }; }
|
||||
async function enter(page: Page, owner: Owner, sample: Sample = desktop, target = '/operations?source=phase7-operations#preserved') {
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.addInitScript(({ language, theme }) => { if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language); if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.setViewportSize({ width: sample.width, height: sample.height }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(target);
|
||||
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') })); if (stored.language !== sample.language || stored.theme !== sample.theme) { await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.reload(); }
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme); await expect(bell(page, sample.language)).toBeVisible();
|
||||
}
|
||||
function activity(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: copy('operationsActivity', language), exact: true }); }
|
||||
function taskCard(page: Page, id: string, language: Sample['language'] = 'en') { return activity(page, language).locator(`article[data-operation-id="${id}"]`); }
|
||||
function noticeCard(page: Page, notice: UserNotification, language: Sample['language'] = 'en') { return activity(page, language).getByRole('article', { name: notice.title, exact: true }); }
|
||||
async function focusInViewport(page: Page, control: Locator) {
|
||||
await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus();
|
||||
await control.evaluate(element => element.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
||||
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
||||
await expect(control).toBeFocused();
|
||||
const rect = await control.boundingBox(); expect(rect).not.toBeNull();
|
||||
const viewport = page.viewportSize()!; expect(rect!.y).toBeGreaterThanOrEqual(0); expect(rect!.y + rect!.height).toBeLessThanOrEqual(viewport.height);
|
||||
expect(await control.evaluate(element => { const r = element.getBoundingClientRect(); const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return hit === element || !!hit && element.contains(hit); })).toBe(true);
|
||||
return rect;
|
||||
}
|
||||
async function capture(page: Page, info: TestInfo, sample: Sample, state: string, projection: string | null = null) {
|
||||
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
|
||||
const region = activity(page, sample.language);
|
||||
const geometry = await region.evaluate(element => { const r = element.getBoundingClientRect(); const focus = document.activeElement; const f = focus?.getBoundingClientRect(); return { viewport: { width: innerWidth, height: innerHeight }, rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), region: { left: r.left, right: r.right, top: r.top, bottom: r.bottom, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }, focused: focus ? { tag: focus.tagName, text: focus.textContent, insideRegion: element.contains(focus), rect: f ? { left: f.left, right: f.right, top: f.top, bottom: f.bottom } : null } : null }; });
|
||||
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p708-operations-${sample.language}-${sample.theme}-${sample.width}-${sample.height}-${state}`;
|
||||
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, boundary, controlledProjection: projection, geometry, ariaSnapshot: await page.locator('body').ariaSnapshot(), axe: { scope: 'All default-enabled rules plus target-size; no exclusions', testEngine: axe.testEngine, testEnvironment: axe.testEnvironment, toolOptions: axe.toolOptions, violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, qualifications: ['Finite local Chromium screenshots/geometry only; human AT, physical touch, software keyboard, zoom and production acceptance remain pending.', 'Controlled metadata is labelled explicitly and does not claim current producer output or execute an operation handler.', 'Full-page sticky captures are not viewport-obstruction evidence; focused-control geometry is recorded before and after explicit scroll restoration.'] }), contentType: 'application/json' });
|
||||
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
||||
expect(geometry.rootOverflow).toBe(0); expect(geometry.region.left).toBeGreaterThanOrEqual(0); expect(geometry.region.right).toBeLessThanOrEqual(sample.width); expect(geometry.region.scrollWidth).toBeLessThanOrEqual(geometry.region.clientWidth); expect(axe.violations).toEqual([]);
|
||||
}
|
||||
|
||||
test('[P7-08 operations] actual UI cancellation creates a notice and retry preserves the task identity before a new cancellation notice', async ({ page, session }, info) => {
|
||||
test.setTimeout(150_000); const id = seedQueued(info, session.owner); const queued = await operation(page.request, id); expect(queued).toMatchObject({ taskType: 'native-offline-fixture', status: 'queued', canCancel: true, canRetry: false, subjectType: null });
|
||||
await enter(page, session.owner); await expect(page.getByText('native-offline-fixture', { exact: true })).toBeVisible(); await expect(taskCard(page, id)).toHaveAccessibleName('native-offline-fixture'); await expect(taskCard(page, id).getByRole('heading', { name: 'native-offline-fixture', exact: true })).toBeVisible();
|
||||
const cancelledResponse = waitResponse(page, 'POST', `/operations/${id}/cancel`); await keyboard(page, page.locator('main').getByRole('button', { name: copy('cancel'), exact: true })); const cancelled = await json<Operation>(await cancelledResponse); utcFields(cancelled); expect(cancelled).toMatchObject({ id, status: 'cancelled', canCancel: false, canRetry: true }); expect(await operation(page.request, id)).toEqual(cancelled);
|
||||
const firstNotice = (await notices(page.request)).find(item => item.operationId === id)!; expect(firstNotice).toMatchObject({ kind: 'operation_cancelled', operationId: id, linkPath: null, readAtUtc: null }); expect(firstNotice.createdAtUtc).toBe(cancelled.completedAtUtc);
|
||||
await expect(noticeCard(page, firstNotice)).toBeVisible(); await expect(taskCard(page, id).getByRole('button', { name: copy('retry'), exact: true })).toBeEnabled(); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 1 }));
|
||||
await keyboard(page, bell(page)); await expect(panel(page)).toBeVisible(); await expect(panel(page).getByText(firstNotice.title, { exact: true })).toBeVisible(); await expect(panel(page).getByRole('button', { name: copy('notificationsOpen'), exact: true })).toHaveCount(0); await page.keyboard.press('Escape'); await expect(panel(page)).toHaveCount(0);
|
||||
const retryResponse = waitResponse(page, 'POST', `/operations/${id}/retry`); await keyboard(page, taskCard(page, id).getByRole('button', { name: copy('retry'), exact: true })); const retried = await json<Operation>(await retryResponse); expect(retried).toEqual(queued); expect(await operation(page.request, id)).toEqual(queued); expect(await notices(page.request)).toEqual([]); await expect(noticeCard(page, firstNotice)).toHaveCount(0); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 0 }));
|
||||
const secondResponse = waitResponse(page, 'POST', `/operations/${id}/cancel`); await keyboard(page, taskCard(page, id).getByRole('button', { name: copy('cancel'), exact: true })); const secondCancellation = await json<Operation>(await secondResponse); const secondNotice = (await notices(page.request)).find(item => item.operationId === id)!; expect(secondNotice.id).not.toBe(firstNotice.id); expect(secondNotice).toMatchObject({ operationId: id, linkPath: null, readAtUtc: null }); expect(await operation(page.request, id)).toEqual(secondCancellation); await expect(noticeCard(page, secondNotice)).toBeVisible();
|
||||
expect(count(session, 'POST', `/operations/${id}/cancel`)).toHaveLength(2); expect(count(session, 'POST', `/operations/${id}/retry`)).toHaveLength(1); expect(session.requests.filter(request => request.method !== 'GET' && request.path.startsWith('/api/notifications/'))).toEqual([]);
|
||||
await capture(page, info, desktop, 'actual-ui-cancel-retry-same-id'); await info.attach('operations-actual-ui-lifecycle', { body: JSON.stringify({ queued, cancelled, firstNotice, retried, secondCancellation, secondNotice, qualification: 'No worker/handler/result is activated. Actual terminal notice linkPath stays null, so no notification Open capability is fabricated.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-08 operations] actual read and dismiss persist through reload while the underlying cancelled task remains unchanged', async ({ page, session }, info) => {
|
||||
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); await enter(page, session.owner); const row = noticeCard(page, seeded.notice); await expect(row).toBeVisible(); await expect(row.getByRole('button', { name: copy('notificationsOpen'), exact: true })).toHaveCount(0);
|
||||
const read = waitResponse(page, 'POST', `/notifications/${seeded.notice.id}/read`); await keyboard(page, row.getByRole('button', { name: copy('notificationsMarkRead'), exact: true })); expect((await read).status()).toBe(204); await expect(row.getByRole('button', { name: copy('notificationsMarkRead'), exact: true })).toHaveCount(0); const saved = (await notices(page.request)).find(item => item.id === seeded.notice.id)!; expect(saved.readAtUtc).toMatch(/Z$/); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 0 }));
|
||||
await page.reload(); await expect(noticeCard(page, seeded.notice)).toBeVisible(); await expect(noticeCard(page, seeded.notice).getByRole('button', { name: copy('notificationsMarkRead'), exact: true })).toHaveCount(0); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)).toEqual(saved);
|
||||
const dismissed = waitResponse(page, 'DELETE', `/notifications/${seeded.notice.id}`); await keyboard(page, noticeCard(page, seeded.notice).getByRole('button', { name: copy('notificationsDismiss'), exact: true })); expect((await dismissed).status()).toBe(204); await expect(noticeCard(page, seeded.notice)).toHaveCount(0); expect(await activity(page).evaluate(element => element.contains(document.activeElement))).toBe(true); expect(await notices(page.request)).toEqual([]); expect(await operation(page.request, seeded.id)).toEqual(seeded.value);
|
||||
await page.reload(); await expect(activity(page).getByText(copy('operationsNoNotifications'), { exact: true })).toBeVisible(); await expect(taskCard(page, seeded.id)).toBeVisible(); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); expect(count(session, 'POST', `/notifications/${seeded.notice.id}/read`)).toHaveLength(1); expect(count(session, 'DELETE', `/notifications/${seeded.notice.id}`)).toHaveLength(1); expect(session.requests.filter(request => request.method !== 'GET' && request.path.startsWith('/api/operations/'))).toEqual([]);
|
||||
await capture(page, info, desktop, 'actual-read-dismiss-task-unchanged'); await info.attach('operations-notice-persistence', { body: JSON.stringify({ taskBefore: seeded.value, noticeBefore: seeded.notice, readBack: saved, taskAfter: await operation(page.request, seeded.id), remainingNotices: await notices(page.request) }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-08 operations] controlled failed read and held old poll cannot revert a committed cancellation or replay it during GET-only recovery', async ({ page, session }, info) => {
|
||||
test.setTimeout(180_000); const id = seedQueued(info, session.owner); const queued = await operation(page.request, id); const gate = hold(); const started = hold(); const delivered = hold(); let loads = 0; let capturedOld: Operation[] = [];
|
||||
await page.route(`${apiUrl}/operations?limit=50`, async route => { loads += 1; if (loads === 1 || loads === 4) return route.fulfill({ status: 503, json: {} }); const response = await route.fetch(); if (loads === 3) { capturedOld = await response.json() as Operation[]; expect(capturedOld.find(item => item.id === id)).toEqual(queued); started.release(); await gate.pending; try { return await route.fulfill({ response, json: capturedOld }); } finally { delivered.release(); } } return route.fulfill({ response }); });
|
||||
try {
|
||||
await enter(page, session.owner); const initialFailure = activity(page).getByRole('alert').filter({ hasText: copy('operationsLoadFailed') }); await expect(initialFailure).toBeVisible(); await expect(activity(page).getByText(copy('operationsEmpty'), { exact: true })).toHaveCount(0); await expect(activity(page).getByText(copy('operationsNoNotifications'), { exact: true })).toHaveCount(0);
|
||||
await keyboard(page, initialFailure.getByRole('button', { name: copy('retry'), exact: true })); await expect(taskCard(page, id).getByRole('button', { name: copy('cancel'), exact: true })).toBeEnabled(); expect(loads).toBe(2); expect(session.requests.filter(request => request.method !== 'GET')).toEqual([]);
|
||||
await expect.poll(() => loads, { timeout: 25_000 }).toBe(3); await started.pending; const cancellation = waitResponse(page, 'POST', `/operations/${id}/cancel`); await keyboard(page, taskCard(page, id).getByRole('button', { name: copy('cancel'), exact: true })); const committed = await json<Operation>(await cancellation); expect(committed).toMatchObject({ id, status: 'cancelled', canRetry: true });
|
||||
const refreshFailure = activity(page).getByRole('alert').filter({ hasText: copy('operationsCommittedRefreshFailed') }); await expect(refreshFailure).toBeVisible(); await expect(taskCard(page, id).getByText(copy('operationsStatusCancelled'), { exact: true })).toBeVisible(); await expect(taskCard(page, id).getByRole('button', { name: copy('retry'), exact: true })).toBeDisabled();
|
||||
gate.release(); await delivered.pending; await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); await expect(taskCard(page, id).getByText(copy('operationsStatusCancelled'), { exact: true })).toBeVisible(); expect(await operation(page.request, id)).toEqual(committed); expect(loads).toBe(4);
|
||||
const languageSave = waitResponse(page, 'PUT', '/auth/preferences/language'); await keyboard(page, page.locator('header').getByRole('button', { name: 'Norsk', exact: true })); expect((await languageSave).status()).toBe(204); await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); const localizedFailure = activity(page, 'nb').getByRole('alert').filter({ hasText: copy('operationsCommittedRefreshFailed', 'nb') }); await expect(localizedFailure).toBeVisible(); expect(loads).toBe(4);
|
||||
await keyboard(page, localizedFailure.getByRole('button', { name: copy('retry', 'nb'), exact: true })); await expect(localizedFailure).toHaveCount(0); await expect(taskCard(page, id, 'nb').getByRole('button', { name: copy('retry', 'nb'), exact: true })).toBeEnabled(); expect(loads).toBe(5); expect(count(session, 'POST', `/operations/${id}/cancel`)).toHaveLength(1); expect(count(session, 'POST', `/operations/${id}/retry`)).toHaveLength(0); expect(await activity(page, 'nb').evaluate(element => element.contains(document.activeElement))).toBe(true); expect(await operation(page.request, id)).toEqual(committed);
|
||||
await capture(page, info, { ...desktop, language: 'nb' }, 'controlled-postcommit-get-only-recovery', 'Initial operations GET503; actual15s poll held with an actual queued snapshot; actual cancel POST200; follow-up operations GET503. Old queued response is released after cancellation. Recovery is GET-only and actual persisted task remains cancelled. No provider, worker, fabricated result or uncertain delivery claim.');
|
||||
await info.attach('operations-controlled-race-receipt', { body: JSON.stringify({ queued, capturedOld, committed, operationLoads: loads, actualTaskAfter: await operation(page.request, id), operationWrites: session.requests.filter(request => request.method !== 'GET' && request.path.startsWith('/api/operations/')) }), contentType: 'application/json' });
|
||||
} finally { gate.release(); }
|
||||
});
|
||||
|
||||
test('[P7-08 operations] actual owner and anonymous boundaries plus sign-out prevent earlier task and notice exposure', async ({ page, session, playwright }, info) => {
|
||||
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); const otherEmail = await account(session.admin, session.administrator); const otherApi = await playwright.request.newContext(); const anonymous = await playwright.request.newContext();
|
||||
try {
|
||||
const other = await login(otherApi, otherEmail); expect(other.id).not.toBe(session.owner.id); expect(await json<Operation[]>(await otherApi.get(`${apiUrl}/operations?limit=50&ownerUserId=${session.owner.id}`))).toEqual([]); expect(await notices(otherApi)).toEqual([]);
|
||||
expect((await otherApi.get(`${apiUrl}/operations/${seeded.id}`)).status()).toBe(404); for (const action of ['cancel', 'retry']) expect((await otherApi.post(`${apiUrl}/operations/${seeded.id}/${action}`, { headers: other.headers })).status()).toBe(404);
|
||||
expect((await otherApi.post(`${apiUrl}/notifications/${seeded.notice.id}/read`, { headers: other.headers })).status()).toBe(404); expect((await otherApi.delete(`${apiUrl}/notifications/${seeded.notice.id}`, { headers: other.headers })).status()).toBe(404);
|
||||
for (const endpoint of ['/operations?limit=50', `/operations/${seeded.id}`, '/notifications?limit=50', '/notifications/unread-count']) expect((await anonymous.get(`${apiUrl}${endpoint}`)).status()).toBe(401);
|
||||
for (const action of ['cancel', 'retry']) expect((await anonymous.post(`${apiUrl}/operations/${seeded.id}/${action}`)).status()).toBe(401); expect((await anonymous.post(`${apiUrl}/notifications/${seeded.notice.id}/read`)).status()).toBe(401); expect((await anonymous.delete(`${apiUrl}/notifications/${seeded.notice.id}`)).status()).toBe(401);
|
||||
expect(await operation(page.request, seeded.id)).toEqual(seeded.value); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)).toEqual(seeded.notice);
|
||||
await enter(page, session.owner); await expect(taskCard(page, seeded.id)).toBeVisible(); await expect(noticeCard(page, seeded.notice)).toBeVisible(); await keyboard(page, page.locator('header').getByRole('button', { name: copy('user'), exact: true })); const logout = waitResponse(page, 'POST', '/auth/logout'); await keyboard(page, page.getByRole('menuitem', { name: copy('signOut'), exact: true })); expect((await logout).status()).toBe(204); await expect(page).toHaveURL(/\/login(?:\?|$)/); await expect(page.getByText(seeded.notice.title, { exact: true })).toHaveCount(0); await expect(page.getByText('native-offline-fixture', { exact: true })).toHaveCount(0); expect((await page.request.get(`${apiUrl}/operations?limit=50`)).status()).toBe(401);
|
||||
const signedInOther = await login(page.request, otherEmail); await enter(page, signedInOther); await expect(activity(page).getByText(copy('operationsEmpty'), { exact: true })).toBeVisible(); await expect(activity(page).getByText(copy('operationsNoNotifications'), { exact: true })).toBeVisible(); await expect(page.getByText(seeded.notice.title, { exact: true })).toHaveCount(0); await expect(taskCard(page, seeded.id)).toHaveCount(0); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 0 })); expect(await json<Operation[]>(await page.request.get(`${apiUrl}/operations?limit=50`))).toEqual([]);
|
||||
await capture(page, info, desktop, 'actual-other-owner-empty'); await info.attach('operations-auth-boundary-receipt', { body: JSON.stringify({ earlierTaskId: seeded.id, earlierNoticeId: seeded.notice.id, otherOwnerEmpty: true, actualAnonymousDenials: 'All four GET, both operation POST, notice read POST and dismiss DELETE require401', qualification: 'Real local Identity sessions/CSRF and isolated owner metadata; no authentication tokens or cookies are attached.' }), contentType: 'application/json' });
|
||||
} finally { await otherApi.dispose(); await anonymous.dispose(); }
|
||||
});
|
||||
|
||||
test('[P7-08 operations] eight finite bilingual theme widths retain long controlled metadata and reachable focused actions with complete axe vectors', async ({ page, session }, info) => {
|
||||
test.setTimeout(300_000); const seeded = await terminal(page, session.owner, info); const longTitle = 'Kontrollert varsel om samfunnskritisk plattformutvikling og internasjonale samarbeidspartnere Æ Ø Å'; const longMessage = ('Kontrollert varseltekst for lesbarhet og linjebryting på små skjermer. LangUbruttReferanseForGrensekontroll0123456789ÆØÅ. ').repeat(6); const longStage = ('Kontrollert lang fremdriftstekst — ikke faktisk produsert oppgavestatus. LangUbruttOperasjonsReferanse0123456789ÆØÅ. ').repeat(6);
|
||||
const projection = 'Controlled GET metadata only: one actual cancelled native-offline-fixture task keeps its unknown taskType/id/status/capability/timestamp fields, with progressStage and progressPercent=42 projected solely for layout. Its actual notice keeps id/operationId/timestamps/read state/absent link, with only title/message replaced by controlled long text. No operation handler or long-message producer is claimed.';
|
||||
await page.route(`${apiUrl}/operations?limit=50`, async route => { const response = await route.fetch(); const values = await response.json() as Operation[]; expect(values.find(item => item.id === seeded.id)).toEqual(seeded.value); return route.fulfill({ response, json: values.map(item => item.id === seeded.id ? { ...item, progressStage: longStage, progressPercent: 42 } : item) }); });
|
||||
await page.route(`${apiUrl}/notifications?limit=50`, async route => { const response = await route.fetch(); const values = await response.json() as UserNotification[]; expect(values.find(item => item.id === seeded.notice.id)).toEqual(seeded.notice); return route.fulfill({ response, json: values.map(item => item.id === seeded.notice.id ? { ...item, title: longTitle, message: longMessage } : item) }); });
|
||||
const samples: Sample[] = [{ language: 'en', theme: 'light', width: 320, height: 740 }, { language: 'nb', theme: 'dark', width: 360, height: 844 }, { language: 'en', theme: 'dark', width: 390, height: 844 }, { language: 'nb', theme: 'light', width: 768, height: 1024 }, { language: 'en', theme: 'dark', width: 1024, height: 768 }, { language: 'nb', theme: 'light', width: 1280, height: 320 }, { language: 'en', theme: 'light', width: 1440, height: 900 }, { language: 'nb', theme: 'dark', width: 1920, height: 1080 }];
|
||||
for (const sample of samples) {
|
||||
await enter(page, session.owner, sample); await expect(activity(page, sample.language).getByRole('heading', { name: copy('operationsActivity', sample.language), exact: true })).toBeVisible(); const task = taskCard(page, seeded.id, sample.language); await expect(task.getByText('native-offline-fixture', { exact: true })).toBeVisible(); await expect(task.getByText(copy('operationsStatusCancelled', sample.language), { exact: true })).toBeVisible(); await expect(task.getByText(longStage, { exact: true })).toBeVisible(); await expect(task.getByRole('progressbar', { name: copy('operationsProgress', sample.language, { task: 'native-offline-fixture' }), exact: true })).toHaveAttribute('aria-valuenow', '42'); await expect(activity(page, sample.language).getByRole('article', { name: longTitle, exact: true })).toBeVisible(); await expect(activity(page, sample.language).getByRole('button', { name: copy('notificationsOpen', sample.language), exact: true })).toHaveCount(0);
|
||||
const retry = task.getByRole('button', { name: copy('retry', sample.language), exact: true }); const before = await focusInViewport(page, retry); await capture(page, info, sample, 'controlled-long-metadata-keyboard', projection); const after = await focusInViewport(page, retry); await info.attach(`p708-operations-${sample.width}-focused-action-bounds`, { body: JSON.stringify({ sample, beforeCapture: before, afterScrollRestoration: after, controlledProjection: projection }), contentType: 'application/json' }); if ([320, 1280].includes(sample.width)) await info.attach(`p708-operations-${sample.width}-focused-viewport`, { body: await page.screenshot({ fullPage: false, animations: 'disabled' }), contentType: 'image/png' });
|
||||
}
|
||||
expect(session.requests.filter(request => request.method !== 'GET')).toEqual([]); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)).toEqual(seeded.notice);
|
||||
});
|
||||
@@ -429,6 +429,19 @@ export const translations = {
|
||||
operations: "Operations",
|
||||
refresh: "Refresh",
|
||||
operationsSubtitle: "Background work survives navigation and refresh.",
|
||||
operationsActivity: "Recent activity",
|
||||
operationsRecentHelp: "Shows up to 50 recent notifications and 50 recent operations.",
|
||||
operationsCommittedRefreshFailed: "The action completed, but recent activity could not be refreshed. Refresh before taking another action.",
|
||||
operationsCreated: "Created {date}",
|
||||
operationsTaskCv: "CV processing",
|
||||
operationsTaskStrategy: "Strategy snapshot",
|
||||
operationsStatusQueued: "Queued",
|
||||
operationsStatusRunning: "Running",
|
||||
operationsStatusWaitingForRetry: "Waiting to retry",
|
||||
operationsStatusWaitingForFallback: "Waiting for approved fallback",
|
||||
operationsStatusSucceeded: "Completed",
|
||||
operationsStatusFailed: "Failed",
|
||||
operationsStatusCancelled: "Cancelled",
|
||||
operationsLoadFailed: "Operations could not be loaded.",
|
||||
operationsActionFailed: "The action could not be completed.",
|
||||
operationsLoading: "Loading operations",
|
||||
@@ -3173,6 +3186,19 @@ export const translations = {
|
||||
operations: "Operasjoner",
|
||||
refresh: "Oppdater",
|
||||
operationsSubtitle: "Bakgrunnsarbeid fortsetter når du navigerer eller oppdaterer siden.",
|
||||
operationsActivity: "Nylig aktivitet",
|
||||
operationsRecentHelp: "Viser opptil 50 nylige varsler og 50 nylige operasjoner.",
|
||||
operationsCommittedRefreshFailed: "Handlingen er fullført, men nylig aktivitet kunne ikke oppdateres. Oppdater før du utfører en ny handling.",
|
||||
operationsCreated: "Opprettet {date}",
|
||||
operationsTaskCv: "CV-behandling",
|
||||
operationsTaskStrategy: "Strategioversikt",
|
||||
operationsStatusQueued: "I kø",
|
||||
operationsStatusRunning: "Pågår",
|
||||
operationsStatusWaitingForRetry: "Venter på nytt forsøk",
|
||||
operationsStatusWaitingForFallback: "Venter på godkjent reservevalg",
|
||||
operationsStatusSucceeded: "Fullført",
|
||||
operationsStatusFailed: "Mislyktes",
|
||||
operationsStatusCancelled: "Avbrutt",
|
||||
operationsLoadFailed: "Operasjonene kunne ikke lastes.",
|
||||
operationsActionFailed: "Handlingen kunne ikke fullføres.",
|
||||
operationsLoading: "Laster operasjoner",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
|
||||
import { api } from "./api";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import OperationsPage from "./views/OperationsPage";
|
||||
import { translations } from "./i18n/translations";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
@@ -43,20 +44,45 @@ const notification = {
|
||||
readAtUtc: null,
|
||||
};
|
||||
|
||||
let serverOperations: any[];
|
||||
let serverNotifications: any[];
|
||||
function deferred<T = any>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (cause: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
async function language(value: "en" | "nb") {
|
||||
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: value })));
|
||||
}
|
||||
function response(url: string) { return { data: url.startsWith("/operations") ? serverOperations : serverNotifications } as any; }
|
||||
function activity() { return screen.getByRole("region", { name: "Recent activity" }); }
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({
|
||||
data: url.startsWith("/operations") ? [operation] : [notification],
|
||||
} as any));
|
||||
mockedApi.post.mockResolvedValue({ data: {} } as any);
|
||||
mockedApi.delete.mockResolvedValue({ data: {} } as any);
|
||||
document.documentElement.lang = "en";
|
||||
serverOperations = [{ ...operation }]; serverNotifications = [{ ...notification }];
|
||||
mockedApi.get.mockReset().mockImplementation((url: string) => Promise.resolve(response(url)));
|
||||
mockedApi.post.mockReset().mockImplementation((url: string) => {
|
||||
if (url.startsWith("/operations")) {
|
||||
serverOperations = serverOperations.map(item => ({ ...item, status: url.endsWith("/cancel") ? "cancelled" : "queued", canCancel: url.endsWith("/retry"), canRetry: url.endsWith("/cancel") }));
|
||||
return Promise.resolve({ data: serverOperations[0] } as any);
|
||||
}
|
||||
serverNotifications = serverNotifications.map(item => ({ ...item, readAtUtc: "2026-08-02T12:06:00Z" }));
|
||||
return Promise.resolve({ data: undefined } as any);
|
||||
});
|
||||
mockedApi.delete.mockReset().mockImplementation(() => { serverNotifications = []; return Promise.resolve({ data: undefined } as any); });
|
||||
});
|
||||
afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); });
|
||||
|
||||
test("shows persistent operation and notification states with accessible actions", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("strategy_snapshot")).toBeInTheDocument();
|
||||
const task = screen.getByRole("article", { name: operation.taskType });
|
||||
expect(task).toHaveAccessibleName(operation.taskType);
|
||||
expect(task).toHaveAttribute("data-operation-id", operation.id);
|
||||
expect(screen.getByText("Analysing role")).toBeInTheDocument();
|
||||
expect(screen.getByRole("progressbar", { name: "strategy_snapshot progress" })).toHaveAttribute("aria-valuenow", "40");
|
||||
expect(screen.getByText("Operation failed")).toBeInTheDocument();
|
||||
@@ -85,6 +111,8 @@ test("shows honest empty and failure states", async () => {
|
||||
mockedApi.get.mockRejectedValue(new Error("offline"));
|
||||
renderPage();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
|
||||
expect(screen.queryByText("No notifications.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("No background operations yet.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("localizes operation controls and empty states in Norwegian Bokmål", async () => {
|
||||
@@ -97,3 +125,137 @@ test("localizes operation controls and empty states in Norwegian Bokmål", async
|
||||
expect(screen.getByText("Ingen bakgrunnsoperasjoner ennå.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Oppdater" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("[P7-08 operations] initial failure offers single-flight GET Retry without false empty or writes", async () => {
|
||||
mockedApi.get.mockRejectedValue(new Error("offline")); renderPage();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
|
||||
expect((mockedApi.get.mock.calls[0][1]?.signal as AbortSignal).aborted).toBe(true);
|
||||
expect(screen.queryByText("No notifications.")).not.toBeInTheDocument(); expect(screen.queryByText("No background operations yet.")).not.toBeInTheDocument();
|
||||
const pending = deferred(); mockedApi.get.mockImplementation(() => pending.promise);
|
||||
const retry = screen.getByRole("button", { name: "Retry" }); act(() => { retry.focus(); fireEvent.click(retry); fireEvent.click(retry); });
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(4); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
await act(async () => pending.resolve({ data: [] })); expect(await screen.findByText("No notifications.")).toBeVisible();
|
||||
expect(screen.getByRole("heading", { name: "Recent activity", level: 2 })).toHaveFocus(); expect(activity()).toHaveAttribute("aria-busy", "false");
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/operations?limit=50", expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/notifications?limit=50", expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test("[P7-08 operations] cached refresh failure preserves known rows and does not fabricate empty results", async () => {
|
||||
renderPage(); expect(await screen.findByText(operation.taskType)).toBeVisible(); mockedApi.get.mockRejectedValue(new Error("offline"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh" })); expect(await screen.findByRole("alert")).toHaveTextContent("Operations could not be loaded.");
|
||||
expect(screen.getByText(operation.taskType)).toBeVisible(); expect(screen.getByText(notification.title)).toBeVisible();
|
||||
expect(screen.queryByText("No notifications.")).not.toBeInTheDocument(); expect(screen.queryByText("No background operations yet.")).not.toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(["Cancel", "Retry", "Mark read", "Dismiss"])("[P7-08 operations] same-render duplicate %s reserves exactly one mutation and blocks competing work", async label => {
|
||||
if (label === "Retry") serverOperations = [{ ...operation, status: "cancelled", canCancel: false, canRetry: true }];
|
||||
const pending = deferred(); mockedApi.post.mockImplementation(() => pending.promise); mockedApi.delete.mockImplementation(() => pending.promise);
|
||||
renderPage(); const button = await screen.findByRole("button", { name: label });
|
||||
act(() => { fireEvent.click(button); fireEvent.click(button); fireEvent.click(screen.getByRole("button", { name: label === "Dismiss" ? "Mark read" : "Dismiss" })); });
|
||||
expect(mockedApi.post.mock.calls.length + mockedApi.delete.mock.calls.length).toBe(1); expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
|
||||
await act(async () => pending.resolve({ data: label === "Cancel" || label === "Retry" ? { ...serverOperations[0], canCancel: false, canRetry: false } : undefined }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled());
|
||||
});
|
||||
|
||||
test("[P7-08 operations] language and ordinary rerenders reformat cached data without refetch or polling restart", async () => {
|
||||
const intervals = jest.spyOn(window, "setInterval"); const cleared = jest.spyOn(window, "clearInterval");
|
||||
serverOperations = [{ ...operation, taskType: "strategy.snapshot", status: "waiting_for_retry" }]; const { rerender } = renderPage();
|
||||
expect(await screen.findByText("Strategy snapshot")).toBeVisible(); expect(screen.getByText("Waiting to retry")).toBeVisible(); expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
const starts = intervals.mock.calls.length; const pollId = intervals.mock.results[intervals.mock.calls.findIndex(([, delay]) => delay === 15000)].value; await language("nb");
|
||||
expect(screen.getByText("Strategioversikt")).toBeVisible(); expect(screen.getByText("Venter på nytt forsøk")).toBeVisible();
|
||||
expect(screen.getByText(`Opprettet ${new Date(operation.createdAtUtc).toLocaleString("nb-NO")}`)).toBeVisible();
|
||||
rerender(<I18nProvider><OperationsPage /></I18nProvider>); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(intervals).toHaveBeenCalledTimes(starts); expect(cleared).not.toHaveBeenCalledWith(pollId);
|
||||
});
|
||||
|
||||
test("[P7-08 operations] 15-second polling is single-flight and resumes after the held pair completes", async () => {
|
||||
jest.useFakeTimers(); renderPage(); await act(async () => {}); expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
const pending = deferred(); mockedApi.get.mockImplementation(() => pending.promise);
|
||||
await act(async () => jest.advanceTimersByTime(15000)); expect(mockedApi.get).toHaveBeenCalledTimes(4);
|
||||
await act(async () => jest.advanceTimersByTime(30000)); expect(mockedApi.get).toHaveBeenCalledTimes(4);
|
||||
await act(async () => pending.resolve({ data: [] })); mockedApi.get.mockImplementation((url: string) => Promise.resolve(response(url)));
|
||||
await act(async () => jest.advanceTimersByTime(15000)); expect(mockedApi.get).toHaveBeenCalledTimes(6); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[P7-08 operations] mutation invalidates a held old poll before adopting the returned same-ID DTO", async () => {
|
||||
jest.useFakeTimers(); renderPage(); await act(async () => {}); const oldOperations = deferred(); const oldNotices = deferred();
|
||||
mockedApi.get.mockImplementationOnce(() => oldOperations.promise).mockImplementationOnce(() => oldNotices.promise);
|
||||
await act(async () => jest.advanceTimersByTime(15000)); const signal = mockedApi.get.mock.calls[2][1]?.signal as AbortSignal; expect(signal).toBeInstanceOf(AbortSignal);
|
||||
const post = deferred(); const refresh = deferred(); mockedApi.post.mockImplementationOnce(() => post.promise); mockedApi.get.mockImplementation(() => refresh.promise);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(signal.aborted).toBe(true);
|
||||
const cancelled = { ...operation, status: "cancelled", canCancel: false, canRetry: true };
|
||||
await act(async () => post.resolve({ data: cancelled })); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(screen.getByRole("article", { name: operation.taskType })).toHaveTextContent("Cancelled");
|
||||
await act(async () => { oldOperations.resolve({ data: [{ ...operation, progressStage: "Stale old poll" }] }); oldNotices.resolve({ data: [notification] }); });
|
||||
expect(screen.queryByText("Stale old poll")).not.toBeInTheDocument();
|
||||
await act(async () => refresh.resolve({ data: [] })); expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("[P7-08 operations] committed cancel plus failed refresh announces truth and GET Retry cannot replay or unlock stale writes", async () => {
|
||||
const changed = jest.fn(); window.addEventListener("notifications-changed", changed);
|
||||
try { renderPage(); const cancel = await screen.findByRole("button", { name: "Cancel" }); const cancelled = { ...operation, status: "cancelled", canCancel: false, canRetry: true };
|
||||
mockedApi.post.mockResolvedValueOnce({ data: cancelled } as any); mockedApi.get.mockRejectedValue(new Error("read offline")); act(() => cancel.focus()); fireEvent.click(cancel);
|
||||
const failure = await screen.findByRole("alert"); expect(failure).toHaveTextContent("The action completed, but recent activity could not be refreshed. Refresh before taking another action.");
|
||||
const item = screen.getByRole("article", { name: operation.taskType }); expect(within(item).getByRole("button", { name: "Retry" })).toBeDisabled(); expect(item).toHaveTextContent("Cancelled"); expect(changed).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
|
||||
serverOperations = [cancelled]; mockedApi.get.mockImplementation((url: string) => Promise.resolve(response(url)));
|
||||
fireEvent.click(within(failure).getByRole("button", { name: "Retry" })); await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled(); expect(changed).toHaveBeenCalledTimes(1); expect(within(item).getByRole("button", { name: "Retry" })).toBeEnabled();
|
||||
} finally { window.removeEventListener("notifications-changed", changed); }
|
||||
});
|
||||
|
||||
test.each(["Mark read", "Dismiss"])("[P7-08 operations] committed204 %s updates notices locally even when refresh fails without completing a task", async label => {
|
||||
const changed = jest.fn(); window.addEventListener("notifications-changed", changed);
|
||||
try { renderPage(); const action = await screen.findByRole("button", { name: label }); mockedApi.get.mockRejectedValue(new Error("offline")); fireEvent.click(action);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("The action completed, but recent activity could not be refreshed.");
|
||||
expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument(); if (label === "Dismiss") expect(screen.queryByText(notification.title)).not.toBeInTheDocument(); else expect(screen.getByText(notification.title)).toBeVisible();
|
||||
expect(screen.getByRole("article", { name: operation.taskType })).toHaveTextContent("Running"); expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
expect(mockedApi.post.mock.calls.filter(([url]) => String(url).startsWith("/operations"))).toEqual([]); expect(changed).toHaveBeenCalledTimes(1);
|
||||
} finally { window.removeEventListener("notifications-changed", changed); }
|
||||
});
|
||||
|
||||
test("[P7-08 operations] failed POST preserves eligibility and current-locale recovery without false commit or new GET", async () => {
|
||||
const pending = deferred(); mockedApi.post.mockImplementationOnce(() => pending.promise); const changed = jest.fn(); window.addEventListener("notifications-changed", changed);
|
||||
try { renderPage(); const cancel = await screen.findByRole("button", { name: "Cancel" }); act(() => cancel.focus()); fireEvent.click(cancel); await language("nb"); expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
await act(async () => pending.reject(new Error("offline"))); expect(await screen.findByRole("alert")).toHaveTextContent(translations.nb.operationsActionFailed); expect(screen.getByRole("button", { name: translations.nb.cancel })).toBeEnabled(); expect(screen.getByRole("button", { name: translations.nb.cancel })).toHaveFocus(); expect(changed).not.toHaveBeenCalled(); expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
await language("en"); expect(screen.getByRole("alert")).toHaveTextContent(translations.en.operationsActionFailed); expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
} finally { window.removeEventListener("notifications-changed", changed); }
|
||||
});
|
||||
|
||||
test("[P7-08 operations] server action admission overrides displayed status and unknown metadata remains honest", async () => {
|
||||
serverOperations = [{ ...operation, taskType: "Unknown_Task_Type", status: "succeeded", progressStage: "arbitrary-stage", failureCategory: "unmapped_category", canCancel: true, canRetry: false }, { ...operation, id: "foreign-looking-local-fixture", taskType: "cv.process", status: "running", canCancel: false, canRetry: true }, { ...operation, id: "unmapped-prototype-code", taskType: "toString", status: "constructor", canCancel: false, canRetry: false }];
|
||||
renderPage(); expect(await screen.findByText("Unknown_Task_Type")).toBeVisible(); const first = screen.getByRole("article", { name: "Unknown_Task_Type" }); expect(first).toHaveAttribute("data-operation-id", operation.id); expect(within(first).getByRole("button", { name: "Cancel" })).toBeEnabled(); expect(within(first).queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); expect(first).toHaveTextContent("arbitrary-stage"); expect(first).toHaveTextContent("unmapped category");
|
||||
const second = screen.getByRole("article", { name: "CV processing" }); expect(second).toHaveAttribute("data-operation-id", "foreign-looking-local-fixture"); expect(within(second).queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(within(second).getByRole("button", { name: "Retry" })).toBeEnabled();
|
||||
const unknown = screen.getByRole("article", { name: "toString" }); expect(unknown).toHaveAttribute("data-operation-id", "unmapped-prototype-code"); expect(unknown).toHaveTextContent("constructor"); expect(within(unknown).queryAllByRole("button")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("[P7-08 operations] unmount aborts a pending read and late completion cannot fetch or publish", async () => {
|
||||
const pending = deferred(); mockedApi.get.mockImplementation(() => pending.promise); const { unmount } = renderPage(); const signal = mockedApi.get.mock.calls[0][1]?.signal as AbortSignal; expect(signal).toBeInstanceOf(AbortSignal); unmount(); expect(signal.aborted).toBe(true); await act(async () => pending.resolve({ data: [operation] })); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[P7-08 operations] unmount during a write prevents its late follow-up GET, event and focus effects", async () => {
|
||||
const changed = jest.fn(); window.addEventListener("notifications-changed", changed); const pending = deferred(); mockedApi.post.mockImplementationOnce(() => pending.promise);
|
||||
try { const { unmount } = renderPage(); fireEvent.click(await screen.findByRole("button", { name: "Cancel" })); unmount(); await act(async () => pending.resolve({ data: { ...operation, status: "cancelled" } })); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(changed).not.toHaveBeenCalled(); } finally { window.removeEventListener("notifications-changed", changed); }
|
||||
});
|
||||
|
||||
test("[P7-08 operations] removing the initiating control restores meaningful focus while deliberate focus elsewhere stays put", async () => {
|
||||
const pending = deferred(); mockedApi.post.mockImplementationOnce(() => pending.promise); renderPage(); const mark = await screen.findByRole("button", { name: "Mark read" }); act(() => mark.focus()); fireEvent.click(mark); serverNotifications = [{ ...notification, readAtUtc: "2026-08-02T12:06:00Z" }]; await act(async () => pending.resolve({ data: undefined })); expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
|
||||
const next = deferred(); mockedApi.delete.mockImplementationOnce(() => next.promise); const dismiss = screen.getByRole("button", { name: "Dismiss" }); act(() => dismiss.focus()); fireEvent.click(dismiss); const external = document.createElement("button"); external.textContent = "Deliberate external target"; document.body.appendChild(external); act(() => external.focus()); serverNotifications = []; await act(async () => next.resolve({ data: undefined })); expect(external).toHaveFocus(); external.remove();
|
||||
});
|
||||
|
||||
test("[P7-08 operations] accepted background polls preserve surviving and deliberate focus and recover removed focused controls", async () => {
|
||||
jest.useFakeTimers(); renderPage(); await act(async () => {});
|
||||
const cancel = screen.getByRole("button", { name: "Cancel" }); act(() => cancel.focus());
|
||||
serverOperations = [{ ...operation, progressStage: "Fresh progress with the same action" }];
|
||||
await act(async () => jest.advanceTimersByTime(15000)); expect(cancel).toHaveFocus(); expect(cancel.isConnected).toBe(true);
|
||||
const external = document.createElement("button"); external.textContent = "Deliberate target outside recent activity"; document.body.appendChild(external);
|
||||
try {
|
||||
act(() => external.focus()); serverOperations = [{ ...operation, status: "cancelled", canCancel: false, canRetry: true }];
|
||||
await act(async () => jest.advanceTimersByTime(15000)); expect(external).toHaveFocus(); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
|
||||
serverOperations = [{ ...operation }]; await act(async () => jest.advanceTimersByTime(15000)); expect(external).toHaveFocus();
|
||||
act(() => screen.getByRole("button", { name: "Cancel" }).focus()); serverOperations = [{ ...operation, status: "cancelled", canCancel: false, canRetry: true }];
|
||||
await act(async () => jest.advanceTimersByTime(15000)); expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
|
||||
act(() => screen.getByRole("button", { name: "Mark read" }).focus()); serverNotifications = [];
|
||||
await act(async () => jest.advanceTimersByTime(15000)); expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Recent activity" })).toHaveFocus();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(12); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
} finally { external.remove(); }
|
||||
});
|
||||
|
||||
@@ -1,147 +1,172 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Alert, Box, Button, Chip, LinearProgress, Paper, Stack, Typography } from "@mui/material";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { notificationDateLabel, UserNotification } from "../notifications";
|
||||
import type { TranslationKey } from "../i18n/translations";
|
||||
import { PageHeader, PageSection } from "../layout/PageLayout";
|
||||
import { notificationDateLabel, type UserNotification } from "../notifications";
|
||||
import type { UserOperation } from "../types";
|
||||
|
||||
type Operation = {
|
||||
id: string;
|
||||
taskType: string;
|
||||
status: string;
|
||||
subjectType?: string | null;
|
||||
createdAtUtc: string;
|
||||
completedAtUtc?: string | null;
|
||||
cancellationRequestedAtUtc?: string | null;
|
||||
progressStage?: string | null;
|
||||
progressPercent?: number | null;
|
||||
failureCategory?: string | null;
|
||||
canCancel: boolean;
|
||||
canRetry: boolean;
|
||||
const statusKeys: Record<string, TranslationKey> = {
|
||||
queued: "operationsStatusQueued", running: "operationsStatusRunning",
|
||||
waiting_for_retry: "operationsStatusWaitingForRetry", waiting_for_external_fallback: "operationsStatusWaitingForFallback",
|
||||
succeeded: "operationsStatusSucceeded", failed: "operationsStatusFailed", cancelled: "operationsStatusCancelled",
|
||||
};
|
||||
|
||||
const statusLabel = (value: string) => value.replaceAll("_", " ");
|
||||
const taskKeys: Record<string, TranslationKey> = { "cv.process": "operationsTaskCv", "strategy.snapshot": "operationsTaskStrategy" };
|
||||
const buttonSx = { minHeight: { xs: 44, sm: 36 }, whiteSpace: "normal", overflowWrap: "anywhere", "@media (pointer: coarse)": { minHeight: 44 } } as const;
|
||||
type Failure = { cause: unknown; kind: "load" | "action" | "committed" };
|
||||
type ActionUpdate = { operation?: UserOperation; readId?: string; dismissId?: string };
|
||||
|
||||
export default function OperationsPage() {
|
||||
const { language, t } = useI18n();
|
||||
const [operations, setOperations] = useState<Operation[]>([]);
|
||||
const [operations, setOperations] = useState<UserOperation[]>([]);
|
||||
const [notifications, setNotifications] = useState<UserNotification[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [error, setError] = useState<Failure | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const mounted = useRef(false);
|
||||
const sequence = useRef(0);
|
||||
const activeLoad = useRef<AbortController | null>(null);
|
||||
const pendingAction = useRef(false);
|
||||
const refreshRequired = useRef(false);
|
||||
const activityRef = useRef<HTMLDivElement>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const actionFocus = useRef<HTMLElement | null>(null);
|
||||
const readFocus = useRef<HTMLElement | null>(null);
|
||||
|
||||
const load = useCallback(async (showLoading = false) => {
|
||||
if (showLoading) setLoading(true);
|
||||
const load = useCallback(async (afterWrite = false) => {
|
||||
if (!mounted.current || activeLoad.current || pendingAction.current && !afterWrite) return;
|
||||
const request = ++sequence.current;
|
||||
const controller = new AbortController();
|
||||
activeLoad.current = controller;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [operationResponse, notificationResponse] = await Promise.all([
|
||||
api.get<Operation[]>("/operations?limit=50"),
|
||||
api.get<UserNotification[]>("/notifications?limit=50"),
|
||||
api.get<UserOperation[]>("/operations?limit=50", { signal: controller.signal }),
|
||||
api.get<UserNotification[]>("/notifications?limit=50", { signal: controller.signal }),
|
||||
]);
|
||||
if (!mounted.current || controller.signal.aborted || request !== sequence.current) return;
|
||||
const active = document.activeElement;
|
||||
readFocus.current = active instanceof HTMLElement && activityRef.current?.contains(active) ? active : null;
|
||||
setOperations(operationResponse.data ?? []);
|
||||
setNotifications(notificationResponse.data ?? []);
|
||||
setHasLoaded(true);
|
||||
refreshRequired.current = false;
|
||||
setError(null);
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, t("operationsLoadFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
} catch (cause) {
|
||||
if (mounted.current && !controller.signal.aborted && request === sequence.current) {
|
||||
setError({ cause, kind: refreshRequired.current ? "committed" : "load" });
|
||||
controller.abort();
|
||||
}
|
||||
}, [t]);
|
||||
} finally {
|
||||
if (mounted.current && request === sequence.current) { activeLoad.current = null; setLoading(false); }
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void load();
|
||||
const interval = window.setInterval(() => void load(), 15000);
|
||||
return () => window.clearInterval(interval);
|
||||
return () => { mounted.current = false; sequence.current += 1; activeLoad.current?.abort(); activeLoad.current = null; window.clearInterval(interval); };
|
||||
}, [load]);
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<unknown>, notificationsChanged = false) => {
|
||||
if (busyKey) return;
|
||||
useEffect(() => {
|
||||
if (loading || busyKey || !actionFocus.current) return;
|
||||
const target = actionFocus.current;
|
||||
actionFocus.current = null;
|
||||
if (document.activeElement !== activityRef.current) return;
|
||||
if (target.isConnected && !target.matches(":disabled")) target.focus();
|
||||
else headingRef.current?.focus();
|
||||
}, [busyKey, error, loading, notifications, operations]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = readFocus.current;
|
||||
readFocus.current = null;
|
||||
if (target && !target.isConnected && document.activeElement === document.body) headingRef.current?.focus();
|
||||
}, [loading, notifications, operations]);
|
||||
|
||||
const rememberFocus = () => {
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement && active !== activityRef.current && activityRef.current?.contains(active)) {
|
||||
actionFocus.current = active;
|
||||
activityRef.current.focus();
|
||||
}
|
||||
};
|
||||
const refresh = () => { rememberFocus(); void load(); };
|
||||
const runAction = async (key: string, action: () => Promise<ActionUpdate>) => {
|
||||
if (!mounted.current || pendingAction.current || refreshRequired.current) return;
|
||||
pendingAction.current = true;
|
||||
sequence.current += 1;
|
||||
activeLoad.current?.abort();
|
||||
activeLoad.current = null;
|
||||
setLoading(false);
|
||||
rememberFocus();
|
||||
setBusyKey(key);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
await load();
|
||||
if (notificationsChanged) window.dispatchEvent(new Event("notifications-changed"));
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, t("operationsActionFailed")));
|
||||
const update = await action();
|
||||
if (!mounted.current) return;
|
||||
if (update.operation) setOperations(items => items.map(item => item.id === update.operation!.id ? update.operation! : item));
|
||||
if (update.readId) setNotifications(items => items.map(item => item.id === update.readId ? { ...item, readAtUtc: new Date().toISOString() } : item));
|
||||
if (update.dismissId) setNotifications(items => items.filter(item => item.id !== update.dismissId));
|
||||
// A failed follow-up read must never invite replay of a committed write.
|
||||
refreshRequired.current = true;
|
||||
window.dispatchEvent(new Event("notifications-changed"));
|
||||
await load(true);
|
||||
} catch (cause) {
|
||||
if (mounted.current) setError({ cause, kind: "action" });
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
if (mounted.current) { pendingAction.current = false; setBusyKey(null); }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
<Typography color="text.secondary">{t("operationsSubtitle")}</Typography>
|
||||
<Button variant="outlined" onClick={() => void load(true)} disabled={loading}>{t("refresh")}</Button>
|
||||
</Box>
|
||||
const locked = busyKey !== null || error?.kind === "committed";
|
||||
const taskLabel = (task: string) => Object.hasOwn(taskKeys, task) ? t(taskKeys[task]) : task;
|
||||
const dateLabel = (value: string) => notificationDateLabel(value, language === "nb" ? "nb-NO" : "en-GB");
|
||||
const errorTitle = error ? t(error.kind === "committed" ? "operationsCommittedRefreshFailed" : error.kind === "action" ? "operationsActionFailed" : "operationsLoadFailed") : "";
|
||||
const errorDetail = error ? getApiErrorMessage(error.cause, errorTitle) : "";
|
||||
|
||||
{error ? <Alert severity="error" aria-live="polite">{error}</Alert> : null}
|
||||
return <Stack ref={activityRef} role="region" aria-label={t("operationsActivity")} aria-busy={loading || busyKey !== null} tabIndex={-1} spacing={2} sx={{ minWidth: 0 }}>
|
||||
<PageHeader level="h2" title={t("operationsActivity")} titleRef={headingRef} description={t("operationsSubtitle")} actions={<Button variant="outlined" onClick={refresh} disabled={loading || busyKey !== null} sx={buttonSx}>{t("refresh")}</Button>} />
|
||||
<Typography variant="body2" color="text.secondary">{t("operationsRecentHelp")}</Typography>
|
||||
{error ? <Alert severity="error" sx={{ overflowWrap: "anywhere", display: "grid", gridTemplateColumns: "auto minmax(0, 1fr)", "& .MuiAlert-message": { minWidth: 0 }, "& .MuiAlert-action": { gridColumn: 2, m: 0, p: 0, justifySelf: "start" } }} action={<Button color="inherit" size="small" onClick={refresh} disabled={loading || busyKey !== null} sx={buttonSx}>{t("retry")}</Button>}>
|
||||
<Typography variant="body2">{errorTitle}</Typography>
|
||||
{errorDetail !== errorTitle ? <Typography variant="body2">{errorDetail}</Typography> : null}
|
||||
</Alert> : null}
|
||||
{loading ? <LinearProgress aria-label={t("operationsLoading")} /> : null}
|
||||
|
||||
<Paper component="section" aria-labelledby="notifications-heading" sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Typography id="notifications-heading" variant="h6" sx={{ mb: 2 }}>{t("notifications")}</Typography>
|
||||
{notifications.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsNoNotifications")}</Typography> : null}
|
||||
<Stack spacing={1.5}>
|
||||
{notifications.map((notification) => (
|
||||
<Box key={notification.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2, opacity: notification.readAtUtc ? 0.75 : 1 }}>
|
||||
<Typography sx={{ fontWeight: notification.readAtUtc ? 600 : 800 }}>{notification.title}</Typography>
|
||||
<Typography color="text.secondary">{notification.message}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{notificationDateLabel(notification.createdAtUtc, language === "nb" ? "nb-NO" : "en")}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{!notification.readAtUtc ? (
|
||||
<Button size="small" disabled={busyKey !== null} onClick={() => void runAction(`read-${notification.id}`, () => api.post(`/notifications/${notification.id}/read`), true)}>
|
||||
{t("notificationsMarkRead")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="small" color="inherit" disabled={busyKey !== null} onClick={() => void runAction(`dismiss-${notification.id}`, () => api.delete(`/notifications/${notification.id}`), true)}>
|
||||
{t("notificationsDismiss")}
|
||||
</Button>
|
||||
<PageSection surface title={t("notifications")}>
|
||||
{hasLoaded && notifications.length === 0 ? <Typography color="text.secondary">{t("operationsNoNotifications")}</Typography> : null}
|
||||
<Stack spacing={1.5}>{notifications.map(notification => <Paper key={notification.id} component="article" aria-label={notification.title} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, overflowWrap: "anywhere" }}>
|
||||
<Typography component="h3" variant="sectionTitle">{notification.title}</Typography>
|
||||
{!notification.readAtUtc ? <Typography variant="caption" color="text.secondary">{t("notificationsUnread")}</Typography> : null}
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{notification.message}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{dateLabel(notification.createdAtUtc)}</Typography>
|
||||
<Stack direction="row" useFlexGap spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{!notification.readAtUtc ? <Button size="small" disabled={locked} sx={buttonSx} onClick={() => void runAction("read-" + notification.id, async () => { await api.post("/notifications/" + notification.id + "/read"); return { readId: notification.id }; })}>{t("notificationsMarkRead")}</Button> : null}
|
||||
<Button size="small" color="inherit" disabled={locked} sx={buttonSx} onClick={() => void runAction("dismiss-" + notification.id, async () => { await api.delete("/notifications/" + notification.id); return { dismissId: notification.id }; })}>{t("notificationsDismiss")}</Button>
|
||||
</Stack>
|
||||
</Paper>)}</Stack>
|
||||
</PageSection>
|
||||
<PageSection surface title={t("operations")}>
|
||||
{hasLoaded && operations.length === 0 ? <Typography color="text.secondary">{t("operationsEmpty")}</Typography> : null}
|
||||
<Stack spacing={1.5}>{operations.map(operation => <Paper key={operation.id} component="article" aria-label={taskLabel(operation.taskType)} data-operation-id={operation.id} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, overflowWrap: "anywhere" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", minWidth: 0 }}>
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere", minWidth: 0 }}>{taskLabel(operation.taskType)}</Typography>
|
||||
<Chip size="small" label={Object.hasOwn(statusKeys, operation.status) ? t(statusKeys[operation.status]) : operation.status.replaceAll("_", " ")} sx={{ maxWidth: "100%", height: "auto", "& .MuiChip-label": { whiteSpace: "normal", py: 0.5, overflowWrap: "anywhere" } }} />
|
||||
</Box>
|
||||
))}
|
||||
<Typography variant="caption" color="text.secondary">{t("operationsCreated", { date: dateLabel(operation.createdAtUtc) })}</Typography>
|
||||
{operation.progressStage ? <Typography variant="body2" sx={{ mt: 1 }}>{operation.progressStage}</Typography> : null}
|
||||
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={t("operationsProgress", { task: taskLabel(operation.taskType) })} sx={{ mt: 1 }} /> : null}
|
||||
{operation.cancellationRequestedAtUtc ? <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("operationsCancellationRequested")}</Typography> : null}
|
||||
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1, overflowWrap: "anywhere" }}>{t("operationsFailed", { category: operation.failureCategory.replaceAll("_", " ") })}</Alert> : null}
|
||||
<Stack direction="row" useFlexGap spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{operation.canCancel ? <Button size="small" color="error" disabled={locked} sx={buttonSx} onClick={() => void runAction("cancel-" + operation.id, async () => ({ operation: (await api.post<UserOperation>("/operations/" + operation.id + "/cancel")).data }))}>{t("cancel")}</Button> : null}
|
||||
{operation.canRetry ? <Button size="small" variant="outlined" disabled={locked} sx={buttonSx} onClick={() => void runAction("retry-" + operation.id, async () => ({ operation: (await api.post<UserOperation>("/operations/" + operation.id + "/retry")).data }))}>{t("retry")}</Button> : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper component="section" aria-labelledby="operations-heading" sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Typography id="operations-heading" variant="h6" sx={{ mb: 2 }}>{t("operations")}</Typography>
|
||||
{operations.length === 0 && !loading ? <Typography color="text.secondary">{t("operationsEmpty")}</Typography> : null}
|
||||
<Stack spacing={1.5}>
|
||||
{operations.map((operation) => (
|
||||
<Box key={operation.id} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{operation.taskType}</Typography>
|
||||
<Chip size="small" label={statusLabel(operation.status)} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">{t("operationsStarted", { date: notificationDateLabel(operation.createdAtUtc, language === "nb" ? "nb-NO" : "en") })}</Typography>
|
||||
{operation.progressStage ? <Typography sx={{ mt: 1 }}>{operation.progressStage}</Typography> : null}
|
||||
{operation.progressPercent != null ? <LinearProgress variant="determinate" value={operation.progressPercent} aria-label={t("operationsProgress", { task: operation.taskType })} sx={{ mt: 1 }} /> : null}
|
||||
{operation.cancellationRequestedAtUtc ? <Typography color="text.secondary" sx={{ mt: 1 }}>{t("operationsCancellationRequested")}</Typography> : null}
|
||||
{operation.failureCategory ? <Alert severity="error" sx={{ mt: 1 }}>{t("operationsFailed", { category: statusLabel(operation.failureCategory) })}</Alert> : null}
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: "wrap" }}>
|
||||
{operation.canCancel ? (
|
||||
<Button size="small" color="error" disabled={busyKey !== null} onClick={() => void runAction(`cancel-${operation.id}`, () => api.post(`/operations/${operation.id}/cancel`))}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
{operation.canRetry ? (
|
||||
<Button size="small" variant="outlined" disabled={busyKey !== null} onClick={() => void runAction(`retry-${operation.id}`, () => api.post(`/operations/${operation.id}/retry`), true)}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
</Paper>)}</Stack>
|
||||
</PageSection>
|
||||
</Stack>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user