diff --git a/bun.lock b/bun.lock index 4d9e7887..3e845de7 100644 --- a/bun.lock +++ b/bun.lock @@ -63,7 +63,7 @@ "jsdom": "^28.1.0", "knip": "^6.11.0", "oxlint": "^1.75.0", - "playwright": "^1.55.0", + "playwright": "^1.63.0", "ts-morph": "^28.0.0", "typescript": "~5.6.2", "vite": "^6.0.1", @@ -932,7 +932,7 @@ "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], - "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1146,9 +1146,9 @@ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], - "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], @@ -1402,14 +1402,10 @@ "make-dir/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "tsconfig-paths-webpack-plugin/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "vite/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], diff --git a/docs/plans/pwa-release-robustness.md b/docs/plans/pwa-release-robustness.md index cce3d55f..513cc900 100644 --- a/docs/plans/pwa-release-robustness.md +++ b/docs/plans/pwa-release-robustness.md @@ -1,190 +1,112 @@ -# PWA release robustness: proposed contract - -Status: slice 1 implementation and verification in progress; later release-health, -deployment and rollout slices remain proposals, not an implemented recovery system. - -## Implementation alignment (2026-10-01) - -- Reams has not shipped yet. Establish distinct completed bootstrap artifacts - now; retain the actual first shipped artifacts for subsequent release gates. - Bootstrap runs are not evidence of upgrading a previously deployed release. -- One Update approval authorizes all Reams tabs to update. The initiating tab - flushes before activation; each other tab flushes locally before its own reload, - without another approval. A failed save keeps that tab open and offers Retry. - Debouncing alone does not prove pending writes are durably saved. -- Persist approval with the worker release; resumed tabs query it locally even - offline. A bare controllerchange or initial claim is not approval. Reloads use - the precached canonical shell, not an online-only query-bearing navigation. -- Keep the waiting-worker policy and existing root scope. Cache retention is - bounded to the current and preceding successfully activated release; retain - old immutable deployment assets for older tabs online. Unlimited old-tab - offline lazy-loading is not promised across arbitrary missed releases. -- The initial gate uses an atomic, asset-retaining local server fixture. Verify - actual hosting/proxy behavior and trusted TLS separately before deployment. -- Credential/session continuity, installed-device smoke tests, production health - reports, revocation and independent recovery remain separate acceptance work. - -Alignment update: real-artifact upgrade testing and observable update health are -required. Atomic deployment/asset retention is reasonable deployment baseline. -A separate independent recovery surface is deferred. Worker activation and page -reload are distinct: activation does not itself discard an open editor's buffer. -Automatic activation on installation remains a possible later policy, not the -current implementation. Activation after one approval and locally write-flushed -reloads across tabs is the aligned implementation target above. - -## Product outcome - -A user with an older installed Reams must be able to reach a working release -without losing durably saved notes, pending publication jobs, credentials or -preferences. Healthy editing sessions require one explicit approval for the -release before tabs reload, with each tab's writes flushed first. An -unavailable network is not classified as a bad release. Recovery cannot promise -to recover unsaved keystrokes that never reached durable storage. - -## Research: Skyreader - -Reviewed the public [disnet/skyreader repository](https://github.com/disnet/skyreader) -at commit `4528ffd9e666c7e2ae2a8b53b2d5148993442540`. The guessed Tangled locations -were not available; this repository links the live skyreader.app deployment. - -Useful mechanisms: - -- Workbox injects a revisioned, complete precache manifest. A missing required - asset fails installation instead of producing a half-installed release. -- Its worker answers `GET_VERSION`; the page compares the controlling build - with its own build to distinguish an actual update from mobile re-claim events. -- It runs production PWA tests in Chromium and WebKit, including build-output - validation, shell/chunk consistency, offline behavior and preload recovery. -- Deployment retains old immutable chunks for approximately 30 days so old - pages can continue lazy-loading their own code. -- A preload failure triggers at most one automatic reload within five minutes; - repeated recovery failure is reported to the backend with a build identifier. -- Deployment smoke checks validate the served version, shell and asset caching. - -Important policy distinction: Skyreader calls `skipWaiting()` during install and -claims clients immediately. Its Update button reloads onto an already-active -worker. Activation does not replace an open page's JavaScript or discard its -in-memory editor buffer. It changes subsequent request handling, so compatibility -with the old page and retention of its assets still matter. Reams currently -holds the worker waiting, but Skyreader's activation-plus-prompted-reload model -is viable for Reams if verified with our write-flush/data-preservation contract. -Do not add an automatic page reload on controllerchange. - -## One release identity and a small observable protocol - -Generate one release descriptor per edition from the completed artifact: - -- release ID and edition; -- worker URL and complete asset-manifest identity; -- recovery-protocol version; -- persistent-data compatibility range. - -The page embeds its release ID. Workers answer a bounded `GET_STATUS` message -with their release ID, edition and protocol version. A network-only release -endpoint supplies the eligible target, last known-good target and revoked -releases. A rollout generation is distinct from a release ID: a rollback to an -older release is still a new deployment decision. - -The client can then distinguish four things: the page it is executing, its -controlling worker, a waiting candidate, and the server's offered release. -Unknown/legacy worker versions remain unknown; an event alone is not a version. - -## Required release gate - -Use actual previously shipped artifact A and candidate artifact B, not only -two modified cache names around the same JavaScript bundle. Preserve one browser -profile and origin across the deployment. Serve finished artifacts atomically -through the production-equivalent server/proxy and cache policy. - -The gate must prove: - -1. Install A and create durable edits, pending outbox work and preferences. -2. Deploy B while A remains open. Detect B, completely install it and offer - Update while A remains usable, including its unsaved editor buffer. If B - activates and claims automatically, A's running page must remain functional - against that worker. No unapproved page reload. -3. Approve Update, flush writes, switch/reload to B and reach the app-ready checkpoint. - Read back the original data and pending jobs. -4. Repeat B to C in the same long-lived profile; cover resume, multiple tabs, - and offline/online transitions. -5. Intentionally fail a required asset or its integrity check. B must not take - control; A must continue working. Publish a repaired candidate and prove the - same profile can recover without clearing site data. -6. Exercise missing lazy chunks, failed script checks, stale HTTP responses, - boot exceptions, interrupted deployment and a revoked release. - -Cheap artifact checks validate all manifest URLs, response status/MIME, -worker URL/scope, script syntax and hashes before browser runs. Checks run for -Classic and ATProto. Chromium plus WebKit cover browser-engine differences; -installed iOS/Android smoke checks remain necessary. A certificate-bypassed -test does not prove production certificate trust. - -## Runtime detection and production feedback - -Use explicit states: current, checking, downloading, ready, check failed, -installation failed, update stalled, applying, recovery needed. - -"Update stalled" requires evidence: an eligible newer target from successful -network probes plus repeated checks without progress. Do not infer it merely -from no waiting worker. Pause timers while backgrounded/offline, use bounded -backoff, and retain the last failure phase and sanitized failed asset path. - -After activation, record a boot attempt and require an app-ready checkpoint. -Distinguish code/asset failures from offline sync, permission loss or a slow -device. Bound automatic repair attempts per release/session; no reload loops. - -Report release IDs, edition, browser family, phase and failure category to our -own endpoint. Never include note bodies, record values, credentials or OAuth -queries. Aggregate installation/boot/recovery failures by release so an operator -can halt a rollout or revoke a build before every user is affected. Telemetry -must not be required for editing, and cannot report if all relevant code fails -to run; server smoke probes and an independent recovery path cover that gap. - -## Deferred: recovery independent of the normal app - -This is a possible later extension, not a prerequisite for the first release -gate or update-health work. Start with retries, observable failures, retained -assets and deployment rollback/forward repair. Decide whether an independent -surface is needed based on failures those mechanisms cannot recover from. - -Ship a small, stable recovery surface that does not import the main editor or -its broken chunk graph. Protect its route and the release endpoint from worker -caching/routing in the protocol and test that boundary. Keeping an escape route -outside the worker's scope provides a stronger boundary than relying only on -its fetch handler; adopting `/app/` scope would be a separate routing decision. - -Offer a normal retry first. With user approval, a bounded worker-script -re-fetch/re-registration can stage a known-good candidate in the same scope. -Only targeted app-asset caches may be rebuilt. Notes/outbox IndexedDB, -credentials and preferences are outside the repair boundary. Never use the -existing reset-all-local-state shortcut as update recovery. - -If the editor is working, flush before recovery. If it is not, preserve durable -data and provide an export path that does not require the failed app bundle. -Switching back to older code is permitted only when that code can read the -current persistent-data schema; otherwise choose a compatible forward repair. -Retain old assets and keep schema changes backward-compatible across the -declared recovery window. - -These hooks must ship in a good release before they can protect future users. -They cannot retroactively make an arbitrary previously broken worker cooperate. -The current mobile instance still needs its own recovery; this proposal is the -repeatable prevention/recovery system rather than another deployment patch. - -## Proposed vertical slices - -1. **Real-artifact upgrade gate.** Turn the current persistent-profile test into - mandatory A-to-B-to-C acceptance with saved-data assertions and a deliberately - failed candidate followed by repair, across both editions and engines. -2. **Observable updates.** Release descriptor, worker status round-trip and an - in-app summary that identifies the phase and offered/running/waiting versions. - Demonstrate a stalled update becoming visible instead of silently disappearing. -3. **Deployment baseline.** Atomic publication of completed artifacts, retained - immutable assets and smoke checks of the actually served version/headers. -4. **Controlled rollout.** App-ready health reports, release revocation and - compatible rollback/forward-repair decisions driven by production feedback. -5. **Deferred independent recovery.** Add only if demonstrated failure classes - require a path that does not depend on the normal app bundle. - -Start with slice 1. Agree the recovery/activation and routing decisions before -building a larger updater abstraction. +# PWA release robustness: bounded contract + +Status: pared down after product reassessment. Keep the safe-update core and +real-artifact acceptance tests, not a release-management or recovery platform. + +## Product contract + +- A running editor does not switch releases merely because one is available. + Download and validate the complete candidate first, then offer **Update**. +- One explicit approval authorizes all open Reams tabs to switch. Flush local + writes before a tab reloads; a failed flush leaves its buffer intact. +- Background tabs discover that same approval locally on resume. No additional + approval is needed, and a fully staged release can load without the network. +- A bare controller change is not approval. Same-build claims must not reload + the editor. Normal app-shell refreshes, including a root query string, stay on + the controlling release while a candidate is waiting. +- Browser lifecycle activation when no old clients remain is distinct from + replacing an open editor. No claim is made that the app can override all + browser hard-refresh, storage-eviction or lifecycle behavior. +- Never clear notes, outbox IndexedDB, credentials or preferences as an update + action. Unsaved keystrokes that never became durable cannot be guaranteed. +- **No telemetry of any kind.** No health endpoint, reporting, cohorts, analytics + or diagnostic histories. Local test reports are explicit operator outputs, + not production-user monitoring. +- No update-diagnostics settings panel, server-offer polling, stalled-state + framework, custom publisher or general repair/rollback machinery. + +## One complete offline release + +Reams is a local-first SPA. Opening a note is not fetching an HTML note page. +The current Vite build is code-split, but every emitted application chunk, +stylesheet, font and WASM asset belongs in the complete precache manifest. +Physical JavaScript splitting must not create a need to fetch feature code +from the origin after a successful installation. + +Required-asset installation validates status, MIME and exact bytes. An incomplete +or corrupt candidate fails installation and leaves the active release usable. +The running page and worker share a cache-derived release identity; the bounded +worker status message exists for update correctness, not a user-facing dashboard. + +Once a new release activates, retain only its application cache. Do not search +older caches or an old-chunk server archive for obsolete application code. +Unknown application chunks fail locally rather than falling through to network; +an already-approved old page is notified to flush and switch. A missing current +cache entry may only use network bytes matching that release's own manifest, +never mix in a later shell. + +OAuth callbacks, API/integration responses and user data remain outside the +application asset cache. Serving the static cached shell for root navigation +does not cache query values or an OAuth response. + +## Skyreader: reference, not policy + +Rechecked `disnet/skyreader` at +`4528ffd9e666c7e2ae2a8b53b2d5148993442540`: + +- Workbox installs a complete revisioned precache. +- Its worker calls `skipWaiting()` during installation and claims clients. Its + **Update** button reloads onto a worker that is already active; activation + alone does not replace a running page's JavaScript. +- A worker version round-trip prevents prompts for same-build mobile claims. +- Skyreader is also code-split. Its deployment retains old immutable assets for + 30 days in production, and its preload-error handler can automatically reload + once within five minutes. It also reports errors to its backend. + +Reams deliberately does **not** adopt automatic activation in open sessions, +old-chunk server retention, automatic preload-error reload or telemetry. +Complete precaching, correct identity checks and production-artifact tests are +the useful mechanisms to retain. + +Prior old-entry/lazy-asset fetch probes were synthetic retention checks. They +were not evidence that ordinary Reams note navigation needs old network chunks. +Replace that assumption with a real offline lazy-feature test. + +## Release gate + +Reams has not shipped yet. Use distinct completed bootstrap artifacts A/B/C +now; retain the actual first shipped artifact for future upgrade gates. Do not +call configuration-distinct bootstrap builds shipped-code migration evidence. + +One persistent browser profile and origin per edition/engine must prove: + +1. Install A, create a durable note and real preferences; seed applicable outbox + storage honestly and assert exact preservation, not just row counts. +2. Open and edit offline, exercise a genuinely lazy feature without an origin + asset request, and reopen the same profile after process restart. +3. Offer B without unapproved reload. Reject a required-asset 503 and wrong-byte + HTTP 200; restore the same completed B and recover without resetting storage. +4. Hold real debounce writes pending in separate notes in two tabs. Approve once, + load the completely cached B offline, and compare exact saved content. +5. Repeat B to C in the same profile, including an old tab that misses update + notifications and resumes without a network connection to the app origin. +6. Confirm normal refresh and root-query navigation cannot load a waiting + release before approval. Keep source/worker identities consistent. + +Run Classic and ATProto on Chromium and WebKit. Missing cells or prerequisites +fail the matrix. Playwright WebKit's defective offline-emulation flag is not +evidence against the app: exercise real origin transport failure and report that +control explicitly; Chromium additionally exercises its native offline flag. + +Actual installed iOS/Android behavior, trusted production TLS, and the chosen +hosting setup require separate checks. Credential storage seeding is not account +restore certification. Do not manufacture an authenticated claim to make this +gate green. + +## Deferred and out of scope + +No independent recovery surface, compatible-downgrade selector, release-health +collector, deployment orchestrator or default diagnostics UI. Reconsider a +specific failure only when it is reproduced through the real product and cannot +be handled by the bounded update flow. Adversarial review checks this contract; +it does not expand the product scope. diff --git a/docs/verification/pwa-production-baseline.md b/docs/verification/pwa-production-baseline.md index f211b810..3e8f1e89 100644 --- a/docs/verification/pwa-production-baseline.md +++ b/docs/verification/pwa-production-baseline.md @@ -1,231 +1,116 @@ -# PWA real-artifact baseline (fixture-equivalent) - -Reams has never been deployed. This check therefore uses three separately -completed, immutable edition build directories as explicitly labeled bootstrap -artifacts A, B and C. It is fixture-equivalent evidence, not proof through a -production deployment proxy. Never manufacture B or C by editing an A bundle. - -Build each artifact into a different output directory, finalizing each build -normally (the `build:*:vite` scripts invoke `scripts/finalize-edition-build.mjs`). -Keep the directories unchanged during the run. The gate preflight reads the -finalized worker `APP_ASSETS`, confirms every manifest path exists and digests -its bytes (the checksum manifest is reported, never discarded), verifies the -worker's `APP_INTEGRITY` digests equal the actual final bytes of every manifest -path (index.html includes the injected `reams-cache` meta), recursively checks -that the manifest covers the complete actual asset graph (no built asset can -silently miss the offline cache), parses worker syntax, verifies the -`reams-cache` meta matches the worker cache identity, and checks that all three -releases share one edition with three distinct finalized cache identities and -entry scripts. The fixture server changes its active -directory pointer as one in-process assignment and serves old `/assets/` and -`/fonts/` unchanged (asset retention) from the deployed history only — never -from future, unreleased artifacts. The root path is served as `index.html` with -the matching MIME type. Unhashed shell/worker paths are `no-cache`, while -immutable assets receive long-lived immutable caching. A server-side bytes -mutation (HTTP 200, `no-store`) can fault one asset so the worker's per-asset -integrity check is exercised without touching the artifact directory. This -models atomic pointer switching and asset retention, but is not a real -deployment/proxy smoke check. The gate's readiness checkpoint is the active -markdown file plus the CM6 editor surface — never a text-length threshold, -which would assume a minimum sample-document size. - -Run Chromium from the Nix development shell: +# Focused PWA artifact gate + +The accepted scope is safe updates and a complete offline artifact, not release +diagnostics UI, telemetry, a custom publisher, an old-chunk archive or a recovery +platform. See the pared-down release contract in +`docs/plans/pwa-release-robustness.md`. + +## Inputs and commands + +Build A/B/C into separate directories and leave them unchanged during a run. +Reams has not shipped: these are bootstrap artifacts, not a claim that a shipped +historical release was tested. Each edition needs distinct entry scripts and +worker identities. Use valid loopback sync URLs, not browser-blocked ports. + +The release-root layout is: + +```text +A/classic A/atproto +B/classic B/atproto +C/classic C/atproto +``` + +Run the four-cell acceptance matrix: ```sh -nix develop --command sh -c 'PLAYWRIGHT_CHROMIUM_BIN="$(command -v chromium)" node scripts/pwa-baseline.mjs --gate --a /absolute/path/A --b /absolute/path/B --c /absolute/path/C --engine chromium --report /tmp/pwa-gate.json' +nix develop --command sh -c 'PLAYWRIGHT_CHROMIUM_BIN="$(command -v chromium)" npm run test:pwa:gate -- --a /path/A --b /path/B --c /path/C --report /tmp/pwa-matrix.json' ``` -The mandatory release gate is the full matrix (see below): +WebKit must have the matching browser and Linux prerequisites. The official +`mcr.microsoft.com/playwright:v1.63.0-noble` container provides those for +Playwright 1.63.0. Supply the repository, completed artifact roots and an output +directory to it; point `PLAYWRIGHT_CHROMIUM_BIN` at its bundled Chromium. +Missing artifacts, engines or reports are failures, not skipped passes. + +For a single cell: ```sh -nix develop --command sh -c 'PLAYWRIGHT_CHROMIUM_BIN="$(command -v chromium)" npm run test:pwa:gate -- --a /absolute/path/rootA --b /absolute/path/rootB --c /absolute/path/rootC --report /tmp/pwa-gate-matrix.json' +node scripts/pwa-baseline.mjs --gate --a /path/A/classic --b /path/B/classic --c /path/C/classic --engine chromium --report /tmp/pwa-cell.json ``` -Select WebKit with `--engine webkit`; a missing WebKit executable or system -library fails the run (the launch error is recorded and the gate exits -non-zero) rather than being skipped green. Gate mode requires all three -artifacts and rejects synthetic B and diagnostic mode. Do not use -`--diagnostic` for acceptance. `--notes N` can add fixture notes to the legacy -baseline journey; it does not seed an ATProto publication job. Classic has no -publication outbox, so no Classic outbox claim is made. In matrix mode -`--engine` is irrelevant: the matrix always runs both engines, so the -single-cell default is documented, not enforced. - -## Gate journey (slice 1) - -The gate runs one browser via `launchPersistentContext` against a temporary -profile directory and a single origin. It closes the context and reopens the -**same** profile directory for restart checkpoints, so IndexedDB, localStorage, -service-worker registrations and caches genuinely survive a browser restart. - -1. **Fresh profile, offline editing, restart.** Install A, edit a note online - and offline, then close the persistent context and reopen the same profile - directory. The note (root/path/canonical/editor), the loaded release meta - and the persisted preference must all survive the restart. -2. **Deterministic failed installs and same-bytes repair.** With two tabs open - on A, the server pointer switches to B and deterministically faults B's - install twice: first a 503 for one of B's required lazy JS chunks, then a - server-side bytes mutation (HTTP 200) for a different B asset that fails the - worker's per-asset integrity check. Both faults are recorded as expected - failures. The gate observes the `updatefound` → `installing` → - `statechange` lifecycle with a bounded promise (no fixed wait) and asserts - the installing worker becomes `redundant`, no `waiting` worker appears, the - partial B cache is cleaned, the active A worker is unchanged (its - `REAMS_STATUS` cache identity is still A), the page does not reload (an - in-memory sentinel survives), and the note stays editable. Restoring the - exact B bytes and calling `registration.update()` must make B `waiting` - without reloading the page. -3. **Two-tab offline consent for B with a deterministic pending write.** The - consent marker is typed immediately before clicking **Update** (not before - the long asset stage) **without** the harness flush helper; the consent - path's own flush must save it. The pending write is made deterministic, not - "type and hope the debounce has not saved": test-only page instrumentation - holds the writer's real 100ms debounce flush timers (the delay from - `scheduleFlush` in `src/sync/text-first-sync-controller.ts`), and the gate - immediately pre-consent asserts — through the real registered - `documentSyncCallbacks` surface and the document manager's writer progress — - that both writes are genuinely pending (`durableRevision < - materializedRevision`; `vault.read`/`readMarkdown` return the materialized - canonical which already includes the marker, so durability is only visible - through progress) and that a real debounce timer was held. No fake writes, - no hardcoded success. Each tab edits a **different** note: an independent - secondary note is created with the vault API and opened through the - workspace API, so the consent flush is exercised across two documents - without same-doc cross-tab concurrency conflicts, and the pending secondary - edit must survive the approval. B is staged while online, then the browser - is set offline and the server disconnected before approval. Unapproved - staging must not reload the page: an in-memory sentinel and the editor - buffer both survive until the click. Clicking the real **Update** button - approves the release; the worker persists the approved marker before - `skipWaiting`, activates, and broadcasts so **both** tabs flush and reload. - Both tabs must load B completely cached with their exact saved notes — - canonical and editor content must equal the editor content captured before - consent (marker substring checks alone are too weak to catch dropped - content) — and the active worker's `REAMS_STATUS` must report the approved - release. The page `reams-cache` meta proves which release each tab loaded. -4. **Restart after B.** Reopen the same profile directory; B must still be the - loaded release with the same note and preference. -5. **B→C with a missed-broadcast/offline resume.** Return online, open two tabs - on B, repeat the deterministic marker-before-consent pattern (each tab on a - different note), and prove both tabs load C completely cached with their - saved notes. Both engines use a clearly labeled notification-loss simulation: - the second tab's service-worker messages, controller-change notifications and - resume events are discarded while suspended, never replayed. This proves - resume-time discovery independently of queued activation notifications; it - does not claim to emulate an OS killing or suspending an installed PWA. The worker - still really activates and the profile really persists. After the initiator - approves C, the suspended tab is resumed offline and `focus`/`pageshow` are - dispatched; the app's `check()` runs `queryStatusOnEvents()`, discovers the - locally approved C release, flushes its preserved buffer and reloads. The - suspended tab must not have reloaded while suspended, and its preserved - secondary-note edit must survive the resume reload. Restart again and - confirm C, the note and the preference survive. -6. **Persisted preference.** The preference is written under the real - localStorage key owned by the Sync settings section - (`reams:sync-server-url` from `src/sync/sync-server-config.ts`); the value is - a transport-blocked `ws://` URL so persisting it never opens a socket. Exact - readback is asserted after B, after C, after each restart and while offline. -7. **ATProto outbox storage seed.** For the `atproto` edition, one valid real - job is seeded **before any update** — at the first A page, before the - offline and restart phases — so update preservation is actually proven. The - job is captured from the live note (real document ID, real markdown, real - heads) and written directly into IndexedDB - (`reams-lichen-new-note-outbox-v1`, `jobs` store with the - `by-owner-document` unique index), shaped exactly like the current - `LichenNewNoteJob` contract. Publication is unavailable (no account, no - network), so the gate honestly marks the storage as seeded. The exact row is - asserted after the offline reopen, after the A restart, after B, after the B - restart, after C, and after the C restart. The `classic` edition has no - publication outbox, so the evidence is recorded as not-applicable. -8. **Atomic pointer retention.** After the pointer has moved to C, A's and B's - entry assets must still be served with immutable caching. The server - fallback only consults the deployed history (never future, unreleased - artifacts), and a real request for an A-only asset — made through the fetch - API with a query string so the service worker provably does not intercept it - — must be served from the retained A directory and recorded against release - A. In addition, **Node-side probes** fetch the retained A entry, B entry and - A-only asset directly from the in-process fixture server (bypassing the - service worker entirely) and verify the served bytes match the artifact - checksum **exactly** and carry immutable caching headers. - -No credentials are fabricated. A dummy OAuth session would trigger account -restore/login flows, so credential preservation across updates is honestly -recorded as **not proven** by this gate rather than seeded. The ATProto outbox -seed uses a clearly-test DID identifier, not a credential. - -## Release gate matrix (`--gate --matrix`) - -`npm run test:pwa:gate` runs `node scripts/pwa-baseline.mjs --gate --matrix`: -the mandatory release gate requires the entire **Classic/ATProto × -Chromium/WebKit** matrix. A single-cell `--gate --engine` run can pass Chromium -while WebKit is missing or broken, so acceptance is only granted by the matrix. - -- `--a`, `--b` and `--c` are **release roots**, each containing `classic/` and - `atproto/` subdirectories with the three immutable edition build artifacts. - Cell artifacts are `/classic` and `/atproto`. (Current main - artifacts are separate per-edition directories such as - `/tmp/opencode/reams-pwa-final-classic-*`, not yet under a parent release - root; the matrix expects the release-root structure.) -- `--report` is the **summary path**. The main process spawns four single-cell - `--gate` children of the same script sequentially — one per edition × engine — - each with its own report (`.classic.chromium.json`, - `.classic.webkit.json`, `.atproto.chromium.json`, - `.atproto.webkit.json`) — then summarizes artifact/edition/engine - cell pass/fail into the summary path. -- The matrix **fails if ANY cell is missing, fails preflight, or fails in the - browser**. There are no skips and no fake greens: every cell must actually - pass. A missing release subdirectory fails the matrix before any browser is - launched; a missing `PLAYWRIGHT_CHROMIUM_BIN` fails the Chromium cells. -- The direct single-cell CLI is preserved: - `node scripts/pwa-baseline.mjs --gate --a ... --b ... --c ... --engine chromium|webkit`. - -### Current status: WebKit blocker — matrix is NOT all green - -Chromium passes both editions, but WebKit does not in the verified Linux test -environment. With Playwright 1.60.0's official `noble` container (WebKit 26.4), -offline navigation fails with `WebKit encountered an internal error`. A tiny -standalone worker, independent of Reams, also returned no `cache.keys()` entries -and no `cache.match()` response after an awaited `cache.put()`. This does not -establish a failure of installed Safari/iOS; that needs separate device evidence. -The -matrix therefore **must remain unaccepted** until the WebKit blocker is -resolved; the summary records the failing WebKit cells rather than reporting -all green. - -### Verification checkpoint (2026-10-01) - -- Targeted updater, Header and mount tests: 47 passing; worker/finalizer tests: - 24 passing. TypeScript checking passed. -- Both editions passed the full single-cell Chromium journeys with the Nix - Chromium 136 browser. They also passed Chromium 148 in the official - Playwright 1.60.0 container's four-cell matrix. -- The matrix correctly exited nonzero: both WebKit 26.4 cells failed the first - offline navigation. It did not skip or relabel those failures as successful. -- The existing synthetic lifecycle baseline and installed-profile HTTPS/cache- - bypass probe passed after refreshing their page/worker/integrity identities. - The HTTPS probe bypasses certificate verification, not proof of trusted TLS. -- Reports are retained under `/tmp/opencode/reams-pwa-matrix.json` and its four - edition/engine cell reports; Nix single-cell reports are under - `/tmp/opencode/reams-pwa-final-{classic,atproto}-chromium.json`. -- These are three separately finalized bootstrap builds with distinct build-time - sync-server defaults, not shipped release upgrades or code-migration proof. -- No live deployment was changed. Production hosting, credential/session - continuity, actual installed-device suspension, update-health telemetry and - controlled rollout/revocation remain unverified or unimplemented follow-ups. - -## Evidence boundary - -The legacy journey remains the offline-edit plus single A-to-B opt-in check -(`--a` with optional `--b`/`--synthetic-b`). Gate mode additionally publishes -per-requirement runtime evidence (`result.evidence`), per-check outputs -(`result.checks`), the artifact checksum manifest (`result.artifacts`), the -recorded expected failures, and the selected engine/browser version. It exits -non-zero and reports `pass: false` until every acceptance requirement has -positive evidence; preflight success and legacy A-to-B success alone cannot -produce a gate pass. The deterministic pending-write-at-consent step and the -missed-broadcast/offline-resume step are mandatory evidence: the gate cannot -pass if either is unproven. Matrix mode publishes a summary report with one -cell per edition × engine; the matrix `pass` is the conjunction of every cell's -`pass`, so a single failing cell (missing artifact, preflight, or browser -failure) keeps the matrix unaccepted. Browser/fuzzer campaigns are run at the -project checkpoint, not as part of implementation unit verification. +Synthetic B and diagnostic mode remain lifecycle/debug aids and cannot satisfy +the real-artifact gate. + +## What the gate proves + +- Complete finalized worker manifest, final-file integrity and matching HTML + identity. Required files are checked before browser launch. +- A durable note and a real stored preference survive offline editing, reopen + and process restart in the same profile and origin. +- ATProto's pending outbox row is seeded through storage and compared exactly + after updates/restarts. This is storage preservation, not normal enqueue or + authenticated publication certification. Classic has no such outbox. +- A required-asset 503 and an HTTP 200 with wrong bytes reject B installation; + A remains usable. Restoring the same B bytes recovers without site-data reset. +- Real held debounce writes in independent notes are flushed by one approved + update. Both tabs load the complete B artifact offline; B→C repeats in the + same persistent profile. +- A tab whose update notifications are deliberately lost discovers the same + prior approval locally on resume. The simulation does not claim OS-level + installed-PWA suspension behavior. +- A real APL fenced-code feature loads its lazy JavaScript through the worker + with HTTP 200 while the origin is unavailable. The fixture must observe zero + origin `/assets/` requests during that action. This tests an actual feature, + not a synthetic request for an obsolete entry script. + +The fixture serves only the currently deployed artifact. No retained server +chunks, archive fallback or artificial old-asset probes are involved. Once an +approved worker takes control, only its app cache remains. Obsolete application +chunks return a local failure instead of falling through to the network. + +## Network and browser boundaries + +Every engine gets real origin transport failure: sockets are destroyed, and a +unique bounded Node request must fail and appear in the disconnected request +log. Chromium additionally uses the native browser offline flag, restored after +context restart. Each cell reports which control was used. + +Playwright WebKit offline emulation rejects worker-served responses upstream +([#42775](https://github.com/microsoft/playwright/issues/42775)); it is not used +as a substitute for real transport failure. The persistent-cache issue fixed +in Playwright 1.62 ([#41618](https://github.com/microsoft/playwright/issues/41618)) +is why the runner dependency is at least 1.63. + +Known request cancellations are accepted only for deliberate driver teardown or +actual, confirmed document replacement. They remain recorded. Other script or +asset failures fail the run. A root query is not an upgrade escape hatch: the +controlled shell stays on its active release until Update is approved. + +The optional installed-profile HTTPS probe uses a self-signed certificate and +explicit bypasses; it does not prove production certificate trust. Actual +iOS/Android installation, production hosting and authenticated account restore +remain separate evidence boundaries. No telemetry is collected by the app. + +## Verification record + +- The pre-cutback core matrix passed both editions on Chromium 153 and WebKit + 26.6; its local report is + `/tmp/opencode/reams-pwa-matrix-163-final-approved.json`. +- The narrowed matrix passed all four cells against finished B/C artifacts, + without retained old caches or a server archive. Its accepted report is + `/tmp/opencode/reams-pwa-pared-final.json`, with matching cell reports. Every + cell exercised the real offline APL feature and observed zero origin asset + requests for it. +- Targeted core tests passed: 51 updater/settings/header/mount tests, 32 worker + and finalizer tests, plus TypeScript checking. The installed-profile HTTPS + probe also passed ordinary-refresh and root-query pinning before approval, + then an explicit update with preserved note/device state. Certificate bypass + was necessary for the disposable fixture and is not production TLS evidence. +- Final commit review approved the bounded changes after two regression fixes: + explicit apply owns the tab's save barrier exclusively, and injected-fault + exceptions apply only to the individual fault-time request, never later + failures sharing its filename. The rebuilt four-cell matrix passed again; + its retained report is `/tmp/opencode/reams-pwa-commit.json`. +- The original synthetic entry/lazy-asset retention probes did not establish + that ordinary Reams note navigation requests old code from the origin. They + have been removed, rather than used to justify deployment machinery. diff --git a/package-lock.json b/package-lock.json index 24b58d01..a291e8a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,7 +67,7 @@ "jsdom": "^28.1.0", "knip": "^6.11.0", "oxlint": "^1.75.0", - "playwright": "^1.55.0", + "playwright": "^1.63.0", "ts-morph": "^28.0.0", "typescript": "~5.6.2", "vite": "^6.0.1", @@ -7862,50 +7862,32 @@ } }, "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=20" } }, "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=20" } }, "node_modules/postcss": { diff --git a/package.json b/package.json index 646e20b1..25afe962 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "jsdom": "^28.1.0", "knip": "^6.11.0", "oxlint": "^1.75.0", - "playwright": "^1.55.0", + "playwright": "^1.63.0", "ts-morph": "^28.0.0", "typescript": "~5.6.2", "vite": "^6.0.1", diff --git a/public/service-worker.js b/public/service-worker.js index 674b64e8..1fd1132b 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -8,16 +8,17 @@ const APP_PATHS = new Set(APP_ASSETS); // Page↔worker update protocol version. Bump when the message contract changes // so a page can tell an incompatible worker apart from a compatible one. const PROTOCOL_VERSION = 2; -// Non-fetchable metadata keys: these live in the release's own cache and are +// Non-fetchable safety metadata: these live in the release's own cache and are // never served by the fetch handler below. const APPROVED_KEY = '__reams_approved_release__'; const INSTALLED_KEY = '__reams_installed_release__'; -const ACTIVATED_KEY = '__reams_activated_release__'; // Critical MIME types: an asset that comes back with the wrong content type is // rejected even when its bytes hash correctly. JS is served as either of the // two standards-valid JavaScript MIME types (text/javascript or // application/javascript); anything else — including text/html — is rejected. +// JSON accepts the standard Web Manifest MIME (application/manifest+json) in +// addition to application/json so /manifest.json can be precached. const CRITICAL_MIME = { '.html': ['text/html'], '.js': ['text/javascript', 'application/javascript'], @@ -40,12 +41,19 @@ async function digestBase64(bytes) { } function validateMime(path, response) { - const type = (response.headers.get('content-type') ?? '').toLowerCase(); + const raw = response.headers.get('content-type') ?? ''; + // Compare only the media type, before any parameters such as "; charset=utf-8", + // and match it exactly. A prefix match would accept lookalikes like + // "text/javascript-bad" or "text/htmlx", which install cleanly but fail when + // the browser boots — defeating the required-asset gate. + const type = raw.split(';', 1)[0].trim().toLowerCase(); const extension = path.slice(path.lastIndexOf('.')); - const expected = CRITICAL_MIME[extension]; + const expected = path === '/manifest.json' + ? ['application/json', 'application/manifest+json'] + : CRITICAL_MIME[extension]; if (!expected) return; // Non-critical extensions are not MIME-checked. - if (!expected.some(prefix => type.startsWith(prefix))) { - throw new Error(`install: ${path} has content-type "${type}", expected ${expected.join(' or ')}`); + if (!expected.includes(type)) { + throw new Error(`install: ${path} has content-type "${raw}", expected ${expected.join(' or ')}`); } } @@ -147,40 +155,12 @@ self.addEventListener('message', (event) => { self.addEventListener('activate', (event) => { event.waitUntil((async () => { const names = await caches.keys(); - // Keep the preceding release for other open tabs still running its JS. - // Version-numbered caches predate the restricted manifest and may contain - // OAuth or integration responses. Never retain one as the previous release. - const legacy = names.filter(name => name.startsWith(CACHE_PREFIX) && /-v\d+$/.test(name)); - const older = names.filter(name => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME && !legacy.includes(name)); - // Only caches that successfully activated are real releases. A failed or - // waiting candidate (no activation marker) is never treated as the previous - // release, so the actual preceding release is retained. - const activated = []; - const superseded = []; - for (const name of older) { - const cache = await caches.open(name); - const marker = await cache.match(ACTIVATED_KEY); - if (marker) { - activated.push(name); - } else { - // A successful install that never activated (superseded by a newer - // release before approval) is dead weight once a newer release is - // activating. Prune it; a mid-install cache has no install marker and - // is left for its own install to finish or fail. - const installed = await cache.match(INSTALLED_KEY); - if (installed) superseded.push(name); - } - } - // Bound retention to current + previous: delete every activated release - // older than the most recent one, plus superseded waiting candidates. - await Promise.all([...legacy, ...activated.slice(0, -1), ...superseded] + // Once the approved release takes control, its complete offline artifact + // is the only app-asset cache. Old running pages must switch, not fetch an + // obsolete build from the origin or a retained archive. + await Promise.all(names.filter(name => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME) .map(name => caches.delete(name))); - // Persist this release's activation marker so future pruning recognizes it - // as a real release rather than a failed or waiting candidate. const cache = await caches.open(CACHE_NAME); - await cache.put(ACTIVATED_KEY, new Response(JSON.stringify({ - cacheName: CACHE_NAME, edition: EDITION, protocol: PROTOCOL_VERSION, activated: true, - }), { headers: { 'Content-Type': 'application/json' } })); await self.clients.claim(); // After claiming, broadcast an explicit approved-release status so every // relevant app client can flush and reload independently. Pages that miss @@ -208,9 +188,13 @@ self.addEventListener('fetch', (event) => { if (event.request.method !== 'GET') return; const url = new URL(event.request.url); if (url.origin !== self.location.origin) return; - // OAuth transactions, live integration reads, query-bearing and encoded - // URLs are never eligible for the offline asset cache. - if (url.search || url.pathname.includes('%') || url.pathname.startsWith('/oauth') || + // App-shell navigation stays on the controlling release, even with a query. + // Only the static /index.html response is used; query values are never cached. + const shellNavigation = event.request.mode === 'navigate' && + (url.pathname === '/' || url.pathname === '/index.html'); + // OAuth and integration responses remain completely outside asset caching. + if ((!shellNavigation && url.search) || url.pathname.includes('%') || + url.pathname.startsWith('/oauth') || /^\/(?:api|xrpc)(?:\/|$)/.test(url.pathname) || /^\/\.well-known(?:\/|$)/.test(url.pathname)) return; if (event.request.mode === 'navigate' && url.pathname !== '/' && url.pathname !== '/index.html') return; @@ -218,23 +202,29 @@ self.addEventListener('fetch', (event) => { // Document navigations use this release's HTML, never a future release's // network response. Other requests are cached only if in the build manifest. const path = event.request.mode === 'navigate' ? '/index.html' : url.pathname; - // Metadata keys (approval/install/activation markers) are never fetchable. - if (path === `/${APPROVED_KEY}` || path === `/${INSTALLED_KEY}` || path === `/${ACTIVATED_KEY}`) return; + // Approval/install markers are never fetchable. + if (path === `/${APPROVED_KEY}` || path === `/${INSTALLED_KEY}`) return; if (!APP_PATHS.has(path)) { if (!path.startsWith('/assets/') && !path.startsWith('/fonts/')) return; event.respondWith((async () => { - const names = await caches.keys(); - for (const name of names.filter(key => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)) { - const response = await (await caches.open(name)).match(event.request); - if (response) return response; + // Do not reach the network for obsolete application chunks. If this tab + // belongs to an already-approved older page, ask its existing updater to + // flush and switch to the complete cached release. + const marker = await (await caches.open(CACHE_NAME)).match(APPROVED_KEY); + if (marker && event.clientId) { + const client = await self.clients.get(event.clientId); + client?.postMessage({ type: 'REAMS_APPROVED_RELEASE', cacheName: CACHE_NAME, + edition: EDITION, protocol: PROTOCOL_VERSION }); } - return fetch(event.request); + return new Response('Application asset belongs to another release. Update the app.', { status: 410 }); })()); return; } event.respondWith(caches.open(CACHE_NAME).then(async cache => { const cached = await cache.match(path); if (cached) return cached; - return fetch(event.request); + // A missing cache entry must not mix this release's shell with whatever + // happens to be deployed now. Only the exact manifest bytes may be used. + return fetchValidated(path); })); -}); \ No newline at end of file +}); diff --git a/scripts/finalize-edition-build.mjs b/scripts/finalize-edition-build.mjs index a548c28e..3c72c061 100644 --- a/scripts/finalize-edition-build.mjs +++ b/scripts/finalize-edition-build.mjs @@ -56,4 +56,4 @@ for (const [token, value] of Object.entries(replacements)) { } await writeFile(serviceWorkerPath, generated) await writeFile(indexPath, finalHtml) -if (edition === 'classic') await rm(path.join(output, 'oauth-callback.html'), { force: true }) \ No newline at end of file +if (edition === 'classic') await rm(path.join(output, 'oauth-callback.html'), { force: true }) diff --git a/scripts/pwa-baseline.mjs b/scripts/pwa-baseline.mjs index 80459cf9..5e943dc0 100644 --- a/scripts/pwa-baseline.mjs +++ b/scripts/pwa-baseline.mjs @@ -64,7 +64,9 @@ const sha256Base64 = bytes => createHash('sha256').update(bytes).digest('base64' * Checksums are computed from the actual bytes and returned (never discarded). * The worker's APP_INTEGRITY digests must equal the actual final bytes of every * manifest path, and the manifest must cover the complete actual asset graph - * (recursively) so no built asset can silently miss the offline cache. */ + * (recursively) so no built asset can silently miss the offline cache. The + * baseline is a pure private inspection: it needs no release descriptor + * metadata and no worker manifest-identity constant. */ async function inspectArtifact(artifact) { const worker = await readFile(resolve(artifact, 'service-worker.js'), 'utf8') const html = await readFile(resolve(artifact, 'index.html'), 'utf8') @@ -196,16 +198,18 @@ async function prepare(input) { } /** Fixture server: one in-process pointer assignment switches the active - * completed directory atomically. Old /assets/ and /fonts/ remain served - * (asset retention) from the deployed history only — never from future, - * unreleased artifacts. The root path is served as index.html (with the - * matching MIME type). Shell/worker paths are no-cache, hashed assets - * immutable. A `state.mutate` entry serves mutated bytes (HTTP 200, no-store) - * for one path so the worker's per-asset integrity check can be faulted - * without touching the artifact directory. */ + * completed directory atomically. Only the deployed artifact is ever served — + * there is no older-release retention or history fallback in the fixture. The + * root path is served as index.html (with the matching MIME type). Shell/worker + * paths are no-cache, hashed assets immutable. A `state.mutate` entry serves + * mutated bytes (HTTP 200, no-store) for one path so the worker's per-asset + * integrity check can be faulted without touching the artifact directory. When + * offline, the transport is failed by destroying the socket (a real origin + * outage) rather than returning an HTTP error. */ function createFixtureServer(dirs, state) { return createServer(async (req, res) => { - const name = new URL(req.url, 'http://localhost').pathname + const requestUrl = new URL(req.url, 'http://localhost') + const name = requestUrl.pathname const decoded = decodeURIComponent(name.slice(1)) const servedName = decoded === '' ? 'index.html' : decoded let status = 200 @@ -214,7 +218,13 @@ function createFixtureServer(dirs, state) { let expected = false let cacheControl = null if (state.disconnected) { - status = 503 + // Fail the transport, rather than returning an HTTP error. WebKit's + // Playwright offline flag currently kills worker-served responses too + // (upstream #42775), so every engine exercises a real origin outage. + state.requests.push({ release, path: name, status: 0, offline: true, expected: false, + outageProbe: requestUrl.searchParams.get('gate-network-control') }) + req.socket.destroy() + return } else if (state.missing && state.missing.path === name) { status = 503 expected = true @@ -231,16 +241,14 @@ function createFixtureServer(dirs, state) { cacheControl = 'no-store' } } else { - const isAsset = name.startsWith('/assets/') || name.startsWith('/fonts/') - const candidates = isAsset ? [state.deployed, ...state.deployedHistory.filter(r => r !== state.deployed)] : [state.deployed] - for (const candidate of candidates) { - const file = resolve(dirs[candidate], servedName) - if (relative(dirs[candidate], file).startsWith('..')) { status = 403; break } + const file = resolve(dirs[state.deployed], servedName) + if (relative(dirs[state.deployed], file).startsWith('..')) { + status = 403 + } else { try { body = await readFile(file) - release = candidate + release = state.deployed status = 200 - break } catch { status = 404 } } } @@ -265,8 +273,8 @@ const waitFor = async (predicate, timeoutMs, intervalMs = 100) => { /** Full practical slice-1 gate: persistent-profile restart, deterministic * failed-install (503 + integrity fault) and same-bytes repair, two-tab * offline consent for B and C, persisted preference readback, ATProto outbox - * storage seed/readback across every update and restart, and atomic pointer - * retention proven by a real server request that bypasses the service worker. */ + * storage seed/readback across every update and restart, and one approval + * reloading every tab offline. */ async function runGate(input, prepared) { // prepared exposes inspected (with per-artifact entry scripts); entryC is // read from inspected.C.entry, never from a missing prepared field. @@ -283,14 +291,14 @@ async function runGate(input, prepared) { const lazyB2 = infoB.lazyJs.find(p => p !== lazyB) ?? lazyB // Real localStorage key owned by the Sync settings section // (SYNC_SERVER_URL_KEY in src/sync/sync-server-config.ts). The value is a - // transport-blocked ws:// URL so persisting it never opens a socket. + // valid loopback URL with no fixture sync service, not a browser-blocked port. const PREF_KEY = 'reams:sync-server-url' - const PREF_VALUE = 'ws://10.0.0.1:1' + const PREF_VALUE = 'ws://127.0.0.1:65534' const result = { mode: 'fixture-equivalent-real-artifact-gate', fixtureNotes: input.notes, offline: null, update: null, failures: [], requests: [], timingsMs: {}, - expectedFailures: [], checks: {}, engine: input.engine, browserVersion: null, + expectedFailures: [], expectedTeardownCancellations: [], checks: {}, engine: input.engine, browserVersion: null, artifacts: Object.fromEntries(Object.entries(inspected).map(([label, info]) => [label, { cacheName: info.cache, edition: info.edition, entry: info.entry, workerSha256: info.workerSha, assetSha256: info.checksums, @@ -298,23 +306,41 @@ async function runGate(input, prepared) { evidence: { persistentProfileRestart: false, bToC: false, failedInstallAndSameBytesRepair: false, secondaryTabApprovalAndOfflineReload: false, deterministicPendingWriteAtConsent: false, missedBroadcastOfflineResume: false, persistedPreference: false, atprotoOutboxStorageSeedReadback: false, - atomicPointerRetention: false }, + offlineLazyFeature: false }, credentialPreservation: { status: 'not-proven', reason: 'No OAuth credential dummy was seeded: a stored session would trigger account restore/login flows. Credential preservation across updates is honestly not proven by this gate.' }, } const dirs = { A: input.a, B: input.b, C: input.c } - const state = { deployed: 'A', disconnected: false, missing: null, mutate: null, - deployedHistory: ['A'], requests: result.requests } - const deploy = release => { - state.deployed = release - if (!state.deployedHistory.includes(release)) state.deployedHistory.push(release) - } + const state = { deployed: 'A', disconnected: false, missing: null, mutate: null, requests: result.requests } + const deploy = release => { state.deployed = release } const server = createFixtureServer(dirs, state) const profileDir = await mkdtemp(resolve(tmpdir(), 'reams-pwa-profile-')) let context = null let browser = null let seedJob = null + const errors = [] + const deliberatelyClosingPages = new WeakSet() + const closePage = async p => { deliberatelyClosingPages.add(p); await p.close() } + const closeContext = async ctx => { + for (const p of ctx.pages()) deliberatelyClosingPages.add(p) + await ctx.close() + } + // Generation-class cancellation handling: a request that fails with a known + // cancellation error ('Load request cancelled' / 'net::ERR_ABORTED') is + // expected ONLY when it belonged to a document generation that a later + // navigation superseded, or when the driver deliberately closed its page. + // Every other request failure still fails the gate — nothing is hidden. + const pageGenerations = new WeakMap() + const requestGenerations = new WeakMap() + const injectedFaultRequests = new WeakSet() + const navigationBoundaries = new WeakMap() + const cancellationDetails = new WeakMap() + const expectedNavigationAbort = entry => { + const details = cancellationDetails.get(entry) + if (!details?.navigationStarted) return false + return details.generation < (pageGenerations.get(details.page) ?? 0) + } try { await new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', done) }) const url = `http://127.0.0.1:${server.address().port}` @@ -366,21 +392,68 @@ async function runGate(input, prepared) { } }) const launch = async () => { - const ctx = await engine.launchPersistentContext(profileDir, launchOptions) + const ctx = await engine.launchPersistentContext(profileDir, { ...launchOptions, + offline: input.engine === 'chromium' && state.disconnected }) await installSuspensionInstrumentation(ctx) + if (state.disconnected) await proveTransportOutage() return ctx } + // Unique, bounded Node-side control that proves the injected origin outage + // is real and worker-independent: the fixture server records the probe and + // destroys the socket, so the fetch must fail. + const proveTransportOutage = async () => { + const probe = crypto.randomUUID() + let failed = false + try { await fetch(`${url}/?gate-network-control=${probe}`, { signal: AbortSignal.timeout(5_000) }) } catch { failed = true } + const recorded = state.requests.some(request => request.outageProbe === probe && request.offline && request.status === 0) + if (!failed || !recorded) throw new Error('The worker-independent control did not prove the injected origin outage') + result.checks.transportOutageControls = (result.checks.transportOutageControls ?? 0) + 1 + } const setOffline = async offline => { state.disconnected = offline - await context.setOffline(offline) + // Playwright #42775: WebKit's emulation rejects even literal worker + // responses. All engines exercise actual origin transport failure; + // Chromium additionally exercises the browser connectivity flag. + if (input.engine === 'chromium') await context.setOffline(offline) + if (offline) await proveTransportOutage() } - const errors = [] + result.offlineControl = input.engine === 'webkit' ? 'real-origin-transport-outage' : 'real-origin-transport-outage-and-browser-offline' const attachErrors = ctx => { ctx.on('page', p => { + pageGenerations.set(p, 0) + p.on('framenavigated', frame => { + if (frame === p.mainFrame()) { + pageGenerations.set(p, (pageGenerations.get(p) ?? 0) + 1) + } + }) + p.on('request', r => { + requestGenerations.set(r, pageGenerations.get(p) ?? 0) + const path = new URL(r.url()).pathname + if (path === state.missing?.path || path === state.mutate?.path) injectedFaultRequests.add(r) + if (r.isNavigationRequest() && r.frame() === p.mainFrame()) { + const generations = navigationBoundaries.get(p) ?? new Set() + generations.add(pageGenerations.get(p) ?? 0) + navigationBoundaries.set(p, generations) + } + }) p.on('pageerror', e => errors.push({ path: null, message: e.message })) p.on('requestfailed', r => { const path = new URL(r.url()).pathname - if (path.startsWith('/assets/')) errors.push({ path, message: `${r.url()}: ${r.failure()?.errorText}` }) + if (!path.startsWith('/assets/')) return + const errorText = r.failure()?.errorText ?? '' + const knownCancellation = errorText === 'Load request cancelled' || errorText === 'net::ERR_ABORTED' + if (knownCancellation && deliberatelyClosingPages.has(p)) { + result.expectedTeardownCancellations.push({ path, message: `${r.url()}: ${errorText}`, + reason: 'driver-teardown' }) + return + } + const entry = { path, message: `${r.url()}: ${errorText}`, injectedFault: injectedFaultRequests.has(r) } + if (knownCancellation) { + const generation = requestGenerations.get(r) + cancellationDetails.set(entry, { page: p, generation, + navigationStarted: navigationBoundaries.get(p)?.has(generation) === true }) + } + errors.push(entry) }) }) } @@ -509,7 +582,7 @@ async function runGate(input, prepared) { }, path, { timeout: 15_000 }) } // Open a note by path through the workspace API and capture its state. Polls -// for the file so a restart's async file-index hydration cannot race the open. + // for the file so a restart's async file-index hydration cannot race the open. const openNote = async (p, path) => { await p.waitForFunction(path => { const app = window.__reamsApp @@ -601,7 +674,25 @@ async function runGate(input, prepared) { }) }, timeoutMs) } + // Terminal worker state of a completed install lifecycle, or null when the + // lifecycle timed out. Used to match actual B/C waiting synchronously + // through the bounded promise instead of an async waitForFunction poll. + const lifecycleTerminal = lifecycle => { + if (lifecycle.timedOut) return null + const terminal = [...lifecycle.events].reverse().find(event => event.type === 'statechange') + return terminal?.state ?? null + } const cachesNamed = p => p.evaluate(async () => (await caches.keys()).sort()) + const waitForCacheCleanup = async p => { + // Storage visibility is cross-process; observe cleanup rather than making + // a one-shot read immediately after the worker's terminal-state event. + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + if (!(await cachesNamed(p)).includes(cacheB)) return + await p.waitForTimeout(50) + } + throw new Error('Failed B cache was not cleaned within the cleanup deadline') + } // ATProto outbox: seed one valid real job from the LIVE note (real // documentId, real markdown, real heads) and read it back exactly. The job // is immutable, so the exact row must survive every update and restart. No @@ -675,7 +766,13 @@ async function runGate(input, prepared) { const first = await context.newPage() await open(first, 'gateFirstEditor') await first.evaluate(() => navigator.serviceWorker.ready) + // Record the actual driver reload boundary: WebKit may cancel an old + // worker request before emitting its new navigation-request event. + const firstBoundary = navigationBoundaries.get(first) ?? new Set() + firstBoundary.add(pageGenerations.get(first) ?? 0) + navigationBoundaries.set(first, firstBoundary) await first.reload({ waitUntil: 'domcontentloaded' }) + await waitMeta(first, cacheA) await first.waitForFunction(() => navigator.serviceWorker.controller !== null) await first.locator('.cm-content').waitFor() const onlineMarker = `pwa-gate-online-${Date.now()}` @@ -700,7 +797,7 @@ async function runGate(input, prepared) { result.checks.atprotoOutbox = { applicable: false, reason: 'Classic edition has no Lichen publication outbox.' } result.evidence.atprotoOutboxStorageSeedReadback = 'not-applicable' } - await first.close() + await closePage(first) state.disconnected = true await setOffline(true) @@ -710,23 +807,34 @@ async function runGate(input, prepared) { if (seedJob) await assertOutboxRow(offline, 'afterOffline') const offlineMarker = `pwa-gate-offline-${Date.now()}` await append(offline, offlineMarker) + // Exercise a genuine lazy feature, not a deliberate fetch of an obsolete + // entry script. CodeMirror loads the APL language chunk on first use. + const assetRequestsBefore = state.requests.filter(r => r.path.startsWith('/assets/')).length + const languageResponse = offline.waitForResponse(response => /\/assets\/apl-[^/]+\.js$/.test(new URL(response.url()).pathname), { timeout: 15_000 }) + await append(offline, '```apl\n1 + 2\n```') + const language = await languageResponse + const assetRequestsAfter = state.requests.filter(r => r.path.startsWith('/assets/')).length + if (language.status() !== 200 || !language.fromServiceWorker() || assetRequestsAfter !== assetRequestsBefore) { + throw new Error('The offline language feature fetched application code from the origin') + } + result.evidence.offlineLazyFeature = true const afterOfflineEdit = await note(offline) - await offline.close() + await closePage(offline) const again = await context.newPage() await open(again, 'gateSecondOfflineEditor') result.offline = sameNote(await note(again), original, afterOfflineEdit) - await again.close() + await closePage(again) await setOffline(false) state.disconnected = false if (!result.offline) throw new Error('Offline edit missing on second offline reopen') const prefProbe = await context.newPage() await open(prefProbe) result.checks.preferenceAfterOffline = await prefRead(prefProbe) === PREF_VALUE - await prefProbe.close() + await closePage(prefProbe) if (!result.checks.preferenceAfterOffline) throw new Error('Preference lost after offline reopen') // Restart checkpoint: close the persistent context, reopen the SAME profile directory. - await context.close() + await closeContext(context) context = await launch() browser = context.browser() attachErrors(context) @@ -764,6 +872,7 @@ async function runGate(input, prepared) { await observeInstallLifecycle(tab1, 15_000) await tab1.evaluate(() => navigator.serviceWorker.getRegistration().then(r => r.update())) const failedLifecycle = await tab1.evaluate(() => window.__pwaInstallLifecycle) + await waitForCacheCleanup(tab1) await waitFor(() => state.requests.some(r => r.path === lazyB && r.status === 503 && r.expected), 15_000) result.expectedFailures.push({ release: 'B', path: lazyB, status: 503, reason: 'deterministic missing required B lazy chunk' }) const failedReg = await registrationState(tab1) @@ -797,6 +906,7 @@ async function runGate(input, prepared) { await observeInstallLifecycle(tab1, 15_000) await tab1.evaluate(() => navigator.serviceWorker.getRegistration().then(r => r.update())) const integrityLifecycle = await tab1.evaluate(() => window.__pwaInstallLifecycle) + await waitForCacheCleanup(tab1) state.mutate = null result.expectedFailures.push({ release: 'B', path: lazyB2, status: 200, reason: 'deterministic mutated B asset bytes for the integrity fault' }) const integrityReg = await registrationState(tab1) @@ -817,10 +927,12 @@ async function runGate(input, prepared) { if (!result.checks.integrityFault.bCacheCleaned) throw new Error('Failed B cache was not cleaned after the integrity fault') if (result.checks.integrityFault.sentinel !== 'gate-no-reload-b') throw new Error('Page reloaded during the integrity fault') // 2c: restore exact B (same bytes) and prove the repair installs and waits - // without reloading the page. + // without reloading the page. The waiting state is matched synchronously + // through the bounded install-lifecycle promise, not an async poll. + await observeInstallLifecycle(tab1, 15_000) await tab1.evaluate(() => navigator.serviceWorker.getRegistration().then(r => r.update())) - await tab1.evaluate(async () => { window.__pwaGateRegistration = await navigator.serviceWorker.getRegistration() }) - await tab1.waitForFunction(() => window.__pwaGateRegistration.waiting?.state === 'installed', null, { timeout: 15_000 }) + const repairLifecycle = await tab1.evaluate(() => window.__pwaInstallLifecycle) + if (lifecycleTerminal(repairLifecycle) !== 'installed') throw new Error('B did not become waiting after restoring its asset') const activeRepair = await queryStatus(tab1, 'active') result.checks.sameBytesRepair = { waiting: (await registrationState(tab1)).waiting === 'installed', @@ -915,10 +1027,11 @@ async function runGate(input, prepared) { if (!result.checks.preferenceAfterB) throw new Error('Preference lost after B') result.evidence.secondaryTabApprovalAndOfflineReload = true - // ---- Phase 4: restart checkpoint after B ---- - await tab1.close() - await tab2.close() - await context.close() + // ---- Phase 4: restart checkpoint after B (offline: the app worker has + // precached the whole manifest, so B loads from cache before approval) ---- + await closePage(tab1) + await closePage(tab2) + await closeContext(context) context = await launch() browser = context.browser() attachErrors(context) @@ -941,14 +1054,20 @@ async function runGate(input, prepared) { await setOffline(false) const tab1c = await context.newPage() await open(tab1c, 'gateTab1B2') + // Layout preferences are shared across tabs and may restore the secondary + // note. C must edit the original primary note, not whichever tab restored. + await openNote(tab1c, primaryPath) const tab2c = await context.newPage() await open(tab2c, 'gateTab2B2') await tab1c.evaluate(() => { window.__pwaSentinel = 'gate-no-reload-c' }) await tab2c.evaluate(() => { window.__pwaSentinel = 'gate-no-reload-c' }) deploy('C') + // Match the actual C waiting state synchronously through the bounded + // install-lifecycle promise, not an async waitForFunction poll. + await observeInstallLifecycle(tab1c, 15_000) await tab1c.evaluate(() => navigator.serviceWorker.getRegistration().then(r => r.update())) - await tab1c.evaluate(async () => { window.__pwaGateRegistration = await navigator.serviceWorker.getRegistration() }) - await tab1c.waitForFunction(() => window.__pwaGateRegistration.waiting?.state === 'installed', null, { timeout: 15_000 }) + const stagedCLifecycle = await tab1c.evaluate(() => window.__pwaInstallLifecycle) + if (lifecycleTerminal(stagedCLifecycle) !== 'installed') throw new Error('C did not stage as waiting while B stayed active') const activeStagedC = await queryStatus(tab1c, 'active') result.checks.stagedC = { waiting: (await registrationState(tab1c)).waiting === 'installed', activeCache: activeStagedC?.cacheName ?? null } if (!result.checks.stagedC.waiting || result.checks.stagedC.activeCache !== cacheB) { @@ -1021,7 +1140,7 @@ async function runGate(input, prepared) { }).catch(() => { /* the resume may already have started the reload */ }) await waitMeta(tab2c, cacheC) await waitReady(tab2c, 'gateTab2C') - const tab1CNote = await note(tab1c) + const tab1CNote = await openNote(tab1c, primaryPath) const tab2CNote = await openNote(tab2c, secondaryPathC) result.checks.cConsent = { tab1Meta: await metaCache(tab1c), tab2Meta: await metaCache(tab2c), @@ -1048,9 +1167,9 @@ async function runGate(input, prepared) { await setOffline(false) // ---- Phase 6: final restart checkpoint after C ---- - await tab1c.close() - await tab2c.close() - await context.close() + await closePage(tab1c) + await closePage(tab2c) + await closeContext(context) context = await launch() browser = context.browser() attachErrors(context) @@ -1070,92 +1189,34 @@ async function runGate(input, prepared) { result.evidence.persistedPreference = true if (seedJob) result.evidence.atprotoOutboxStorageSeedReadback = true - // ---- Phase 8: atomic pointer retention - old /assets/ stay served ---- - const fetchAsset = (p, path) => p.evaluate(async path => { - const response = await fetch(path, { cache: 'no-store' }) - return { status: response.status, cacheControl: response.headers.get('cache-control'), - type: response.headers.get('content-type') } - }, path) - const aRetained = await fetchAsset(restartedC, entryA) - const bRetained = await fetchAsset(restartedC, entryB) - // A-only asset: guaranteed to exist only in A (entry scripts are distinct). - const aOnly = Object.keys(infoA.checksums).filter(p => !(p in infoB.checksums) && !(p in infoC.checksums)) - const aOnlyLazy = aOnly.find(p => p.endsWith('.js') && p !== entryA) ?? entryA - // Bypass the service worker (query-bearing URLs are never intercepted) so - // the request provably reaches the fixture server, which must serve the - // retired A-only asset from the retained A directory. - const aOnlyBypass = await restartedC.evaluate(async path => { - const response = await fetch(`${path}?gate-probe=${Date.now()}`, { cache: 'no-store' }) - return { status: response.status, cacheControl: response.headers.get('cache-control'), - type: response.headers.get('content-type') } - }, aOnlyLazy) - // Node-side probes bypass the service worker entirely (the fixture server is - // in-process): verify the retained bytes match the artifact checksum EXACTLY - // and carry immutable caching. The page-side query-bypass probe remains as a - // second, independent path. - const nodeProbe = async (path, expectedChecksum) => { - const response = await fetch(`http://127.0.0.1:${server.address().port}${path}`) - const body = Buffer.from(await response.arrayBuffer()) - return { - status: response.status, - cacheControl: response.headers.get('cache-control'), - contentType: response.headers.get('content-type'), - sha256: sha256(body), - exactChecksum: sha256(body) === expectedChecksum, - } - } - const aNode = await nodeProbe(entryA, infoA.checksums[entryA]) - const bNode = await nodeProbe(entryB, infoB.checksums[entryB]) - const aOnlyNode = await nodeProbe(aOnlyLazy, infoA.checksums[aOnlyLazy]) - const aOnlyServerRequest = result.requests.find(r => r.path === aOnlyLazy && r.status === 200) - result.checks.pointerRetention = { - aEntryAsset: aRetained, bEntryAsset: bRetained, - aOnlyLazyAsset: aOnlyLazy, aOnlyLazyServerRequest: aOnlyServerRequest, aOnlyLazyBypassSw: aOnlyBypass, - nodeProbes: { aEntry: aNode, bEntry: bNode, aOnly: aOnlyNode }, - } - if (aRetained.status !== 200 || bRetained.status !== 200) throw new Error('Old release assets were not retained after pointer switches') - if (!aRetained.cacheControl?.includes('immutable') || !bRetained.cacheControl?.includes('immutable')) { - throw new Error('Retained assets lack immutable caching') - } - if (aOnlyBypass.status !== 200 || !aOnlyServerRequest || aOnlyServerRequest.release !== 'A') { - throw new Error('A-only asset was not served from the retained A release') - } - for (const [label, probe] of [['A entry', aNode], ['B entry', bNode], ['A-only', aOnlyNode]]) { - if (probe.status !== 200) throw new Error(`Node probe for ${label} returned HTTP ${probe.status}`) - if (!probe.cacheControl?.includes('immutable')) throw new Error(`Node probe for ${label} lacks immutable caching`) - if (!probe.exactChecksum) throw new Error(`Node probe for ${label} bytes do not match the artifact checksum`) - } - result.evidence.atomicPointerRetention = true - result.update = { b: result.checks.bConsent, c: result.checks.cConsent } - result.failures.push(...errors.filter(e => e.path === null || - !result.expectedFailures.some(f => f.path === e.path)).map(e => e.message)) result.failures.push(...result.requests.filter(r => r.path.startsWith('/assets/') && r.status !== 200 && !r.offline && !r.expected).map(r => `Asset ${r.path}: HTTP ${r.status}`)) } catch (error) { console.error(error) - result.pageDiagnostics = await Promise.all((context?.pages() ?? []).map(async p => { - try { return await p.evaluate(async () => { - const r = await navigator.serviceWorker.getRegistration() - return { url: location.pathname, meta: document.querySelector('meta[name="reams-cache"]')?.getAttribute('content'), - alerts: [...document.querySelectorAll('[role="alert"]')].map(e => e.textContent), - inert: document.documentElement.inert, active: r?.active?.state, waiting: r?.waiting?.state } - }) } catch { return { unavailable: true } } - })) - console.error(result.pageDiagnostics) result.failures.push(error instanceof Error ? error.message : String(error)) process.exitCode = 1 } finally { - try { await context?.close() } catch (error) { result.failures.push(`Context cleanup: ${error}`); process.exitCode = 1 } + try { if (context) await closeContext(context) } catch (error) { result.failures.push(`Context cleanup: ${error}`); process.exitCode = 1 } try { await browser?.close() } catch (error) { result.failures.push(`Browser cleanup: ${error}`); process.exitCode = 1 } try { await new Promise(done => server.close(done)) } catch (error) { result.failures.push(`Server cleanup: ${error}`); process.exitCode = 1 } try { await rm(profileDir, { recursive: true, force: true }) } catch (error) { result.failures.push(`Profile cleanup: ${error}`); process.exitCode = 1 } + for (const entry of errors) { + if (expectedNavigationAbort(entry)) { + result.expectedTeardownCancellations.push({ path: entry.path, message: entry.message, + reason: 'confirmed-old-document-navigation' }) + } else if (!entry.injectedFault) { + result.failures.push(entry.message) + } + } + result.observedRequestFailures = errors const missing = Object.entries(result.evidence).filter(([, complete]) => !complete).map(([name]) => name) if (missing.length) result.failures.push(`Gate acceptance incomplete: ${missing.join(', ')}`) result.pass = result.offline === true && result.failures.length === 0 if (input.report) await writeFile(input.report, `${JSON.stringify(result, null, 2)}\n`) console.log(JSON.stringify({ mode: result.mode, fixtureNotes: result.fixtureNotes, offline: result.offline, update: result.update, evidence: result.evidence, expectedFailures: result.expectedFailures, + expectedTeardownCancellations: result.expectedTeardownCancellations, timingsMs: result.timingsMs, failures: result.failures, pass: result.pass, report: input.report }, null, 2)) if (!input.diagnostic && !result.pass) process.exitCode = 1 } @@ -1225,6 +1286,7 @@ async function runMatrix(input) { if (cellResult.engine !== engine) record.failures.push('Cell did not run the required matrix engine') record.pass = child.status === 0 && cellResult.pass === true && editionMatches && cellResult.engine === engine record.browserVersion = cellResult.browserVersion ?? null + record.offlineControl = cellResult.offlineControl ?? null record.evidence = cellResult.evidence ?? null record.failures.push(...(cellResult.failures ?? [])) if (record.failures.length) record.pass = false diff --git a/scripts/spikes/finalize-edition-build.test.mjs b/scripts/spikes/finalize-edition-build.test.mjs index e1182857..6c67e433 100644 --- a/scripts/spikes/finalize-edition-build.test.mjs +++ b/scripts/spikes/finalize-edition-build.test.mjs @@ -6,9 +6,45 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { createHash } from 'node:crypto' import { fileURLToPath } from 'node:url' +import vm from 'node:vm' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') +test('gate excuses only the fault-time request, not later failures of the same asset', async () => { + // Execute the runner's actual request handlers and final error aggregation; + // no browser or test-only export is needed for this classification regression. + const source = await readFile(path.join(root, 'scripts/pwa-baseline.mjs'), 'utf8') + const attachBody = source.match(/const attachErrors = ctx => (\{[\s\S]*?\n \})\n/)?.[1] + const aggregate = source.match(/for \(const entry of errors\) \{([\s\S]*?)\n \}\n result\.observedRequestFailures/)?.[1] + assert.ok(attachBody && aggregate) + const callbacks = new Map() + const page = { on: (name, callback) => callbacks.set(name, callback), mainFrame: () => null } + const errors = [] + const asset = '/assets/same-chunk.js' + const state = { missing: { path: asset }, mutate: null } + const result = { failures: [], expectedFailures: [{ path: asset }], expectedTeardownCancellations: [] } + const context = vm.createContext({ URL, errors, state, result, + pageGenerations: new WeakMap(), requestGenerations: new WeakMap(), navigationBoundaries: new WeakMap(), + cancellationDetails: new WeakMap(), deliberatelyClosingPages: new WeakSet(), injectedFaultRequests: new WeakSet(), + expectedNavigationAbort: () => false }) + const attach = vm.runInContext(`(ctx => ${attachBody})`, context) + attach({ on: (_event, callback) => callback(page) }) + const request = () => ({ url: () => `http://localhost${asset}`, isNavigationRequest: () => false, + failure: () => ({ errorText: 'net::ERR_FAILED' }) }) + const injected = request() + callbacks.get('request')(injected) + callbacks.get('requestfailed')(injected) + state.missing = null + const unrelated = request() + callbacks.get('request')(unrelated) + callbacks.get('requestfailed')(unrelated) + vm.runInContext(`for (const entry of errors) {${aggregate}\n}`, context) + assert.equal(errors[0].injectedFault, true) + assert.equal(errors[1].injectedFault, false) + assert.equal(result.failures.length, 1, 'the later same-path failure must make acceptance fail') + assert.match(result.failures[0], /same-chunk\.js/) +}) + async function fixture() { const dir = await mkdtemp(path.join(tmpdir(), 'reams-finalize-')) await mkdir(path.join(dir, 'assets'), { recursive: true }) @@ -19,7 +55,6 @@ async function fixture() { "const EDITION = '%REAMS_EDITION%';", 'const APP_ASSETS = %REAMS_ASSETS%;', 'const APP_INTEGRITY = %REAMS_INTEGRITY%;', - 'const APP_PATHS = new Set(APP_ASSETS);', ].join('\n') const html = 't' await writeFile(path.join(dir, 'service-worker.js'), worker) @@ -87,4 +122,27 @@ test('atproto finalizer includes oauth-login-scope.json in the manifest and inte } finally { await rm(dir, { recursive: true, force: true }) } -}) \ No newline at end of file +}) + +test('finalizer precaches a MIME-less deployed file with its exact integrity', async () => { + const { dir } = await fixture() + try { + // A deployed file with no recognized MIME extension is still part of the + // worker main's precache graph: the worker caches every deployed file + // regardless of its content type, so the finalizer must include it in the + // manifest and integrity map with the exact final bytes. + await writeFile(path.join(dir, 'assets', 'odd.bin'), 'binary-bytes') + execFileSync(process.execPath, ['scripts/finalize-edition-build.mjs', 'classic'], { + cwd: root, env: { ...process.env, REAMS_BUILD_OUT_DIR: dir }, + }) + const finalWorker = await readFile(path.join(dir, 'service-worker.js'), 'utf8') + const manifest = JSON.parse(finalWorker.match(/const APP_ASSETS = (\[[^;]+\]);/)?.[1]) + assert.ok(manifest.includes('/assets/odd.bin'), 'a MIME-less deployed file must be a cached asset') + const integrity = JSON.parse(finalWorker.match(/const APP_INTEGRITY = (\{[^;]+\});/)?.[1]) + const bytes = await readFile(path.join(dir, 'assets', 'odd.bin')) + assert.equal(integrity['/assets/odd.bin'], createHash('sha256').update(bytes).digest('base64'), + 'the MIME-less asset must carry an exact integrity digest') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/scripts/spikes/pwa-update-browser.test.mjs b/scripts/spikes/pwa-update-browser.test.mjs index ad0bd691..76a951b3 100644 --- a/scripts/spikes/pwa-update-browser.test.mjs +++ b/scripts/spikes/pwa-update-browser.test.mjs @@ -9,7 +9,7 @@ import { get } from 'node:https' import { createHash } from 'node:crypto' import { chromium } from 'playwright' -test('an installed Reams profile offers Update, preserves edits, and supports a cache-bypass recovery URL', { +test('an installed Reams profile offers Update, preserves edits, and pins the current release until approval', { skip: process.env.RUN_PWA_UPDATE_CHECK !== '1' || !process.env.PLAYWRIGHT_CHROMIUM_BIN, timeout: 120_000, }, async () => { const dir = await mkdtemp(join(tmpdir(), 'reams-pwa-update-')) @@ -104,22 +104,33 @@ test('an installed Reams profile offers Update, preserves edits, and supports a await context.close(); context = undefined await publish('C') - // Page-level certificate exceptions do not necessarily permit worker-script - // updates. Diagnose this separately from the trusted-TLS update assertion. - context = await chromium.launchPersistentContext(profile, options) + // The worker agent pins the same cache HTML until approval: an ordinary + // reload and a query-bearing root navigation must BOTH keep serving the + // current release (B). No query upgrades the release. Only the explicit + // Update click switches to C. + // This functional probe uses a disposable self-signed origin. Permit both + // page and worker traffic consistently; trusted TLS is a separate check. + context = await chromium.launchPersistentContext(profile, { ...options, args: ['--ignore-certificate-errors'] }) page = await context.newPage() editor = page.locator('.cm-content[contenteditable="true"]').first() const errors = [] page.on('console', message => { if (/SW registration failed|SSL certificate/i.test(message.text())) errors.push(message.text()) }) await page.goto(origin) await page.waitForTimeout(2_000) - console.log(`TLS diagnostic: ${errors.length ? errors.join(' | ') : 'no worker TLS error observed in this Chromium configuration'}`) + if (errors.length) throw new Error(`The disposable HTTPS fixture blocked a worker update: ${errors.join(' | ')}`) + const updateC = page.getByRole('button', { name: 'Update', exact: true }) + await updateC.waitFor({ timeout: 30_000 }) + await page.reload() + assert.equal(await marker(page), 'B', 'an ordinary reload must not serve C before approval') await page.goto(`${origin}/?refresh=update-probe-C`) - assert.equal(await marker(page), 'C', 'query-bearing recovery navigation bypasses the pinned offline HTML') + assert.equal(await marker(page), 'B', 'a root query must not upgrade to C before approval') + await updateC.waitFor({ timeout: 30_000 }) + await updateC.click() + await page.waitForFunction(() => document.querySelector('meta[name="reams-update-probe"]')?.content === 'C') await editor.waitFor(); await editor.click(); await editor.press('ControlOrMeta+End') await page.waitForFunction(() => document.querySelector('.cm-content')?.textContent.includes('PWA retained edit sentinel')) assert.equal(await page.evaluate(() => localStorage.getItem('reams:pwa-update-sentinel')), 'preserved') - console.log('PASS: a versioned recovery URL serves current UI while retaining notes and local state.') + console.log('PASS: the worker agent pins the current release until Update is clicked; approval serves C while retaining notes and local state.') } finally { await context?.close() if (server && server.exitCode === null) { server.kill('SIGTERM'); await new Promise(resolve => server.once('exit', resolve)) } diff --git a/scripts/spikes/service-worker-cache.test.mjs b/scripts/spikes/service-worker-cache.test.mjs index 06e1b9a0..e1446e7d 100644 --- a/scripts/spikes/service-worker-cache.test.mjs +++ b/scripts/spikes/service-worker-cache.test.mjs @@ -63,7 +63,11 @@ function makeWorker({ clients = [], caches: cachesOverride, files = {}, fail = n const self = { location: { origin: 'https://reams.test' }, addEventListener: (name, listener) => handlers.set(name, listener), - clients: { claim: async () => {}, matchAll: async () => clients }, + clients: { + claim: async () => {}, + matchAll: async () => clients, + get: async id => clients.find(client => client.id === id) ?? null, + }, skipWaiting: async () => { self.skipped = true }, } const requests = [] @@ -115,10 +119,13 @@ async function dispatchActivate(handlers) { await completion } -async function dispatchFetch(handlers, path, { method = 'GET', mode = 'same-origin' } = {}) { +async function dispatchFetch(handlers, path, { method = 'GET', mode = 'same-origin', clientId } = {}) { let result + // A plain request object: Node's Request constructor rejects the browser-only + // 'navigate' mode, and the worker only reads .url/.method/.mode off the event. handlers.get('fetch')({ - request: new Request(`https://reams.test${path}`, { method, mode }), + request: { url: `https://reams.test${path}`, method, mode }, + clientId, respondWith(value) { result = value }, }) if (result) await result @@ -132,7 +139,10 @@ const plain = value => JSON.parse(JSON.stringify(value)) test('service worker never intercepts OAuth, API, encoded or query-bearing URLs', async () => { const handlers = new Map() const puts = [] - const cache = { match: async () => undefined, put: async request => { puts.push(request.url) } } + const cache = { + match: async key => keyOf(key) === '/assets/index.js' ? new Response('cached', { status: 200 }) : undefined, + put: async request => { puts.push(request.url) }, + } const self = { location: { origin: 'https://reams.test' }, addEventListener: (name, listener) => handlers.set(name, listener) } runInNewContext(workerSource(), { self, caches: { open: async () => cache }, URL, fetch: async () => ({ status: 200, type: 'basic', headers: new Headers(), clone() { return this } }), @@ -147,8 +157,11 @@ test('service worker never intercepts OAuth, API, encoded or query-bearing URLs' '/oauth/broker/session', '/oauth-callback.html?code=secret', '/api', '/api/feedback/space', '/xrpc/com.atproto.repo.getRecord', '/.well-known/oauth-client-metadata', '/%6fauth/broker/session', '/api%2Ffeedback', '/?code=secret', '/index.html?token=secret', + '/release.json', '/release.json?probe=1', ]) assert.equal(await dispatch(path), false, `${path} must bypass CacheStorage`) - for (const path of ['/oauth/callback', '/oauth-callback.html?code=secret', '/?code=secret', '/index.html?token=secret']) { + // OAuth navigations bypass; root/index navigations with queries are shell + // navigations pinned to the cached index and are covered separately. + for (const path of ['/oauth/callback', '/oauth-callback.html?code=secret']) { assert.equal(await dispatch(path, 'GET', 'navigate'), false, `${path} navigation must bypass CacheStorage`) } assert.equal(await dispatch('/assets/index.js', 'POST'), false) @@ -157,7 +170,7 @@ test('service worker never intercepts OAuth, API, encoded or query-bearing URLs' assert.equal(puts.length, 0) }) -test('activation removes legacy caches that could contain OAuth responses', async () => { +test('activation keeps only the current cache and purges every other same-edition cache', async () => { const removed = [] const { handlers } = makeWorker({ caches: { @@ -167,7 +180,8 @@ test('activation removes legacy caches that could contain OAuth responses', asyn }, }) await dispatchActivate(handlers) - assert.deepEqual(removed, ['reams-atproto-v1', 'reams-atproto-v2']) + assert.deepEqual(removed, ['reams-atproto-v1', 'reams-atproto-v2', 'reams-atproto-deadbeef'], + 'activation must purge every other same-edition cache, legacy or not') }) test('approval persists an approved marker in the worker cache before skipWaiting', async () => { @@ -191,7 +205,7 @@ test('GET_STATUS reports not approved before any approval', async () => { }]) }) -test('GET_STATUS reports the persisted approval with the release identity', async () => { +test('GET_STATUS reports the persisted approval', async () => { const { handlers } = makeWorker() await dispatchMessage(handlers, { type: 'REAMS_APPROVE_UPDATE', release: 'reams-atproto-current' }) const replies = await dispatchMessage(handlers, { type: 'REAMS_GET_STATUS', requestId: 'probe-2' }) @@ -317,6 +331,57 @@ test('a JS asset served as text/html is rejected', async () => { assert.equal(cachesStore.has('reams-atproto-current'), false, 'the partial cache must be deleted') }) +test('a JS asset served with a charset parameter is accepted', async () => { + const files = { + '/index.html': Buffer.from('hi'), + '/assets/index.js': Buffer.from('console.log("hi")'), + } + const integrity = {} + for (const [path, bytes] of Object.entries(files)) integrity[path] = createHash('sha256').update(bytes).digest('base64') + const { handlers, store } = makeWorker({ files, integrity, mimeOverride: { '/assets/index.js': 'text/javascript; charset=utf-8' } }) + await dispatchInstall(handlers) + assert.ok(store.get('/assets/index.js'), 'a JS asset served with a charset parameter must be accepted') + assert.ok(store.get('__reams_installed_release__'), 'install must complete with a charset parameter') +}) + +test('a JS asset served as text/javascript-bad is rejected and the active release is preserved', async () => { + const files = { + '/index.html': Buffer.from('hi'), + '/assets/index.js': Buffer.from('console.log("hi")'), + } + const integrity = {} + for (const [path, bytes] of Object.entries(files)) integrity[path] = createHash('sha256').update(bytes).digest('base64') + const { handlers, cachesStore } = makeWorker({ files, integrity, mimeOverride: { '/assets/index.js': 'text/javascript-bad' } }) + await assert.rejects(dispatchInstall(handlers), /content-type/) + assert.equal(cachesStore.has('reams-atproto-current'), false, 'the partial cache must be deleted so the active release stays intact') +}) + +test('an HTML asset served as text/htmlx is rejected and the active release is preserved', async () => { + const files = { + '/index.html': Buffer.from('hi'), + '/assets/index.js': Buffer.from('console.log("hi")'), + } + const integrity = {} + for (const [path, bytes] of Object.entries(files)) integrity[path] = createHash('sha256').update(bytes).digest('base64') + const { handlers, cachesStore } = makeWorker({ files, integrity, mimeOverride: { '/index.html': 'text/htmlx' } }) + await assert.rejects(dispatchInstall(handlers), /content-type/) + assert.equal(cachesStore.has('reams-atproto-current'), false, 'the partial cache must be deleted so the active release stays intact') +}) + +test('a manifest served as application/manifest+json is accepted', async () => { + const files = { + '/index.html': Buffer.from('hi'), + '/assets/index.js': Buffer.from('console.log("hi")'), + '/manifest.json': Buffer.from('{"name":"reams"}'), + } + const integrity = {} + for (const [path, bytes] of Object.entries(files)) integrity[path] = createHash('sha256').update(bytes).digest('base64') + const { handlers, store } = makeWorker({ files, integrity, mimeOverride: { '/manifest.json': 'application/manifest+json' } }) + await dispatchInstall(handlers) + assert.ok(store.get('/manifest.json'), 'a manifest served as application/manifest+json must be accepted') + assert.ok(store.get('__reams_installed_release__'), 'install must complete with application/manifest+json') +}) + test('precache fetches bypass the HTTP cache with no-store while integrity stays active', async () => { const files = { '/index.html': Buffer.from('hi'), @@ -350,112 +415,107 @@ test('a failed re-install keeps a completed same-identity cache', async () => { assert.ok(store.get('__reams_installed_release__'), 'the install marker must survive a failed re-install') }) -test('activation prunes only older activated caches and keeps the previous release', async () => { - const sharedStore = new Map() - const a = makeWorker({ cacheName: 'reams-atproto-a', sharedStore }) - a.store.set('__reams_installed_release__', markerResponse('reams-atproto-a')) - a.store.set('__reams_activated_release__', markerResponse('reams-atproto-a', 'atproto', { activated: true })) - const b = makeWorker({ cacheName: 'reams-atproto-b', sharedStore }) - b.store.set('__reams_installed_release__', markerResponse('reams-atproto-b')) - b.store.set('__reams_activated_release__', markerResponse('reams-atproto-b', 'atproto', { activated: true })) - const c = makeWorker({ cacheName: 'reams-atproto-c', sharedStore }) - await dispatchActivate(c.handlers) - assert.equal(c.cachesStore.has('reams-atproto-a'), false, 'the older activated cache must be pruned') - assert.ok(c.cachesStore.has('reams-atproto-b'), 'the previous activated release must be retained') - assert.ok(c.cachesStore.has('reams-atproto-c'), 'the current cache must be retained') +test('an obsolete /assets or /fonts chunk returns 410 and never reaches the network', async () => { + const files = { + '/index.html': Buffer.from('hi'), + '/assets/index.js': Buffer.from('console.log("hi")'), + } + const integrity = {} + for (const [path, bytes] of Object.entries(files)) integrity[path] = createHash('sha256').update(bytes).digest('base64') + const { handlers, requests } = makeWorker({ files, integrity }) + for (const path of ['/assets/lazy-old.js', '/fonts/old.woff2']) { + const served = await dispatchFetch(handlers, path) + assert.equal(served.status, 410, `${path} must be answered with 410`) + } + assert.equal(requests.length, 0, 'obsolete chunks must never reach the network') }) -test('activation drops superseded waiting candidates and prunes to current + previous', async () => { - const sharedStore = new Map() - // D is the oldest activated release; A is the newest activated predecessor; - // B is a waiting candidate (installed but never activated, superseded by C). - const d = makeWorker({ cacheName: 'reams-atproto-d', sharedStore }) - d.store.set('__reams_installed_release__', markerResponse('reams-atproto-d')) - d.store.set('__reams_activated_release__', markerResponse('reams-atproto-d', 'atproto', { activated: true })) - const a = makeWorker({ cacheName: 'reams-atproto-a', sharedStore }) - a.store.set('__reams_installed_release__', markerResponse('reams-atproto-a')) - a.store.set('__reams_activated_release__', markerResponse('reams-atproto-a', 'atproto', { activated: true })) - const b = makeWorker({ cacheName: 'reams-atproto-b', sharedStore }) - b.store.set('__reams_installed_release__', markerResponse('reams-atproto-b')) - const c = makeWorker({ cacheName: 'reams-atproto-c', sharedStore }) - await dispatchActivate(c.handlers) - assert.ok(c.cachesStore.has('reams-atproto-a'), 'the newest activated predecessor must be retained') - assert.ok(c.cachesStore.has('reams-atproto-c'), 'the current cache must be retained') - assert.equal(c.cachesStore.has('reams-atproto-b'), false, 'a superseded waiting candidate must be pruned') - assert.equal(c.cachesStore.has('reams-atproto-d'), false, 'the oldest activated release must be pruned') +test('an obsolete chunk on an approved page messages the client to reload', async () => { + const files = { + '/index.html': Buffer.from('hi'), + '/assets/index.js': Buffer.from('console.log("hi")'), + } + const integrity = {} + for (const [path, bytes] of Object.entries(files)) integrity[path] = createHash('sha256').update(bytes).digest('base64') + const posted = [] + const { handlers, store } = makeWorker({ files, integrity, clients: [{ id: 'tab-1', postMessage: message => posted.push(message) }] }) + store.set('__reams_approved_release__', markerResponse('reams-atproto-current', 'atproto', { approved: true })) + const served = await dispatchFetch(handlers, '/assets/lazy-old.js', { clientId: 'tab-1' }) + assert.equal(served.status, 410) + assert.deepEqual(plain(posted), [{ + type: 'REAMS_APPROVED_RELEASE', cacheName: 'reams-atproto-current', edition: 'atproto', protocol: 2, + }], 'an approved page must be told to flush and switch to the cached release') }) -test('a mid-install cache without an install marker is not pruned by another activation', async () => { - const sharedStore = new Map() - const a = makeWorker({ cacheName: 'reams-atproto-a', sharedStore }) - a.store.set('__reams_installed_release__', markerResponse('reams-atproto-a')) - a.store.set('__reams_activated_release__', markerResponse('reams-atproto-a', 'atproto', { activated: true })) - // B exists but has neither an install nor an activation marker: it is - // mid-install and must not be pruned by another worker's activation. - const b = makeWorker({ cacheName: 'reams-atproto-b', sharedStore }) - const c = makeWorker({ cacheName: 'reams-atproto-c', sharedStore }) - await dispatchActivate(c.handlers) - assert.ok(c.cachesStore.has('reams-atproto-a'), 'the activated predecessor must be retained') - assert.ok(c.cachesStore.has('reams-atproto-b'), 'a mid-install cache must not be pruned') - assert.ok(c.cachesStore.has('reams-atproto-c'), 'the current cache must be retained') +test('an obsolete chunk on an unapproved page sends no reload message', async () => { + const files = { + '/index.html': Buffer.from('hi'), + '/assets/index.js': Buffer.from('console.log("hi")'), + } + const integrity = {} + for (const [path, bytes] of Object.entries(files)) integrity[path] = createHash('sha256').update(bytes).digest('base64') + const posted = [] + const { handlers, store } = makeWorker({ files, integrity, clients: [{ id: 'tab-1', postMessage: message => posted.push(message) }] }) + // No APPROVED marker in the current cache: the page must not be told to reload. + const served = await dispatchFetch(handlers, '/assets/lazy-old.js', { clientId: 'tab-1' }) + assert.equal(served.status, 410) + assert.deepEqual(plain(posted), [], 'an unapproved page must not receive a reload message') }) -test('failed B install then C activation retains the actual preceding A for lazy offline', async () => { - const sharedStore = new Map() - const filesA = { +test('root navigation with a query serves the cached index body without network', async () => { + const files = { '/index.html': Buffer.from('A'), - '/assets/index.js': Buffer.from('console.log("A")'), - '/assets/lazy-a.js': Buffer.from('console.log("lazy A")'), - } - const integrityA = {} - for (const [path, bytes] of Object.entries(filesA)) integrityA[path] = createHash('sha256').update(bytes).digest('base64') - const a = makeWorker({ cacheName: 'reams-atproto-a', files: filesA, integrity: integrityA, sharedStore }) - for (const [path, bytes] of Object.entries(filesA)) { - a.store.set(path, new Response(bytes, { headers: { 'Content-Type': mimeFor(path) } })) - } - a.store.set('__reams_installed_release__', markerResponse('reams-atproto-a')) - a.store.set('__reams_activated_release__', markerResponse('reams-atproto-a', 'atproto', { activated: true })) - - // B: install fails on a missing lazy chunk; the partial cache is deleted. - const filesB = { - '/index.html': Buffer.from('B'), - '/assets/index.js': Buffer.from('console.log("B")'), - '/assets/lazy-b.js': Buffer.from('console.log("lazy B")'), + '/assets/index.js': Buffer.from('console.log("hi")'), } - const integrityB = {} - for (const [path, bytes] of Object.entries(filesB)) integrityB[path] = createHash('sha256').update(bytes).digest('base64') - const b = makeWorker({ cacheName: 'reams-atproto-b', files: filesB, integrity: integrityB, fail: new Set(['/assets/lazy-b.js']), sharedStore }) - await assert.rejects(dispatchInstall(b.handlers), /HTTP 503/) - assert.equal(b.cachesStore.has('reams-atproto-b'), false, 'B partial cache must be deleted on failed install') - - // C: installs and activates. - const filesC = { - '/index.html': Buffer.from('C'), - '/assets/index.js': Buffer.from('console.log("C")'), + const integrity = {} + for (const [path, bytes] of Object.entries(files)) integrity[path] = createHash('sha256').update(bytes).digest('base64') + const { handlers, store, requests } = makeWorker({ files, integrity }) + await dispatchInstall(handlers) + // The origin would now serve a different body B; the cached A must win so a + // '?refresh' navigation can never upgrade the shell. + files['/index.html'] = Buffer.from('B') + const served = await dispatchFetch(handlers, '/?refresh=1', { mode: 'navigate' }) + assert.equal(served.status, 200) + assert.equal(await served.text(), 'A', 'the cached index body must be served') + assert.equal(requests.length, 2, 'a cached shell navigation must not reach the network') +}) + +test('an ordinary root reload serves the cached index body without network', async () => { + const files = { + '/index.html': Buffer.from('A'), + '/assets/index.js': Buffer.from('console.log("hi")'), } - const integrityC = {} - for (const [path, bytes] of Object.entries(filesC)) integrityC[path] = createHash('sha256').update(bytes).digest('base64') - const c = makeWorker({ cacheName: 'reams-atproto-c', files: filesC, integrity: integrityC, sharedStore }) - await dispatchInstall(c.handlers) - await dispatchActivate(c.handlers) - - // A must be retained as the actual preceding activated release; B must not linger. - assert.ok(c.cachesStore.has('reams-atproto-a'), 'A must be retained after C activation') - assert.ok(c.cachesStore.has('reams-atproto-c'), 'C must be the current cache') - assert.equal(c.cachesStore.has('reams-atproto-b'), false, 'B partial cache must not linger') - - // A's lazy chunk is still served offline by C's fetch fallback. - const served = await dispatchFetch(c.handlers, '/assets/lazy-a.js') - assert.ok(served, 'A lazy chunk must be served from the retained A cache') + const integrity = {} + for (const [path, bytes] of Object.entries(files)) integrity[path] = createHash('sha256').update(bytes).digest('base64') + const { handlers, store, requests } = makeWorker({ files, integrity }) + await dispatchInstall(handlers) + files['/index.html'] = Buffer.from('B') + const served = await dispatchFetch(handlers, '/', { mode: 'navigate' }) assert.equal(served.status, 200) + assert.equal(await served.text(), 'A', 'the cached index body must be served') + assert.equal(requests.length, 2, 'a cached root reload must not reach the network') +}) + +test('a missing current-cache asset with wrong HTML bytes from the network rejects and caches nothing new', async () => { + const files = { + '/index.html': Buffer.from('hi'), + '/assets/index.js': Buffer.from('console.log("hi")'), + } + const integrity = {} + for (const [path, bytes] of Object.entries(files)) integrity[path] = createHash('sha256').update(bytes).digest('base64') + const { handlers, store, requests } = makeWorker({ files, integrity }) + // The current cache is missing /assets/index.js; the origin now serves wrong + // HTML bytes for it. The exact-manifest gate must reject and cache nothing. + files['/assets/index.js'] = Buffer.from('tampered') + await assert.rejects(dispatchFetch(handlers, '/assets/index.js'), /integrity/) + assert.equal(store.has('/assets/index.js'), false, 'a rejected network body must not be cached') }) test('metadata keys are never served by the fetch handler', async () => { const { handlers, store } = makeWorker() store.set('__reams_approved_release__', markerResponse('reams-atproto-current', 'atproto', { approved: true })) store.set('__reams_installed_release__', markerResponse('reams-atproto-current')) - store.set('__reams_activated_release__', markerResponse('reams-atproto-current', 'atproto', { activated: true })) - for (const path of ['/__reams_approved_release__', '/__reams_installed_release__', '/__reams_activated_release__']) { + for (const path of ['/__reams_approved_release__', '/__reams_installed_release__']) { const served = await dispatchFetch(handlers, path) assert.equal(served, undefined, `${path} must bypass CacheStorage`) } diff --git a/src/app/__tests__/pwa-updates.test.ts b/src/app/__tests__/pwa-updates.test.ts index 9178128e..7cf540b5 100644 --- a/src/app/__tests__/pwa-updates.test.ts +++ b/src/app/__tests__/pwa-updates.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('PWA update protocol', () => { let events: Map void> - let registration: EventTarget & { waiting: ServiceWorker | null; installing: ServiceWorker | null; update: ReturnType } + let registration: EventTarget & { waiting: ServiceWorker | null; installing: ServiceWorker | null; active: ServiceWorker | null; update: ReturnType } let serviceWorker: EventTarget & { controller: ServiceWorker | null; register: ReturnType } let reload: ReturnType let assign: ReturnType @@ -41,7 +41,7 @@ describe('PWA update protocol', () => { writable: true, configurable: true, }) - registration = Object.assign(new EventTarget(), { waiting: null, installing: null, update: vi.fn().mockResolvedValue(undefined) }) + registration = Object.assign(new EventTarget(), { waiting: null, installing: null, active: null, update: vi.fn().mockResolvedValue(undefined) }) serviceWorker = Object.assign(new EventTarget(), { controller: {} as ServiceWorker, register: vi.fn().mockResolvedValue(registration) }) vi.stubGlobal('navigator', { onLine: true, serviceWorker }) }) @@ -287,6 +287,44 @@ describe('PWA update protocol', () => { await promise }) + it('keeps explicit apply ownership when activation also triggers automatic status replies', async () => { + const oldPost = vi.fn() + const api = await registerSynced(oldPost) + // An overlapping second flush would fail and thaw the tab. It must never + // run while the initiating apply owns the barrier. + const flush = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValue(new Error('overlapping flush failed')) + api.registerPwaUpdateFlush(flush) + const post = vi.fn() + const waiting = { state: 'installed', postMessage: post } as unknown as ServiceWorker + registration.waiting = waiting + const operation = api.applyPwaUpdate() + await vi.advanceTimersByTimeAsync(0) + const targetRequest = lastStatusRequest(post) + serviceWorker.dispatchEvent(statusReply(waiting, targetRequest.requestId, { approved: false })) + await vi.advanceTimersByTimeAsync(0) + expect(flush).toHaveBeenCalledTimes(1) + serviceWorker.controller = waiting + serviceWorker.dispatchEvent(new Event('controllerchange')) + serviceWorker.dispatchEvent(new MessageEvent('message', { + data: { type: 'REAMS_APPROVED_RELEASE', cacheName: 'reams-classic-new' }, source: waiting, + })) + await vi.advanceTimersByTimeAsync(0) + const requests = post.mock.calls.filter(call => call[0]?.type === 'REAMS_GET_STATUS').map(call => call[0]) + const explicitValidation = requests[requests.length - 1]! + expect(requests.length).toBeGreaterThanOrEqual(4) + // Deliver the automatic replies first; hold the explicit final validation. + for (const request of requests.slice(1, -1)) serviceWorker.dispatchEvent(statusReply(waiting, request.requestId)) + await vi.advanceTimersByTimeAsync(0) + expect(flush).toHaveBeenCalledTimes(1) + expect(document.documentElement.inert).toBe(true) + expect(assign).not.toHaveBeenCalled() + expect(reload).not.toHaveBeenCalled() + serviceWorker.dispatchEvent(statusReply(waiting, explicitValidation.requestId)) + await operation + expect(flush).toHaveBeenCalledTimes(1) + expect(assign).toHaveBeenCalledTimes(1) + }) + it('thaws after an activation timeout and performs a fresh flush when approval eventually arrives', async () => { const api = await import('../pwa-updates') const flush = vi.fn().mockResolvedValue(undefined) @@ -671,4 +709,46 @@ describe('PWA update protocol', () => { await vi.waitFor(() => expect(assign).toHaveBeenCalledTimes(1)) await promise }) + + it('does not flush from a status reply that arrives after the controller changed', async () => { + const api = await import('../pwa-updates') + const flush = vi.fn().mockResolvedValue(undefined) + api.registerPwaUpdateFlush(flush) + const oldPost = vi.fn() + const oldController = { postMessage: oldPost } as unknown as ServiceWorker + serviceWorker.controller = oldController + await api.registerPwaUpdates() + events.get('focus')!() + await vi.waitFor(() => expect(oldPost).toHaveBeenCalledWith(expect.objectContaining({ type: 'REAMS_GET_STATUS' }))) + const statusCall = lastStatusRequest(oldPost) + // The controller changes before the reply arrives. + const newPost = vi.fn() + serviceWorker.controller = { postMessage: newPost } as unknown as ServiceWorker + serviceWorker.dispatchEvent(new Event('controllerchange')) + // The stale reply from the old controller must not flush or reload. + serviceWorker.dispatchEvent(statusReply(oldController, statusCall.requestId, { cacheName: 'reams-classic-new', approved: true })) + await vi.advanceTimersByTimeAsync(100) + expect(flush).not.toHaveBeenCalled() + expect(assign).not.toHaveBeenCalled() + expect(reload).not.toHaveBeenCalled() + }) + + it('does not autoreload from a responding registration.active when the page has no controller', async () => { + serviceWorker.controller = null + const activePost = vi.fn() + registration.active = { postMessage: activePost } as unknown as ServiceWorker + const api = await import('../pwa-updates') + const flush = vi.fn().mockResolvedValue(undefined) + api.registerPwaUpdateFlush(flush) + await api.registerPwaUpdates() + await vi.advanceTimersByTimeAsync(0) + // A broadcast arrives while the page is controller-less: the validation + // query targets the true controller (none), never registration.active. + serviceWorker.dispatchEvent(approvedReleaseMessage('reams-classic-new')) + await vi.advanceTimersByTimeAsync(100) + expect(activePost).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'REAMS_GET_STATUS' })) + expect(flush).not.toHaveBeenCalled() + expect(assign).not.toHaveBeenCalled() + expect(reload).not.toHaveBeenCalled() + }) }) diff --git a/src/app/pwa-updates.ts b/src/app/pwa-updates.ts index 2a8d71d4..8edebe64 100644 --- a/src/app/pwa-updates.ts +++ b/src/app/pwa-updates.ts @@ -305,7 +305,10 @@ function reloadTowardUpdate(target: string, explicit = false): void { async function flushAndReload(cacheName: string, explicit = false): Promise { if (reloadScheduled) return false if (!pageCacheName || cacheName === pageCacheName) return false - if (handling) return false + // The explicit apply owns this tab's save barrier until it finishes. Its + // controller-change/broadcast notifications must not start a second flush + // that can thaw the editor underneath the first operation. + if (handling || applying) return false // A failed automatic flush toward this release is bounded to one attempt per // session; only an explicit retry may try again. if (!explicit && failedFlushExhausted(cacheName)) { @@ -385,8 +388,11 @@ function onWorkerMessage(event: MessageEvent) { if (!fromServiceWorker(event)) return // A broadcast alone is not trusted: validate against the current // controller's status before flushing toward the claimed release. - const worker = navigator.serviceWorker.controller ?? registration?.active ?? null + const worker = navigator.serviceWorker.controller void queryStatus(worker, STATUS_TIMEOUT_MS).then(status => { + // Authority is the current controller ONLY: a reply from a worker that + // is no longer (or never was) the controller must not drive approval. + if (worker !== navigator.serviceWorker.controller) return if (status) processStatus(status, data.cacheName) }) } @@ -395,8 +401,11 @@ function onWorkerMessage(event: MessageEvent) { /** Resume query: ask the controlling worker whether an approved release waits. */ function queryStatusOnEvents(): void { if (document.visibilityState !== 'visible') return - const worker = navigator.serviceWorker.controller ?? registration?.active ?? null + const worker = navigator.serviceWorker.controller void queryStatus(worker, STATUS_TIMEOUT_MS).then(status => { + // Authority is the current controller ONLY: a reply from a worker that is + // no longer (or never was) the controller must not drive approval. + if (worker !== navigator.serviceWorker.controller) return if (status) processStatus(status) }) } @@ -408,8 +417,11 @@ function queryStatusOnEvents(): void { * timeout) drops the target instead of reloading without authority. */ async function resumePendingTarget(cacheName: string): Promise { - const worker = navigator.serviceWorker.controller ?? registration?.active ?? null + const worker = navigator.serviceWorker.controller const status = await queryStatus(worker, STATUS_TIMEOUT_MS) + // Authority is the current controller ONLY: a reply from a worker that is no + // longer (or never was) the controller must not authorize a reload. + if (worker !== navigator.serviceWorker.controller) return if (!status) return if (status.cacheName !== cacheName || !status.approved) return await flushAndReload(cacheName) @@ -486,8 +498,7 @@ export async function registerPwaUpdates(): Promise { void registration.update().then(() => { lastSuccessfulCheck = Date.now() refresh() - }).catch(error => { - console.warn('Could not check for a Reams update:', error) + }).catch(() => { schedule(10_000) }).finally(() => { checking = false }) } @@ -496,8 +507,8 @@ export async function registerPwaUpdates(): Promise { window.addEventListener('focus', check) window.addEventListener('pageshow', check) check() - } catch (error) { - console.error('SW registration failed:', error) + } catch { + // Registration failures are silent: no telemetry or logging is added. } } @@ -612,8 +623,11 @@ export async function retryPwaUpdate(): Promise { } // Revalidate the current controller's authority before flushing toward any // retained or discovered target. - const worker = navigator.serviceWorker.controller ?? registration?.active ?? null + const worker = navigator.serviceWorker.controller const status = await queryStatus(worker, STATUS_TIMEOUT_MS) + // Authority is the current controller ONLY: a reply from a worker that is no + // longer (or never was) the controller must not authorize a reload. + if (worker !== navigator.serviceWorker.controller) return let target: string | null = null if (status?.approved && status.cacheName && status.cacheName !== pageCacheName) { target = status.cacheName diff --git a/src/layout/settings/OfflineSection.test.tsx b/src/layout/settings/OfflineSection.test.tsx index d7384db2..17233608 100644 --- a/src/layout/settings/OfflineSection.test.tsx +++ b/src/layout/settings/OfflineSection.test.tsx @@ -9,7 +9,11 @@ describe('Offline settings', () => { cleanup() if (originalStorage) Object.defineProperty(navigator, 'storage', originalStorage) else delete (navigator as { storage?: StorageManager }).storage + // navigator.onLine lives on the prototype (no own descriptor), so a test + // that defines an own `false` must delete it again or the offline state + // leaks into every later test in this file. if (originalOnline) Object.defineProperty(navigator, 'onLine', originalOnline) + else delete (navigator as { onLine?: boolean }).onLine }) it('shows best-effort storage accurately and follows connectivity changes', async () => { diff --git a/src/layout/settings/OfflineSection.tsx b/src/layout/settings/OfflineSection.tsx index 93c494c9..84ebd0e3 100644 --- a/src/layout/settings/OfflineSection.tsx +++ b/src/layout/settings/OfflineSection.tsx @@ -22,6 +22,9 @@ export function OfflineSection() { } }, []) + let protection = 'Unknown' + if (persistent !== null) protection = persistent ? 'Persistent' : 'Best effort' + return (

Offline

@@ -35,7 +38,7 @@ export function OfflineSection() { : 'Unavailable'}
Storage protection - {persistent === null ? 'Unknown' : persistent ? 'Persistent' : 'Best effort'} + {protection}
)