Files
openclaw/test/scripts/release-user-journey-assertions.test.ts
2026-05-31 06:51:34 +01:00

363 lines
12 KiB
TypeScript

import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer, type AddressInfo, type Socket } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { describe, expect, it } from "vitest";
import { runReleaseUserJourneyAssertion } from "../../scripts/e2e/lib/release-user-journey/assertions.mjs";
const ASSERTIONS_SCRIPT = "scripts/e2e/lib/release-user-journey/assertions.mjs";
function writeJson(filePath: string, value: unknown) {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function runAssertion(
home: string,
args: string[],
options: { env?: Record<string, string>; timeoutMs?: number } = {},
) {
return spawnSync(process.execPath, [ASSERTIONS_SCRIPT, ...args], {
encoding: "utf8",
env: {
...process.env,
HOME: home,
...options.env,
},
killSignal: "SIGKILL",
timeout: options.timeoutMs,
});
}
async function withEnv<T>(env: Record<string, string>, callback: () => Promise<T>): Promise<T> {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(env)) {
previous.set(key, process.env[key]);
process.env[key] = value;
}
try {
return await callback();
} finally {
for (const [key, value] of previous) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
async function waitForFile(filePath: string, timeoutMs = 3000): Promise<string> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (existsSync(filePath)) {
return readFileSync(filePath, "utf8");
}
await delay(25);
}
throw new Error(`timed out waiting for ${filePath}`);
}
async function stopChild(child: ChildProcessWithoutNullStreams): Promise<void> {
if (child.exitCode !== null) {
return;
}
child.kill("SIGTERM");
const startedAt = Date.now();
while (child.exitCode === null && Date.now() - startedAt < 1000) {
await delay(25);
}
if (child.exitCode === null) {
child.kill("SIGKILL");
}
}
function startTcpFixture(portPath: string, connectionHandlerSource: string) {
return spawn(
process.execPath,
[
"--input-type=module",
"--eval",
[
'import net from "node:net";',
'import fs from "node:fs";',
`const server = net.createServer(${connectionHandlerSource});`,
'server.listen(0, "127.0.0.1", () => {',
" const address = server.address();",
" fs.writeFileSync(process.env.PORT_FILE, String(address.port));",
"});",
"setInterval(() => {}, 1000);",
].join("\n"),
],
{
env: { ...process.env, PORT_FILE: portPath },
stdio: "pipe",
},
);
}
async function startTcpFixtureServer(handler: (socket: Socket) => void): Promise<{
port: number;
stop: () => Promise<void>;
}> {
const sockets = new Set<Socket>();
const server = createServer(handler);
server.on("connection", (socket) => {
sockets.add(socket);
socket.on("error", () => undefined);
socket.on("close", () => sockets.delete(socket));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
return {
port: address.port,
stop: async () => {
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
},
};
}
describe("release user journey assertions", () => {
it("fails when uninstall leaves the managed plugin directory behind", () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-release-user-assertions-"));
const home = path.join(root, "home");
const pluginId = "journey-plugin-a";
const installPath = path.join(home, ".openclaw", "extensions", pluginId);
const installPathFile = path.join(root, "install-path.txt");
try {
writeJson(path.join(home, ".openclaw", "openclaw.json"), {
plugins: {
entries: {},
allow: [],
deny: [],
},
});
writeJson(path.join(home, ".openclaw", "plugins", "installs.json"), {
installRecords: {},
});
mkdirSync(installPath, { recursive: true });
writeFileSync(installPathFile, installPath, "utf8");
const result = runAssertion(home, ["assert-plugin-uninstalled", pluginId, installPathFile]);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("managed plugin directory still present");
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("passes after uninstall clears config, records, and managed files", () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-release-user-assertions-"));
const home = path.join(root, "home");
const installPathFile = path.join(root, "install-path.txt");
try {
writeJson(path.join(home, ".openclaw", "openclaw.json"), {
plugins: {
entries: {},
allow: [],
deny: [],
},
});
writeJson(path.join(home, ".openclaw", "plugins", "installs.json"), {
installRecords: {},
});
writeFileSync(
installPathFile,
path.join(home, ".openclaw", "extensions", "journey-plugin-a"),
"utf8",
);
const result = runAssertion(home, [
"assert-plugin-uninstalled",
"journey-plugin-a",
installPathFile,
]);
expect(result.status).toBe(0);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("remembers the installed plugin path from the install record", () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-release-user-assertions-"));
const home = path.join(root, "home");
const pluginId = "journey-plugin-a";
const sourcePath = path.join(root, "source", pluginId);
const installPath = path.join(home, ".openclaw", "extensions", pluginId);
const installPathFile = path.join(root, "install-path.txt");
const sourcePathFile = path.join(root, "source-path.txt");
try {
mkdirSync(sourcePath, { recursive: true });
mkdirSync(installPath, { recursive: true });
writeJson(path.join(home, ".openclaw", "plugins", "installs.json"), {
installRecords: {
[pluginId]: {
source: "path",
sourcePath,
installPath,
},
},
});
const result = runAssertion(home, [
"remember-plugin-install-path",
pluginId,
installPathFile,
sourcePathFile,
sourcePath,
]);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("accepts ready ClickClack fixture state", async () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-release-user-assertions-"));
const home = path.join(root, "home");
const server = await startTcpFixtureServer((socket) => {
const body = JSON.stringify({ socketCount: 1 });
socket.end(
`HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`,
);
});
try {
await expect(
withEnv({ HOME: home, OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS: "1000" }, () =>
runReleaseUserJourneyAssertion("wait-clickclack-socket", [
`http://127.0.0.1:${server.port}`,
"1",
]),
),
).resolves.toBeUndefined();
} finally {
await server.stop();
rmSync(root, { force: true, recursive: true });
}
});
it("bounds stalled ClickClack fixture HTTP probes", async () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-release-user-assertions-"));
const home = path.join(root, "home");
const server = await startTcpFixtureServer((socket) =>
socket.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n"),
);
try {
const startedAt = Date.now();
await expect(
withEnv({ HOME: home, OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS: "100" }, () =>
runReleaseUserJourneyAssertion("wait-clickclack-socket", [
`http://127.0.0.1:${server.port}`,
"0.2",
]),
),
).rejects.toThrow("Timed out waiting for ClickClack websocket connection");
expect(Date.now() - startedAt).toBeLessThan(2500);
} finally {
await server.stop();
rmSync(root, { force: true, recursive: true });
}
});
it("rejects loose HTTP timeout env values instead of parsing prefixes", async () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-release-user-assertions-"));
const home = path.join(root, "home");
const server = await startTcpFixtureServer((socket) =>
socket.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n"),
);
try {
await expect(
withEnv({ HOME: home, OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS: "100ms" }, () =>
runReleaseUserJourneyAssertion("wait-clickclack-socket", [
`http://127.0.0.1:${server.port}`,
"0.2",
]),
),
).rejects.toThrow("Timed out waiting for ClickClack websocket connection");
} finally {
await server.stop();
rmSync(root, { force: true, recursive: true });
}
});
it("bounds ClickClack fixture error response bodies", async () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-release-user-assertions-"));
const home = path.join(root, "home");
const server = await startTcpFixtureServer((socket) => {
const body = "x".repeat(128);
socket.end(
`HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`,
);
});
try {
await expect(
withEnv(
{
HOME: home,
OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES: "16",
OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS: "1000",
},
() =>
runReleaseUserJourneyAssertion("post-clickclack-inbound", [
`http://127.0.0.1:${server.port}`,
"hello",
]),
),
).rejects.toThrow("ClickClack inbound response body exceeded 16 bytes");
} finally {
await server.stop();
rmSync(root, { force: true, recursive: true });
}
});
it("rejects loose body byte env values instead of parsing prefixes", async () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-release-user-assertions-"));
const home = path.join(root, "home");
const server = await startTcpFixtureServer((socket) => {
const body = "x".repeat(128);
socket.end(
`HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`,
);
});
try {
await expect(
withEnv(
{
HOME: home,
OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES: "16bytes",
OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS: "1000",
},
() =>
runReleaseUserJourneyAssertion("post-clickclack-inbound", [
`http://127.0.0.1:${server.port}`,
"hello",
]),
),
).rejects.toThrow("fixture inbound failed: 500");
} finally {
await server.stop();
rmSync(root, { force: true, recursive: true });
}
});
});