Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/e2e.ts

Raw
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<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;
	}
}