mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
* 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>
204 lines
7.2 KiB
TypeScript
204 lines
7.2 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./paseo-home-fork";
|
|
import { startIsolatedHostDaemon } from "./isolated-host-daemon";
|
|
|
|
export interface E2EWorker {
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
function resolveOptionalHome(value: string | undefined): string | null {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) return null;
|
|
return resolvePaseoHomePath(trimmed === "current" ? "~/.paseo" : trimmed);
|
|
}
|
|
|
|
async function createFakeEditorBin(): Promise<string> {
|
|
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
|
let realGhPath = "";
|
|
try {
|
|
const locator = process.platform === "win32" ? "where.exe" : "which";
|
|
const candidates = execFileSync(locator, ["gh"], { encoding: "utf8" })
|
|
.split(/\r?\n/u)
|
|
.map((candidate) => candidate.trim())
|
|
.filter(Boolean);
|
|
realGhPath =
|
|
candidates.find(
|
|
(candidate) =>
|
|
process.platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(path.extname(candidate)),
|
|
) ?? "";
|
|
} catch {
|
|
// The local GitHub fixture remains usable without a system gh binary.
|
|
}
|
|
const fakeEditorSource = `#!/usr/bin/env node
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const recordPath = process.env.PASEO_E2E_EDITOR_RECORD_PATH;
|
|
if (recordPath) {
|
|
fs.appendFileSync(recordPath, JSON.stringify({
|
|
command: path.basename(process.argv[1]),
|
|
args: process.argv.slice(2),
|
|
cwd: process.cwd(),
|
|
at: Date.now()
|
|
}) + "\\n");
|
|
}
|
|
`;
|
|
for (const editorCommand of ["cursor", "code"]) {
|
|
const editorPath = path.join(binDir, editorCommand);
|
|
await writeFile(editorPath, fakeEditorSource);
|
|
await chmod(editorPath, 0o755);
|
|
if (process.platform === "win32") {
|
|
await writeFile(`${editorPath}.cmd`, `@node "%~dp0${editorCommand}" %*\r\n`);
|
|
}
|
|
}
|
|
|
|
const fakeGhPath = path.join(binDir, "gh");
|
|
const fakeGhSource = `#!/usr/bin/env node
|
|
const { spawnSync } = require("child_process");
|
|
const args = process.argv.slice(2);
|
|
const fixtureRemote = "https://github.com/paseo-e2e/local-fixture.git";
|
|
const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"]
|
|
}).stdout?.trim();
|
|
|
|
if (origin === fixtureRemote) {
|
|
const command = args.slice(0, 2).join(" ");
|
|
if (command === "auth status") process.exit(0);
|
|
if (command === "repo view") {
|
|
process.stdout.write(JSON.stringify({ owner: { login: "paseo-e2e" }, name: "local-fixture", parent: null }));
|
|
process.exit(0);
|
|
}
|
|
if (command === "issue list") {
|
|
process.stdout.write("[]");
|
|
process.exit(0);
|
|
}
|
|
if (command === "pr list" || command === "pr view") {
|
|
const pr = {
|
|
number: 1,
|
|
title: "Use pasted PR as start ref",
|
|
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
|
state: "OPEN",
|
|
body: null,
|
|
labels: [],
|
|
baseRefName: "main",
|
|
headRefName: "pr-branch-1",
|
|
updatedAt: "2026-01-01T00:00:00Z"
|
|
};
|
|
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
|
process.exit(0);
|
|
}
|
|
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
|
|
process.stdout.write(JSON.stringify({
|
|
data: { repository: { pullRequest: {
|
|
number: 1,
|
|
baseRefName: "main",
|
|
headRefName: "pr-branch-1",
|
|
isCrossRepository: false,
|
|
headRepositoryOwner: { login: "paseo-e2e" },
|
|
headRepository: {
|
|
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
|
|
url: fixtureRemote
|
|
}
|
|
} } }
|
|
}));
|
|
process.exit(0);
|
|
}
|
|
process.stderr.write("Unsupported local GitHub fixture command: " + args.join(" ") + "\\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
const realGhPath = ${JSON.stringify(realGhPath)};
|
|
if (!realGhPath) process.exit(127);
|
|
const result = spawnSync(realGhPath, args, { stdio: "inherit" });
|
|
process.exit(result.status ?? 1);
|
|
`;
|
|
await writeFile(fakeGhPath, fakeGhSource);
|
|
await chmod(fakeGhPath, 0o755);
|
|
if (process.platform === "win32") {
|
|
await writeFile(`${fakeGhPath}.cmd`, '@node "%~dp0gh" %*\r\n');
|
|
}
|
|
return binDir;
|
|
}
|
|
|
|
async function applyMetadataFork(targetHome: string, providerIds: string[]): Promise<void> {
|
|
const sourceHome = resolveOptionalHome(process.env.E2E_FORK_PASEO_HOME_FROM);
|
|
if (!sourceHome) return;
|
|
const result = await forkPaseoHomeMetadata({ sourceHome, targetHome });
|
|
process.env.E2E_FORK_SOURCE_PASEO_HOME = result.sourceHome;
|
|
process.env.E2E_FORK_TARGET_PASEO_HOME = result.targetHome;
|
|
process.env.E2E_FORK_COPIED_FILES = String(result.copiedFiles);
|
|
process.env.E2E_FORK_COPIED_BYTES = String(result.copiedBytes);
|
|
|
|
if (providerIds.length === 0) return;
|
|
|
|
const sourceConfig = JSON.parse(
|
|
await readFile(path.join(result.sourceHome, "config.json"), "utf8"),
|
|
);
|
|
const sourceProviders = sourceConfig.agents?.providers ?? {};
|
|
const providers = Object.fromEntries(
|
|
providerIds.map((providerId: string) => {
|
|
const provider = sourceProviders[providerId];
|
|
if (!provider) {
|
|
throw new Error(`E2E provider '${providerId}' is not configured in ${result.sourceHome}`);
|
|
}
|
|
return [providerId, provider];
|
|
}),
|
|
);
|
|
await writeFile(
|
|
path.join(targetHome, "config.json"),
|
|
`${JSON.stringify({ version: 1, agents: { providers } }, null, 2)}\n`,
|
|
);
|
|
}
|
|
|
|
export async function startE2EWorker(
|
|
workerIndex: number,
|
|
options: { forkProviders?: string[] } = {},
|
|
): Promise<E2EWorker> {
|
|
const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME);
|
|
const paseoHome = requestedRoot
|
|
? path.join(requestedRoot, `worker-${workerIndex}`)
|
|
: await mkdtemp(path.join(tmpdir(), `paseo-e2e-worker-${workerIndex}-`));
|
|
const preserveHome = Boolean(requestedRoot) || process.env.E2E_KEEP_PASEO_HOME === "1";
|
|
const fakeEditorBin = await createFakeEditorBin();
|
|
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
|
|
const serverId = `srv_e2e_worker_${workerIndex}`;
|
|
|
|
try {
|
|
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
|
|
const daemon = await startIsolatedHostDaemon(serverId, {
|
|
paseoHome,
|
|
preserveHome,
|
|
environment: {
|
|
NODE_ENV: "development",
|
|
PATH: `${fakeEditorBin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
PASEO_E2E_EDITOR_RECORD_PATH: editorRecordPath,
|
|
},
|
|
});
|
|
|
|
process.env.E2E_DAEMON_PORT = String(daemon.port);
|
|
process.env.E2E_SERVER_ID = daemon.serverId;
|
|
process.env.E2E_PASEO_HOME = daemon.paseoHome;
|
|
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
|
|
delete process.env.E2E_RELAY_PORT;
|
|
delete process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
|
|
|
console.log(
|
|
`[e2e] Worker ${workerIndex} daemon started on port ${daemon.port}, home: ${daemon.paseoHome}`,
|
|
);
|
|
return {
|
|
close: async () => {
|
|
await daemon.close();
|
|
await rm(fakeEditorBin, { recursive: true, force: true });
|
|
console.log(`[e2e] Worker ${workerIndex} daemon stopped`);
|
|
},
|
|
};
|
|
} catch (error) {
|
|
await rm(fakeEditorBin, { recursive: true, force: true });
|
|
if (!preserveHome) await rm(paseoHome, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
}
|