gstack/bin/gstack-browse-repair.ts

129 lines
6.1 KiB
TypeScript

#!/usr/bin/env bun
/** Repair the Playwright browser cache used by gstack browse. */
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const CACHE = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright');
const MAX_INSTALL_MS = 300_000;
type BrowserSpec = { name: string; revision: string; browserVersion: string };
function fail(message: string): never {
console.error(`[browse-repair] ${message}`);
process.exit(1);
}
function readSpec(name: string): BrowserSpec {
const file = path.join(ROOT, 'node_modules', 'playwright-core', 'browsers.json');
const data = JSON.parse(fs.readFileSync(file, 'utf8')) as { browsers: BrowserSpec[] };
const spec = data.browsers.find(browser => browser.name === name);
if (!spec) fail(`Playwright browser spec ${name} not found`);
return spec;
}
function expectedExecutable(spec: BrowserSpec): string | null {
if (process.platform !== 'darwin' || process.arch !== 'arm64') return null;
if (spec.name === 'chromium') {
return path.join(CACHE, `chromium-${spec.revision}`, 'chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing');
}
if (spec.name === 'chromium-headless-shell') {
return path.join(CACHE, `chromium_headless_shell-${spec.revision}`, 'chrome-headless-shell-mac-arm64', 'chrome-headless-shell');
}
return null;
}
function installationDirectory(spec: BrowserSpec): string {
return spec.name === 'chromium'
? path.join(CACHE, `chromium-${spec.revision}`)
: path.join(CACHE, `chromium_headless_shell-${spec.revision}`);
}
function isHealthy(spec: BrowserSpec): boolean {
const executable = expectedExecutable(spec);
if (!executable || !fs.existsSync(executable)) return false;
if (!fs.existsSync(path.join(installationDirectory(spec), 'INSTALLATION_COMPLETE'))) return false;
const result = Bun.spawnSync([executable, '--version'], { stdout: 'pipe', stderr: 'pipe', timeout: 10_000 });
return result.exitCode === 0;
}
function archiveUrl(spec: BrowserSpec): string | null {
if (process.platform !== 'darwin' || process.arch !== 'arm64') return null;
const archive = spec.name === 'chromium'
? 'chrome-mac-arm64.zip'
: spec.name === 'chromium-headless-shell'
? 'chrome-headless-shell-mac-arm64.zip'
: null;
return archive ? `https://storage.googleapis.com/chrome-for-testing-public/${spec.browserVersion}/mac-arm64/${archive}` : null;
}
async function repairMacArtifact(spec: BrowserSpec): Promise<void> {
const url = archiveUrl(spec);
const executable = expectedExecutable(spec);
if (!url || !executable) fail(`no direct repair path for ${process.platform}/${process.arch}`);
const installDir = installationDirectory(spec);
if (fs.existsSync(installDir)) {
const backup = `${installDir}.incomplete-${Date.now()}`;
fs.renameSync(installDir, backup);
console.error(`[browse-repair] preserved incomplete cache at ${backup}`);
}
fs.mkdirSync(installDir, { recursive: true, mode: 0o700 });
const archive = path.join(os.tmpdir(), `gstack-${spec.name}-${spec.revision}-${process.pid}.zip`);
try {
// macOS ships curl, and its file-backed transfer avoids buffering a
// 150MB+ Chromium archive inside Bun. The earlier fetch + Bun.write path
// could spin indefinitely while materializing a large Response object.
const download = Bun.spawnSync([
'curl', '--fail', '--location', '--silent', '--show-error',
'--max-time', String(MAX_INSTALL_MS / 1000), '--output', archive, url,
], { stdout: 'pipe', stderr: 'pipe', timeout: MAX_INSTALL_MS + 5_000 });
if (download.exitCode !== 0) {
fail(`download failed for ${spec.name}: ${download.stderr.toString().trim() || 'timed out'}`);
}
const unpack = Bun.spawnSync(['ditto', '-x', '-k', archive, installDir], { stdout: 'pipe', stderr: 'pipe', timeout: MAX_INSTALL_MS });
if (unpack.exitCode !== 0) fail(`could not unpack ${spec.name}: ${unpack.stderr.toString().trim()}`);
} finally {
try { fs.unlinkSync(archive); } catch {}
}
if (!expectedExecutable(spec) || !fs.existsSync(expectedExecutable(spec)!)) {
fail(`${spec.name} still failed its executable health check`);
}
// Playwright's registry only recognizes browser directories carrying this
// marker. Without it, a later installer/GC can discard a working repair.
fs.writeFileSync(path.join(installDir, 'INSTALLATION_COMPLETE'), '', { mode: 0o600 });
if (!isHealthy(spec)) fail(`${spec.name} still failed its registry health check`);
}
function repairViaPlaywright(): void {
const cli = path.join(ROOT, 'node_modules', 'playwright-core', 'cli.js');
const result = Bun.spawnSync(['node', cli, 'install', 'chromium', 'chromium-headless-shell'], {
stdout: 'pipe', stderr: 'pipe', timeout: MAX_INSTALL_MS,
});
if (result.exitCode !== 0) fail(`Playwright install failed: ${result.stderr.toString().trim() || 'timed out'}`);
}
const repair = process.argv.slice(2).includes('--repair');
// The direct executable health check below is intentionally macOS/Apple
// Silicon-specific. Other Playwright platforms use different archive layouts;
// defer their validation/install semantics to Playwright's own CLI rather than
// declaring a healthy cache broken on every setup run.
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
if (repair) repairViaPlaywright();
console.log('[browse-repair] Playwright browser repair delegated to Playwright');
process.exit(0);
}
const specs = [readSpec('chromium'), readSpec('chromium-headless-shell')];
const unhealthy = specs.filter(spec => !isHealthy(spec));
if (!unhealthy.length) {
console.log('[browse-repair] Playwright browser cache is healthy');
process.exit(0);
}
if (!repair) fail(`missing or unhealthy: ${unhealthy.map(spec => spec.name).join(', ')} (run with --repair)`);
for (const spec of unhealthy) await repairMacArtifact(spec);
const stillUnhealthy = specs.filter(spec => !isHealthy(spec));
if (stillUnhealthy.length) fail(`repair incomplete: ${stillUnhealthy.map(spec => spec.name).join(', ')}`);
console.log('[browse-repair] Playwright browser cache repaired');