import { execFileSync, spawn } from "node:child_process"; import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join, relative, sep } from "node:path"; import { pathToFileURL } from "node:url"; import { ROOT } from "./lib/common.ts"; export function within(directory: string, path: string): boolean { const rel = relative(directory, path); return ( rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) ); } export function isolatedEnvironment( root: string, parent: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, ): NodeJS.ProcessEnv { const originalHome = parent["HOME"] ?? parent["USERPROFILE"]; if (!originalHome) throw new Error("E2E requires HOME or USERPROFILE"); const originalData = parent["MISE_DATA_DIR"] ?? join( parent["XDG_DATA_HOME"] ?? (platform === "win32" ? (parent["LOCALAPPDATA"] ?? join(originalHome, "AppData", "Local")) : join(originalHome, ".local", "share")), "mise", ); const originalConfig = parent["MISE_CONFIG_DIR"] ?? join(parent["XDG_CONFIG_HOME"] ?? join(originalHome, ".config"), "mise"); // Only the CI job's explicit disposable cache mount may outlive a run. const cacheRoot = parent["CI"] === "true" ? parent["PI_EXT_CI_CACHE_ROOT"] : undefined; if (cacheRoot && !isAbsolute(cacheRoot)) throw new Error("CI cache root must be absolute"); const env: NodeJS.ProcessEnv = { ...parent, HOME: join(root, "home"), USERPROFILE: join(root, "home"), APPDATA: join(root, "config"), LOCALAPPDATA: join(root, "data"), XDG_CONFIG_HOME: join(root, "config"), XDG_CACHE_HOME: join(root, "cache"), XDG_DATA_HOME: join(root, "data"), XDG_STATE_HOME: join(root, "state"), TMPDIR: join(root, "tmp"), TMP: join(root, "tmp"), TEMP: join(root, "tmp"), PLAYWRIGHT_BROWSERS_PATH: cacheRoot ? join(cacheRoot, "browsers") : join(root, "browsers"), npm_config_cache: cacheRoot ? (parent["NPM_CONFIG_CACHE"] ?? join(cacheRoot, "npm")) : join(root, "npm-cache"), PI_CODING_AGENT_DIR: join(root, "agent"), PI_BAK_E2E_S3: "0", PI_EXT_E2E_ROOT: root, MISE_CONFIG_DIR: originalConfig, MISE_DATA_DIR: originalData, MISE_CACHE_DIR: cacheRoot ? join(cacheRoot, "downloads") : join(root, "mise-cache"), MISE_STATE_DIR: join(root, "mise-state"), MISE_AUTO_INSTALL: "0", MISE_TASK_RUN_AUTO_INSTALL: "0", }; for (const key of Object.keys(env)) if (key.startsWith("PI_BAK_S3_")) delete env[key]; return env; } export function requireVideoTools(): void { let encoders: string; try { encoders = execFileSync("ffmpeg", ["-hide_banner", "-encoders"], { encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024, }); } catch (cause) { throw new Error( "Chrome video tests require a working ffmpeg binary on PATH", { cause }, ); } if (!/(?:^|\s)libvpx-vp9(?:\s|$)/u.test(encoders)) throw new Error( "Chrome video tests require ffmpeg with the libvpx-vp9 encoder on PATH", ); try { execFileSync("ffprobe", ["-version"], { encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024, }); } catch (cause) { throw new Error( "Chrome video tests require a working ffprobe binary on PATH", { cause }, ); } } function run( command: string, args: string[], env: NodeJS.ProcessEnv, signal: AbortSignal, ): Promise { if (signal.aborted) return Promise.resolve(130); return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: ROOT, env, stdio: "inherit", detached: process.platform !== "win32", }); const interrupt = () => { try { if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGTERM"); else child.kill("SIGTERM"); } catch { // The child already exited. } }; signal.addEventListener("abort", interrupt, { once: true }); child.once("error", (error) => { signal.removeEventListener("abort", interrupt); reject(error); }); child.once("close", (code, exitSignal) => { signal.removeEventListener("abort", interrupt); resolve(signal.aborted ? 130 : (code ?? (exitSignal ? 1 : 0))); }); }); } export function selectedTasks( tasks: string[], filter: string | undefined, ): string[] { if (!filter) return tasks; if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(filter)) throw new Error("E2E filter must be an extension slug"); const selected = tasks.filter( (task) => task === `//extensions/${filter}:e2e`, ); if (selected.length !== 1) throw new Error(`No local E2E task for ${filter}`); return selected; } export async function main( tasks: string[] = process.argv.slice(2), ): Promise { if (tasks.length === 0) throw new Error("E2E requires explicit Mise task names"); tasks = selectedTasks(tasks, process.env["PI_EXT_E2E_FILTER"]); if (tasks.includes("//extensions/chrome-cdp:e2e")) requireVideoTools(); const [repo, osTemp] = await Promise.all([ realpath(ROOT), realpath(tmpdir()), ]); if (within(repo, osTemp)) throw new Error( `OS temporary directory must be outside repository: ${osTemp}`, ); const root = await mkdtemp(join(osTemp, "pi-ext-e2e-")); const abort = new AbortController(); const interrupt = () => abort.abort(); process.once("SIGINT", interrupt); process.once("SIGTERM", interrupt); try { for (const name of [ "home", "config", "cache", "data", "state", "tmp", "agent", "browsers", ]) await mkdir(join(root, name)); const env = isolatedEnvironment(root); const installed = await run( "npm", ["exec", "--", "playwright", "install", "chromium", "firefox"], env, abort.signal, ); if (installed !== 0) return installed; const failures = []; for (const task of tasks) { if (abort.signal.aborted) return 130; const exitCode = await run( "mise", [ "run", ...(process.platform === "win32" ? ["--shell", "cmd.exe /d /s /c"] : []), task, ], env, abort.signal, ); if (abort.signal.aborted) return 130; if (exitCode !== 0) failures.push(task); } if (failures.length) { console.error(`E2E failed: ${failures.join(", ")}`); return 1; } return 0; } finally { process.off("SIGINT", interrupt); process.off("SIGTERM", interrupt); await rm(root, { recursive: true, force: true }); } } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { try { process.exitCode = await main(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; } }