castle-web-cli 0.4.80 → 0.4.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -2,6 +2,7 @@ import type { Browser } from "playwright-core";
|
|
|
2
2
|
import type { PlaytestInstallEvent } from "./playtest.js";
|
|
3
3
|
export declare const INSTALL_START_LABEL = "Downloading playtest browser (one-time, ~250MB)\u2026";
|
|
4
4
|
export declare const INSTALL_WAIT_LABEL = "Waiting for browser download (shared)\u2026";
|
|
5
|
+
export declare const DEPS_REPAIR_LABEL = "Installing missing browser system libraries (one-time)\u2026";
|
|
5
6
|
export interface PlaywrightChromiumLike {
|
|
6
7
|
executablePath(): string;
|
|
7
8
|
launch(options?: {
|
|
@@ -14,6 +15,8 @@ export interface PlaywrightModuleLike {
|
|
|
14
15
|
export interface PlaytestBrowserSeams {
|
|
15
16
|
loadPlaywright?: () => Promise<PlaywrightModuleLike>;
|
|
16
17
|
runInstall?: (onLine: (line: string) => void) => Promise<void>;
|
|
18
|
+
runInstallDeps?: () => Promise<void>;
|
|
19
|
+
platform?: NodeJS.Platform;
|
|
17
20
|
}
|
|
18
21
|
export interface BrowserInstallHooks {
|
|
19
22
|
onProgress?: (label: string) => void;
|
|
@@ -44,18 +44,39 @@ const LAUNCH_ARGS = [
|
|
|
44
44
|
];
|
|
45
45
|
const MISSING_PLAYWRIGHT_MESSAGE = "playwright-core is not installed in this CLI (run `npm install` from the repo root).";
|
|
46
46
|
const MANUAL_INSTALL_HINT = "install manually with `npx playwright install chromium`";
|
|
47
|
+
const MANUAL_DEPS_HINT = "install manually with `npx playwright install-deps chromium` (needs root/apt)";
|
|
47
48
|
export const INSTALL_START_LABEL = "Downloading playtest browser (one-time, ~250MB)\u2026";
|
|
48
49
|
export const INSTALL_WAIT_LABEL = "Waiting for browser download (shared)\u2026";
|
|
50
|
+
export const DEPS_REPAIR_LABEL = "Installing missing browser system libraries (one-time)\u2026";
|
|
49
51
|
// Progress labels are throttled to 20-point steps: every onProgress string
|
|
50
52
|
// becomes a task-feed ROW (see runTaskAgentIn's onActivity wiring in
|
|
51
53
|
// agent.ts), so per-percent updates would flood the feed with ~100 rows.
|
|
52
54
|
const PROGRESS_STEP = 20;
|
|
55
|
+
// The Chromium binary and the OS shared libraries it dynamically links
|
|
56
|
+
// (libnspr4, libnss3, libatk-1.0, libgbm, ...) are two SEPARATE things: the
|
|
57
|
+
// on-demand download above only fetches the former. A box whose base image
|
|
58
|
+
// never baked in the latter (e.g. an E2B sandbox built from a pre-fix
|
|
59
|
+
// template -- see castle-cloud-cli/template.mjs) downloads Chromium fine but
|
|
60
|
+
// then fails to LAUNCH it with a linker error naming the missing .so. This
|
|
61
|
+
// regex is how launchBrowser tells "the binary can't find a system library"
|
|
62
|
+
// apart from any other launch failure (crash, bad args, OOM, ...) so it only
|
|
63
|
+
// attempts the apt-get repair below for the failure mode that repair can
|
|
64
|
+
// actually fix.
|
|
65
|
+
const MISSING_SHARED_LIBRARY_RE = /error while loading shared libraries|cannot open shared object file|\blib[\w.+-]+\.so\b/i;
|
|
66
|
+
function looksLikeMissingSharedLibrary(err) {
|
|
67
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
68
|
+
return MISSING_SHARED_LIBRARY_RE.test(message);
|
|
69
|
+
}
|
|
53
70
|
function launchFailureMessage(err) {
|
|
54
71
|
const message = err instanceof Error ? err.message : String(err);
|
|
55
72
|
const looksLikeMissingBrowser = /executable doesn't exist|download.*chromium/i.test(message);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
73
|
+
if (looksLikeMissingBrowser) {
|
|
74
|
+
return `chromium is not installed for playwright -- ${MANUAL_INSTALL_HINT}. (${message})`;
|
|
75
|
+
}
|
|
76
|
+
if (looksLikeMissingSharedLibrary(err)) {
|
|
77
|
+
return `chromium is missing OS-level system libraries -- ${MANUAL_DEPS_HINT}. (${message})`;
|
|
78
|
+
}
|
|
79
|
+
return `could not launch chromium: ${message}`;
|
|
59
80
|
}
|
|
60
81
|
async function loadPlaywrightReal() {
|
|
61
82
|
return (await import("playwright-core"));
|
|
@@ -114,6 +135,36 @@ function runInstallReal(onLine) {
|
|
|
114
135
|
});
|
|
115
136
|
});
|
|
116
137
|
}
|
|
138
|
+
// Same install CLI, `install-deps` instead of `install` -- apt-get's the OS
|
|
139
|
+
// shared libraries Chromium links (sudo internally if not already root; see
|
|
140
|
+
// the module header). No progress-line parsing: apt-get's output doesn't
|
|
141
|
+
// carry a percent, and this is a rare repair path, not the common-case
|
|
142
|
+
// download bar `runInstallReal` throttles updates for.
|
|
143
|
+
function runInstallDepsReal() {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const cliPath = resolveInstallCli();
|
|
146
|
+
if (!cliPath) {
|
|
147
|
+
reject(new Error(MISSING_PLAYWRIGHT_MESSAGE));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const child = spawn(process.execPath, [cliPath, "install-deps", "chromium"], {
|
|
151
|
+
env: process.env,
|
|
152
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
153
|
+
});
|
|
154
|
+
let output = "";
|
|
155
|
+
child.stdout.on("data", (chunk) => (output += chunk.toString("utf8")));
|
|
156
|
+
child.stderr.on("data", (chunk) => (output += chunk.toString("utf8")));
|
|
157
|
+
child.on("error", (err) => reject(err));
|
|
158
|
+
child.on("exit", (code, signal) => {
|
|
159
|
+
if (code === 0)
|
|
160
|
+
resolve();
|
|
161
|
+
else {
|
|
162
|
+
const tail = output.trim().split("\n").slice(-5).join(" | ");
|
|
163
|
+
reject(new Error(`install-deps exited with ${signal ? `signal ${signal}` : `code ${code}`}${tail ? ` (${tail})` : ""}`));
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
117
168
|
// MODULE-level (not per-manager): one Chromium download per process, no
|
|
118
169
|
// matter how many managers/serves/prewarms race for it. Reset to null when
|
|
119
170
|
// the flight settles -- on success the fs existence check gates any future
|
|
@@ -206,9 +257,67 @@ async function ensureInstalled(runInstall, hooks, getCachePath) {
|
|
|
206
257
|
flight.progressListeners.delete(listener);
|
|
207
258
|
}
|
|
208
259
|
}
|
|
260
|
+
// -- deps repair (retrofits EXISTING sandboxes booted from a pre-fix image) ------
|
|
261
|
+
// Chromium DOWNLOAD (above) and Chromium's OS-level LAUNCH dependencies are
|
|
262
|
+
// fixed at two different times for two different populations: a template
|
|
263
|
+
// rebuild (castle-cloud-cli/template.mjs) bakes the libs in for sandboxes
|
|
264
|
+
// created AFTER the fix, but does nothing for already-running sandboxes that
|
|
265
|
+
// booted from the old image and may have already downloaded Chromium (so
|
|
266
|
+
// `missingExecutable` is false for them -- ensureInstalled's branch never
|
|
267
|
+
// runs again). This is the fallback for that population: attempted lazily,
|
|
268
|
+
// ONLY on an actual launch failure that looks like a missing shared library
|
|
269
|
+
// (see MISSING_SHARED_LIBRARY_RE), ONLY on linux (never on a local macOS
|
|
270
|
+
// dev machine), and at MOST ONCE per process -- a repair that fails (no
|
|
271
|
+
// sudo, no apt, offline) is not worth retrying on every subsequent playtest
|
|
272
|
+
// call. Module-level (not per-manager) for the same reason installFlight is:
|
|
273
|
+
// one apt-get for the whole process, no matter how many managers/callers hit
|
|
274
|
+
// a broken launch concurrently.
|
|
275
|
+
let depsRepairFlight = null;
|
|
276
|
+
let depsRepairSettled = null;
|
|
277
|
+
function logDepsRepairStart() {
|
|
278
|
+
console.error("[playtest] chromium launch failed on a missing OS library -- attempting one-time repair (apt-get via playwright install-deps)...");
|
|
279
|
+
}
|
|
280
|
+
function logDepsRepairResult(ok, elapsedMs, err) {
|
|
281
|
+
const elapsedS = (elapsedMs / 1000).toFixed(1);
|
|
282
|
+
if (ok) {
|
|
283
|
+
console.error(`[playtest] system library repair succeeded in ${elapsedS}s -- retrying browser launch`);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
287
|
+
console.error(`[playtest] system library repair failed after ${elapsedS}s: ${message}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function attemptDepsRepair(runInstallDeps, onProgress) {
|
|
291
|
+
if (depsRepairSettled) {
|
|
292
|
+
// Already tried once this process: a success means the retry that
|
|
293
|
+
// follows should just work (no need to repeat), a failure means
|
|
294
|
+
// repeating would just fail again the same way (no sudo/apt/network) --
|
|
295
|
+
// either way, don't re-run apt-get on every subsequent broken launch.
|
|
296
|
+
return Promise.resolve(depsRepairSettled.ok ? { ok: true } : { ok: false, error: "system library repair already failed once this process" });
|
|
297
|
+
}
|
|
298
|
+
if (depsRepairFlight)
|
|
299
|
+
return depsRepairFlight;
|
|
300
|
+
const startedAt = Date.now();
|
|
301
|
+
logDepsRepairStart();
|
|
302
|
+
onProgress?.(DEPS_REPAIR_LABEL);
|
|
303
|
+
depsRepairFlight = runInstallDeps().then(() => {
|
|
304
|
+
depsRepairSettled = { ok: true };
|
|
305
|
+
logDepsRepairResult(true, Date.now() - startedAt);
|
|
306
|
+
return { ok: true };
|
|
307
|
+
}, (err) => {
|
|
308
|
+
depsRepairSettled = { ok: false };
|
|
309
|
+
logDepsRepairResult(false, Date.now() - startedAt, err);
|
|
310
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
311
|
+
}).finally(() => {
|
|
312
|
+
depsRepairFlight = null;
|
|
313
|
+
});
|
|
314
|
+
return depsRepairFlight;
|
|
315
|
+
}
|
|
209
316
|
export function createPlaytestBrowserManager(seams) {
|
|
210
317
|
const loadPlaywright = seams?.loadPlaywright ?? loadPlaywrightReal;
|
|
211
318
|
const runInstall = seams?.runInstall ?? runInstallReal;
|
|
319
|
+
const runInstallDeps = seams?.runInstallDeps ?? runInstallDepsReal;
|
|
320
|
+
const platform = seams?.platform ?? process.platform;
|
|
212
321
|
let browser = null;
|
|
213
322
|
let idleTimer = null;
|
|
214
323
|
let launching = null;
|
|
@@ -243,7 +352,7 @@ export function createPlaytestBrowserManager(seams) {
|
|
|
243
352
|
return true;
|
|
244
353
|
}
|
|
245
354
|
}
|
|
246
|
-
async function
|
|
355
|
+
async function tryLaunch(playwright) {
|
|
247
356
|
try {
|
|
248
357
|
const launched = await playwright.chromium.launch({ args: LAUNCH_ARGS });
|
|
249
358
|
launched.on("disconnected", () => {
|
|
@@ -254,8 +363,28 @@ export function createPlaytestBrowserManager(seams) {
|
|
|
254
363
|
return { ok: true, browser: launched };
|
|
255
364
|
}
|
|
256
365
|
catch (err) {
|
|
257
|
-
return { ok: false, error:
|
|
366
|
+
return { ok: false, error: err };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async function launchBrowser(playwright, hooks) {
|
|
370
|
+
const first = await tryLaunch(playwright);
|
|
371
|
+
if (first.ok)
|
|
372
|
+
return first;
|
|
373
|
+
// Self-heal path for sandboxes that already have the Chromium BINARY
|
|
374
|
+
// (so ensureInstalled's download branch never re-runs) but are missing
|
|
375
|
+
// the OS libraries it links -- see the module header above
|
|
376
|
+
// attemptDepsRepair for why this is gated to linux + this specific
|
|
377
|
+
// failure shape + at most one attempt per process.
|
|
378
|
+
if (platform === "linux" && looksLikeMissingSharedLibrary(first.error)) {
|
|
379
|
+
const repaired = await attemptDepsRepair(runInstallDeps, hooks?.onProgress);
|
|
380
|
+
if (repaired.ok) {
|
|
381
|
+
const retry = await tryLaunch(playwright);
|
|
382
|
+
if (retry.ok)
|
|
383
|
+
return retry;
|
|
384
|
+
return { ok: false, error: launchFailureMessage(retry.error) };
|
|
385
|
+
}
|
|
258
386
|
}
|
|
387
|
+
return { ok: false, error: launchFailureMessage(first.error) };
|
|
259
388
|
}
|
|
260
389
|
// Two DIFFERENT single-flights, deliberately layered: the install check
|
|
261
390
|
// runs per caller (so every concurrent caller passes its OWN hooks through
|
|
@@ -301,7 +430,7 @@ export function createPlaytestBrowserManager(seams) {
|
|
|
301
430
|
if (existing?.isConnected())
|
|
302
431
|
return { ok: true, browser: existing, installedMs };
|
|
303
432
|
if (!launching) {
|
|
304
|
-
launching = launchBrowser(playwright).finally(() => {
|
|
433
|
+
launching = launchBrowser(playwright, hooks).finally(() => {
|
|
305
434
|
launching = null;
|
|
306
435
|
});
|
|
307
436
|
}
|