test: repair lifecycle fixtures and remove redundant mocks (#138238)

This commit is contained in:
Peter Steinberger 2026-09-04 05:08:12 -07:00 • committed by GitHub
parent 00a7a128dd
commit e7fffcf7a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 45 additions and 34 deletions

View file

@ -1,7 +1,8 @@
// Browser tests cover browser request.shared control state plugin behavior.
import { createServer } from "node:http";
import { expectDefined } from "@openclaw/normalization-core";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getFreePort } from "../browser/test-port.js";
import type { OpenClawConfig } from "../config/config.js";
const mocks = vi.hoisted(() => ({
@ -31,6 +32,11 @@ vi.mock("../browser/control-auth.js", () => ({
shouldAutoGenerateBrowserAuth: mocks.shouldAutoGenerateBrowserAuth,
}));
// This suite tests shared state; server auth/bind suites cover real HTTP listeners.
vi.mock("../browser/http-listen.js", () => ({
listenBrowserHttpServer: vi.fn(async () => createServer()),
}));
vi.mock("../browser/server-lifecycle.js", () => ({
stopKnownBrowserProfiles: mocks.stopKnownBrowserProfiles,
}));
@ -109,6 +115,7 @@ async function browserRequestStatus(): Promise<unknown> {
}
describe("browser.request local control state", () => {
const controlPort = 18_791;
afterEach(async () => {
await stopBrowserControlService();
await stopBrowserControlServer();
@ -117,7 +124,6 @@ describe("browser.request local control state", () => {
});
it("uses the same resolved browser config as the HTTP control service", async () => {
const controlPort = await getFreePort();
const gatewayPort = controlPort - 2;
mocks.runtimeConfig = browserConfig({
@ -150,7 +156,6 @@ describe("browser.request local control state", () => {
});
it("retains port auth until a failed stop is retried successfully", async () => {
const controlPort = await getFreePort();
mocks.runtimeConfig = browserConfig({ gatewayPort: controlPort - 2 });
mocks.runtimeSourceConfig = mocks.runtimeConfig;
mocks.ensureBrowserControlAuth.mockResolvedValueOnce({ auth: { token: "test-token" } });
@ -167,21 +172,20 @@ describe("browser.request local control state", () => {
});
it("clears auth when a stop queues behind cold startup", async () => {
const controlPort = await getFreePort();
mocks.runtimeConfig = browserConfig({ gatewayPort: controlPort - 2 });
mocks.runtimeSourceConfig = mocks.runtimeConfig;
let releaseAuth!: () => void;
const authGate = new Promise<void>((resolve) => {
releaseAuth = resolve;
});
const authStarted = createDeferred<void>();
const authGate = createDeferred<void>();
mocks.ensureBrowserControlAuth.mockImplementationOnce(async () => {
await authGate;
authStarted.resolve();
await authGate.promise;
return { auth: { token: "test-token" } };
});
const starting = startBrowserControlServerFromConfig();
await authStarted.promise;
const stopping = stopBrowserControlServer();
releaseAuth();
authGate.resolve();
await expect(starting).resolves.toBeTruthy();
await expect(stopping).resolves.toBeUndefined();
expect(getBridgeAuthForPort(controlPort)).toBeUndefined();

View file

@ -185,9 +185,12 @@ describe("voice-call diagnostic stream ownership", () => {
}
const fd = opened.value;
expect(fs.fstatSync(fd).isFile()).toBe(true);
// The OS can reuse fd before the command continuation resumes; observe the real close.
const close = vi.spyOn(fs, "closeSync");
process.stdout.emit("error", failed);
expect(await running).toBe(failed);
expect(() => fs.fstatSync(fd)).toThrow();
expect(close).toHaveBeenCalledExactlyOnceWith(fd);
expect(close).toHaveReturnedWith(undefined);
expect(sleepMock).not.toHaveBeenCalled();
expect(process.stdout.listenerCount("drain")).toBe(drainListeners);
});

View file

@ -69,6 +69,7 @@ function normalizeMockProviderId(providerId?: string): string {
type SessionManagerMocks = {
getSessionTarget: Mock<() => undefined>;
getAppendParentId: Mock<() => string | null>;
getLeafId: Mock<() => string | null>;
getLeafEntry: UnknownMock;
getEntry: UnknownMock;
@ -79,6 +80,7 @@ type SessionManagerMocks = {
appendThinkingLevelChange: UnknownMock;
appendModelChange: UnknownMock;
appendCustomEntry: UnknownMock;
appendMessage: UnknownMock;
appendSessionInfo: UnknownMock;
appendLabelChange: UnknownMock;
flushPendingPersistence: UnknownMock;
@ -277,6 +279,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
const trajectoryEvents: CapturedTrajectoryEvent[] = [];
const sessionManager = {
getSessionTarget: vi.fn(() => undefined),
getAppendParentId: vi.fn<() => string | null>(() => null),
getLeafId: vi.fn<() => string | null>(() => null),
getLeafEntry: vi.fn(() => null),
getEntry: vi.fn(() => undefined),
@ -287,6 +290,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
appendThinkingLevelChange: vi.fn(),
appendModelChange: vi.fn(),
appendCustomEntry: vi.fn(),
appendMessage: vi.fn(),
appendSessionInfo: vi.fn(),
appendLabelChange: vi.fn(),
flushPendingPersistence: vi.fn(),
@ -792,20 +796,12 @@ vi.mock("../../sandbox/runtime-status.js", () => ({
}),
}));
vi.mock("../../tool-call-id.js", async (importOriginal) => {
return await importOriginal<typeof import("../../tool-call-id.js")>();
});
vi.mock("../../tool-fs-policy.js", () => ({
resolveSessionPermissionExecMode: (policy: { mode: string }) =>
({ "read-only": "deny", guarded: "ask", workspace: "auto", full: "full" })[policy.mode],
resolveEffectiveToolFsWorkspaceOnly: () => false,
}));
vi.mock("../../tool-policy.js", async (importOriginal) => {
return await importOriginal<typeof import("../../tool-policy.js")>();
});
vi.mock("../../transcript-policy.js", () => ({
resolveTranscriptPolicy: () => ({
allowSyntheticToolResults: false,
@ -927,13 +923,6 @@ vi.mock("../thinking.js", async (importOriginal) => {
};
});
vi.mock("../tool-name-allowlist.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../tool-name-allowlist.js")>();
return {
...actual,
};
});
vi.mock("../tool-split.js", () => ({
splitSdkTools: ({ tools }: { tools: unknown[] }) => ({
customTools: tools,
@ -1152,6 +1141,7 @@ export function resetEmbeddedAttemptHarness(
hoisted.embeddedSystemPromptInputs.length = 0;
hoisted.trajectoryEvents.length = 0;
hoisted.sessionManager.getSessionTarget.mockReset().mockReturnValue(undefined);
hoisted.sessionManager.getAppendParentId.mockReset().mockReturnValue(null);
hoisted.sessionManager.getLeafId.mockReset().mockReturnValue(null);
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
hoisted.sessionManager.getEntry.mockReset().mockReturnValue(undefined);
@ -1165,6 +1155,7 @@ export function resetEmbeddedAttemptHarness(
hoisted.sessionManager.appendThinkingLevelChange.mockReset();
hoisted.sessionManager.appendModelChange.mockReset();
hoisted.sessionManager.appendCustomEntry.mockReset();
hoisted.sessionManager.appendMessage.mockReset();
hoisted.sessionManager.appendSessionInfo.mockReset();
hoisted.sessionManager.appendLabelChange.mockReset();
hoisted.sessionManager.flushPendingPersistence.mockReset();

View file

@ -10,8 +10,10 @@ import {
} from "../../../infra/diagnostic-events.js";
import { wrapToolWithBeforeToolCallHook } from "../../agent-tools.before-tool-call.js";
import type { createOpenClawCodingTools } from "../../agent-tools.js";
import { Agent, type AgentTool } from "../../runtime/index.js";
import { Agent, type AgentEvent, type AgentTool } from "../../runtime/index.js";
import { getInternalToolExecutionPreparer } from "../../runtime/internal-hooks.js";
import { TOOL_EXECUTION_GATED_MESSAGE } from "../../tool-policy-shared.js";
import { isToolResultError } from "../../tool-result-error.js";
import type { ToolSearchCatalogRef } from "../../tool-search.js";
import { createAgentsWaitTool } from "../../tools/agents-wait-tool.js";
import { createSessionsSpawnTool } from "../../tools/sessions-spawn-tool.js";
@ -89,7 +91,7 @@ describe("runEmbeddedAttempt tool-search catalog cleanup", () => {
},
])(
"does not enter the original preparer or action through denied $mode",
async ({ toolName, code }) => {
async ({ mode, toolName, code }) => {
const execute = vi.fn(async () => ({ content: [], details: {} }));
const prepare = vi.fn(async (args: unknown) => args);
const native =
@ -101,8 +103,7 @@ describe("runEmbeddedAttempt tool-search catalog cleanup", () => {
const source = wrapToolWithBeforeToolCallHook(native);
expect(getInternalToolExecutionPreparer(source)).toBeDefined();
hoisted.createOpenClawCodingToolsMock.mockReturnValue([source]);
const observed: AssistantMessage["content"][] = [];
const outcomes: Array<{ toolName: string; isError: boolean }> = [];
const outcomes: Extract<AgentEvent, { type: "tool_execution_end" }>[] = [];
await createContextEngineAttemptRunner({
contextEngine: createContextEngineBootstrapAndAssemble(),
sessionKey: "agent:main:main",
@ -119,6 +120,10 @@ describe("runEmbeddedAttempt tool-search catalog cleanup", () => {
let turn = 0;
const agent = new Agent({
initialState: { model: options.model, tools: allTools },
// AgentSession's result middleware normally classifies structured tool failures.
afterToolCall: async ({ result, isError }) => ({
isError: isError || isToolResultError(result),
}),
streamFn: () => {
const content: AssistantMessage["content"] =
turn++ === 0
@ -135,7 +140,6 @@ describe("runEmbeddedAttempt tool-search catalog cleanup", () => {
},
]
: [{ type: "text", text: "Denied as expected." }];
observed.push(content);
const message: AssistantMessage = {
role: "assistant",
content,
@ -190,10 +194,18 @@ describe("runEmbeddedAttempt tool-search catalog cleanup", () => {
config: { tools: { codeMode: Boolean(code), toolSearch: false } },
},
});
expect(observed.length).toBeGreaterThanOrEqual(1);
expect(outcomes).toContainEqual(
expect.objectContaining({ toolName: code ? "exec" : toolName, isError: true }),
);
if (code) {
expect(outcomes[0]?.result).toMatchObject({
details: {
error: expect.stringContaining(
mode === "joined Code Mode" ? "agents is not defined" : TOOL_EXECUTION_GATED_MESSAGE,
),
},
});
}
expect(prepare).not.toHaveBeenCalled();
expect(execute).not.toHaveBeenCalled();
},

View file

@ -171,6 +171,7 @@ describe.skipIf(process.platform === "win32")("service-managed child lifecycle",
const command = `
process.on("SIGTERM", () => {
require("node:fs").writeFileSync(${JSON.stringify(termPath)}, String(process.pid));
process.exit(0);
});
require("node:fs").writeFileSync(${JSON.stringify(pidPath)}, String(process.pid));
setInterval(() => {}, 1000);
@ -212,8 +213,8 @@ describe.skipIf(process.platform === "win32")("service-managed child lifecycle",
await expect(supervisor.waitForScope(runId)).rejects.toThrow("cleanup identity lost");
await expect(run.waitForExtinction?.()).rejects.toThrow("cleanup identity lost");
await expect(supervisor.shutdown()).rejects.toThrow("cleanup identity lost");
// Observe the anchor's TERM before its unchanged grace; neither the timeout
// result nor the failed join may disable its independent group cleanup.
// TERM must still reach the command after failed cleanup joins. Dedicated
// escalation cases cover commands that keep running through the TERM grace.
await waitForPidFile(termPath, 5_000, realDelay);
await waitFor(() => !isAlive(startedPid));
} finally {