diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 0776b7d236..14d4dc1841 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -307,3 +307,9 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-30 | PR-1497 | 978459f8788568be7aa0dd4d6a8b309a3d2d077e | PR #1497 final current-main review and typecheck repair | APPROVE after fixing Error.code narrowing in the offline readiness test; no remaining P0-P2 findings. | check:codex-cloud PASS; full Vitest 444 files / 4644 passed / 3 skipped; readiness focused 6/6; tsc --noEmit PASS; issue and ledger guards PASS; Prettier and diff checks PASS | | 2026-07-30 | PR-1497 | 5bcf26b12b89e89539a3dfc903155cc49475a68b | PR #1497 append-only ledger reconciliation | APPROVE: existing ledger order restored; exact PR diff is three append-only review rows; no remaining findings | typecheck PASS on repaired code; parent unit coverage PASS; branch-review-ledger PASS; unresolved threads 0; fresh hosted CI required | | 2026-07-30 | PR-1497 | b0243464df533a94199b670f1bf0563d84d3f4d6 | PR #1497 combined exact-head review after concurrent main sync | APPROVE; retained all append-only records and the type-safe timeout diagnostic; no remaining P0-P2 findings. | check:codex-cloud PASS; full Vitest 444 files / 4644 passed / 3 skipped; readiness 6/6; tsc --noEmit PASS; issue/ledger/format/diff/final audit PASS | +| 2026-07-30 | codex/playwright-container-alignment | 5ce50f64993a43efb00c4f8cfa86c26c895b8532 | issue 121 container browser fallback | approved: managed browser remains preferred; immutable-container fallback is explicit, newest-compatible, logged, unit-pinned, and launch-proven | verify:cheap 443 files/4631 pass; focused vitest 37/37; fallback Chromium launch; focused Playwright 1/1; check:rag:fixtures; outstanding guard; diff check | +| 2026-07-30 | codex/playwright-container-alignment | 70a603087ae7ea9c0e6db0701aa13ffdddb79081 | preinstalled Chromium fallback | P2 fixed: Linux fallback now filters by process architecture, preventing an x64-only shell from being selected on arm64. No remaining findings. | 2 files/38 tests; Prettier; ESLint; git diff --check | +| 2026-07-30 | codex/playwright-container-alignment | 936cab24f00202081aad780f8712152409a212d3 | container browser fallback review fixes | approved: automated P2s fixed by architecture filtering and designated /opt/pw-browsers root; generic stale caches fail closed | focused vitest 11/11; ESLint; Prettier; outstanding guard; diff check | +| 2026-07-30 | PR-1507 | 12e5c8c977a873a7b900596fdc310a1a78d863f0 | PR #1507 final current-main review | APPROVE; architecture-specific container lookup and immutable-root fail-closed fallback are preserved after current-main reconciliation; no remaining P0-P2 findings. | focused Vitest 2 files / 40 tests PASS; tsc --noEmit PASS; issue/ledger/format/diff guards PASS | +| 2026-07-30 | PR-1507 | 12e5c8c977a873a7b900596fdc310a1a78d863f0 | PR #1507 current-main merge and ledger normalization | APPROVED — no findings after current-main sync; feature diff unchanged and ledger reduced to three unique append-only review rows. | focused Vitest 2 files/40 tests PASS; outstanding-issues PASS; branch-review-ledger PASS; diff check PASS; merge-tree f8f88bd79706c2fb36637014a25e93af6e08eb99 | +| 2026-07-30 | PR-1507 | 5e22b89f7bdb73335d12a0cf4091915615b20dd7 | PR #1507 remote ancestry reconciliation | APPROVED — identical-tree remote merge ancestry reconciled without content change; no remaining findings. | focused Vitest 2 files/40 tests PASS on identical tree; issue and ledger guards PASS; diff check PASS; merge-tree d6594063a4aa2c5f8b7a9ec72c1c41c94e6937fa | diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index f67a75cb8f..5c3d84ecb2 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -133,7 +133,6 @@ removed after current-main verification; it is not missing recommended work. | #101 | P3 | rec | Canary-gated retrieval parallelisation candidates | **Outcome:** independent retrieval stages stop running serially, proven by a live canary pair. Candidates: metadata/memory/visual hydration triples repeated on four branches (`rag.ts:2460,2493,2521` and three more) while `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all`, so the omission is inconsistency rather than intent; the nested `await`-in-loop scope enumeration (`search-scope.ts:202,328`); typeahead results never cached (`rag.ts:2698-2711`); universal-search coalescing (`/api/search` has it, `/api/search/universal` does not). Each changes candidate assembly, truncation, or what the next keystroke returns, so each needs 36/36 retrieval plus recall 1.0 and zero per-case rr regressions. Distinct from #001 (semantic rerank). Resolved #075 and #083 are the precedents for why these are gated rather than free. **Stop:** needs the #098 harness and explicit canary approval first. | `docs/audit/latency-audit-2026-07-28.md` L2-1/L2-2/L2-8/L1-5 | 2026-07-29 | | #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Runbook prepared 2026-07-29 — NOT applied, item stays open:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive, though **the "recall is byte-identical" claim was RETRACTED on 2026-07-29 review**: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with no `ORDER BY`, so a new index can change which title-alias documents feed candidate assembly. Only the documents-list use stays ordering-safe; `(status,id)` is canary-gated too — see runbook, and making that `.limit(12)` deterministic first does **not** lift the gate — an unordered `LIMIT` has no stable selection to preserve, so imposing an order can pick a different twelve and is itself an ordering behaviour change on a retrieval surface, which AGENTS.md requires a canary pair for. Sequencing the ordering fix first is worthwhile (unordered `LIMIT` on a retrieval input is latent nondeterminism regardless) but yields two canary-gated changes, not one (PR #1377 review). **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** **author the migration first** — `supabase/migrations/` is the source of truth and `schema.sql` only a mirror, so hand-run operator SQL never reaches staging, disaster-recovery replay, or a local `supabase db reset`, and a `required_indexes` registration would fail there (PR #1377 review); follow the `20260717170000_registry_projection_cleanup.sql` idempotent pattern. **That migration must also carry the health-function change** — `required_indexes` lives inside `search_schema_health()`, which is redefined by `create or replace function` in eleven migrations (copy `20260705180000_reconcile_search_health_indexes.sql:62`); editing `schema.sql:3177` alone moves only the mirror and leaves the indexes unmonitored on hosted (PR #1377 review). Then apply concurrently, confirm `indisvalid`, mirror both the index statements and the identical function body into `schema.sql`, run `npm run drift:manifest` (Docker), and deploy the migration LAST — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. **Rollback is three deployed phases, not the reverse of one:** retract `required_indexes` via its own `create or replace function` migration and deploy → drop concurrently live → only then deploy the `schema.sql` removal plus an idempotent forward `drop index if exists` migration, because Supabase wraps migrations in a transaction and a plain `DROP INDEX` there takes the lock the concurrent procedure exists to avoid (PR #1377 review). | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | | #103 | P2 | issue | Wide table-facts trigram index missing from `schema.sql` | **Outcome:** the migration chain and `schema.sql` agree on `document_table_facts` trigram indexes. `supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql` creates a wide 5-column trigram index that is **absent from `supabase/schema.sql`**, so local replay and the live database can diverge. Distinct from #102: different owner and verification path. **Next:** confirm whether the wide index exists live, then take one of exactly two routes — **retained:** mirror `document_table_facts_text_trgm_idx` into `supabase/schema.sql` beside the narrow one and regenerate `drift-manifest.json`; **redundant:** drop it through a new forward migration, never by deleting `20260714190000`. **`drift-allowlist.json` is NOT a third option** (PR #1377 review): its own header scopes it to _"Known live-vs-`schema.sql` divergence"_, so it can silence a live drift finding but cannot reconcile the migration chain with the mirror — a fresh `supabase db reset` still runs `20260714190000` and creates the index while `schema.sql` still omits it, leaving this row's stated outcome unmet. **No offline gate catches this today:** the migration↔`schema.sql` parity test (`tests/drift-detection.test.ts:59-68`) only asserts one migration's `schema_drift_snapshot` function definition, not an index inventory — which is why this sits open rather than red in CI, and why a replay-to-schema inventory comparison is the check that would have caught it. Note the narrow `document_table_facts_title_row_param_trgm_idx` (`schema.sql:6425`) is the one the effective RPC expression (`:6726`) actually matches, so the wide index may be genuinely redundant — do not drop it without live scan evidence, per the monitored-not-auto-fixed index policy. | `docs/audit/latency-audit-2026-07-28.md` limitations; `npm run check:drift` | 2026-07-29 | -| #121 | P3 | issue | Container Playwright browser build lags the pinned client | **Outcome:** browser gates run in remote sessions without hand-patching. **Evidence 2026-07-30:** the repo's Playwright client resolves headless-shell build `1234`; the container image provides `1194` at `/opt/pw-browsers`, so every browser test fails at launch. Worked around in-session by symlinking `chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell` to the `1194` `headless_shell` binary plus its sibling resources — container-local, nothing committed, and it disappears with the session. `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` means the mismatch cannot self-heal. **Next:** decide whether the image pins the browser build or the repo pins a client matching the image; until then any remote session claiming browser proof must state which it used. **Added 2026-07-30 (session closing `#120`):** the mismatch reproduced unchanged on `main` at `c5c1a86` — `npx playwright install --dry-run chromium` reports `chromium v1234` while `/opt/pw-browsers` holds only `chromium-1194` and `chromium_headless_shell-1194`, and one `verify:phone-chrome` run lost all 13 browser tests at launch. It has now been misread twice: the 2026-07-30 handoff records 13 launch failures taken as "my change is wrong", and `#120` was filed as a gate defect from a reading taken under this condition (closed as not reproducible; the gate exits 1 correctly). **Detection, before trusting or filing anything from a browser gate:** compare `npx playwright install --dry-run chromium` against `ls /opt/pw-browsers`. **Stop:** do not file a gate defect from a run whose tests never launched — zero assertions executed, so the output describes the environment, not the diff. **Correction 2026-07-30 — the symlink workaround is not always available, so this row's own evidence overstates the escape hatch.** In a Claude Code remote session the sandbox **refused** `mkdir`/`ln -s` under `/opt/pw-browsers` (permission denied by the auto-mode classifier, not by file permissions — the directory itself is writable). So a sandboxed session has no way to bridge the builds, and the honest options reduce to two: request the write permission explicitly, or state that no browser evidence is available and leave the browser claim unmade. **Retracted the same day, by me:** the sentence above claiming the options "reduce to two" was wrong, and it is left standing rather than deleted because the retraction is the useful part. There is a third route that needs no filesystem write at all: **`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`**, read by `playwright.config.ts:11` and honoured by the preflight (`scripts/playwright-browser-preflight.mjs:101`), pointed at the container's existing `1194` binary. Verified by launching it, not by reading the flag: `chromium.launch({ executablePath: "/opt/pw-browsers/chromium_headless_shell-1194/chrome-linux/headless_shell" })` under the repo's Playwright 1.62 client reported `version 141.0.7390.37`, rendered a page and measured a `boundingBox` of the expected height. So a sandboxed remote session **can** produce browser evidence, and the earlier claim that it cannot was an over-generalisation from one blocked `mkdir`. The mismatch itself is unchanged and this row stays open — the point is that its consequence is a one-env-var workaround, not a hard stop. **Related, landed 2026-07-30 (PR #1432, `3054d685`):** `assertPlaywrightBrowsersReady` now runs inside `scripts/run-playwright.mjs`, so a missing binary exits `1` with one explicit message naming the override, instead of surfacing as N tests "failing" at launch — the misdiagnosis that produced `#120`. | `docs/testing.md`; container `/opt/pw-browsers` | 2026-07-30 | | #117 | P2 | rec | Therapy Compass catalogue payload is the mobile LCP outlier | **Outcome:** `/therapy-compass` mobile LCP lands near the other mobile routes instead of double them. **Measured 2026-07-30** by the new pre-merge Lighthouse budget: mobile LCP 5229 ms, TBT 612 ms, CLS 0.142, against 2123-2460 ms on every other mobile route and 826 ms on desktop — so it is client-side work under mobile CPU/network throttling, not server latency. **Cause:** `useTherapyData` fetches `public/therapy-compass-data/therapies-index.json` (690 KB raw, 139 KB gzipped, 205 records x 16 fields) for the home/search/pathways screens, so the download plus JSON parse sits on the critical path before content paints. 90% of that weight is long-form clinical prose — indications 159 KB (26%), contraindicationsOrCautions 139 KB (23%), bestUsedFor 73 KB (12%), clinicalSummary 67 KB (11%), patientPopulation 59 KB (10%), targetSymptoms 48 KB (8%) — while name, slug, category, tags and setting together are 54 KB (7%). **Blocked on one decision per field group: rendered on the card, matched by search, or neither.** `therapy-card.tsx` references five of those prose fields and the same index feeds the search screen, so stripping fields could silently change clinical display or search recall. **Next:** settle that per-field question, then either pre-truncate prose that only feeds card display, or move search matching server-side / load prose on first keystroke. **Gate:** `check:therapy-data-index` plus the therapy Playwright journeys; re-measure with `npm run verify:lighthouse`. **Stop:** do not drop a field from the catalogue payload without confirming no card renders it and no search path matches on it. Same class as #013 (route-chunk / catalogue JSON weight), different route and now measured. | session 2026-07-30 Lighthouse budget first run; PR #1404 | 2026-07-30 | | #118 | P2 | task | Adopt the visual and Lighthouse baselines so the two new gates actually gate | **Outcome:** `visual-baseline` and `lighthouse-budget` stop reporting and start blocking. **Detail:** PR #1404 added both as `continue-on-error` jobs outside `pr-required`, deliberately. `tests/ui-visual-baseline.spec.ts` has no committed baselines, so all six targets fail with a missing-snapshot error by design; the job uploads them on every run (run 30513537912, artifact 8748062487, 31 files). `lighthouse-budget.json` ships `enforce: false` with `baseline: null`, so the grader warns rather than grades. **Next:** (1) download that artifact, review the six PNGs and commit them under the platform-scoped screenshots directory that `playwright.visual.config.ts` names in its `snapshotPathTemplate` — from CI, never a developer machine, because font hinting differs between them; (2) run `npm run check:lighthouse-budget -- --update` against a known-good CI build and flip `enforce`, but not before #117 or the baseline pins a known-slow route; (3) then add each job to `pr-required` and drop `continue-on-error` in the same edit. **Also:** PR #1404 added the first rendered-effect contract for #094, but 37 of the 38 unlayered visual classes still carry exemptions in `tests/helpers/style-contracts.ts` rather than contracts; and `scripts/run-lighthouse-budget.mjs` duplicates about 50 lines of the isolated-server boot in `scripts/run-playwright.mjs`, deferred to avoid destabilising the required UI gate in the same change. **Stop:** do not make a missing baseline skip instead of fail — that is the soft-skip-green pattern `AGENTS.md` forbids. | session 2026-07-30; PR #1404 | 2026-07-30 | | #142 | P3 | task | Four loose dated docs need source and migration edits before they can be filed | **Outcome:** every dated point-in-time doc lives in `docs/audit/` or `docs/archive/` as `docs/README.md` requires, not loose at the `docs/` top level. **Detail:** PR #1436 filed the five that were docs-only moves. These four are referenced from outside `docs/`, so relocating them means editing source, tests and migration SQL comments — a different risk class than a docs tidy, and not worth bundling into one: `capacity-review.md` (`scripts/soak-test.ts`), `tenancy-defense-in-depth-review.md` (`src/lib/owner-scope.ts`, `tests/owner-scope-guard.test.ts`, two migrations, `SECURITY.md`, `.claude/agents/clinical-governance-reviewer.md`), `operator-apply-july8-batch.md` (three migrations plus `supabase/schema.sql`), `scale-readiness-review.md` (one migration). Also note `forward-codify-retrieval-rpcs-workorder.md` is indexed as a completed workorder but live is still ahead of the repo on those RPCs, so archiving it would misrepresent open operator work. **Next:** treat as low priority — the docs are correctly indexed and reachable where they are; only file them if a pass is already editing those migrations. Editing applied migration SQL is subject to `npm run check:migration-role`'s immutability pin. | PR #1436; session 2026-07-30 | 2026-07-30 | @@ -169,6 +168,7 @@ Move resolved rows here with the resolution date and a one-line outcome. Keep th | #139 | rec | Change scope exposed dead outputs and appeared to over-trigger coverage | Resolved 2026-07-30. Unconsumed `source_changed`, `workflow_changed`, and `changed_files` job outputs were removed. The remaining coverage breadth is now an explicit fail-safe policy: every non-documentation change, including workflow-only edits, runs unit coverage because those files can alter test setup or the gate itself. Self-tests pin both cases; narrower signals still scope build, UI, database, and container work. | 2026-07-30 | | #129 | issue | GitHub update-branch ignored the custom ledger merge driver | Resolved 2026-07-30 by constraining the operation rather than trusting it as a conflict oracle. The branch-sync procedure requires `git merge-tree --write-tree` first, permits only a human-authenticated update, and directs clean custom-driver false conflicts to a local `git merge origin/main` plus ordinary push. | 2026-07-30 | | #132 | issue | Client-side push guards could be bypassed in agent environments | Resolved 2026-07-30 under the row's explicit-limitation outcome. AGENTS.md names the missing-hook and missing-`gh` fail-open paths as load-bearing operator rules; ordinary PR CI enforces formatting server-side, while the tested local sentinel remains an early auto-merge race guard where `gh` is available. No privileged `pull_request_target` execution of PR code was added. | 2026-07-30 | +| #121 | issue | Container Playwright browser build lagged the pinned client | Resolved 2026-07-30 at the repository runner boundary. Managed Playwright browsers remain preferred; only the designated download-disabled `/opt/pw-browsers` image root can select the newest platform- and architecture-compatible preinstalled headless shell and pass that exact executable to the Playwright child. The fallback is unit-pinned and was launch-proven with a simulated older container revision; generic shared or stale developer caches still fail closed. | 2026-07-30 | | ID | Type | Summary | Outcome | Resolved | | ---- | ----- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | diff --git a/docs/testing.md b/docs/testing.md index e22da818b3..4f5e7eabc3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -62,7 +62,7 @@ Reference examples: `tests/icon-button.dom.test.tsx` (accessible-name contract), ## Playwright ownership -The repository runner exclusively builds and serves each Playwright production app. It selects a safe port, verifies `/api/local-project-id`, uses an isolated `.next-playwright/` build directory, replaces provider configuration with inert loopback values, and removes its server and output on success, failure, or signal. Playwright configuration never starts a server. The production boot guard permits this demo profile only when the output is isolated, provider mode is offline, credentials are absent, and the Supabase URL is the inert `127.0.0.1:1` target. Before acquiring the heavy lock or building, the runner preflights the Chromium (or requested Firefox/WebKit) executable — including the default `chrome-headless-shell` binary and any `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` override — and exits non-zero immediately when it is missing, so a launch-infra failure cannot be mistaken for product-test failures after a multi-minute build. +The repository runner exclusively builds and serves each Playwright production app. It selects a safe port, verifies `/api/local-project-id`, uses an isolated `.next-playwright/` build directory, replaces provider configuration with inert loopback values, and removes its server and output on success, failure, or signal. Playwright configuration never starts a server. The production boot guard permits this demo profile only when the output is isolated, provider mode is offline, credentials are absent, and the Supabase URL is the inert `127.0.0.1:1` target. Before acquiring the heavy lock or building, the runner preflights the Chromium (or requested Firefox/WebKit) executable — including the default `chrome-headless-shell` binary and any `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` override — and exits non-zero immediately when it is missing, so a launch-infra failure cannot be mistaken for product-test failures after a multi-minute build. The designated download-disabled container image is the one exception: when `PLAYWRIGHT_BROWSERS_PATH` is exactly `/opt/pw-browsers` and the client-pinned shell is absent, the runner selects the newest preinstalled shell for the current platform and CPU architecture, then passes its exact path to Playwright. It logs that fallback before the build; generic shared caches still fail closed even when `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`, so a developer cache cannot silently bless a stale browser. When capturing Playwright or `verify:phone-chrome` output through a shell pipe (`cmd 2>&1 | tee …`), enable `set -o pipefail` (or avoid the pipe). Without it, bash reports the pipeline exit from `tee` (`0`) while the log still ends in `N failed` — a measurement artifact that previously looked like a green-when-broken gate (outstanding-issues #120). The Node runners themselves already propagate Playwright’s exit status. diff --git a/scripts/playwright-browser-preflight.mjs b/scripts/playwright-browser-preflight.mjs index 63c829d29b..21e6c511a9 100644 --- a/scripts/playwright-browser-preflight.mjs +++ b/scripts/playwright-browser-preflight.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { existsSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; import { chromium, firefox, webkit } from "playwright"; @@ -46,6 +46,65 @@ const CHROMIUM_HEADLESS_SHELL_LAYOUTS = Object.freeze({ "chrome-win64": ["chrome-headless-shell-win64", "chrome-headless-shell.exe"], }); +const PREINSTALLED_CHROMIUM_LAYOUTS = Object.freeze({ + linux: { + x64: [ + ["chrome-headless-shell-linux64", "chrome-headless-shell"], + ["chrome-linux", "headless_shell"], + ], + arm64: [["chrome-linux", "headless_shell"]], + }, + darwin: { + x64: [["chrome-headless-shell-mac-x64", "chrome-headless-shell"]], + arm64: [["chrome-headless-shell-mac-arm64", "chrome-headless-shell"]], + }, + win32: [["chrome-headless-shell-win64", "chrome-headless-shell.exe"]], +}); + +function preinstalledChromiumLayouts(platform = process.platform, architecture = process.arch) { + if (platform === "linux" || platform === "darwin") { + return PREINSTALLED_CHROMIUM_LAYOUTS[platform][architecture] ?? []; + } + return PREINSTALLED_CHROMIUM_LAYOUTS[platform] ?? []; +} + +/** + * Find the newest headless shell supplied by a download-disabled container. + * + * This is intentionally narrower than scanning every Playwright cache: a stale + * developer cache must still fail closed. The caller separately validates the + * designated immutable-container root before invoking this search. + */ +export function newestPreinstalledChromiumHeadlessShell( + browsersRoot, + { + fileExists = existsSync, + readDirectory = readdirSync, + platform = process.platform, + architecture = process.arch, + } = {}, +) { + if (!browsersRoot) return null; + let directories; + try { + directories = readDirectory(browsersRoot, { withFileTypes: true }); + } catch { + return null; + } + const revisions = directories + .filter((entry) => entry.isDirectory() && /^chromium_headless_shell-\d+$/.test(entry.name)) + .map((entry) => ({ name: entry.name, revision: Number(entry.name.slice("chromium_headless_shell-".length)) })) + .sort((left, right) => right.revision - left.revision); + const layouts = preinstalledChromiumLayouts(platform, architecture); + for (const directory of revisions) { + for (const layout of layouts) { + const executable = path.join(browsersRoot, directory.name, ...layout); + if (fileExists(executable)) return executable; + } + } + return null; +} + /** * Derive the default headless-shell binary Playwright launches for Chromium * tests. `chromium.executablePath()` points at full Chrome for Testing; the @@ -96,7 +155,17 @@ function browserFamilyForProject(project) { return PROJECT_BROWSER_FAMILIES[project] ?? null; } -export function resolvePlaywrightBrowserExecutable(family, env = process.env) { +export function resolvePlaywrightBrowserExecutable( + family, + env = process.env, + { + managedChromiumPath = defaultChromiumHeadlessShellPath(), + fileExists = existsSync, + platform = process.platform, + architecture = process.arch, + containerBrowsersRoot = platform === "linux" ? "/opt/pw-browsers" : null, + } = {}, +) { if (family === "chromium") { const override = env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH?.trim(); if (override) { @@ -106,9 +175,37 @@ export function resolvePlaywrightBrowserExecutable(family, env = process.env) { source: "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", }; } + if (managedChromiumPath && fileExists(managedChromiumPath)) { + return { + family, + path: managedChromiumPath, + source: "playwright chromium-headless-shell", + }; + } + const downloadsDisabled = /^(?:1|true)$/i.test(env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD?.trim() ?? ""); + const exposedBrowsersRoot = env.PLAYWRIGHT_BROWSERS_PATH?.trim(); + const normalizedExposedRoot = exposedBrowsersRoot?.replaceAll("\\", "/").replace(/\/+$/, ""); + const normalizedContainerRoot = containerBrowsersRoot?.replaceAll("\\", "/").replace(/\/+$/, ""); + const designatedContainerRoot = + normalizedExposedRoot && normalizedContainerRoot && normalizedExposedRoot === normalizedContainerRoot; + if (downloadsDisabled && designatedContainerRoot) { + const preinstalled = newestPreinstalledChromiumHeadlessShell(exposedBrowsersRoot, { + fileExists, + platform, + architecture, + }); + if (preinstalled) { + return { + family, + path: preinstalled, + source: "preinstalled container Chromium (PLAYWRIGHT_BROWSERS_PATH)", + managedPath: managedChromiumPath, + }; + } + } return { family, - path: defaultChromiumHeadlessShellPath(), + path: managedChromiumPath, source: "playwright chromium-headless-shell", }; } diff --git a/scripts/run-playwright.mjs b/scripts/run-playwright.mjs index 784ed5b32a..d4f7a070fd 100644 --- a/scripts/run-playwright.mjs +++ b/scripts/run-playwright.mjs @@ -50,7 +50,16 @@ const mockupProjectRequested = // Fail loud on missing browser binaries before the heavy lock or production build. // Otherwise launch failures surface as "N failed" product tests and are easy to misread // when a caller pipes output without `pipefail` (outstanding-issues #120). -assertPlaywrightBrowsersReady(playwrightArgs); +const browserPreflight = assertPlaywrightBrowsersReady(playwrightArgs); +const preinstalledChromium = browserPreflight.checked.find( + (entry) => entry.source === "preinstalled container Chromium (PLAYWRIGHT_BROWSERS_PATH)", +); +if (preinstalledChromium && !process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) { + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH = preinstalledChromium.path; + console.error( + `[playwright] Managed Chromium is unavailable; using the preinstalled container browser at ${preinstalledChromium.path}.`, + ); +} const runId = `${process.pid}-${Date.now()}`; const relativeRunRoot = `.next-playwright/${runId}`; diff --git a/tests/playwright-browser-preflight.test.ts b/tests/playwright-browser-preflight.test.ts index 92758022a7..e926b19a64 100644 --- a/tests/playwright-browser-preflight.test.ts +++ b/tests/playwright-browser-preflight.test.ts @@ -1,6 +1,10 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { defaultChromiumHeadlessShellPath, + newestPreinstalledChromiumHeadlessShell, playwrightBrowserPreflight, playwrightProjectNames, requestedPlaywrightBrowserProjects, @@ -83,6 +87,84 @@ describe("playwright browser preflight", () => { }); }); + it("selects the newest shell only for the designated download-disabled container root", () => { + const root = mkdtempSync(join(tmpdir(), "pw-container-browsers-")); + const older = join(root, "chromium_headless_shell-1194", "chrome-linux", "headless_shell"); + const newer = join(root, "chromium_headless_shell-1200", "chrome-linux", "headless_shell"); + try { + mkdirSync(join(older, ".."), { recursive: true }); + mkdirSync(join(newer, ".."), { recursive: true }); + writeFileSync(older, ""); + writeFileSync(newer, ""); + + expect(newestPreinstalledChromiumHeadlessShell(root, { platform: "linux", architecture: "x64" })).toBe(newer); + + const managedPath = join(root, "chromium_headless_shell-1234", "missing"); + expect( + resolvePlaywrightBrowserExecutable( + "chromium", + { + NODE_ENV: "test", + PLAYWRIGHT_BROWSERS_PATH: root, + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1", + }, + { + managedChromiumPath: managedPath, + platform: "linux", + architecture: "x64", + containerBrowsersRoot: root, + }, + ), + ).toMatchObject({ + family: "chromium", + path: newer, + source: "preinstalled container Chromium (PLAYWRIGHT_BROWSERS_PATH)", + managedPath, + }); + expect( + resolvePlaywrightBrowserExecutable( + "chromium", + { + NODE_ENV: "test", + PLAYWRIGHT_BROWSERS_PATH: root, + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1", + }, + { managedChromiumPath: managedPath, platform: "linux", architecture: "x64" }, + ), + ).toEqual({ + family: "chromium", + path: managedPath, + source: "playwright chromium-headless-shell", + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("does not select an x64-only container shell on Linux arm64", () => { + const root = mkdtempSync(join(tmpdir(), "pw-container-architectures-")); + const x64Only = join( + root, + "chromium_headless_shell-1300", + "chrome-headless-shell-linux64", + "chrome-headless-shell", + ); + const arm64Compatible = join(root, "chromium_headless_shell-1200", "chrome-linux", "headless_shell"); + try { + mkdirSync(join(x64Only, ".."), { recursive: true }); + mkdirSync(join(arm64Compatible, ".."), { recursive: true }); + writeFileSync(x64Only, ""); + writeFileSync(arm64Compatible, ""); + + expect(newestPreinstalledChromiumHeadlessShell(root, { platform: "linux", architecture: "x64" })).toBe(x64Only); + expect(newestPreinstalledChromiumHeadlessShell(root, { platform: "linux", architecture: "arm64" })).toBe( + arm64Compatible, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("fails closed when the required Chromium binary is missing", () => { const result = playwrightBrowserPreflight(["--project=chromium"], { NODE_ENV: "test", diff --git a/tests/test-runner-safety.test.ts b/tests/test-runner-safety.test.ts index c01bf053b4..7f6702e48b 100644 --- a/tests/test-runner-safety.test.ts +++ b/tests/test-runner-safety.test.ts @@ -685,6 +685,8 @@ describe("provider-safe test environment", () => { ); expect(preflight).toContain("chromium_headless_shell"); expect(preflight).toContain("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"); + expect(preflight).toContain("PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD"); + expect(runner).toContain("process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH = preinstalledChromium.path"); expect(packageJson.scripts["test:e2e:pr"]).toContain('--grep-invert "@quarantine|@mockup"'); expect(packageJson.scripts["test:e2e:regression"]).toContain('--grep-invert "@critical|@quarantine|@mockup"'); expect(baseUrl.indexOf("if (!allowEnsure)")).toBeLessThan(baseUrl.indexOf("findExistingLocalProjectUrl();"));