test: speed up and stabilize full suite

This commit is contained in:
Peter Steinberger
2026-07-05 07:59:48 -04:00
parent 785ab74779
commit 1f484a8dbd
142 changed files with 2872 additions and 1734 deletions
+1 -1
View File
@@ -174,7 +174,7 @@ If `pnpm test` flakes on a loaded host, rerun once before treating it as a regre
- `pnpm test:perf:imports`: enables Vitest import-duration + import-breakdown reporting, while still using scoped lane routing for explicit file/directory targets. `pnpm test:perf:imports:changed` scopes the same profiling to files changed since `origin/main`.
- `pnpm test:perf:changed:bench -- --ref <git-ref>` benchmarks the routed changed-mode path against the native root-project run for the same committed git diff; `pnpm test:perf:changed:bench -- --worktree` benchmarks the current worktree change set without committing first.
- `pnpm test:perf:profile:main` writes a CPU profile for the Vitest main thread (`.artifacts/vitest-main-profile`); `pnpm test:perf:profile:runner` writes CPU + heap profiles for the unit runner (`.artifacts/vitest-runner-profile`).
- `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/baseline-before.json`: runs every full-suite Vitest leaf config serially and writes grouped duration data plus per-config JSON/log artifacts. The Test Performance Agent uses this as its baseline before attempting slow-test fixes. `pnpm test:perf:groups:compare .artifacts/test-perf/baseline-before.json .artifacts/test-perf/after-agent.json` compares grouped reports after a performance-focused change.
- `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/baseline-before.json`: runs every full-suite Vitest leaf config serially and writes grouped duration data plus per-config JSON/log artifacts. Full-suite reports isolate files by default so retained module graphs and GC pauses from earlier files are not charged to later assertions; pass `-- --no-isolate` only when intentionally profiling shared-worker accumulation. The Test Performance Agent uses this as its baseline before attempting slow-test fixes. `pnpm test:perf:groups:compare .artifacts/test-perf/baseline-before.json .artifacts/test-perf/after-agent.json` compares grouped reports after a performance-focused change.
- Full, extension, and include-pattern shard runs update local timing data in `.artifacts/vitest-shard-timings.json`; later whole-config runs use those timings to balance slow and fast shards. Include-pattern CI shards append the shard name to the timing key, which keeps filtered shard timings visible without replacing whole-config timing data. Set `OPENCLAW_TEST_PROJECTS_TIMINGS=0` to ignore the local timing artifact.
## Benchmarks
+2 -2
View File
@@ -2839,10 +2839,10 @@ describe("active-memory plugin", () => {
it("returns partial transcript text on timeout when the subagent has already written assistant output", async () => {
testing.setMinimumTimeoutMsForTests(1);
testing.setSetupGraceTimeoutMsForTests(0);
testing.setTimeoutPartialDataGraceMsForTests(200);
testing.setTimeoutPartialDataGraceMsForTests(50);
api.pluginConfig = {
agents: ["main"],
timeoutMs: 1_000,
timeoutMs: 100,
maxSummaryChars: 40,
persistTranscripts: true,
logging: true,
@@ -242,7 +242,7 @@ describe("startCodexAttemptThread", () => {
});
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
const { harness, run } = startThreadWithHarness(2_000);
const { harness, run } = startThreadWithHarness(500);
const runError = run.then(
() => undefined,
(error: unknown) => error,
@@ -253,7 +253,7 @@ describe("startCodexAttemptThread", () => {
const error = await runError;
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
interval: 1,
timeout: 2_000,
timeout: 1_000,
});
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("codex app-server startup timed out");
@@ -290,7 +290,7 @@ describe("startCodexAttemptThread", () => {
});
it("closes the shared app-server when startup times out during initialize", async () => {
const { harness, run } = startThreadWithHarness(2_000);
const { harness, run } = startThreadWithHarness(500);
const runError = run.then(
() => undefined,
(error: unknown) => error,
@@ -304,7 +304,7 @@ describe("startCodexAttemptThread", () => {
expect((error as Error).message).toBe("codex app-server startup timed out");
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
interval: 1,
timeout: 2_000,
timeout: 1_000,
});
expect(
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
@@ -85,6 +85,7 @@ describe("resolveCodexProviderWebSearchSupport", () => {
await expect(resolveSupport(clientFactory, "amazon-bedrock")).resolves.toBe("unsupported");
await expect(resolveSupport(clientFactory, "custom-provider")).resolves.toBe("unsupported");
await expect(resolveSupport(clientFactory, "lmstudio")).resolves.toBe("unsupported");
expect(request).not.toHaveBeenCalled();
});
});
@@ -5657,41 +5657,6 @@ describe("runCodexAppServerAttempt", () => {
expect(turnRequestParams?.approvalsReviewer).toBe("user");
});
it("keeps managed web_search for provider-qualified Codex model overrides", async () => {
testing.setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("web_search")]);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(async (method) => {
if (method === "modelProvider/capabilities/read") {
return { webSearch: true };
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.modelId = "lmstudio/local-model";
const run = runCodexAppServerAttempt(params);
await waitForMethod("turn/start");
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
expect(requests.map((request) => request.method)).not.toContain(
"modelProvider/capabilities/read",
);
const startRequest = requests.find((request) => request.method === "thread/start");
const startRequestParams = startRequest?.params as Record<string, unknown> | undefined;
const startConfig = startRequestParams?.config as Record<string, unknown> | undefined;
const dynamicToolNames = specNames(
(startRequestParams?.dynamicTools as CodexDynamicToolSpec[] | undefined) ?? [],
);
expect(startRequestParams?.model).toBe("local-model");
expect(startRequestParams?.modelProvider).toBe("lmstudio");
expect(startConfig?.web_search).toBe("disabled");
expect(dynamicToolNames).toContain("web_search");
});
it("uses bound local model providers when disabling Guardian on resumed threads", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
+3
View File
@@ -77,6 +77,9 @@ export async function createCopilotByokProxy(
}
await new Promise<void>((resolve) => {
server.close(() => resolve());
// The proxy is private and ephemeral. Do not let a client's keep-alive
// socket delay shutdown after every upstream request has been aborted.
server.closeAllConnections();
});
},
};
@@ -220,6 +220,10 @@ describe("discord native /think autocomplete", () => {
});
({ findCommandByNativeName, resolveCommandArgChoices, resolveDiscordNativeChoiceContext } =
await loadDiscordThinkAutocompleteModulesForTest());
// Compile the provider-backed default choice context outside per-case timing.
const { command, levelArg } = requireThinkLevelCommand();
resolveCommandArgChoices({ command, arg: levelArg, cfg: createConfig(), catalog: [] });
});
beforeEach(async () => {
+20 -40
View File
@@ -963,19 +963,13 @@ describe("sendMessageIMessage receipts", () => {
});
it("does not use the local default chat.db path for custom cliPath wrappers", async () => {
vi.useFakeTimers({ now: 1_000 });
vi.stubEnv("HOME", "/Users/me");
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
const runCliJson = vi.fn();
const resolveSentMessageGuidImpl = vi.fn(async () => null);
const approvalText = createApprovalText("approval-remote");
const nowSpy = vi.spyOn(Date, "now");
nowSpy
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(6_001);
await expect(
const rejection = expect(
sendMessageIMessage("chat_id:42", approvalText, {
config: {
channels: {
@@ -994,6 +988,8 @@ describe("sendMessageIMessage receipts", () => {
resolveSentMessageGuidImpl,
}),
).rejects.toThrow("imsg rpc timeout (send)");
await vi.runAllTimersAsync();
await rejection;
expect(runCliJson).not.toHaveBeenCalled();
expect(resolveSentMessageGuidImpl).toHaveBeenCalledWith({
@@ -1005,6 +1001,7 @@ describe("sendMessageIMessage receipts", () => {
});
it("does not use the local default chat.db path for auto-detected ssh wrappers", async () => {
vi.useFakeTimers({ now: 1_000 });
vi.stubEnv("HOME", "/Users/me");
const wrapperDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-wrapper-"));
const wrapperPath = path.join(wrapperDir, "imsg");
@@ -1013,15 +1010,8 @@ describe("sendMessageIMessage receipts", () => {
const runCliJson = vi.fn();
const resolveSentMessageGuidImpl = vi.fn(async () => null);
const approvalText = createApprovalText("approval-ssh-wrapper");
const nowSpy = vi.spyOn(Date, "now");
nowSpy
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(6_001);
try {
await expect(
const rejection = expect(
sendMessageIMessage("chat_id:42", approvalText, {
config: IMESSAGE_TEST_CFG,
client,
@@ -1030,6 +1020,8 @@ describe("sendMessageIMessage receipts", () => {
resolveSentMessageGuidImpl,
}),
).rejects.toThrow("imsg rpc timeout (send)");
await vi.runAllTimersAsync();
await rejection;
} finally {
fs.rmSync(wrapperDir, { recursive: true, force: true });
}
@@ -1063,17 +1055,11 @@ describe("sendMessageIMessage receipts", () => {
});
it("throws the rpc timeout without resending when sent-row recovery misses", async () => {
vi.useFakeTimers({ now: 1_000 });
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
const runCliJson = vi.fn();
const resolveSentMessageGuidImpl = vi.fn(async () => null);
const nowSpy = vi.spyOn(Date, "now");
nowSpy
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(6_001);
await expect(
const rejection = expect(
sendMessageIMessage("chat_id:42", "hello", {
config: IMESSAGE_TEST_CFG,
createClient: async () => client,
@@ -1082,23 +1068,19 @@ describe("sendMessageIMessage receipts", () => {
resolveSentMessageGuidImpl,
}),
).rejects.toThrow("imsg rpc timeout (send)");
await vi.runAllTimersAsync();
await rejection;
expect(getClientMocks(client).stop).toHaveBeenCalledTimes(1);
expect(runCliJson).not.toHaveBeenCalled();
});
it("does not stop caller-owned rpc clients after sent-row recovery misses", async () => {
vi.useFakeTimers({ now: 1_000 });
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
const runCliJson = vi.fn();
const resolveSentMessageGuidImpl = vi.fn(async () => null);
const nowSpy = vi.spyOn(Date, "now");
nowSpy
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(6_001);
await expect(
const rejection = expect(
sendMessageIMessage("chat_id:42", "hello", {
config: IMESSAGE_TEST_CFG,
client,
@@ -1107,6 +1089,8 @@ describe("sendMessageIMessage receipts", () => {
resolveSentMessageGuidImpl,
}),
).rejects.toThrow("imsg rpc timeout (send)");
await vi.runAllTimersAsync();
await rejection;
expect(runCliJson).not.toHaveBeenCalled();
expect(getClientMocks(client).stop).not.toHaveBeenCalled();
@@ -1130,18 +1114,12 @@ describe("sendMessageIMessage receipts", () => {
});
it("throws the rpc timeout without resending when approval GUID recovery misses", async () => {
vi.useFakeTimers({ now: 1_000 });
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
const runCliJson = vi.fn();
const resolveSentMessageGuidImpl = vi.fn(async () => null);
const approvalText = createApprovalText();
const nowSpy = vi.spyOn(Date, "now");
nowSpy
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(6_001);
await expect(
const rejection = expect(
sendMessageIMessage("chat_id:42", approvalText, {
config: IMESSAGE_TEST_CFG,
client,
@@ -1150,6 +1128,8 @@ describe("sendMessageIMessage receipts", () => {
resolveSentMessageGuidImpl,
}),
).rejects.toThrow("imsg rpc timeout (send)");
await vi.runAllTimersAsync();
await rejection;
expect(runCliJson).not.toHaveBeenCalled();
expect(resolveSentMessageGuidImpl).toHaveBeenCalled();
+12
View File
@@ -21,6 +21,14 @@ const getIMessageSetupStatus = createPluginSetupWizardStatus({
} as never);
const spawnMock = vi.hoisted(() => vi.fn());
const setupToolsMocks = vi.hoisted(() => ({
detectBinary: vi.fn(async () => false),
}));
vi.mock("openclaw/plugin-sdk/setup-tools", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/setup-tools")>()),
...setupToolsMocks,
}));
function createMockChildProcess() {
const child = new EventEmitter() as EventEmitter & {
@@ -182,6 +190,10 @@ describe("createIMessageRpcClient", () => {
});
describe("imessage setup status", () => {
beforeEach(() => {
setupToolsMocks.detectBinary.mockClear();
});
it("does not inherit configured state from a sibling account", async () => {
const result = await getIMessageSetupStatus({
cfg: {
@@ -45,7 +45,9 @@ class FakeWebSocket {
close(): void {}
terminate(): void {}
terminate(): void {
this.emitClose(1000);
}
get openListenerCount(): number {
return this.openListeners.length;
@@ -107,7 +109,8 @@ vi.mock("./draft-stream.js", () => ({
createMattermostDraftStream: mockState.createMattermostDraftStream,
}));
vi.mock("./monitor-resources.js", () => ({
vi.mock("./monitor-resources.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./monitor-resources.js")>()),
createMattermostMonitorResources: () => ({
resolveMattermostMedia: mockState.resolveMattermostMedia,
sendTypingIndicator: vi.fn(async () => {}),
@@ -419,7 +422,7 @@ describe("mattermost inbound user posts", () => {
channel_display_name: "Town Square",
sender_name: "alice",
post: JSON.stringify({
id: "post-1",
id: "post-inbound-system-event-regular",
channel_id: "chan-1",
user_id: "user-1",
message: "hello from mattermost",
@@ -439,7 +442,7 @@ describe("mattermost inbound user posts", () => {
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("hello from mattermost");
expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1");
expect(ctx?.MessageSid).toBe("post-1");
expect(ctx?.MessageSid).toBe("post-inbound-system-event-regular");
expect(ctx?.OriginatingChannel).toBe("mattermost");
expect(ctx?.Provider).toBe("mattermost");
});
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { validateQaEvidenceSummaryJson } from "./evidence-summary.js";
import { readQaScenarioById, type QaSeedScenarioWithSource } from "./scenario-catalog.js";
import { createTempDirHarness } from "./temp-dir.test-helper.js";
@@ -149,6 +149,7 @@ async function writeScriptProducerEvidence(params: {
describe("qa test file scenario runner", () => {
afterEach(async () => {
qaTestFileScenarioRunnerTesting.resetTimeoutCleanupTimings();
await Promise.all([
cleanupTempDirs(),
...tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
@@ -494,17 +495,15 @@ describe("qa test file scenario runner", () => {
expect(commands.map((command) => command.timeoutMs)).toEqual([3 * 60 * 60_000]);
});
it("times out script scenarios and kills descendant process groups", async () => {
if (process.platform === "win32") {
return;
}
const repoRoot = process.cwd();
const tempRoot = await makeTempDir("qa-script-timeout-");
const scriptPath = path.join(tempRoot, "hanging-producer.ts");
const descendantPidPath = path.join(tempRoot, "descendant.pid");
describe.skipIf(process.platform === "win32")("script timeout process groups", () => {
const commandTimeoutMs = 1_500;
let descendantPid: number | undefined;
try {
let result: Awaited<ReturnType<typeof runQaTestFileScenarios>>;
beforeAll(async () => {
const tempRoot = await makeTempDir("qa-script-timeout-");
const scriptPath = path.join(tempRoot, "hanging-producer.ts");
const descendantPidPath = path.join(tempRoot, "descendant.pid");
const descendantScript = [
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
@@ -523,29 +522,39 @@ describe("qa test file scenario runner", () => {
"utf8",
);
const commandTimeoutMs = 3_000;
qaTestFileScenarioRunnerTesting.setTimeoutCleanupTimings({
forceSettleMs: 25,
killGraceMs: 50,
});
const run = runQaTestFileScenarios({
repoRoot,
repoRoot: process.cwd(),
outputDir: path.join(tempRoot, "out"),
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
scenarios: [makeTestFileScenario("script", scriptPath)],
commandTimeoutMs,
});
descendantPid = await readPid(descendantPidPath, commandTimeoutMs - 250);
descendantPid = await readPid(descendantPidPath, commandTimeoutMs);
result = await run;
await waitForDead(descendantPid, 2_000);
});
const result = await run;
afterAll(() => {
if (descendantPid !== undefined && isProcessRunning(descendantPid)) {
process.kill(descendantPid, "SIGKILL");
}
});
it("times out script scenarios and kills descendant process groups", () => {
expect(result.results[0]?.status).toBe("fail");
expect(result.results[0]?.failureMessage).toMatch(
new RegExp(`timed out after ${commandTimeoutMs}ms`, "u"),
);
await waitForDead(descendantPid, 2_000);
} finally {
if (descendantPid !== undefined && isProcessRunning(descendantPid)) {
process.kill(descendantPid, "SIGKILL");
if (descendantPid === undefined) {
throw new Error("descendant pid was not captured");
}
}
expect(isProcessRunning(descendantPid)).toBe(false);
});
});
it("force-kills Windows scenario command trees when graceful taskkill fails", () => {
@@ -1062,64 +1071,71 @@ describe("qa test file scenario runner", () => {
expect(artifactPath?.includes("..")).toBe(false);
});
it("runs the UX Matrix script producer and imports its evidence bundle", async () => {
const repoRoot = process.cwd();
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-ux-matrix-script-"));
tempRoots.push(outputDir);
const scenario = readQaScenarioById("ux-matrix-evidence-dashboard");
describe("UX Matrix scenario composition", () => {
let outputDir: string;
let result: Awaited<ReturnType<typeof runQaTestFileScenarios>>;
let evidence: ReturnType<typeof validateQaEvidenceSummaryJson>;
expect(scenario.execution.kind).toBe("script");
const result = await runQaTestFileScenarios({
repoRoot,
outputDir,
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
scenarios: [scenario],
env: {
OPENCLAW_QA_REF: "scenario-ref",
} as NodeJS.ProcessEnv,
beforeAll(async () => {
outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-ux-matrix-script-"));
tempRoots.push(outputDir);
const scenario = readQaScenarioById("ux-matrix-evidence-dashboard");
expect(scenario.execution.kind).toBe("script");
result = await runQaTestFileScenarios({
repoRoot: process.cwd(),
outputDir,
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
scenarios: [scenario],
env: {
OPENCLAW_QA_REF: "scenario-ref",
} as NodeJS.ProcessEnv,
});
evidence = validateQaEvidenceSummaryJson(
JSON.parse(await fs.readFile(result.evidencePath, "utf8")),
);
});
expect(result.executionKind).toBe("script");
expect(result.results[0]?.producerEvidence?.entries).toHaveLength(3);
const evidence = validateQaEvidenceSummaryJson(
JSON.parse(await fs.readFile(result.evidencePath, "utf8")),
);
expect(evidence.entries.map((entry) => entry.test.id)).toEqual([
"ux-matrix.qa-lab.producer-artifact-fixture",
"ux-matrix.control-ui.screenshot-artifact",
"ux-matrix.cli.entrypoint-help",
]);
expect(
evidence.entries.flatMap((entry) => entry.coverage.map((coverage) => coverage.id)),
).toEqual(
expect.arrayContaining([
"qa.artifact-safety",
"tools.evidence",
"workspace.artifacts",
"ui.control",
"gateway.control-ui-hosting",
"cli.entrypoint",
"cli.status-snapshots",
]),
);
const artifactKinds = evidence.entries.flatMap(
(entry) => entry.execution?.artifacts.map((artifact) => artifact.kind) ?? [],
);
expect(artifactKinds).toEqual(expect.arrayContaining(["html", "log"]));
const fixtureEntry = evidence.entries.find(
(entry) => entry.test.id === "ux-matrix.qa-lab.producer-artifact-fixture",
);
expect(fixtureEntry?.execution?.artifacts.map((artifact) => artifact.path)).toContain(
path.join(
outputDir,
"ux-matrix-evidence-dashboard",
"surfaces",
"qa-lab",
"stages",
"producer-artifact-fixture",
"producer-artifact-fixture.html",
),
);
it("runs the checked-in producer and imports its evidence bundle", () => {
expect(result.executionKind).toBe("script");
expect(result.results[0]?.producerEvidence?.entries).toHaveLength(3);
expect(evidence.entries.map((entry) => entry.test.id)).toEqual([
"ux-matrix.qa-lab.producer-artifact-fixture",
"ux-matrix.control-ui.screenshot-artifact",
"ux-matrix.cli.entrypoint-help",
]);
expect(
evidence.entries.flatMap((entry) => entry.coverage.map((coverage) => coverage.id)),
).toEqual(
expect.arrayContaining([
"qa.artifact-safety",
"tools.evidence",
"workspace.artifacts",
"ui.control",
"gateway.control-ui-hosting",
"cli.entrypoint",
"cli.status-snapshots",
]),
);
const artifactKinds = evidence.entries.flatMap(
(entry) => entry.execution?.artifacts.map((artifact) => artifact.kind) ?? [],
);
expect(artifactKinds).toEqual(expect.arrayContaining(["html", "log"]));
const fixtureEntry = evidence.entries.find(
(entry) => entry.test.id === "ux-matrix.qa-lab.producer-artifact-fixture",
);
expect(fixtureEntry?.execution?.artifacts.map((artifact) => artifact.path)).toContain(
path.join(
outputDir,
"ux-matrix-evidence-dashboard",
"surfaces",
"qa-lab",
"stages",
"producer-artifact-fixture",
"producer-artifact-fixture.html",
),
);
});
});
});
@@ -96,6 +96,8 @@ type QaTestFileRunnerDefinition = {
const DEFAULT_QA_TEST_FILE_COMMAND_TIMEOUT_MS = 30 * 60_000;
const QA_TEST_FILE_COMMAND_TIMEOUT_KILL_GRACE_MS = 2_000;
const QA_TEST_FILE_COMMAND_TIMEOUT_FORCE_SETTLE_MS = 500;
let qaTestFileCommandTimeoutKillGraceMs = QA_TEST_FILE_COMMAND_TIMEOUT_KILL_GRACE_MS;
let qaTestFileCommandTimeoutForceSettleMs = QA_TEST_FILE_COMMAND_TIMEOUT_FORCE_SETTLE_MS;
const QA_TEST_FILE_COMMAND_PARENT_SIGNALS = ["SIGINT", "SIGTERM"] as const;
export function isQaTestFileScenario(
@@ -345,8 +347,8 @@ function runQaScenarioCommand(
signal: result.signal,
...(failureMessage ? { failureMessage } : {}),
});
}, QA_TEST_FILE_COMMAND_TIMEOUT_FORCE_SETTLE_MS);
}, QA_TEST_FILE_COMMAND_TIMEOUT_KILL_GRACE_MS);
}, qaTestFileCommandTimeoutForceSettleMs);
}, qaTestFileCommandTimeoutKillGraceMs);
};
timeoutTimer =
timeoutMs === undefined
@@ -826,4 +828,12 @@ export async function runQaTestFileScenarios(
export const qaTestFileScenarioRunnerTesting = {
killQaScenarioWindowsProcessTree,
resetTimeoutCleanupTimings() {
qaTestFileCommandTimeoutKillGraceMs = QA_TEST_FILE_COMMAND_TIMEOUT_KILL_GRACE_MS;
qaTestFileCommandTimeoutForceSettleMs = QA_TEST_FILE_COMMAND_TIMEOUT_FORCE_SETTLE_MS;
},
setTimeoutCleanupTimings(params: { forceSettleMs: number; killGraceMs: number }) {
qaTestFileCommandTimeoutKillGraceMs = params.killGraceMs;
qaTestFileCommandTimeoutForceSettleMs = params.forceSettleMs;
},
};
@@ -12,10 +12,7 @@ import {
type UploadPrepareResponse,
} from "../types.js";
import type { ApiClient } from "./api-client.js";
import {
ChunkedMediaApi,
UploadDailyLimitExceededError,
} from "./media-chunked.js";
import { ChunkedMediaApi, UploadDailyLimitExceededError } from "./media-chunked.js";
import type { UploadCacheAdapter } from "./media.js";
import { UPLOAD_PREPARE_FALLBACK_CODE } from "./retry.js";
import type { TokenManager } from "./token.js";
@@ -142,6 +139,7 @@ describe("media-chunked: ChunkedMediaApi.uploadChunked", () => {
});
afterEach(() => {
vi.useRealTimers();
globalThis.fetch = originalFetch;
fetchWithSsrFGuardMock.mockReset();
vi.restoreAllMocks();
@@ -282,6 +280,7 @@ describe("media-chunked: ChunkedMediaApi.uploadChunked", () => {
});
it("bounds COS PUT error bodies without using response.text()", async () => {
vi.useFakeTimers();
const client = mockApiClient();
const tm = mockTokenManager();
const logger = { info: vi.fn(), error: vi.fn(), warn: vi.fn() };
@@ -324,18 +323,17 @@ describe("media-chunked: ChunkedMediaApi.uploadChunked", () => {
});
const api = new ChunkedMediaApi(client, tm, { logger });
let error: unknown;
try {
await api.uploadChunked({
const upload = api
.uploadChunked({
scope: "group",
targetId: "g1",
fileType: MediaFileType.FILE,
source: { kind: "buffer", buffer: Buffer.from("01234567"), fileName: "blob.bin" },
creds: { appId: "a", clientSecret: "s" },
});
} catch (caught) {
error = caught;
}
})
.catch((error: unknown) => error);
await vi.runAllTimersAsync();
const error = await upload;
expect(String(error)).toContain("COS PUT failed: 503 Service Unavailable");
expect(String(error)).toContain("cos gateway unavailable");
+40 -31
View File
@@ -780,6 +780,7 @@ describe("startTelegramWebhook", () => {
});
it("durably retries a webhook update after acknowledging Telegram", async () => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
const runtimeLog = vi.fn();
const seenUpdates: unknown[] = [];
handleUpdateSpy.mockImplementation(async (update: unknown) => {
@@ -790,39 +791,47 @@ describe("startTelegramWebhook", () => {
});
const payload = JSON.stringify({ update_id: 3, message: { text: "boom" } });
await withStartedWebhook(
{
secret: TELEGRAM_SECRET,
path: TELEGRAM_WEBHOOK_PATH,
runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() },
},
async ({ port }) => {
const response = await postWebhookJson({
url: webhookUrl(port, TELEGRAM_WEBHOOK_PATH),
payload,
try {
await withStartedWebhook(
{
secret: TELEGRAM_SECRET,
});
path: TELEGRAM_WEBHOOK_PATH,
runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() },
},
async ({ port }) => {
const response = await postWebhookJson({
url: webhookUrl(port, TELEGRAM_WEBHOOK_PATH),
payload,
secret: TELEGRAM_SECRET,
});
expect(response.status).toBe(200);
expect(await response.text()).toBe("");
await vi.waitFor(() => expect(seenUpdates).toEqual([JSON.parse(payload)]));
await vi.waitFor(async () =>
expect(
(await listTelegramSpooledUpdates({ spoolDir: requireWebhookSpoolDir() })).length,
).toBe(1),
);
expectMockMessageContains(runtimeLog, "webhook spooled update 3 failed; keeping for retry");
await sleep(1_100);
await vi.waitFor(() =>
expect(seenUpdates).toEqual([JSON.parse(payload), JSON.parse(payload)]),
);
await vi.waitFor(async () =>
expect(await listTelegramSpooledUpdates({ spoolDir: requireWebhookSpoolDir() })).toEqual(
[],
),
);
},
);
expect(response.status).toBe(200);
expect(await response.text()).toBe("");
await vi.waitFor(() => expect(seenUpdates).toEqual([JSON.parse(payload)]));
await vi.waitFor(async () =>
expect(
(await listTelegramSpooledUpdates({ spoolDir: requireWebhookSpoolDir() })).length,
).toBe(1),
);
expectMockMessageContains(
runtimeLog,
"webhook spooled update 3 failed; keeping for retry",
);
vi.setSystemTime(Date.now() + 1_100);
await vi.advanceTimersByTimeAsync(500);
await vi.waitFor(() =>
expect(seenUpdates).toEqual([JSON.parse(payload), JSON.parse(payload)]),
);
await vi.waitFor(async () =>
expect(
await listTelegramSpooledUpdates({ spoolDir: requireWebhookSpoolDir() }),
).toEqual([]),
);
},
);
} finally {
vi.useRealTimers();
}
});
it("keeps a timed-out webhook lane guarded until replay settles", async () => {
@@ -186,8 +186,12 @@ const inboundRuntimeMocks = vi.hoisted(() => {
return current;
}
async function* fakeMediaStream() {
yield Buffer.from("fake-media-data");
}
return {
downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("fake-media-data")),
downloadMediaMessage: vi.fn(() => fakeMediaStream()),
isJidGroup: vi.fn((jid: string | undefined | null) =>
typeof jid === "string" ? jid.endsWith("@g.us") : false,
),
+25 -13
View File
@@ -455,7 +455,14 @@ async function getFreePort(): Promise<number> {
});
}
async function runDirectPrompt(prompt: string): Promise<PromptResult> {
async function runDirectPrompt(
prompt: string,
options: {
claudeBin?: string;
shutdownWaitMs?: number;
timeoutMs?: number;
} = {},
): Promise<PromptResult> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-direct-prompt-probe-"));
const proxyPort = ENABLE_CAPTURE ? await getFreePort() : undefined;
const proxy =
@@ -466,17 +473,21 @@ async function runDirectPrompt(prompt: string): Promise<PromptResult> {
try {
const stdout: string[] = [];
const stderr: string[] = [];
const child = spawn(CLAUDE_BIN, [...DIRECT_CLAUDE_ARGS, prompt, USER_PROMPT], {
cwd: process.cwd(),
env: {
...process.env,
...(proxyPort ? { ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}` } : {}),
ANTHROPIC_API_KEY: "",
ANTHROPIC_API_KEY_OLD: "",
const child = spawn(
options.claudeBin ?? CLAUDE_BIN,
[...DIRECT_CLAUDE_ARGS, prompt, USER_PROMPT],
{
cwd: process.cwd(),
env: {
...process.env,
...(proxyPort ? { ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}` } : {}),
ANTHROPIC_API_KEY: "",
ANTHROPIC_API_KEY_OLD: "",
},
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
},
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
});
);
child.stdout.on("data", (chunk) => stdout.push(String(chunk)));
child.stderr.on("data", (chunk) => stderr.push(String(chunk)));
const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
@@ -490,7 +501,7 @@ async function runDirectPrompt(prompt: string): Promise<PromptResult> {
await waitForGatewayPromptChildTreeExit(
child,
exitPromise.then(() => undefined),
1_500,
options.shutdownWaitMs ?? 1_500,
);
};
const removeParentSignalHandlers = installGatewayPromptParentSignalHandlers(
@@ -505,7 +516,7 @@ async function runDirectPrompt(prompt: string): Promise<PromptResult> {
void stopDirectChild("SIGKILL").finally(() => {
resolve({ code: null, signal: "SIGKILL" });
});
}, TIMEOUT_MS);
}, options.timeoutMs ?? TIMEOUT_MS);
}),
]).finally(() => {
if (timeoutTimer) {
@@ -963,6 +974,7 @@ export const testing = {
readLogTail,
readRequestBody,
resolveAnthropicUpstreamUrl,
runDirectPrompt,
stopGatewayPromptChild,
summarizeCapture,
summarizeText,
+11 -1
View File
@@ -105,10 +105,20 @@ type CliOptions = {
const DEFAULT_RUNS = 5;
const DEFAULT_WARMUP = 1;
const DEFAULT_TIMEOUT_MS = 30_000;
const TIMEOUT_KILL_GRACE_MS = 1_000;
const DEFAULT_TIMEOUT_KILL_GRACE_MS = 1_000;
const TIMEOUT_KILL_GRACE_MS = resolveTimeoutKillGraceMs(process.env);
const PROCESS_GROUP_EXIT_POLL_MS = 25;
const DEFAULT_ENTRY = "openclaw.mjs";
const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__=";
function resolveTimeoutKillGraceMs(env: NodeJS.ProcessEnv): number {
const raw = env.VITEST ? env.OPENCLAW_TEST_CLI_STARTUP_TIMEOUT_KILL_GRACE_MS : undefined;
if (!raw || !/^\d+$/u.test(raw)) {
return DEFAULT_TIMEOUT_KILL_GRACE_MS;
}
const parsed = Number(raw);
return Number.isSafeInteger(parsed) ? parsed : DEFAULT_TIMEOUT_KILL_GRACE_MS;
}
const VALUE_FLAGS = new Set([
"--case",
"--compare-baseline",
+19 -2
View File
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
import { resolvePathEnvKey, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const CRABBOX_METADATA_PROBE_TIMEOUT_MS = 5_000;
const ignoreRepoBinary = process.env.OPENCLAW_CRABBOX_WRAPPER_IGNORE_REPO_BINARY === "1";
const repoLocal = ignoreRepoBinary ? null : resolveCrabboxBinary(process.env, process.platform);
const pathLocal = resolvePathBinary("crabbox", process.env, process.platform);
@@ -361,7 +362,7 @@ function checkedOutput(command, commandArgs) {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
timeout: 5_000,
timeout: resolveMetadataProbeTimeoutMs(process.env),
killSignal: "SIGKILL",
});
const timedOut = result.error?.name === "Error" && result.signal === "SIGKILL";
@@ -3467,7 +3468,7 @@ const child = spawn(childInvocation.command, childInvocation.args, {
env: childEnv,
windowsVerbatimArguments: childInvocation.windowsVerbatimArguments,
});
const childKillGraceMs = 5_000;
const childKillGraceMs = resolveChildKillGraceMs(process.env);
let childForceKillTimer;
let childTreeShutdownStarted = false;
if (fullCheckout) {
@@ -3604,3 +3605,19 @@ async function waitForChildTreeExit(childProcess, timeoutMs) {
}
return !childProcessTreeIsAlive(childProcess);
}
function resolveChildKillGraceMs(env) {
if (!env.VITEST || !env.OPENCLAW_TEST_CRABBOX_CHILD_KILL_GRACE_MS) {
return 5_000;
}
const value = Number.parseInt(env.OPENCLAW_TEST_CRABBOX_CHILD_KILL_GRACE_MS, 10);
return Number.isFinite(value) && value >= 0 ? value : 5_000;
}
function resolveMetadataProbeTimeoutMs(env) {
if (!env.VITEST || !env.OPENCLAW_TEST_CRABBOX_METADATA_PROBE_TIMEOUT_MS) {
return CRABBOX_METADATA_PROBE_TIMEOUT_MS;
}
const value = Number.parseInt(env.OPENCLAW_TEST_CRABBOX_METADATA_PROBE_TIMEOUT_MS, 10);
return Number.isFinite(value) && value > 0 ? value : CRABBOX_METADATA_PROBE_TIMEOUT_MS;
}
+12 -2
View File
@@ -551,14 +551,15 @@ async function shutdownActiveCommands(signal) {
return commandShutdownPromise;
}
const children = [...activeCommandChildren];
const killGraceMs = resolveCommandParentSignalKillGraceMs(process.env);
for (const child of children) {
signalProcessGroup(child, signal);
}
commandShutdownPromise = Promise.all(
children.map((child) =>
finishTimedOutCommandProcessTree(child, {
forceKillAt: Date.now() + COMMAND_PARENT_SIGNAL_KILL_GRACE_MS,
timeoutKillGraceMs: COMMAND_PARENT_SIGNAL_KILL_GRACE_MS,
forceKillAt: Date.now() + killGraceMs,
timeoutKillGraceMs: killGraceMs,
}),
),
).finally(() => {
@@ -568,6 +569,15 @@ async function shutdownActiveCommands(signal) {
return commandShutdownPromise;
}
function resolveCommandParentSignalKillGraceMs(env) {
const raw = env.VITEST && env.OPENCLAW_TEST_KITCHEN_SINK_PARENT_SIGNAL_KILL_GRACE_MS;
if (!raw) {
return COMMAND_PARENT_SIGNAL_KILL_GRACE_MS;
}
const value = Number.parseInt(raw, 10);
return Number.isFinite(value) && value >= 0 ? value : COMMAND_PARENT_SIGNAL_KILL_GRACE_MS;
}
async function waitForCommandProcessTreeExit(child, timeoutMs) {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
@@ -309,7 +309,11 @@ async function waitClickClackSocket() {
30,
"ClickClack websocket timeout seconds",
);
const deadline = Date.now() + timeoutSeconds * 1000;
await waitForClickClackSocket({ baseUrl, timeoutMs: timeoutSeconds * 1000 });
}
export async function waitForClickClackSocket({ baseUrl, timeoutMs, pollIntervalMs = 250 }) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const remainingMs = Math.max(1, deadline - Date.now());
const state = await withClickClackFixtureResponse(
@@ -329,7 +333,7 @@ async function waitClickClackSocket() {
}
}
await new Promise((resolve) => {
setTimeout(resolve, 250);
setTimeout(resolve, Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
});
}
throw new Error(`Timed out waiting for ClickClack websocket connection at ${baseUrl}`);
+7 -10
View File
@@ -104,10 +104,7 @@ function readPositiveInt(raw, fallback, label) {
function clampSecretProofTimerTimeoutMs(valueMs) {
const value = Number.isFinite(valueMs) ? valueMs : 1;
return Math.min(
Math.max(1, Math.floor(value)),
MAX_SECRET_PROOF_TIMER_TIMEOUT_MS,
);
return Math.min(Math.max(1, Math.floor(value)), MAX_SECRET_PROOF_TIMER_TIMEOUT_MS);
}
function readPositiveTimerMs(raw, fallback, label) {
@@ -361,6 +358,9 @@ async function cleanupEnv(root, options = {}) {
function runCommand(command, args, options = {}) {
const timeoutMs = clampSecretProofTimerTimeoutMs(options.timeoutMs ?? COMMAND_TIMEOUT_MS);
const timeoutKillGraceMs = clampSecretProofTimerTimeoutMs(
options.timeoutKillGraceMs ?? COMMAND_TIMEOUT_KILL_GRACE_MS,
);
return new Promise((resolve, reject) => {
const usesProcessGroup = options.detached ?? process.platform !== "win32";
const child = childProcess.spawn(command, args, {
@@ -379,11 +379,8 @@ function runCommand(command, args, options = {}) {
let killTimer;
let forceKillAt;
const armForceKill = () => {
forceKillAt ??= Date.now() + COMMAND_TIMEOUT_KILL_GRACE_MS;
killTimer ??= setTimeout(
() => terminateProcessTree(child, "SIGKILL"),
COMMAND_TIMEOUT_KILL_GRACE_MS,
);
forceKillAt ??= Date.now() + timeoutKillGraceMs;
killTimer ??= setTimeout(() => terminateProcessTree(child, "SIGKILL"), timeoutKillGraceMs);
killTimer.unref();
};
const abort = () => {
@@ -421,7 +418,7 @@ function runCommand(command, args, options = {}) {
const finishTerminatedTree = async () => {
await finishTimedOutCommandProcessTree(child, {
forceKillAt,
timeoutKillGraceMs: COMMAND_TIMEOUT_KILL_GRACE_MS,
timeoutKillGraceMs,
});
if (killTimer) {
clearTimeout(killTimer);
+20 -8
View File
@@ -118,6 +118,22 @@ docker_build_run_command() {
"$@"
}
docker_build_maybe_print_heartbeat() {
local label="$1"
local elapsed_seconds="$2"
local next_heartbeat="$3"
local log_file="$4"
if [ "$elapsed_seconds" -lt "$next_heartbeat" ]; then
return 1
fi
local log_bytes="0"
if [ -f "$log_file" ]; then
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
log_bytes="${log_bytes//[[:space:]]/}"
fi
echo "Docker build $label still running (${elapsed_seconds}s elapsed, ${log_bytes} log bytes captured)..."
}
docker_build_run_logged() {
local label="$1"
local timeout_value="$2"
@@ -195,18 +211,14 @@ docker_build_run_logged() {
docker_build_run_command "$timeout_value" "$@" >"$log_file" 2>&1 &
build_pid="$!"
while kill -0 "$build_pid" 2>/dev/null; do
/bin/sleep 1 &
# Poll promptly so short builds do not pay a one-second wrapper tax.
/bin/sleep 0.1 &
heartbeat_sleep_pid="$!"
wait "$heartbeat_sleep_pid" 2>/dev/null || true
heartbeat_sleep_pid=""
local elapsed_seconds=$((SECONDS - started_at))
if [ "$elapsed_seconds" -ge "$next_heartbeat" ] && kill -0 "$build_pid" 2>/dev/null; then
local log_bytes="0"
if [ -f "$log_file" ]; then
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
log_bytes="${log_bytes//[[:space:]]/}"
fi
echo "Docker build $label still running (${elapsed_seconds}s elapsed, ${log_bytes} log bytes captured)..."
if kill -0 "$build_pid" 2>/dev/null && \
docker_build_maybe_print_heartbeat "$label" "$elapsed_seconds" "$next_heartbeat" "$log_file"; then
next_heartbeat=$((elapsed_seconds + heartbeat_seconds))
fi
done
+20 -8
View File
@@ -65,6 +65,22 @@ run_logged_print() {
rm -f "$log_file"
}
docker_e2e_maybe_print_log_heartbeat() {
local label="$1"
local elapsed_seconds="$2"
local next_heartbeat="$3"
local log_file="$4"
if [ "$elapsed_seconds" -lt "$next_heartbeat" ]; then
return 1
fi
local log_bytes="0"
if [ -f "$log_file" ]; then
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
log_bytes="${log_bytes//[[:space:]]/}"
fi
echo "still running $label (${elapsed_seconds}s elapsed, ${log_bytes} log bytes captured)"
}
run_logged_print_heartbeat() {
local label="$1"
local interval_seconds="$2"
@@ -143,15 +159,11 @@ run_logged_print_heartbeat() {
local next_heartbeat=$interval_seconds
local status=0
while kill -0 "$command_pid" 2>/dev/null; do
/bin/sleep 1
# Poll promptly so short commands do not pay a one-second wrapper tax.
/bin/sleep 0.1
local elapsed_seconds=$((SECONDS - started_at))
if [ "$elapsed_seconds" -ge "$next_heartbeat" ] && kill -0 "$command_pid" 2>/dev/null; then
local log_bytes="0"
if [ -f "$log_file" ]; then
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
log_bytes="${log_bytes//[[:space:]]/}"
fi
echo "still running $label (${elapsed_seconds}s elapsed, ${log_bytes} log bytes captured)"
if kill -0 "$command_pid" 2>/dev/null && \
docker_e2e_maybe_print_log_heartbeat "$label" "$elapsed_seconds" "$next_heartbeat" "$log_file"; then
next_heartbeat=$((elapsed_seconds + interval_seconds))
fi
done
+8 -4
View File
@@ -133,6 +133,10 @@ type ClawHubRequestOptions = {
requestTimeoutMs?: number;
};
type ClawHubRetryOptions = ClawHubRequestOptions & {
sleep?: (ms: number) => Promise<void>;
};
async function fetchClawHubRequest(
url: URL,
options: ClawHubRequestOptions = {},
@@ -485,10 +489,8 @@ async function doesClawHubPackageExist(
async function hasClawHubTrustedPublisher(
packageName: string,
options: {
fetchImpl?: typeof fetch;
options: ClawHubRetryOptions & {
registryBaseUrl?: string;
requestTimeoutMs?: number;
} = {},
): Promise<boolean> {
const url = new URL(
@@ -537,7 +539,7 @@ async function hasClawHubTrustedPublisher(
}
await response.body?.cancel().catch(() => undefined);
await delay(clawHubRetryDelayMs(response, attempt));
await (options.sleep ?? delay)(clawHubRetryDelayMs(response, attempt));
}
}
@@ -600,6 +602,7 @@ export async function collectPluginClawHubReleasePlan(params?: {
fetchImpl?: typeof fetch;
requestTimeoutMs?: number;
resolveLatestVersion?: NpmLatestVersionResolver;
sleep?: (ms: number) => Promise<void>;
}): Promise<PluginReleasePlan> {
const rootDir = params?.rootDir;
const selection = params?.selection ?? [];
@@ -648,6 +651,7 @@ export async function collectPluginClawHubReleasePlan(params?: {
registryBaseUrl: params?.registryBaseUrl,
fetchImpl: params?.fetchImpl,
requestTimeoutMs: params?.requestTimeoutMs,
sleep: params?.sleep,
})
: false;
const alreadyPublished = packageExists
+91 -43
View File
@@ -74,6 +74,7 @@ const SOURCE_ROOTS: Record<NativeI18nSurface, string[]> = {
const ANDROID_EXTENSIONS = new Set([".kt", ".kts"]);
const APPLE_EXTENSIONS = new Set([".swift", ".plist"]);
const NATIVE_FORMAT_RE = /%(?:\d+\$)?[@a-z]/giu;
const NATIVE_SOURCE_READ_CONCURRENCY = 32;
const APPLE_UI_MULTILINE_CALLS =
/(?:Text|Label|Button|TextField|SecureField|Picker|Section|LabeledContent|Toggle|Menu|ShareLink|Link|TextEditor|ProgressView|Gauge|DisclosureGroup|ControlGroup|DatePicker|Stepper)\s*\(\s*"""([\s\S]*?)"""/gu;
const APPLE_LOCALIZED_STRING_CALLS =
@@ -564,6 +565,27 @@ function normalizeSource(source: string): string {
return source;
}
function identifierBefore(source: string, offset: number): string | null {
let cursor = offset - 1;
while (cursor >= 0 && source.charCodeAt(cursor) <= 32) {
cursor -= 1;
}
const end = cursor + 1;
while (cursor >= 0 && (isAsciiAlphaNumeric(source[cursor]) || source[cursor] === "_")) {
cursor -= 1;
}
const start = cursor + 1;
if (
start === end ||
(!isAsciiLowercaseLetter(source[start]) &&
!isAsciiUppercaseLetter(source[start]) &&
source[start] !== "_")
) {
return null;
}
return source.slice(start, end);
}
function enclosingCallName(source: string, offset: number): string | null {
let depth = 0;
for (let index = offset - 1; index >= 0; index -= 1) {
@@ -578,7 +600,7 @@ function enclosingCallName(source: string, offset: number): string | null {
depth -= 1;
continue;
}
return source.slice(0, index).match(/([A-Za-z_][A-Za-z0-9_]*)\s*$/u)?.[1] ?? null;
return identifierBefore(source, index);
}
return null;
}
@@ -837,36 +859,31 @@ function extractCandidates(
return entries;
}
async function walkFiles(
root: string,
surface: NativeI18nSurface,
out: string[] = [],
): Promise<string[]> {
async function walkFiles(root: string, surface: NativeI18nSurface): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(root, entry.name);
if (entry.isDirectory()) {
if (GENERATED_PATH_RE.test(fullPath) || EXCLUDED_PATH_RE.test(fullPath)) {
continue;
const nested = await Promise.all(
entries.map(async (entry): Promise<string[]> => {
const fullPath = path.join(root, entry.name);
if (entry.isDirectory()) {
if (GENERATED_PATH_RE.test(fullPath) || EXCLUDED_PATH_RE.test(fullPath)) {
return [];
}
return await walkFiles(fullPath, surface);
}
await walkFiles(fullPath, surface, out);
continue;
}
const extension = path.extname(entry.name);
const isAndroidValuesXml =
surface === "android" &&
extension === ".xml" &&
path.dirname(fullPath).endsWith(`${path.sep}res${path.sep}values`);
const allowed = surface === "apple" ? APPLE_EXTENSIONS : ANDROID_EXTENSIONS;
if (
entry.isFile() &&
(allowed.has(extension) || isAndroidValuesXml) &&
!EXCLUDED_FILE_RE.test(entry.name)
) {
out.push(fullPath);
}
}
return out;
const extension = path.extname(entry.name);
const isAndroidValuesXml =
surface === "android" &&
extension === ".xml" &&
path.dirname(fullPath).endsWith(`${path.sep}res${path.sep}values`);
const allowed = surface === "apple" ? APPLE_EXTENSIONS : ANDROID_EXTENSIONS;
return entry.isFile() &&
(allowed.has(extension) || isAndroidValuesXml) &&
!EXCLUDED_FILE_RE.test(entry.name)
? [fullPath]
: [];
}),
);
return nested.flat();
}
function withIds(entries: Candidate[]): NativeI18nEntry[] {
@@ -899,24 +916,55 @@ function withIds(entries: Candidate[]): NativeI18nEntry[] {
});
}
async function mapWithConcurrency<T, R>(
values: readonly T[],
limit: number,
run: (value: T) => Promise<R>,
): Promise<R[]> {
const results = Array<R>(values.length);
let nextIndex = 0;
const workerCount = Math.min(limit, values.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
for (;;) {
const index = nextIndex;
nextIndex += 1;
if (index >= values.length) {
return;
}
results[index] = await run(values[index]);
}
}),
);
return results;
}
export async function collectNativeI18nEntries(): Promise<NativeI18nEntry[]> {
const sources: Array<{
const roots = (["android", "apple"] as const).flatMap((surface) =>
SOURCE_ROOTS[surface].map((sourceRoot) => ({ sourceRoot, surface })),
);
const filesByRoot = await Promise.all(
roots.map(async ({ sourceRoot, surface }) => ({
files: (await walkFiles(sourceRoot, surface)).toSorted(),
surface,
})),
);
const sources = await mapWithConcurrency(
filesByRoot.flatMap(({ files, surface }) => files.map((filePath) => ({ filePath, surface }))),
NATIVE_SOURCE_READ_CONCURRENCY,
async ({ filePath, surface }) => ({
repoPath: path.relative(ROOT, filePath).split(path.sep).join("/"),
source: await readFile(filePath, "utf8"),
surface,
}),
);
const typedSources: Array<{
repoPath: string;
source: string;
surface: NativeI18nSurface;
}> = [];
for (const surface of ["android", "apple"] as const) {
for (const sourceRoot of SOURCE_ROOTS[surface]) {
const files = await walkFiles(sourceRoot, surface);
for (const filePath of files.toSorted()) {
const source = await readFile(filePath, "utf8");
const repoPath = path.relative(ROOT, filePath).split(path.sep).join("/");
sources.push({ repoPath, source, surface });
}
}
}
}> = sources;
const uiCallNames = new Set([...APPLE_BUILTIN_UI_TYPES, ...ANDROID_BUILTIN_UI_CALLS]);
for (const { source, surface } of sources) {
for (const { source, surface } of typedSources) {
if (surface === "android") {
for (const match of source.matchAll(ANDROID_COMPOSABLE_FUNCTION)) {
if (match[1]) {
@@ -933,7 +981,7 @@ export async function collectNativeI18nEntries(): Promise<NativeI18nEntry[]> {
}
}
}
const entries = sources.flatMap(({ repoPath, source, surface }) =>
const entries = typedSources.flatMap(({ repoPath, source, surface }) =>
extractCandidates(surface, repoPath, source, uiCallNames),
);
return withIds(entries);
@@ -0,0 +1,132 @@
// Lightweight CLI contract for the issue #78851 model-resolution profiler.
export type Issue78851ModelResolutionOptions = {
agentCount: number;
cpuProfDir?: string;
cpuProfOutput?: string;
json: boolean;
keepTemp: boolean;
lookupsPerRun: number;
modelsPerProvider: number;
output?: string;
providers: number;
runs: number;
runtimeHooks: boolean;
warmup: number;
};
const BOOLEAN_FLAGS = new Set(["--help", "-h", "--json", "--keep-temp", "--runtime-hooks"]);
const VALUE_FLAGS = new Set([
"--agents",
"--cpu-prof-dir",
"--cpu-prof-output",
"--lookups",
"--models-per-provider",
"--output",
"--providers",
"--runs",
"--warmup",
]);
export class Issue78851CliArgumentError extends Error {
override name = "Issue78851CliArgumentError";
}
function parseFlagValue(flag: string, args: readonly string[]): string | undefined {
const index = args.indexOf(flag);
if (index === -1) {
return undefined;
}
const value = args[index + 1];
if (!value || value.startsWith("-")) {
throw new Issue78851CliArgumentError(`${flag} requires a value`);
}
return value;
}
function parseInteger(
flag: string,
fallback: number,
args: readonly string[],
minimum: number,
label: string,
): number {
const raw = parseFlagValue(flag, args);
if (!raw) {
return fallback;
}
const value = Number(raw);
if (!Number.isInteger(value) || value < minimum) {
throw new Issue78851CliArgumentError(`${flag} must be a ${label} integer`);
}
return value;
}
function validateArgs(args: readonly string[]): void {
const seenValueFlags = new Set<string>();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
if (BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (!VALUE_FLAGS.has(arg)) {
throw new Issue78851CliArgumentError(`Unknown argument: ${arg}`);
}
if (seenValueFlags.has(arg)) {
throw new Issue78851CliArgumentError(`${arg} was provided more than once`);
}
seenValueFlags.add(arg);
const value = args[index + 1];
if (!value || value.startsWith("-")) {
throw new Issue78851CliArgumentError(`${arg} requires a value`);
}
index += 1;
}
}
export function issue78851ModelResolutionHelpRequested(args: readonly string[]): boolean {
return args.includes("--help") || args.includes("-h");
}
export function parseIssue78851ModelResolutionOptions(
args: readonly string[],
): Issue78851ModelResolutionOptions {
validateArgs(args);
return {
agentCount: parseInteger("--agents", 8, args, 1, "positive"),
cpuProfDir: parseFlagValue("--cpu-prof-dir", args),
cpuProfOutput: parseFlagValue("--cpu-prof-output", args),
json: args.includes("--json"),
keepTemp: args.includes("--keep-temp"),
lookupsPerRun: parseInteger("--lookups", 32, args, 1, "positive"),
modelsPerProvider: parseInteger("--models-per-provider", 16, args, 1, "positive"),
output: parseFlagValue("--output", args),
providers: parseInteger("--providers", 48, args, 1, "positive"),
runs: parseInteger("--runs", 8, args, 1, "positive"),
runtimeHooks: args.includes("--runtime-hooks"),
warmup: parseInteger("--warmup", 1, args, 0, "non-negative"),
};
}
export function issue78851ModelResolutionUsage(): string {
return `OpenClaw issue #78851 model-resolution profiler
Usage:
pnpm perf:issue-78851 -- [options]
node --import tsx scripts/perf/issue-78851-model-resolution.ts [options]
Options:
--providers <n> Synthetic configured providers (default: 48)
--models-per-provider <n> Models per provider (default: 16)
--agents <n> Agent configs/fallback chains (default: 8)
--lookups <n> resolveModelAsync calls per phase (default: 32)
--runs <n> Measured runs (default: 8)
--warmup <n> Warmup runs before measurement (default: 1)
--cpu-prof-dir <dir> Write a V8 .cpuprofile for the measured loop
--cpu-prof-output <path> Write the V8 .cpuprofile to this exact path
--runtime-hooks Include provider runtime hook resolution
--output <path> Write JSON report
--json Print JSON report
--keep-temp Keep generated temp state
--help, -h Show this text
`;
}
+11 -148
View File
@@ -10,38 +10,13 @@ import {
resetModelsJsonReadyCacheForTest,
} from "../../src/agents/models-config.js";
import type { OpenClawConfig } from "../../src/config/types.openclaw.js";
type Options = {
agentCount: number;
cpuProfDir?: string;
cpuProfOutput?: string;
json: boolean;
keepTemp: boolean;
lookupsPerRun: number;
modelsPerProvider: number;
output?: string;
providers: number;
runs: number;
runtimeHooks: boolean;
warmup: number;
};
const BOOLEAN_FLAGS = new Set(["--help", "-h", "--json", "--keep-temp", "--runtime-hooks"]);
const VALUE_FLAGS = new Set([
"--agents",
"--cpu-prof-dir",
"--cpu-prof-output",
"--lookups",
"--models-per-provider",
"--output",
"--providers",
"--runs",
"--warmup",
]);
class CliArgumentError extends Error {
override name = "CliArgumentError";
}
import {
Issue78851CliArgumentError,
issue78851ModelResolutionHelpRequested,
issue78851ModelResolutionUsage,
parseIssue78851ModelResolutionOptions,
type Issue78851ModelResolutionOptions as Options,
} from "./issue-78851-model-resolution-cli.js";
type PhaseSample = {
ensureMs: number;
@@ -85,117 +60,6 @@ type Report = {
cpuProfilePath?: string;
};
function parseFlagValue(flag: string, args = process.argv.slice(2)): string | undefined {
const index = args.indexOf(flag);
if (index === -1) {
return undefined;
}
const value = args[index + 1];
if (!value || value.startsWith("-")) {
throw new CliArgumentError(`${flag} requires a value`);
}
return value;
}
function hasFlag(flag: string, args = process.argv.slice(2)): boolean {
return args.includes(flag);
}
function parsePositiveInt(flag: string, fallback: number, args = process.argv.slice(2)): number {
const raw = parseFlagValue(flag, args);
if (!raw) {
return fallback;
}
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
throw new CliArgumentError(`${flag} must be a positive integer`);
}
if (!Number.isInteger(value)) {
throw new CliArgumentError(`${flag} must be a positive integer`);
}
return value;
}
function parseNonNegativeInt(flag: string, fallback: number, args = process.argv.slice(2)): number {
const raw = parseFlagValue(flag, args);
if (!raw) {
return fallback;
}
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
throw new CliArgumentError(`${flag} must be a non-negative integer`);
}
if (!Number.isInteger(value)) {
throw new CliArgumentError(`${flag} must be a non-negative integer`);
}
return value;
}
function validateCliArgs(args = process.argv.slice(2)): void {
const seenValueFlags = new Set<string>();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
if (BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (VALUE_FLAGS.has(arg)) {
if (seenValueFlags.has(arg)) {
throw new CliArgumentError(`${arg} was provided more than once`);
}
seenValueFlags.add(arg);
const value = args[index + 1];
if (!value || value.startsWith("-")) {
throw new CliArgumentError(`${arg} requires a value`);
}
index += 1;
continue;
}
throw new CliArgumentError(`Unknown argument: ${arg}`);
}
}
function parseOptions(args = process.argv.slice(2)): Options {
validateCliArgs(args);
return {
agentCount: parsePositiveInt("--agents", 8, args),
cpuProfDir: parseFlagValue("--cpu-prof-dir", args),
cpuProfOutput: parseFlagValue("--cpu-prof-output", args),
json: hasFlag("--json", args),
keepTemp: hasFlag("--keep-temp", args),
lookupsPerRun: parsePositiveInt("--lookups", 32, args),
modelsPerProvider: parsePositiveInt("--models-per-provider", 16, args),
output: parseFlagValue("--output", args),
providers: parsePositiveInt("--providers", 48, args),
runs: parsePositiveInt("--runs", 8, args),
runtimeHooks: hasFlag("--runtime-hooks", args),
warmup: parseNonNegativeInt("--warmup", 1, args),
};
}
function printUsage(): void {
process.stdout.write(`OpenClaw issue #78851 model-resolution profiler
Usage:
pnpm perf:issue-78851 -- [options]
node --import tsx scripts/perf/issue-78851-model-resolution.ts [options]
Options:
--providers <n> Synthetic configured providers (default: 48)
--models-per-provider <n> Models per provider (default: 16)
--agents <n> Agent configs/fallback chains (default: 8)
--lookups <n> resolveModelAsync calls per phase (default: 32)
--runs <n> Measured runs (default: 8)
--warmup <n> Warmup runs before measurement (default: 1)
--cpu-prof-dir <dir> Write a V8 .cpuprofile for the measured loop
--cpu-prof-output <path> Write the V8 .cpuprofile to this exact path
--runtime-hooks Include provider runtime hook resolution
--output <path> Write JSON report
--json Print JSON report
--keep-temp Keep generated temp state
--help, -h Show this text
`);
}
function round(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -478,12 +342,11 @@ function printHuman(report: Report, cpuProfilePath?: string): void {
async function main(): Promise<void> {
const args = process.argv.slice(2);
validateCliArgs(args);
if (hasFlag("--help", args) || hasFlag("-h", args)) {
printUsage();
const options = parseIssue78851ModelResolutionOptions(args);
if (issue78851ModelResolutionHelpRequested(args)) {
process.stdout.write(issue78851ModelResolutionUsage());
return;
}
const options = parseOptions(args);
const tempRoot = await mkdtemp(path.join(tmpdir(), "openclaw-issue-78851-"));
const workspaceDir = path.join(tempRoot, "workspace");
await mkdir(workspaceDir, { recursive: true });
@@ -547,7 +410,7 @@ async function main(): Promise<void> {
}
main().catch((error: unknown) => {
if (error instanceof CliArgumentError) {
if (error instanceof Issue78851CliArgumentError) {
process.stderr.write(`${error.message}\n`);
process.exit(1);
}
+209 -118
View File
@@ -3,7 +3,7 @@
// Reports plugin SDK export surface metadata.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import ts from "typescript";
import {
deprecatedBarrelPluginSdkEntrypoints,
@@ -25,7 +25,7 @@ Options:
`;
}
function parseArgs(argv) {
export function parsePluginSdkSurfaceReportArgs(argv) {
const args = { check: false, help: false };
for (const arg of argv) {
if (arg === "--check") {
@@ -40,28 +40,14 @@ function parseArgs(argv) {
}
return args;
}
let cliArgs;
try {
cliArgs = parseArgs(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
if (cliArgs.help) {
process.stdout.write(usage());
process.exit(0);
}
const checkOnly = cliArgs.check;
const publicEntrypointSet = new Set(publicPluginSdkEntrypoints);
const localOnlyEntrypointSet = new Set(privateLocalOnlyPluginSdkEntrypoints);
const deprecatedPublicEntrypointSet = new Set(deprecatedPublicPluginSdkEntrypoints);
const deprecatedBarrelEntrypointSet = new Set(deprecatedBarrelPluginSdkEntrypoints);
const forbiddenPublicSubpaths = new Set(["test-utils"]);
function readBudgetEnv(name, fallback) {
const raw = process.env[name];
export function readPluginSdkSurfaceBudgetEnv(name, fallback, env = process.env) {
const raw = env[name];
if (raw === undefined) {
return fallback;
}
@@ -76,8 +62,8 @@ function readBudgetEnv(name, fallback) {
return parsed;
}
function readEntrypointBudgetEnv(name, fallback) {
const raw = process.env[name];
export function readPluginSdkEntrypointBudgetEnv(name, fallback, env = process.env) {
const raw = env[name];
if (raw === undefined) {
return fallback;
}
@@ -101,7 +87,7 @@ function readEntrypointBudgetEnv(name, fallback) {
return Object.freeze({ ...fallback, ...overrides });
}
const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
export const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
core: 2,
health: 1,
lmstudio: 1,
@@ -197,29 +183,40 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
zod: 282,
});
let budgets;
let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 324),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10429),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5206),
publicDeprecatedExports: readBudgetEnv(
export function readPluginSdkSurfaceBudgets(env = process.env) {
const budgets = {
publicEntrypoints: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS",
324,
env,
),
publicExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
10429,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
5206,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3261,
env,
),
publicWildcardReexports: readBudgetEnv(
publicWildcardReexports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS",
212,
env,
),
};
publicDeprecatedExportsByEntrypointBudget = readEntrypointBudgetEnv(
const publicDeprecatedExportsByEntrypointBudget = readPluginSdkEntrypointBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS_BY_ENTRYPOINT",
defaultPublicDeprecatedExportsByEntrypointBudget,
env,
);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
return { budgets, publicDeprecatedExportsByEntrypointBudget };
}
function entrypointPath(entrypoint) {
@@ -360,6 +357,36 @@ function collectExportStats(entrypoints) {
return { byEntrypoint, totals };
}
function selectExportStats(scannedStats, entrypoints) {
const byEntrypoint = new Map();
const totals = {
entrypoints: entrypoints.length,
exports: 0,
callableExports: 0,
deprecatedExports: 0,
deprecatedCallableExports: 0,
uniqueExports: 0,
uniqueCallableExports: 0,
};
for (const entrypoint of entrypoints) {
const stats = scannedStats.byEntrypoint.get(entrypoint) ?? {
exports: 0,
callableExports: 0,
deprecatedExports: 0,
deprecatedCallableExports: 0,
};
byEntrypoint.set(entrypoint, stats);
totals.exports += stats.exports;
totals.callableExports += stats.callableExports;
totals.deprecatedExports += stats.deprecatedExports;
totals.deprecatedCallableExports += stats.deprecatedCallableExports;
}
// Export identities are entrypoint-qualified, so the selected totals are unique.
totals.uniqueExports = totals.exports;
totals.uniqueCallableExports = totals.callableExports;
return { byEntrypoint, totals };
}
function formatStats(label, stats) {
return [
`${label}:`,
@@ -372,10 +399,10 @@ function formatStats(label, stats) {
].join("\n");
}
function collectDeprecatedEntrypointBudgetFailures(byEntrypoint) {
function collectDeprecatedEntrypointBudgetFailures(byEntrypoint, entrypointBudgets) {
const failures = [];
for (const [entrypoint, stats] of byEntrypoint) {
const budget = publicDeprecatedExportsByEntrypointBudget[entrypoint] ?? 0;
const budget = entrypointBudgets[entrypoint] ?? 0;
if (stats.deprecatedExports > budget) {
failures.push(
`public deprecated exports in ${entrypoint} ${stats.deprecatedExports} > ${budget}`,
@@ -385,95 +412,159 @@ function collectDeprecatedEntrypointBudgetFailures(byEntrypoint) {
return failures;
}
const allStats = collectExportStats(pluginSdkEntrypoints);
const publicStats = collectExportStats(publicPluginSdkEntrypoints);
const localOnlyStats = collectExportStats(privateLocalOnlyPluginSdkEntrypoints);
const publicWildcards = countWildcardReexports(publicPluginSdkEntrypoints);
const packageExportedSubpaths = readPackageExportedSubpaths();
const leakedForbiddenExports = packageExportedSubpaths.filter((subpath) =>
forbiddenPublicSubpaths.has(subpath),
);
const localOnlyStillPublic = privateLocalOnlyPluginSdkEntrypoints.filter((entrypoint) =>
publicEntrypointSet.has(entrypoint),
);
const localOnlyMissingFromInventory = [...localOnlyEntrypointSet].filter(
(entrypoint) => !pluginSdkEntrypoints.includes(entrypoint),
);
const deprecatedMissingFromPublic = [...deprecatedPublicEntrypointSet].filter(
(entrypoint) => !publicEntrypointSet.has(entrypoint),
);
const deprecatedBarrelMissingFromInventory = [...deprecatedBarrelEntrypointSet].filter(
(entrypoint) => !pluginSdkEntrypoints.includes(entrypoint),
);
const deprecatedBarrelWithoutWildcard = [...deprecatedBarrelEntrypointSet].filter((entrypoint) => {
const source = fs.readFileSync(entrypointPath(entrypoint), "utf8");
return !/^\s*export\s+(?:type\s+)?\*\s+from\s+["'][^"']+["']/mu.test(source);
});
console.log(formatStats("all SDK entrypoints", allStats.totals));
console.log(formatStats("public package SDK entrypoints", publicStats.totals));
console.log(formatStats("local-only SDK entrypoints", localOnlyStats.totals));
console.log(`deprecated public subpaths: ${deprecatedPublicPluginSdkEntrypoints.length}`);
console.log(`deprecated barrel subpaths: ${deprecatedBarrelPluginSdkEntrypoints.length}`);
console.log(`public wildcard reexports: ${publicWildcards.count}`);
console.log(`package-exported forbidden subpaths: ${leakedForbiddenExports.length}`);
const failures = [];
if (publicPluginSdkEntrypoints.length > budgets.publicEntrypoints) {
failures.push(
`public entrypoints ${publicPluginSdkEntrypoints.length} > ${budgets.publicEntrypoints}`,
export function collectPluginSdkSurfaceReport() {
const scannedEntrypoints = [
...new Set([
...pluginSdkEntrypoints,
...publicPluginSdkEntrypoints,
...privateLocalOnlyPluginSdkEntrypoints,
]),
];
const scannedStats = collectExportStats(scannedEntrypoints);
const allStats = selectExportStats(scannedStats, pluginSdkEntrypoints);
const publicStats = selectExportStats(scannedStats, publicPluginSdkEntrypoints);
const localOnlyStats = selectExportStats(scannedStats, privateLocalOnlyPluginSdkEntrypoints);
const publicWildcards = countWildcardReexports(publicPluginSdkEntrypoints);
const leakedForbiddenExports = readPackageExportedSubpaths().filter((subpath) =>
forbiddenPublicSubpaths.has(subpath),
);
}
if (publicStats.totals.exports > budgets.publicExports) {
failures.push(`public exports ${publicStats.totals.exports} > ${budgets.publicExports}`);
}
if (publicStats.totals.callableExports > budgets.publicFunctionExports) {
failures.push(
`public callable exports ${publicStats.totals.callableExports} > ${budgets.publicFunctionExports}`,
const localOnlyStillPublic = privateLocalOnlyPluginSdkEntrypoints.filter((entrypoint) =>
publicEntrypointSet.has(entrypoint),
);
}
if (publicStats.totals.deprecatedExports > budgets.publicDeprecatedExports) {
failures.push(
`public deprecated exports ${publicStats.totals.deprecatedExports} > ${budgets.publicDeprecatedExports}`,
const localOnlyMissingFromInventory = [...localOnlyEntrypointSet].filter(
(entrypoint) => !pluginSdkEntrypoints.includes(entrypoint),
);
}
failures.push(...collectDeprecatedEntrypointBudgetFailures(publicStats.byEntrypoint));
if (publicWildcards.count > budgets.publicWildcardReexports) {
failures.push(
`public wildcard reexports ${publicWildcards.count} > ${budgets.publicWildcardReexports}`,
const deprecatedMissingFromPublic = [...deprecatedPublicEntrypointSet].filter(
(entrypoint) => !publicEntrypointSet.has(entrypoint),
);
}
if (leakedForbiddenExports.length > 0) {
failures.push(`forbidden public subpaths: ${leakedForbiddenExports.join(", ")}`);
}
if (localOnlyStillPublic.length > 0) {
failures.push(`local-only entrypoints still public: ${localOnlyStillPublic.join(", ")}`);
}
if (localOnlyMissingFromInventory.length > 0) {
failures.push(
`local-only entrypoints missing from inventory: ${localOnlyMissingFromInventory.join(", ")}`,
const deprecatedBarrelMissingFromInventory = [...deprecatedBarrelEntrypointSet].filter(
(entrypoint) => !pluginSdkEntrypoints.includes(entrypoint),
);
}
if (deprecatedMissingFromPublic.length > 0) {
failures.push(
`deprecated public entrypoints missing from package surface: ${deprecatedMissingFromPublic.join(", ")}`,
);
}
if (deprecatedBarrelMissingFromInventory.length > 0) {
failures.push(
`deprecated barrel entrypoints missing from inventory: ${deprecatedBarrelMissingFromInventory.join(", ")}`,
);
}
if (deprecatedBarrelWithoutWildcard.length > 0) {
failures.push(
`deprecated barrel entrypoints without wildcard exports: ${deprecatedBarrelWithoutWildcard.join(", ")}`,
const deprecatedBarrelWithoutWildcard = [...deprecatedBarrelEntrypointSet].filter(
(entrypoint) => {
const source = fs.readFileSync(entrypointPath(entrypoint), "utf8");
return !/^\s*export\s+(?:type\s+)?\*\s+from\s+["'][^"']+["']/mu.test(source);
},
);
return {
allStats,
deprecatedBarrelMissingFromInventory,
deprecatedBarrelWithoutWildcard,
deprecatedMissingFromPublic,
leakedForbiddenExports,
localOnlyMissingFromInventory,
localOnlyStats,
localOnlyStillPublic,
publicStats,
publicWildcards,
};
}
if (checkOnly && failures.length > 0) {
console.error("plugin SDK surface budget failed:");
for (const failure of failures) {
console.error(`- ${failure}`);
export function evaluatePluginSdkSurfaceReport(
report,
{ budgets, publicDeprecatedExportsByEntrypointBudget },
) {
const failures = [];
if (publicPluginSdkEntrypoints.length > budgets.publicEntrypoints) {
failures.push(
`public entrypoints ${publicPluginSdkEntrypoints.length} > ${budgets.publicEntrypoints}`,
);
}
if (report.publicStats.totals.exports > budgets.publicExports) {
failures.push(`public exports ${report.publicStats.totals.exports} > ${budgets.publicExports}`);
}
if (report.publicStats.totals.callableExports > budgets.publicFunctionExports) {
failures.push(
`public callable exports ${report.publicStats.totals.callableExports} > ${budgets.publicFunctionExports}`,
);
}
if (report.publicStats.totals.deprecatedExports > budgets.publicDeprecatedExports) {
failures.push(
`public deprecated exports ${report.publicStats.totals.deprecatedExports} > ${budgets.publicDeprecatedExports}`,
);
}
failures.push(
...collectDeprecatedEntrypointBudgetFailures(
report.publicStats.byEntrypoint,
publicDeprecatedExportsByEntrypointBudget,
),
);
if (report.publicWildcards.count > budgets.publicWildcardReexports) {
failures.push(
`public wildcard reexports ${report.publicWildcards.count} > ${budgets.publicWildcardReexports}`,
);
}
if (report.leakedForbiddenExports.length > 0) {
failures.push(`forbidden public subpaths: ${report.leakedForbiddenExports.join(", ")}`);
}
if (report.localOnlyStillPublic.length > 0) {
failures.push(`local-only entrypoints still public: ${report.localOnlyStillPublic.join(", ")}`);
}
if (report.localOnlyMissingFromInventory.length > 0) {
failures.push(
`local-only entrypoints missing from inventory: ${report.localOnlyMissingFromInventory.join(", ")}`,
);
}
if (report.deprecatedMissingFromPublic.length > 0) {
failures.push(
`deprecated public entrypoints missing from package surface: ${report.deprecatedMissingFromPublic.join(", ")}`,
);
}
if (report.deprecatedBarrelMissingFromInventory.length > 0) {
failures.push(
`deprecated barrel entrypoints missing from inventory: ${report.deprecatedBarrelMissingFromInventory.join(", ")}`,
);
}
if (report.deprecatedBarrelWithoutWildcard.length > 0) {
failures.push(
`deprecated barrel entrypoints without wildcard exports: ${report.deprecatedBarrelWithoutWildcard.join(", ")}`,
);
}
return failures;
}
function renderPluginSdkSurfaceReport(report) {
return [
formatStats("all SDK entrypoints", report.allStats.totals),
formatStats("public package SDK entrypoints", report.publicStats.totals),
formatStats("local-only SDK entrypoints", report.localOnlyStats.totals),
`deprecated public subpaths: ${deprecatedPublicPluginSdkEntrypoints.length}`,
`deprecated barrel subpaths: ${deprecatedBarrelPluginSdkEntrypoints.length}`,
`public wildcard reexports: ${report.publicWildcards.count}`,
`package-exported forbidden subpaths: ${report.leakedForbiddenExports.length}`,
].join("\n");
}
function main(argv = process.argv.slice(2), env = process.env) {
const cliArgs = parsePluginSdkSurfaceReportArgs(argv);
if (cliArgs.help) {
process.stdout.write(usage());
return 0;
}
const budgetConfig = readPluginSdkSurfaceBudgets(env);
const report = collectPluginSdkSurfaceReport();
process.stdout.write(`${renderPluginSdkSurfaceReport(report)}\n`);
const failures = evaluatePluginSdkSurfaceReport(report, budgetConfig);
if (cliArgs.check && failures.length > 0) {
process.stderr.write(`plugin SDK surface budget failed:\n`);
for (const failure of failures) {
process.stderr.write(`- ${failure}\n`);
}
return 1;
}
return 0;
}
const isMain =
typeof process.argv[1] === "string" &&
process.argv[1].length > 0 &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
if (isMain) {
try {
process.exitCode = main();
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
process.exit(1);
}
@@ -17,7 +17,7 @@ const ROOT_SHIMS_MAX_OLD_SPACE_SIZE =
process.env.OPENCLAW_ROOT_SHIMS_MAX_OLD_SPACE_SIZE?.trim() || "8192";
const ROOT_SHIMS_NODE_OPTIONS =
`${process.env.NODE_OPTIONS ?? ""} --max-old-space-size=${ROOT_SHIMS_MAX_OLD_SPACE_SIZE}`.trim();
const NODE_STEP_ABORT_KILL_GRACE_MS = 1_000;
const DEFAULT_NODE_STEP_ABORT_KILL_GRACE_MS = 1_000;
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
const NODE_STEP_PARENT_SIGNALS = ["SIGHUP", "SIGINT", "SIGTERM"];
const NODE_STEP_PARENT_SIGNAL_EXIT_CODES = new Map([
@@ -25,7 +25,7 @@ const NODE_STEP_PARENT_SIGNAL_EXIT_CODES = new Map([
["SIGINT", 130],
["SIGTERM", 143],
]);
const ACTIVE_NODE_STEP_KILLERS = new Set();
const ACTIVE_NODE_STEP_KILLERS = new Map();
let nodeStepParentSignalForwardersInstalled = false;
let exitingAfterParentSignal = false;
let parentSignalExitCode = 1;
@@ -475,11 +475,17 @@ export function signalNodeStep(
}
function signalActiveNodeSteps(signal) {
for (const killNodeStep of ACTIVE_NODE_STEP_KILLERS) {
for (const killNodeStep of ACTIVE_NODE_STEP_KILLERS.keys()) {
killNodeStep(signal);
}
}
function activeNodeStepKillGraceMs() {
return ACTIVE_NODE_STEP_KILLERS.size > 0
? Math.max(...ACTIVE_NODE_STEP_KILLERS.values())
: DEFAULT_NODE_STEP_ABORT_KILL_GRACE_MS;
}
function installNodeStepParentSignalForwarders() {
if (nodeStepParentSignalForwardersInstalled) {
return;
@@ -497,7 +503,7 @@ function installNodeStepParentSignalForwarders() {
signalActiveNodeSteps(signal);
parentSignalExitTimer ??= setTimeout(
() => process.exit(parentSignalExitCode),
NODE_STEP_ABORT_KILL_GRACE_MS,
activeNodeStepKillGraceMs(),
);
});
}
@@ -519,6 +525,10 @@ function resolveNodeStepTimerTimeoutMs(valueMs) {
*/
export function runNodeStep(label, args, timeoutMs, params = {}) {
const resolvedTimeoutMs = resolveNodeStepTimerTimeoutMs(timeoutMs);
const abortKillGraceMs = Math.max(
0,
Math.floor(params.abortKillGraceMs ?? DEFAULT_NODE_STEP_ABORT_KILL_GRACE_MS),
);
const abortController = params.abortController;
const spawnImpl = params.spawnImpl ?? spawn;
installNodeStepParentSignalForwarders();
@@ -571,18 +581,18 @@ export function runNodeStep(label, args, timeoutMs, params = {}) {
await waitForProcessGroupExit(100);
}
};
ACTIVE_NODE_STEP_KILLERS.add(killNodeStep);
ACTIVE_NODE_STEP_KILLERS.set(killNodeStep, abortKillGraceMs);
const abortStep = () => {
if (settled || canceled) {
return;
}
canceled = true;
killNodeStep("SIGTERM");
killDeadlineAt = Date.now() + NODE_STEP_ABORT_KILL_GRACE_MS;
killDeadlineAt = Date.now() + abortKillGraceMs;
killTimer = setTimeout(() => {
killTimer = undefined;
killNodeStep("SIGKILL");
}, NODE_STEP_ABORT_KILL_GRACE_MS);
}, abortKillGraceMs);
killTimer.unref?.();
};
function cleanup() {
@@ -667,7 +677,11 @@ export async function runNodeStepsInParallel(steps) {
const abortController = new AbortController();
const results = await Promise.allSettled(
steps.map((step) =>
runNodeStep(step.label, step.args, step.timeoutMs, { abortController, env: step.env }),
runNodeStep(step.label, step.args, step.timeoutMs, {
abortController,
abortKillGraceMs: step.abortKillGraceMs,
env: step.env,
}),
),
);
const firstFailure = results.find((result) => result.status === "rejected");
+17 -11
View File
@@ -15,6 +15,7 @@ import { formatErrorMessage } from "./lib/error-format.mjs";
const DEFAULT_CONCURRENCY = 6;
const DEFAULT_TIMEOUT_MS = 90_000;
const DEFAULT_COMBINED_TIMEOUT_MS = 180_000;
const DEFAULT_CHILD_SHUTDOWN_GRACE_MS = 1_000;
const DEFAULT_TOP = 10;
const OUTPUT_CAPTURE_MAX_CHARS = 128 * 1024;
const STDERR_PREVIEW_MAX_CHARS = 8 * 1024;
@@ -24,7 +25,7 @@ const PARENT_SIGNAL_EXIT_CODES = new Map([
["SIGINT", 130],
["SIGTERM", 143],
]);
const activeCaseChildren = new Set();
const activeCaseChildren = new Map();
const parentSignalHandlers = new Map();
let parentSignalHandlersInstalled = false;
let parentSignalShutdownStarted = false;
@@ -203,6 +204,7 @@ export async function runCase({
name,
body,
timeoutMs,
shutdownGraceMs = DEFAULT_CHILD_SHUTDOWN_GRACE_MS,
spawnImpl = spawn,
}) {
return await new Promise((resolve) => {
@@ -216,7 +218,7 @@ export async function runCase({
stdio: ["ignore", "pipe", "pipe"],
},
);
trackActiveCaseChild(child);
trackActiveCaseChild(child, shutdownGraceMs);
let stdout = createOutputCapture();
let stderr = createOutputCapture();
@@ -265,7 +267,7 @@ export async function runCase({
child.on("close", (code, signal) => {
void (async () => {
if (timedOut) {
await waitForChildProcessTreeExit(child, 1_000);
await waitForChildProcessTreeExit(child, shutdownGraceMs);
}
const stderrText = formatCapturedOutput(stderr);
settle({
@@ -321,8 +323,8 @@ function childProcessTreeIsAlive(child) {
}
}
function trackActiveCaseChild(child) {
activeCaseChildren.add(child);
function trackActiveCaseChild(child, shutdownGraceMs) {
activeCaseChildren.set(child, shutdownGraceMs);
installParentSignalHandlers();
}
@@ -365,7 +367,7 @@ function removeInstalledParentSignalHandlers() {
function handleParentSignal(signal) {
if (parentSignalShutdownStarted) {
for (const child of activeCaseChildren) {
for (const child of activeCaseChildren.keys()) {
signalChildProcessTree(child, "SIGKILL");
}
return;
@@ -375,17 +377,21 @@ function handleParentSignal(signal) {
}
async function cleanupActiveCaseChildrenForParentSignal(signal) {
const children = [...activeCaseChildren];
for (const child of children) {
const children = [...activeCaseChildren.entries()];
for (const [child] of children) {
signalChildProcessTree(child, signal);
}
await Promise.all(children.map((child) => waitForChildProcessTreeExit(child, 1_000)));
for (const child of children) {
await Promise.all(
children.map(([child, shutdownGraceMs]) => waitForChildProcessTreeExit(child, shutdownGraceMs)),
);
for (const [child] of children) {
if (childProcessTreeIsAlive(child)) {
signalChildProcessTree(child, "SIGKILL");
}
}
await Promise.all(children.map((child) => waitForChildProcessTreeExit(child, 1_000)));
await Promise.all(
children.map(([child, shutdownGraceMs]) => waitForChildProcessTreeExit(child, shutdownGraceMs)),
);
removeInstalledParentSignalHandlers();
process.exit(PARENT_SIGNAL_EXIT_CODES.get(signal) ?? 1);
}
+25 -17
View File
@@ -128,6 +128,30 @@ function parseOptions(argv: readonly string[]): ProducerOptions {
};
}
type ProducerCliOutput = {
error: (message: string) => void;
log: (message: string) => void;
};
export async function runUxMatrixEvidenceProducerCli(
argv: readonly string[],
output: ProducerCliOutput = console,
): Promise<number> {
try {
if (isHelpRequest(argv)) {
output.log(usage());
return 0;
}
const result = await runUxMatrixEvidenceProducer(parseOptions(argv));
output.log(`UX Matrix evidence: ${path.join(result.artifactBase, QA_EVIDENCE_FILENAME)}`);
output.log(`UX Matrix entries: ${result.evidence.entries.length}`);
return 0;
} catch (error) {
output.error(error instanceof Error ? error.message : String(error));
return 1;
}
}
async function writeJson(filePath: string, value: unknown) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
@@ -784,21 +808,5 @@ export async function runUxMatrixEvidenceProducer(options: ProducerOptions) {
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
(async () => {
const cliArgs = process.argv.slice(2);
if (isHelpRequest(cliArgs)) {
console.log(usage());
return;
}
const result = await runUxMatrixEvidenceProducer(parseOptions(cliArgs));
console.log(`UX Matrix evidence: ${path.join(result.artifactBase, QA_EVIDENCE_FILENAME)}`);
console.log(`UX Matrix entries: ${result.evidence.entries.length}`);
})()
.then(() => {
process.exitCode = 0;
})
.catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
process.exitCode = await runUxMatrixEvidenceProducerCli(process.argv.slice(2));
}
@@ -25,6 +25,7 @@ export const ARTIFACT_TARBALL_SCAN_MAX_ENTRIES = 10_000;
const COMMAND_STDOUT_CAPTURE_MAX_CHARS = 8 * 1024 * 1024;
const COMMAND_STDERR_CAPTURE_MAX_CHARS = 128 * 1024;
const COMMAND_TIMEOUT_KILL_AFTER_MS = 5_000;
const FORWARDED_SIGNAL_KILL_AFTER_MS = 250;
const COMMAND_PROCESS_TREE_EXIT_POLL_MS = 50;
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
const ACTIVE_CHILD_KILLERS = new Set();
@@ -58,7 +59,7 @@ for (const signal of Object.keys(SIGNAL_EXIT_CODES)) {
killChild("SIGKILL");
}
process.exit(forwardedSignalExitCode);
}, COMMAND_TIMEOUT_KILL_AFTER_MS);
}, FORWARDED_SIGNAL_KILL_AFTER_MS);
});
}
export const OPENCLAW_PACKAGE_SPEC_RE =
@@ -457,7 +458,7 @@ async function assertExpectedSha256(file, expected) {
export const assertExpectedSha256ForTest = assertExpectedSha256;
async function findSingleTarball(dir) {
async function findSingleTarball(dir, maxEntries = ARTIFACT_TARBALL_SCAN_MAX_ENTRIES) {
const root = path.resolve(ROOT_DIR, dir);
const pending = [root];
const tarballs = [];
@@ -471,9 +472,9 @@ async function findSingleTarball(dir) {
const handle = await fs.opendir(currentDir);
for await (const entry of handle) {
scannedEntries += 1;
if (scannedEntries > ARTIFACT_TARBALL_SCAN_MAX_ENTRIES) {
if (scannedEntries > maxEntries) {
throw new Error(
`source=artifact scan exceeded ${ARTIFACT_TARBALL_SCAN_MAX_ENTRIES} filesystem entries under ${dir}; provide a smaller artifact directory containing exactly one .tgz.`,
`source=artifact scan exceeded ${maxEntries} filesystem entries under ${dir}; provide a smaller artifact directory containing exactly one .tgz.`,
);
}
+3 -2
View File
@@ -6,9 +6,10 @@ import { performance } from "node:perf_hooks";
const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
const DEFAULT_OUTPUT_MAX_BYTES = 512 * 1024;
const TIMEOUT_KILL_GRACE_MS = 5_000;
// Boundary checks are disposable subprocesses; bound descendant cleanup after timeout.
const TIMEOUT_KILL_GRACE_MS = 250;
const PROCESS_GROUP_EXIT_POLL_MS = 25;
const POST_FORCE_KILL_WAIT_MS = 1_000;
const POST_FORCE_KILL_WAIT_MS = 250;
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
/** Ordered list of supplemental boundary checks used by CI sharding. */
+27 -22
View File
@@ -998,9 +998,9 @@ export function resolveTestProjectsRunnerSpawnParams(env, platform = process.pla
};
}
function spawnTestProjectsRunner(argv, env) {
function spawnTestProjectsRunner(argv, env, options = {}) {
let forwardedSignal = null;
const child = spawn(process.execPath, [testProjectsRunnerPath, ...argv], {
const child = spawn(process.execPath, [options.runnerPath ?? testProjectsRunnerPath, ...argv], {
...resolveTestProjectsRunnerSpawnParams(env),
});
const teardown = installVitestProcessGroupCleanup({
@@ -1014,6 +1014,30 @@ function spawnTestProjectsRunner(argv, env) {
return { child, getForwardedSignal: () => forwardedSignal, teardown };
}
export function runTestProjectsDelegation(argv, env, options = {}) {
const { child, getForwardedSignal, teardown } = spawnTestProjectsRunner(argv, env, options);
child.on("exit", (code, signal) => {
teardown();
const forwardedSignal = getForwardedSignal();
if (forwardedSignal) {
forceKillVitestProcessGroup(child);
process.kill(process.pid, forwardedSignal);
return;
}
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
child.on("error", (error) => {
teardown();
console.error(error);
process.exit(1);
});
return child;
}
function main(argv = process.argv.slice(2), env = process.env) {
if (argv.length === 0) {
console.error("usage: node scripts/run-vitest.mjs <vitest args...>");
@@ -1033,26 +1057,7 @@ function main(argv = process.argv.slice(2), env = process.env) {
const delegatedArgs = resolveTestProjectsDelegationArgs(argv);
if (delegatedArgs) {
const { child, getForwardedSignal, teardown } = spawnTestProjectsRunner(delegatedArgs, env);
child.on("exit", (code, signal) => {
teardown();
const forwardedSignal = getForwardedSignal();
if (forwardedSignal) {
forceKillVitestProcessGroup(child);
process.kill(process.pid, forwardedSignal);
return;
}
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
child.on("error", (error) => {
teardown();
console.error(error);
process.exit(1);
});
runTestProjectsDelegation(delegatedArgs, env);
return;
}
+30 -39
View File
@@ -438,7 +438,7 @@ async function writeTimingStore(timingStore, results) {
console.log(`==> Docker lane timings: ${timingStore.file}`);
}
async function writeRunSummary(logDir, summary) {
export async function writeRunSummary(logDir, summary) {
const file = path.join(logDir, "summary.json");
const payload = {
...summary,
@@ -594,7 +594,7 @@ export function runShellCommand({
env,
stdio: pipeOutput ? ["ignore", "pipe", "pipe"] : "inherit",
});
activeChildren.add(child);
activeChildren.set(child, resolvedTimeoutKillGraceMs);
let timedOut = false;
let noOutputTimedOut = false;
let killTimer;
@@ -614,10 +614,7 @@ export function runShellCommand({
}
terminateChild(child, "SIGTERM");
killAt = Date.now() + resolvedTimeoutKillGraceMs;
killTimer = setTimeout(
() => terminateChild(child, "SIGKILL"),
resolvedTimeoutKillGraceMs,
);
killTimer = setTimeout(() => terminateChild(child, "SIGKILL"), resolvedTimeoutKillGraceMs);
killTimer.unref?.();
};
const resetNoOutputTimer = () => {
@@ -725,7 +722,7 @@ export function runShellCaptureCommand({
env,
stdio: ["ignore", "pipe", "pipe"],
});
activeChildren.add(child);
activeChildren.set(child, resolvedTimeoutKillGraceMs);
let stdout = "";
let stderr = "";
let stdoutTruncated = false;
@@ -878,6 +875,25 @@ async function runCleanupSmoke(baseEnv, logDir, command, startedAtMs) {
};
}
export async function runCleanupSmokePhase(baseEnv, logDir, phases) {
const command = "pnpm test:docker:cleanup";
const startedAtMs = Date.now();
let failure;
try {
await runPhase(phases, CLEANUP_SMOKE_NAME, {}, async () => {
failure = await runCleanupSmoke(baseEnv, logDir, command, startedAtMs);
if (failure) {
throw new Error(
`Run cleanup smoke after parallel lanes failed with status ${failure.status}`,
);
}
});
} catch (error) {
failure ??= await recordCleanupSmokeFailure(error, baseEnv, logDir, command, startedAtMs);
}
return failure;
}
async function runForegroundGroup(entries, env) {
const failures = [];
for (const entry of entries) {
@@ -1298,7 +1314,7 @@ async function printFailureSummary(failures, tailLines) {
}
}
const activeChildren = new Set();
const activeChildren = new Map();
let activeChildrenShutdownPromise;
function shellCommandSkippedForShutdown() {
@@ -1376,7 +1392,7 @@ function terminateChild(child, signal) {
}
function terminateActiveChildren(signal) {
for (const child of activeChildren) {
for (const child of activeChildren.keys()) {
terminateChild(child, signal);
}
}
@@ -1386,13 +1402,13 @@ async function shutdownActiveChildren(signal, exitCode) {
terminateActiveChildren("SIGKILL");
return activeChildrenShutdownPromise;
}
const children = [...activeChildren];
const children = [...activeChildren.entries()];
terminateActiveChildren(signal);
activeChildrenShutdownPromise = Promise.all(
children.map((child) =>
children.map(([child, timeoutKillGraceMs]) =>
finishTimedOutShellProcessTree(child, {
killAt: Date.now() + SHELL_TIMEOUT_KILL_GRACE_MS,
timeoutKillGraceMs: SHELL_TIMEOUT_KILL_GRACE_MS,
killAt: Date.now() + timeoutKillGraceMs,
timeoutKillGraceMs,
}),
),
).finally(() => {
@@ -1717,32 +1733,7 @@ async function main() {
}
if (profile === DEFAULT_PROFILE && selectedLaneNames.length === 0) {
const cleanupSmokeCommand = "pnpm test:docker:cleanup";
const cleanupStartedAtMs = Date.now();
let cleanupFailure;
try {
await runPhase(phases, CLEANUP_SMOKE_NAME, {}, async () => {
cleanupFailure = await runCleanupSmoke(
baseEnv,
logDir,
cleanupSmokeCommand,
cleanupStartedAtMs,
);
if (cleanupFailure) {
throw new Error(
`Run cleanup smoke after parallel lanes failed with status ${cleanupFailure.status}`,
);
}
});
} catch (error) {
cleanupFailure ??= await recordCleanupSmokeFailure(
error,
baseEnv,
logDir,
cleanupSmokeCommand,
cleanupStartedAtMs,
);
}
const cleanupFailure = await runCleanupSmokePhase(baseEnv, logDir, phases);
if (cleanupFailure) {
failures.push(cleanupFailure);
}
+60 -9
View File
@@ -596,6 +596,9 @@ async function runVitestJsonReport(params) {
env: {
...process.env,
...params.env,
// The JSON reporter can stay silent for the entire config. The profiler
// owns the wall-clock timeout and process-group cleanup for this child.
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "0",
NODE_OPTIONS: [
(params.env?.NODE_OPTIONS ?? process.env.NODE_OPTIONS)?.trim(),
...resolveVitestNodeArgs({ ...process.env, ...params.env }).filter(
@@ -908,15 +911,35 @@ export function resolveRunPlanConcurrency(args, runPlanCount) {
return Math.min(2, runPlanCount);
}
function hasExplicitIsolationArg(args) {
return args.some(
(arg) => arg === "--isolate" || arg === "--no-isolate" || arg.startsWith("--isolate="),
);
}
/**
* Gives full-suite duration reports one process lifetime per test file.
* This prevents unrelated retained module graphs and GC pauses from being
* attributed to whichever assertion happens to run next in a shared worker.
*/
export function resolveReportVitestArgs(args) {
if (!args.fullSuite || hasExplicitIsolationArg(args.vitestArgs)) {
return args.vitestArgs;
}
return [...args.vitestArgs, "--isolate=true"];
}
/**
* Builds concrete report run specs from parsed args and config plans.
*/
export function resolveReportRunSpecs(args, runPlans, params = {}) {
const concurrency = params.concurrency ?? resolveRunPlanConcurrency(args, runPlans.length);
const env = params.env ?? process.env;
const vitestArgs = resolveReportVitestArgs(args);
const specs = runPlans.map((plan) => ({
...plan,
env: resolveFullSuiteVitestEnv(args, env, plan.label),
vitestArgs,
}));
if (concurrency <= 1) {
return specs;
@@ -933,11 +956,34 @@ function printRunLine(run) {
);
}
async function runReportPlans(params) {
function printSlowTestsForRun(entry, maxTestMs) {
if (maxTestMs === null || !fs.existsSync(entry.reportPath)) {
return;
}
const input = readReportInputs([entry]).reports[0];
if (!input) {
return;
}
const report = buildGroupedTestReport({
groupBy: "area",
maxTestMs,
reports: [input],
});
for (const test of report.slowTests) {
console.log(
`[test-group-report] slow-test config=${test.config} duration=${formatMs(test.durationMs)} file=${test.file} name=${test.fullName}`,
);
}
}
export async function runReportPlans(params) {
const concurrency = resolveRunPlanConcurrency(params.args, params.runPlans.length);
const runSpecs = resolveReportRunSpecs(params.args, params.runPlans, { concurrency });
const runVitest = params.runVitestJsonReport ?? runVitestJsonReport;
const results = [];
results.length = runSpecs.length;
const runs = [];
runs.length = runSpecs.length;
let nextIndex = 0;
let failed = false;
let exitCode = 0;
@@ -948,7 +994,7 @@ async function runReportPlans(params) {
nextIndex += 1;
const plan = runSpecs[index];
const slug = sanitizePathSegment(plan.label);
const run = await runVitestJsonReport({
const run = await runVitest({
config: plan.config,
forwardedArgs: plan.forwardedArgs,
env: plan.env,
@@ -958,8 +1004,9 @@ async function runReportPlans(params) {
rss: params.args.rss,
timeoutMs: params.args.timeoutMs,
killGraceMs: params.args.killGraceMs,
vitestArgs: params.args.vitestArgs,
vitestArgs: plan.vitestArgs,
});
runs[index] = run;
printRunLine(run);
let includeEntry = true;
if (run.status !== 0) {
@@ -968,7 +1015,6 @@ async function runReportPlans(params) {
console.error(
`[test-group-report] missing JSON report for failed config; see ${run.logPath}`,
);
exitCode = 1;
includeEntry = false;
} else {
console.error(
@@ -976,12 +1022,14 @@ async function runReportPlans(params) {
);
}
if (!params.args.allowFailures) {
exitCode = run.status;
exitCode = run.status || 1;
}
}
results[index] = includeEntry
? { config: plan.label, reportPath: run.reportPath, run }
: null;
const entry = includeEntry ? { config: plan.label, reportPath: run.reportPath, run } : null;
results[index] = entry;
if (entry) {
printSlowTestsForRun(entry, params.args.maxTestMs);
}
}
}
@@ -995,6 +1043,7 @@ async function runReportPlans(params) {
failed,
exitCode,
runEntries: results.filter(Boolean),
runs: runs.filter(Boolean),
};
}
@@ -1030,6 +1079,7 @@ async function main() {
const { reportDir, logDir } = resolveReportArtifactDirs(output);
const runEntries = [];
const runs = [];
const runPlans = resolveRunPlans(args);
let failed = false;
let exitCode = 0;
@@ -1046,6 +1096,7 @@ async function main() {
failed = result.failed;
exitCode = result.exitCode;
runEntries.push(...result.runEntries);
runs.push(...result.runs);
}
if (exitCode !== 0) {
@@ -1083,7 +1134,7 @@ async function main() {
...report,
command: "test-group-report",
failed,
runs: reportInputs.map((entry) => entry.run).filter(Boolean),
runs: runs.length > 0 ? runs : reportInputs.map((entry) => entry.run).filter(Boolean),
system: {
node: process.version,
platform: process.platform,
+1 -1
View File
@@ -3186,7 +3186,7 @@ function resolveDocsI18nBehaviorTargets(changedPath) {
if (!/^scripts\/docs-i18n\/testdata\/behavior\/[^/]+\/[^/]+$/u.test(changedPath)) {
return null;
}
return ["test/scripts/docs-i18n-behavior.test.ts"];
return ["test/scripts/docs-i18n.test.ts"];
}
function resolveDocsI18nGoTargets(changedPath) {
+3 -2
View File
@@ -34,9 +34,10 @@ const CGROUP_MEMORY_LIMIT_PATHS = [
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
];
const PROC_MEMINFO_PATH = "/proc/meminfo";
const TERMINATION_GRACE_MS = 5_000;
// Build descendants get a short cleanup window; a timed-out build must not hold CI for seconds.
const TERMINATION_GRACE_MS = 250;
const PROCESS_GROUP_EXIT_POLL_MS = 25;
const POST_FORCE_KILL_WAIT_MS = 1_000;
const POST_FORCE_KILL_WAIT_MS = 250;
const ROOT_TSDOWN_OUTPUT_ROOTS = ["dist", "dist-runtime"];
const PRESERVED_TSDOWN_OUTPUT_FILES = ["dist/cli-startup-metadata.json"];
const PRESERVE_CLI_STARTUP_METADATA_ENV = "OPENCLAW_PRESERVE_CLI_STARTUP_METADATA";
+2 -1
View File
@@ -13,6 +13,7 @@ const repoRoot = path.resolve(here, "..");
const uiDir = path.join(repoRoot, "ui");
const WINDOWS_CMD_EXE_EXTENSIONS = new Set([".cmd", ".bat"]);
const FORWARDED_SIGNAL_KILL_GRACE_MS = 250;
function usage() {
// keep this tiny; it's invoked from npm scripts too
@@ -140,7 +141,7 @@ function runSpawnCall(spawnCall, label) {
forwardedSignalDrainTimer = setInterval(waitForForwardedSignalChildren, 25);
forceKillTimer = setTimeout(() => {
signalProcessTree(child, "SIGKILL", forwardedSignalPids);
}, 5_000);
}, FORWARDED_SIGNAL_KILL_GRACE_MS);
forceKillTimer.unref?.();
}
},
+12 -1
View File
@@ -2,7 +2,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpInitializeSessionInput } from "../acp/control-plane/manager.types.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -13,6 +13,7 @@ import {
type SessionBindingPlacement,
type SessionBindingRecord,
} from "../infra/outbound/session-binding-service.js";
import { resolveThinkingDefault } from "./model-selection.js";
function createDefaultSpawnConfig(): OpenClawConfig {
return {
@@ -686,6 +687,16 @@ function enableTelegramCurrentConversationBindings(): void {
}
describe("spawnAcpDirect", () => {
beforeAll(() => {
resolveThinkingDefault({
cfg: {
agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } },
},
provider: "anthropic",
model: "claude-sonnet-4-6",
});
});
beforeEach(() => {
replaceSpawnConfig(createDefaultSpawnConfig());
hoisted.areHeartbeatsEnabledMock.mockReset().mockReturnValue(true);
+10 -15
View File
@@ -722,15 +722,15 @@ describe("session MCP runtime", () => {
expect(activeLeases).toBe(0);
});
it("keeps MCP tools/list responses that exceed the connection timeout but finish within the internal catalog timeout", async () => {
it("uses the internal catalog timeout for MCP tools/list after connecting", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "bundle-mcp-slow-listtools-"));
const serverPath = path.join(tempDir, "slow-list-tools.mjs");
const logPath = path.join(tempDir, "server.log");
testing.setBundleMcpCatalogListTimeoutMsForTest(3_000);
testing.setBundleMcpCatalogListTimeoutMsForTest(300);
await writeListToolsMcpServer({
filePath: serverPath,
logPath,
delayMs: 1_250,
delayMs: 100,
});
const runtime = await getOrCreateSessionMcpRuntime({
@@ -758,7 +758,7 @@ describe("session MCP runtime", () => {
serverName: "slowListTools",
toolCount: 1,
});
await expect(fs.readFile(logPath, "utf8")).resolves.toContain("delay tools/list 1250");
await expect(fs.readFile(logPath, "utf8")).resolves.toContain("delay tools/list 100");
} finally {
await runtime.dispose();
await fs.rm(tempDir, { recursive: true, force: true });
@@ -1901,6 +1901,7 @@ describe("disposeSession timeout", () => {
"force-closes transport and client when terminateSession hangs past the timeout",
{ timeout: 15_000 },
async () => {
testing.setBundleMcpDisposeTimeoutMsForTest(100);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "bundle-mcp-force-close-"));
const serverPath = path.join(tempDir, "hanging-terminate.mjs");
const logPath = path.join(tempDir, "server.log");
@@ -1984,10 +1985,7 @@ process.stdin.on("end", () => {
await runtime.dispose();
const elapsed = Date.now() - start;
// The timeout fires at 5s and force-closes transport + client,
// so disposal must complete well before 8s even when the process
// ignores shutdown signals.
expect(elapsed).toBeLessThan(8_000);
expect(elapsed).toBeLessThan(1_000);
await retireSessionMcpRuntime({
sessionId: "session-force-close-timeout",
@@ -2001,6 +1999,7 @@ process.stdin.on("end", () => {
"completes disposal even when the MCP server process ignores shutdown",
{ timeout: 15_000 },
async () => {
testing.setBundleMcpDisposeTimeoutMsForTest(100);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "bundle-mcp-dispose-timeout-"));
const serverPath = path.join(tempDir, "hanging-close.mjs");
const logPath = path.join(tempDir, "server.log");
@@ -2083,9 +2082,7 @@ process.stdin.on("end", () => {
await runtime.dispose();
const elapsed = Date.now() - start;
// Dispose should complete within DISPOSE_TIMEOUT_MS (5s) + a small buffer,
// not hang indefinitely.
expect(elapsed).toBeLessThan(8_000);
expect(elapsed).toBeLessThan(1_000);
await fs.rm(tempDir, { recursive: true, force: true });
},
@@ -2095,6 +2092,7 @@ process.stdin.on("end", () => {
"force-closes streamable-http transport when DELETE hangs past the timeout",
{ timeout: 15_000 },
async () => {
testing.setBundleMcpDisposeTimeoutMsForTest(100);
const sessionId = "test-session-" + Date.now();
const server = http.createServer((req, res) => {
if (req.method === "GET") {
@@ -2176,10 +2174,7 @@ process.stdin.on("end", () => {
await runtime.dispose();
const elapsed = Date.now() - start;
// The timeout fires at 5s and force-closes transport + client,
// so disposal must complete well before 8s even when the DELETE
// request never receives a response.
expect(elapsed).toBeLessThan(8_000);
expect(elapsed).toBeLessThan(1_000);
} finally {
server.close();
}
+57 -21
View File
@@ -35,6 +35,7 @@ import type {
} from "./agent-bundle-mcp-types.js";
import { loadEmbeddedAgentMcpConfig } from "./embedded-agent-mcp.js";
import { isMcpConfigRecord } from "./mcp-config-shared.js";
import { OpenClawStdioClientTransport } from "./mcp-stdio-transport.js";
import { resolveMcpTransport } from "./mcp-transport.js";
type BundleMcpSession = {
@@ -66,9 +67,23 @@ const SESSION_MCP_RUNTIME_SWEEP_INTERVAL_MS = 60 * 1000;
const BUNDLE_MCP_FAILURE_THRESHOLD = 3;
const BUNDLE_MCP_FAILURE_COOLDOWN_MS = 60_000;
const BUNDLE_MCP_CATALOG_LIST_TIMEOUT_MS = 1_500;
const BUNDLE_MCP_DISPOSE_TIMEOUT_MS = 5_000;
const BUNDLE_MCP_CATALOG_CONNECT_CONCURRENCY = 6;
const BUNDLE_MCP_METADATA_TEXT_LIMIT = 1_200;
let bundleMcpCatalogListTimeoutMs: number | undefined;
const BUNDLE_MCP_TEST_STATE_KEY = Symbol.for("openclaw.bundleMcpTestState");
type BundleMcpTestState = { disposeTimeoutMs?: number };
function getBundleMcpTestState(): BundleMcpTestState {
const globalStore = globalThis as Record<PropertyKey, unknown>;
const existing = globalStore[BUNDLE_MCP_TEST_STATE_KEY] as BundleMcpTestState | undefined;
if (existing) {
return existing;
}
const state: BundleMcpTestState = {};
globalStore[BUNDLE_MCP_TEST_STATE_KEY] = state;
return state;
}
type McpToolSelection = {
include?: readonly string[];
@@ -287,6 +302,15 @@ function setBundleMcpCatalogListTimeoutMsForTest(timeoutMs?: number): void {
? Math.floor(timeoutMs)
: undefined;
}
function setBundleMcpDisposeTimeoutMsForTest(timeoutMs?: number): void {
// Non-isolated test workers can reload this module while a facade still
// references an older copy. Share the override across those copies.
getBundleMcpTestState().disposeTimeoutMs =
typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
? Math.floor(timeoutMs)
: undefined;
}
async function listAllResources(client: Client, timeoutMs: number) {
const resources: unknown[] = [];
let cursor: string | undefined;
@@ -386,14 +410,30 @@ function summarizeServerCapabilities(capabilities: ServerCapabilities | undefine
: undefined,
};
}
// Safety net for hung MCP servers, not a tuning parameter.
const DISPOSE_TIMEOUT_MS = 5_000;
async function settleWithin(promise: Promise<unknown>, timeoutMs: number): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined;
return await Promise.race([
promise.then(
() => true,
() => true,
),
new Promise<void>((resolve) => {
timer = setTimeout(() => {
resolve();
}, timeoutMs);
timer.unref?.();
}).then(() => false),
]).finally(() => {
if (timer) {
clearTimeout(timer);
}
});
}
async function disposeSession(session: BundleMcpSession) {
session.detachStderr?.();
let timer: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
await Promise.race([
const timeoutMs = getBundleMcpTestState().disposeTimeoutMs ?? BUNDLE_MCP_DISPOSE_TIMEOUT_MS;
const closed = await settleWithin(
(async () => {
if (session.transportType === "streamable-http") {
await (session.transport as StreamableHTTPClientTransport)
@@ -403,23 +443,17 @@ async function disposeSession(session: BundleMcpSession) {
await session.transport.close().catch(() => {});
await session.client.close().catch(() => {});
})(),
new Promise<void>((resolve) => {
timer = setTimeout(() => {
timedOut = true;
resolve();
}, DISPOSE_TIMEOUT_MS);
timer.unref?.();
}),
]).finally(() => {
if (timer) {
clearTimeout(timer);
}
});
if (timedOut) {
timeoutMs,
);
if (!closed) {
// Force-close transport and client so a hung terminateSession() DELETE
// gets its AbortSignal triggered by the transport teardown.
await session.transport.close().catch(() => {});
await session.client.close().catch(() => {});
// gets its AbortSignal triggered by teardown. Stdio owns a process group,
// so force it dead before disposal can report completion.
const transportClose =
session.transport instanceof OpenClawStdioClientTransport
? session.transport.forceClose()
: session.transport.close();
await settleWithin(Promise.allSettled([transportClose, session.client.close()]), timeoutMs);
}
}
@@ -1275,11 +1309,13 @@ export const testing = {
async resetSessionMcpRuntimeManager() {
await disposeAllSessionMcpRuntimes();
setBundleMcpCatalogListTimeoutMsForTest();
setBundleMcpDisposeTimeoutMsForTest();
},
getCachedSessionIds() {
return getSessionMcpRuntimeManager().listSessionIds();
},
setBundleMcpCatalogListTimeoutMsForTest,
setBundleMcpDisposeTimeoutMsForTest,
resolveSessionMcpRuntimeIdleTtlMs,
};
export { testing as __testing };
@@ -1,5 +1,5 @@
/** Tests live model switching behavior in active agent command sessions. */
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
import { INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END } from "./internal-events.js";
import { LiveSessionModelSwitchError } from "./live-model-switch-error.js";
@@ -323,6 +323,12 @@ vi.mock("../logging/subsystem.js", () => ({
},
}));
afterAll(() => {
// This suite runs in a shared worker; do not leak its module-level logger
// mock into later files that verify real warning diagnostics.
vi.doUnmock("../logging/subsystem.js");
});
vi.mock("../channels/model-overrides.js", () => ({
resolveChannelModelOverride: (params: unknown) => state.resolveChannelModelOverrideMock(params),
}));
+16 -1
View File
@@ -2,7 +2,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSolidPngBuffer } from "../../test/helpers/image-fixtures.js";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { getReplyPayloadMetadata } from "../auto-reply/reply-payload.js";
@@ -24,6 +24,7 @@ import {
resolveMcpLoopbackYieldContext,
updateMcpLoopbackToolCallCapture,
} from "../gateway/mcp-http.loopback-runtime.js";
import { resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import type { getProcessSupervisor } from "../process/supervisor/index.js";
import type { RunExit } from "../process/supervisor/types.js";
@@ -308,7 +309,17 @@ const CLI_RESEED_PROMPT =
"Continue this conversation using the OpenClaw transcript below as prior session history.\n\n<conversation_history>\nUser: earlier context\n</conversation_history>\n\n<next_user_message>\nhi\n</next_user_message>";
describe("runCliAgent reliability", () => {
beforeEach(() => {
// Binding-flush retry timing has dedicated coverage. Reliability cases only
// need its stable not-yet-flushed outcome, without filesystem polling/sleeps.
setCliRunnerTestDeps({
claudeCliSessionTranscriptHasContent: async () => false,
delay: async () => {},
});
});
afterEach(() => {
restoreCliRunnerTestDeps();
replyRunTesting.resetReplyRunRegistry();
mockGetGlobalHookRunner.mockReset();
mockAutoCapture.mockReset();
@@ -318,6 +329,8 @@ describe("runCliAgent reliability", () => {
sessionFileEnvSnapshot?.restore();
sessionFileEnvSnapshot = undefined;
resetClaudeLiveSessionsForTest();
resetDiagnosticEventsForTest();
vi.useRealTimers();
});
it("fails with timeout when no-output watchdog trips", async () => {
@@ -1659,6 +1672,7 @@ describe("runCliAgent reliability", () => {
});
it("keeps non-capture live-session artifacts through fresh recovery retry", async () => {
vi.useFakeTimers();
supervisorSpawnMock.mockClear();
const artifactDir = autoCleanupTempDirs.make("openclaw-live-retry-artifacts-");
const mcpConfigPath = path.join(artifactDir, "mcp.json");
@@ -1807,6 +1821,7 @@ describe("runCliAgent reliability", () => {
},
});
await firstSpawned;
await vi.advanceTimersByTimeAsync(1_000);
const result = await resultPromise;
expect(result.payloads).toEqual([{ text: "fresh ok" }]);
+17 -13
View File
@@ -1,6 +1,6 @@
// Covers the compaction planning worker boundary and timeout behavior.
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { compactionPlanningWorkerTesting } from "./compaction-planning-worker.js";
import { runCompactionPlanningWorkerInput } from "./compaction-planning.worker.js";
import type { AgentMessage } from "./runtime/index.js";
@@ -20,6 +20,21 @@ function createSyntheticWorkerUrl(source: string): URL {
}
describe("compaction planning worker", () => {
let packagedSummaryChunks: Awaited<
ReturnType<typeof compactionPlanningWorkerTesting.runCompactionPlanningWorker>
>;
beforeAll(async () => {
packagedSummaryChunks = await compactionPlanningWorkerTesting.runCompactionPlanningWorker({
input: {
kind: "summaryChunks",
messages: [makeMessage(1), makeMessage(2), makeMessage(3)],
maxChunkTokens: 1200,
},
timeoutMs: 30_000,
});
});
it("resolves the packaged worker URL from stable and hashed dist modules", () => {
// Hashed bundle names still resolve to the stable worker sibling emitted by
// the build, so runtime imports do not depend on the main chunk hash.
@@ -42,18 +57,7 @@ describe("compaction planning worker", () => {
});
});
it("plans summary chunks in the packaged worker", async () => {
const packagedSummaryChunks = await compactionPlanningWorkerTesting.runCompactionPlanningWorker(
{
input: {
kind: "summaryChunks",
messages: [makeMessage(1), makeMessage(2), makeMessage(3)],
maxChunkTokens: 1200,
},
timeoutMs: 30_000,
},
);
it("plans summary chunks in the packaged worker", () => {
expect(packagedSummaryChunks.kind).toBe("summaryChunks");
if (packagedSummaryChunks.kind !== "summaryChunks") {
return;
+1 -33
View File
@@ -1,12 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { execNodeEvalSync } from "../test-utils/node-process.js";
import { lookupCachedContextWindow, providerContextTokenCacheKey } from "./context-cache.js";
import {
CONTEXT_WINDOW_RUNTIME_STATE,
resetContextWindowCacheForTest,
} from "./context-runtime-state.js";
import { ensureContextWindowCacheLoaded } from "./context.js";
import { resetContextWindowCacheForTest } from "./context-runtime-state.js";
afterEach(() => {
resetContextWindowCacheForTest();
@@ -38,30 +32,4 @@ describe("context runtime state", () => {
expect(output).toBe("0:true:true");
});
it("warms fresh caches instead of reusing a pre-generation load promise", async () => {
const legacyLoadPromise = Promise.resolve();
CONTEXT_WINDOW_RUNTIME_STATE.loadPromise = legacyLoadPromise;
CONTEXT_WINDOW_RUNTIME_STATE.loadGeneration = null;
CONTEXT_WINDOW_RUNTIME_STATE.configuredConfig = {
models: {
providers: {
"fresh-provider": {
baseUrl: "https://example.invalid",
models: [{ id: "fresh-model", contextWindow: 123_456 } as never],
},
},
},
} satisfies OpenClawConfig;
await ensureContextWindowCacheLoaded();
expect(
lookupCachedContextWindow(providerContextTokenCacheKey("fresh-provider", "fresh-model")),
).toBe(123_456);
expect(CONTEXT_WINDOW_RUNTIME_STATE.loadPromise).not.toBe(legacyLoadPromise);
expect(CONTEXT_WINDOW_RUNTIME_STATE.loadGeneration).toBe(
CONTEXT_WINDOW_RUNTIME_STATE.generation,
);
});
});
+23
View File
@@ -2,6 +2,8 @@
// model resolution.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { lookupCachedContextWindow, providerContextTokenCacheKey } from "./context-cache.js";
import { CONTEXT_WINDOW_RUNTIME_STATE } from "./context-runtime-state.js";
type DiscoveredModel = {
id: string;
@@ -366,6 +368,27 @@ describe("lookupContextTokens", () => {
).toBe(1_048_576);
});
it("warms fresh caches instead of reusing a pre-generation load promise", async () => {
const legacyLoadPromise = Promise.resolve();
CONTEXT_WINDOW_RUNTIME_STATE.loadPromise = legacyLoadPromise;
CONTEXT_WINDOW_RUNTIME_STATE.loadGeneration = null;
CONTEXT_WINDOW_RUNTIME_STATE.configuredConfig = createContextOverrideConfig(
"fresh-provider",
"fresh-model",
123_456,
);
await contextModule.ensureContextWindowCacheLoaded();
expect(
lookupCachedContextWindow(providerContextTokenCacheKey("fresh-provider", "fresh-model")),
).toBe(123_456);
expect(CONTEXT_WINDOW_RUNTIME_STATE.loadPromise).not.toBe(legacyLoadPromise);
expect(CONTEXT_WINDOW_RUNTIME_STATE.loadGeneration).toBe(
CONTEXT_WINDOW_RUNTIME_STATE.generation,
);
});
it("status waits for pending context warmup but releases on timeout", async () => {
vi.useFakeTimers();
try {
+18 -13
View File
@@ -1,25 +1,30 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resolveMemoryFlushContextWindowTokens } from "../auto-reply/reply/memory-flush.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { refreshContextWindowCache, resetContextWindowCacheForTest } from "./context.js";
describe("OpenCode Go context metadata", () => {
afterEach(() => {
resetContextWindowCacheForTest();
});
let contextWindowTokens: number | undefined;
let configuredModels: OpenClawConfig["models"];
it("warms the provider-owned context window without writing model config", async () => {
beforeAll(async () => {
const cfg: OpenClawConfig = {};
await refreshContextWindowCache(cfg);
contextWindowTokens = resolveMemoryFlushContextWindowTokens({
cfg,
provider: "opencode-go",
modelId: "deepseek-v4-pro",
});
configuredModels = cfg.models;
});
expect(
resolveMemoryFlushContextWindowTokens({
cfg,
provider: "opencode-go",
modelId: "deepseek-v4-pro",
}),
).toBe(1_000_000);
expect(cfg.models).toBeUndefined();
afterAll(() => {
resetContextWindowCacheForTest();
});
it("warms the provider-owned context window without writing model config", () => {
expect(contextWindowTokens).toBe(1_000_000);
expect(configuredModels).toBeUndefined();
});
});
@@ -53,6 +53,15 @@ function resolveIncompleteTurnPayloadText(
describe("runEmbeddedAgent incomplete-turn safety", () => {
beforeAll(async () => {
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
resetRunOverflowCompactionHarnessMocks();
mockedGlobalHookRunner.hasHooks.mockImplementation(() => false);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({ assistantTexts: ["warmup"] }),
);
await runEmbeddedAgent({
...overflowBaseRunParams,
runId: "run-incomplete-turn-warmup",
});
});
beforeEach(() => {
+23
View File
@@ -121,6 +121,29 @@ describe("OpenClawStdioClientTransport", () => {
await closing;
});
it("force-closes an in-flight repeated graceful shutdown before returning", async () => {
vi.useFakeTimers();
const child = new MockChildProcess();
spawnMock.mockReturnValue(child);
const transport = new OpenClawStdioClientTransport({ command: "npx" });
const started = transport.start();
child.emit("spawn");
await started;
const closing = transport.close();
const repeatedClose = transport.close();
const forced = transport.forceClose();
expect(signalProcessTreeMock).toHaveBeenCalledWith(4321, "SIGKILL");
child.exitCode = 0;
child.emit("close", 0);
await expect(forced).resolves.toBeUndefined();
await expect(closing).resolves.toBeUndefined();
await expect(repeatedClose).resolves.toBeUndefined();
expect(transport.pid).toBeNull();
});
it("does not kill the process tree when graceful stdio close exits", async () => {
vi.useFakeTimers();
const child = new MockChildProcess();
+25 -2
View File
@@ -36,6 +36,7 @@ export class OpenClawStdioClientTransport implements Transport {
private readonly readBuffer = new ReadBuffer();
private readonly stderrStream: PassThrough | null = null;
private process?: ChildProcess;
private closingProcess?: ChildProcess;
constructor(private readonly serverParams: OpenClawStdioServerParameters) {
if (serverParams.stderr === "pipe" || serverParams.stderr === "overlapped") {
@@ -97,7 +98,7 @@ export class OpenClawStdioClientTransport implements Transport {
}
get pid() {
return this.process?.pid ?? null;
return this.process?.pid ?? this.closingProcess?.pid ?? null;
}
private processReadBuffer() {
@@ -115,8 +116,11 @@ export class OpenClawStdioClientTransport implements Transport {
}
async close(): Promise<void> {
const processToClose = this.process;
const processToClose = this.process ?? this.closingProcess;
this.process = undefined;
if (processToClose) {
this.closingProcess = processToClose;
}
if (processToClose) {
const closePromise = new Promise<void>((resolve) => {
processToClose.once("close", () => resolve());
@@ -137,6 +141,25 @@ export class OpenClawStdioClientTransport implements Transport {
}
}
}
if (this.closingProcess === processToClose) {
this.closingProcess = undefined;
}
this.readBuffer.clear();
}
async forceClose(): Promise<void> {
const processToClose = this.process ?? this.closingProcess;
this.process = undefined;
if (processToClose?.pid && processToClose.exitCode === null) {
const closePromise = new Promise<void>((resolve) => {
processToClose.once("close", () => resolve());
});
signalProcessTree(processToClose.pid, "SIGKILL");
await Promise.race([closePromise, delay(SIGKILL_REAP_TIMEOUT_MS)]);
}
if (this.closingProcess === processToClose) {
this.closingProcess = undefined;
}
this.readBuffer.clear();
}
+26 -1
View File
@@ -101,6 +101,26 @@ const providerModelNormalizationMock = vi.hoisted(() => ({
normalizeProviderModelIdWithRuntime: vi.fn(() => undefined),
}));
const providerPolicySurfaceMock = vi.hoisted(() => ({
resolveBundledProviderPolicySurface: vi.fn((providerId: string) => {
if (providerId !== "anthropic" && providerId !== "amazon-bedrock") {
return null;
}
return {
resolveThinkingProfile: (context: { modelId: string }) =>
context.modelId.includes("claude-") && context.modelId.includes("4-6")
? {
levels: [
{ id: "off", label: "off", rank: 0 },
{ id: "adaptive", label: "adaptive", rank: 6 },
],
defaultLevel: "adaptive",
}
: undefined,
};
}),
}));
vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({
getCurrentPluginMetadataSnapshot: () => manifestNormalizationSnapshot,
}));
@@ -110,6 +130,11 @@ vi.mock("./provider-model-normalization.runtime.js", () => ({
providerModelNormalizationMock.normalizeProviderModelIdWithRuntime,
}));
vi.mock("../plugins/provider-public-artifacts.js", () => ({
resolveBundledProviderPolicySurface:
providerPolicySurfaceMock.resolveBundledProviderPolicySurface,
}));
vi.mock("./model-selection-cli.js", () => ({
isCliProvider: () => false,
}));
@@ -2677,7 +2702,7 @@ describe("model-selection", () => {
expect(resolveClaudeCliOpus48Thinking(cfg)).toBe("off");
});
it("uses bundled provider thinking defaults when no explicit config overrides them", () => {
it("uses provider policy thinking defaults when no explicit config overrides them", () => {
const cfg = {} as OpenClawConfig;
expect(resolveAnthropicOpusThinking(cfg)).toBe("adaptive");
@@ -1,14 +1,13 @@
// Verifies models.json planning applies config env vars and discovery scope.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createConfigRuntimeEnv } from "../config/env-vars.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { withEnvAsync } from "../test-utils/env.js";
import { saveAuthProfileStore } from "./auth-profiles/store.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
replaceRuntimeAuthProfileStoreSnapshots,
} from "./auth-profiles/store.js";
import { unsetEnv, withTempEnv } from "./models-config.e2e-harness.js";
import {
planOpenClawModelsJsonWithDeps,
@@ -18,7 +17,6 @@ import type { ProviderConfig } from "./models-config.providers.secrets.js";
import { encodePluginModelCatalogRelativePath } from "./plugin-model-catalog.js";
const TEST_ENV_VAR = "OPENCLAW_MODELS_CONFIG_TEST_ENV";
const BUNDLED_PLUGINS_DIR = fileURLToPath(new URL("../../extensions/", import.meta.url));
function createImplicitOpenRouterProvider(): ProviderConfig {
return {
@@ -468,22 +466,23 @@ describe("models-config", () => {
});
it("keeps google-vertex static catalog rows when an auth profile supplies the API key", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-models-"));
const agentDir = "/tmp/openclaw-google-vertex-models-profile";
try {
saveAuthProfileStore(
replaceRuntimeAuthProfileStoreSnapshots([
{
version: 1,
profiles: {
"google-vertex:default": {
type: "api_key",
provider: "google-vertex",
keyRef: { source: "env", provider: "default", id: "GOOGLE_CLOUD_API_KEY" },
agentDir,
store: {
version: 1,
profiles: {
"google-vertex:default": {
type: "api_key",
provider: "google-vertex",
keyRef: { source: "env", provider: "default", id: "GOOGLE_CLOUD_API_KEY" },
},
},
},
},
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
]);
const plan = await planOpenClawModelsJsonWithDeps(
{
@@ -526,71 +525,56 @@ describe("models-config", () => {
"gemini-2.5-pro",
]);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
clearRuntimeAuthProfileStoreSnapshots();
}
});
it("keeps google-vertex static catalog rows when ADC auth evidence supplies the marker", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-models-"));
const credentialsPath = path.join(agentDir, "application_default_credentials.json");
await fs.writeFile(credentialsPath, JSON.stringify({ type: "authorized_user" }), "utf8");
try {
const plan = await withEnvAsync(
{
OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
},
async () =>
await planOpenClawModelsJsonWithDeps(
{
cfg: {
agents: {
defaults: {
models: {
"google-vertex/gemini-2.5-pro": {},
},
model: { primary: "google-vertex/gemini-2.5-pro" },
},
},
models: { providers: {} },
it("keeps google-vertex static catalog rows when discovery supplies the ADC marker", async () => {
const plan = await planOpenClawModelsJsonWithDeps(
{
cfg: {
agents: {
defaults: {
models: {
"google-vertex/gemini-2.5-pro": {},
},
agentDir,
env: {
OPENCLAW_BUNDLED_PLUGINS_DIR: BUNDLED_PLUGINS_DIR,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
GOOGLE_CLOUD_PROJECT: "vertex-project",
GOOGLE_CLOUD_LOCATION: "global",
} as NodeJS.ProcessEnv,
existingRaw: "",
existingParsed: null,
model: { primary: "google-vertex/gemini-2.5-pro" },
},
{
resolveImplicitProviders: async () => ({
"google-vertex": createImplicitGoogleVertexProvider(),
}),
},
),
);
},
models: { providers: {} },
},
agentDir: "/tmp/openclaw-google-vertex-adc-models",
env: {},
existingRaw: "",
existingParsed: null,
},
{
// Provider discovery owns ADC detection; this planner test only proves
// the resulting marker and static rows survive models.json planning.
resolveImplicitProviders: async () => ({
"google-vertex": {
...createImplicitGoogleVertexProvider(),
apiKey: "gcp-vertex-credentials",
},
}),
},
);
expect(plan.action).toBe("write");
if (plan.action !== "write") {
throw new Error("Expected models.json write plan");
}
const parsed = JSON.parse(plan.contents) as {
providers?: Record<
string,
{ apiKey?: string; api?: string; models?: Array<{ id?: string }> }
>;
};
expect(parsed.providers?.["google-vertex"]?.api).toBe("google-vertex");
expect(parsed.providers?.["google-vertex"]?.apiKey).toBe("gcp-vertex-credentials");
expect(parsed.providers?.["google-vertex"]?.models?.map((model) => model.id)).toEqual([
"gemini-2.5-pro",
]);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
expect(plan.action).toBe("write");
if (plan.action !== "write") {
throw new Error("Expected models.json write plan");
}
const parsed = JSON.parse(plan.contents) as {
providers?: Record<
string,
{ apiKey?: string; api?: string; models?: Array<{ id?: string }> }
>;
};
expect(parsed.providers?.["google-vertex"]?.api).toBe("google-vertex");
expect(parsed.providers?.["google-vertex"]?.apiKey).toBe("gcp-vertex-credentials");
expect(parsed.providers?.["google-vertex"]?.models?.map((model) => model.id)).toEqual([
"gemini-2.5-pro",
]);
});
it("uses config env.vars entries for implicit provider discovery without mutating process.env", async () => {
@@ -371,6 +371,16 @@ let createSessionStatusTool: typeof import("./tools/session-status-tool.js").cre
beforeAll(async () => {
({ createSessionStatusTool } = await import("./tools/session-status-tool.js"));
resetSessionStore({
"agent:main:spawned": {
sessionId: "spawned-status-warmup",
updatedAt: 1,
spawnedWorkspaceDir: "/tmp/openclaw-spawned-workspace",
providerOverride: "anthropic",
modelOverride: "claude-opus-4-6",
},
});
await getSessionStatusTool("agent:main:spawned").execute("warm-spawned-workspace-status", {});
});
function resetSessionStore(store: Record<string, SessionEntry>) {
+2 -1
View File
@@ -1522,6 +1522,7 @@ describe("Tool Search", () => {
}, 5_000);
it("aborts already-started bridged calls when code mode times out", async () => {
testing.setToolSearchMinCodeTimeoutMsForTest(50);
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const target = pluginTool("fake_abort_on_timeout", "Long-running target tool");
let observedSignal: AbortSignal | undefined;
@@ -1554,7 +1555,7 @@ describe("Tool Search", () => {
const config = {
tools: {
toolSearch: { enabled: true, mode: "code", codeTimeoutMs: 1_000 },
toolSearch: { enabled: true, mode: "code", codeTimeoutMs: 100 },
},
} as never;
applyToolSearchCatalog({
+51 -33
View File
@@ -5,7 +5,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { isInboundPathAllowed } from "@openclaw/media-core/inbound-path-policy";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { ModelDefinitionConfig } from "../../config/types.models.js";
import { encodePngRgba, fillPixel } from "../../media/png-encode.js";
@@ -350,32 +350,7 @@ async function writeAuthProfiles(agentDir: string, profiles: unknown) {
}
async function createOpenClawCodingToolsWithFreshModules(options?: CreateOpenClawCodingToolsArgs) {
const defaultImageModels = new Map<string, string>([
["anthropic", "claude-opus-4-6"],
["minimax", "MiniMax-VL-01"],
["minimax-cn", "MiniMax-VL-01"],
["minimax-portal", "MiniMax-VL-01"],
["minimax-portal-cn", "MiniMax-VL-01"],
["codex", "gpt-5.5"],
["openai", "gpt-5.4-mini"],
["opencode", "gpt-5-nano"],
["opencode-go", "kimi-k2.6"],
["zai", "glm-4.6v"],
]);
testing.setProviderDepsForTest({
buildProviderRegistry: (overrides?: Record<string, MediaUnderstandingProvider>) =>
imageProviderHarness.buildProviderRegistry(overrides),
getMediaUnderstandingProvider: (
id: string,
registry: Map<string, MediaUnderstandingProvider>,
) => imageProviderHarness.getMediaUnderstandingProvider(id, registry),
describeImageWithModel: describeGenericImageWithModel,
describeImagesWithModel: describeGenericImagesWithModel,
resolveAutoMediaKeyProviders: ({ capability }) =>
capability === "image" ? ["openai", "anthropic"] : [],
resolveDefaultMediaModel: ({ providerId, capability }) =>
capability === "image" ? defaultImageModels.get(providerId.toLowerCase()) : undefined,
});
installFastLocalImageProviderStubs(minimaxProvider, moonshotProvider);
return createOpenClawCodingTools(options);
}
@@ -780,20 +755,29 @@ function installFastLocalImageProviderStubs(...providers: MediaUnderstandingProv
}),
loadImageWebMediaRuntime: async () => ({
loadWebMedia: async (mediaUrl, options) => {
const localRoots =
options && typeof options !== "number" && "localRoots" in options
? options.localRoots
: [];
const inboundRoots =
options && typeof options !== "number" && "inboundRoots" in options
? options.inboundRoots
: [];
if (
localRoots !== "any" &&
!isInboundPathAllowed({
filePath: mediaUrl,
roots: inboundRoots ?? [],
roots: [...(localRoots ?? []), ...(inboundRoots ?? [])],
})
) {
throw new Error(`Local media path is not under an allowed directory: ${mediaUrl}`);
}
const readFile =
options && typeof options !== "number" && "readFile" in options
? options.readFile
: undefined;
return {
buffer: await fs.readFile(mediaUrl),
buffer: readFile ? await readFile(mediaUrl) : await fs.readFile(mediaUrl),
contentType: "image/png",
kind: "image",
fileName: path.basename(mediaUrl),
@@ -982,6 +966,39 @@ describe("image tool implicit imageModel config", () => {
"GITHUB_TOKEN",
]);
beforeAll(async () => {
await withTempAgentDir(async (agentDir) => {
installImageUnderstandingProviderStubs();
await writeAuthProfiles(agentDir, {
version: 1,
profiles: {
"minimax-portal:default": {
type: "oauth",
provider: "minimax-portal",
access: "oauth-test",
refresh: "refresh-test",
expires: Date.now() + 60_000,
},
},
});
stubMinimaxOkFetch();
const tool = requireImageTool(
createImageTool({
agentDir,
config: {
agents: {
defaults: {
model: { primary: "minimax-portal/MiniMax-M2.7" },
imageModel: { primary: "minimax-portal/MiniMax-VL-01" },
},
},
},
}),
);
await expectImageToolExecOk(tool, `data:image/png;base64,${ONE_PIXEL_PNG_B64}`);
});
});
beforeEach(() => {
installImageUnderstandingProviderStubs(minimaxProvider, moonshotProvider);
});
@@ -1226,7 +1243,7 @@ describe("image tool implicit imageModel config", () => {
text: `ok ${params.provider}/${params.model}`,
model: params.model,
}));
installImageUnderstandingProviderStubs({
installFastLocalImageProviderStubs({
id: "opencode-go",
capabilities: ["image"],
describeImage,
@@ -1454,7 +1471,7 @@ describe("image tool implicit imageModel config", () => {
text: "ok",
model: params.model,
}));
installImageUnderstandingProviderStubs({
installFastLocalImageProviderStubs({
id: "ollama",
capabilities: ["image"],
describeImage,
@@ -1487,7 +1504,7 @@ describe("image tool implicit imageModel config", () => {
text: "ok",
model: params.model,
}));
installImageUnderstandingProviderStubs({
installFastLocalImageProviderStubs({
id: "ollama",
capabilities: ["image"],
describeImage,
@@ -1755,7 +1772,7 @@ describe("image tool implicit imageModel config", () => {
text: `ok ${params.model}`,
model: params.model,
}));
installImageUnderstandingProviderStubs({
installFastLocalImageProviderStubs({
id: "ollama",
capabilities: ["image"],
describeImage,
@@ -1869,6 +1886,7 @@ describe("image tool implicit imageModel config", () => {
it("sends moonshot image requests with user+image payloads only", async () => {
await withTempAgentDir(async (agentDir) => {
installFastLocalImageProviderStubs(minimaxProvider, moonshotProvider);
vi.stubEnv("MOONSHOT_API_KEY", "moonshot-test");
const fetch = stubOpenAiCompletionsOkFetch("ok moonshot");
const cfg: OpenClawConfig = {
@@ -117,6 +117,21 @@ describe("buildReplyPayloads media filter integration", () => {
plugin: createChannelTestPluginBase({ id: "discord" }),
source: "test",
},
{
pluginId: "feishu-plugin",
source: "test",
plugin: {
...createChannelTestPluginBase({ id: "feishu" }),
meta: {
id: "feishu",
label: "Feishu",
selectionLabel: "Feishu",
docsPath: "/channels/feishu",
blurb: "test stub",
aliases: ["lark"],
},
},
},
]),
);
});
@@ -445,28 +460,6 @@ describe("buildReplyPayloads media filter integration", () => {
});
it("delivers distinct same-target replies when target provider is channel alias", async () => {
resetPluginRuntimeStateForTest();
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "feishu-plugin",
source: "test",
plugin: {
id: "feishu",
meta: {
id: "feishu",
label: "Feishu",
selectionLabel: "Feishu",
docsPath: "/channels/feishu",
blurb: "test stub",
aliases: ["lark"],
},
capabilities: { chatTypes: ["direct"] },
config: { listAccountIds: () => [], resolveAccount: () => ({}) },
},
},
]),
);
await expectSameTargetRepliesDelivered({ provider: "lark", to: "ou_abc123" });
});
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ReplyPayload } from "../types.js";
import {
@@ -22,11 +22,14 @@ function setNoAbort() {
}
describe("dispatchReplyFromConfig stale visible admission recovery", () => {
beforeEach(async () => {
beforeAll(async () => {
({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js"));
({ createReplyOperation, __testing: replyRunTesting } =
await import("./reply-run-registry.js"));
({ resetInboundDedupe } = await import("./inbound-dedupe.js"));
});
beforeEach(() => {
replyRunTesting.resetReplyRunRegistry();
resetInboundDedupe();
resetPluginTtsAndThreadMocks();
@@ -2181,16 +2181,16 @@ describe("dispatchReplyFromConfig", () => {
dispatcher,
replyResolver,
});
let settled = false;
void resultPromise.finally(() => {
settled = true;
});
try {
const result = await Promise.race([
resultPromise,
new Promise<"blocked">((resolve) => {
setTimeout(() => resolve("blocked"), 1_000);
}),
]);
expect(result).toBe("blocked");
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(settled).toBe(false);
expect(replyResolver).not.toHaveBeenCalled();
} finally {
activeOperation.complete();
@@ -299,6 +299,9 @@ describe("runPreparedReply media-only handling", () => {
await import("./inbound-meta.js"));
({ testing: replyRunTesting, getActiveReplyRunCount } =
await import("./reply-run-registry.js"));
// Load deferred reply dependencies before per-case timing starts.
await runPreparedReply(baseParams());
});
beforeEach(async () => {
@@ -11,6 +11,7 @@ import {
writeSessionStoreForTestAsync,
} from "../../config/sessions/test-helpers.js";
import { getReplyPayloadMetadata } from "../reply-payload.js";
import { handleGoalCommand } from "./commands-goal.js";
import {
buildFastReplyCommandContext,
initFastReplySessionState,
@@ -35,7 +36,9 @@ function emptyAliasIndex(): ModelAliasIndex {
}
const mocks = vi.hoisted(() => ({
buildStatusReply: vi.fn(),
ensureAgentWorkspace: vi.fn(),
handleCommands: vi.fn(),
handleInlineActions: vi.fn(),
initSessionState: vi.fn(),
loadModelCatalog: vi.fn<LoadModelCatalogFn>(async () => [
@@ -49,6 +52,14 @@ const mocks = vi.hoisted(() => ({
resolveReplyDirectives: vi.fn(),
}));
vi.mock("./commands.runtime.js", () => ({
handleCommands: (...args: unknown[]) => mocks.handleCommands(...args),
}));
vi.mock("./commands-status.js", () => ({
buildStatusReply: (...args: unknown[]) => mocks.buildStatusReply(...args),
}));
vi.mock("../../agents/model-catalog.js", async () => {
const actual = await vi.importActual<typeof import("../../agents/model-catalog.js")>(
"../../agents/model-catalog.js",
@@ -132,7 +143,34 @@ describe("getReplyFromConfig fast test bootstrap", () => {
}),
resolveRuntimeCliBackends: () => [],
});
mocks.buildStatusReply.mockReset();
mocks.buildStatusReply.mockImplementation(async (params: unknown) => {
const status = params as {
cfg: OpenClawConfig;
resolvedThinkLevel?: string;
resolveDefaultThinkingLevel: () => Promise<string | undefined>;
sessionKey?: string;
};
const agentId = status.sessionKey?.split(":")[1];
const agentThinkingDefault = status.cfg.agents?.list?.find(
(agent) => agent.id === agentId,
)?.thinkingDefault;
const thinkLevel =
status.resolvedThinkLevel ??
agentThinkingDefault ??
status.cfg.agents?.defaults?.thinkingDefault ??
(await status.resolveDefaultThinkingLevel());
return { text: `OpenClaw\nThink: ${thinkLevel ?? "off"}` };
});
mocks.ensureAgentWorkspace.mockReset();
mocks.handleCommands.mockReset();
mocks.handleCommands.mockImplementation(async (params: unknown) => {
const result = await handleGoalCommand(
params as Parameters<typeof handleGoalCommand>[0],
true,
);
return result ?? { shouldContinue: true, reply: undefined };
});
mocks.handleInlineActions.mockReset();
mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: { text: "ok" } });
mocks.initSessionState.mockReset();
@@ -558,6 +596,7 @@ describe("getReplyFromConfig fast test bootstrap", () => {
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(mocks.initSessionState).not.toHaveBeenCalled();
expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled();
expect(mocks.handleCommands).toHaveBeenCalledOnce();
expect(mocks.resolveReplyDirectives).toHaveBeenCalledOnce();
const directiveParams = requireDirectiveParams();
expect(directiveParams.sessionKey).toBe(targetSessionKey);
+2
View File
@@ -433,6 +433,8 @@ async function runConfigCommand(args: string[]) {
describe("config cli", () => {
beforeAll(async () => {
({ registerConfigCli } = await import("./config-cli.js"));
const { resolveConfigSecretTargetByPath } = await import("../secrets/target-registry.js");
resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]);
sharedProgram = new Command();
sharedProgram.exitOverride();
registerConfigCli(sharedProgram);
+8 -1
View File
@@ -3,7 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { withEnvOverride } from "../config/test-helpers.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { registerGatewayCli } from "./gateway-cli.js";
@@ -147,6 +147,13 @@ function firstMockArg(mock: { mock: { calls: ReadonlyArray<ReadonlyArray<unknown
}
describe("gateway-cli coverage", () => {
beforeAll(async () => {
// Gateway startup intentionally primes this large graph before installing
// signal handlers. Load it as suite setup so failure-path timings measure
// the lifecycle behavior rather than the one-time module parse.
await import("./gateway-cli/lifecycle.runtime.js");
});
beforeEach(() => {
gatewayProgram = createGatewayProgram();
runtimeLogs.length = 0;
+35
View File
@@ -68,6 +68,9 @@ const legacyConfigRepairMocks = vi.hoisted(() => ({
const launchdUpdateCleanupMocks = vi.hoisted(() => ({
disableCurrentOpenClawUpdateLaunchdJob: vi.fn(async () => false),
}));
const restartHealthTestControl = vi.hoisted(() => ({
snapshot: undefined as unknown,
}));
const nodeVersionSatisfiesEngine = vi.fn();
const execFile = vi.fn((...args: unknown[]) => {
const callback = args.at(-1);
@@ -340,6 +343,23 @@ vi.mock("./update-cli/restart-helper.js", () => ({
runRestartScript: (...args: unknown[]) => runRestartScript(...args),
}));
vi.mock("./daemon-cli/restart-health.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./daemon-cli/restart-health.js")>();
return {
...actual,
waitForGatewayHealthyRestart: (
...args: Parameters<typeof actual.waitForGatewayHealthyRestart>
) =>
restartHealthTestControl.snapshot === undefined
? actual.waitForGatewayHealthyRestart(...args)
: Promise.resolve(
restartHealthTestControl.snapshot as Awaited<
ReturnType<typeof actual.waitForGatewayHealthyRestart>
>,
),
};
});
// Mock doctor (heavy module; should not run in unit tests)
vi.mock("../commands/doctor.js", () => ({
doctorCommand: vi.fn(),
@@ -781,6 +801,7 @@ describe("update-cli", () => {
delete process.env.OPENCLAW_SERVICE_MARKER;
delete process.env.OPENCLAW_SERVICE_KIND;
delete process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV];
restartHealthTestControl.snapshot = undefined;
vi.clearAllMocks();
resetRuntimeCapture();
spawn.mockImplementation(() => {
@@ -3722,6 +3743,20 @@ describe("update-cli", () => {
pid: 4242,
state: "running",
});
restartHealthTestControl.snapshot = {
runtime: { status: "stopped", pid: null, state: "stopped" },
portUsage: {
port: 18789,
status: "busy",
listeners: [{ pid: 4242, command: "openclaw-gateway" }],
hints: [],
},
healthy: true,
staleGatewayPids: [],
gatewayVersion: "1.0.0",
waitOutcome: "timeout",
elapsedMs: 60_000,
};
mockGitUpdateAfterMutation();
await updateCommand({ yes: true });
+9 -2
View File
@@ -2,7 +2,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { writeStateDirDotEnv } from "../config/test-helpers.js";
import type { OpenClawConfig } from "../config/types.js";
import { collectPreservedExistingServiceEnvVars } from "./daemon-install-helpers.js";
@@ -171,6 +171,11 @@ function mockNodeGatewayPlanFixture(
}
describe("buildGatewayInstallPlan", () => {
beforeAll(async () => {
const { resolveConfigSecretTargetByPath } = await import("../secrets/target-registry.js");
resolveConfigSecretTargetByPath(["channels", "discord", "token"]);
});
// Prevent tests from reading the developer's real ~/.openclaw/.env when
// passing `env: {}` (which falls back to os.homedir for state-dir resolution).
let isolatedHome: string;
@@ -410,6 +415,7 @@ describe("buildGatewayInstallPlan", () => {
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe(
"CUSTOM_VAR,GOOGLE_API_KEY,SAFE_KEY",
);
expect(mocks.loadPluginManifestRegistryForPluginRegistry).not.toHaveBeenCalled();
});
it("renders config env SecretRefs as file-backed managed values on Linux", async () => {
@@ -1435,7 +1441,8 @@ describe("buildGatewayInstallPlan — dotenv merge", () => {
},
});
const home = "/home/testuser";
// Avoid macOS /home autofs lookups while exercising the same user-tool paths.
const home = "/Users/testuser";
const plan = await buildGatewayInstallPlan({
env: { HOME: tmpDir },
port: 3000,
+51 -20
View File
@@ -6,7 +6,7 @@ import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import { formatCliCommand } from "../cli/command-format.js";
import { collectDurableServiceEnvVarSources } from "../config/state-dir-dotenv.js";
import type { OpenClawConfig } from "../config/types.js";
import { resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js";
import { coerceSecretRef, resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import { resolveGatewayStateDir, resolveGatewayTaskScriptPath } from "../daemon/paths.js";
import {
@@ -67,6 +67,27 @@ const NON_PERSISTED_CONFIG_SECRET_ENV_TARGET_IDS = new Set([
]);
const EXEC_SECRET_REF_PASS_ENV_ALLOWED_OVERRIDE_ONLY_KEYS = new Set(["HOME"]);
function configContainsSecretRef(config: OpenClawConfig | undefined): boolean {
if (!config) {
return false;
}
const pending: unknown[] = [config];
const seen = new Set<object>();
const defaults = config.secrets?.defaults;
while (pending.length > 0) {
const value = pending.pop();
if (coerceSecretRef(value, defaults)) {
return true;
}
if (!value || typeof value !== "object" || seen.has(value)) {
continue;
}
seen.add(value);
pending.push(...Object.values(value));
}
return false;
}
function isBlockedExecSecretRefPassEnvKey(key: string): boolean {
if (isDangerousHostEnvVarName(key)) {
return true;
@@ -162,10 +183,11 @@ type ExecSecretRefPassEnvSource = {
function collectConfigSecretRefServiceEnvVars(params: {
env: Record<string, string | undefined>;
config?: OpenClawConfig;
configContainsSecretRef: boolean;
stateDirDotEnvEnvironment: Record<string, string | undefined>;
warn?: DaemonInstallWarnFn;
}): Record<string, string> {
if (!params.config) {
if (!params.config || !params.configContainsSecretRef) {
return {};
}
const entries: Record<string, string> = {};
@@ -214,6 +236,7 @@ function collectConfigSecretRefServiceEnvVars(params: {
function collectExecSecretRefPassEnvServiceEnvVars(params: {
env: Record<string, string | undefined>;
config?: OpenClawConfig;
configContainsSecretRef: boolean;
authStore?: AuthProfileStore;
durableEnvironment: Record<string, string | undefined>;
warn?: DaemonInstallWarnFn;
@@ -224,31 +247,35 @@ function collectExecSecretRefPassEnvServiceEnvVars(params: {
const entries: Record<string, string> = {};
let manifestRegistry: Pick<PluginManifestRegistry, "plugins"> | undefined;
const sources: ExecSecretRefPassEnvSource[] = [];
for (const target of discoverConfigSecretTargets(params.config)) {
if (!target.entry.includeInPlan) {
continue;
if (params.configContainsSecretRef) {
for (const target of discoverConfigSecretTargets(params.config)) {
if (!target.entry.includeInPlan) {
continue;
}
const { ref } = resolveSecretInputRef({
value: target.value,
refValue: target.refValue,
defaults: params.config.secrets?.defaults,
});
if (!ref || ref.source !== "exec") {
continue;
}
sources.push({ ref, warningTitle: "Config SecretRef" });
}
const { ref } = resolveSecretInputRef({
value: target.value,
refValue: target.refValue,
defaults: params.config.secrets?.defaults,
});
if (!ref || ref.source !== "exec") {
continue;
}
sources.push({ ref, warningTitle: "Config SecretRef" });
}
for (const ref of collectAuthProfileSecretRefs(params.authStore)) {
if (ref.source === "exec") {
sources.push({ ref, warningTitle: "Auth profile" });
}
}
for (const ref of collectPluginConfigSecretRefs({
env: params.env,
config: params.config,
})) {
if (ref.source === "exec") {
sources.push({ ref, warningTitle: "Plugin config SecretRef" });
if (params.configContainsSecretRef) {
for (const ref of collectPluginConfigSecretRefs({
env: params.env,
config: params.config,
})) {
if (ref.source === "exec") {
sources.push({ ref, warningTitle: "Plugin config SecretRef" });
}
}
}
for (const { ref, warningTitle } of sources) {
@@ -543,9 +570,12 @@ async function buildGatewayInstallEnvironment(params: {
env: params.env,
config: params.config,
});
// Full target discovery materializes plugin metadata; configs without refs do not need it.
const containsConfigSecretRef = configContainsSecretRef(params.config);
const configSecretRefEnvironment = collectConfigSecretRefServiceEnvVars({
env: params.env,
config: params.config,
configContainsSecretRef: containsConfigSecretRef,
stateDirDotEnvEnvironment,
warn: params.warn,
});
@@ -553,6 +583,7 @@ async function buildGatewayInstallEnvironment(params: {
const execSecretRefPassEnvEnvironment = collectExecSecretRefPassEnvServiceEnvVars({
env: params.env,
config: params.config,
configContainsSecretRef: containsConfigSecretRef,
authStore,
durableEnvironment,
warn: params.warn,
+9
View File
@@ -1501,7 +1501,16 @@ describe("doctor config flow", () => {
import("./doctor/shared/legacy-config-issues.js"),
import("./doctor/shared/plugin-tool-allowlist-warnings.js"),
import("./doctor/shared/preview-warnings.js"),
import("./doctor/shared/hooks-token-reuse-repair.js"),
]);
await collectDoctorWarnings({
channels: {
slack: {
dangerouslyAllowNameMatching: true,
accounts: { work: { allowFrom: ["alice"] } },
},
},
});
});
beforeEach(() => {
@@ -0,0 +1,39 @@
// Repairs canonical binding references after agent config migration.
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { normalizeAgentId } from "../../../routing/session-key.js";
export function pruneBindingsForMissingAgents(
cfg: OpenClawConfig,
changes: string[],
): OpenClawConfig {
const agents = cfg.agents?.list;
const bindings = cfg.bindings;
if (!Array.isArray(agents) || agents.length === 0 || !Array.isArray(bindings)) {
return cfg;
}
const validAgents = agents.filter((agent): agent is { id: string } => {
return agent !== null && typeof agent === "object" && typeof agent.id === "string";
});
if (validAgents.length !== agents.length) {
return cfg;
}
const agentIds = new Set(validAgents.map((agent) => normalizeAgentId(agent.id)));
const nextBindings = bindings.filter((binding) => {
const agentId = binding && typeof binding === "object" ? binding.agentId : undefined;
return typeof agentId !== "string" || agentIds.has(normalizeAgentId(agentId));
});
const removed = bindings.length - nextBindings.length;
if (removed === 0) {
return cfg;
}
changes.push(
`Removed ${removed} binding${removed === 1 ? "" : "s"} that referenced missing agents.list ids.`,
);
return {
...cfg,
...(nextBindings.length > 0 ? { bindings: nextBindings } : { bindings: undefined }),
};
}
@@ -1,9 +1,9 @@
// Core doctor compatibility migration pipeline for current config objects.
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { runPluginSetupConfigMigrations } from "../../../plugins/setup-registry.js";
import { normalizeAgentId } from "../../../routing/session-key.js";
import { migrateLegacySecretRefEnvMarkers } from "../../../secrets/legacy-secretref-env-marker.js";
import { applyChannelDoctorCompatibilityMigrations } from "./channel-legacy-config-migrate.js";
import { pruneBindingsForMissingAgents } from "./legacy-config-binding-repair.js";
import { normalizeBaseCompatibilityConfigValues } from "./legacy-config-compatibility-base.js";
import {
normalizeLegacyCommandsConfig,
@@ -18,7 +18,11 @@ function repairNullAgentWorkspaces(cfg: OpenClawConfig, changes: string[]): Open
let repaired = 0;
const nextAgents = agents.map((agent) => {
if (agent && typeof agent === "object" && (agent as Record<string, unknown>).workspace === null) {
if (
agent &&
typeof agent === "object" &&
(agent as Record<string, unknown>).workspace === null
) {
repaired += 1;
const { workspace: _workspace, ...rest } = agent as Record<string, unknown>;
return rest;
@@ -44,39 +48,6 @@ function repairNullAgentWorkspaces(cfg: OpenClawConfig, changes: string[]): Open
};
}
function pruneBindingsForMissingAgents(cfg: OpenClawConfig, changes: string[]): OpenClawConfig {
const agents = cfg.agents?.list;
const bindings = cfg.bindings;
if (!Array.isArray(agents) || agents.length === 0 || !Array.isArray(bindings)) {
return cfg;
}
const validAgents = agents.filter((agent): agent is { id: string } => {
return agent !== null && typeof agent === "object" && typeof agent.id === "string";
});
if (validAgents.length !== agents.length) {
return cfg;
}
const agentIds = new Set(validAgents.map((agent) => normalizeAgentId(agent.id)));
const nextBindings = bindings.filter((binding) => {
const agentId = binding && typeof binding === "object" ? binding.agentId : undefined;
return typeof agentId !== "string" || agentIds.has(normalizeAgentId(agentId));
});
const removed = bindings.length - nextBindings.length;
if (removed === 0) {
return cfg;
}
changes.push(
`Removed ${removed} binding${removed === 1 ? "" : "s"} that referenced missing agents.list ids.`,
);
return {
...cfg,
...(nextBindings.length > 0 ? { bindings: nextBindings } : { bindings: undefined }),
};
}
/** Normalize current config through core, plugin setup, channel, and secret-ref migrations. */
export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
config: OpenClawConfig;
@@ -2,9 +2,14 @@
import { describe, expect, it } from "vitest";
import { findLegacyConfigIssues } from "../../../config/legacy.js";
import type { OpenClawConfig } from "../../../config/types.js";
import { normalizeCompatibilityConfigValues } from "./legacy-config-core-migrate.js";
import { pruneBindingsForMissingAgents } from "./legacy-config-binding-repair.js";
import { LEGACY_CONFIG_MIGRATIONS } from "./legacy-config-migrations.js";
function repairBindingsForTest(config: OpenClawConfig) {
const changes: string[] = [];
return { config: pruneBindingsForMissingAgents(config, changes), changes };
}
function migrateLegacyConfigForTest(raw: unknown): {
config: OpenClawConfig | null;
changes: string[];
@@ -31,7 +36,7 @@ function expectMigrationChangesToIncludeFragments(changes: string[], fragments:
describe("compatibility binding repair migrate", () => {
it("prunes bindings for missing agents when agents.list is valid", () => {
const res = normalizeCompatibilityConfigValues({
const res = repairBindingsForTest({
agents: {
list: [{ id: "alpha" }],
},
@@ -56,7 +61,7 @@ describe("compatibility binding repair migrate", () => {
],
} as unknown as OpenClawConfig;
const res = normalizeCompatibilityConfigValues(cfg);
const res = repairBindingsForTest(cfg);
expect(res.config.bindings).toEqual(cfg.bindings);
expect(res.changes).not.toContain("Removed 1 binding that referenced missing agents.list ids.");
@@ -2,7 +2,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveRegistryUpdateChannel } from "../../../infra/update-channels.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "../../../plugins/clawhub-error-codes.js";
import {
@@ -191,6 +191,12 @@ vi.mock("../../../plugins/update.js", async (importOriginal) => {
});
describe("repairMissingConfiguredPluginInstalls", () => {
beforeAll(async () => {
// The doctor module owns a broad install/catalog graph. Its cold import is
// suite setup; individual cases measure detection and repair behavior.
await import("./missing-configured-plugin-install.js");
});
beforeEach(() => {
vi.clearAllMocks();
mocks.loadPluginMetadataSnapshot.mockReturnValue({
+7 -1
View File
@@ -1,6 +1,6 @@
// Verifies pruning-related config defaults and migrations.
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { setBundledPluginsDirOverrideForTest } from "../plugins/bundled-dir.js";
import { resetBundledPluginPublicArtifactLoaderForTest } from "../plugins/public-surface-loader.js";
import type { OpenClawConfig } from "./config.js";
@@ -17,6 +17,12 @@ function applyAnthropicDefaultsForTest(config: OpenClawConfig) {
}
describe("config pruning defaults", () => {
beforeAll(() => {
setBundledPluginsDirOverrideForTest(path.resolve(import.meta.dirname, "../../extensions"));
// Provider public artifacts are process-stable. Keep their one-time load out of assertions.
applyAnthropicDefaultsForTest({ agents: { defaults: {} } });
});
beforeEach(() => {
setBundledPluginsDirOverrideForTest(path.resolve(import.meta.dirname, "../../extensions"));
resetBundledPluginPublicArtifactLoaderForTest();
+1 -1
View File
@@ -169,7 +169,7 @@ export function applyModelDefaults(
const normalizedProvider = normalizeProviderConfigForConfigDefaults({
provider: providerId,
providerConfig: provider,
manifestRegistry: options.manifestRegistry,
manifestRegistry,
});
const models = normalizedProvider.models;
if (!Array.isArray(models) || models.length === 0) {
+14 -1
View File
@@ -1,7 +1,9 @@
// Covers best-effort config IO reads and warning behavior.
import fs from "node:fs/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import { setBundledPluginsDirOverrideForTest } from "../plugins/bundled-dir.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
closeOpenClawStateDatabaseForTest,
@@ -14,6 +16,7 @@ import {
readConfigFileSnapshot,
readSourceConfigBestEffort,
} from "./config.js";
import { applyProviderConfigDefaultsForConfig } from "./provider-policy.js";
import { withTempHome, writeOpenClawConfig } from "./test-helpers.js";
type ConfigHealthDatabase = Pick<OpenClawStateKyselyDatabase, "config_health_entries">;
@@ -31,10 +34,20 @@ function readConfigHealthRow(env: NodeJS.ProcessEnv, configPath: string) {
}
describe("readBestEffortConfig", () => {
beforeAll(() => {
setBundledPluginsDirOverrideForTest(path.resolve(import.meta.dirname, "../../extensions"));
// Materialized reads use the process-stable provider policy cache.
applyProviderConfigDefaultsForConfig({ provider: "anthropic", config: {}, env: {} });
});
afterEach(() => {
closeOpenClawStateDatabaseForTest();
});
afterAll(() => {
setBundledPluginsDirOverrideForTest(undefined);
});
it("can read snapshots without updating config observation state", async () => {
await withTempHome(async (home) => {
const configPath = await writeOpenClawConfig(home, {
+39 -2
View File
@@ -1,13 +1,50 @@
// Verifies default model alias config values and overrides.
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { applyModelDefaults } from "./defaults.js";
import { applyModelDefaults as applyModelDefaultsWithPolicy } from "./defaults.js";
import type { OpenClawConfig } from "./types.js";
import { validateConfigObjectWithPlugins } from "./validation.js";
const emptyManifestRegistry = { plugins: [] } satisfies Pick<PluginManifestRegistry, "plugins">;
function applyModelDefaults(
cfg: OpenClawConfig,
options?: Parameters<typeof applyModelDefaultsWithPolicy>[1],
) {
return applyModelDefaultsWithPolicy(cfg, options ?? { manifestRegistry: emptyManifestRegistry });
}
describe("applyModelDefaults", () => {
beforeAll(() => {
vi.stubEnv(
"OPENCLAW_BUNDLED_PLUGINS_DIR",
path.resolve(import.meta.dirname, "../../extensions"),
);
// Provider public artifacts are process-stable. Keep their one-time load out of assertions.
applyModelDefaults({
models: {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
models: [],
},
},
},
});
applyModelDefaults({
models: {
providers: {
anthropic: {
baseUrl: "https://api.anthropic.com",
models: [],
},
},
},
});
});
beforeEach(() => {
vi.stubEnv(
"OPENCLAW_BUNDLED_PLUGINS_DIR",
+3
View File
@@ -1,4 +1,7 @@
/** Shared Vitest module mocks for isolated-agent cron tests. */
// Isolated turns lazily consume this process-stable runtime after agent execution. Load it during
// suite setup so first-test timings cover cron behavior rather than module initialization.
import "../utils/usage-format.js";
import { vi } from "vitest";
vi.mock("../agents/embedded-agent.js", () => ({
+19 -2
View File
@@ -5,6 +5,7 @@ import nodePath from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DoctorPrompter } from "../commands/doctor-prompter.js";
import { CORE_HEALTH_CHECKS } from "./doctor-core-checks.js";
import "./doctor-tool-result-cap-advice.js";
import {
createDoctorHealthContribution,
resolveDoctorContributionHealthChecks,
@@ -1558,12 +1559,22 @@ describe("doctor health contributions", () => {
mode: "lint",
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
} as const;
const checks = [toolResultCapCheck!];
const detect = vi.fn(async () => [
{
checkId: "core/doctor/tool-result-cap",
severity: "warning" as const,
message: "Configured tool result cap overrides the model-window default.",
path: "agents.defaults.contextLimits.toolResultMaxChars",
},
]);
// This case owns lint selection; the real cap detector is exercised below.
const checks = [{ ...toolResultCapCheck!, detect }];
await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({
checksRun: 0,
checksSkipped: 1,
});
expect(detect).not.toHaveBeenCalled();
await expect(
runDoctorLintChecks(ctx, { checks, includeAllChecks: true }),
).resolves.toMatchObject({
@@ -1582,6 +1593,7 @@ describe("doctor health contributions", () => {
checksRun: 1,
checksSkipped: 0,
});
expect(detect).toHaveBeenCalledTimes(2);
});
it("keeps stale plugin-runtime symlinks opt-in for structured lint selection", async () => {
@@ -1741,17 +1753,21 @@ describe("doctor health contributions", () => {
expect(stateIntegrityCheck).toMatchObject({ defaultEnabled: false });
expect(stateIntegrityCheck).toBeDefined();
const detect = vi.fn(async () => []);
const ctx = {
cfg: {},
mode: "lint",
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
} as const;
const checks = [stateIntegrityCheck!];
// Selection behavior does not need the real state-integrity filesystem scan.
const checks = [{ ...stateIntegrityCheck!, detect }];
await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({
checksRun: 0,
checksSkipped: 1,
});
expect(detect).not.toHaveBeenCalled();
await expect(
runDoctorLintChecks(ctx, { checks, includeAllChecks: true }),
).resolves.toMatchObject({
@@ -1764,6 +1780,7 @@ describe("doctor health contributions", () => {
checksRun: 1,
checksSkipped: 0,
});
expect(detect).toHaveBeenCalledTimes(2);
});
it("collects memory-search notes as structured findings", async () => {
+2 -2
View File
@@ -977,7 +977,7 @@ describe("createGatewayCloseHandler", () => {
createGatewayCloseTestDeps({
broadcast,
chatAbortControllers,
getPendingReplyCount: () => 1,
getPendingReplyCount: vi.fn().mockReturnValueOnce(1).mockReturnValue(0),
markMainSessionsAbortedForRestart,
nodeSendToSession,
}),
@@ -1031,7 +1031,7 @@ describe("createGatewayCloseHandler", () => {
const close = createGatewayCloseHandler(
createGatewayCloseTestDeps({
chatAbortControllers,
getPendingReplyCount: () => 1,
getPendingReplyCount: vi.fn().mockReturnValueOnce(1).mockReturnValue(0),
markMainSessionsAbortedForRestart,
}),
);
+8 -1
View File
@@ -1,7 +1,8 @@
/**
* Tests for gateway secret resolution and redacted secret method responses.
*/
import { describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { isKnownSecretTargetId } from "../../secrets/target-registry.js";
import {
TALK_TEST_PROVIDER_API_KEY_PATH,
TALK_TEST_PROVIDER_API_KEY_PATH_SEGMENTS,
@@ -93,6 +94,12 @@ async function expectMemoryStatusResolveUnavailable(params: {
}
describe("secrets handlers", () => {
beforeAll(() => {
// Plugin target metadata is process-stable. Load it as suite setup so the
// unknown-id assertion measures handler validation, not manifest discovery.
isKnownSecretTargetId("unknown.target");
});
function createHandlers(overrides?: {
reloadSecrets?: () => Promise<{ warningCount: number }>;
resolveSecrets?: (params: {
+2 -2
View File
@@ -7,7 +7,7 @@ import {
validateSecretsResolveParams,
validateSecretsResolveResult,
} from "../../../packages/gateway-protocol/src/index.js";
import { isKnownSecretTargetId } from "../../secrets/target-registry.js";
import { isKnownCoreSecretTargetId, isKnownSecretTargetId } from "../../secrets/target-registry.js";
import type { GatewayRequestHandlers } from "./types.js";
function errorMessage(error: unknown): string {
@@ -132,7 +132,7 @@ export function createSecretsHandlers(params: {
// Target ids are a closed registry. Reject unknown ids before resolving
// so callers cannot probe arbitrary config paths through this method.
for (const targetId of targetIds) {
if (!isKnownSecretTargetId(targetId)) {
if (!isKnownCoreSecretTargetId(targetId) && !isKnownSecretTargetId(targetId)) {
respond(
false,
undefined,
+19 -1
View File
@@ -1,6 +1,6 @@
// Startup log tests cover security warnings, model detail formatting, plugin
// summaries, bind URLs, ANSI output, and dangerous config reporting.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
import { formatAgentModelStartupDetails, logGatewayStartup } from "./server-startup-log.js";
@@ -15,6 +15,24 @@ vi.mock("../plugins/plugin-registry.js", async (importOriginal) => ({
}));
describe("gateway startup log", () => {
beforeAll(() => {
formatAgentModelStartupDetails({
cfg: {
models: {
providers: {
google: {
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
models: [],
},
},
},
},
provider: "google",
model: "gemma-4-26b-a4b-it",
});
});
beforeEach(() => {
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReset();
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({
+28 -1
View File
@@ -1,7 +1,7 @@
// Session utility performance tests protect resolver cache scaling for large
// session lists with repeated provider/model tuples.
import path from "node:path";
import { describe, test, expect, vi } from "vitest";
import { beforeAll, describe, test, expect, vi } from "vitest";
import * as thinking from "../auto-reply/thinking.js";
import type { OpenClawConfig } from "../config/config.js";
import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js";
@@ -24,6 +24,33 @@ import { listSessionsFromStore } from "./session-utils.js";
* are the actual scaling failure mode we care about.
*/
describe("listSessionsFromStore resolver cache", () => {
beforeAll(async () => {
await withStateDirEnv("openclaw-perf-warm-", async ({ stateDir }) => {
resetPluginRuntimeStateForTest();
setActivePluginRegistry(createEmptyPluginRegistry());
const cfg = {
agents: { defaults: { model: { primary: "google-vertex/gemini-3-flash-preview" } } },
} as OpenClawConfig;
resetConfigRuntimeState();
setRuntimeConfigSnapshot(cfg);
listSessionsFromStore({
cfg,
storePath: path.join(stateDir, "sessions.json"),
store: {
google: {
updatedAt: 1,
modelProvider: "google-vertex",
model: "gemini-3-flash-preview",
},
openai: { updatedAt: 1, modelProvider: "openai", model: "gpt-5" },
anthropic: { updatedAt: 1, modelProvider: "anthropic", model: "claude-opus-4-7" },
openrouter: { updatedAt: 1, modelProvider: "openrouter", model: "z-ai/glm-5" },
},
opts: {},
});
});
});
test("collapses non-lightweight per-row resolver work to O(unique provider/model tuples)", async () => {
await withStateDirEnv("openclaw-perf-", async ({ stateDir }) => {
resetPluginRuntimeStateForTest();
+16 -1
View File
@@ -3,7 +3,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, beforeAll, describe, expect, test } from "vitest";
import {
addSubagentRunForTests,
resetSubagentRegistryForTests,
@@ -325,6 +325,21 @@ function childTranscriptEntry(sessionId: string, now: number): SessionEntry {
}
describe("listSessionsFromStore search", () => {
beforeAll(() => {
listSessionsFromStore({
cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }),
storePath: "/tmp/sessions.json",
store: {
"agent:main:main": {
updatedAt: 1,
modelProvider: "openai",
model: "gpt-5.4",
},
},
opts: { search: "openai" },
});
});
afterEach(() => {
resetSubagentRegistryForTests();
resetAgentRunContextForTest();
+21 -1
View File
@@ -3,7 +3,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import { writeAcpSessionMetaForMigration } from "../acp/runtime/session-meta.js";
import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js";
import type { OpenClawConfig } from "../config/config.js";
@@ -100,6 +100,24 @@ function expectFields(value: unknown, expected: Record<string, unknown>): void {
}
describe("gateway session utils", () => {
beforeAll(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
listSessionsFromStore({
cfg: createModelDefaultsConfig({ primary: "anthropic/claude-sonnet-4.6" }),
storePath: "",
store: {
"agent:main:main": {
updatedAt: 1,
modelProvider: "anthropic",
model: "claude-sonnet-4.6",
},
},
opts: {},
});
resetConfigRuntimeState();
resetPluginRuntimeStateForTest();
});
afterEach(() => {
resetConfigRuntimeState();
resetPluginRuntimeStateForTest();
@@ -403,6 +421,8 @@ describe("gateway session utils", () => {
storePath: "",
store: {},
key: "agent:main:main",
lightweightListRow: true,
skipTranscriptUsageFallback: true,
entry: {
sessionId: "session-1",
updatedAt: 1,
+61 -52
View File
@@ -850,62 +850,71 @@ describe("state migrations", () => {
);
});
it("preserves plugin ownership captured before an aliased store rewrite", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const targetStorePath = path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json");
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
await fs.writeFile(
targetStorePath,
JSON.stringify({
"agent:main:desk": { sessionId: "foreign-main", updatedAt: 30 },
"agent:worker-1:main": {
sessionId: "worker-main",
updatedAt: 20,
acp: {
backend: "test",
agent: "worker-1",
runtimeSessionName: "legacy-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 20,
describe("aliased store ownership", () => {
let configuredStorePath: string;
let targetStorePath: string;
let targetStore: Record<string, { sessionId: string }>;
let result: Awaited<ReturnType<typeof autoMigrateLegacyState>>;
beforeAll(async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
targetStorePath = path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json");
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
await fs.writeFile(
targetStorePath,
JSON.stringify({
"agent:main:desk": { sessionId: "foreign-main", updatedAt: 30 },
"agent:worker-1:main": {
sessionId: "worker-main",
updatedAt: 20,
acp: {
backend: "test",
agent: "worker-1",
runtimeSessionName: "legacy-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 20,
},
},
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 10 },
}),
"utf8",
);
configuredStorePath = path.join(root, "configured-sessions.json");
await fs.link(targetStorePath, configuredStorePath);
const cfg = {
agents: { list: [{ id: "worker-1", default: true }] },
session: { mainKey: "desk", store: configuredStorePath },
plugins: {
entries: {
"voice-call": { config: { agentId: "worker-1" } },
},
},
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 10 },
}),
"utf8",
);
const configuredStorePath = path.join(root, "configured-sessions.json");
await fs.link(targetStorePath, configuredStorePath);
const cfg = {
agents: { list: [{ id: "worker-1", default: true }] },
session: { mainKey: "desk", store: configuredStorePath },
plugins: {
entries: {
"voice-call": { config: { agentId: "worker-1" } },
},
},
} as OpenClawConfig;
} as OpenClawConfig;
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
targetStore = JSON.parse(await fs.readFile(targetStorePath, "utf8")) as Record<
string,
{ sessionId: string }
>;
});
const targetStore = JSON.parse(await fs.readFile(targetStorePath, "utf8")) as Record<
string,
{ sessionId: string }
>;
expect(targetStore["agent:main:desk"]?.sessionId).toBe("foreign-main");
expect(targetStore["agent:worker-1:main"]?.sessionId).toBe("worker-main");
expect(targetStore["agent:worker-1:desk"]).toBeUndefined();
expect(targetStore["agent:worker-1:main"]).toHaveProperty("acp");
expect(fsSync.statSync(configuredStorePath).ino).toBe(fsSync.statSync(targetStorePath).ino);
expect(result.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining(`aliased store ${configuredStorePath}`),
expect.stringContaining(`aliased store ${targetStorePath}`),
expect.stringContaining("Deferred ACP metadata migration"),
]),
);
it("preserves plugin ownership captured before an aliased store rewrite", () => {
expect(targetStore["agent:main:desk"]?.sessionId).toBe("foreign-main");
expect(targetStore["agent:worker-1:main"]?.sessionId).toBe("worker-main");
expect(targetStore["agent:worker-1:desk"]).toBeUndefined();
expect(targetStore["agent:worker-1:main"]).toHaveProperty("acp");
expect(fsSync.statSync(configuredStorePath).ino).toBe(fsSync.statSync(targetStorePath).ino);
expect(result.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining(`aliased store ${configuredStorePath}`),
expect.stringContaining(`aliased store ${targetStorePath}`),
expect.stringContaining("Deferred ACP metadata migration"),
]),
);
});
});
it("preserves a singleton final symlink through all session migration phases", async () => {
@@ -50,7 +50,11 @@ describe("Anthropic Cloudflare guard-specific SSRF blocking proof", () => {
} satisfies Model<"anthropic-messages">;
const { streamAnthropic } = await import("@openclaw/ai/internal/anthropic");
const stream = streamAnthropic(blockedModel, context, { apiKey: "sk-ant-test" });
const stream = streamAnthropic(blockedModel, context, {
apiKey: "sk-ant-test",
// Retries only repeat the same deterministic guard rejection.
maxRetries: 0,
});
const result = await stream.result();
expect(result.stopReason).toBe("error");
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it, vi } from "vitest";
describe("logging state", () => {
it("stays process-local across module reloads", async () => {
const first = await import("./state.js");
vi.resetModules();
const second = await import("./state.js");
expect(second.loggingState).toBe(first.loggingState);
});
});
+32 -19
View File
@@ -1,20 +1,33 @@
// Process-local logging state shared by logger, console capture, and test reset helpers.
export const loggingState = {
cachedLogger: null as unknown,
cachedSettings: null as unknown,
cachedConsoleSettings: null as unknown,
overrideSettings: null as unknown,
invalidEnvLogLevelValue: null as string | null,
consolePatched: false,
forceConsoleToStderr: false,
consoleTimestampPrefix: false,
consoleSubsystemFilter: null as string[] | null,
resolvingConsoleSettings: false,
streamErrorHandlersInstalled: false,
rawConsole: null as {
log: typeof console.log;
info: typeof console.info;
warn: typeof console.warn;
error: typeof console.error;
} | null,
};
const LOGGING_STATE_KEY = Symbol.for("openclaw.loggingState");
function createLoggingState() {
return {
cachedLogger: null as unknown,
cachedSettings: null as unknown,
cachedConsoleSettings: null as unknown,
overrideSettings: null as unknown,
invalidEnvLogLevelValue: null as string | null,
consolePatched: false,
forceConsoleToStderr: false,
consoleTimestampPrefix: false,
consoleSubsystemFilter: null as string[] | null,
resolvingConsoleSettings: false,
streamErrorHandlersInstalled: false,
rawConsole: null as {
log: typeof console.log;
info: typeof console.info;
warn: typeof console.warn;
error: typeof console.error;
} | null,
};
}
type LoggingState = ReturnType<typeof createLoggingState>;
const globalStore = globalThis as Record<PropertyKey, unknown>;
// Test runners can reload modules without creating a new process. Keep one
// state object so overrides and caches remain coherent across those copies.
export const loggingState =
(globalStore[LOGGING_STATE_KEY] as LoggingState | undefined) ?? createLoggingState();
globalStore[LOGGING_STATE_KEY] = loggingState;
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it } from "vitest";
import {
emitDiagnosticEvent,
resetDiagnosticEventsForTest,
} from "../../infra/diagnostic-events.js";
import { createDiagnosticLogRecordCapture } from "./diagnostic-log-capture.js";
describe("diagnostic log capture", () => {
afterEach(() => {
resetDiagnosticEventsForTest();
});
it("flushes log records queued behind multiple diagnostic batches", async () => {
const capture = createDiagnosticLogRecordCapture();
for (let index = 0; index < 350; index += 1) {
emitDiagnosticEvent({
type: "log.record",
level: "warn",
message: `warning-${index}`,
});
}
await capture.flush();
expect(capture.records).toHaveLength(350);
capture.cleanup();
});
});
@@ -1,5 +1,6 @@
// Diagnostic log capture helpers collect emitted diagnostic logs for tests.
import {
hasPendingInternalDiagnosticEvent,
onInternalDiagnosticEvent,
type DiagnosticEventPayload,
} from "../../infra/diagnostic-events.js";
@@ -9,7 +10,13 @@ type CapturedDiagnosticLogRecord = Extract<DiagnosticEventPayload, { type: "log.
/** Flushes asynchronous diagnostic log record delivery. */
export async function flushDiagnosticLogRecords(): Promise<void> {
for (let index = 0; index < 3; index += 1) {
// The dispatcher drains 100 records per turn. A busy shared test process can
// have several batches ahead of the log under test, so wait for queued log
// records instead of assuming a fixed small number of turns.
for (let index = 0; index < 128; index += 1) {
if (!hasPendingInternalDiagnosticEvent((event) => event.type === "log.record")) {
return;
}
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
@@ -3,8 +3,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { saveAuthProfileStore } from "../agents/auth-profiles/store.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import { CUSTOM_LOCAL_AUTH_MARKER } from "../agents/model-auth-markers.js";
import type { OpenClawConfig } from "../config/types.js";
import { withEnvAsync } from "../test-utils/env.js";
@@ -21,9 +21,38 @@ vi.mock("../plugins/capability-provider-runtime.js", async () => {
return createEmptyCapabilityProviderMockModule();
});
const modelAuthTestControl = vi.hoisted(() => ({
forceMissingProvider: false,
store: undefined as AuthProfileStore | undefined,
}));
vi.mock("../agents/model-auth.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../agents/model-auth.js")>();
return {
...actual,
resolveApiKeyForProvider: async (
...args: Parameters<typeof actual.resolveApiKeyForProvider>
) => {
if (modelAuthTestControl.forceMissingProvider) {
throw new actual.ProviderAuthError(
"missing-provider-auth",
args[0].provider,
`No API key found for provider "${args[0].provider}".`,
);
}
const [params] = args;
return await actual.resolveApiKeyForProvider({
...params,
store: modelAuthTestControl.store ?? params.store,
});
},
};
});
vi.mock("../plugins/providers.js", async (importOriginal) => ({
...(await importOriginal()),
resolveOwningPluginIdsForProvider: () => [],
resolveOwningPluginIdsForProviderRef: () => [],
}));
const AUTH_ENV = {
@@ -32,6 +61,11 @@ const AUTH_ENV = {
OPENCLAW_AGENT_DIR: undefined,
} satisfies Record<string, string | undefined>;
beforeEach(() => {
modelAuthTestControl.forceMissingProvider = false;
modelAuthTestControl.store = undefined;
});
function createAudioProvider(
id: string,
transcribeAudio: (req: AudioTranscriptionRequest) => Promise<{ text: string; model?: string }>,
@@ -150,6 +184,9 @@ describe("runCapability local no-auth audio providers", () => {
});
it("regression #74644: plugin-only local no-auth audio provider can use no-auth", async () => {
// This test owns the media-provider fallback after generic auth misses;
// model-auth integration and profile precedence are covered below.
modelAuthTestControl.forceMissingProvider = true;
await withIsolatedAgentDir(async (agentDir) => {
await withEnvAsync(AUTH_ENV, async () => {
await withAudioFixture(
@@ -235,24 +272,20 @@ describe("runCapability local no-auth audio providers", () => {
});
it("uses OpenAI API key auth for audio when the default OpenAI profile is OAuth", async () => {
modelAuthTestControl.store = {
version: 1,
profiles: {
"openai:default": {
type: "oauth",
provider: "openai",
access: "oauth-chat-token",
refresh: "oauth-refresh-token",
expires: Date.now() + 60_000,
},
},
};
await withIsolatedAgentDir(async (agentDir) => {
await withEnvAsync({ ...AUTH_ENV, OPENAI_API_KEY: "env-openai-audio-key" }, async () => {
saveAuthProfileStore(
{
version: 1,
profiles: {
"openai:default": {
type: "oauth",
provider: "openai",
access: "oauth-chat-token",
refresh: "oauth-refresh-token",
expires: Date.now() + 60_000,
},
},
},
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
await withAudioFixture(
"openclaw-openai-audio-oauth-env-key",
async ({ ctx, media, cache }) => {
@@ -285,22 +318,18 @@ describe("runCapability local no-auth audio providers", () => {
});
it("prefers stored auth profile credentials over plugin-only media no-auth", async () => {
modelAuthTestControl.store = {
version: 1,
profiles: {
"local-audio:default": {
type: "api_key",
provider: "local-audio",
key: "stored-local-audio-key",
},
},
};
await withIsolatedAgentDir(async (agentDir) => {
await withEnvAsync(AUTH_ENV, async () => {
saveAuthProfileStore(
{
version: 1,
profiles: {
"local-audio:default": {
type: "api_key",
provider: "local-audio",
key: "stored-local-audio-key",
},
},
},
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
await withAudioFixture(
"openclaw-local-audio-stored-profile",
async ({ ctx, media, cache }) => {
@@ -338,6 +367,7 @@ describe("runCapability local no-auth audio providers", () => {
});
it("still rejects a remote audio provider without credentials", async () => {
modelAuthTestControl.forceMissingProvider = true;
await withIsolatedAgentDir(async (agentDir) => {
await withEnvAsync(AUTH_ENV, async () => {
await withAudioFixture("openclaw-remote-audio-no-auth", async ({ ctx, media, cache }) => {
@@ -423,6 +453,7 @@ describe("runCapability local no-auth audio providers", () => {
});
it("allows a media auth hook to provide an api key after normal auth misses", async () => {
modelAuthTestControl.forceMissingProvider = true;
await withIsolatedAgentDir(async (agentDir) => {
await withEnvAsync(AUTH_ENV, async () => {
await withAudioFixture("openclaw-local-audio-hook-key", async ({ ctx, media, cache }) => {
@@ -463,6 +494,7 @@ describe("runCapability local no-auth audio providers", () => {
});
it("does not allow plugin-only media provider without explicit no-auth", async () => {
modelAuthTestControl.forceMissingProvider = true;
await withIsolatedAgentDir(async (agentDir) => {
await withEnvAsync(AUTH_ENV, async () => {
await withAudioFixture("openclaw-local-audio-no-hook", async ({ ctx, media, cache }) => {
@@ -495,6 +527,7 @@ describe("runCapability local no-auth audio providers", () => {
});
it("does not allow plugin-only media provider when no-auth hook returns null", async () => {
modelAuthTestControl.forceMissingProvider = true;
await withIsolatedAgentDir(async (agentDir) => {
await withEnvAsync(AUTH_ENV, async () => {
await withAudioFixture("openclaw-local-audio-null-hook", async ({ ctx, media, cache }) => {
@@ -622,6 +655,7 @@ describe("runCapability local no-auth audio providers", () => {
});
it("allows explicit no-auth for plugin-only no-auth video provider", async () => {
modelAuthTestControl.forceMissingProvider = true;
await withIsolatedAgentDir(async (agentDir) => {
await withEnvAsync(AUTH_ENV, async () => {
await withVideoFixture(
@@ -1,7 +1,7 @@
/**
* Contract suite for bundled web search provider registration and runtime behavior.
*/
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import {
pluginRegistrationContractRegistry,
resolveWebSearchProviderContractEntriesForPluginId,
@@ -57,6 +57,12 @@ export function describeWebSearchProviderContracts(pluginId: string) {
};
describe(`${pluginId} web search provider contract registry load`, () => {
beforeAll(() => {
// Public-artifact loading is suite setup shared by every provider
// assertion; keep its cold module cost out of an arbitrary first test.
resolveProviders();
});
it("loads bundled web search providers", () => {
expect(resolveProviders().length).toBeGreaterThan(0);
});
@@ -2,7 +2,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { findLegacyConfigIssues } from "../config/legacy.js";
import type { OpenClawConfig } from "../config/types.js";
import {
@@ -309,33 +309,39 @@ describe("doctor contract registry load-path plugins", () => {
]);
});
it("loads session-store agent IDs from the real Voice Call doctor contract", () => {
const stateDir = makeTempDir();
const pluginRoot = path.join(repoRoot, "extensions", "voice-call");
const config = {
plugins: {
load: { paths: [pluginRoot] },
entries: {
"voice-call": {
enabled: true,
config: {
agentId: "Voice",
numbers: {
"+15550001111": { agentId: "Cards" },
"+15550002222": {},
describe("real Voice Call contract", () => {
let agentIds: string[];
beforeAll(() => {
const stateDir = makeTempDir();
const pluginRoot = path.join(repoRoot, "extensions", "voice-call");
const config = {
plugins: {
load: { paths: [pluginRoot] },
entries: {
"voice-call": {
enabled: true,
config: {
agentId: "Voice",
numbers: {
"+15550001111": { agentId: "Cards" },
"+15550002222": {},
},
},
},
},
},
},
} as OpenClawConfig;
} as OpenClawConfig;
expect(
listPluginDoctorSessionStoreAgentIds({
agentIds = listPluginDoctorSessionStoreAgentIds({
config,
env: makeHermeticDoctorEnv(stateDir),
pluginIds: ["voice-call"],
}),
).toEqual(["cards", "voice"]);
});
});
it("loads session-store agent IDs from the real Voice Call doctor contract", () => {
expect(agentIds).toEqual(["cards", "voice"]);
});
});
});
@@ -45,20 +45,21 @@ function bundledPluginIdsWithContract(
}
describe("web provider public artifacts", () => {
it("loads public artifacts for every bundled web provider declared in manifests", () => {
it("declares bundled web providers in manifests", () => {
expect(webSearchPluginIds).not.toHaveLength(0);
for (const pluginId of webSearchPluginIds) {
expect(
loadBundledWebSearchProviderEntriesFromDir({ dirName: pluginId, pluginId }),
).not.toBeNull();
}
expect(webFetchPluginIds).not.toHaveLength(0);
for (const pluginId of webFetchPluginIds) {
expect(
loadBundledWebFetchProviderEntriesFromDir({ dirName: pluginId, pluginId }),
).not.toBeNull();
}
});
it.each(webSearchPluginIds)("loads public web-search artifacts for %s", (pluginId) => {
expect(
loadBundledWebSearchProviderEntriesFromDir({ dirName: pluginId, pluginId }),
).not.toBeNull();
});
it.each(webFetchPluginIds)("loads public web-fetch artifacts for %s", (pluginId) => {
expect(
loadBundledWebFetchProviderEntriesFromDir({ dirName: pluginId, pluginId }),
).not.toBeNull();
});
it("registers compatibility runtime paths for bundled SecretRef-capable web search providers", () => {
+6 -1
View File
@@ -1,5 +1,5 @@
/** Tests secrets configure plan generation and target validation. */
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
TALK_TEST_PROVIDER_API_KEY_PATH,
@@ -12,8 +12,13 @@ import {
collectConfigureProviderChanges,
hasConfigurePlanChanges,
} from "./configure-plan.js";
import { resolveConfigSecretTargetByPath } from "./target-registry.js";
describe("secrets configure plan helpers", () => {
beforeAll(() => {
resolveConfigSecretTargetByPath(["channels", "telegram", "botToken"]);
});
it("builds configure candidates from supported configure targets", () => {
const config = {
talk: {
+16 -1
View File
@@ -1,5 +1,5 @@
/** Tests secrets plan normalization, target validation, and ref conversion. */
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import {
INVALID_EXEC_SECRET_REF_IDS,
VALID_EXEC_SECRET_REF_IDS,
@@ -10,6 +10,7 @@ import {
TALK_TEST_PROVIDER_ID,
} from "../test-utils/talk-test-provider.js";
import { isSecretsApplyPlan, resolveValidatedPlanTarget } from "./plan.js";
import { resolveConfigSecretTargetByPath } from "./target-registry.js";
type ValidatedPlanTarget = NonNullable<ReturnType<typeof resolveValidatedPlanTarget>>;
@@ -23,6 +24,10 @@ function requireValidatedPlanTarget(
}
describe("secrets plan validation", () => {
beforeAll(() => {
resolveConfigSecretTargetByPath(["channels", "telegram", "botToken"]);
});
it("accepts legacy provider target types", () => {
const resolved = resolveValidatedPlanTarget({
type: "models.providers.apiKey",
@@ -76,6 +81,16 @@ describe("secrets plan validation", () => {
expect(resolved).toBeNull();
});
it("rejects path-like channel ids without throwing", () => {
expect(
resolveValidatedPlanTarget({
type: "channels.foo/bar.token",
path: "channels.foo/bar.token",
pathSegments: ["channels", "foo/bar", "token"],
}),
).toBeNull();
});
it("validates plan files with non-legacy target types", () => {
const isValid = isSecretsApplyPlan({
version: 1,
+2
View File
@@ -391,6 +391,8 @@ describe("runtime web tools resolution", () => {
secretResolve = await import("./resolve.js");
({ createResolverContext } = await import("./runtime-shared.js"));
({ resolveRuntimeWebTools } = await import("./runtime-web-tools.js"));
// The managed-index branch lazily loads this stable runtime once per process.
await import("./runtime-web-tools-fallback.runtime.js");
});
beforeEach(() => {
+17
View File
@@ -136,6 +136,12 @@ const COVERAGE_WEB_SEARCH_PROVIDERS = new Map(
envVar: "MINIMAX_API_KEY",
order: 70,
}),
createCoverageWebSearchProvider({
pluginId: "parallel",
id: "parallel",
envVar: "PARALLEL_API_KEY",
order: 75,
}),
createCoverageWebSearchProvider({
pluginId: "tavily",
id: "tavily",
@@ -609,6 +615,9 @@ function applyConfigForOpenClawTarget(
if (entry.id === "plugins.entries.google.config.webSearch.apiKey") {
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "gemini");
}
if (entry.id === "plugins.entries.parallel.config.webSearch.apiKey") {
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "parallel");
}
if (entry.id === "plugins.entries.xai.config.webSearch.apiKey") {
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "grok");
}
@@ -864,6 +873,14 @@ describe("secrets runtime target coverage", () => {
if (googleChatBatch) {
await expectOpenClawCoverageBatchResolved("openclaw.json core", googleChatBatch);
}
const webProviderBatch = OPENCLAW_PLUGIN_COVERAGE_BATCHES.find((batch) =>
batch.some((entry) => entry.id.includes(".config.webSearch.")),
);
if (webProviderBatch) {
// Warm the shared plugin snapshot once; individual target assertions then
// measure resolution work instead of one-time manifest discovery.
await expectOpenClawCoverageBatchResolved("openclaw.json plugins", webProviderBatch);
}
});
describe("openclaw.json core and channel registry targets", () => {
+51 -9
View File
@@ -115,7 +115,12 @@ function getCompiledChannelOpenClawTargets(
channelId: string,
): CompiledTargetRegistryEntry[] | null {
const normalizedChannelId = channelId.trim();
if (!normalizedChannelId) {
if (
!normalizedChannelId ||
normalizedChannelId === "." ||
normalizedChannelId === ".." ||
/[\\/:]/.test(normalizedChannelId)
) {
return null;
}
if (compiledChannelOpenClawTargets.has(normalizedChannelId)) {
@@ -144,6 +149,18 @@ function normalizeAllowedTargetIds(targetIds?: Iterable<string>): Set<string> |
);
}
function configHasPluginEntries(config: OpenClawConfig): boolean {
return Boolean(config.plugins?.entries && Object.keys(config.plugins.entries).length > 0);
}
function getConfiguredChannelOpenClawTargets(
config: OpenClawConfig,
): CompiledTargetRegistryEntry[] {
return Object.keys(config.channels ?? {}).flatMap(
(channelId) => getCompiledChannelOpenClawTargets(channelId) ?? [],
);
}
function resolveDiscoveryEntries(params: {
allowedTargetIds: Set<string> | null;
defaultEntries: CompiledTargetRegistryEntry[];
@@ -267,6 +284,13 @@ export function isKnownSecretTargetId(value: unknown): value is string {
);
}
/** Checks the static core registry without materializing plugin/channel contracts. */
export function isKnownCoreSecretTargetId(value: unknown): value is string {
return (
typeof value === "string" && getCompiledCoreOpenClawTargetState().knownTargetIds.has(value)
);
}
/**
* Resolves a secrets apply-plan target against registered target type and path patterns.
*/
@@ -280,6 +304,15 @@ export function resolvePlanTargetAgainstRegistry(candidate: {
if (coreEntries) {
return resolvePlanTargetAgainstEntries(candidate, coreEntries);
}
const explicitChannelId =
candidate.pathSegments[0] === "channels" ? (candidate.pathSegments[1]?.trim() ?? "") : "";
if (explicitChannelId) {
const channelEntries = getCompiledChannelOpenClawTargets(explicitChannelId) ?? [];
const channelTypeEntries = buildTargetTypeIndex(channelEntries).get(candidate.type);
if (channelTypeEntries) {
return resolvePlanTargetAgainstEntries(candidate, channelTypeEntries);
}
}
const entries = getCompiledSecretTargetRegistryState().targetsByType.get(candidate.type);
return resolvePlanTargetAgainstEntries(candidate, entries);
}
@@ -397,17 +430,26 @@ export function discoverConfigSecretTargetsByIds(
targetIds?: Iterable<string>,
): DiscoveredConfigSecretTarget[] {
const allowedTargetIds = normalizeAllowedTargetIds(targetIds);
const registryState =
const coreState = getCompiledCoreOpenClawTargetState();
const hasOnlyCoreTargetIds =
allowedTargetIds !== null &&
Array.from(allowedTargetIds).every((targetId) =>
getCompiledCoreOpenClawTargetState().knownTargetIds.has(targetId),
)
? getCompiledCoreOpenClawTargetState()
: getCompiledSecretTargetRegistryState();
Array.from(allowedTargetIds).every((targetId) => coreState.knownTargetIds.has(targetId));
const configuredEntries = hasOnlyCoreTargetIds
? coreState.openClawCompiledSecretTargets
: allowedTargetIds !== null && !configHasPluginEntries(config)
? [...coreState.openClawCompiledSecretTargets, ...getConfiguredChannelOpenClawTargets(config)]
: null;
const configuredEntriesById = configuredEntries
? buildConfigTargetIdIndex(configuredEntries)
: null;
const canUseConfiguredEntries =
configuredEntries !== null &&
Array.from(allowedTargetIds).every((targetId) => configuredEntriesById?.has(targetId));
const registryState = canUseConfiguredEntries ? null : getCompiledSecretTargetRegistryState();
const discoveryEntries = resolveDiscoveryEntries({
allowedTargetIds,
defaultEntries: registryState.openClawCompiledSecretTargets,
entriesById: registryState.openClawTargetsById,
defaultEntries: configuredEntries ?? registryState?.openClawCompiledSecretTargets ?? [],
entriesById: configuredEntriesById ?? registryState?.openClawTargetsById ?? new Map(),
});
return discoverSecretTargetsFromEntries(config, discoveryEntries);
}
+57 -1
View File
@@ -28,6 +28,24 @@ const { loadBundledPluginPublicArtifactModuleSyncMock } = vi.hoisted(() => ({
],
};
}
if (dirName === "telegram" && artifactBasename === "secret-contract-api.js") {
return {
secretTargetRegistryEntries: [
{
id: "channels.telegram.botToken",
targetType: "channels.telegram.botToken",
configFile: "openclaw.json",
pathPattern: "channels.telegram.botToken",
refPathPattern: "channels.telegram.botTokenRef",
secretShape: "sibling_ref",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
],
};
}
throw new Error(
`Unable to resolve bundled plugin public surface ${dirName}/${artifactBasename}`,
);
@@ -43,7 +61,11 @@ vi.mock("../plugins/public-surface-loader.js", () => ({
loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock,
}));
import { resolveConfigSecretTargetByPath } from "./target-registry.js";
import {
discoverConfigSecretTargetsByIds,
resolveConfigSecretTargetByPath,
resolvePlanTargetAgainstRegistry,
} from "./target-registry.js";
describe("secret target registry fast path", () => {
beforeEach(() => {
@@ -65,4 +87,38 @@ describe("secret target registry fast path", () => {
});
expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled();
});
it("discovers selected core config targets without loading plugin metadata", () => {
const targets = discoverConfigSecretTargetsByIds(
{
gateway: { auth: { token: "test-token" } },
channels: { telegram: { botToken: "ignored-token" } },
},
["gateway.auth.token"],
);
expect(targets.map((target) => target.entry.id)).toEqual(["gateway.auth.token"]);
expect(loadBundledPluginPublicArtifactModuleSyncMock).not.toHaveBeenCalled();
expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled();
});
it("discovers selected configured channel targets without loading plugin metadata", () => {
const targets = discoverConfigSecretTargetsByIds(
{ channels: { telegram: { botToken: "test-token" } } },
["channels.telegram.botToken"],
);
expect(targets.map((target) => target.entry.id)).toContain("channels.telegram.botToken");
expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled();
});
it("resolves channel plan targets without loading plugin metadata", () => {
const target = resolvePlanTargetAgainstRegistry({
type: "channels.telegram.botToken",
pathSegments: ["channels", "telegram", "botToken"],
});
expect(target?.entry.id).toBe("channels.telegram.botToken");
expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More