Files
paseo/packages/app/e2e/support/global-setup.ts
T
f4b5f32862 test(e2e): boot the Playwright harness on Windows (#3804)
* test(e2e): boot the Playwright harness on Windows

The harness could not start on Windows. Metro was launched with
spawn("npx", ...) and the isolated daemon resolved its runner with
execSync("which tsx") — npx is a .cmd shim that Node's bare spawn
cannot launch, and `which` does not exist there.

Route both through process.execPath instead of the node_modules/.bin
shims. Node refuses to spawn .cmd or .bat without shell: true, and
shell: true is the wrong escape hatch: it concatenates argv unescaped
(DEP0190), splits paths containing spaces, and sends kill signals to
cmd.exe rather than the real child. Spawning Node directly has none of
those properties and is identical on POSIX, so no platform branch is
needed. The same reasoning fixes the published-server npm install,
which passed "npm.cmd" to execFileSync and had been failing with
EINVAL on Windows since Node's CVE-2024-27980 mitigation landed.

Teardown now kills the process tree. A Windows signal reaches only the
direct child, so Metro's and the supervisor's forked workers kept the
listening port and the next run failed to bind.

The daemon startup deadline goes from 20s to 90s. A cold tsx start was
measured at 25s on Windows and exceeded 60s under load; the loop still
fails immediately on a crashed child, so the ceiling only costs time
that a genuinely slow start needs.

The fake editor and gh binaries the harness puts on PATH gain .cmd
wrappers, because Windows resolves PATH entries by PATHEXT and cannot
execute an extension-less shebang file. The wrappers point at the same
payload file so the recorded command name stays "cursor", "code", or
"gh" on every platform.

The asdf-backed local Elixir relay stays POSIX-only and now says so
before spawning.

* test(ci): follow shared e2e daemon launcher

---------

Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-24 18:20:19 +02:00

210 lines
6.9 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import { killProcessTree } from "./helpers/spawn-node";
import { existsSync } from "node:fs";
import path from "node:path";
import net from "node:net";
import dotenv from "dotenv";
export interface WaitForServerOptions {
host?: string;
timeoutMs?: number;
label: string;
childProcess?: ChildProcess | null;
getRecentOutput?: () => string;
}
type ServerProbe = (host: string, port: number) => Promise<void>;
const RESERVED_LOCAL_PORTS = new Set([
6767, // Developer daemon.
61680, // OpenCode's default local server.
]);
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
const lines: string[] = [];
return {
add(line) {
lines.push(line);
if (lines.length > maxLines) lines.shift();
},
dump: () => lines.join("\n"),
};
}
function formatRecentOutput(getRecentOutput?: () => string): string {
const output = getRecentOutput?.().trim();
return output ? `\nRecent output:\n${output}` : "";
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
return;
}
server.close(() => resolve(address.port));
});
});
}
async function getAvailableE2EPort(): Promise<number> {
for (;;) {
const port = await getAvailablePort();
if (!RESERVED_LOCAL_PORTS.has(port)) return port;
}
}
async function connectToServer(host: string, port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, host, () => {
socket.end();
resolve();
});
socket.setTimeout(1_000, () => {
socket.destroy();
reject(new Error(`Connection timed out to ${host}:${port}`));
});
socket.on("error", reject);
});
}
async function waitForServer(
port: number,
options: WaitForServerOptions,
probe: ServerProbe = connectToServer,
): Promise<void> {
const { host = "127.0.0.1", timeoutMs = 15_000, label, childProcess, getRecentOutput } = options;
const deadline = Date.now() + timeoutMs;
let lastConnectionError: unknown = null;
while (Date.now() < deadline) {
if (childProcess?.exitCode !== null && childProcess?.exitCode !== undefined) {
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
throw new Error(
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`,
);
}
try {
await probe(host, port);
return;
} catch (error) {
lastConnectionError = error;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
const reason =
lastConnectionError instanceof Error
? ` Last connection error: ${lastConnectionError.message}`
: "";
throw new Error(
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`,
);
}
async function probeMetro(host: string, port: number): Promise<void> {
const response = await fetch(`http://${host}:${port}/status`, {
signal: AbortSignal.timeout(1_000),
});
const body = (await response.text()).trim();
if (response.status !== 200 || body !== "packager-status:running") {
throw new Error(
`Expected Metro status on ${host}:${port}, received HTTP ${response.status}: ${JSON.stringify(body.slice(0, 200))}`,
);
}
}
export async function waitForMetro(port: number, options: WaitForServerOptions): Promise<void> {
await waitForServer(port, options, probeMetro);
}
export async function warmMetro(port: number): Promise<void> {
const origin = `http://127.0.0.1:${port}`;
const documentResponse = await fetch(origin, { signal: AbortSignal.timeout(120_000) });
if (!documentResponse.ok) {
throw new Error(`Metro document warmup failed with HTTP ${documentResponse.status}`);
}
const document = await documentResponse.text();
const scriptSources = [...document.matchAll(/<script[^>]+src=["']([^"']+)["']/g)].map(
(match) => match[1],
);
if (scriptSources.length === 0) {
throw new Error("Metro document warmup found no scripts to compile");
}
for (const source of scriptSources) {
const scriptUrl = new URL(source, origin);
if (scriptUrl.origin !== origin) continue;
const response = await fetch(scriptUrl, { signal: AbortSignal.timeout(120_000) });
if (!response.ok) {
throw new Error(
`Metro bundle warmup failed for ${scriptUrl.pathname}: HTTP ${response.status}`,
);
}
await response.arrayBuffer();
}
}
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
const appDir = path.resolve(__dirname, "../..");
const expoCli = require.resolve("expo/bin/cli");
// Spawns Node directly to bypass Windows .cmd shim execution restrictions without shell: true.
const child = spawn(process.execPath, [expoCli, "start", "--web", "--port", String(port)], {
cwd: appDir,
env: {
...process.env,
BROWSER: "none",
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_WEB_PLATFORM: "electron" } : {}),
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
const log = (chunk: Buffer, stream: "stdout" | "stderr") => {
for (const line of chunk.toString().split("\n").filter(Boolean)) {
buffer.add(`[${stream}] ${line}`);
console[stream === "stdout" ? "log" : "error"](`[metro] ${line}`);
}
};
child.stdout?.on("data", (chunk: Buffer) => log(chunk, "stdout"));
child.stderr?.on("data", (chunk: Buffer) => log(chunk, "stderr"));
return child;
}
async function loadHarnessEnvironment(repoRoot: string): Promise<void> {
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) dotenv.config({ path: envTestPath });
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, "../../../..");
await loadHarnessEnvironment(repoRoot);
const metroPort = await getAvailableE2EPort();
const metroOutput = createLineBuffer();
let metroProcess: ChildProcess | null = null;
try {
metroProcess = startMetro(metroPort, metroOutput);
await waitForMetro(metroPort, {
label: "Metro web server",
timeoutMs: 120_000,
childProcess: metroProcess,
getRecentOutput: metroOutput.dump,
});
await warmMetro(metroPort);
process.env.E2E_METRO_PORT = String(metroPort);
console.log(`[e2e] Metro warmed on port ${metroPort}`);
return async () => {
await killProcessTree(metroProcess);
console.log("[e2e] Metro stopped");
};
} catch (error) {
await killProcessTree(metroProcess);
throw error;
}
}