Files
paseo/packages/app/e2e/support/global-setup.ts
T
Mohamed BoudraandGitHub 5a30affd7f Skip unrelated required checks without losing their names (#2542)
* fix(ci): skip unrelated required checks cleanly

Matrix jobs had to start no-op runners to preserve interpolated required-check names. Static named jobs let GitHub report genuine skipped conclusions while keeping those names stable.

* fix(ci): gate changes to install patching script

* fix(ci): route cross-package test contracts narrowly

* fix(ci): keep routed contracts merge-blocking

* fix(server): exclude nested test utilities from builds

* fix(ci): cover app-owned native and browser domains

* refactor(ci): align tests with runtime domains

Own browser specs, desktop specs, and Electron-only app code by stable directories so PR routing does not depend on filename conventions. Keep desktop integration coverage inside the required desktop check.

* fix(ci): keep routing and desktop suites isolated

The routing regression must run before dependency installation, and desktop Vitest must not collect Playwright-owned specs.

* fix(ci): isolate desktop e2e runtime

* perf(test): isolate Playwright workers

* perf(test): parallelize Playwright within specs

* fix(test): keep Playwright file scheduling

* fix(test): share dynamic Metro fixture

* fix(test): preserve own-host restart fixtures

* fix(ci): preserve routed contracts after rebase

* Stabilize Playwright wheel-scroll assertions
2026-08-02 00:56:05 +08:00

227 lines
7.2 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
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();
}
}
async function stopProcess(child: ChildProcess | null): Promise<void> {
if (!child || child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
once(child, "exit"),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
resolve();
}, 5_000);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
const appDir = path.resolve(__dirname, "../..");
const child = spawn("npx", ["expo", "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 stopProcess(metroProcess);
console.log("[e2e] Metro stopped");
};
} catch (error) {
await stopProcess(metroProcess);
throw error;
}
}