Files
openwork/evals/specs/headless-world-lifecycle.e2e.test.ts

219 lines
9.0 KiB
TypeScript

import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { eventually, test } from "@openwork/testkit";
import {
readHeadlessRuntimeManifest,
resolveHeadlessWorldRuntimePaths,
stopHeadlessRuntime,
} from "@openwork/world";
import { expect, onTestFinished } from "vitest";
import { bootDevHeadless } from "../../worlds/dev-headless.ts";
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
test("a world owns and supervises the real headless web and backend lifecycle", { timeout: 180_000 }, async ({ evidence }) => {
const firstName = `headless-world-e2e-${process.pid}`;
const supervisedName = `headless-supervisor-e2e-${process.pid}`;
const names = [firstName, supervisedName];
const cleanup = async (): Promise<void> => {
for (const name of names) {
const paths = resolveHeadlessWorldRuntimePaths(REPO_ROOT, name);
const manifest = await readHeadlessRuntimeManifest(paths.runtimeManifestPath);
if (manifest) await stopHeadlessRuntime(manifest);
}
};
onTestFinished(cleanup);
await cleanup();
await using stack = new AsyncDisposableStack();
const first = await bootDevHeadless(stack, { name: firstName, replace: true });
const firstPaths = resolveHeadlessWorldRuntimePaths(REPO_ROOT, firstName);
const firstManifest = first.manifest;
expect(first.reused).toBe(false);
expect(firstManifest.world?.name).toBe(firstName);
expect(firstManifest.world?.launchId).toMatch(/^[0-9a-f-]{36}$/i);
expect(firstManifest.runtimeManifestPath).toBe(firstPaths.runtimeManifestPath);
expect(firstManifest.supervisorPid).toBeGreaterThan(0);
const health = await fetch(firstManifest.healthUrl);
const web = await fetch(firstManifest.webUrl);
expect(health.ok).toBe(true);
expect(web.ok).toBe(true);
const reused = await bootDevHeadless(stack, { name: firstName });
expect(reused.reused).toBe(true);
expect(reused.manifest.world?.launchId).toBe(firstManifest.world?.launchId);
expect(reused.manifest.pids).toEqual(firstManifest.pids);
expect(reused.manifest.token).toBe(firstManifest.token);
expect(reused.manifest.hostToken).toBe(firstManifest.hostToken);
const keptTokens = await bootDevHeadless(stack, {
name: firstName,
replace: true,
keepTokens: true,
});
expect(keptTokens.reused).toBe(false);
expect(keptTokens.manifest.world?.launchId).not.toBe(firstManifest.world?.launchId);
expect(keptTokens.manifest.token).toBe(firstManifest.token);
expect(keptTokens.manifest.hostToken).toBe(firstManifest.hostToken);
const replaced = await bootDevHeadless(stack, {
name: firstName,
replace: true,
keepTokens: true,
rotateTokens: true,
});
expect(replaced.reused).toBe(false);
expect(replaced.manifest.world?.launchId).not.toBe(keptTokens.manifest.world?.launchId);
expect(replaced.manifest.token).not.toBe(keptTokens.manifest.token);
expect(replaced.manifest.hostToken).not.toBe(keptTokens.manifest.hostToken);
expect(await readHeadlessRuntimeManifest(firstPaths.runtimeManifestPath)).toEqual(replaced.manifest);
await stack.disposeAsync();
await eventually(
async () => await readHeadlessRuntimeManifest(firstPaths.runtimeManifestPath) === null,
{ within: 10_000, intervalMs: 100, label: "owned runtime manifest removed after teardown" },
);
await expect(fetch(replaced.manifest.healthUrl)).rejects.toThrow();
await expect(fetch(replaced.manifest.webUrl)).rejects.toThrow();
await using supervisedStack = new AsyncDisposableStack();
const supervised = await bootDevHeadless(supervisedStack, { name: supervisedName, replace: true });
const supervisedPaths = resolveHeadlessWorldRuntimePaths(REPO_ROOT, supervisedName);
const supervisedManifest = supervised.manifest;
const webPid = supervisedManifest.pids.web;
if (!webPid) {
throw new Error("The supervised headless world did not publish its owned web process.");
}
expect(webPid).toBeGreaterThan(0);
process.kill(webPid, "SIGTERM");
await eventually(async () => {
const manifestGone = await readHeadlessRuntimeManifest(supervisedPaths.runtimeManifestPath) === null;
const serverStopped = await fetch(supervisedManifest.healthUrl)
.then(() => false)
.catch(() => true);
return manifestGone && serverStopped;
}, {
within: 15_000,
intervalMs: 200,
label: "detached supervisor stopped the backend after the web process exited",
});
await supervisedStack.disposeAsync();
evidence.recordAssertionEvidence(
"The direct world builder owns the real headless surface lifecycle",
"A named direct-script builder launched healthy Vite and openwork-server processes, reused them without rotating credentials, replaced them with requested token behavior, and removed its runtime manifest and listeners on disposal.",
true,
);
evidence.recordAssertionEvidence(
"Detached headless siblings remain supervised",
"After the spec terminated the owned Vite process, the detached supervisor stopped openwork-server and removed the owned runtime manifest within the bound.",
true,
);
});
test("the dev:headless-web alias remains foreground and Ctrl-C owns teardown", { timeout: 180_000 }, async ({ evidence }) => {
const paths = resolveHeadlessWorldRuntimePaths(REPO_ROOT, "dev-headless");
const existing = await readHeadlessRuntimeManifest(paths.runtimeManifestPath);
if (existing) await stopHeadlessRuntime(existing);
const output: string[] = [];
const adjacentCorepack = join(
dirname(process.execPath),
process.platform === "win32" ? "corepack.cmd" : "corepack",
);
const npmExecPath = process.env.npm_execpath?.trim();
const hasAdjacentCorepack = await access(adjacentCorepack).then(() => true, () => false);
const launcherCommand = npmExecPath
? process.execPath
: hasAdjacentCorepack
? adjacentCorepack
: process.platform === "win32" ? "pnpm.cmd" : "pnpm";
const launcherArgs = npmExecPath
? [npmExecPath, "dev:headless-web", "--replace"]
: hasAdjacentCorepack
? ["pnpm", "dev:headless-web", "--replace"]
: ["dev:headless-web", "--replace"];
const launcher = spawn(launcherCommand, launcherArgs, {
cwd: REPO_ROOT,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
launcher.stdout.on("data", (chunk) => output.push(String(chunk)));
launcher.stderr.on("data", (chunk) => output.push(String(chunk)));
let launcherError: Error | null = null;
launcher.once("error", (error) => { launcherError = error; });
onTestFinished(async () => {
const manifest = await readHeadlessRuntimeManifest(paths.runtimeManifestPath);
if (manifest) await stopHeadlessRuntime(manifest);
if (launcher.exitCode === null) launcher.kill("SIGTERM");
});
const manifest = await eventually(async () => {
if (launcherError) throw launcherError;
const candidate = await readHeadlessRuntimeManifest(paths.runtimeManifestPath);
if (launcher.exitCode !== null) {
throw new Error(`dev:headless-web exited before health with ${launcher.exitCode}:\n${output.join("")}`);
}
if (!candidate) return null;
return await fetch(candidate.healthUrl).then((response) => response.ok).catch(() => false)
? candidate
: null;
}, {
within: 60_000,
intervalMs: 200,
label: `foreground compatibility launch\n${output.join("")}`,
});
if (!manifest) throw new Error("dev:headless-web did not publish a healthy runtime manifest.");
expect(launcher.exitCode).toBeNull();
await eventually(
() => output.join("").includes("Ctrl-C (or pnpm world down dev-headless) tears it down."),
{
within: 15_000,
intervalMs: 100,
label: `foreground compatibility readiness\n${output.join("")}`,
},
);
process.kill(manifest.pids.launcher, "SIGINT");
await eventually(
async () => await readHeadlessRuntimeManifest(paths.runtimeManifestPath) === null,
{
within: 15_000,
intervalMs: 100,
label: `foreground compatibility manifest cleanup\n${output.join("")}`,
},
);
await eventually(
() => fetch(manifest.healthUrl).then(() => false).catch(() => true),
{
within: 15_000,
intervalMs: 100,
label: `foreground compatibility server teardown\n${output.join("")}`,
},
);
await eventually(
() => launcher.exitCode === null ? false : { exitCode: launcher.exitCode },
{
within: 15_000,
intervalMs: 100,
label: `foreground compatibility launcher exit\n${output.join("")}`,
},
);
expect(launcher.exitCode).toBe(0);
evidence.recordAssertionEvidence(
"The legacy headless command remains foreground by default",
"The real pnpm dev:headless-web command remained alive after both services reached health, then exited cleanly only after SIGINT reached its launcher.",
true,
);
evidence.recordAssertionEvidence(
"Foreground Ctrl-C tears down both owned services",
"After SIGINT, the compatibility command removed its owner manifest and its openwork-server health endpoint stopped responding within the bound.",
true,
);
});