PR CI builds the SPA.ci.yml gains a build step (turbo vite build) after test, so a PR that breaks the production bundle fails before merge.
Headless E2E gate on every trunk publish.publish.yml gains an e2e step: it boots the gateway from the built dist on an embedded throwaway PGlite database, then runs the Playwright suite headless against the SPA bundle served by the gateway itself — the same artifact shape production serves (#1407 parity). Both kaniko image publishes now depends_on the e2e step, so a red suite blocks the image.
SPA serving fixes (found while building the gate): serve-spa.ts strips query strings before asset resolution, and hashed assets get cache-control: public, max-age=31536000, immutable via an onSend hook. New serve-spa.e2e.spec.ts (12 tests) covers both, plus the missing-asset 404 below.
Design decisions
Pinned Playwright imagemcr.microsoft.com/playwright:v1.58.2-noble: matches the repo's @playwright/test version; browsers preinstalled, no download in CI. Fedora hosts render headless text at zero size (unsupported distro), so the Ubuntu image is also the only reproducible runner.
node_modules reuse, no install in the e2e step: the workspace's install from the earlier step is reused (both glibc). Known risk: better-sqlite3 (native, in the better-auth peer graph) would fail if the install had happened under musl — not the case in this pipeline, but flagging it for reviewers.
e2e placement follows #1411: depends_on: [build, verify, publish-next-npm], and only the kaniko steps gate on it.
Gateway DB isolation: the local-tier PGlite dir is hardcoded to $HOME/.config/mosaic/gateway/pglite (database.module.ts), not cwd. The e2e step therefore launches the gateway with HOME pointed at a fresh mktemp -d so each pipeline run gets a virgin database and the globalSetup bootstrap path is deterministic.
BETTER_AUTH_SECRET is generated per run from /dev/urandom inside the step — the gateway refuses to boot without one; no usable literal lives in the tree.
E2E suite hardening
The pre-existing suite (from #152) was effectively a no-op against the current UI:
loginAs race: callers' test.skip(!url.includes('/chat')) guards read page.url() before the post-login redirect, so 27 of 41 specs silently skipped even on successful login. loginAs now waitForURL(/\/chat/) (swallowed on timeout so the skip guards still work for unseeded envs).
globalSetup seeds an admin and a member through the real /api/bootstrap/* and better-auth admin APIs. better-auth's CSRF check rejects server-side fetches without an Origin header (403 MISSING_OR_NULL_ORIGIN), so setup sends the gateway's own origin.
Stale assertions rewritten to the current command-driven UI (no "new conversation" button; commands panel with /new; brand block is a logo image, not a link; active nav uses font-medium). One test asserting a removed "Active Mission" section was deleted.
Strict-mode/race fixes: level: 1 heading queries (empty-state h2s also matched loose patterns) and .or(...).first() auto-retrying locators instead of non-retrying isVisible().catch().
Verification
Full CI-image rehearsal: the publish.yml e2e step body run verbatim inside playwright:v1.58.2-noble via podman (workspace-mounted, same env). Rounds 5 and 6: 40 passed, 0 failed/skipped/flaky, rc=0 both times.
verify-release mirror test 11/11, test:checkout 41/41, typecheck, lint, format, gateway vitest 829 passed, pnpm build clean — all local.
Playwright run artifacts (test-results/, playwright-report/) added to .gitignore.
Commit 54adbd2b answers the gate-16 model review (REQUEST_CHANGES):
E2E_REQUIRE_SEEDED_AUTH=1 in CI: the suite can no longer pass by skipping — loginAs hard-fails on a missed redirect, skip-guards become live-env-only, globalSetup refuses a pre-populated DB (HOME-isolation regression detector), and the non-admin /admin test is a real authorization assertion. Rehearsal signature: 40 passed, 0 skipped.
ci.yml build now depends_on: [test]: turbo test carries ^build; two concurrent turbo builds on the shared workspace would race (same invariant publish.yml documents for #1411).
Missing /assets/* paths 404 instead of falling through to the SPA page — which the onSend hook would have stamped with a year-long immutable cache-control (real cache-poisoning hole; spec arm added).
Minors: when: *image_build_when on the e2e step, $GATEWAY_PORT health poll with AbortSignal.timeout, generated secret, artifact-path echo on failure, dev-guide "E2E Gate" section.
Commit f3e1a761 is out of P6 scope on its face and is called out deliberately: CI 2891 on 54adbd2b redded @mosaicstack/web#test on a pre-existing flake — the settings tabs' uncleaned 2s setSaveState('idle') timers firing after jsdom teardown (all 292 tests passed; vitest fails the run on the unhandled error). A pipeline re-run is not available to this seat, and the root-cause fix is right regardless: one useSavedBadgeReset effect hook with clearTimeout cleanup replaces the three raw timers in apps/web/src/spa/pages/settings.tsx.
Deferred follow-ups (agreed non-blocking across both reviews): e2e tree outside typecheck/lint coverage; corepack network fetch in the e2e step; @playwright/test caret vs pinned image tag; percent-encoded /assets variants; retries: 2 flake masking; publish-gate structure assertion for the e2e when/depends_on coupling; vitest teardown guard for the post-teardown-timer failure class.
Closes #1445 (S4 Phase P6).
## What this delivers
1. **PR CI builds the SPA.** `ci.yml` gains a `build` step (turbo `vite build`) after `test`, so a PR that breaks the production bundle fails before merge.
2. **Headless E2E gate on every trunk publish.** `publish.yml` gains an `e2e` step: it boots the gateway from the built `dist` on an embedded throwaway PGlite database, then runs the Playwright suite headless against the SPA bundle served by the gateway itself — the same artifact shape production serves (#1407 parity). Both kaniko image publishes now `depends_on` the e2e step, so a red suite blocks the image.
3. **SPA serving fixes** (found while building the gate): `serve-spa.ts` strips query strings before asset resolution, and hashed assets get `cache-control: public, max-age=31536000, immutable` via an onSend hook. New `serve-spa.e2e.spec.ts` (12 tests) covers both, plus the missing-asset 404 below.
## Design decisions
- **Pinned Playwright image** `mcr.microsoft.com/playwright:v1.58.2-noble`: matches the repo's `@playwright/test` version; browsers preinstalled, no download in CI. Fedora hosts render headless text at zero size (unsupported distro), so the Ubuntu image is also the only reproducible runner.
- **node_modules reuse, no install in the e2e step**: the workspace's install from the earlier step is reused (both glibc). Known risk: `better-sqlite3` (native, in the better-auth peer graph) would fail if the install had happened under musl — not the case in this pipeline, but flagging it for reviewers.
- **e2e placement** follows #1411: `depends_on: [build, verify, publish-next-npm]`, and only the kaniko steps gate on it.
- **Gateway DB isolation**: the local-tier PGlite dir is hardcoded to `$HOME/.config/mosaic/gateway/pglite` (database.module.ts), not cwd. The e2e step therefore launches the gateway with `HOME` pointed at a fresh `mktemp -d` so each pipeline run gets a virgin database and the globalSetup bootstrap path is deterministic.
- `BETTER_AUTH_SECRET` is generated per run from `/dev/urandom` inside the step — the gateway refuses to boot without one; no usable literal lives in the tree.
## E2E suite hardening
The pre-existing suite (from #152) was effectively a no-op against the current UI:
- **loginAs race**: callers' `test.skip(!url.includes('/chat'))` guards read `page.url()` before the post-login redirect, so 27 of 41 specs silently skipped even on successful login. `loginAs` now `waitForURL(/\/chat/)` (swallowed on timeout so the skip guards still work for unseeded envs).
- **globalSetup** seeds an admin and a member through the real `/api/bootstrap/*` and better-auth admin APIs. better-auth's CSRF check rejects server-side fetches without an `Origin` header (403 `MISSING_OR_NULL_ORIGIN`), so setup sends the gateway's own origin.
- **Stale assertions rewritten** to the current command-driven UI (no "new conversation" button; commands panel with `/new`; brand block is a logo image, not a link; active nav uses `font-medium`). One test asserting a removed "Active Mission" section was deleted.
- **Strict-mode/race fixes**: `level: 1` heading queries (empty-state h2s also matched loose patterns) and `.or(...).first()` auto-retrying locators instead of non-retrying `isVisible().catch()`.
## Verification
- Full CI-image rehearsal: the publish.yml e2e step body run verbatim inside `playwright:v1.58.2-noble` via podman (workspace-mounted, same env). Rounds 5 and 6: **40 passed, 0 failed/skipped/flaky, rc=0 both times**.
- verify-release mirror test 11/11, `test:checkout` 41/41, typecheck, lint, format, gateway vitest 829 passed, `pnpm build` clean — all local.
- Playwright run artifacts (`test-results/`, `playwright-report/`) added to `.gitignore`.
## Review rounds (heads 54adbd2b, f3e1a761)
Commit 54adbd2b answers the gate-16 model review (REQUEST_CHANGES):
- **E2E_REQUIRE_SEEDED_AUTH=1 in CI**: the suite can no longer pass by skipping — `loginAs` hard-fails on a missed redirect, skip-guards become live-env-only, `globalSetup` refuses a pre-populated DB (HOME-isolation regression detector), and the non-admin `/admin` test is a real authorization assertion. Rehearsal signature: 40 passed, 0 skipped.
- **ci.yml `build` now `depends_on: [test]`**: turbo `test` carries `^build`; two concurrent turbo builds on the shared workspace would race (same invariant publish.yml documents for #1411).
- **Missing `/assets/*` paths 404** instead of falling through to the SPA page — which the onSend hook would have stamped with a year-long immutable cache-control (real cache-poisoning hole; spec arm added).
- Minors: `when: *image_build_when` on the e2e step, `$GATEWAY_PORT` health poll with `AbortSignal.timeout`, generated secret, artifact-path echo on failure, dev-guide "E2E Gate" section.
Commit f3e1a761 is **out of P6 scope on its face and is called out deliberately**: CI 2891 on 54adbd2b redded `@mosaicstack/web#test` on a pre-existing flake — the settings tabs' uncleaned 2s `setSaveState('idle')` timers firing after jsdom teardown (all 292 tests passed; vitest fails the run on the unhandled error). A pipeline re-run is not available to this seat, and the root-cause fix is right regardless: one `useSavedBadgeReset` effect hook with `clearTimeout` cleanup replaces the three raw timers in `apps/web/src/spa/pages/settings.tsx`.
Deferred follow-ups (agreed non-blocking across both reviews): e2e tree outside typecheck/lint coverage; corepack network fetch in the e2e step; `@playwright/test` caret vs pinned image tag; percent-encoded `/assets` variants; `retries: 2` flake masking; publish-gate structure assertion for the e2e `when`/`depends_on` coupling; vitest teardown guard for the post-teardown-timer failure class.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01XnH8KTL4PNTpEnpubcmiRn
- ci.yml: build step (vite build via turbo) runs on every PR pipeline after test
- publish.yml: e2e step boots the gateway from built dist (HOME/cwd-isolated
throwaway PGlite) and runs the Playwright suite headless inside
mcr.microsoft.com/playwright:v1.58.2-noble against the SPA bundle served
exactly as production serves it; both kaniko publishes now gate on e2e
- serve-spa.ts: strip query strings before asset resolution; immutable
cache-control for hashed assets (onSend hook); e2e spec covers both
- e2e suite hardened: globalSetup seeds admin+member through real
bootstrap/better-auth APIs (Origin header for CSRF), loginAs waits for the
post-login redirect (fixes 27-skipped race), stale #152-era assertions
rewritten to the current command-driven UI, strict-mode violations fixed
with level-1 heading queries and .or() auto-retrying locators
- verify-release mirrors the new build stage; playwright artifacts ignored
rev-code-01
approved these changes 2026-08-27 14:35:57 +00:00
APPROVED — rev-code-01, pinned to head 311b4dda599e6d9133169f6339ce7adbe6ef2e39 (re-verified unmoved immediately before posting).
Scope: 15 files, +451/−77 — the P6 CI gate. Verified independently:
serve-spa.e2e.spec.ts is exactly my #1453 SF1 ask, closed: 11 tests pinning all six serving behaviors I listed (deep-link fallback, declared-route precedence, backend JSON 404s, exact static serving, unset-disables, bad-dir-fails-boot) plus my #1453 N1 query-string edge and the new cache semantics. Run by me: 11/11 (embedded in the gateway suite's 829 pass — 818 + these 11). The spec builds a real Nest+Fastify app mirroring main.ts mount order against a fixture dist dir — the right harness.
serve-spa fixes correct:isBackendPath now splits the query string before matching (closes /api?x=1); immutable caching for /assets/ via an onSend hook with the documented reason (@fastify/static applies its own computed cache-control after setHeaders, which would override); non-asset paths keep revalidation semantics — asserted both directions in the spec.
CI wiring sound: ci.yml gains the canonical build mirror after typecheck; publish.yml's e2e step boots the real gateway dist on the embedded PGlite path with HOME and cwd isolation (matching database.module.ts's HOME-keyed PGlite dir), a 90s readiness loop that also detects gateway death and dumps the log, depends_on: [build, verify, publish-next-npm] honoring the #1411 serialization invariant, and both kaniko publishes gate on e2e — a red bundle never ships an image. Playwright image pinned v1.58.2-noble matching @playwright/test ^1.58.2 in the lockfile (bump-together note in the comment). The BETTER_AUTH_SECRET literal is a documented throwaway for the per-pipeline ephemeral local DB — not a credential (verified: value is inert prose, DB is mktemp-HOME-scoped PGlite).
E2E hardening verified by read: globalSetup seeds through the real /api/bootstrap/* + better-auth admin APIs and fails loud on any seeding failure ("a gate whose suites silently skip would pass while proving nothing") while staying no-op against populated environments; the Origin-header CSRF workaround matches better-auth's documented check; loginAs waitForURL-swallow preserves the skip guards; playwright.config flips to the gateway-served bundle (P5 parity) with CI list reporter and single worker.
verify-release mirror updated correctly:build moved into the enforced pnpm-stage mirror set (read the assertion — it now discriminates a ci.yml missing the build step); checkout suite 41/41.
Housekeeping: supertest + @types/supertest added as devDeps; eslint's stale **/.next/** ignore dropped (my #1453 delta N1); Playwright artifacts ignored.
Gates run by me at this head (fresh worktree, build-first): build rc=0; gateway suite 829 pass / 45 skip, incl. serve-spa 11/11; typecheck 45/45; lint 25/25; format rc=0; test:checkout 41/41. (First suite run failed 66 files with Failed to resolve entry for @mosaicstack/db — the known fresh-worktree missing-build state, not the PR; resolved by building workspace deps first, consistent with my #1434 note.)
Non-blocking:
[N1]retries: 2 in CI can mask a first-run flake as a green gate. The rehearsal (two rounds, 0 flaky) is good evidence today; if the gate ever passes only on retry, treat the retry count as consumed and fix the flake rather than absorbing it.
[N2] The image-tag ↔ lockfile @playwright/test coupling is comment-enforced; a structure-test assertion (same style as the build-mirror check) would make the drift mechanically caught. Future nicety.
CI note: 2890 running on this head at review time — its build step and (post-merge) the trunk e2e step are this PR's own verification. Merge waits on 2890 terminal green.
**APPROVED — rev-code-01, pinned to head `311b4dda599e6d9133169f6339ce7adbe6ef2e39`** (re-verified unmoved immediately before posting).
Scope: 15 files, +451/−77 — the P6 CI gate. Verified independently:
1. **`serve-spa.e2e.spec.ts` is exactly my #1453 SF1 ask, closed:** 11 tests pinning all six serving behaviors I listed (deep-link fallback, declared-route precedence, backend JSON 404s, exact static serving, unset-disables, bad-dir-fails-boot) plus my #1453 N1 query-string edge and the new cache semantics. **Run by me: 11/11** (embedded in the gateway suite's 829 pass — 818 + these 11). The spec builds a real Nest+Fastify app mirroring main.ts mount order against a fixture dist dir — the right harness.
2. **serve-spa fixes correct:** `isBackendPath` now splits the query string before matching (closes `/api?x=1`); immutable caching for `/assets/` via an `onSend` hook with the documented reason (`@fastify/static` applies its own computed cache-control after `setHeaders`, which would override); non-asset paths keep revalidation semantics — asserted both directions in the spec.
3. **CI wiring sound:** ci.yml gains the canonical `build` mirror after typecheck; publish.yml's `e2e` step boots the real gateway `dist` on the embedded PGlite path with HOME **and** cwd isolation (matching `database.module.ts`'s HOME-keyed PGlite dir), a 90s readiness loop that also detects gateway death and dumps the log, `depends_on: [build, verify, publish-next-npm]` honoring the #1411 serialization invariant, and **both kaniko publishes gate on `e2e`** — a red bundle never ships an image. Playwright image pinned `v1.58.2-noble` matching `@playwright/test ^1.58.2` in the lockfile (bump-together note in the comment). The `BETTER_AUTH_SECRET` literal is a documented throwaway for the per-pipeline ephemeral local DB — not a credential (verified: value is inert prose, DB is `mktemp`-HOME-scoped PGlite).
4. **E2E hardening verified by read:** globalSetup seeds through the real `/api/bootstrap/*` + better-auth admin APIs and **fails loud on any seeding failure** ("a gate whose suites silently skip would pass while proving nothing") while staying no-op against populated environments; the Origin-header CSRF workaround matches better-auth's documented check; `loginAs` waitForURL-swallow preserves the skip guards; playwright.config flips to the gateway-served bundle (P5 parity) with CI `list` reporter and single worker.
5. **verify-release mirror updated correctly:** `build` moved into the enforced pnpm-stage mirror set (read the assertion — it now discriminates a ci.yml missing the build step); checkout suite 41/41.
6. **Housekeeping:** supertest + @types/supertest added as devDeps; eslint's stale `**/.next/**` ignore dropped (my #1453 delta N1); Playwright artifacts ignored.
7. **Gates run by me at this head (fresh worktree, build-first):** build rc=0; gateway suite **829 pass / 45 skip, incl. serve-spa 11/11**; typecheck 45/45; lint 25/25; format rc=0; test:checkout 41/41. (First suite run failed 66 files with `Failed to resolve entry for @mosaicstack/db` — the known fresh-worktree missing-build state, not the PR; resolved by building workspace deps first, consistent with my #1434 note.)
Non-blocking:
- **[N1]** `retries: 2` in CI can mask a first-run flake as a green gate. The rehearsal (two rounds, 0 flaky) is good evidence today; if the gate ever passes only on retry, treat the retry count as consumed and fix the flake rather than absorbing it.
- **[N2]** The image-tag ↔ lockfile `@playwright/test` coupling is comment-enforced; a structure-test assertion (same style as the build-mirror check) would make the drift mechanically caught. Future nicety.
CI note: 2890 running on this head at review time — its `build` step and (post-merge) the trunk `e2e` step are this PR's own verification. Merge waits on 2890 terminal green.
- M1: E2E_REQUIRE_SEEDED_AUTH=1 in the CI e2e step makes login failures hard
failures (loginAs throws, guards disabled, globalSetup refuses a pre-populated
DB); auth.spec redirect test asserts outright under the flag; non-admin
/admin test is now a real authorization assertion
- M2: ci.yml build step depends_on test — never two concurrent turbo builds
on the shared workspace
- M3: unknown /assets/* paths 404 from the SPA catch-all instead of serving
index.html with an immutable cache header; spec arm added
- minors: e2e step gets when: *image_build_when, health poll uses
GATEWAY_PORT + AbortSignal.timeout, BETTER_AUTH_SECRET generated per run
(no literal in tree), failure echoes artifact path, dev-guide documents the
gate, stale verify-release comment fixed
fred
dismissed rev-code-01's review 2026-08-27 14:53:04 +00:00
Reason:
New commits pushed, approval review dismissed automatically according to repository settings
rev-code-01
approved these changes 2026-08-27 15:00:22 +00:00
APPROVED (delta re-review) — rev-code-01, pinned to head 54adbd2b3d78c6b85e346e48b988e6ca1901ec98 (sole parent = my reviewed 311b4dda, review id 338; head re-verified unmoved immediately before posting).
Delta commit read in full; every fix verified:
M1 (seeded-auth hard gate) — correct and complete.E2E_REQUIRE_SEEDED_AUTH=1 set in the CI e2e step; loginAs throws on missed redirect under the flag (wait not swallowed); globalSetup REFUSES a pre-populated DB under the flag (isolation-regression detection, not skip-and-pass); I swept all nine test.skip guard sites — every one is now !REQUIRE_SEEDED_AUTH && … (admin/chat/navigation×2/projects/settings/auth-redirect), so the flag disables every skip path; auth.spec asserts outright under the flag. The live-env affordance survives with the flag unset. This closes the strongest version of my N1 concern (a login regression could previously go green via universal skips — now structurally impossible in CI).
M2 (ci.yml serialization) — correct.build now depends_on: test (not typecheck): turbo gives test a ^build dependency, so concurrent step runs would race two turbo builds on the shared workspace dist/cache with no cross-process locking — the same invariant publish.yml documents for #1411. Right fix, right comment.
M3 (assets 404) — correct, real cache-poisoning hole closed. The catch-all now 404s /assets* paths instead of serving index.html — the stale-index.html browser would otherwise have cached an HTML body under a year-long immutable header. Spec arm added asserting 404 + no-immutable + no-SPA-body, including the query-string variant. Run by me: serve-spa spec now 12/12 (was 11), gateway suite 830 pass / 45 skip (829+1).
Minors all verified: e2e step carries when: *image_build_when (same filter as the steps it gates; skipped-dependency semantics keep docs-only main merges free of the browser suite); health poll uses $GATEWAY_PORT + AbortSignal.timeout(2000); BETTER_AUTH_SECRET generated per run from /dev/urandom — no literal in the tree at all now (better than the disclosed-placeholder state I previously accepted); failure output names the artifact path; dev-guide documents the gate with a local repro recipe (Fedora-headless caveat included); the stale verify-release comment corrected.
Gates at this head, run by me (fresh worktree, build-first): build rc=0; gateway 830/45 incl. serve-spa 12/12; typecheck 45/45; lint 25/25; format rc=0; checkout 41/41.
N2 from my id-338 review (image-tag ↔ lockfile coupling as a structure assertion) remains the acknowledged follow-up, unchanged by this delta.
CI note: 2890 (previous head) terminal success; 2891 running on this head — merge waits on 2891 terminal green.
**APPROVED (delta re-review) — rev-code-01, pinned to head `54adbd2b3d78c6b85e346e48b988e6ca1901ec98`** (sole parent = my reviewed `311b4dda`, review id 338; head re-verified unmoved immediately before posting).
Delta commit read in full; every fix verified:
1. **M1 (seeded-auth hard gate) — correct and complete.** `E2E_REQUIRE_SEEDED_AUTH=1` set in the CI e2e step; `loginAs` throws on missed redirect under the flag (wait not swallowed); globalSetup REFUSES a pre-populated DB under the flag (isolation-regression detection, not skip-and-pass); I swept all nine `test.skip` guard sites — every one is now `!REQUIRE_SEEDED_AUTH && …` (admin/chat/navigation×2/projects/settings/auth-redirect), so the flag disables every skip path; auth.spec asserts outright under the flag. The live-env affordance survives with the flag unset. This closes the strongest version of my N1 concern (a login regression could previously go green via universal skips — now structurally impossible in CI).
2. **M2 (ci.yml serialization) — correct.** `build` now `depends_on: test` (not typecheck): turbo gives `test` a `^build` dependency, so concurrent step runs would race two turbo builds on the shared workspace dist/cache with no cross-process locking — the same invariant publish.yml documents for #1411. Right fix, right comment.
3. **M3 (assets 404) — correct, real cache-poisoning hole closed.** The catch-all now 404s `/assets*` paths instead of serving index.html — the stale-index.html browser would otherwise have cached an HTML body under a year-long immutable header. Spec arm added asserting 404 + no-immutable + no-SPA-body, including the query-string variant. **Run by me: serve-spa spec now 12/12** (was 11), gateway suite **830 pass / 45 skip** (829+1).
4. **Minors all verified:** e2e step carries `when: *image_build_when` (same filter as the steps it gates; skipped-dependency semantics keep docs-only main merges free of the browser suite); health poll uses `$GATEWAY_PORT` + `AbortSignal.timeout(2000)`; `BETTER_AUTH_SECRET` generated per run from `/dev/urandom` — no literal in the tree at all now (better than the disclosed-placeholder state I previously accepted); failure output names the artifact path; dev-guide documents the gate with a local repro recipe (Fedora-headless caveat included); the stale verify-release comment corrected.
5. **Gates at this head, run by me (fresh worktree, build-first):** build rc=0; gateway **830/45 incl. serve-spa 12/12**; typecheck 45/45; lint 25/25; format rc=0; checkout 41/41.
N2 from my id-338 review (image-tag ↔ lockfile coupling as a structure assertion) remains the acknowledged follow-up, unchanged by this delta.
CI note: 2890 (previous head) terminal **success**; 2891 running on this head — merge waits on 2891 terminal green.
CI 2891 failed on an unhandled ReferenceError: the 2s
setTimeout(() => setSaveState('idle')) in the settings tabs fired after
jsdom teardown. Replace the three uncleaned timers with one
useSavedBadgeReset effect hook that clears on unmount.
fred
dismissed rev-code-01's review 2026-08-27 15:07:37 +00:00
Reason:
New commits pushed, approval review dismissed automatically according to repository settings
rev-code-01
approved these changes 2026-08-27 15:09:44 +00:00
APPROVED (delta re-review) — rev-code-01, pinned to head f3e1a761aafdb0c8995ec60ad2b60f7b33909628 (sole parent = my reviewed 54adbd2b, review id 339; head re-verified unmoved immediately before posting).
Single-file delta (+13/−3, settings.tsx) read in full — the flake root-cause fix:
The hook is the correct React pattern.useSavedBadgeReset arms the 2s reset only when saveState === 'saved', returns a clearTimeout cleanup, and re-runs on state change — so the timer never outlives the tab (unmount clears it; a re-save replaces it). All three raw setTimeout call sites (Profile/Appearance/Notifications tabs) removed, hook mounted at each; zero raw timers remain in the file (grep); useEffect already imported.
This is the root-cause class my N1 stance asks for: the CI 2891 failure is itself the red control — the raw timer fired into torn-down jsdom (window is not defined, unhandled) while all 292 tests passed; the fix removes the timer at teardown by construction (the spec's afterEach unmount triggers the cleanup). The suite carries no explicit timer assertion — the discriminating evidence is 2891-red → suite-green-with-no-banner, plus 2892 (running) as the CI-side confirmation.
Gates at this head, run by me: web build rc=0; web suite 292/292 with zero unhandled-banner hits (settings spec 4/4); web tsc --noEmit clean; repo format rc=0.
CI note: 2891 failed at the test step exactly as described; 2892 running on this head — merge waits on 2892 terminal green.
**APPROVED (delta re-review) — rev-code-01, pinned to head `f3e1a761aafdb0c8995ec60ad2b60f7b33909628`** (sole parent = my reviewed `54adbd2b`, review id 339; head re-verified unmoved immediately before posting).
Single-file delta (+13/−3, `settings.tsx`) read in full — the flake root-cause fix:
1. **The hook is the correct React pattern.** `useSavedBadgeReset` arms the 2s reset only when `saveState === 'saved'`, returns a `clearTimeout` cleanup, and re-runs on state change — so the timer never outlives the tab (unmount clears it; a re-save replaces it). All three raw `setTimeout` call sites (Profile/Appearance/Notifications tabs) removed, hook mounted at each; zero raw timers remain in the file (grep); `useEffect` already imported.
2. **This is the root-cause class my N1 stance asks for:** the CI 2891 failure is itself the red control — the raw timer fired into torn-down jsdom (`window is not defined`, unhandled) while all 292 tests passed; the fix removes the timer at teardown by construction (the spec's `afterEach` unmount triggers the cleanup). The suite carries no explicit timer assertion — the discriminating evidence is 2891-red → suite-green-with-no-banner, plus 2892 (running) as the CI-side confirmation.
3. **Gates at this head, run by me:** web build rc=0; web suite **292/292 with zero unhandled-banner hits** (settings spec 4/4); web `tsc --noEmit` clean; repo format rc=0.
CI note: 2891 failed at the test step exactly as described; 2892 running on this head — merge waits on 2892 terminal green.
fred
merged commit e605c83b27 into next2026-08-27 15:26:11 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #1445 (S4 Phase P6).
What this delivers
ci.ymlgains abuildstep (turbovite build) aftertest, so a PR that breaks the production bundle fails before merge.publish.ymlgains ane2estep: it boots the gateway from the builtdiston an embedded throwaway PGlite database, then runs the Playwright suite headless against the SPA bundle served by the gateway itself — the same artifact shape production serves (#1407 parity). Both kaniko image publishes nowdepends_onthe e2e step, so a red suite blocks the image.serve-spa.tsstrips query strings before asset resolution, and hashed assets getcache-control: public, max-age=31536000, immutablevia an onSend hook. Newserve-spa.e2e.spec.ts(12 tests) covers both, plus the missing-asset 404 below.Design decisions
mcr.microsoft.com/playwright:v1.58.2-noble: matches the repo's@playwright/testversion; browsers preinstalled, no download in CI. Fedora hosts render headless text at zero size (unsupported distro), so the Ubuntu image is also the only reproducible runner.better-sqlite3(native, in the better-auth peer graph) would fail if the install had happened under musl — not the case in this pipeline, but flagging it for reviewers.depends_on: [build, verify, publish-next-npm], and only the kaniko steps gate on it.$HOME/.config/mosaic/gateway/pglite(database.module.ts), not cwd. The e2e step therefore launches the gateway withHOMEpointed at a freshmktemp -dso each pipeline run gets a virgin database and the globalSetup bootstrap path is deterministic.BETTER_AUTH_SECRETis generated per run from/dev/urandominside the step — the gateway refuses to boot without one; no usable literal lives in the tree.E2E suite hardening
The pre-existing suite (from #152) was effectively a no-op against the current UI:
test.skip(!url.includes('/chat'))guards readpage.url()before the post-login redirect, so 27 of 41 specs silently skipped even on successful login.loginAsnowwaitForURL(/\/chat/)(swallowed on timeout so the skip guards still work for unseeded envs)./api/bootstrap/*and better-auth admin APIs. better-auth's CSRF check rejects server-side fetches without anOriginheader (403MISSING_OR_NULL_ORIGIN), so setup sends the gateway's own origin./new; brand block is a logo image, not a link; active nav usesfont-medium). One test asserting a removed "Active Mission" section was deleted.level: 1heading queries (empty-state h2s also matched loose patterns) and.or(...).first()auto-retrying locators instead of non-retryingisVisible().catch().Verification
playwright:v1.58.2-noblevia podman (workspace-mounted, same env). Rounds 5 and 6: 40 passed, 0 failed/skipped/flaky, rc=0 both times.test:checkout41/41, typecheck, lint, format, gateway vitest 829 passed,pnpm buildclean — all local.test-results/,playwright-report/) added to.gitignore.Review rounds (heads
54adbd2b,f3e1a761)Commit
54adbd2banswers the gate-16 model review (REQUEST_CHANGES):loginAshard-fails on a missed redirect, skip-guards become live-env-only,globalSetuprefuses a pre-populated DB (HOME-isolation regression detector), and the non-admin/admintest is a real authorization assertion. Rehearsal signature: 40 passed, 0 skipped.buildnowdepends_on: [test]: turbotestcarries^build; two concurrent turbo builds on the shared workspace would race (same invariant publish.yml documents for #1411)./assets/*paths 404 instead of falling through to the SPA page — which the onSend hook would have stamped with a year-long immutable cache-control (real cache-poisoning hole; spec arm added).when: *image_build_whenon the e2e step,$GATEWAY_PORThealth poll withAbortSignal.timeout, generated secret, artifact-path echo on failure, dev-guide "E2E Gate" section.Commit
f3e1a761is out of P6 scope on its face and is called out deliberately: CI 2891 on54adbd2bredded@mosaicstack/web#teston a pre-existing flake — the settings tabs' uncleaned 2ssetSaveState('idle')timers firing after jsdom teardown (all 292 tests passed; vitest fails the run on the unhandled error). A pipeline re-run is not available to this seat, and the root-cause fix is right regardless: oneuseSavedBadgeReseteffect hook withclearTimeoutcleanup replaces the three raw timers inapps/web/src/spa/pages/settings.tsx.Deferred follow-ups (agreed non-blocking across both reviews): e2e tree outside typecheck/lint coverage; corepack network fetch in the e2e step;
@playwright/testcaret vs pinned image tag; percent-encoded/assetsvariants;retries: 2flake masking; publish-gate structure assertion for the e2ewhen/depends_oncoupling; vitest teardown guard for the post-teardown-timer failure class.🤖 Generated with Claude Code
https://claude.ai/code/session_01XnH8KTL4PNTpEnpubcmiRn
APPROVED — rev-code-01, pinned to head
311b4dda599e6d9133169f6339ce7adbe6ef2e39(re-verified unmoved immediately before posting).Scope: 15 files, +451/−77 — the P6 CI gate. Verified independently:
serve-spa.e2e.spec.tsis exactly my #1453 SF1 ask, closed: 11 tests pinning all six serving behaviors I listed (deep-link fallback, declared-route precedence, backend JSON 404s, exact static serving, unset-disables, bad-dir-fails-boot) plus my #1453 N1 query-string edge and the new cache semantics. Run by me: 11/11 (embedded in the gateway suite's 829 pass — 818 + these 11). The spec builds a real Nest+Fastify app mirroring main.ts mount order against a fixture dist dir — the right harness.isBackendPathnow splits the query string before matching (closes/api?x=1); immutable caching for/assets/via anonSendhook with the documented reason (@fastify/staticapplies its own computed cache-control aftersetHeaders, which would override); non-asset paths keep revalidation semantics — asserted both directions in the spec.buildmirror after typecheck; publish.yml'se2estep boots the real gatewaydiston the embedded PGlite path with HOME and cwd isolation (matchingdatabase.module.ts's HOME-keyed PGlite dir), a 90s readiness loop that also detects gateway death and dumps the log,depends_on: [build, verify, publish-next-npm]honoring the #1411 serialization invariant, and both kaniko publishes gate one2e— a red bundle never ships an image. Playwright image pinnedv1.58.2-noblematching@playwright/test ^1.58.2in the lockfile (bump-together note in the comment). TheBETTER_AUTH_SECRETliteral is a documented throwaway for the per-pipeline ephemeral local DB — not a credential (verified: value is inert prose, DB ismktemp-HOME-scoped PGlite)./api/bootstrap/*+ better-auth admin APIs and fails loud on any seeding failure ("a gate whose suites silently skip would pass while proving nothing") while staying no-op against populated environments; the Origin-header CSRF workaround matches better-auth's documented check;loginAswaitForURL-swallow preserves the skip guards; playwright.config flips to the gateway-served bundle (P5 parity) with CIlistreporter and single worker.buildmoved into the enforced pnpm-stage mirror set (read the assertion — it now discriminates a ci.yml missing the build step); checkout suite 41/41.**/.next/**ignore dropped (my #1453 delta N1); Playwright artifacts ignored.Failed to resolve entry for @mosaicstack/db— the known fresh-worktree missing-build state, not the PR; resolved by building workspace deps first, consistent with my #1434 note.)Non-blocking:
retries: 2in CI can mask a first-run flake as a green gate. The rehearsal (two rounds, 0 flaky) is good evidence today; if the gate ever passes only on retry, treat the retry count as consumed and fix the flake rather than absorbing it.@playwright/testcoupling is comment-enforced; a structure-test assertion (same style as the build-mirror check) would make the drift mechanically caught. Future nicety.CI note: 2890 running on this head at review time — its
buildstep and (post-merge) the trunke2estep are this PR's own verification. Merge waits on 2890 terminal green.New commits pushed, approval review dismissed automatically according to repository settings
APPROVED (delta re-review) — rev-code-01, pinned to head
54adbd2b3d78c6b85e346e48b988e6ca1901ec98(sole parent = my reviewed311b4dda, review id 338; head re-verified unmoved immediately before posting).Delta commit read in full; every fix verified:
E2E_REQUIRE_SEEDED_AUTH=1set in the CI e2e step;loginAsthrows on missed redirect under the flag (wait not swallowed); globalSetup REFUSES a pre-populated DB under the flag (isolation-regression detection, not skip-and-pass); I swept all ninetest.skipguard sites — every one is now!REQUIRE_SEEDED_AUTH && …(admin/chat/navigation×2/projects/settings/auth-redirect), so the flag disables every skip path; auth.spec asserts outright under the flag. The live-env affordance survives with the flag unset. This closes the strongest version of my N1 concern (a login regression could previously go green via universal skips — now structurally impossible in CI).buildnowdepends_on: test(not typecheck): turbo givestesta^builddependency, so concurrent step runs would race two turbo builds on the shared workspace dist/cache with no cross-process locking — the same invariant publish.yml documents for #1411. Right fix, right comment./assets*paths instead of serving index.html — the stale-index.html browser would otherwise have cached an HTML body under a year-long immutable header. Spec arm added asserting 404 + no-immutable + no-SPA-body, including the query-string variant. Run by me: serve-spa spec now 12/12 (was 11), gateway suite 830 pass / 45 skip (829+1).when: *image_build_when(same filter as the steps it gates; skipped-dependency semantics keep docs-only main merges free of the browser suite); health poll uses$GATEWAY_PORT+AbortSignal.timeout(2000);BETTER_AUTH_SECRETgenerated per run from/dev/urandom— no literal in the tree at all now (better than the disclosed-placeholder state I previously accepted); failure output names the artifact path; dev-guide documents the gate with a local repro recipe (Fedora-headless caveat included); the stale verify-release comment corrected.N2 from my id-338 review (image-tag ↔ lockfile coupling as a structure assertion) remains the acknowledged follow-up, unchanged by this delta.
CI note: 2890 (previous head) terminal success; 2891 running on this head — merge waits on 2891 terminal green.
CI 2891 failed on an unhandled ReferenceError: the 2s setTimeout(() => setSaveState('idle')) in the settings tabs fired after jsdom teardown. Replace the three uncleaned timers with one useSavedBadgeReset effect hook that clears on unmount.New commits pushed, approval review dismissed automatically according to repository settings
APPROVED (delta re-review) — rev-code-01, pinned to head
f3e1a761aafdb0c8995ec60ad2b60f7b33909628(sole parent = my reviewed54adbd2b, review id 339; head re-verified unmoved immediately before posting).Single-file delta (+13/−3,
settings.tsx) read in full — the flake root-cause fix:useSavedBadgeResetarms the 2s reset only whensaveState === 'saved', returns aclearTimeoutcleanup, and re-runs on state change — so the timer never outlives the tab (unmount clears it; a re-save replaces it). All three rawsetTimeoutcall sites (Profile/Appearance/Notifications tabs) removed, hook mounted at each; zero raw timers remain in the file (grep);useEffectalready imported.window is not defined, unhandled) while all 292 tests passed; the fix removes the timer at teardown by construction (the spec'safterEachunmount triggers the cleanup). The suite carries no explicit timer assertion — the discriminating evidence is 2891-red → suite-green-with-no-banner, plus 2892 (running) as the CI-side confirmation.tsc --noEmitclean; repo format rc=0.CI note: 2891 failed at the test step exactly as described; 2892 running on this head — merge waits on 2892 terminal green.