Skip to content

Commit 4a146d1

Browse files
committed
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # skills-manifest.json
2 parents dcee7d0 + 30900c3 commit 4a146d1

33 files changed

Lines changed: 1060 additions & 133 deletions

docs/packages/cli.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
542542
| `--snapshots` | Write overview frames (annotated with labeled finding boxes when there are errors) plus `finding-NN-<code>.png` crops |
543543
| `--samples` / `--at` / `--at-transitions` | Control the seek grid (default 9 samples; `--at-transitions` adds tween boundaries) |
544544
| `--tolerance` | Allowed overflow in px before reporting (default 2) |
545-
| `--timeout` | Initial settle budget in ms (default 3000) |
545+
| `--timeout` | Initial render-ready budget in ms; also raises the page-navigation budget above its 10s floor (default 3000) |
546546
| `--no-contrast` | Skip the WCAG audit while iterating |
547547
| `--strict` | Exit non-zero on warnings too (default: only errors) |
548548
| `--caption-zone "<x0=..;y0=..;x1=..;y1=..>"` | Opt-in band gate: flags content whose center sits inside the fractional band (optional `severity`, `seek`) |

packages/cli/src/browser/manager.test.ts

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,18 +130,25 @@ function installPuppeteerBrowsersMock(
130130
executablePath: string;
131131
path?: string;
132132
buildId?: string;
133+
platform?: string;
133134
}>;
135+
browserPlatform?: string;
134136
installedInHfCacheError?: Error;
135137
installResult?: { executablePath: string };
136138
installImpl?: () => Promise<{ executablePath: string }>;
137139
} = {},
138140
) {
139141
vi.doMock("@puppeteer/browsers", () => ({
140142
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
141-
detectBrowserPlatform: () => "linux",
143+
detectBrowserPlatform: () => opts.browserPlatform ?? "linux",
142144
getInstalledBrowsers: opts.installedInHfCacheError
143145
? vi.fn().mockRejectedValue(opts.installedInHfCacheError)
144-
: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []),
146+
: vi.fn().mockResolvedValue(
147+
(opts.installedInHfCache ?? []).map((browser) => ({
148+
platform: opts.browserPlatform ?? "linux",
149+
...browser,
150+
})),
151+
),
145152
install: vi
146153
.fn()
147154
.mockImplementation(
@@ -222,6 +229,41 @@ describe("findBrowser — cache resolution", () => {
222229
expect(result).toEqual({ executablePath: SYSTEM_CHROME, source: "system" });
223230
});
224231

232+
it("ignores a current-version HyperFrames cache entry for another platform", async () => {
233+
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
234+
Object.defineProperty(process, "arch", { value: "arm64", configurable: true });
235+
const macArm64Binary = join(
236+
HF_CACHE,
237+
"chrome-headless-shell",
238+
"mac_arm-131.0.6778.85",
239+
"chrome-headless-shell-mac-arm64",
240+
"chrome-headless-shell",
241+
);
242+
installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY, macArm64Binary]) });
243+
installPuppeteerBrowsersMock({
244+
browserPlatform: "mac_arm",
245+
installedInHfCache: [
246+
{
247+
browser: "chrome-headless-shell",
248+
executablePath: HF_BINARY,
249+
buildId: CHROME_VERSION,
250+
platform: "linux",
251+
},
252+
{
253+
browser: "chrome-headless-shell",
254+
executablePath: macArm64Binary,
255+
buildId: CHROME_VERSION,
256+
platform: "mac_arm",
257+
},
258+
],
259+
});
260+
261+
const { findBrowser } = await import("./manager.js");
262+
const result = await findBrowser();
263+
264+
expect(result).toEqual({ executablePath: macArm64Binary, source: "cache" });
265+
});
266+
225267
it("re-downloads when the hyperframes cache manifest points at a missing binary", async () => {
226268
const redownloadedBinary = join(
227269
HF_CACHE,
@@ -492,6 +534,91 @@ describe("findBrowser — cache resolution", () => {
492534
expect(result).toEqual({ executablePath: PUPPETEER_BINARY, source: "cache" });
493535
});
494536

537+
it.each([
538+
{
539+
hostPlatform: "darwin",
540+
hostArch: "arm64",
541+
expectedDirectory: "chrome-headless-shell-mac-arm64",
542+
expectedExecutable: "chrome-headless-shell",
543+
},
544+
{
545+
hostPlatform: "darwin",
546+
hostArch: "x64",
547+
expectedDirectory: "chrome-headless-shell-mac-x64",
548+
expectedExecutable: "chrome-headless-shell",
549+
},
550+
{
551+
hostPlatform: "linux",
552+
hostArch: "x64",
553+
expectedDirectory: "chrome-headless-shell-linux64",
554+
expectedExecutable: "chrome-headless-shell",
555+
},
556+
{
557+
hostPlatform: "win32",
558+
hostArch: "ia32",
559+
expectedDirectory: "chrome-headless-shell-win32",
560+
expectedExecutable: "chrome-headless-shell.exe",
561+
},
562+
{
563+
hostPlatform: "win32",
564+
hostArch: "x64",
565+
expectedDirectory: "chrome-headless-shell-win64",
566+
expectedExecutable: "chrome-headless-shell.exe",
567+
},
568+
])(
569+
"selects only the host-compatible cached shell on $hostPlatform/$hostArch when every platform is present",
570+
async ({ hostPlatform, hostArch, expectedDirectory, expectedExecutable }) => {
571+
Object.defineProperty(process, "platform", { value: hostPlatform, configurable: true });
572+
Object.defineProperty(process, "arch", { value: hostArch, configurable: true });
573+
const version = "host-148.0.7778.97";
574+
const candidates = [
575+
["chrome-headless-shell-linux64", "chrome-headless-shell"],
576+
["chrome-headless-shell-mac-arm64", "chrome-headless-shell"],
577+
["chrome-headless-shell-mac-x64", "chrome-headless-shell"],
578+
["chrome-headless-shell-win32", "chrome-headless-shell.exe"],
579+
["chrome-headless-shell-win64", "chrome-headless-shell.exe"],
580+
] as const;
581+
const binaries = candidates.map(([directory, executable]) =>
582+
join(PUPPETEER_CACHE, version, directory, executable),
583+
);
584+
const expectedBinary = join(PUPPETEER_CACHE, version, expectedDirectory, expectedExecutable);
585+
installFsMocks({
586+
existing: new Set([PUPPETEER_CACHE, ...binaries]),
587+
dirs: { [PUPPETEER_CACHE]: [version] },
588+
});
589+
installPuppeteerBrowsersMock();
590+
591+
const { findBrowser } = await import("./manager.js");
592+
const result = await findBrowser();
593+
594+
expect(result).toEqual({ executablePath: expectedBinary, source: "cache" });
595+
},
596+
);
597+
598+
it.each([
599+
{ hostPlatform: "linux", hostArch: "arm64" },
600+
{ hostPlatform: "win32", hostArch: "arm64" },
601+
])(
602+
"does not select a foreign cached shell on unsupported $hostPlatform/$hostArch",
603+
async ({ hostPlatform, hostArch }) => {
604+
Object.defineProperty(process, "platform", { value: hostPlatform, configurable: true });
605+
Object.defineProperty(process, "arch", { value: hostArch, configurable: true });
606+
const version = "host-148.0.7778.97";
607+
const binaries = [
608+
join(PUPPETEER_CACHE, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
609+
join(PUPPETEER_CACHE, version, "chrome-headless-shell-win64", "chrome-headless-shell.exe"),
610+
];
611+
installFsMocks({
612+
existing: new Set([PUPPETEER_CACHE, ...binaries]),
613+
dirs: { [PUPPETEER_CACHE]: [version] },
614+
});
615+
installPuppeteerBrowsersMock();
616+
617+
const { findBrowser } = await import("./manager.js");
618+
await expect(findBrowser()).resolves.toBeUndefined();
619+
},
620+
);
621+
495622
it("prefers the puppeteer cache over the hyperframes cache when BOTH are populated", async () => {
496623
// The HF cache is pinned to `CHROME_VERSION` (131-era) which lags upstream
497624
// by many releases. The engine's `resolveHeadlessShellPath` scans the

packages/cli/src/browser/manager.ts

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ function findFromEnv(): BrowserResult | undefined {
282282
*/
283283
async function findFromHyperframesCache(): Promise<CacheLookupResult> {
284284
if (!existsSync(CACHE_DIR)) return {};
285-
const { Browser, getInstalledBrowsers } = await loadPuppeteerBrowsers();
285+
const { Browser, detectBrowserPlatform, getInstalledBrowsers } = await loadPuppeteerBrowsers();
286286
// A corrupt cache (stub file where a browser dir is expected, malformed
287287
// metadata) makes getInstalledBrowsers throw. Treat that as "no cached
288288
// browser" so resolution falls through to system/download instead of
@@ -302,9 +302,14 @@ async function findFromHyperframesCache(): Promise<CacheLookupResult> {
302302
// an older hyperframes version (this pin has moved 131 → 151 → 152 across
303303
// releases) must NOT satisfy resolution, or an upgrade silently keeps
304304
// running whatever build happened to be cached instead of ever fetching
305-
// the version this release actually needs (HF#2060 review).
305+
// the version this release actually needs (HF#2060 review). Match platform
306+
// as well so a shared/migrated cache cannot return a foreign executable.
307+
const hostPlatform = detectBrowserPlatform();
306308
const match = installed.find(
307-
(b) => b.browser === Browser.CHROMEHEADLESSSHELL && b.buildId === CHROME_VERSION,
309+
(b) =>
310+
b.browser === Browser.CHROMEHEADLESSSHELL &&
311+
b.buildId === CHROME_VERSION &&
312+
b.platform === hostPlatform,
308313
);
309314
if (match && existsSync(match.executablePath)) {
310315
return { result: { executablePath: match.executablePath, source: "cache" } };
@@ -383,8 +388,33 @@ function compareVersionDirsDescending(a: string, b: string): number {
383388
return 0;
384389
}
385390

391+
const CACHED_HEADLESS_SHELL_EXECUTABLES: Readonly<
392+
Record<string, readonly [directory: string, executable: string]>
393+
> = {
394+
"darwin/arm64": ["chrome-headless-shell-mac-arm64", "chrome-headless-shell"],
395+
"darwin/x64": ["chrome-headless-shell-mac-x64", "chrome-headless-shell"],
396+
"linux/x64": ["chrome-headless-shell-linux64", "chrome-headless-shell"],
397+
"win32/ia32": ["chrome-headless-shell-win32", "chrome-headless-shell.exe"],
398+
"win32/x64": ["chrome-headless-shell-win64", "chrome-headless-shell.exe"],
399+
};
400+
401+
function cachedHeadlessShellExecutable(
402+
hostPlatform = process.platform,
403+
hostArch = process.arch,
404+
): readonly [directory: string, executable: string] | undefined {
405+
// Chrome for Testing cache entries are host-specific. Resolve exactly one
406+
// platform/architecture directory so a foreign binary cannot win by probe order.
407+
// Chrome for Testing does not publish Linux ARM64 binaries. Windows ARM64 can
408+
// emulate x64 only on supported Windows 11 builds, which this platform/arch-only
409+
// resolver cannot prove, so both hosts deliberately fall through to system
410+
// browser discovery instead of attempting a potentially foreign cached binary.
411+
return CACHED_HEADLESS_SHELL_EXECUTABLES[`${hostPlatform}/${hostArch}`];
412+
}
413+
386414
function findFromPuppeteerCache(): BrowserResult | undefined {
387415
if (!existsSync(PUPPETEER_CACHE_DIR)) return undefined;
416+
const executable = cachedHeadlessShellExecutable();
417+
if (!executable) return undefined;
388418
let versions: string[];
389419
try {
390420
// Numeric semver-style sort, newest first. Lexicographic `.sort().reverse()`
@@ -399,26 +429,9 @@ function findFromPuppeteerCache(): BrowserResult | undefined {
399429
// Same shape as `resolveHeadlessShellPath` in engine/browserManager.ts —
400430
// keep them aligned. If puppeteer ever changes the on-disk layout the two
401431
// need to move together.
402-
const candidates = [
403-
join(PUPPETEER_CACHE_DIR, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
404-
join(
405-
PUPPETEER_CACHE_DIR,
406-
version,
407-
"chrome-headless-shell-mac-arm64",
408-
"chrome-headless-shell",
409-
),
410-
join(PUPPETEER_CACHE_DIR, version, "chrome-headless-shell-mac-x64", "chrome-headless-shell"),
411-
join(
412-
PUPPETEER_CACHE_DIR,
413-
version,
414-
"chrome-headless-shell-win64",
415-
"chrome-headless-shell.exe",
416-
),
417-
];
418-
for (const binary of candidates) {
419-
if (existsSync(binary)) {
420-
return { executablePath: binary, source: "cache" };
421-
}
432+
const binary = join(PUPPETEER_CACHE_DIR, version, ...executable);
433+
if (existsSync(binary)) {
434+
return { executablePath: binary, source: "cache" };
422435
}
423436
}
424437
return undefined;

packages/cli/src/capture/captureCompositionFrame.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export interface SettledCompositionPage {
6161
}
6262

6363
export interface OpenSettledCompositionPageOptions {
64+
// Separate from the post-navigation render-ready budget. Diagnostic callers
65+
// without their own navigation knob keep the historical 10-second minimum.
66+
navigationTimeoutMs?: number;
6467
renderReadyTimeoutMs: number;
6568
renderReadyWarningSuffix: string;
6669
// Screenshot paths take the engine's software-GPU default; validate/check
@@ -182,7 +185,7 @@ export async function openSettledCompositionPage(
182185
await options.beforeNavigate?.(page);
183186
await page.goto(url, {
184187
waitUntil: "domcontentloaded",
185-
timeout: resolveDiagnosticNavigationTimeoutMs(),
188+
timeout: resolveDiagnosticNavigationTimeoutMs(process.env, options.navigationTimeoutMs),
186189
});
187190
const renderReadyTimedOut = !(await waitForCompositionSettle(page, options));
188191
return { browser: chromeBrowser, page, renderReadyTimedOut };

packages/cli/src/commands/check.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ export function createCheckCommand(
8686
},
8787
timeout: {
8888
type: "string",
89-
description: "Ms to wait for scripts and media to settle initially (default: 3000)",
89+
description:
90+
"Initial render-ready timeout in ms; also sets the navigation minimum (10s floor, default: 3000)",
9091
default: "3000",
9192
},
9293
contrast: {

packages/cli/src/utils/checkBrowser.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,28 @@ it("carries raw browser geometry through the page driver and pipeline", async ()
155155
expect(mocks.serverClose).toHaveBeenCalledOnce();
156156
});
157157

158+
it("uses check --timeout for both navigation and render-ready settling", async () => {
159+
mountCanvasFixture();
160+
const page = fakePage();
161+
installSessionMock(page);
162+
163+
await runBrowserCheck(
164+
PROJECT,
165+
{ ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false, timeout: 30_000 },
166+
{ kind: "none" },
167+
runAuditGrid,
168+
);
169+
170+
expect(openSettledCompositionPage).toHaveBeenCalledWith(
171+
"<html></html>",
172+
"http://127.0.0.1:3000",
173+
expect.objectContaining({
174+
navigationTimeoutMs: 30_000,
175+
renderReadyTimeoutMs: 30_000,
176+
}),
177+
);
178+
});
179+
158180
it("round-trips the browser script's raw contrast candidates back into finish", async () => {
159181
// The U2 regression class: Node parses prepare's candidates for reporting,
160182
// but must hand the UNTOUCHED objects back to __contrastAuditFinish — the

packages/cli/src/utils/checkBrowser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export async function runBrowserCheck(
165165
try {
166166
const launchSettleStart = Date.now();
167167
const session = await openSettledCompositionPage(html, server.url, {
168+
navigationTimeoutMs: options.timeout,
168169
renderReadyTimeoutMs: options.timeout,
169170
renderReadyWarningSuffix: "checking the current page state",
170171
browserGpuMode: resolveCliChromeGpuMode(),
@@ -225,6 +226,7 @@ export async function captureFindingCrops(
225226
const written: string[] = [];
226227
try {
227228
const session = await openSettledCompositionPage(html, server.url, {
229+
navigationTimeoutMs: options.timeout,
228230
renderReadyTimeoutMs: options.timeout,
229231
renderReadyWarningSuffix: "capturing finding crops",
230232
browserGpuMode: resolveCliChromeGpuMode(),

packages/cli/src/utils/renderArgs.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,23 @@ describe("resolveDiagnosticNavigationTimeoutMs", () => {
118118
resolveDiagnosticNavigationTimeoutMs({ PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS: "invalid" }),
119119
).toBe(10_000);
120120
});
121+
122+
it("raises the diagnostic navigation budget to a larger caller-provided minimum", () => {
123+
expect(resolveDiagnosticNavigationTimeoutMs({}, 30_000)).toBe(30_000);
124+
});
125+
126+
it("does not let a caller-provided minimum shorten the default budget", () => {
127+
expect(resolveDiagnosticNavigationTimeoutMs({}, 3_000)).toBe(10_000);
128+
});
129+
130+
it("does not let a caller-provided minimum shorten the environment override", () => {
131+
expect(
132+
resolveDiagnosticNavigationTimeoutMs(
133+
{ PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS: "90000" },
134+
30_000,
135+
),
136+
).toBe(90_000);
137+
});
121138
});
122139

123140
describe("parseCompositionEntryArg", () => {

packages/cli/src/utils/renderArgs.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,20 @@ export function resolveBrowserTimeoutMsArg(raw: string | undefined): number | un
118118
return result.value;
119119
}
120120

121-
/** Navigation budget shared by snapshot/check/inspect browser diagnostics. */
121+
/**
122+
* Navigation budget shared by snapshot/check/inspect browser diagnostics.
123+
*
124+
* The environment variable remains the historical global override. Callers
125+
* with their own timeout knob can supply a minimum without shortening that
126+
* override or the existing 10-second default.
127+
*/
122128
export function resolveDiagnosticNavigationTimeoutMs(
123129
env: Record<string, string | undefined> = process.env,
130+
minimumTimeoutMs = 0,
124131
): number {
125132
const parsed = Number(env.PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS);
126-
return Number.isFinite(parsed) && parsed > 0 ? parsed : 10_000;
133+
const configured = Number.isFinite(parsed) && parsed > 0 ? parsed : 10_000;
134+
return Math.max(configured, minimumTimeoutMs);
127135
}
128136

129137
// ── --composition ──────────────────────────────────────────────────────

0 commit comments

Comments
 (0)