repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
scripts/e2e.ts
Rawimport { 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<number> {
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<number> {
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;
}
}