mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
* fix(daemon): keep lifecycle operations on the selected instance Restart reconstructed launch inputs and address fallback could redirect a home-selected shutdown to another daemon. Let supervisors publish bound endpoints, retain their worker restart contract, and share captured local lifecycle ownership between CLI and Desktop. * test(daemon): verify bounded desktop readiness and cancellable artifacts * test(cli): verify reload target hints and durable offline pairing * test(daemon): align lifecycle fixtures with ownership and targeting * fix(daemon): preserve reachability when status details time out Provider discovery can outlast a status request after authentication succeeds. Keep local connection facts separate from unavailable details, while explicit remote queries retain their error outcome. Migrate CLI and packaged Desktop fixtures to persistent managed configuration and explicit targets. Isolate generic Git observation from forge discovery and reuse disposable CLI homes on Windows. * test(daemon): observe restart through the worker status RPC The bounded CLI status observation may omit worker details while an authenticated connection remains healthy. Exercise the app-style restart through its full RPC and retain the replacement observation, with a real slow provider probe covering the distinction.
217 lines
7.1 KiB
TypeScript
217 lines
7.1 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, // Installed daemon.
|
|
6768, // 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() {
|
|
if (process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1") {
|
|
if (!process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL) {
|
|
throw new Error("PASEO_REPLICA_CACHE_MEASUREMENT_URL must be set for live measurement");
|
|
}
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
}
|