Skip to content

Commit 5bc730a

Browse files
vanceingallsclaude
andcommitted
fix(cli): prefer puppeteer cache + numeric version sort (staff review)
Two correctness fixes from PR #821 self-review: 1. Cache priority order. Previous order was hyperframes-managed cache → puppeteer cache. HF cache is pinned to CHROME_VERSION (131-era) which lags 17+ releases behind upstream; if a user separately installed a newer chrome-headless-shell via @puppeteer/browsers install, the CLI would silently hand engine the older HF-cache binary while engine's own resolveHeadlessShellPath would have picked the newer one. Flip the priority so puppeteer cache wins, matching engine semantics. 2. Numeric (not lexicographic) version sort. `readdirSync.sort().reverse()` over names like `linux-148.0.7778.97` and `linux-99.0.6533.123` would return `linux-99...` first because character '9' outranks '1'. Parse each name into integer segments and compare them numerically. Tests: add both-caches-populated and linux-148-beats-linux-99 cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c6a9818 commit 5bc730a

2 files changed

Lines changed: 126 additions & 17 deletions

File tree

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

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,10 @@ describe("findBrowser — cache resolution", () => {
102102
vi.doUnmock("@puppeteer/browsers");
103103
});
104104

105-
it("resolves to the hyperframes-managed cache when present", async () => {
105+
it("resolves to the hyperframes-managed cache when puppeteer cache is empty", async () => {
106+
// Only HF cache populated. Puppeteer cache is the higher-priority path
107+
// (see "prefers puppeteer cache" test below), so this exercises the
108+
// last-resort fallback.
106109
installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY]) });
107110
installPuppeteerBrowsersMock({
108111
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
@@ -129,8 +132,35 @@ describe("findBrowser — cache resolution", () => {
129132
expect(result).toEqual({ executablePath: PUPPETEER_BINARY, source: "cache" });
130133
});
131134

135+
it("prefers the puppeteer cache over the hyperframes cache when BOTH are populated", async () => {
136+
// The HF cache is pinned to `CHROME_VERSION` (131-era) which lags upstream
137+
// by many releases. The engine's `resolveHeadlessShellPath` scans the
138+
// puppeteer cache and selects newest-version-first; if the CLI handed
139+
// engine the older HF-cache binary while a newer puppeteer-cache binary
140+
// exists, the two would silently disagree on which binary to use.
141+
// This test pins the priority: puppeteer cache wins when both are populated.
142+
installFsMocks({
143+
existing: new Set([HF_CACHE, HF_BINARY, PUPPETEER_CACHE, PUPPETEER_BINARY]),
144+
dirs: { [PUPPETEER_CACHE]: ["linux-148.0.7778.97"] },
145+
});
146+
installPuppeteerBrowsersMock({
147+
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
148+
});
149+
150+
const { findBrowser } = await import("./manager.js");
151+
const result = await findBrowser();
152+
153+
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
154+
expect(result?.source).toBe("cache");
155+
});
156+
132157
it("picks the newest version when multiple chrome-headless-shell builds are cached", async () => {
133-
const olderBinary = `${PUPPETEER_CACHE}/linux-131.0.6778.85/chrome-headless-shell-linux64/chrome-headless-shell`;
158+
const olderBinary = join(
159+
PUPPETEER_CACHE,
160+
"linux-131.0.6778.85",
161+
"chrome-headless-shell-linux64",
162+
"chrome-headless-shell",
163+
);
134164
installFsMocks({
135165
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY, olderBinary]),
136166
dirs: { [PUPPETEER_CACHE]: ["linux-131.0.6778.85", "linux-148.0.7778.97"] },
@@ -143,6 +173,32 @@ describe("findBrowser — cache resolution", () => {
143173
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
144174
});
145175

176+
it("uses numeric (not lexicographic) version ordering — linux-148 beats linux-99", async () => {
177+
// Regression guard for the lexicographic-sort bug: `"linux-99..."` sorts
178+
// after `"linux-148..."` character-by-character (because `'9' > '1'`),
179+
// which would have caused the CLI to hand engine an ancient 99-era binary
180+
// when a fresh 148 was sitting right next to it. Numeric semver-style
181+
// ordering is the only correct semantic.
182+
const linux99Binary = join(
183+
PUPPETEER_CACHE,
184+
"linux-99.0.6533.123",
185+
"chrome-headless-shell-linux64",
186+
"chrome-headless-shell",
187+
);
188+
installFsMocks({
189+
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY, linux99Binary]),
190+
// Intentionally list the entries in an order that would expose the bug
191+
// under naive `.sort().reverse()` (which puts `linux-99...` first).
192+
dirs: { [PUPPETEER_CACHE]: ["linux-99.0.6533.123", "linux-148.0.7778.97"] },
193+
});
194+
installPuppeteerBrowsersMock();
195+
196+
const { findBrowser } = await import("./manager.js");
197+
const result = await findBrowser();
198+
199+
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
200+
});
201+
146202
it("falls back to system Chrome and warns on Linux when no cache has headless-shell", async () => {
147203
installFsMocks({ existing: new Set([SYSTEM_CHROME]) });
148204
installPuppeteerBrowsersMock();

packages/cli/src/browser/manager.ts

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,26 @@ function findFromEnv(): BrowserResult | undefined {
7373
}
7474

7575
async function findFromCache(): Promise<BrowserResult | undefined> {
76-
// 1) Hyperframes-managed cache (populated by `clearBrowser` + `install` below).
76+
// 1) Puppeteer's managed cache — where `npx @puppeteer/browsers install
77+
// chrome-headless-shell` lands, and where `puppeteer install` from a project
78+
// depending on full `puppeteer` (not `puppeteer-core`) lands. The engine's
79+
// `resolveHeadlessShellPath` reads from here and selects newest-version-
80+
// first; the CLI must match that semantic or it will silently hand the
81+
// engine an older binary than the engine itself would pick.
82+
//
83+
// We intentionally check puppeteer BEFORE the hyperframes-managed cache:
84+
// the HF cache is pinned to `CHROME_VERSION` (above) which lags behind
85+
// upstream Chrome by many releases. If a user installed chrome-headless-shell
86+
// separately (via `@puppeteer/browsers install`) we want to use that
87+
// newer binary, not the pinned-stale fallback.
88+
const fromPuppeteer = findFromPuppeteerCache();
89+
if (fromPuppeteer) {
90+
return fromPuppeteer;
91+
}
92+
93+
// 2) Hyperframes-managed cache (populated by `ensureBrowser` below as a
94+
// download-of-last-resort). This is the fallback path: only reached when
95+
// no puppeteer-cache binary exists.
7796
if (existsSync(CACHE_DIR)) {
7897
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
7998
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
@@ -82,27 +101,61 @@ async function findFromCache(): Promise<BrowserResult | undefined> {
82101
}
83102
}
84103

85-
// 2) Puppeteer's managed cache — where `npx @puppeteer/browsers install
86-
// chrome-headless-shell` lands, and where `puppeteer install` from a project
87-
// that depends on full `puppeteer` (not `puppeteer-core`) lands. The engine
88-
// already reads from here (`resolveHeadlessShellPath`); without this branch
89-
// the CLI would skip past a perfectly good chrome-headless-shell and fall
90-
// through to `findFromSystem()`, picking regular Chrome which has dropped
91-
// `HeadlessExperimental.enable` and disables the perf-optimized capture
92-
// path.
93-
const fromPuppeteer = findFromPuppeteerCache();
94-
if (fromPuppeteer) {
95-
return fromPuppeteer;
104+
return undefined;
105+
}
106+
107+
/**
108+
* Parse a puppeteer-cache version directory name (`linux-148.0.7778.97`,
109+
* `mac_arm-131.0.6778.85`, etc.) into a numeric tuple for ordering.
110+
*
111+
* Lexicographic sort on these strings is buggy because `"99"` > `"148"` (the
112+
* `9` outranks the `1` character-wise), so a 99-era binary would beat a
113+
* 148-era binary in `.sort().reverse()`. We split on `-` to drop the platform
114+
* prefix, then on `.` to get integer segments. Returns `undefined` for names
115+
* that don't have at least one parseable numeric segment so they sort last.
116+
*/
117+
function parseVersionSegments(versionDir: string): number[] | undefined {
118+
const dashIdx = versionDir.indexOf("-");
119+
const versionPart = dashIdx >= 0 ? versionDir.slice(dashIdx + 1) : versionDir;
120+
const segments = versionPart.split(".");
121+
const parsed: number[] = [];
122+
for (const seg of segments) {
123+
const n = parseInt(seg, 10);
124+
if (!Number.isFinite(n)) {
125+
// Stop at the first non-numeric segment but keep what we've collected.
126+
break;
127+
}
128+
parsed.push(n);
96129
}
130+
return parsed.length > 0 ? parsed : undefined;
131+
}
97132

98-
return undefined;
133+
/** Numeric semver-style descending comparator for puppeteer cache dirs. */
134+
function compareVersionDirsDescending(a: string, b: string): number {
135+
const pa = parseVersionSegments(a);
136+
const pb = parseVersionSegments(b);
137+
// Unparseable names sort after parseable ones (so we still try them, just last).
138+
if (!pa && !pb) return 0;
139+
if (!pa) return 1;
140+
if (!pb) return -1;
141+
const len = Math.max(pa.length, pb.length);
142+
for (let i = 0; i < len; i += 1) {
143+
const av = pa[i] ?? 0;
144+
const bv = pb[i] ?? 0;
145+
if (av !== bv) return bv - av; // descending (newest first)
146+
}
147+
return 0;
99148
}
100149

101150
function findFromPuppeteerCache(): BrowserResult | undefined {
102151
if (!existsSync(PUPPETEER_CACHE_DIR)) return undefined;
103152
let versions: string[];
104153
try {
105-
versions = readdirSync(PUPPETEER_CACHE_DIR).sort().reverse(); // newest first
154+
// Numeric semver-style sort, newest first. Lexicographic `.sort().reverse()`
155+
// (the previous implementation, still in engine `resolveHeadlessShellPath`)
156+
// mis-orders `linux-99...` ahead of `linux-148...` because character `'9'`
157+
// outranks `'1'`. See `parseVersionSegments` above.
158+
versions = [...readdirSync(PUPPETEER_CACHE_DIR)].sort(compareVersionDirsDescending);
106159
} catch {
107160
return undefined;
108161
}
@@ -159,7 +212,7 @@ function warnSystemFallbackOnce(executablePath: string): void {
159212
if (isHeadlessShellBinary(executablePath)) return;
160213
_warnedSystemFallback = true;
161214
console.warn(
162-
`[hyperframes] Using system Chrome at ${executablePath}; HeadlessExperimental.beginFrame is unavailable in regular Chrome builds, so the perf-optimized capture path falls back to screenshot mode. Install chrome-headless-shell for the optimized path:\n npx @puppeteer/browsers install chrome-headless-shell`,
215+
`[hyperframes] Using system Chrome at ${executablePath}; HeadlessExperimental.beginFrame is unavailable in regular Chrome builds, so the perf-optimized capture path falls back to screenshot mode. Install chrome-headless-shell for the optimized path:\n npx @puppeteer/browsers install chrome-headless-shell\n(Or set HYPERFRAMES_BROWSER_PATH to point at an existing chrome-headless-shell binary.)`,
163216
);
164217
}
165218

0 commit comments

Comments
 (0)