54d507b8d7
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
240 lines
7.9 KiB
JavaScript
240 lines
7.9 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createServer } from "node:http";
|
|
import { chmod, mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { spawn } from "node:child_process";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const desktopRoot = resolve(__dirname, "..");
|
|
const repoRoot = resolve(desktopRoot, "../..");
|
|
|
|
// The Rust side (apps/desktop/src-tauri/src/backend.rs) does a two-stage wait before it
|
|
// navigates the webview: wait_for_backend polls GET /health every 250ms against a bounded
|
|
// HEALTH_TIMEOUT (60s), then wait_for_ready polls GET /ready every 250ms with NO timeout —
|
|
// it only gives up on child exit or shutdown. HEALTH_REQUESTED_TIMEOUT_MS must stay above
|
|
// HEALTH_TIMEOUT so the fake runtime doesn't get killed here before the Rust side even
|
|
// finishes the first stage; desktop-launch-smoke.test.mjs asserts that relationship so the
|
|
// two stay in sync. READY_REQUESTED_TIMEOUT_MS only bounds this test — the fake runtime
|
|
// answers /ready immediately once it's listening, so the real wait_for_ready being unbounded
|
|
// doesn't matter here.
|
|
export const HEALTH_REQUESTED_TIMEOUT_MS = 90_000;
|
|
export const READY_REQUESTED_TIMEOUT_MS = 60_000;
|
|
export const ROOT_REQUESTED_TIMEOUT_MS = 60_000;
|
|
|
|
// Only run the CLI behavior when this file is executed directly (`node desktop-launch-smoke.mjs`
|
|
// or the fake-runtime re-exec below) — not when desktop-launch-smoke.test.mjs imports it.
|
|
const isEntryPoint = resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url);
|
|
if (isEntryPoint) {
|
|
if (process.argv[2] === "--fake-runtime") {
|
|
await runFakeRuntime(process.argv[3], process.argv.slice(4));
|
|
} else {
|
|
await runSmoke();
|
|
}
|
|
}
|
|
|
|
async function runSmoke() {
|
|
const appBinary = resolve(desktopRoot, "src-tauri/target/release/kandev-desktop");
|
|
if (!existsSync(appBinary)) {
|
|
throw new Error(`Missing desktop binary at ${appBinary}; run pnpm build first`);
|
|
}
|
|
|
|
const tmp = await mkdtemp(join(tmpdir(), "kandev-desktop-e2e-"));
|
|
const runtimeDir = join(tmp, "runtime");
|
|
const stateDir = join(tmp, "state");
|
|
await mkdir(join(runtimeDir, "bin"), { recursive: true });
|
|
await mkdir(stateDir, { recursive: true });
|
|
|
|
await writeFakeRuntime(runtimeDir, stateDir);
|
|
|
|
const launchedViaXvfb = !process.env.DISPLAY && commandExists("xvfb-run");
|
|
const command = launchedViaXvfb ? "xvfb-run" : appBinary;
|
|
const args = launchedViaXvfb ? ["-a", appBinary] : [];
|
|
const child = spawn(command, args, {
|
|
cwd: repoRoot,
|
|
detached: process.platform !== "win32",
|
|
env: {
|
|
...process.env,
|
|
KANDEV_DESKTOP_RUNTIME_DIR: runtimeDir,
|
|
WEBKIT_DISABLE_COMPOSITING_MODE: "1",
|
|
NO_AT_BRIDGE: "1",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout?.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr?.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
|
|
const failIfExited = () => {
|
|
if (child.exitCode !== null) {
|
|
throw new Error(`desktop app exited early with code ${child.exitCode}\n${stdout}\n${stderr}`);
|
|
}
|
|
};
|
|
const describeChild = () => `[stdout]\n${stdout}\n[stderr]\n${stderr}`;
|
|
|
|
try {
|
|
await waitForFile(
|
|
join(stateDir, "health-requested"),
|
|
HEALTH_REQUESTED_TIMEOUT_MS,
|
|
failIfExited,
|
|
describeChild,
|
|
);
|
|
await waitForFile(
|
|
join(stateDir, "ready-requested"),
|
|
READY_REQUESTED_TIMEOUT_MS,
|
|
failIfExited,
|
|
describeChild,
|
|
);
|
|
await waitForFile(
|
|
join(stateDir, "root-requested"),
|
|
ROOT_REQUESTED_TIMEOUT_MS,
|
|
failIfExited,
|
|
describeChild,
|
|
);
|
|
} finally {
|
|
await stopProcess(child);
|
|
}
|
|
|
|
console.log(
|
|
"Desktop smoke passed: WebView requested / after backend health and readiness succeeded.",
|
|
);
|
|
}
|
|
|
|
async function writeFakeRuntime(runtimeDir, stateDir) {
|
|
const fakeRuntime = join(runtimeDir, "bin", process.platform === "win32" ? "kandev.cmd" : "kandev");
|
|
const agentctl = join(runtimeDir, "bin", process.platform === "win32" ? "agentctl.cmd" : "agentctl");
|
|
const remoteHelpers = [
|
|
["agentctl-linux-amd64", "linux/amd64"],
|
|
["agentctl-linux-arm64", "linux/arm64"],
|
|
["agentctl-darwin-arm64", "darwin/arm64"],
|
|
["agentctl-darwin-amd64", "darwin/amd64"],
|
|
];
|
|
|
|
if (process.platform === "win32") {
|
|
await writeFile(
|
|
fakeRuntime,
|
|
`@echo off\r\nnode "${fileURLToPath(import.meta.url)}" --fake-runtime "${stateDir}" %*\r\n`,
|
|
);
|
|
await writeFile(agentctl, "@echo off\r\necho fake agentctl\r\n");
|
|
} else {
|
|
await writeFile(
|
|
fakeRuntime,
|
|
`#!/usr/bin/env bash\nexec node "${fileURLToPath(import.meta.url)}" --fake-runtime "${stateDir}" "$@"\n`,
|
|
);
|
|
await writeFile(agentctl, "#!/usr/bin/env bash\necho fake agentctl\n");
|
|
await chmod(fakeRuntime, 0o755);
|
|
await chmod(agentctl, 0o755);
|
|
}
|
|
|
|
for (const [name, platform] of remoteHelpers) {
|
|
const helper = join(runtimeDir, "bin", name);
|
|
await writeFile(helper, `#!/usr/bin/env bash\necho fake agentctl ${platform} helper\n`);
|
|
if (process.platform !== "win32") {
|
|
await chmod(helper, 0o755);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runFakeRuntime(stateDir, args) {
|
|
const portIndex = args.indexOf("--port");
|
|
const port = portIndex >= 0 ? Number(args[portIndex + 1]) : 0;
|
|
const isHeadless = args.includes("--headless");
|
|
|
|
if (!isHeadless || !Number.isInteger(port) || port <= 0) {
|
|
await writeFile(join(stateDir, "invalid-args"), JSON.stringify(args));
|
|
process.exit(2);
|
|
}
|
|
|
|
await writeFile(join(stateDir, "launched"), JSON.stringify({ args, port }));
|
|
|
|
const server = createServer(async (req, res) => {
|
|
if (req.url === "/health") {
|
|
await writeFile(join(stateDir, "health-requested"), "1");
|
|
const headers = { "content-type": "application/json" };
|
|
if (process.env.KANDEV_DESKTOP_HEALTH_TOKEN) {
|
|
headers["x-kandev-desktop-health-token"] = process.env.KANDEV_DESKTOP_HEALTH_TOKEN;
|
|
}
|
|
res.writeHead(200, headers);
|
|
res.end('{"status":"ok"}');
|
|
return;
|
|
}
|
|
|
|
if (req.url === "/ready") {
|
|
await writeFile(join(stateDir, "ready-requested"), "1");
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end('{"status":"ok"}');
|
|
return;
|
|
}
|
|
|
|
if (req.url === "/") {
|
|
await writeFile(join(stateDir, "root-requested"), "1");
|
|
res.writeHead(200, { "content-type": "text/html" });
|
|
res.end("<!doctype html><title>Kandev</title><main>Kandev desktop smoke</main>");
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404);
|
|
res.end("not found");
|
|
});
|
|
|
|
await new Promise((resolveListen) => server.listen(port, "127.0.0.1", resolveListen));
|
|
|
|
const stop = async () => {
|
|
await writeFile(join(stateDir, "terminated"), "1");
|
|
server.close(() => process.exit(0));
|
|
};
|
|
process.on("SIGTERM", stop);
|
|
process.on("SIGINT", stop);
|
|
}
|
|
|
|
export async function waitForFile(path, timeoutMs, tick, describeDetail) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
tick?.();
|
|
if (existsSync(path)) {
|
|
return;
|
|
}
|
|
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
}
|
|
const detail = describeDetail?.();
|
|
throw new Error(`Timed out waiting for ${path}${detail ? `\n\n${detail}` : ""}`);
|
|
}
|
|
|
|
async function stopProcess(child) {
|
|
if (child.exitCode !== null) {
|
|
return;
|
|
}
|
|
if (process.platform === "win32") {
|
|
child.kill();
|
|
} else {
|
|
process.kill(-child.pid, "SIGTERM");
|
|
}
|
|
await Promise.race([
|
|
new Promise((resolveExit) => child.once("exit", resolveExit)),
|
|
new Promise((resolveTimeout) => setTimeout(resolveTimeout, 5_000)),
|
|
]);
|
|
if (child.exitCode === null) {
|
|
if (process.platform === "win32") {
|
|
child.kill("SIGKILL");
|
|
} else {
|
|
process.kill(-child.pid, "SIGKILL");
|
|
}
|
|
}
|
|
}
|
|
|
|
function commandExists(command) {
|
|
const path = process.env.PATH ?? "";
|
|
return path
|
|
.split(delimiter)
|
|
.filter(Boolean)
|
|
.some((entry) => existsSync(join(entry, command)));
|
|
}
|