From 3beb1b9da933f08e88ad5d71133ca5afc9d455a6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 21:58:19 +0000 Subject: [PATCH] test: speed up heavy suites with shared fixtures --- extensions/acpx/src/runtime.test.ts | 71 ++++++++++--------- src/agents/skills-install.test.ts | 31 ++++++-- ...skills.buildworkspaceskillsnapshot.test.ts | 36 +++++----- src/agents/skills.test.ts | 16 ++--- src/auto-reply/reply/session.test.ts | 49 +++++++------ src/browser/extension-relay.test.ts | 65 ++++++++--------- src/gateway/gateway.test.ts | 16 +++-- src/gateway/server.auth.control-ui.suite.ts | 8 ++- src/gateway/server.auth.shared.ts | 27 ++++--- src/gateway/test-helpers.server.ts | 10 ++- .../bot.create-telegram-bot.test-harness.ts | 24 ++++--- test/helpers/temp-home.ts | 25 ++++++- 12 files changed, 227 insertions(+), 151 deletions(-) diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index bad0e08e397..2e773820528 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; -import { afterAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js"; import { cleanupMockRuntimeFixtures, @@ -10,7 +10,14 @@ import { } from "./runtime-internals/test-fixtures.js"; import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js"; +let sharedFixture: Awaited> | null = null; + +beforeAll(async () => { + sharedFixture = await createMockRuntimeFixture(); +}); + afterAll(async () => { + sharedFixture = null; await cleanupMockRuntimeFixtures(); }); @@ -21,14 +28,10 @@ describe("AcpxRuntime", () => { createRuntime: async () => fixture.runtime, agentId: "codex", successPrompt: "contract-pass", - errorPrompt: "trigger-error", includeControlChecks: false, assertSuccessEvents: (events) => { expect(events.some((event) => event.type === "done")).toBe(true); }, - assertErrorOutcome: ({ events, thrown }) => { - expect(events.some((event) => event.type === "error") || Boolean(thrown)).toBe(true); - }, }); const logs = await readMockRuntimeLogEntries(fixture.logPath); @@ -108,34 +111,12 @@ describe("AcpxRuntime", () => { expect(promptArgs).toContain("--approve-all"); }); - it("passes a queue-owner TTL by default to avoid long idle stalls", async () => { - const { runtime, logPath } = await createMockRuntimeFixture(); - const handle = await runtime.ensureSession({ - sessionKey: "agent:codex:acp:ttl-default", - agent: "codex", - mode: "persistent", - }); - - for await (const _event of runtime.runTurn({ - handle, - text: "ttl-default", - mode: "prompt", - requestId: "req-ttl-default", - })) { - // drain - } - - const logs = await readMockRuntimeLogEntries(logPath); - const prompt = logs.find((entry) => entry.kind === "prompt"); - expect(prompt).toBeDefined(); - const promptArgs = (prompt?.args as string[]) ?? []; - const ttlFlagIndex = promptArgs.indexOf("--ttl"); - expect(ttlFlagIndex).toBeGreaterThanOrEqual(0); - expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1"); - }); - it("preserves leading spaces across streamed text deltas", async () => { - const { runtime } = await createMockRuntimeFixture(); + const runtime = sharedFixture?.runtime; + expect(runtime).toBeDefined(); + if (!runtime) { + throw new Error("shared runtime fixture missing"); + } const handle = await runtime.ensureSession({ sessionKey: "agent:codex:acp:space", agent: "codex", @@ -156,10 +137,28 @@ describe("AcpxRuntime", () => { expect(textDeltas).toEqual(["alpha", " beta", " gamma"]); expect(textDeltas.join("")).toBe("alpha beta gamma"); + + // Keep the default queue-owner TTL assertion on a runTurn that already exists. + const activeLogPath = process.env.MOCK_ACPX_LOG; + expect(activeLogPath).toBeDefined(); + const logs = await readMockRuntimeLogEntries(String(activeLogPath)); + const prompt = logs.find( + (entry) => + entry.kind === "prompt" && String(entry.sessionName ?? "") === "agent:codex:acp:space", + ); + expect(prompt).toBeDefined(); + const promptArgs = (prompt?.args as string[]) ?? []; + const ttlFlagIndex = promptArgs.indexOf("--ttl"); + expect(ttlFlagIndex).toBeGreaterThanOrEqual(0); + expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1"); }); it("emits done once when ACP stream repeats stop reason responses", async () => { - const { runtime } = await createMockRuntimeFixture(); + const runtime = sharedFixture?.runtime; + expect(runtime).toBeDefined(); + if (!runtime) { + throw new Error("shared runtime fixture missing"); + } const handle = await runtime.ensureSession({ sessionKey: "agent:codex:acp:double-done", agent: "codex", @@ -181,7 +180,11 @@ describe("AcpxRuntime", () => { }); it("maps acpx error events into ACP runtime error events", async () => { - const { runtime } = await createMockRuntimeFixture(); + const runtime = sharedFixture?.runtime; + expect(runtime).toBeDefined(); + if (!runtime) { + throw new Error("shared runtime fixture missing"); + } const handle = await runtime.ensureSession({ sessionKey: "agent:codex:acp:456", agent: "codex", diff --git a/src/agents/skills-install.test.ts b/src/agents/skills-install.test.ts index b7110ebb82a..1e6d95018ec 100644 --- a/src/agents/skills-install.test.ts +++ b/src/agents/skills-install.test.ts @@ -1,7 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { withTempWorkspace } from "./skills-install.download-test-utils.js"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createFixtureSuite } from "../test-utils/fixture-suite.js"; +import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; +import { setTempStateDir } from "./skills-install.download-test-utils.js"; import { installSkill } from "./skills-install.js"; import { runCommandWithTimeoutMock, @@ -36,6 +38,27 @@ metadata: {"openclaw":{"install":[{"id":"deps","kind":"node","package":"example- return skillDir; } +const workspaceSuite = createFixtureSuite("openclaw-skills-install-"); +let tempHome: TempHomeEnv; + +beforeAll(async () => { + tempHome = await createTempHomeEnv("openclaw-skills-install-home-"); + await workspaceSuite.setup(); +}); + +afterAll(async () => { + await workspaceSuite.cleanup(); + await tempHome.restore(); +}); + +async function withWorkspaceCase( + run: (params: { workspaceDir: string; stateDir: string }) => Promise, +): Promise { + const workspaceDir = await workspaceSuite.createCaseDir("case"); + const stateDir = setTempStateDir(workspaceDir); + await run({ workspaceDir, stateDir }); +} + describe("installSkill code safety scanning", () => { beforeEach(() => { runCommandWithTimeoutMock.mockClear(); @@ -50,7 +73,7 @@ describe("installSkill code safety scanning", () => { }); it("adds detailed warnings for critical findings and continues install", async () => { - await withTempWorkspace(async ({ workspaceDir }) => { + await withWorkspaceCase(async ({ workspaceDir }) => { const skillDir = await writeInstallableSkill(workspaceDir, "danger-skill"); scanDirectoryWithSummaryMock.mockResolvedValue({ scannedFiles: 1, @@ -84,7 +107,7 @@ describe("installSkill code safety scanning", () => { }); it("warns and continues when skill scan fails", async () => { - await withTempWorkspace(async ({ workspaceDir }) => { + await withWorkspaceCase(async ({ workspaceDir }) => { await writeInstallableSkill(workspaceDir, "scanfail-skill"); scanDirectoryWithSummaryMock.mockRejectedValue(new Error("scanner exploded")); diff --git a/src/agents/skills.buildworkspaceskillsnapshot.test.ts b/src/agents/skills.buildworkspaceskillsnapshot.test.ts index 9fec26d165d..35b9b93aa0b 100644 --- a/src/agents/skills.buildworkspaceskillsnapshot.test.ts +++ b/src/agents/skills.buildworkspaceskillsnapshot.test.ts @@ -1,15 +1,19 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; -import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; +import { createFixtureSuite } from "../test-utils/fixture-suite.js"; import { writeSkill } from "./skills.e2e-test-helpers.js"; import { buildWorkspaceSkillSnapshot, buildWorkspaceSkillsPrompt } from "./skills.js"; -const tempDirs = createTrackedTempDirs(); +const fixtureSuite = createFixtureSuite("openclaw-skills-snapshot-suite-"); -afterEach(async () => { - await tempDirs.cleanup(); +beforeAll(async () => { + await fixtureSuite.setup(); +}); + +afterAll(async () => { + await fixtureSuite.cleanup(); }); function withWorkspaceHome(workspaceDir: string, cb: () => T): T { @@ -18,7 +22,7 @@ function withWorkspaceHome(workspaceDir: string, cb: () => T): T { describe("buildWorkspaceSkillSnapshot", () => { it("returns an empty snapshot when skills dirs are missing", async () => { - const workspaceDir = await tempDirs.make("openclaw-"); + const workspaceDir = await fixtureSuite.createCaseDir("workspace"); const snapshot = withWorkspaceHome(workspaceDir, () => buildWorkspaceSkillSnapshot(workspaceDir, { @@ -32,7 +36,7 @@ describe("buildWorkspaceSkillSnapshot", () => { }); it("omits disable-model-invocation skills from the prompt", async () => { - const workspaceDir = await tempDirs.make("openclaw-"); + const workspaceDir = await fixtureSuite.createCaseDir("workspace"); await writeSkill({ dir: path.join(workspaceDir, "skills", "visible-skill"), name: "visible-skill", @@ -61,7 +65,7 @@ describe("buildWorkspaceSkillSnapshot", () => { }); it("keeps prompt output aligned with buildWorkspaceSkillsPrompt", async () => { - const workspaceDir = await tempDirs.make("openclaw-"); + const workspaceDir = await fixtureSuite.createCaseDir("workspace"); await writeSkill({ dir: path.join(workspaceDir, "skills", "visible"), name: "visible", @@ -106,7 +110,7 @@ describe("buildWorkspaceSkillSnapshot", () => { }); it("truncates the skills prompt when it exceeds the configured char budget", async () => { - const workspaceDir = await tempDirs.make("openclaw-"); + const workspaceDir = await fixtureSuite.createCaseDir("workspace"); // Keep fixture size modest while still forcing truncation logic. for (let i = 0; i < 8; i += 1) { @@ -138,8 +142,8 @@ describe("buildWorkspaceSkillSnapshot", () => { }); it("limits discovery for nested repo-style skills roots (dir/skills/*)", async () => { - const workspaceDir = await tempDirs.make("openclaw-"); - const repoDir = await tempDirs.make("openclaw-skills-repo-"); + const workspaceDir = await fixtureSuite.createCaseDir("workspace"); + const repoDir = await fixtureSuite.createCaseDir("skills-repo"); for (let i = 0; i < 8; i += 1) { const name = `repo-skill-${String(i).padStart(2, "0")}`; @@ -175,7 +179,7 @@ describe("buildWorkspaceSkillSnapshot", () => { }); it("skips skills whose SKILL.md exceeds maxSkillFileBytes", async () => { - const workspaceDir = await tempDirs.make("openclaw-"); + const workspaceDir = await fixtureSuite.createCaseDir("workspace"); await writeSkill({ dir: path.join(workspaceDir, "skills", "small-skill"), @@ -211,8 +215,8 @@ describe("buildWorkspaceSkillSnapshot", () => { }); it("detects nested skills roots beyond the first 25 entries", async () => { - const workspaceDir = await tempDirs.make("openclaw-"); - const repoDir = await tempDirs.make("openclaw-skills-repo-"); + const workspaceDir = await fixtureSuite.createCaseDir("workspace"); + const repoDir = await fixtureSuite.createCaseDir("skills-repo"); // Create 30 nested dirs, but only the last one is an actual skill. for (let i = 0; i < 30; i += 1) { @@ -250,8 +254,8 @@ describe("buildWorkspaceSkillSnapshot", () => { }); it("enforces maxSkillFileBytes for root-level SKILL.md", async () => { - const workspaceDir = await tempDirs.make("openclaw-"); - const rootSkillDir = await tempDirs.make("openclaw-root-skill-"); + const workspaceDir = await fixtureSuite.createCaseDir("workspace"); + const rootSkillDir = await fixtureSuite.createCaseDir("root-skill"); await writeSkill({ dir: rootSkillDir, diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index c84b8cdf62f..33341e6ad1f 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createFixtureSuite } from "../test-utils/fixture-suite.js"; import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; import { writeSkill } from "./skills.e2e-test-helpers.js"; import { @@ -13,7 +13,7 @@ import { loadWorkspaceSkillEntries, } from "./skills.js"; -const tempDirs: string[] = []; +const fixtureSuite = createFixtureSuite("openclaw-skills-suite-"); let tempHome: TempHomeEnv | null = null; const resolveTestSkillDirs = (workspaceDir: string) => ({ @@ -21,11 +21,7 @@ const resolveTestSkillDirs = (workspaceDir: string) => ({ bundledSkillsDir: path.join(workspaceDir, ".bundled"), }); -const makeWorkspace = async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-")); - tempDirs.push(workspaceDir); - return workspaceDir; -}; +const makeWorkspace = async () => await fixtureSuite.createCaseDir("workspace"); const withClearedEnv = ( keys: string[], @@ -52,6 +48,7 @@ const withClearedEnv = ( }; beforeAll(async () => { + await fixtureSuite.setup(); tempHome = await createTempHomeEnv("openclaw-skills-home-"); await fs.mkdir(path.join(tempHome.home, ".openclaw", "agents", "main", "sessions"), { recursive: true, @@ -63,10 +60,7 @@ afterAll(async () => { await tempHome.restore(); tempHome = null; } - - await Promise.all( - tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); + await fixtureSuite.cleanup(); }); describe("buildWorkspaceSkillCommandSpecs", () => { diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index aa0b127f9ee..4f8a2610305 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -5,7 +5,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { buildModelAliasIndex } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { saveSessionStore } from "../../config/sessions.js"; import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js"; import { applyResetModelOverride } from "./session-reset-model.js"; @@ -51,6 +50,14 @@ async function makeStorePath(prefix: string): Promise { const createStorePath = makeStorePath; +async function writeSessionStoreFast( + storePath: string, + store: Record>, +): Promise { + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(store), "utf-8"); +} + describe("initSessionState thread forking", () => { it("forks a new session from the parent session file", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -89,7 +96,7 @@ describe("initSessionState thread forking", () => { const storePath = path.join(root, "sessions.json"); const parentSessionKey = "agent:main:slack:channel:c1"; - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [parentSessionKey]: { sessionId: parentSessionId, sessionFile: parentSessionFile, @@ -175,7 +182,7 @@ describe("initSessionState thread forking", () => { const storePath = path.join(root, "sessions.json"); const parentSessionKey = "agent:main:slack:channel:c1"; const threadSessionKey = "agent:main:slack:channel:c1:thread:123"; - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [parentSessionKey]: { sessionId: parentSessionId, sessionFile: parentSessionFile, @@ -256,7 +263,7 @@ describe("initSessionState thread forking", () => { const storePath = path.join(root, "sessions.json"); const parentSessionKey = "agent:main:slack:channel:c1"; // Set totalTokens well above PARENT_FORK_MAX_TOKENS (100_000) - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [parentSessionKey]: { sessionId: parentSessionId, sessionFile: parentSessionFile, @@ -324,7 +331,7 @@ describe("initSessionState thread forking", () => { const storePath = path.join(root, "sessions.json"); const parentSessionKey = "agent:main:slack:channel:c1"; - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [parentSessionKey]: { sessionId: parentSessionId, sessionFile: parentSessionFile, @@ -461,7 +468,7 @@ describe("initSessionState RawBody", () => { vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); try { await fs.mkdir(path.dirname(storePath), { recursive: true }); - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [sessionKey]: { sessionId, sessionFile, @@ -507,7 +514,7 @@ describe("initSessionState reset policy", () => { const sessionKey = "agent:main:whatsapp:dm:s1"; const existingSessionId = "daily-session-id"; - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [sessionKey]: { sessionId: existingSessionId, updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), @@ -532,7 +539,7 @@ describe("initSessionState reset policy", () => { const sessionKey = "agent:main:whatsapp:dm:s-edge"; const existingSessionId = "daily-edge-session"; - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [sessionKey]: { sessionId: existingSessionId, updatedAt: new Date(2026, 0, 17, 3, 30, 0).getTime(), @@ -557,7 +564,7 @@ describe("initSessionState reset policy", () => { const sessionKey = "agent:main:whatsapp:dm:s2"; const existingSessionId = "idle-session-id"; - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [sessionKey]: { sessionId: existingSessionId, updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(), @@ -587,7 +594,7 @@ describe("initSessionState reset policy", () => { const sessionKey = "agent:main:slack:channel:c1:thread:123"; const existingSessionId = "thread-session-id"; - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [sessionKey]: { sessionId: existingSessionId, updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), @@ -618,7 +625,7 @@ describe("initSessionState reset policy", () => { const sessionKey = "agent:main:discord:channel:c1"; const existingSessionId = "thread-nosuffix"; - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [sessionKey]: { sessionId: existingSessionId, updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), @@ -648,7 +655,7 @@ describe("initSessionState reset policy", () => { const sessionKey = "agent:main:whatsapp:dm:s4"; const existingSessionId = "type-default-session"; - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [sessionKey]: { sessionId: existingSessionId, updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), @@ -678,7 +685,7 @@ describe("initSessionState reset policy", () => { const sessionKey = "agent:main:whatsapp:dm:s3"; const existingSessionId = "legacy-session-id"; - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [sessionKey]: { sessionId: existingSessionId, updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(), @@ -710,7 +717,7 @@ describe("initSessionState channel reset overrides", () => { const sessionId = "session-override"; const updatedAt = Date.now() - (10080 - 1) * 60_000; - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [sessionKey]: { sessionId, updatedAt, @@ -747,7 +754,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => { sessionKey: string; sessionId: string; }): Promise { - await saveSessionStore(params.storePath, { + await writeSessionStoreFast(params.storePath, { [params.sessionKey]: { sessionId: params.sessionId, updatedAt: Date.now(), @@ -840,7 +847,7 @@ describe("initSessionState reset triggers in Slack channels", () => { sessionKey: string; sessionId: string; }): Promise { - await saveSessionStore(params.storePath, { + await writeSessionStoreFast(params.storePath, { [params.sessionKey]: { sessionId: params.sessionId, updatedAt: Date.now(), @@ -989,7 +996,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset", sessionId: string; overrides: Record; }): Promise { - await saveSessionStore(params.storePath, { + await writeSessionStoreFast(params.storePath, { [params.sessionKey]: { sessionId: params.sessionId, updatedAt: Date.now(), @@ -1390,7 +1397,7 @@ describe("initSessionState stale threadId fallback", () => { describe("initSessionState dmScope delivery migration", () => { it("retires stale main-session delivery route when dmScope uses per-channel DM keys", async () => { const storePath = await createStorePath("dm-scope-retire-main-route-"); - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { "agent:main:main": { sessionId: "legacy-main", updatedAt: Date.now(), @@ -1436,7 +1443,7 @@ describe("initSessionState dmScope delivery migration", () => { it("keeps legacy main-session delivery route when current DM target does not match", async () => { const storePath = await createStorePath("dm-scope-keep-main-route-"); - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { "agent:main:main": { sessionId: "legacy-main", updatedAt: Date.now(), @@ -1483,7 +1490,7 @@ describe("initSessionState internal channel routing preservation", () => { it("keeps persisted external lastChannel when OriginatingChannel is internal webchat", async () => { const storePath = await createStorePath("preserve-external-channel-"); const sessionKey = "agent:main:telegram:group:12345"; - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [sessionKey]: { sessionId: "sess-1", updatedAt: Date.now(), @@ -1517,7 +1524,7 @@ describe("initSessionState internal channel routing preservation", () => { it("keeps persisted external route when OriginatingChannel is non-deliverable", async () => { const storePath = await createStorePath("preserve-nondeliverable-route-"); const sessionKey = "agent:main:discord:channel:24680"; - await saveSessionStore(storePath, { + await writeSessionStoreFast(storePath, { [sessionKey]: { sessionId: "sess-2", updatedAt: Date.now(), diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index c00b281ee40..d185875bca6 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -126,20 +126,16 @@ async function waitForListMatch( timeoutMs = RELAY_LIST_MATCH_TIMEOUT_MS, intervalMs = 20, ): Promise { - let latest: T | undefined; - await expect - .poll( - async () => { - latest = await fetchList(); - return predicate(latest); - }, - { timeout: timeoutMs, interval: intervalMs }, - ) - .toBe(true); - if (latest === undefined) { - throw new Error("expected list value"); + const deadline = Date.now() + timeoutMs; + let latest: T | null = null; + while (Date.now() <= deadline) { + latest = await fetchList(); + if (predicate(latest)) { + return latest; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); } - return latest; + throw new Error("timeout waiting for list match"); } describe("chrome extension relay server", () => { @@ -453,14 +449,13 @@ describe("chrome extension relay server", () => { }), ); - const list = await waitForListMatch( + await waitForListMatch( async () => (await fetch(`${cdpUrl}/json/list`, { headers: relayAuthHeaders(cdpUrl), }).then((r) => r.json())) as Array<{ id?: string }>, (entries) => entries.some((entry) => entry.id === "t-minimal"), ); - expect(list.some((entry) => entry.id === "t-minimal")).toBe(true); }); it("waits briefly for extension reconnect before failing CDP commands", async () => { @@ -666,7 +661,7 @@ describe("chrome extension relay server", () => { }), ); - const list2 = await waitForListMatch( + await waitForListMatch( async () => (await fetch(`${cdpUrl}/json/list`, { headers: relayAuthHeaders(cdpUrl), @@ -683,12 +678,6 @@ describe("chrome extension relay server", () => { t.title === "DER STANDARD", ), ); - expect( - list2.some( - (t) => - t.id === "t1" && t.url === "https://www.derstandard.at/" && t.title === "DER STANDARD", - ), - ).toBe(true); const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), @@ -699,7 +688,10 @@ describe("chrome extension relay server", () => { cdp.send(JSON.stringify({ id: 1, method: "Target.getTargets" })); const res1 = JSON.parse(await q.next()) as { id: number; result?: unknown }; expect(res1.id).toBe(1); - expect(JSON.stringify(res1.result ?? {})).toContain("t1"); + const targetInfos = ( + res1.result as { targetInfos?: Array<{ targetId?: string }> } | undefined + )?.targetInfos; + expect((targetInfos ?? []).some((target) => target.targetId === "t1")).toBe(true); cdp.send( JSON.stringify({ @@ -719,11 +711,13 @@ describe("chrome extension relay server", () => { const res2 = received.find((m) => m.id === 2); expect(res2?.id).toBe(2); - expect(JSON.stringify(res2?.result ?? {})).toContain("cb-tab-1"); + expect((res2?.result as { sessionId?: string } | undefined)?.sessionId).toBe("cb-tab-1"); const evt = received.find((m) => m.method === "Target.attachedToTarget"); expect(evt?.method).toBe("Target.attachedToTarget"); - expect(JSON.stringify(evt?.params ?? {})).toContain("t1"); + expect( + (evt?.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo?.targetId, + ).toBe("t1"); cdp.close(); ext.close(); @@ -771,15 +765,13 @@ describe("chrome extension relay server", () => { }), ); - const updatedList = await waitForListMatch( + await waitForListMatch( async () => (await fetch(`${cdpUrl}/json/list`, { headers: relayAuthHeaders(cdpUrl), }).then((r) => r.json())) as Array<{ id?: string }>, (list) => list.every((target) => target.id !== "t1"), ); - - expect(updatedList.some((target) => target.id === "t1")).toBe(false); ext.close(); }); @@ -860,14 +852,13 @@ describe("chrome extension relay server", () => { expect(response?.id).toBe(77); expect(response?.error?.message ?? "").toContain("No target with given id"); - const updatedList = await waitForListMatch( + await waitForListMatch( async () => (await fetch(`${cdpUrl}/json/list`, { headers: relayAuthHeaders(cdpUrl), }).then((r) => r.json())) as Array<{ id?: string }>, (list) => list.every((target) => target.id !== "t1"), ); - expect(updatedList.some((target) => target.id === "t1")).toBe(false); cdp.close(); ext.close(); @@ -903,7 +894,9 @@ describe("chrome extension relay server", () => { const first = JSON.parse(await q.next()) as { method?: string; params?: unknown }; expect(first.method).toBe("Target.attachedToTarget"); - expect(JSON.stringify(first.params ?? {})).toContain("t1"); + expect( + (first.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo?.targetId, + ).toBe("t1"); ext.send( JSON.stringify({ @@ -930,8 +923,11 @@ describe("chrome extension relay server", () => { const detached = received.find((m) => m.method === "Target.detachedFromTarget"); const attached = received.find((m) => m.method === "Target.attachedToTarget"); - expect(JSON.stringify(detached?.params ?? {})).toContain("t1"); - expect(JSON.stringify(attached?.params ?? {})).toContain("t2"); + expect((detached?.params as { targetId?: string } | undefined)?.targetId).toBe("t1"); + expect( + (attached?.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo + ?.targetId, + ).toBe("t2"); cdp.close(); ext.close(); @@ -1007,14 +1003,13 @@ describe("chrome extension relay server", () => { }), ); - const list1 = await waitForListMatch( + await waitForListMatch( async () => (await fetch(`${cdpUrl}/json/list`, { headers: relayAuthHeaders(cdpUrl), }).then((r) => r.json())) as Array<{ id?: string }>, (list) => list.some((t) => t.id === "t10"), ); - expect(list1.some((t) => t.id === "t10")).toBe(true); // Disconnect extension and wait for grace period cleanup. const ext1Closed = waitForClose(ext1, 2_000); diff --git a/src/gateway/gateway.test.ts b/src/gateway/gateway.test.ts index 5af71dde048..aea5a816fa7 100644 --- a/src/gateway/gateway.test.ts +++ b/src/gateway/gateway.test.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -18,6 +17,11 @@ import { buildOpenAiResponsesProviderConfig } from "./test-openai-responses-mode let writeConfigFile: typeof import("../config/config.js").writeConfigFile; let resolveConfigPath: typeof import("../config/config.js").resolveConfigPath; const GATEWAY_E2E_TIMEOUT_MS = 30_000; +let gatewayTestSeq = 0; + +function nextGatewayId(prefix: string): string { + return `${prefix}-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}-${gatewayTestSeq++}`; +} describe("gateway e2e", () => { beforeAll(async () => { @@ -49,14 +53,14 @@ describe("gateway e2e", () => { process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1"; - const token = `test-${randomUUID()}`; + const token = nextGatewayId("test-token"); process.env.OPENCLAW_GATEWAY_TOKEN = token; const workspaceDir = path.join(tempHome, "openclaw"); await fs.mkdir(workspaceDir, { recursive: true }); - const nonceA = randomUUID(); - const nonceB = randomUUID(); + const nonceA = nextGatewayId("nonce-a"); + const nonceB = nextGatewayId("nonce-b"); const toolProbePath = path.join(workspaceDir, `.openclaw-tool-probe.${nonceA}.txt`); await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`); @@ -90,7 +94,7 @@ describe("gateway e2e", () => { model: "openai/gpt-5.2", }); - const runId = randomUUID(); + const runId = nextGatewayId("run"); const payload = await client.request<{ status?: unknown; result?: unknown; @@ -149,7 +153,7 @@ describe("gateway e2e", () => { delete process.env.OPENCLAW_STATE_DIR; delete process.env.OPENCLAW_CONFIG_PATH; - const wizardToken = `wiz-${randomUUID()}`; + const wizardToken = nextGatewayId("wiz-token"); const port = await getFreeGatewayPort(); const server = await startGatewayServer(port, { bind: "loopback", diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 297e3577b93..f754d007870 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -26,6 +26,8 @@ import { writeTrustedProxyControlUiConfig, } from "./server.auth.shared.js"; +let controlUiIdentityPathSeq = 0; + export function registerControlUiAndPairingSuite(): void { const trustedProxyControlUiCases: Array<{ name: string; @@ -195,7 +197,6 @@ export function registerControlUiAndPairingSuite(): void { const challenge = await challengePromise; const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; expect(typeof nonce).toBe("string"); - const { randomUUID } = await import("node:crypto"); const os = await import("node:os"); const path = await import("node:path"); const scopes = [ @@ -210,7 +211,10 @@ export function registerControlUiAndPairingSuite(): void { scopes, clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, - identityPath: path.join(os.tmpdir(), `openclaw-controlui-device-${randomUUID()}.json`), + identityPath: path.join( + os.tmpdir(), + `openclaw-controlui-device-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}-${controlUiIdentityPathSeq++}.json`, + ), nonce: String(nonce), }); const res = await connectReq(ws, { diff --git a/src/gateway/server.auth.shared.ts b/src/gateway/server.auth.shared.ts index 567d3114992..e9ed780193b 100644 --- a/src/gateway/server.auth.shared.ts +++ b/src/gateway/server.auth.shared.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import os from "node:os"; import path from "node:path"; import { expect } from "vitest"; @@ -22,6 +21,22 @@ import { withGatewayServer, } from "./test-helpers.js"; +let authIdentityPathSeq = 0; + +function nextAuthIdentityPath(prefix: string): string { + const poolId = process.env.VITEST_POOL_ID ?? "0"; + const fileName = + prefix + + "-" + + String(process.pid) + + "-" + + poolId + + "-" + + String(authIdentityPathSeq++) + + ".json"; + return path.join(os.tmpdir(), fileName); +} + async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise { if (ws.readyState === WebSocket.CLOSED) { return true; @@ -287,10 +302,7 @@ async function startRateLimitedTokenServerWithPairedDeviceToken() { } as any; const { server, ws, port, prevToken } = await startServerWithClient(); - const deviceIdentityPath = path.join( - os.tmpdir(), - "openclaw-auth-rate-limit-" + randomUUID() + ".json", - ); + const deviceIdentityPath = nextAuthIdentityPath("openclaw-auth-rate-limit"); try { const initial = await connectReq(ws, { token: "secret", deviceIdentityPath }); if (!initial.ok) { @@ -321,10 +333,7 @@ async function ensurePairedDeviceTokenForCurrentIdentity(ws: WebSocket): Promise const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { getPairedDevice } = await import("../infra/device-pairing.js"); - const deviceIdentityPath = path.join( - os.tmpdir(), - "openclaw-auth-device-" + randomUUID() + ".json", - ); + const deviceIdentityPath = nextAuthIdentityPath("openclaw-auth-device"); const res = await connectReq(ws, { token: "secret", deviceIdentityPath }); if (!res.ok) { diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index d6afcc82d58..944f0a0f3ae 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -61,6 +61,7 @@ const GATEWAY_TEST_ENV_KEYS = [ let gatewayEnvSnapshot: ReturnType | undefined; let tempHome: string | undefined; let tempConfigRoot: string | undefined; +let suiteConfigRootSeq = 0; export async function writeSessionStore(params: { entries: Record>; @@ -121,7 +122,11 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { } applyGatewaySkipEnv(); if (options.uniqueConfigRoot) { - tempConfigRoot = await fs.mkdtemp(path.join(tempHome, "openclaw-test-")); + const suiteRoot = path.join(tempHome, ".openclaw-test-suite"); + await fs.mkdir(suiteRoot, { recursive: true }); + tempConfigRoot = path.join(suiteRoot, `case-${suiteConfigRootSeq++}`); + await fs.rm(tempConfigRoot, { recursive: true, force: true }); + await fs.mkdir(tempConfigRoot, { recursive: true }); } else { tempConfigRoot = path.join(tempHome, ".openclaw-test"); await fs.rm(tempConfigRoot, { recursive: true, force: true }); @@ -182,6 +187,9 @@ async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) { tempHome = undefined; } tempConfigRoot = undefined; + if (options.restoreEnv) { + suiteConfigRootSeq = 0; + } } export function installGatewayTestHooks(options?: { scope?: "test" | "suite" }) { diff --git a/src/telegram/bot.create-telegram-bot.test-harness.ts b/src/telegram/bot.create-telegram-bot.test-harness.ts index 122ef973a54..ec98de4fbfa 100644 --- a/src/telegram/bot.create-telegram-bot.test-harness.ts +++ b/src/telegram/bot.create-telegram-bot.test-harness.ts @@ -9,7 +9,7 @@ type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; const { sessionStorePath } = vi.hoisted(() => ({ - sessionStorePath: `/tmp/openclaw-telegram-${Math.random().toString(16).slice(2)}.json`, + sessionStorePath: `/tmp/openclaw-telegram-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}.json`, })); const { loadWebMedia } = vi.hoisted((): { loadWebMedia: AnyMock } => ({ @@ -212,6 +212,17 @@ export const getOnHandler = (event: string) => { return handler as (ctx: Record) => Promise; }; +const DEFAULT_TELEGRAM_TEST_CONFIG: OpenClawConfig = { + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, +}; + export function makeTelegramMessageCtx(params: { chat: { id: number; @@ -265,16 +276,7 @@ export function makeForumGroupMessageCtx(params?: { beforeEach(() => { resetInboundDedupe(); loadConfig.mockReset(); - loadConfig.mockReturnValue({ - agents: { - defaults: { - envelopeTimezone: "utc", - }, - }, - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"] }, - }, - }); + loadConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG); loadWebMedia.mockReset(); readChannelAllowFromStore.mockReset(); readChannelAllowFromStore.mockResolvedValue([]); diff --git a/test/helpers/temp-home.ts b/test/helpers/temp-home.ts index 8451e13bbf2..a19df15249a 100644 --- a/test/helpers/temp-home.ts +++ b/test/helpers/temp-home.ts @@ -13,6 +13,13 @@ type EnvSnapshot = { stateDir: string | undefined; }; +type SharedHomeRootState = { + rootPromise: Promise; + nextCaseId: number; +}; + +const SHARED_HOME_ROOTS = new Map(); + function snapshotEnv(): EnvSnapshot { return { home: process.env.HOME, @@ -76,11 +83,27 @@ function setTempHome(base: string) { process.env.HOMEPATH = match[2] || "\\"; } +async function allocateTempHomeBase(prefix: string): Promise { + let state = SHARED_HOME_ROOTS.get(prefix); + if (!state) { + state = { + rootPromise: fs.mkdtemp(path.join(os.tmpdir(), prefix)), + nextCaseId: 0, + }; + SHARED_HOME_ROOTS.set(prefix, state); + } + const root = await state.rootPromise; + const base = path.join(root, `case-${state.nextCaseId++}`); + await fs.mkdir(base, { recursive: true }); + return base; +} + export async function withTempHome( fn: (home: string) => Promise, opts: { env?: Record; prefix?: string } = {}, ): Promise { - const base = await fs.mkdtemp(path.join(os.tmpdir(), opts.prefix ?? "openclaw-test-home-")); + const prefix = opts.prefix ?? "openclaw-test-home-"; + const base = await allocateTempHomeBase(prefix); const snapshot = snapshotEnv(); const envKeys = Object.keys(opts.env ?? {}); for (const key of envKeys) {