mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
ee86b24ac1
* fix(node-host): recover MCP catalogs and sessions Share placement-neutral MCP lifecycle and result projection while keeping Gateway session ownership and node process ownership separate. Refresh node catalogs live, recover closed or expired transports without replay, and preserve MCP application errors across node.invoke.\n\nFixes #125044 * test: register node MCP CI inventory * fix(node-host): stop MCP recovery after abort
673 lines
24 KiB
TypeScript
673 lines
24 KiB
TypeScript
// Test Live Shard tests cover test live shard script behavior.
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
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 {
|
|
LIVE_TEST_SHARDS,
|
|
RELEASE_LIVE_TEST_SHARDS,
|
|
addLiveShardReportArgs,
|
|
buildLiveShardPnpmArgs,
|
|
buildLiveShardReportPath,
|
|
buildLiveShardSpawnParams,
|
|
collectAllLiveTestFiles,
|
|
parseLiveShardArgs,
|
|
removeLiveShardReportFile,
|
|
resolveLiveShardPreparation,
|
|
selectLiveShardFiles,
|
|
validateLiveShardReportPayload,
|
|
} from "../../scripts/test-live-shard.mts";
|
|
import { expectNoReaddirSyncDuring } from "../../src/test-utils/fs-scan-assertions.js";
|
|
import { waitForPidFile } from "../helpers/process-wait.js";
|
|
|
|
describe("scripts/test-live-shard", () => {
|
|
const allFiles = collectAllLiveTestFiles();
|
|
|
|
it("discovers live tests without scanning source roots in-process", () => {
|
|
expectNoReaddirSyncDuring(() => {
|
|
const files = collectAllLiveTestFiles();
|
|
|
|
expect(files.length).toBeGreaterThan(0);
|
|
expect(files.every((file) => file.endsWith(".live.test.ts"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("covers every native live test and tracks provider-filtered release fanout", () => {
|
|
const selected = RELEASE_LIVE_TEST_SHARDS.flatMap((shard) =>
|
|
selectLiveShardFiles(shard, allFiles).map((file) => ({ file, shard })),
|
|
);
|
|
const selectedFiles = selected.map(({ file }) => file);
|
|
const duplicateFiles = selectedFiles.filter(
|
|
(file, index) => selectedFiles.indexOf(file) !== index,
|
|
);
|
|
const musicProviderFanout = selected
|
|
.filter(({ file }) => file === "extensions/music-generation-providers.live.test.ts")
|
|
.map(({ shard }) => shard)
|
|
.toSorted();
|
|
|
|
expect(allFiles.length).toBeGreaterThan(0);
|
|
expect([...new Set(selectedFiles)].toSorted((a, b) => a.localeCompare(b))).toEqual(allFiles);
|
|
expect(duplicateFiles).toEqual([
|
|
"src/agents/zai.live.test.ts",
|
|
"extensions/music-generation-providers.live.test.ts",
|
|
]);
|
|
expect(musicProviderFanout).toEqual([
|
|
"native-live-extensions-media-music-google",
|
|
"native-live-extensions-media-music-minimax",
|
|
]);
|
|
});
|
|
|
|
it("keeps aggregate shard aliases available outside the release partition", () => {
|
|
expect(LIVE_TEST_SHARDS).toEqual([
|
|
...RELEASE_LIVE_TEST_SHARDS,
|
|
"native-live-extensions-o-z",
|
|
"native-live-extensions-media",
|
|
"native-live-extensions-media-music",
|
|
]);
|
|
|
|
const oToZAlias = selectLiveShardFiles("native-live-extensions-o-z", allFiles);
|
|
expect(oToZAlias).toEqual(
|
|
[
|
|
...selectLiveShardFiles("native-live-extensions-o-z-other", allFiles),
|
|
...selectLiveShardFiles("native-live-extensions-xai", allFiles),
|
|
].toSorted((a, b) => a.localeCompare(b)),
|
|
);
|
|
|
|
const mediaAlias = selectLiveShardFiles("native-live-extensions-media", allFiles);
|
|
expect(mediaAlias).toEqual(
|
|
[
|
|
...selectLiveShardFiles("native-live-extensions-media-audio", allFiles),
|
|
...selectLiveShardFiles("native-live-extensions-media-music", allFiles),
|
|
...selectLiveShardFiles("native-live-extensions-media-video", allFiles),
|
|
].toSorted((a, b) => a.localeCompare(b)),
|
|
);
|
|
});
|
|
|
|
it("keeps slow gateway backend and media-capable extension files in their own shards", () => {
|
|
expect(selectLiveShardFiles("native-live-src-agents", allFiles)).toContain(
|
|
"src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.live.test.ts",
|
|
);
|
|
expect(selectLiveShardFiles("native-live-src-agents", allFiles)).toContain(
|
|
"src/skills/workshop/experience-review.live.test.ts",
|
|
);
|
|
expect(selectLiveShardFiles("native-live-src-agents", allFiles)).toContain(
|
|
"src/agents/zai.live.test.ts",
|
|
);
|
|
expect(selectLiveShardFiles("native-live-src-agents-zai-coding", allFiles)).toEqual([
|
|
"src/agents/zai.live.test.ts",
|
|
]);
|
|
expect(selectLiveShardFiles("native-live-src-gateway-backends", allFiles)).toEqual([
|
|
"src/gateway/gateway-acp-bind.live.test.ts",
|
|
"src/gateway/gateway-cli-backend.live.test.ts",
|
|
"src/gateway/gateway-codex-bind.live.test.ts",
|
|
"src/gateway/gateway-codex-harness.live.test.ts",
|
|
]);
|
|
expect(selectLiveShardFiles("native-live-src-gateway-profiles", allFiles)).toEqual([
|
|
"src/gateway/gateway-models.profiles.live.test.ts",
|
|
"src/gateway/gateway-openai-long-context.live.test.ts",
|
|
]);
|
|
expect(selectLiveShardFiles("native-live-src-gateway-core", allFiles)).toEqual([
|
|
"src/gateway/android-node.capabilities.live.test.ts",
|
|
"src/gateway/gateway-acp-spawn-defaults.live.test.ts",
|
|
"src/gateway/gateway-trajectory-export.live.test.ts",
|
|
"src/system-agent/rescue-channel.live.test.ts",
|
|
"src/system-agent/setup-app-recommendations.live.test.ts",
|
|
]);
|
|
expect(selectLiveShardFiles("native-live-src-infra", allFiles)).toEqual([
|
|
"src/infra/push-apns-http2.live.test.ts",
|
|
]);
|
|
expect(selectLiveShardFiles("native-live-test", allFiles)).toEqual([
|
|
"test/e2e/qa-lab/runtime/gateway-node-mcp.live.test.ts",
|
|
"test/image-generation.infer-cli.live.test.ts",
|
|
"test/image-generation.runtime.live.test.ts",
|
|
"test/openai-onboarding.live.test.ts",
|
|
]);
|
|
expect(selectLiveShardFiles("native-live-extensions-media", allFiles)).toEqual([
|
|
"extensions/minimax/minimax.live.test.ts",
|
|
"extensions/music-generation-providers.live.test.ts",
|
|
"extensions/openai/openai-tts.live.test.ts",
|
|
"extensions/video-generation-providers.live.test.ts",
|
|
"extensions/volcengine/tts.live.test.ts",
|
|
"extensions/vydra/vydra.live.test.ts",
|
|
]);
|
|
expect(selectLiveShardFiles("native-live-extensions-openai", allFiles)).toEqual([
|
|
"extensions/openai/openai-provider.live.test.ts",
|
|
"extensions/openai/openai.live.test.ts",
|
|
"extensions/openai/realtime-quicksilver-gateway-bridge.live.test.ts",
|
|
"extensions/openai/realtime-quicksilver.live.test.ts",
|
|
"extensions/openai/realtime-voice-provider.live.test.ts",
|
|
]);
|
|
expect(selectLiveShardFiles("native-live-extensions-l-n", allFiles)).toEqual([
|
|
"extensions/memory-lancedb/memory-lancedb.live.test.ts",
|
|
"extensions/meta/meta.live.test.ts",
|
|
"extensions/microsoft/microsoft.live.test.ts",
|
|
"extensions/mistral/mistral.live.test.ts",
|
|
]);
|
|
expect(selectLiveShardFiles("native-live-extensions-moonshot", allFiles)).toEqual([
|
|
"extensions/moonshot/moonshot.live.test.ts",
|
|
]);
|
|
});
|
|
|
|
it("keeps the Codex CLI backend live smoke on a minimal tool profile", () => {
|
|
const source = readFileSync("src/gateway/gateway-cli-backend.live.test.ts", "utf8");
|
|
|
|
expect(source).toContain('providerId === "codex-cli" && !schemaProbePluginPath');
|
|
expect(source).toContain('profile: "minimal" as const');
|
|
});
|
|
|
|
it("rejects unknown shard names", () => {
|
|
expect(() => selectLiveShardFiles("native-live-missing")).toThrow(/Unknown live test shard/u);
|
|
expect(() => selectLiveShardFiles("native-live-extensions-l-z")).toThrow(
|
|
/Unknown live test shard/u,
|
|
);
|
|
});
|
|
|
|
it("parses list mode and rejects unknown live shard options", () => {
|
|
expect(parseLiveShardArgs(["native-live-src-agents", "--list"])).toEqual({
|
|
shard: "native-live-src-agents",
|
|
listOnly: true,
|
|
passthroughArgs: [],
|
|
});
|
|
|
|
expect(() => parseLiveShardArgs(["--lisst", "native-live-src-agents"])).toThrow(
|
|
/Unknown option: --lisst/u,
|
|
);
|
|
});
|
|
|
|
it("prints CLI help before validating shard options", () => {
|
|
const result = spawnSync(process.execPath, ["scripts/test-live-shard.mjs", "--help"], {
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe("");
|
|
expect(result.stdout).toContain("Usage: node scripts/test-live-shard.mjs");
|
|
});
|
|
|
|
it("preserves Vitest passthrough args after the live shard separator", () => {
|
|
expect(parseLiveShardArgs(["native-live-test", "--", "-t", "smoke"])).toEqual({
|
|
shard: "native-live-test",
|
|
listOnly: false,
|
|
passthroughArgs: ["-t", "smoke"],
|
|
});
|
|
expect(buildLiveShardPnpmArgs(["test/foo.live.test.ts"], ["-t", "smoke"])).toEqual([
|
|
"test:live",
|
|
"--",
|
|
"test/foo.live.test.ts",
|
|
"-t",
|
|
"smoke",
|
|
]);
|
|
});
|
|
|
|
it("adds JSON report evidence without dropping operator output", () => {
|
|
const reportPath = buildLiveShardReportPath("native-live-src-agents", {
|
|
OPENCLAW_LIVE_SHARD_REPORT_DIR: ".artifacts/live-proof",
|
|
});
|
|
|
|
expect(reportPath).toBe(".artifacts/live-proof/native-live-src-agents.vitest.json");
|
|
expect(addLiveShardReportArgs(["-t", "smoke"], reportPath)).toEqual([
|
|
"-t",
|
|
"smoke",
|
|
"--reporter=default",
|
|
"--reporter=json",
|
|
"--outputFile.json=.artifacts/live-proof/native-live-src-agents.vitest.json",
|
|
]);
|
|
expect(
|
|
buildLiveShardPnpmArgs(
|
|
["src/agents/xai.live.test.ts"],
|
|
addLiveShardReportArgs([], reportPath),
|
|
),
|
|
).toContain("--reporter=json");
|
|
});
|
|
|
|
it("prepares the private QA runtime for live shards that load its built API", () => {
|
|
const expected = {
|
|
env: { OPENCLAW_BUILD_PRIVATE_QA: "1" },
|
|
profile: "qaRuntime",
|
|
requiredArtifact: "dist/extensions/qa-lab/runtime-api.js",
|
|
};
|
|
|
|
expect(
|
|
resolveLiveShardPreparation(
|
|
selectLiveShardFiles("native-live-extensions-o-z-other", allFiles),
|
|
),
|
|
).toEqual(expected);
|
|
expect(
|
|
resolveLiveShardPreparation(selectLiveShardFiles("native-live-extensions-o-z", allFiles)),
|
|
).toEqual(expected);
|
|
expect(
|
|
resolveLiveShardPreparation(selectLiveShardFiles("native-live-extensions-xai", allFiles)),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("fails live shard reports with no passing tests", () => {
|
|
expect(validateLiveShardReportPayload({ numPassedTests: 1, numTotalTests: 3 })).toEqual({
|
|
ok: true,
|
|
});
|
|
expect(validateLiveShardReportPayload({ numPassedTests: 4, numTotalTests: 3 })).toEqual({
|
|
ok: false,
|
|
reason: "Vitest report numPassedTests exceeds numTotalTests.",
|
|
});
|
|
expect(validateLiveShardReportPayload({ numPassedTests: 0, numTotalTests: 3 })).toEqual({
|
|
ok: false,
|
|
reason: "Vitest report has no passing live tests.",
|
|
});
|
|
expect(validateLiveShardReportPayload({ numPassedTests: 0, numTotalTests: 0 })).toEqual({
|
|
ok: false,
|
|
reason: "Vitest report has no passing live tests.",
|
|
});
|
|
expect(validateLiveShardReportPayload({ numPassedTests: 0 })).toEqual({
|
|
ok: false,
|
|
reason: "Vitest report numTotalTests must be a non-negative integer.",
|
|
});
|
|
});
|
|
|
|
it("requires live shard report evidence for each selected file", () => {
|
|
const payload = {
|
|
numPassedTests: 1,
|
|
numTotalTests: 2,
|
|
testResults: [
|
|
{
|
|
name: path.join(process.cwd(), "src/gateway/gateway-acp-bind.live.test.ts"),
|
|
assertionResults: [{ status: "passed" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(
|
|
validateLiveShardReportPayload(payload, ["src/gateway/gateway-acp-bind.live.test.ts"]),
|
|
).toEqual({ ok: true });
|
|
expect(
|
|
validateLiveShardReportPayload(payload, [
|
|
"src/gateway/gateway-acp-bind.live.test.ts",
|
|
"src/gateway/gateway-cli-backend.live.test.ts",
|
|
]),
|
|
).toEqual({
|
|
ok: false,
|
|
reason:
|
|
"Vitest report missing selected live test file evidence: src/gateway/gateway-cli-backend.live.test.ts",
|
|
});
|
|
expect(
|
|
validateLiveShardReportPayload({ numPassedTests: 1, numTotalTests: 1 }, [
|
|
"src/gateway/gateway-acp-bind.live.test.ts",
|
|
]),
|
|
).toEqual({
|
|
ok: false,
|
|
reason: "Vitest report is missing testResults file evidence.",
|
|
});
|
|
});
|
|
|
|
it("requires each selected live shard file to have a passing assertion", () => {
|
|
const payload = {
|
|
numPassedTests: 1,
|
|
numTotalTests: 2,
|
|
testResults: [
|
|
{
|
|
name: path.join(process.cwd(), "src/gateway/gateway-acp-bind.live.test.ts"),
|
|
assertionResults: [{ status: "passed" }],
|
|
},
|
|
{
|
|
name: path.join(process.cwd(), "src/agents/openai-reasoning-compat.live.test.ts"),
|
|
assertionResults: [{ status: "skipped" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(
|
|
validateLiveShardReportPayload(payload, [
|
|
"src/gateway/gateway-acp-bind.live.test.ts",
|
|
"src/agents/openai-reasoning-compat.live.test.ts",
|
|
]),
|
|
).toEqual({
|
|
ok: false,
|
|
reason:
|
|
"Vitest report selected live test files had no passing assertions: src/agents/openai-reasoning-compat.live.test.ts",
|
|
});
|
|
});
|
|
|
|
it("allows explicitly opt-in live shard files to be skipped until their env is enabled", () => {
|
|
const payload = {
|
|
numPassedTests: 1,
|
|
numTotalTests: 2,
|
|
testResults: [
|
|
{
|
|
name: path.join(process.cwd(), "src/gateway/gateway-codex-harness.live.test.ts"),
|
|
assertionResults: [{ status: "passed" }],
|
|
},
|
|
{
|
|
name: path.join(process.cwd(), "src/gateway/gateway-cli-backend.live.test.ts"),
|
|
assertionResults: [{ status: "skipped" }],
|
|
},
|
|
],
|
|
};
|
|
const expectedFiles = [
|
|
"src/gateway/gateway-codex-harness.live.test.ts",
|
|
"src/gateway/gateway-cli-backend.live.test.ts",
|
|
];
|
|
|
|
expect(validateLiveShardReportPayload(payload, expectedFiles, process.cwd(), {})).toEqual({
|
|
ok: true,
|
|
});
|
|
expect(
|
|
validateLiveShardReportPayload(payload, expectedFiles, process.cwd(), {
|
|
OPENCLAW_LIVE_CLI_BACKEND: "1",
|
|
}),
|
|
).toEqual({
|
|
ok: false,
|
|
reason:
|
|
"Vitest report selected live test files had no passing assertions: src/gateway/gateway-cli-backend.live.test.ts",
|
|
});
|
|
});
|
|
|
|
it("allows gateway core opt-in live files to be skipped until their env is enabled", () => {
|
|
const payload = {
|
|
numPassedTests: 1,
|
|
numTotalTests: 2,
|
|
testResults: [
|
|
{
|
|
name: path.join(process.cwd(), "src/gateway/gateway-codex-harness.live.test.ts"),
|
|
assertionResults: [{ status: "passed" }],
|
|
},
|
|
{
|
|
name: path.join(process.cwd(), "src/gateway/gateway-acp-spawn-defaults.live.test.ts"),
|
|
assertionResults: [{ status: "skipped" }],
|
|
},
|
|
],
|
|
};
|
|
const expectedFiles = [
|
|
"src/gateway/gateway-codex-harness.live.test.ts",
|
|
"src/gateway/gateway-acp-spawn-defaults.live.test.ts",
|
|
];
|
|
|
|
expect(validateLiveShardReportPayload(payload, expectedFiles, process.cwd(), {})).toEqual({
|
|
ok: true,
|
|
});
|
|
expect(
|
|
validateLiveShardReportPayload(payload, expectedFiles, process.cwd(), {
|
|
OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS: "1",
|
|
}),
|
|
).toEqual({
|
|
ok: false,
|
|
reason:
|
|
"Vitest report selected live test files had no passing assertions: src/gateway/gateway-acp-spawn-defaults.live.test.ts",
|
|
});
|
|
});
|
|
|
|
it("allows the OpenAI long-context live file to be skipped until its env is enabled", () => {
|
|
const profilesFile = "src/gateway/gateway-models.profiles.live.test.ts";
|
|
const longContextFile = "src/gateway/gateway-openai-long-context.live.test.ts";
|
|
const payload = {
|
|
numPassedTests: 1,
|
|
numTotalTests: 2,
|
|
testResults: [
|
|
{
|
|
name: path.join(process.cwd(), profilesFile),
|
|
assertionResults: [{ status: "passed" }],
|
|
},
|
|
{
|
|
name: path.join(process.cwd(), longContextFile),
|
|
assertionResults: [{ status: "skipped" }],
|
|
},
|
|
],
|
|
};
|
|
const expectedFiles = [profilesFile, longContextFile];
|
|
|
|
expect(validateLiveShardReportPayload(payload, expectedFiles, process.cwd(), {})).toEqual({
|
|
ok: true,
|
|
});
|
|
expect(
|
|
validateLiveShardReportPayload(payload, expectedFiles, process.cwd(), {
|
|
OPENCLAW_LIVE_OPENAI_LONG_CONTEXT: "1",
|
|
}),
|
|
).toEqual({
|
|
ok: false,
|
|
reason: `Vitest report selected live test files had no passing assertions: ${longContextFile}`,
|
|
});
|
|
});
|
|
|
|
it("allows the experience review live file to be skipped until its env is enabled", () => {
|
|
const reviewFile = "src/skills/workshop/experience-review.live.test.ts";
|
|
const payload = {
|
|
numPassedTests: 1,
|
|
numTotalTests: 2,
|
|
testResults: [
|
|
{
|
|
name: path.join(process.cwd(), "src/agents/openai-reasoning-compat.live.test.ts"),
|
|
assertionResults: [{ status: "passed" }],
|
|
},
|
|
{
|
|
name: path.join(process.cwd(), reviewFile),
|
|
assertionResults: [{ status: "skipped" }],
|
|
},
|
|
],
|
|
};
|
|
const expectedFiles = ["src/agents/openai-reasoning-compat.live.test.ts", reviewFile];
|
|
|
|
expect(validateLiveShardReportPayload(payload, expectedFiles, process.cwd(), {})).toEqual({
|
|
ok: true,
|
|
});
|
|
expect(
|
|
validateLiveShardReportPayload(payload, expectedFiles, process.cwd(), {
|
|
OPENCLAW_LIVE_SKILL_EXPERIENCE_REVIEW: "1",
|
|
}),
|
|
).toEqual({
|
|
ok: false,
|
|
reason: `Vitest report selected live test files had no passing assertions: ${reviewFile}`,
|
|
});
|
|
});
|
|
|
|
it("allows GPT-Live files to be skipped until their shared opt-in is enabled", () => {
|
|
const quicksilverFiles = [
|
|
"extensions/openai/realtime-quicksilver-gateway-bridge.live.test.ts",
|
|
"extensions/openai/realtime-quicksilver.live.test.ts",
|
|
];
|
|
const payload = {
|
|
numPassedTests: 1,
|
|
numTotalTests: 3,
|
|
testResults: [
|
|
{
|
|
name: path.join(process.cwd(), "extensions/openai/openai.live.test.ts"),
|
|
assertionResults: [{ status: "passed" }],
|
|
},
|
|
...quicksilverFiles.map((file) => ({
|
|
name: path.join(process.cwd(), file),
|
|
assertionResults: [{ status: "skipped" }],
|
|
})),
|
|
],
|
|
};
|
|
const expectedFiles = ["extensions/openai/openai.live.test.ts", ...quicksilverFiles];
|
|
|
|
expect(validateLiveShardReportPayload(payload, expectedFiles, process.cwd(), {})).toEqual({
|
|
ok: true,
|
|
});
|
|
expect(
|
|
validateLiveShardReportPayload(payload, expectedFiles, process.cwd(), {
|
|
OPENCLAW_LIVE_GPT_LIVE: "1",
|
|
}),
|
|
).toEqual({
|
|
ok: false,
|
|
reason: `Vitest report selected live test files had no passing assertions: ${quicksilverFiles.join(", ")}`,
|
|
});
|
|
});
|
|
|
|
it("does not count disabled opt-in sentinel assertions as live shard proof", () => {
|
|
const payload = {
|
|
numPassedTests: 1,
|
|
numTotalTests: 2,
|
|
testResults: [
|
|
{
|
|
name: path.join(process.cwd(), "src/gateway/gateway-codex-harness.live.test.ts"),
|
|
assertionResults: [
|
|
{
|
|
ancestorTitles: ["gateway live (Codex harness disabled)"],
|
|
status: "passed",
|
|
title: "is opt-in",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: path.join(process.cwd(), "src/gateway/gateway-cli-backend.live.test.ts"),
|
|
assertionResults: [{ status: "skipped" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(
|
|
validateLiveShardReportPayload(
|
|
payload,
|
|
[
|
|
"src/gateway/gateway-codex-harness.live.test.ts",
|
|
"src/gateway/gateway-cli-backend.live.test.ts",
|
|
],
|
|
process.cwd(),
|
|
{},
|
|
),
|
|
).toEqual({
|
|
ok: false,
|
|
reason: "Vitest report has no enabled selected live test files with passing assertions.",
|
|
});
|
|
});
|
|
|
|
it("removes stale live shard reports before running a shard", () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-live-shard-"));
|
|
const reportPath = path.join(root, "stale.vitest.json");
|
|
writeFileSync(reportPath, JSON.stringify({ numPassedTests: 1, numTotalTests: 1 }), "utf8");
|
|
|
|
try {
|
|
removeLiveShardReportFile(reportPath);
|
|
|
|
expect(existsSync(reportPath)).toBe(false);
|
|
expect(() => removeLiveShardReportFile(reportPath)).not.toThrow();
|
|
} finally {
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("spawns live shard children in a cleanup-friendly process group", () => {
|
|
expect(buildLiveShardSpawnParams({ PATH: "/usr/bin" }, "darwin")).toEqual({
|
|
detached: true,
|
|
env: { PATH: "/usr/bin" },
|
|
stdio: "inherit",
|
|
});
|
|
expect(buildLiveShardSpawnParams({ PATH: "/usr/bin" }, "win32")).toEqual({
|
|
detached: false,
|
|
env: { PATH: "/usr/bin" },
|
|
stdio: "inherit",
|
|
});
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"cleans live shard descendants before forwarding parent SIGTERM",
|
|
async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-live-shard-signal-"));
|
|
const fakePnpmPath = path.join(root, "pnpm");
|
|
const childPidPath = path.join(root, "child.pid");
|
|
const descendantPidPath = path.join(root, "descendant.pid");
|
|
const signaledPath = path.join(root, "signaled");
|
|
let childPid = 0;
|
|
let descendantPid = 0;
|
|
let runner: ReturnType<typeof spawn> | undefined;
|
|
|
|
try {
|
|
writeFakePnpm(fakePnpmPath);
|
|
runner = spawn(
|
|
process.execPath,
|
|
["scripts/test-live-shard.mjs", "native-live-src-agents"],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_FAKE_PNPM_DESCENDANT_PID_PATH: descendantPidPath,
|
|
OPENCLAW_FAKE_PNPM_PID_PATH: childPidPath,
|
|
OPENCLAW_FAKE_PNPM_SIGNALED_PATH: signaledPath,
|
|
npm_execpath: fakePnpmPath,
|
|
},
|
|
stdio: "ignore",
|
|
},
|
|
);
|
|
|
|
childPid = await waitForPidFile(childPidPath, 5_000);
|
|
descendantPid = await waitForPidFile(descendantPidPath, 5_000);
|
|
|
|
runner.kill("SIGTERM");
|
|
|
|
await expect(waitForClose(runner)).resolves.toEqual({ code: null, signal: "SIGTERM" });
|
|
await waitFor(() => existsSync(signaledPath), 5_000);
|
|
expect(readFileSync(signaledPath, "utf8")).toBe("SIGTERM");
|
|
await waitFor(() => !isProcessAlive(childPid), 5_000);
|
|
await waitFor(() => !isProcessAlive(descendantPid), 5_000);
|
|
} finally {
|
|
if (runner?.pid && isProcessAlive(runner.pid)) {
|
|
process.kill(runner.pid, "SIGKILL");
|
|
}
|
|
if (childPid && isProcessAlive(childPid)) {
|
|
process.kill(childPid, "SIGKILL");
|
|
}
|
|
if (descendantPid && isProcessAlive(descendantPid)) {
|
|
process.kill(descendantPid, "SIGKILL");
|
|
}
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
function writeFakePnpm(filePath: string): void {
|
|
writeFileSync(
|
|
filePath,
|
|
[
|
|
"#!/usr/bin/env node",
|
|
'const { spawn } = require("node:child_process");',
|
|
'const fs = require("node:fs");',
|
|
"const child = spawn(process.execPath, [",
|
|
' "-e",',
|
|
" \"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);\",",
|
|
"], { stdio: 'ignore' });",
|
|
'process.on("SIGTERM", () => {',
|
|
' fs.writeFileSync(process.env.OPENCLAW_FAKE_PNPM_SIGNALED_PATH, "SIGTERM");',
|
|
" process.exit(0);",
|
|
"});",
|
|
"fs.writeFileSync(process.env.OPENCLAW_FAKE_PNPM_DESCENDANT_PID_PATH, String(child.pid));",
|
|
"fs.writeFileSync(process.env.OPENCLAW_FAKE_PNPM_PID_PATH, String(process.pid));",
|
|
"setInterval(() => {}, 1000);",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
chmodSync(filePath, 0o755);
|
|
}
|
|
|
|
async function waitFor(condition: () => boolean, timeoutMs: number): Promise<void> {
|
|
const startedAt = Date.now();
|
|
while (!condition()) {
|
|
if (Date.now() - startedAt > timeoutMs) {
|
|
throw new Error("timed out waiting for condition");
|
|
}
|
|
await delay(5);
|
|
}
|
|
}
|
|
|
|
async function waitForClose(
|
|
child: ReturnType<typeof spawn>,
|
|
timeoutMs = 5_000,
|
|
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
|
|
return await Promise.race([
|
|
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
|
child.once("close", (code, signal) => resolve({ code, signal }));
|
|
}),
|
|
delay(timeoutMs, undefined, { ref: false }).then(() => {
|
|
throw new Error("timed out waiting for child close");
|
|
}),
|
|
]);
|
|
}
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|