mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-29 17:19:00 +08:00
* fix: recover managed Gateways after updates and repairs * refactor(update): simplify service inspection diagnostic * fix(test): route Doctor live proofs into the infra shard Include src/commands live tests alongside the CLI update proofs in the existing native-live-src-infra release shard. Extend the routing fixture while preserving full-inventory coverage and provider-filtered fanout assertions. This is required live-shard inventory maintenance, not a baseline edit to silence a check. Validation: reproduced the missing Doctor file before the fix; test-live-shard passed 36 tests on each of three local runs (1.03s, 1.06s, 0.89s test time). Full check-changed and 551 package-acceptance-workflow tests passed on Testbox tbx_01m3jmz9qh20mefc5tw4yhmvgr (run 36360539165). Fresh P1 autoreview was clean.
765 lines
27 KiB
TypeScript
765 lines
27 KiB
TypeScript
import { spawn, spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
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 { scriptModuleEntrypoints } from "../../scripts/script-module-runtime.test-support.mts";
|
|
import {
|
|
LIVE_TEST_SHARDS,
|
|
RELEASE_LIVE_TEST_SHARDS,
|
|
addLiveShardReportArgs,
|
|
buildLiveShardPnpmArgs,
|
|
buildLiveShardReportPath,
|
|
buildLiveShardSpawnParams,
|
|
collectAllLiveTestFiles,
|
|
parseLiveShardArgs,
|
|
removeLiveShardReportFile,
|
|
resolveLiveShardBuildEntrypoint,
|
|
resolveLiveShardBuildProfile,
|
|
resolveLiveShardPreparation,
|
|
selectLiveShardFiles,
|
|
validateLiveShardReportPayload,
|
|
} from "../../scripts/test-live-shard.mts";
|
|
import { resolveRuntimeWorkerUrl } from "../../src/infra/runtime-worker-url.js";
|
|
import { expectNoReaddirSyncDuring } from "../../src/test-utils/fs-scan-assertions.js";
|
|
import { waitForPidFile } from "../helpers/process-wait.js";
|
|
import { preparedScriptWrapperEnv } from "./prepared-script-wrapper.test-support.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("routes fixture files across alphabet boundaries and dedicated slow shards", () => {
|
|
const expectedByShard = {
|
|
"native-live-src-agents": [
|
|
"src/agents/zai.live.test.ts",
|
|
"src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.live.test.ts",
|
|
"src/skills/workshop/experience-review.live.test.ts",
|
|
],
|
|
"native-live-src-agents-zai-coding": ["src/agents/zai.live.test.ts"],
|
|
"native-live-src-gateway-backends": [
|
|
"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",
|
|
],
|
|
"native-live-src-gateway-profiles": [
|
|
"src/gateway/gateway-models.profiles.live.test.ts",
|
|
"src/gateway/gateway-openai-long-context.live.test.ts",
|
|
],
|
|
"native-live-src-gateway-core": [
|
|
"src/gateway/fixture.live.test.ts",
|
|
"src/system-agent/fixture.live.test.ts",
|
|
],
|
|
"native-live-src-infra": [
|
|
"src/cli/update-cli/update-command-node-runtime.live.test.ts",
|
|
"src/commands/doctor-config-preflight.legacy-driver.live.test.ts",
|
|
"src/infra/fixture.live.test.ts",
|
|
],
|
|
"native-live-test": ["test/fixture.live.test.ts"],
|
|
"native-live-extensions-a-k": [
|
|
"extensions/a-provider/model.live.test.ts",
|
|
"extensions/k-provider/model.live.test.ts",
|
|
],
|
|
"native-live-extensions-l-n": [
|
|
"extensions/l-provider/model.live.test.ts",
|
|
"extensions/n-provider/model.live.test.ts",
|
|
],
|
|
"native-live-extensions-moonshot": ["extensions/moonshot/moonshot.live.test.ts"],
|
|
"native-live-extensions-openai": ["extensions/openai/openai.live.test.ts"],
|
|
"native-live-extensions-o-z-other": [
|
|
"extensions/o-provider/model.live.test.ts",
|
|
"extensions/z-provider/model.live.test.ts",
|
|
],
|
|
"native-live-extensions-xai": ["extensions/xai/xai.live.test.ts"],
|
|
"native-live-extensions-media": [
|
|
"extensions/minimax/minimax.live.test.ts",
|
|
"extensions/music-generation-providers.live.test.ts",
|
|
"extensions/openai/openai-tts.live.test.ts",
|
|
"extensions/tts-local-cli/speech-provider.live.test.ts",
|
|
"extensions/video-generation-providers.live.test.ts",
|
|
"extensions/volcengine/tts.live.test.ts",
|
|
"extensions/vydra/vydra.live.test.ts",
|
|
],
|
|
};
|
|
const files = [...new Set(Object.values(expectedByShard).flat())].toSorted((a, b) =>
|
|
a.localeCompare(b),
|
|
);
|
|
|
|
for (const [shard, expectedFiles] of Object.entries(expectedByShard)) {
|
|
expect(selectLiveShardFiles(shard, files), shard).toEqual(expectedFiles);
|
|
}
|
|
expect(selectLiveShardFiles("native-live-extensions-media-audio", allFiles)).toContain(
|
|
"extensions/tts-local-cli/speech-provider.live.test.ts",
|
|
);
|
|
});
|
|
|
|
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",
|
|
env: preparedScriptWrapperEnv([
|
|
[
|
|
new URL("../../scripts/test-live-shard.mts", import.meta.url),
|
|
resolveRuntimeWorkerUrl(scriptModuleEntrypoints.liveShard),
|
|
],
|
|
]),
|
|
});
|
|
|
|
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("prepares gateway profile shards with observable source-runtime diagnostics", () => {
|
|
const preparation = resolveLiveShardPreparation(
|
|
selectLiveShardFiles("native-live-src-gateway-profiles", allFiles),
|
|
);
|
|
|
|
expect(preparation).toEqual({
|
|
env: {},
|
|
profile: "sourcePerformance",
|
|
requiredArtifact: "dist/.runtime-postbuildstamp",
|
|
runtimeEnv: {
|
|
OPENCLAW_DISABLE_BONJOUR: "1",
|
|
OPENCLAW_GATEWAY_STARTUP_TRACE: "1",
|
|
OPENCLAW_LIVE_TEST_QUIET: "0",
|
|
OPENCLAW_LOG_LEVEL: "info",
|
|
OPENCLAW_PLUGIN_LIFECYCLE_TRACE: "1",
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
"native-live-src-gateway-core",
|
|
"native-live-src-infra",
|
|
"native-live-test",
|
|
"src/infra/heartbeat-runner.live.test.ts",
|
|
"test/e2e/qa-lab/runtime/gateway-node-mcp.live.test.ts",
|
|
])("prepares the built gateway runtime before %s starts Vitest", (target) => {
|
|
const files = target.endsWith(".live.test.ts")
|
|
? [target]
|
|
: selectLiveShardFiles(target, allFiles);
|
|
expect(resolveLiveShardPreparation(files)).toEqual({
|
|
env: {},
|
|
profile: "sourcePerformance",
|
|
requiredArtifact: "dist/.runtime-postbuildstamp",
|
|
});
|
|
});
|
|
|
|
it("prepares system-agent gateway tests without building unrelated source shards", () => {
|
|
expect(resolveLiveShardPreparation(["src/system-agent/rescue-channel.live.test.ts"])).toEqual({
|
|
env: {},
|
|
profile: "sourcePerformance",
|
|
requiredArtifact: "dist/.runtime-postbuildstamp",
|
|
});
|
|
expect(resolveLiveShardPreparation(["src/infra/push-apns-http2.live.test.ts"])).toBeNull();
|
|
});
|
|
|
|
it("runs the frozen candidate's available build entrypoint and advertised profile", () => {
|
|
expect(resolveLiveShardBuildEntrypoint((file) => file === "scripts/build-all.mts")).toEqual([
|
|
"--import",
|
|
"tsx",
|
|
"scripts/build-all.mts",
|
|
]);
|
|
expect(resolveLiveShardBuildEntrypoint((file) => file === "scripts/build-all.mjs")).toEqual([
|
|
"scripts/build-all.mjs",
|
|
]);
|
|
expect(() => resolveLiveShardBuildEntrypoint(() => false)).toThrow(
|
|
"Live test shard cannot find scripts/build-all.{mts,mjs}",
|
|
);
|
|
expect(
|
|
resolveLiveShardBuildProfile("sourcePerformance", "Profiles:\n full\n sourcePerformance\n"),
|
|
).toBe("sourcePerformance");
|
|
expect(resolveLiveShardBuildProfile("sourcePerformance", "Profiles:\n full\n")).toBe("full");
|
|
});
|
|
|
|
it.each(["native-live-src-agents", "native-live-extensions-openai"])(
|
|
"prepares executable runtime artifacts before %s exercises live vision",
|
|
(shard) => {
|
|
expect(resolveLiveShardPreparation(selectLiveShardFiles(shard, allFiles))).toEqual({
|
|
env: {},
|
|
profile: "sourcePerformance",
|
|
requiredArtifact: "dist/.runtime-postbuildstamp",
|
|
});
|
|
},
|
|
);
|
|
|
|
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.each([
|
|
["test/e2e/crabbox-sandbox.live.test.ts", "OPENCLAW_E2E_CRABBOX"],
|
|
["src/gateway/gateway-cli-backend.live.test.ts", "OPENCLAW_LIVE_CLI_BACKEND"],
|
|
["src/gateway/gateway-acp-spawn-defaults.live.test.ts", "OPENCLAW_LIVE_ACP_SPAWN_DEFAULTS"],
|
|
["src/gateway/gateway-openai-long-context.live.test.ts", "OPENCLAW_LIVE_OPENAI_LONG_CONTEXT"],
|
|
["src/skills/workshop/experience-review.live.test.ts", "OPENCLAW_LIVE_SKILL_EXPERIENCE_REVIEW"],
|
|
["src/agents/subagent-announce.live.test.ts", "OPENCLAW_LIVE_SUBAGENT_E2E"],
|
|
["src/agents/subagents/announce/subagent-announce.live.test.ts", "OPENCLAW_LIVE_SUBAGENT_E2E"],
|
|
[
|
|
"src/agents/sessions/agent-session.openai-compaction.live.test.ts",
|
|
"OPENCLAW_LIVE_OPENAI_COMPACTION",
|
|
],
|
|
])("respects explicit opt-in and pass evidence for %s", (reviewFile, optInEnv) => {
|
|
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(), {
|
|
[optInEnv]: "1",
|
|
}),
|
|
).toEqual({
|
|
ok: false,
|
|
reason: `Vitest report selected live test files had no passing assertions: ${reviewFile}`,
|
|
});
|
|
const passingPayload = {
|
|
...payload,
|
|
numPassedTests: 2,
|
|
testResults: payload.testResults.map(({ name }) => ({
|
|
name,
|
|
assertionResults: [{ status: "passed" }],
|
|
})),
|
|
};
|
|
expect(
|
|
validateLiveShardReportPayload(passingPayload, expectedFiles, process.cwd(), {
|
|
[optInEnv]: "1",
|
|
}),
|
|
).toEqual({ ok: true });
|
|
});
|
|
|
|
it("allows GPT-Live files to be skipped until their shared opt-in is enabled", () => {
|
|
const gptLiveFiles = [
|
|
"extensions/openai/realtime-meeting.live.test.ts",
|
|
"extensions/openai/realtime-quicksilver-gateway-bridge.live.test.ts",
|
|
"extensions/openai/realtime-quicksilver.live.test.ts",
|
|
"extensions/openai/realtime-talk-defaults.live.test.ts",
|
|
];
|
|
const payload = {
|
|
numPassedTests: 1,
|
|
numTotalTests: 1 + gptLiveFiles.length,
|
|
testResults: [
|
|
{
|
|
name: path.join(process.cwd(), "extensions/openai/openai.live.test.ts"),
|
|
assertionResults: [{ status: "passed" }],
|
|
},
|
|
...gptLiveFiles.map((file) => ({
|
|
name: path.join(process.cwd(), file),
|
|
assertionResults: [{ status: "skipped" }],
|
|
})),
|
|
],
|
|
};
|
|
const expectedFiles = ["extensions/openai/openai.live.test.ts", ...gptLiveFiles];
|
|
|
|
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: ${gptLiveFiles.join(", ")}`,
|
|
});
|
|
const passingPayload = {
|
|
...payload,
|
|
numPassedTests: expectedFiles.length,
|
|
testResults: payload.testResults.map(({ name }) => ({
|
|
name,
|
|
assertionResults: [{ status: "passed" }],
|
|
})),
|
|
};
|
|
expect(
|
|
validateLiveShardReportPayload(passingPayload, expectedFiles, process.cwd(), {
|
|
OPENCLAW_LIVE_GPT_LIVE: "1",
|
|
}),
|
|
).toEqual({ ok: true });
|
|
});
|
|
|
|
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",
|
|
});
|
|
expect(
|
|
buildLiveShardSpawnParams({ OPENCLAW_LOG_LEVEL: "warn", PATH: "/usr/bin" }, "darwin", {
|
|
OPENCLAW_DISABLE_BONJOUR: "1",
|
|
OPENCLAW_GATEWAY_STARTUP_TRACE: "1",
|
|
OPENCLAW_LIVE_TEST_QUIET: "0",
|
|
OPENCLAW_LOG_LEVEL: "info",
|
|
OPENCLAW_PLUGIN_LIFECYCLE_TRACE: "1",
|
|
}),
|
|
).toEqual({
|
|
detached: true,
|
|
env: {
|
|
OPENCLAW_DISABLE_BONJOUR: "1",
|
|
OPENCLAW_GATEWAY_STARTUP_TRACE: "1",
|
|
OPENCLAW_LIVE_TEST_QUIET: "0",
|
|
OPENCLAW_LOG_LEVEL: "info",
|
|
OPENCLAW_PLUGIN_LIFECYCLE_TRACE: "1",
|
|
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 argsPath = path.join(root, "args.json");
|
|
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;
|
|
let runnerClosed: Promise<{ code: number | null; signal: NodeJS.Signals | null }> | undefined;
|
|
|
|
try {
|
|
writeFakePnpm(fakePnpmPath);
|
|
// Give the real shard selector an APNs-only inventory: readiness must belong
|
|
// to the live-test child, not a build prerequisite of another infra test.
|
|
mkdirSync(path.join(root, "src/infra"), { recursive: true });
|
|
writeFileSync(path.join(root, "src/infra/push-apns-http2.live.test.ts"), "");
|
|
const startedRunner = spawn(
|
|
process.execPath,
|
|
[path.resolve("scripts/test-live-shard.mjs"), "native-live-src-infra"],
|
|
{
|
|
cwd: root,
|
|
env: preparedScriptWrapperEnv(
|
|
[
|
|
[
|
|
new URL("../../scripts/test-live-shard.mts", import.meta.url),
|
|
resolveRuntimeWorkerUrl(scriptModuleEntrypoints.liveShard),
|
|
],
|
|
],
|
|
{
|
|
...process.env,
|
|
OPENCLAW_FAKE_PNPM_ARGS_PATH: argsPath,
|
|
OPENCLAW_FAKE_PNPM_DESCENDANT_PID_PATH: descendantPidPath,
|
|
OPENCLAW_FAKE_PNPM_PID_PATH: childPidPath,
|
|
OPENCLAW_FAKE_PNPM_SIGNALED_PATH: signaledPath,
|
|
npm_execpath: fakePnpmPath,
|
|
},
|
|
),
|
|
stdio: "ignore",
|
|
},
|
|
);
|
|
|
|
runner = startedRunner;
|
|
runnerClosed = new Promise((resolve) => {
|
|
startedRunner.once("close", (code, signal) => resolve({ code, signal }));
|
|
});
|
|
|
|
childPid = await waitForPidFile(childPidPath, 5_000);
|
|
descendantPid = await waitForPidFile(descendantPidPath, 5_000);
|
|
expect(JSON.parse(readFileSync(argsPath, "utf8")).slice(0, 3)).toEqual([
|
|
"test:live",
|
|
"--",
|
|
"src/infra/push-apns-http2.live.test.ts",
|
|
]);
|
|
|
|
runner.kill("SIGTERM");
|
|
|
|
await expect(waitForClose(runnerClosed)).resolves.toEqual({
|
|
code: null,
|
|
signal: "SIGTERM",
|
|
});
|
|
// Creation precedes the synchronous write; wait for the signal receipt itself.
|
|
await waitFor(
|
|
() => existsSync(signaledPath) && readFileSync(signaledPath, "utf8") === "SIGTERM",
|
|
5_000,
|
|
);
|
|
await waitFor(() => !isProcessAlive(childPid), 5_000);
|
|
await waitFor(() => !isProcessAlive(descendantPid), 5_000);
|
|
} finally {
|
|
try {
|
|
if (runner?.pid && isProcessAlive(runner.pid)) {
|
|
runner.kill("SIGTERM");
|
|
}
|
|
// Join the shim so it can forward cancellation to its detached child group
|
|
// before the fixture directory (and its process receipts) disappears.
|
|
if (runnerClosed) {
|
|
await waitForClose(runnerClosed);
|
|
}
|
|
} finally {
|
|
// A failed shim join must not skip cleanup of already observed descendants.
|
|
for (const pid of [childPid, descendantPid]) {
|
|
if (pid && isProcessAlive(pid)) {
|
|
process.kill(pid, "SIGKILL");
|
|
}
|
|
}
|
|
try {
|
|
await Promise.all(
|
|
[childPid, descendantPid]
|
|
.filter((pid) => pid > 0)
|
|
.map((pid) => waitFor(() => !isProcessAlive(pid), 5_000)),
|
|
);
|
|
} finally {
|
|
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");',
|
|
'if (process.argv[2] !== "test:live") throw new Error("Expected the live-test invocation");',
|
|
"fs.writeFileSync(process.env.OPENCLAW_FAKE_PNPM_ARGS_PATH, JSON.stringify(process.argv.slice(2)));",
|
|
"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(
|
|
completion: Promise<{ code: number | null; signal: NodeJS.Signals | null }>,
|
|
timeoutMs = 5_000,
|
|
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
|
|
return await Promise.race([
|
|
completion,
|
|
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;
|
|
}
|
|
}
|