From 75cb1651ed7baa52d5e398acafee41c42831c7ec Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:41:43 +0800 Subject: [PATCH 1/2] fix(test): use preinstalled Chromium in immutable containers --- docs/outstanding-issues.md | 2 +- docs/testing.md | 2 +- scripts/playwright-browser-preflight.mjs | 93 +++++++++++++++++++++- scripts/run-playwright.mjs | 11 ++- tests/playwright-browser-preflight.test.ts | 49 ++++++++++++ tests/test-runner-safety.test.ts | 2 + 6 files changed, 153 insertions(+), 6 deletions(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index af446e8d15..02f2103517 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -139,7 +139,6 @@ removed after current-main verification; it is not missing recommended work. | #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 | | #110 | P3 | task | Design-system project token manifest lags its stylesheet | **Outcome:** the claude.ai/design token panel matches the shipped stylesheet. **Detail:** PR #1375 pushed a recompiled `_ds_bundle.css` (Clinical Sky, `--e0`–`--e4`, 4px radius grid, `--tracking-eyebrow`/`--leading-display`/`--leading-prose`) plus the four changed guideline docs to project `08d6f126`, but `_ds_manifest.json` is converter-generated and still advertises `--text-4xs: 0.5rem`, the old `--radius-lg/xl/2xl` values, and `--tw-leading`/`--tw-tracking` entries scoped to the retired `.leading-[…]` / `.tracking-[0.08em]` utilities. Rendering is correct; only the token inventory lags. Hand-editing was rejected — `kind`/`scope`/`annotation` are converter heuristics and a wrong panel is worse than a stale one. **Next:** in a session with the `/design-sync` skill, `npm ci`, then `npm install --prefix .ds-sync --no-save --package-lock=false esbuild ts-morph @types/react @tailwindcss/cli geist`, read `.design-sync/NOTES.md`, and run `resync.mjs --remote` so bundle and manifest regenerate together. **Stop:** do not hand-author `_ds_manifest.json`; the converter is not a published npm package and ships with the skill. | PR #1375; `.design-sync/NOTES.md`; project `08d6f126` (`_ds_needs_recompile` marker present) | 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 | | #126 | P3 | task | Quarterly branch-review ledger rotation reminder | **Outcome:** live ledger stays navigable after #1418 L4 bootstrap. **Next:** each UTC calendar-quarter start (or when the live table feels unwieldy), run `npm run ledger:rotate -- --dry-run`, then `npm run ledger:rotate` and commit live+archive. Lookup/sweep/check already read archives. **Stop:** do not hand-move rows; do not delete unique review content. | session 2026-07-30; follow-up to #1418 / L4 | 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 | @@ -175,6 +174,7 @@ Move resolved rows here with the resolution date and a one-line outcome. Keep th | #135 | rec | API-only App Router changes unnecessarily selected full UI verification | Resolved 2026-07-30. `isUiChangedPath` excludes `src/app/api/**`, while the CI scope self-test pins API-only changes as `ui_changed: false` and preserves the non-UI static/unit safety gates. Current-main `check:ci-scope` passes. | 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 when downloads are explicitly disabled and `PLAYWRIGHT_BROWSERS_PATH` is exposed does preflight select the newest platform-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; ordinary 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..d60bcf658b 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. A download-disabled container that explicitly exposes `PLAYWRIGHT_BROWSERS_PATH` is the one exception: if the client-pinned shell is absent, the runner selects the newest preinstalled shell for the current platform and passes its exact path to Playwright. It logs that fallback before the build; ordinary developer caches still fail closed rather than silently selecting 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..f07616fc2d 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,61 @@ const CHROMIUM_HEADLESS_SHELL_LAYOUTS = Object.freeze({ "chrome-win64": ["chrome-headless-shell-win64", "chrome-headless-shell.exe"], }); +const PREINSTALLED_CHROMIUM_LAYOUTS = Object.freeze({ + linux: [ + ["chrome-headless-shell-linux64", "chrome-headless-shell"], + ["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 === "darwin") return PREINSTALLED_CHROMIUM_LAYOUTS.darwin[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 fallback is available only when + * the caller explicitly exposes PLAYWRIGHT_BROWSERS_PATH and disables browser + * downloads, which is the immutable-container shape recorded in issue #121. + */ +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 +151,16 @@ 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, + } = {}, +) { if (family === "chromium") { const override = env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH?.trim(); if (override) { @@ -106,9 +170,32 @@ 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() ?? ""); + if (downloadsDisabled) { + const preinstalled = newestPreinstalledChromiumHeadlessShell(env.PLAYWRIGHT_BROWSERS_PATH?.trim(), { + 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..c9a9e7e135 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,51 @@ describe("playwright browser preflight", () => { }); }); + it("selects the newest container shell only when managed downloads are disabled", () => { + 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" }, + ), + ).toMatchObject({ + family: "chromium", + path: newer, + source: "preinstalled container Chromium (PLAYWRIGHT_BROWSERS_PATH)", + managedPath, + }); + expect( + resolvePlaywrightBrowserExecutable( + "chromium", + { NODE_ENV: "test", PLAYWRIGHT_BROWSERS_PATH: root }, + { managedChromiumPath: managedPath, platform: "linux", architecture: "x64" }, + ), + ).toEqual({ + family: "chromium", + path: managedPath, + source: "playwright chromium-headless-shell", + }); + } 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 740eb832bc..45b5b87491 100644 --- a/tests/test-runner-safety.test.ts +++ b/tests/test-runner-safety.test.ts @@ -633,6 +633,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();")); From 90d5eee16e38a4f771571c110df89caac1b3e595 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:45:18 +0800 Subject: [PATCH 2/2] docs: record issue 121 review --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 80d2c44f4c..3f1ff51ea3 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -278,3 +278,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-30 | codex/coverage-scope-policy | 4da2a003bc2254507662d1b8b6e9768e94371abd | issue 139 coverage scope policy post-sync | approved: late main sync preserves deliberate workflow coverage and static-only skill policy | check:ci-scope; check:outstanding-issues; check:branch-review-ledger; diff check | | 2026-07-30 | codex/archive-completed-ci-tasks | 5c902f422ceee78ef68132900fda734c1d5bc1f8 | archive issues 133 and 135 | approved: both rows were already resolved on current main and focused guards prove their contracts | check:ci-scope; check:outstanding-issues; check:branch-review-ledger; diff check | | 2026-07-30 | codex/next-local-task | 3e6d6d69c15fc056773657e15879ba2283fa2899 | archive issues 129 and 132 | approved: documented constraints satisfy both explicit outcomes without overstating client-side enforcement | guard:push:self-test; focused vitest 24/24; check:github-actions; check:outstanding-issues; diff check | +| 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 |