From fee5dec9204f729c0735eae7c1b86f45326c6fc4 Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:41:44 -0400 Subject: [PATCH 01/15] Enhance Playwright workflow with Firefox support Added Firefox browser installation and smoke test to Playwright workflow. Signed-off-by: Bradley Saucier --- .github/workflows/deploy.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 93a8a394..1759278a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -97,10 +97,11 @@ jobs: restore-keys: | ${{ runner.os }}-playwright-release- ${{ runner.os }}-playwright-webkit- + ${{ runner.os }}-playwright-firefox- ${{ runner.os }}-playwright- - name: Install Playwright browsers - run: npx playwright install --with-deps chromium webkit + run: npx playwright install --with-deps chromium webkit firefox - name: Run Chromium smoke against CI artifact env: @@ -112,6 +113,11 @@ jobs: PLAYWRIGHT_WEB_SERVER_CMD: npm run preview:prod run: npm run test:e2e:webkit:smoke + - name: Run Firefox release smoke against CI artifact + env: + PLAYWRIGHT_WEB_SERVER_CMD: npm run preview:prod + run: npm run test:e2e:firefox:smoke + deploy: name: Deploy if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' }} From 1b58c573c0fa81b5922fbf320b159d24c9b5b7ed Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:42:22 -0400 Subject: [PATCH 02/15] Add Firefox smoke tests to CI workflow Signed-off-by: Bradley Saucier --- .github/workflows/ci.yml | 51 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8514f137..bc25253d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,12 +128,55 @@ jobs: path: test-results/ retention-days: 7 + firefox_smoke: + name: Firefox smoke + runs-on: ubuntu-24.04 + timeout-minutes: 10 + continue-on-error: false + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.0.0 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + env: + HUSKY: 0 + run: npm ci + + - name: Cache Playwright browsers + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-firefox-${{ hashFiles('package-lock.json') }} + + - name: Install Playwright browsers + run: npx playwright install --with-deps firefox + + - name: Firefox smoke tests + run: npm run test:e2e:firefox + + - name: Upload Firefox traces + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: firefox-smoke-traces + path: test-results/ + retention-days: 7 + verify: name: verify runs-on: ubuntu-latest needs: - verify_matrix - webkit_smoke + - firefox_smoke if: ${{ !cancelled() }} steps: @@ -149,6 +192,12 @@ jobs: echo "webkit_smoke result: ${{ needs.webkit_smoke.result }}" exit 1 + - name: Fail when Firefox smoke does not succeed + if: ${{ needs.firefox_smoke.result != 'success' }} + run: | + echo "firefox_smoke result: ${{ needs.firefox_smoke.result }}" + exit 1 + - name: Confirm required CI success - if: ${{ needs.verify_matrix.result == 'success' && needs.webkit_smoke.result == 'success' }} + if: ${{ needs.verify_matrix.result == 'success' && needs.webkit_smoke.result == 'success' && needs.firefox_smoke.result == 'success' }} run: echo "All required CI jobs passed" From a609527716c9b4147bc393e1703d0973dec33d49 Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:43:04 -0400 Subject: [PATCH 03/15] Document Firefox Playwright smoke lane in CHANGELOG Added a narrow merge-blocking and release Firefox Playwright smoke lane to ensure Gecko engine-level compatibility. Signed-off-by: Bradley Saucier --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fdf6884..b84dc1e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on Keep a Changelog, and this project follows Semantic Versi - removed the internal IndexedDB auto-increment `id` from new JSON and crash-JSON exports, while keeping legacy backups that still carry `id` importable - hardened import commit verification by reading the pre-write snapshot and deriving the undo snapshot inside the rw transaction - unified focus chrome on DomainCard radios, history grid cells, mobile day buttons, and the history scroll region under shared focus utilities +- added a narrow merge-blocking and release Firefox Playwright smoke lane, documented in ADR-0029, that proves Gecko engine-level compatibility without claiming live Firefox policy simulation ### Security From 3b11e329b60503395f71b9116fd764850fd08dca Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:43:34 -0400 Subject: [PATCH 04/15] Update CI guidelines for WebKit and Firefox smoke tests Signed-off-by: Bradley Saucier --- CONTRIBUTING.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5fabdcbe..62d69bec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,7 @@ npm run typecheck npm run test npm run test:e2e npm run test:e2e:webkit +npm run test:e2e:firefox npm run build ``` @@ -51,6 +52,6 @@ npm run build 4. Keep language clinical and professional throughout the repo. 5. Any database schema change must add or update the migration registry, migration tests, and the relevant ADR before merge. 6. Any durability-sensitive migration change must include browser-level upgrade proof before merge, not just unit coverage. -7. The narrow WebKit smoke lane now gates CI. Keep it scoped to engine-level compatibility proof, not Safari policy simulation. -8. If a WebKit smoke failure is accepted as a platform boundary instead of a product bug, document that boundary in docs/webkit-limitations.md before merge. +7. The narrow WebKit and Firefox smoke lanes now gate CI. Keep them scoped to engine-level compatibility proof, not browser-policy simulation. +8. If a WebKit or Firefox smoke failure is accepted as a platform boundary instead of a product bug, document that boundary in docs/webkit-limitations.md or docs/firefox-limitations.md before merge. 9. When a module encodes a non-obvious architectural constraint, keep a short `// Architecture:` ADR reference comment near that boundary so future changes do not have to rediscover the rationale. From fbff35968717c9d29ffe0dc0f220a571f237fa9d Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:44:45 -0400 Subject: [PATCH 05/15] Revise README for browser support and CI updates Updated browser compatibility information and CI coverage details. Signed-off-by: Bradley Saucier --- README.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1bdcee1c..8f6f83b0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@
-[![Pipeline: Mainline Integrity](https://github.com/bradsaucier/opsnormal/actions/workflows/ci.yml/badge.svg)](https://github.com/bradsaucier/opsnormal/actions/workflows/ci.yml) [![Pipeline: Pages Release](https://github.com/bradsaucier/opsnormal/actions/workflows/deploy.yml/badge.svg)](https://github.com/bradsaucier/opsnormal/actions/workflows/deploy.yml) [![CodeQL](https://github.com/bradsaucier/opsnormal/actions/workflows/codeql.yml/badge.svg)](https://github.com/bradsaucier/opsnormal/actions/workflows/codeql.yml) [![Status: v1.0.0](https://img.shields.io/badge/Status-v1.0.0_public_release-36476F?style=flat-square)](./CHANGELOG.md) [![Data posture: Local only](https://img.shields.io/badge/Data_Posture-Local_Only-36476F?style=flat-square)](#trust-contract) +[![Pipeline: Mainline Integrity](https://github.com/bradsaucier/opsnormal/actions/workflows/ci.yml/badge.svg)](https://github.com/bradsaucier/opsnormal/actions/workflows/ci.yml) [![Pipeline: Pages Release](https://github.com/bradsaucier/opsnormal/actions/workflows/deploy.yml/badge.svg)](https://github.com/bradsaucier/opsnormal/actions/workflows/deploy.yml) [![Status: v1.0.0](https://img.shields.io/badge/Status-v1.0.0_public_release-36476F?style=flat-square)](./CHANGELOG.md) [![Data posture: Local only](https://img.shields.io/badge/Data_Posture-Local_Only-36476F?style=flat-square)](#trust-contract)
@@ -167,9 +167,9 @@ Each row's verification-truth column states what the repo currently proves, not | --------------------------- | ---------------------- | -------------------------------------------------------------------------------- | | Chromium-based browsers | Supported | Full Playwright Chromium coverage, production-artifact smoke, and release gating | | Safari and other WebKit UIs | Supported with caveats | Merge-blocking and release WebKit smoke lanes prove engine compatibility only | -| Firefox current release | Expected to work | Manual verification recommended because there is no dedicated Firefox CI lane | +| Firefox current release | Supported with caveats | Merge-blocking and release Firefox smoke lanes prove engine compatibility only | -Read [WebKit CI coverage boundary](./docs/webkit-limitations.md) before making stronger Safari claims than the repo proves. +Read [WebKit CI coverage boundary](./docs/webkit-limitations.md) and [Firefox CI coverage boundary](./docs/firefox-limitations.md) before making stronger browser claims than the repo proves. ### Reliability posture @@ -196,8 +196,8 @@ Accessibility is architectural, not decorative. Quality is enforced through release gates, test coverage, and explicit design constraints. -- GitHub Actions runs lint, typecheck, Vitest coverage, Playwright Chromium verification, a merge-blocking Playwright WebKit smoke lane, and build validation -- GitHub Pages release downloads the `dist-ci-verified` artifact from the successful mainline integrity run, re-smokes that exact bundle in Chromium and WebKit, and only then publishes +- GitHub Actions runs lint, typecheck, Vitest coverage, Playwright Chromium verification, merge-blocking Playwright WebKit and Firefox smoke lanes, and build validation +- GitHub Pages release downloads the `dist-ci-verified` artifact from the successful mainline integrity run, re-smokes that exact bundle in Chromium, WebKit, and Firefox, and only then publishes - The released bundle carries a Sigstore-backed build-provenance attestation that Pipeline: Pages Release verifies before upload. See ADR-0027. - GitHub CodeQL code scanning gates mainline with the `security-extended` and `security-and-quality` query packs. See ADR-0028. - JSON export carries versioning and integrity checks, and import commit verification fails closed before the app claims success @@ -225,11 +225,14 @@ npm run typecheck npm run test npm run test:e2e npm run test:e2e:webkit +npm run test:e2e:firefox npm run build npm run test:e2e:smoke +npm run test:e2e:webkit:smoke +npm run test:e2e:firefox:smoke ``` -`npm run format:check` verifies repository formatting with Prettier, and `npm run format` applies the repository formatting baseline locally. `npm run test:e2e` builds the e2e-mode harness bundle and runs the full Chromium suite. `npm run test:e2e:webkit` runs the narrow WebKit smoke gate that verifies rendering and IndexedDB I/O on a WebKit engine without claiming to reproduce Safari eviction behavior. Run `npm run build` before `npm run test:e2e:smoke` so the smoke command reuses a real production `dist/` build and skips the harness-only specs. +`npm run format:check` verifies repository formatting with Prettier, and `npm run format` applies the repository formatting baseline locally. `npm run test:e2e` builds the e2e-mode harness bundle and runs the full Chromium suite. `npm run test:e2e:webkit` runs the narrow WebKit smoke gate that verifies rendering and IndexedDB I/O on a WebKit engine without claiming to reproduce Safari eviction behavior. `npm run test:e2e:firefox` runs the parallel Gecko smoke gate that verifies boot, IndexedDB persistence, service worker activation, the non-WebKit storage path, and the fallback download path without claiming to simulate live Firefox storage policy. Run `npm run build` before the `test:e2e:*:smoke` commands so the production-artifact smoke checks reuse a real `dist/` build and skip the harness-only specs. ## Documentation matrix @@ -240,6 +243,7 @@ This README stays focused on orientation and first use. Deeper proof, limits, an | [**Architecture overview**](./docs/architecture.md) | Runtime shape, persistence model, recovery posture, PWA behavior, and known limits | | [**Risk register**](./docs/risk-register.md) | Known operational risks, browser-storage hazards, and current mitigations | | [WebKit CI coverage boundary](./docs/webkit-limitations.md) | What the merge-blocking WebKit lane proves, what it cannot prove, and how to triage failures | +| [Firefox CI coverage boundary](./docs/firefox-limitations.md) | What the merge-blocking Firefox lane proves, what it cannot prove, and how to triage failures | | [**Architecture Decision Records**](./docs/decisions/README.md) | Why the repo chose IndexedDB, local-only boundaries, export integrity rules, and related constraints | | [**Test plan**](./docs/test-plan.md) | Verification strategy, release checks, and coverage priorities | | [Release checklist](./docs/release-checklist.md) | Pre-release validation and operator-facing quality gates | From d01573dc2e58f2015e576b73b6c33e24e7b2e3c1 Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:46:41 -0400 Subject: [PATCH 06/15] Enforce Gecko-engine compatibility with Playwright Add Playwright Firefox smoke coverage requirement for compatibility. Signed-off-by: Bradley Saucier --- SECURITY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/SECURITY.md b/SECURITY.md index ed3903cc..9db69ade 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -77,6 +77,7 @@ Current repo controls include: - Root and section-level React error boundaries - Crash fallback that preserves export access after a render fault - CI validation across lint, typecheck, unit and integration tests, end-to-end tests, and build +- Playwright Firefox smoke coverage enforces Gecko-engine compatibility at merge and release - Dependabot coverage for npm and GitHub Actions dependencies ## Dependency maintenance posture From 72545ffe0eb32767f8d94b10966fb85fb59050d3 Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:47:42 -0400 Subject: [PATCH 07/15] Add Firefox smoke lane for engine compatibility testing Document the decision to add a Firefox smoke lane for CI coverage. Signed-off-by: Bradley Saucier --- .../0029-firefox-smoke-engine-compat-gate.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/decisions/0029-firefox-smoke-engine-compat-gate.md diff --git a/docs/decisions/0029-firefox-smoke-engine-compat-gate.md b/docs/decisions/0029-firefox-smoke-engine-compat-gate.md new file mode 100644 index 00000000..5876137b --- /dev/null +++ b/docs/decisions/0029-firefox-smoke-engine-compat-gate.md @@ -0,0 +1,51 @@ +## Status + +Accepted +Amends ADR-0020 and ADR-0021. + +## Context + +ADR-0020 made the narrow WebKit smoke lane merge-blocking CI coverage. +ADR-0021 extended that enforcement to the signed release artifact. +That closed the repository's cross-engine gap for WebKit, but Firefox still remained a README-declared browser surface without dedicated CI evidence. + +That remaining Firefox row was materially weaker than the rest of the repository's posture. +The application depends on Gecko-sensitive browser behavior for IndexedDB persistence, service worker registration, fallback Blob-download export when `showSaveFilePicker` is unavailable, and normal boot under the pinned CSP and Trusted Types contract. +Leaving Firefox on manual-only verification preserved a truthful caveat, but it also left the final supported engine family outside enforced merge and release evidence. + +## Decision + +Add a narrow Playwright Firefox smoke lane that runs both at merge time and at release time against the same `dist-ci-verified` artifact. + +This lane is allowed to prove only these engine-level behaviors on Gecko: + +1. app boot on a Gecko engine without CSP refusal events during normal startup +2. the non-WebKit storage-health path still renders in the shell +3. IndexedDB persistence across reload for the core check-in path +4. service worker registration reaches `activated` +5. the fallback Blob-download export path still triggers when `showSaveFilePicker` is unavailable + +This decision does not authorize stronger claims about live Firefox policy behavior. +The lane is not a browser-policy oracle. +It does not prove OS-level storage persistence policy, live-hardware behavior, privacy-mode persistence differences, or file-manager handoff after the fallback download leaves the browser. +Manual verification on a current Firefox release remains in the release checklist. + +## Triage rule for Firefox smoke failures + +1. If the failure shows shipped behavior is broken on Firefox, fix the product code before merge. +2. If the failure is test fragility, harden the test without widening the claim. +3. If the failure reflects a real platform boundary that the repository accepts, document the boundary here and in `docs/firefox-limitations.md` before merge. + +## Consequences + +Positive: + +- closes the last README compatibility row that lacked automated evidence +- extends the existing engine-compat gate pattern from WebKit to Gecko without changing application code +- blocks merge and release when Firefox-specific regressions hit IndexedDB persistence, service worker registration, or the fallback export path + +Trade-offs: + +- adds another Playwright lane that can block merge or release on real Gecko regressions or runner instability +- increases release latency by one more smoke pass against the shipped artifact +- requires accepted Firefox platform boundaries to be documented explicitly instead of handled silently From 4e18769a781e9831c2d2175bf6fe946fecacc7c4 Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:48:11 -0400 Subject: [PATCH 08/15] Add decision 0029 for Firefox smoke engine compatibility Signed-off-by: Bradley Saucier --- docs/decisions/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 0beaabeb..a6a819d2 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -34,6 +34,7 @@ OpsNormal uses lightweight ADRs to record constraints that should not drift casu | 0026 | CSP directive contract and Trusted Types | | 0027 | Build provenance attestation for release artifact | | 0028 | CodeQL source code scanning gate | +| 0029 | Firefox smoke engine compatibility gate | ## Operating rule From 440af4e01d959b185fe980254870b3456505923a Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:49:15 -0400 Subject: [PATCH 09/15] Add Firefox CI coverage boundary documentation Document the limitations and expectations of Firefox CI coverage. Signed-off-by: Bradley Saucier --- docs/firefox-limitations.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/firefox-limitations.md diff --git a/docs/firefox-limitations.md b/docs/firefox-limitations.md new file mode 100644 index 00000000..a52072ba --- /dev/null +++ b/docs/firefox-limitations.md @@ -0,0 +1,33 @@ +# Firefox CI coverage boundary + +OpsNormal treats the Playwright Firefox lane as a merge-blocking and release-blocking compatibility gate. +That gate is intentionally narrow. +It proves engine-level behavior on a Gecko implementation in CI. +It does not prove live Firefox storage-policy behavior on real hardware. + +## What the Firefox gate is allowed to prove + +1. the shell boots on a Gecko engine without CSP refusal events during normal startup +2. the non-WebKit storage-health path still renders in the shell +3. the core Dexie-backed check-in path persists across a page reload +4. service worker registration reaches an activated state on Firefox +5. the fallback Blob-download export path still works when `showSaveFilePicker` is unavailable + +## What the Firefox gate is not allowed to prove + +1. Firefox storage persistence policy or quota behavior on a real operating system profile +2. private-browsing persistence differences or profile-isolation behavior +3. live-device service-worker lifecycle behavior outside the narrow activation check +4. file-manager handoff behavior after the browser launches the fallback download on a real system + +## Triage rule for Firefox smoke failures + +1. If the failure shows shipped behavior is broken on Firefox, fix the product code before merge. +2. If the failure is test fragility, harden the test without widening the claim. +3. If the failure reflects a real platform boundary that the repository accepts, document the boundary here and in the affected test before merge. + +## Manual verification remains required + +Current Firefox release verification still belongs in the release checklist. +The CI gates strengthen enforcement. +They do not change the truth boundary. From 8401f4a97c4b89d5c59830aecb80cb30a6cdb614 Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:49:44 -0400 Subject: [PATCH 10/15] Enhance release checklist with Firefox tests and updates Updated the release checklist to include Playwright Firefox smoke gate and modified the Pages release process. Signed-off-by: Bradley Saucier --- docs/release-checklist.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release-checklist.md b/docs/release-checklist.md index c99802ab..59d42e2b 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -10,7 +10,8 @@ Before tagging a release: - [ ] per-file coverage thresholds on src/services/importService.ts and src/db/appDb.ts pass - [ ] Playwright Chromium tests pass - [ ] Playwright WebKit smoke gate passes in CI and any failure is triaged as a real regression, a test issue, or an explicitly documented platform boundary in docs/webkit-limitations.md -- [ ] Pages release waits for the successful main-branch run of Pipeline: Mainline Integrity, downloads the exact dist-ci-verified artifact from that run, reruns Chromium smoke and WebKit release smoke against that artifact, and only then publishes +- [ ] Playwright Firefox smoke gate passes in CI and any failure is triaged as a real regression, a test issue, or an explicitly documented platform boundary in docs/firefox-limitations.md +- [ ] Pages release waits for the successful main-branch run of Pipeline: Mainline Integrity, downloads the exact dist-ci-verified artifact from that run, reruns Chromium smoke, WebKit release smoke, and Firefox release smoke against that artifact, and only then publishes - [ ] Pages release verifies the dist-ci-verified build-provenance attestation against the triggering commit SHA before smoke or upload - [ ] public/CNAME matches the GitHub Pages custom-domain setting and the enforced HTTPS origin - [ ] Vitest accessibility assertions pass on the direct-select check-in and history surfaces @@ -28,6 +29,7 @@ Before tagging a release: - [ ] manual recovery in one tab clears stale loop-breaker state in another open tab - [ ] blocked duplicate-tab schema recovery completes after the 5000 millisecond guard window without entering a reload loop - [ ] offline reopen verified manually +- [ ] current Firefox release verified manually for boot, reload persistence, fallback JSON export, and the non-WebKit storage-health path - [ ] export verified manually - [ ] root crash fallback verified manually where browser-specific behavior still matters - [ ] history grid crash containment verified manually From 73827c08ce01049d07e11f0661e95fea206c2a72 Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:50:10 -0400 Subject: [PATCH 11/15] Enhance test plan with Firefox coverage and manual checks Updated Firefox engine coverage details and clarified manual testing requirements for various browsers. Signed-off-by: Bradley Saucier --- docs/test-plan.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/test-plan.md b/docs/test-plan.md index 7bb93f2f..e0839dba 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -67,6 +67,7 @@ Prove that the app: - daily check-in persists through reload - synthetic Safari storage warning states drive the correct backup banner, install guidance, and storage-health messaging in Chromium - narrow WebKit smoke coverage now gates CI for app boot, Apple WebKit warning rendering, and IndexedDB persistence without claiming eviction simulation +- narrow Firefox smoke coverage now gates CI for app boot, the non-WebKit storage-health path, IndexedDB persistence across reload, service worker activation, fallback JSON export download, and CSP-safe startup on a Gecko engine without claiming live Firefox policy simulation - production preview can reopen offline after first load - JSON export can be imported into a clean browser context and re-exported without data loss - import preview and staged merge path hold under the accordion backup panel @@ -140,11 +141,13 @@ The recovery-surface scan set drives the `accessibility-recovery.a11y.spec.ts` f Safari storage lifecycle coverage is intentionally split across three layers. Unit tests prove storage-health and backup-prompt decision logic. Chromium e2e harness tests inject synthetic storage states and verify the exact operator-facing warning surfaces. A narrow WebKit smoke lane now gates CI for rendering and IndexedDB I-O on a WebKit engine, but it does not claim to reproduce Safari's seven-day purge behavior. The exact proof boundary and triage rule live in docs/webkit-limitations.md. +Firefox engine coverage uses a separate narrow smoke lane. That gate proves boot, the non-WebKit storage-health path, IndexedDB persistence across reload, service worker activation, fallback Blob download export, and CSP-safe startup on Gecko. It does not claim to reproduce live Firefox storage policy or hardware-specific behavior. The exact proof boundary and triage rule live in docs/firefox-limitations.md. + No automated lane in this repository simulates Safari's seven-day script-writable-storage purge. That browser behavior depends on real Safari use over time and still requires manual verification on Apple hardware before release, including any installed Home Screen or Add to Dock path. If WebKit purges the app after inactivity, it can erase both IndexedDB and the browser-side timestamp that recorded the last export, so the shell can reopen looking like a clean install. Recovery guidance must direct the operator to restore from the latest JSON export immediately when that blank return occurs. ## Chromium-only note -Playwright service worker validation is limited to Chromium. Offline reopen is still worth testing manually on Safari and mobile hardware before release. The mobile history E2E spec also uses Chromium viewport emulation rather than a real mobile browser, so WebKit and installed-PWA behavior still require manual verification. Full local and CI coverage uses the e2e-mode harness build. The deployment lane runs a narrower production-artifact smoke pass so GitHub Pages is blocked on the real shipped bundle without publishing the harness pages. +Broad Playwright coverage is still Chromium-led. The WebKit and Firefox engine-compat gates are intentionally narrow, and offline reopen is still worth testing manually on Safari, Firefox, and mobile hardware before release. The mobile history E2E spec also uses Chromium viewport emulation rather than a real mobile browser, so WebKit and installed-PWA behavior still require manual verification. Full local and CI coverage uses the e2e-mode harness build. The deployment lane runs a narrower production-artifact smoke pass so GitHub Pages is blocked on the real shipped bundle without publishing the harness pages. ## Manual release checks @@ -158,6 +161,7 @@ Playwright service worker validation is limited to Chromium. Offline reopen is s - verify repeated controllerchange churn in one tab pins the loop-breaker banner instead of continuing automatic reloads - verify manual recovery in one tab clears stale loop-breaker state in another open tab - verify the recovery announcement is spoken after a hard reload into the pinned manual recovery state with at least one screen reader and browser pair +- verify current Firefox release keeps boot, reload persistence, fallback JSON export, and the non-WebKit storage-health path on the deployed build - verify Chrome DevTools "Update on reload" is disabled before manual service-worker handoff checks so the smoke test reflects normal operator behavior - expect up to a 5000 millisecond guard-window delay before a blocked duplicate tab finishes schema-recovery reload From 206f17739cad2ccb9c3106316c7afac74759b416 Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:51:02 -0400 Subject: [PATCH 12/15] Add Firefox test scripts to package.json Signed-off-by: Bradley Saucier --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index e55a7ce4..2e945796 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,8 @@ "test:e2e:smoke": "playwright test --project=chromium --grep-invert @harness", "test:e2e:webkit": "npm run build:e2e && playwright test --project=webkit", "test:e2e:webkit:smoke": "playwright test tests/e2e/webkit-smoke.spec.ts --project=webkit-release", + "test:e2e:firefox": "npm run build:e2e && playwright test --project=firefox", + "test:e2e:firefox:smoke": "playwright test tests/e2e/firefox-smoke.spec.ts --project=firefox-release", "audit:security": "npm audit --audit-level=high", "prepare": "husky" }, From eb4e11dcdec8cc7f1951501a43b25972593df26e Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:51:24 -0400 Subject: [PATCH 13/15] Add Firefox smoke test project to Playwright config Added Firefox smoke test project configuration to Playwright. Signed-off-by: Bradley Saucier --- playwright.config.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index 7348fcab..173461f5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,6 +19,14 @@ const webkitSmokeProject = { ...devices['Desktop Safari'], }, }; +const firefoxSmokeProject = { + testMatch: /.*firefox-smoke\.spec\.ts/, + retries: 2, + workers: 1, + use: { + ...devices['Desktop Firefox'], + }, +}; export default defineConfig({ testDir: './tests/e2e', @@ -42,7 +50,11 @@ export default defineConfig({ projects: [ { name: 'chromium', - testIgnore: [/.*\.a11y\.spec\.ts/, /.*webkit-smoke\.spec\.ts/], + testIgnore: [ + /.*\.a11y\.spec\.ts/, + /.*webkit-smoke\.spec\.ts/, + /.*firefox-smoke\.spec\.ts/, + ], use: { ...devices['Desktop Chrome'] }, }, { @@ -63,5 +75,15 @@ export default defineConfig({ name: 'webkit-release', ...webkitSmokeProject, }, + { + name: 'firefox', + ...firefoxSmokeProject, + }, + { + // Separate project name keeps the release-artifact smoke lane easy to + // distinguish in CI reports without changing the browser contract. + name: 'firefox-release', + ...firefoxSmokeProject, + }, ], }); From 18de82ee98b9157e589b02ccd8de31fba23a168e Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:52:17 -0400 Subject: [PATCH 14/15] Add Firefox smoke tests for OpsNormal Signed-off-by: Bradley Saucier --- tests/e2e/firefox-smoke.spec.ts | 203 ++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 tests/e2e/firefox-smoke.spec.ts diff --git a/tests/e2e/firefox-smoke.spec.ts b/tests/e2e/firefox-smoke.spec.ts new file mode 100644 index 00000000..74569961 --- /dev/null +++ b/tests/e2e/firefox-smoke.spec.ts @@ -0,0 +1,203 @@ +import { + expect, + test, + type BrowserContext, + type Download, + type Page, +} from '@playwright/test'; + +declare global { + interface Window { + __opsCspViolations?: Array<{ + blockedURI: string; + violatedDirective: string; + originalPolicy: string; + }>; + } +} + +async function installCspViolationCollector(page: Page): Promise { + await page.addInitScript(() => { + window.__opsCspViolations = []; + document.addEventListener('securitypolicyviolation', (event) => { + window.__opsCspViolations?.push({ + blockedURI: event.blockedURI, + violatedDirective: event.violatedDirective, + originalPolicy: event.originalPolicy, + }); + }); + }); +} + +async function getCspViolations(page: Page) { + return page.evaluate(() => window.__opsCspViolations ?? []); +} + +async function openStorageHealth(page: Page) { + const storageHealthToggle = page.getByRole('button', { + name: /storage health/i, + }); + await storageHealthToggle.click(); + await expect( + page.getByText('Storage durability', { exact: true }), + ).toBeVisible(); +} + +function sectorRadio( + page: Page, + sectorLabel: string, + statusLabel: 'unmarked' | 'nominal' | 'degraded', +) { + return page.getByRole('radio', { + name: new RegExp(`^${sectorLabel} ${statusLabel}$`, 'i'), + }); +} + +function waitForDownload( + context: BrowserContext, + page: Page, +): Promise { + const downloadContext = context as BrowserContext & { + on: ( + event: 'download', + listener: (download: Download) => void, + ) => BrowserContext; + off: ( + event: 'download', + listener: (download: Download) => void, + ) => BrowserContext; + }; + + return new Promise((resolve) => { + let resolved = false; + + const finish = (download: Download) => { + if (resolved) { + return; + } + + resolved = true; + downloadContext.off('download', handleDownload); + resolve(download); + }; + + const handleDownload = (download: Download) => { + finish(download); + }; + + downloadContext.on('download', handleDownload); + void page.waitForEvent('download').then((download) => { + finish(download); + }); + }); +} + +test.describe('OpsNormal Firefox smoke', () => { + test('boots on Gecko, renders the non-WebKit storage path, and avoids CSP refusal events', async ({ + page, + }) => { + await installCspViolationCollector(page); + await page.goto('/'); + + await expect( + page.getByRole('heading', { name: 'OpsNormal' }), + ).toBeVisible(); + + await openStorageHealth(page); + + await expect( + page.getByText( + /Persistent storage active|Persistent storage not granted|Storage telemetry unavailable on this browser/i, + ), + ).toBeVisible(); + await expect(page.getByText('Browser tab', { exact: true })).toBeVisible(); + await expect( + page.getByText(/High-risk storage posture in Safari on macOS/i), + ).toHaveCount(0); + + await expect.poll(() => getCspViolations(page)).toEqual([]); + }); + + test('persists a check-in across reload in the Firefox smoke lane', async ({ + page, + }) => { + await page.goto('/'); + + const workDegraded = sectorRadio(page, 'Work or School', 'degraded'); + + await workDegraded.click(); + await expect(workDegraded).toHaveAttribute('aria-checked', 'true'); + + await page.reload(); + + await expect(workDegraded).toHaveAttribute('aria-checked', 'true'); + }); + + test('registers the service worker through activation on Firefox', async ({ + page, + }) => { + await page.goto('/'); + + await expect( + page.getByRole('heading', { name: 'OpsNormal' }), + ).toBeVisible(); + + await expect + .poll( + async () => { + return page.evaluate(async () => { + const registration = + await navigator.serviceWorker.getRegistration(); + + return { + activeState: registration?.active?.state ?? null, + activeScriptUrl: registration?.active?.scriptURL ?? null, + waitingState: registration?.waiting?.state ?? null, + waitingScriptUrl: registration?.waiting?.scriptURL ?? null, + installingState: registration?.installing?.state ?? null, + installingScriptUrl: registration?.installing?.scriptURL ?? null, + }; + }); + }, + { + timeout: 30000, + message: + 'Expected a registered service worker to reach the activated state on Firefox.', + }, + ) + .toMatchObject({ + activeState: 'activated', + activeScriptUrl: expect.stringContaining('/sw.js'), + }); + }); + + test('uses the fallback Blob download path for JSON export on Firefox', async ({ + page, + context, + }) => { + const downloadPromise = waitForDownload(context, page); + + await page.goto('/'); + + await expect( + page.getByRole('heading', { name: 'OpsNormal' }), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => { + return typeof ( + window as Window & + typeof globalThis & { + showSaveFilePicker?: unknown; + } + ).showSaveFilePicker; + }), + ) + .toBe('undefined'); + + await page.getByRole('button', { name: 'Export JSON' }).click(); + + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('opsnormal-export.json'); + }); +}); From ad3e1409f565bdb27cb2537850dc3511b01758c1 Mon Sep 17 00:00:00 2001 From: Bradley Saucier Date: Fri, 17 Apr 2026 19:53:09 -0400 Subject: [PATCH 15/15] Fix test timeout for accessibility checks in HistoryGrid Signed-off-by: Bradley Saucier --- tests/unit/historyGrid.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/historyGrid.test.tsx b/tests/unit/historyGrid.test.tsx index 644269c4..c94a2573 100644 --- a/tests/unit/historyGrid.test.tsx +++ b/tests/unit/historyGrid.test.tsx @@ -327,7 +327,7 @@ describe('history helpers and grid behavior', () => { ); expect((await axe(container)).violations).toEqual([]); - }); + }, 15000); it('has no accessibility violations in the desktop history view', async () => { const dateKeys = getTrailingDateKeys(30, new Date(2026, 2, 28)); @@ -338,7 +338,7 @@ describe('history helpers and grid behavior', () => { ); expect((await axe(container)).violations).toEqual([]); - }); + }, 15000); it('keeps a single tabbable desktop gridcell and updates the selected-cell brief during keyboard traversal', async () => { const user = userEvent.setup({ delay: null });