mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
test: speed up and stabilize full suite
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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?.();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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" }]);
|
||||
|
||||
@@ -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,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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user