diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 04b60c3556..6ce8256196 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -30,7 +30,7 @@ const unitIsolatedFilesRaw = [ "src/browser/server-context.remote-tab-ops.test.ts", "src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts", // Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage. - "src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts", + "src/imessage/monitor.shutdown.unhandled-rejection.test.ts", ]; const unitIsolatedFiles = unitIsolatedFilesRaw.filter((file) => fs.existsSync(file)); diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index 0d60a16a69..b8aebb3ec7 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; const noop = () => {}; let lifecycleHandler: @@ -39,16 +39,24 @@ vi.mock("./subagent-registry.store.js", () => ({ })); describe("subagent registry steer restarts", () => { + let mod: typeof import("./subagent-registry.js"); + + beforeAll(async () => { + mod = await import("./subagent-registry.js"); + }); + + const flushAnnounce = async () => { + await new Promise((resolve) => setImmediate(resolve)); + }; + afterEach(async () => { - announceSpy.mockClear(); + announceSpy.mockReset(); + announceSpy.mockResolvedValue(true); lifecycleHandler = undefined; - const mod = await import("./subagent-registry.js"); mod.resetSubagentRegistryForTests({ persist: false }); }); it("suppresses announce for interrupted runs and only announces the replacement run", async () => { - const mod = await import("./subagent-registry.js"); - mod.registerSubagentRun({ runId: "run-old", childSessionKey: "agent:main:subagent:steer", @@ -70,7 +78,7 @@ describe("subagent registry steer restarts", () => { data: { phase: "end" }, }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAnnounce(); expect(announceSpy).not.toHaveBeenCalled(); const replaced = mod.replaceSubagentRunAfterSteer({ @@ -90,7 +98,7 @@ describe("subagent registry steer restarts", () => { data: { phase: "end" }, }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAnnounce(); expect(announceSpy).toHaveBeenCalledTimes(1); const announce = announceSpy.mock.calls[0]?.[0] as { childRunId?: string }; @@ -98,8 +106,6 @@ describe("subagent registry steer restarts", () => { }); it("restores announce for a finished run when steer replacement dispatch fails", async () => { - const mod = await import("./subagent-registry.js"); - mod.registerSubagentRun({ runId: "run-failed-restart", childSessionKey: "agent:main:subagent:failed-restart", @@ -117,11 +123,11 @@ describe("subagent registry steer restarts", () => { data: { phase: "end" }, }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAnnounce(); expect(announceSpy).not.toHaveBeenCalled(); expect(mod.clearSubagentRunSteerRestart("run-failed-restart")).toBe(true); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAnnounce(); expect(announceSpy).toHaveBeenCalledTimes(1); const announce = announceSpy.mock.calls[0]?.[0] as { childRunId?: string }; @@ -129,7 +135,6 @@ describe("subagent registry steer restarts", () => { }); it("marks killed runs terminated and inactive", async () => { - const mod = await import("./subagent-registry.js"); const childSessionKey = "agent:main:subagent:killed"; mod.registerSubagentRun({ @@ -156,7 +161,6 @@ describe("subagent registry steer restarts", () => { }); it("retries deferred parent cleanup after a descendant announces", async () => { - const mod = await import("./subagent-registry.js"); let parentAttempts = 0; announceSpy.mockImplementation(async (params: unknown) => { const typed = params as { childRunId?: string }; @@ -189,14 +193,14 @@ describe("subagent registry steer restarts", () => { runId: "run-parent", data: { phase: "end" }, }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAnnounce(); lifecycleHandler?.({ stream: "lifecycle", runId: "run-child", data: { phase: "end" }, }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAnnounce(); const childRunIds = announceSpy.mock.calls.map( (call) => (call[0] as { childRunId?: string }).childRunId, diff --git a/src/agents/workspace.load-extra-bootstrap-files.test.ts b/src/agents/workspace.load-extra-bootstrap-files.test.ts index 32586029c0..0a478524ae 100644 --- a/src/agents/workspace.load-extra-bootstrap-files.test.ts +++ b/src/agents/workspace.load-extra-bootstrap-files.test.ts @@ -1,12 +1,31 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { makeTempWorkspace } from "../test-helpers/workspace.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { loadExtraBootstrapFiles } from "./workspace.js"; describe("loadExtraBootstrapFiles", () => { + let fixtureRoot = ""; + let fixtureCount = 0; + + const createWorkspaceDir = async (prefix: string) => { + const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); + await fs.mkdir(dir, { recursive: true }); + return dir; + }; + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-extra-bootstrap-")); + }); + + afterAll(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }); + it("loads recognized bootstrap files from glob patterns", async () => { - const workspaceDir = await makeTempWorkspace("openclaw-extra-bootstrap-glob-"); + const workspaceDir = await createWorkspaceDir("glob"); const packageDir = path.join(workspaceDir, "packages", "core"); await fs.mkdir(packageDir, { recursive: true }); await fs.writeFile(path.join(packageDir, "TOOLS.md"), "tools", "utf-8"); @@ -20,7 +39,7 @@ describe("loadExtraBootstrapFiles", () => { }); it("keeps path-traversal attempts outside workspace excluded", async () => { - const rootDir = await makeTempWorkspace("openclaw-extra-bootstrap-root-"); + const rootDir = await createWorkspaceDir("root"); const workspaceDir = path.join(rootDir, "workspace"); const outsideDir = path.join(rootDir, "outside"); await fs.mkdir(workspaceDir, { recursive: true }); @@ -37,7 +56,7 @@ describe("loadExtraBootstrapFiles", () => { return; } - const rootDir = await makeTempWorkspace("openclaw-extra-bootstrap-symlink-"); + const rootDir = await createWorkspaceDir("symlink"); const realWorkspace = path.join(rootDir, "real-workspace"); const linkedWorkspace = path.join(rootDir, "linked-workspace"); await fs.mkdir(realWorkspace, { recursive: true }); diff --git a/src/browser/chrome.test.ts b/src/browser/chrome.test.ts index 471218a1c7..0551b27c28 100644 --- a/src/browser/chrome.test.ts +++ b/src/browser/chrome.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { decorateOpenClawProfile, ensureProfileCleanExit, @@ -23,112 +23,111 @@ async function readJson(filePath: string): Promise> { } describe("browser chrome profile decoration", () => { + let fixtureRoot = ""; + let fixtureCount = 0; + + const createUserDataDir = async () => { + const dir = path.join(fixtureRoot, `profile-${fixtureCount++}`); + await fsp.mkdir(dir, { recursive: true }); + return dir; + }; + + beforeAll(async () => { + fixtureRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-suite-")); + }); + + afterAll(async () => { + if (fixtureRoot) { + await fsp.rm(fixtureRoot, { recursive: true, force: true }); + } + }); + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("writes expected name + signed ARGB seed to Chrome prefs", async () => { - const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-")); - try { - decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); + const userDataDir = await createUserDataDir(); + decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); - const expectedSignedArgb = ((0xff << 24) | 0xff4500) >> 0; + const expectedSignedArgb = ((0xff << 24) | 0xff4500) >> 0; - const localState = await readJson(path.join(userDataDir, "Local State")); - const profile = localState.profile as Record; - const infoCache = profile.info_cache as Record; - const def = infoCache.Default as Record; + const localState = await readJson(path.join(userDataDir, "Local State")); + const profile = localState.profile as Record; + const infoCache = profile.info_cache as Record; + const def = infoCache.Default as Record; - expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); - expect(def.shortcut_name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); - expect(def.profile_color_seed).toBe(expectedSignedArgb); - expect(def.profile_highlight_color).toBe(expectedSignedArgb); - expect(def.default_avatar_fill_color).toBe(expectedSignedArgb); - expect(def.default_avatar_stroke_color).toBe(expectedSignedArgb); + expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); + expect(def.shortcut_name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); + expect(def.profile_color_seed).toBe(expectedSignedArgb); + expect(def.profile_highlight_color).toBe(expectedSignedArgb); + expect(def.default_avatar_fill_color).toBe(expectedSignedArgb); + expect(def.default_avatar_stroke_color).toBe(expectedSignedArgb); - const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); - const browser = prefs.browser as Record; - const theme = browser.theme as Record; - const autogenerated = prefs.autogenerated as Record; - const autogeneratedTheme = autogenerated.theme as Record; + const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); + const browser = prefs.browser as Record; + const theme = browser.theme as Record; + const autogenerated = prefs.autogenerated as Record; + const autogeneratedTheme = autogenerated.theme as Record; - expect(theme.user_color2).toBe(expectedSignedArgb); - expect(autogeneratedTheme.color).toBe(expectedSignedArgb); + expect(theme.user_color2).toBe(expectedSignedArgb); + expect(autogeneratedTheme.color).toBe(expectedSignedArgb); - const marker = await fsp.readFile( - path.join(userDataDir, ".openclaw-profile-decorated"), - "utf-8", - ); - expect(marker.trim()).toMatch(/^\d+$/); - } finally { - await fsp.rm(userDataDir, { recursive: true, force: true }); - } + const marker = await fsp.readFile( + path.join(userDataDir, ".openclaw-profile-decorated"), + "utf-8", + ); + expect(marker.trim()).toMatch(/^\d+$/); }); it("best-effort writes name when color is invalid", async () => { - const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-")); - try { - decorateOpenClawProfile(userDataDir, { color: "lobster-orange" }); - const localState = await readJson(path.join(userDataDir, "Local State")); - const profile = localState.profile as Record; - const infoCache = profile.info_cache as Record; - const def = infoCache.Default as Record; + const userDataDir = await createUserDataDir(); + decorateOpenClawProfile(userDataDir, { color: "lobster-orange" }); + const localState = await readJson(path.join(userDataDir, "Local State")); + const profile = localState.profile as Record; + const infoCache = profile.info_cache as Record; + const def = infoCache.Default as Record; - expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); - expect(def.profile_color_seed).toBeUndefined(); - } finally { - await fsp.rm(userDataDir, { recursive: true, force: true }); - } + expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); + expect(def.profile_color_seed).toBeUndefined(); }); it("recovers from missing/invalid preference files", async () => { - const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-")); - try { - await fsp.mkdir(path.join(userDataDir, "Default"), { recursive: true }); - await fsp.writeFile(path.join(userDataDir, "Local State"), "{", "utf-8"); // invalid JSON - await fsp.writeFile( - path.join(userDataDir, "Default", "Preferences"), - "[]", // valid JSON but wrong shape - "utf-8", - ); + const userDataDir = await createUserDataDir(); + await fsp.mkdir(path.join(userDataDir, "Default"), { recursive: true }); + await fsp.writeFile(path.join(userDataDir, "Local State"), "{", "utf-8"); // invalid JSON + await fsp.writeFile( + path.join(userDataDir, "Default", "Preferences"), + "[]", // valid JSON but wrong shape + "utf-8", + ); - decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); + decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); - const localState = await readJson(path.join(userDataDir, "Local State")); - expect(typeof localState.profile).toBe("object"); + const localState = await readJson(path.join(userDataDir, "Local State")); + expect(typeof localState.profile).toBe("object"); - const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); - expect(typeof prefs.profile).toBe("object"); - } finally { - await fsp.rm(userDataDir, { recursive: true, force: true }); - } + const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); + expect(typeof prefs.profile).toBe("object"); }); it("writes clean exit prefs to avoid restore prompts", async () => { - const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-")); - try { - ensureProfileCleanExit(userDataDir); - const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); - expect(prefs.exit_type).toBe("Normal"); - expect(prefs.exited_cleanly).toBe(true); - } finally { - await fsp.rm(userDataDir, { recursive: true, force: true }); - } + const userDataDir = await createUserDataDir(); + ensureProfileCleanExit(userDataDir); + const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); + expect(prefs.exit_type).toBe("Normal"); + expect(prefs.exited_cleanly).toBe(true); }); it("is idempotent when rerun on an existing profile", async () => { - const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-")); - try { - decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); - decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); + const userDataDir = await createUserDataDir(); + decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); + decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); - const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); - const profile = prefs.profile as Record; - expect(profile.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); - } finally { - await fsp.rm(userDataDir, { recursive: true, force: true }); - } + const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); + const profile = prefs.profile as Record; + expect(profile.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); }); }); diff --git a/src/cli/browser-cli-extension.test.ts b/src/cli/browser-cli-extension.test.ts index 19b994d56c..be5957f314 100644 --- a/src/cli/browser-cli-extension.test.ts +++ b/src/cli/browser-cli-extension.test.ts @@ -1,7 +1,5 @@ -import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const copyToClipboard = vi.fn(); const runtime = { @@ -10,6 +8,91 @@ const runtime = { exit: vi.fn(), }; +type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" }; + +const state = vi.hoisted(() => ({ + entries: new Map(), + counter: 0, +})); + +const abs = (p: string) => path.resolve(p); + +function setFile(p: string, content = "") { + const resolved = abs(p); + state.entries.set(resolved, { kind: "file", content }); + setDir(path.dirname(resolved)); +} + +function setDir(p: string) { + const resolved = abs(p); + if (!state.entries.has(resolved)) { + state.entries.set(resolved, { kind: "dir" }); + } +} + +function copyTree(src: string, dest: string) { + const srcAbs = abs(src); + const destAbs = abs(dest); + const srcPrefix = `${srcAbs}${path.sep}`; + for (const [key, entry] of state.entries.entries()) { + if (key === srcAbs || key.startsWith(srcPrefix)) { + const rel = key === srcAbs ? "" : key.slice(srcPrefix.length); + const next = rel ? path.join(destAbs, rel) : destAbs; + state.entries.set(next, entry); + } + } +} + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + const pathMod = await import("node:path"); + const absInMock = (p: string) => pathMod.resolve(p); + + const wrapped = { + ...actual, + existsSync: (p: string) => state.entries.has(absInMock(p)), + mkdirSync: (p: string, _opts?: unknown) => { + setDir(p); + }, + writeFileSync: (p: string, content: string) => { + setFile(p, content); + }, + renameSync: (from: string, to: string) => { + const fromAbs = absInMock(from); + const toAbs = absInMock(to); + const entry = state.entries.get(fromAbs); + if (!entry) { + throw new Error(`ENOENT: no such file or directory, rename '${from}' -> '${to}'`); + } + state.entries.delete(fromAbs); + state.entries.set(toAbs, entry); + }, + rmSync: (p: string) => { + const root = absInMock(p); + const prefix = `${root}${pathMod.sep}`; + const keys = Array.from(state.entries.keys()); + for (const key of keys) { + if (key === root || key.startsWith(prefix)) { + state.entries.delete(key); + } + } + }, + mkdtempSync: (prefix: string) => { + const dir = `${prefix}${state.counter++}`; + setDir(dir); + return dir; + }, + promises: { + ...actual.promises, + cp: async (src: string, dest: string, _opts?: unknown) => { + copyTree(src, dest); + }, + }, + }; + + return { ...wrapped, default: wrapped }; +}); + vi.mock("../infra/clipboard.js", () => ({ copyToClipboard, })); @@ -18,86 +101,83 @@ vi.mock("../runtime.js", () => ({ defaultRuntime: runtime, })); +let resolveBundledExtensionRootDir: typeof import("./browser-cli-extension.js").resolveBundledExtensionRootDir; +let installChromeExtension: typeof import("./browser-cli-extension.js").installChromeExtension; +let registerBrowserExtensionCommands: typeof import("./browser-cli-extension.js").registerBrowserExtensionCommands; + +beforeAll(async () => { + ({ resolveBundledExtensionRootDir, installChromeExtension, registerBrowserExtensionCommands } = + await import("./browser-cli-extension.js")); +}); + +beforeEach(() => { + state.entries.clear(); + state.counter = 0; + copyToClipboard.mockReset(); + runtime.log.mockReset(); + runtime.error.mockReset(); + runtime.exit.mockReset(); + vi.clearAllMocks(); +}); + function writeManifest(dir: string) { - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify({ manifest_version: 3 })); + setDir(dir); + setFile(path.join(dir, "manifest.json"), JSON.stringify({ manifest_version: 3 })); } -describe("bundled extension resolver", () => { - it("walks up to find the assets directory", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-root-")); +describe("bundled extension resolver (fs-mocked)", () => { + it("walks up to find the assets directory", () => { + const root = abs("/tmp/openclaw-ext-root"); const here = path.join(root, "dist", "cli"); const assets = path.join(root, "assets", "chrome-extension"); - try { - writeManifest(assets); - fs.mkdirSync(here, { recursive: true }); + writeManifest(assets); + setDir(here); - const { resolveBundledExtensionRootDir } = await import("./browser-cli-extension.js"); - expect(resolveBundledExtensionRootDir(here)).toBe(assets); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } + expect(resolveBundledExtensionRootDir(here)).toBe(assets); }); - it("prefers the nearest assets directory", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-root-")); + it("prefers the nearest assets directory", () => { + const root = abs("/tmp/openclaw-ext-root-nearest"); const here = path.join(root, "dist", "cli"); const distAssets = path.join(root, "dist", "assets", "chrome-extension"); const rootAssets = path.join(root, "assets", "chrome-extension"); - try { - writeManifest(distAssets); - writeManifest(rootAssets); - fs.mkdirSync(here, { recursive: true }); + writeManifest(distAssets); + writeManifest(rootAssets); + setDir(here); - const { resolveBundledExtensionRootDir } = await import("./browser-cli-extension.js"); - expect(resolveBundledExtensionRootDir(here)).toBe(distAssets); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } + expect(resolveBundledExtensionRootDir(here)).toBe(distAssets); }); }); -describe("browser extension install", () => { +describe("browser extension install (fs-mocked)", () => { it("installs into the state dir (never node_modules)", async () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-")); + const tmp = abs("/tmp/openclaw-ext-install"); + const sourceDir = path.join(tmp, "source-ext"); + writeManifest(sourceDir); + setFile(path.join(sourceDir, "test.txt"), "ok"); - try { - const { installChromeExtension } = await import("./browser-cli-extension.js"); - // Keep this test hermetic + fast: use a tiny fixture instead of copying the - // full repo assets tree. - const sourceDir = path.join(tmp, "source-ext"); - writeManifest(sourceDir); - fs.writeFileSync(path.join(sourceDir, "test.txt"), "ok"); - const result = await installChromeExtension({ stateDir: tmp, sourceDir }); + const result = await installChromeExtension({ stateDir: tmp, sourceDir }); - expect(result.path).toBe(path.join(tmp, "browser", "chrome-extension")); - expect(fs.existsSync(path.join(result.path, "manifest.json"))).toBe(true); - expect(fs.existsSync(path.join(result.path, "test.txt"))).toBe(true); - expect(result.path.includes("node_modules")).toBe(false); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + expect(result.path).toBe(path.join(tmp, "browser", "chrome-extension")); + expect(state.entries.has(abs(path.join(result.path, "manifest.json")))).toBe(true); + expect(state.entries.has(abs(path.join(result.path, "test.txt")))).toBe(true); + expect(result.path.includes("node_modules")).toBe(false); }); it("copies extension path to clipboard", async () => { const prev = process.env.OPENCLAW_STATE_DIR; - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-path-")); + const tmp = abs("/tmp/openclaw-ext-path"); process.env.OPENCLAW_STATE_DIR = tmp; try { - copyToClipboard.mockReset(); copyToClipboard.mockResolvedValue(true); - runtime.log.mockReset(); - runtime.error.mockReset(); - runtime.exit.mockReset(); const dir = path.join(tmp, "browser", "chrome-extension"); writeManifest(dir); const { Command } = await import("commander"); - const { registerBrowserExtensionCommands } = await import("./browser-cli-extension.js"); const program = new Command(); const browser = program.command("browser").option("--json", false); @@ -107,7 +187,6 @@ describe("browser extension install", () => { ); await program.parseAsync(["browser", "extension", "path"], { from: "user" }); - expect(copyToClipboard).toHaveBeenCalledWith(dir); } finally { if (prev === undefined) { diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index 5bd0f73e48..83d61c97d9 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -64,11 +64,27 @@ describe("runGatewayLoop", () => { const closeFirst = vi.fn(async () => {}); const closeSecond = vi.fn(async () => {}); - const start = vi - .fn() - .mockResolvedValueOnce({ close: closeFirst }) - .mockResolvedValueOnce({ close: closeSecond }) - .mockRejectedValueOnce(new Error("stop-loop")); + + const start = vi.fn(); + let resolveFirst: (() => void) | null = null; + const startedFirst = new Promise((resolve) => { + resolveFirst = resolve; + }); + start.mockImplementationOnce(async () => { + resolveFirst?.(); + return { close: closeFirst }; + }); + + let resolveSecond: (() => void) | null = null; + const startedSecond = new Promise((resolve) => { + resolveSecond = resolve; + }); + start.mockImplementationOnce(async () => { + resolveSecond?.(); + return { close: closeSecond }; + }); + + start.mockRejectedValueOnce(new Error("stop-loop")); const beforeSigterm = new Set( process.listeners("SIGTERM") as Array<(...args: unknown[]) => void>, @@ -80,25 +96,24 @@ describe("runGatewayLoop", () => { process.listeners("SIGUSR1") as Array<(...args: unknown[]) => void>, ); - const loopPromise = import("./run-loop.js").then(({ runGatewayLoop }) => - runGatewayLoop({ - start, - runtime: { - exit: vi.fn(), - } as { exit: (code: number) => never }, - }), - ); + const { runGatewayLoop } = await import("./run-loop.js"); + const loopPromise = runGatewayLoop({ + start, + runtime: { + exit: vi.fn(), + } as { exit: (code: number) => never }, + }); try { - await vi.waitFor(() => { - expect(start).toHaveBeenCalledTimes(1); - }); + await startedFirst; + expect(start).toHaveBeenCalledTimes(1); + await new Promise((resolve) => setImmediate(resolve)); process.emit("SIGUSR1"); - await vi.waitFor(() => { - expect(start).toHaveBeenCalledTimes(2); - }); + await startedSecond; + expect(start).toHaveBeenCalledTimes(2); + await new Promise((resolve) => setImmediate(resolve)); expect(waitForActiveTasks).toHaveBeenCalledWith(30_000); expect(gatewayLog.warn).toHaveBeenCalledWith(DRAIN_TIMEOUT_LOG); diff --git a/src/config/io.ts b/src/config/io.ts index 8ce2ae1d25..e922e005a4 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -933,6 +933,12 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { if (suspiciousReasons.length === 0) { return; } + // Tests often write minimal configs (missing meta, etc); keep output quiet unless requested. + const isVitest = deps.env.VITEST === "true"; + const shouldLogInVitest = deps.env.OPENCLAW_TEST_CONFIG_WRITE_ANOMALY_LOG === "1"; + if (isVitest && !shouldLogInVitest) { + return; + } deps.logger.warn(`Config write anomaly: ${configPath} (${suspiciousReasons.join(", ")})`); }; const auditRecordBase = { diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 75eeaaac0c..b8353e54b6 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { withTempHome } from "./home-env.test-harness.js"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { createConfigIO } from "./io.js"; describe("config io write", () => { @@ -10,6 +10,76 @@ describe("config io write", () => { error: () => {}, }; + type HomeEnvSnapshot = { + home: string | undefined; + userProfile: string | undefined; + homeDrive: string | undefined; + homePath: string | undefined; + stateDir: string | undefined; + }; + + const snapshotHomeEnv = (): HomeEnvSnapshot => ({ + home: process.env.HOME, + userProfile: process.env.USERPROFILE, + homeDrive: process.env.HOMEDRIVE, + homePath: process.env.HOMEPATH, + stateDir: process.env.OPENCLAW_STATE_DIR, + }); + + const restoreHomeEnv = (snapshot: HomeEnvSnapshot) => { + const restoreKey = (key: string, value: string | undefined) => { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }; + restoreKey("HOME", snapshot.home); + restoreKey("USERPROFILE", snapshot.userProfile); + restoreKey("HOMEDRIVE", snapshot.homeDrive); + restoreKey("HOMEPATH", snapshot.homePath); + restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir); + }; + + let fixtureRoot = ""; + let caseId = 0; + + async function withTempHome(prefix: string, fn: (home: string) => Promise): Promise { + const safePrefix = prefix.trim().replace(/[^a-zA-Z0-9._-]+/g, "-") || "tmp"; + const home = path.join(fixtureRoot, `${safePrefix}${caseId++}`); + await fs.mkdir(path.join(home, ".openclaw"), { recursive: true }); + + const snapshot = snapshotHomeEnv(); + process.env.HOME = home; + process.env.USERPROFILE = home; + process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); + + if (process.platform === "win32") { + const match = home.match(/^([A-Za-z]:)(.*)$/); + if (match) { + process.env.HOMEDRIVE = match[1]; + process.env.HOMEPATH = match[2] || "\\"; + } + } + + try { + await fn(home); + } finally { + restoreHomeEnv(snapshot); + } + } + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-io-")); + }); + + afterAll(async () => { + if (!fixtureRoot) { + return; + } + await fs.rm(fixtureRoot, { recursive: true, force: true }); + }); + it("persists caller changes onto resolved config without leaking runtime defaults", async () => { await withTempHome("openclaw-config-io-", async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); diff --git a/src/config/sessions.cache.test.ts b/src/config/sessions.cache.test.ts index 92922576bb..cc3c6cb75a 100644 --- a/src/config/sessions.cache.test.ts +++ b/src/config/sessions.cache.test.ts @@ -1,7 +1,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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { clearSessionStoreCacheForTest, loadSessionStore, @@ -10,12 +10,23 @@ import { } from "./sessions.js"; describe("Session Store Cache", () => { + let fixtureRoot = ""; + let caseId = 0; let testDir: string; let storePath: string; + beforeAll(() => { + fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "session-cache-test-")); + }); + + afterAll(() => { + if (fixtureRoot) { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + beforeEach(() => { - // Create a temporary directory for test - testDir = path.join(os.tmpdir(), `session-cache-test-${Date.now()}`); + testDir = path.join(fixtureRoot, `case-${caseId++}`); fs.mkdirSync(testDir, { recursive: true }); storePath = path.join(testDir, "sessions.json"); @@ -27,10 +38,6 @@ describe("Session Store Cache", () => { }); afterEach(() => { - // Clean up test directory - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } clearSessionStoreCacheForTest(); delete process.env.OPENCLAW_SESSION_CACHE_TTL_MS; }); diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 686a46d374..4bce24426a 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -2,7 +2,6 @@ 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 { sleep } from "../utils.js"; import { buildGroupDisplayName, deriveSessionKey, @@ -490,24 +489,39 @@ describe("sessions", () => { "utf-8", ); - await Promise.all([ - updateSessionStoreEntry({ - storePath, - sessionKey: mainSessionKey, - update: async () => { - await sleep(10); - return { modelOverride: "anthropic/claude-opus-4-5" }; - }, - }), - updateSessionStoreEntry({ - storePath, - sessionKey: mainSessionKey, - update: async () => { - await sleep(1); - return { thinkingLevel: "high" }; - }, - }), - ]); + const createDeferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + }; + const firstStarted = createDeferred(); + const releaseFirst = createDeferred(); + + const p1 = updateSessionStoreEntry({ + storePath, + sessionKey: mainSessionKey, + update: async () => { + firstStarted.resolve(); + await releaseFirst.promise; + return { modelOverride: "anthropic/claude-opus-4-5" }; + }, + }); + const p2 = updateSessionStoreEntry({ + storePath, + sessionKey: mainSessionKey, + update: async () => { + await firstStarted.promise; + return { thinkingLevel: "high" }; + }, + }); + + await firstStarted.promise; + releaseFirst.resolve(); + await Promise.all([p1, p2]); const store = loadSessionStore(storePath); expect(store[mainSessionKey]?.modelOverride).toBe("anthropic/claude-opus-4-5"); diff --git a/src/config/sessions/store.lock.test.ts b/src/config/sessions/store.lock.test.ts index 826b3715e3..91ee7e0ddf 100644 --- a/src/config/sessions/store.lock.test.ts +++ b/src/config/sessions/store.lock.test.ts @@ -27,19 +27,6 @@ describe("session store lock (Promise chain mutex)", () => { return { promise, resolve, reject }; } - async function waitForFile(filePath: string, maxTicks = 50): Promise { - for (let tick = 0; tick < maxTicks; tick += 1) { - try { - await fs.access(filePath); - return; - } catch { - // Works under both real + fake timers (setImmediate is faked). - await new Promise((resolve) => process.nextTick(resolve)); - } - } - throw new Error(`timed out waiting for file: ${filePath}`); - } - async function makeTmpStore( initial: Record = {}, ): Promise<{ dir: string; storePath: string }> { @@ -76,8 +63,8 @@ describe("session store lock (Promise chain mutex)", () => { [key]: { sessionId: "s1", updatedAt: 100, counter: 0 }, }); - // Launch 10 concurrent read-modify-write cycles. - const N = 10; + // Launch a few concurrent read-modify-write cycles (enough to surface stale-read races). + const N = 4; await Promise.all( Array.from({ length: N }, (_, i) => updateSessionStore(storePath, async (store) => { @@ -309,16 +296,17 @@ describe("session store lock (Promise chain mutex)", () => { }); let timedOutRan = false; - const lockPath = `${storePath}.lock`; const releaseLock = createDeferred(); + const lockStarted = createDeferred(); const lockHolder = withSessionStoreLockForTest( storePath, async () => { + lockStarted.resolve(); await releaseLock.promise; }, { timeoutMs: 1_000 }, ); - await waitForFile(lockPath); + await lockStarted.promise; const timedOut = withSessionStoreLockForTest( storePath, async () => { @@ -350,12 +338,15 @@ describe("session store lock (Promise chain mutex)", () => { const lockPath = `${storePath}.lock`; const allowWrite = createDeferred(); + const writeStarted = createDeferred(); const write = updateSessionStore(storePath, async (store) => { + writeStarted.resolve(); await allowWrite.promise; store[key] = { ...store[key], modelOverride: "v" } as unknown as SessionEntry; }); - await waitForFile(lockPath); + await writeStarted.promise; + await fs.access(lockPath); allowWrite.resolve(); await write; diff --git a/src/cron/service.every-jobs-fire.test.ts b/src/cron/service.every-jobs-fire.test.ts index 9a38121aec..b70c3654f7 100644 --- a/src/cron/service.every-jobs-fire.test.ts +++ b/src/cron/service.every-jobs-fire.test.ts @@ -190,12 +190,14 @@ describe("CronService interval/cron jobs fire on time", () => { }); await cron.start(); - for (let minute = 1; minute <= 6; minute++) { + // Perf: a few recomputation cycles are enough to catch legacy "every" drift. + for (let minute = 1; minute <= 3; minute++) { vi.setSystemTime(new Date(nowMs + minute * 60_000)); const minuteRun = await cron.run("minute-cron", "force"); expect(minuteRun).toEqual({ ok: true, ran: true }); } + // "every" cadence is 2m; verify it stays due at the 6-minute boundary. vi.setSystemTime(new Date(nowMs + 6 * 60_000)); const sfRun = await cron.run("legacy-every", "due"); expect(sfRun).toEqual({ ok: true, ran: true }); diff --git a/src/cron/service.issue-16156-list-skips-cron.test.ts b/src/cron/service.issue-16156-list-skips-cron.test.ts index 85b960dd33..dd9363a519 100644 --- a/src/cron/service.issue-16156-list-skips-cron.test.ts +++ b/src/cron/service.issue-16156-list-skips-cron.test.ts @@ -1,7 +1,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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { CronEvent } from "./service.js"; import { CronService } from "./service.js"; @@ -12,14 +12,14 @@ const noopLogger = { error: vi.fn(), }; +let fixtureRoot = ""; +let caseId = 0; + async function makeStorePath() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-16156-")); - return { - storePath: path.join(dir, "cron", "jobs.json"), - cleanup: async () => { - await fs.rm(dir, { recursive: true, force: true }); - }, - }; + const dir = path.join(fixtureRoot, `case-${caseId++}`); + const storePath = path.join(dir, "cron", "jobs.json"); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + return { storePath }; } function createFinishedBarrier() { @@ -44,6 +44,16 @@ function createFinishedBarrier() { } describe("#16156: cron.list() must not silently advance past-due recurring jobs", () => { + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-16156-")); + }); + + afterAll(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined); + } + }); + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2025-12-13T00:00:00.000Z")); @@ -119,7 +129,6 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs" expect(updated?.state.nextRunAtMs).toBeGreaterThan(firstDueAt); cron.stop(); - await store.cleanup(); }); it("does not skip a cron job when status() is called while the job is past-due", async () => { @@ -172,7 +181,6 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs" expect(updated?.state.lastStatus).toBe("ok"); cron.stop(); - await store.cleanup(); }); it("still fills missing nextRunAtMs via list() for enabled jobs", async () => { @@ -226,6 +234,5 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs" expect(job?.state.nextRunAtMs).toBeGreaterThan(nowMs); cron.stop(); - await store.cleanup(); }); }); diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index df1f867cf5..17e480054c 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -24,9 +24,6 @@ async function makeStorePath() { const storePath = path.join(dir, "jobs.json"); return { storePath, - cleanup: async () => { - await fs.rm(dir, { recursive: true, force: true }); - }, }; } @@ -152,7 +149,6 @@ describe("Cron issue regressions", () => { } cron.stop(); - await store.cleanup(); }); it("repairs missing nextRunAtMs on non-schedule updates without touching other jobs", async () => { @@ -183,7 +179,6 @@ describe("Cron issue regressions", () => { expect(updated.state.nextRunAtMs).toBe(created.state.nextRunAtMs); cron.stop(); - await store.cleanup(); }); it("does not advance unrelated due jobs when updating another job", async () => { @@ -230,7 +225,6 @@ describe("Cron issue regressions", () => { expect(persistedDueJob?.state?.nextRunAtMs).toBe(originalDueNextRunAtMs); cron.stop(); - await store.cleanup(); }); it("treats persisted jobs with missing enabled as enabled during update()", async () => { @@ -366,7 +360,6 @@ describe("Cron issue regressions", () => { cron.stop(); timeoutSpy.mockRestore(); - await store.cleanup(); }); it("re-arms timer without hot-looping when a run is already in progress", async () => { @@ -400,7 +393,6 @@ describe("Cron issue regressions", () => { .filter((d): d is number => typeof d === "number"); expect(delays).toContain(60_000); timeoutSpy.mockRestore(); - await store.cleanup(); }); it("skips forced manual runs while a timer-triggered run is in progress", async () => { @@ -467,7 +459,6 @@ describe("Cron issue regressions", () => { await cron.list({ includeDisabled: true }); cron.stop(); - await store.cleanup(); }); it("#13845: one-shot jobs with terminal statuses do not re-fire on restart", async () => { @@ -523,7 +514,6 @@ describe("Cron issue regressions", () => { expect(enqueueSystemEvent).not.toHaveBeenCalled(); cron.stop(); } - await store.cleanup(); }); it("records per-job start time and duration for batched due jobs", async () => { @@ -569,7 +559,5 @@ describe("Cron issue regressions", () => { expect(secondDone?.state.lastRunAtMs).toBe(dueAt + 50); expect(secondDone?.state.lastDurationMs).toBe(20); expect(startedAtEvents).toEqual([dueAt, dueAt + 50]); - - await store.cleanup(); }); }); diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index f9aa49c897..912ea9d1a0 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -1,19 +1,201 @@ -import fs from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js"; import type { CronEvent } from "./service.js"; import { CronService } from "./service.js"; -import { - createCronStoreHarness, - createNoopLogger, - installCronTestHooks, -} from "./service.test-harness.js"; +import { createNoopLogger, installCronTestHooks } from "./service.test-harness.js"; const noopLogger = createNoopLogger(); -const { makeStorePath } = createCronStoreHarness(); installCronTestHooks({ logger: noopLogger }); +type FakeFsEntry = + | { kind: "file"; content: string; mtimeMs: number } + | { kind: "dir"; mtimeMs: number }; + +const fsState = vi.hoisted(() => ({ + entries: new Map(), + nowMs: 0, + fixtureCount: 0, +})); + +const abs = (p: string) => path.resolve(p); +const fixturesRoot = abs(path.join("__openclaw_vitest__", "cron", "runs-one-shot")); +const isFixturePath = (p: string) => { + const resolved = abs(p); + const rootPrefix = `${fixturesRoot}${path.sep}`; + return resolved === fixturesRoot || resolved.startsWith(rootPrefix); +}; + +function bumpMtimeMs() { + fsState.nowMs += 1; + return fsState.nowMs; +} + +function ensureDir(dirPath: string) { + let current = abs(dirPath); + while (true) { + if (!fsState.entries.has(current)) { + fsState.entries.set(current, { kind: "dir", mtimeMs: bumpMtimeMs() }); + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } +} + +function setFile(filePath: string, content: string) { + const resolved = abs(filePath); + ensureDir(path.dirname(resolved)); + fsState.entries.set(resolved, { kind: "file", content, mtimeMs: bumpMtimeMs() }); +} + +async function makeStorePath() { + const dir = path.join(fixturesRoot, `case-${fsState.fixtureCount++}`); + ensureDir(dir); + const storePath = path.join(dir, "cron", "jobs.json"); + ensureDir(path.dirname(storePath)); + return { storePath, cleanup: async () => {} }; +} + +function writeStoreFile(storePath: string, payload: unknown) { + setFile(storePath, JSON.stringify(payload, null, 2)); +} + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + const pathMod = await import("node:path"); + const absInMock = (p: string) => pathMod.resolve(p); + const isFixtureInMock = (p: string) => { + const resolved = absInMock(p); + const rootPrefix = `${absInMock(fixturesRoot)}${pathMod.sep}`; + return resolved === absInMock(fixturesRoot) || resolved.startsWith(rootPrefix); + }; + + const mkErr = (code: string, message: string) => Object.assign(new Error(message), { code }); + + const promises = { + ...actual.promises, + mkdir: async (p: string) => { + if (!isFixtureInMock(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.mkdir as any)(p, { recursive: true }); + } + ensureDir(p); + }, + readFile: async (p: string) => { + if (!isFixtureInMock(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.readFile as any)(p, "utf-8"); + } + const entry = fsState.entries.get(absInMock(p)); + if (!entry || entry.kind !== "file") { + throw mkErr("ENOENT", `ENOENT: no such file or directory, open '${p}'`); + } + return entry.content; + }, + writeFile: async (p: string, data: string | Uint8Array) => { + if (!isFixtureInMock(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.writeFile as any)(p, data, "utf-8"); + } + const content = typeof data === "string" ? data : Buffer.from(data).toString("utf-8"); + setFile(p, content); + }, + rename: async (from: string, to: string) => { + if (!isFixtureInMock(from) || !isFixtureInMock(to)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.rename as any)(from, to); + } + const fromAbs = absInMock(from); + const toAbs = absInMock(to); + const entry = fsState.entries.get(fromAbs); + if (!entry || entry.kind !== "file") { + throw mkErr("ENOENT", `ENOENT: no such file or directory, rename '${from}' -> '${to}'`); + } + ensureDir(pathMod.dirname(toAbs)); + fsState.entries.delete(fromAbs); + fsState.entries.set(toAbs, { ...entry, mtimeMs: bumpMtimeMs() }); + }, + copyFile: async (from: string, to: string) => { + if (!isFixtureInMock(from) || !isFixtureInMock(to)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.copyFile as any)(from, to); + } + const entry = fsState.entries.get(absInMock(from)); + if (!entry || entry.kind !== "file") { + throw mkErr("ENOENT", `ENOENT: no such file or directory, copyfile '${from}' -> '${to}'`); + } + setFile(to, entry.content); + }, + stat: async (p: string) => { + if (!isFixtureInMock(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.stat as any)(p); + } + const entry = fsState.entries.get(absInMock(p)); + if (!entry) { + throw mkErr("ENOENT", `ENOENT: no such file or directory, stat '${p}'`); + } + return { + mtimeMs: entry.mtimeMs, + isDirectory: () => entry.kind === "dir", + isFile: () => entry.kind === "file", + }; + }, + access: async (p: string) => { + if (!isFixtureInMock(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.access as any)(p); + } + const entry = fsState.entries.get(absInMock(p)); + if (!entry) { + throw mkErr("ENOENT", `ENOENT: no such file or directory, access '${p}'`); + } + }, + unlink: async (p: string) => { + if (!isFixtureInMock(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.promises.unlink as any)(p); + } + fsState.entries.delete(absInMock(p)); + }, + } satisfies typeof actual.promises; + + const wrapped = { ...actual, promises }; + return { ...wrapped, default: wrapped }; +}); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + const wrapped = { + ...actual, + mkdir: async (p: string, _opts?: unknown) => { + if (!isFixturePath(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.mkdir as any)(p, { recursive: true }); + } + ensureDir(p); + }, + writeFile: async (p: string, data: string, _enc?: unknown) => { + if (!isFixturePath(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (actual.writeFile as any)(p, data, "utf-8"); + } + setFile(p, data); + }, + }; + return { ...wrapped, default: wrapped }; +}); + +beforeEach(() => { + fsState.entries.clear(); + fsState.nowMs = 0; + fsState.fixtureCount = 0; + ensureDir(fixturesRoot); +}); + function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -57,35 +239,8 @@ function createCronEventHarness() { } describe("CronService", () => { - async function loadLegacyJobFromStore(rawJob: Record) { - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - - await fs.mkdir(path.dirname(store.storePath), { recursive: true }); - await fs.writeFile( - store.storePath, - JSON.stringify({ version: 1, jobs: [rawJob] }, null, 2), - "utf-8", - ); - - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), - }); - - await cron.start(); - const jobs = await cron.list({ includeDisabled: true }); - const job = jobs.find((j) => j.id === rawJob.id); - - return { cron, store, enqueueSystemEvent, requestHeartbeatNow, job }; - } - it("runs a one-shot main job and disables it after success when requested", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -134,6 +289,7 @@ describe("CronService", () => { }); it("runs a one-shot job and deletes it after success by default", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -177,6 +333,7 @@ describe("CronService", () => { }); it("wakeMode now waits for heartbeat completion when available", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -244,6 +401,7 @@ describe("CronService", () => { }); it("passes agentId to runHeartbeatOnce for main-session wakeMode now jobs", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -293,6 +451,7 @@ describe("CronService", () => { }); it("wakeMode now falls back to queued heartbeat when main lane stays busy", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -343,6 +502,7 @@ describe("CronService", () => { }); it("runs an isolated job and posts summary to main", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -391,6 +551,7 @@ describe("CronService", () => { }); it("does not post isolated summary to main when run already delivered output", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -437,6 +598,11 @@ describe("CronService", () => { }); it("migrates legacy payload.provider to payload.channel on load", async () => { + ensureDir(fixturesRoot); + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + const rawJob = { id: "legacy-1", name: "legacy", @@ -456,7 +622,20 @@ describe("CronService", () => { state: {}, }; - const { cron, store, job } = await loadLegacyJobFromStore(rawJob); + writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] }); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); + + await cron.start(); + const jobs = await cron.list({ includeDisabled: true }); + const job = jobs.find((j) => j.id === rawJob.id); // Legacy delivery fields are migrated to the top-level delivery object const delivery = job?.delivery as unknown as Record; expect(delivery?.channel).toBe("telegram"); @@ -469,6 +648,11 @@ describe("CronService", () => { }); it("canonicalizes payload.channel casing on load", async () => { + ensureDir(fixturesRoot); + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + const rawJob = { id: "legacy-2", name: "legacy", @@ -488,7 +672,20 @@ describe("CronService", () => { state: {}, }; - const { cron, store, job } = await loadLegacyJobFromStore(rawJob); + writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] }); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); + + await cron.start(); + const jobs = await cron.list({ includeDisabled: true }); + const job = jobs.find((j) => j.id === rawJob.id); // Legacy delivery fields are migrated to the top-level delivery object const delivery = job?.delivery as unknown as Record; expect(delivery?.channel).toBe("telegram"); @@ -498,6 +695,7 @@ describe("CronService", () => { }); it("posts last output to main even when isolated job errors", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); @@ -546,6 +744,7 @@ describe("CronService", () => { }); it("rejects unsupported session/payload combinations", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const cron = new CronService({ @@ -586,32 +785,29 @@ describe("CronService", () => { }); it("skips invalid main jobs with agentTurn payloads from disk", async () => { + ensureDir(fixturesRoot); const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); const events = createCronEventHarness(); const atMs = Date.parse("2025-12-13T00:00:01.000Z"); - await fs.mkdir(path.dirname(store.storePath), { recursive: true }); - await fs.writeFile( - store.storePath, - JSON.stringify({ - version: 1, - jobs: [ - { - id: "job-1", - enabled: true, - createdAtMs: Date.parse("2025-12-13T00:00:00.000Z"), - updatedAtMs: Date.parse("2025-12-13T00:00:00.000Z"), - schedule: { kind: "at", at: new Date(atMs).toISOString() }, - sessionTarget: "main", - wakeMode: "now", - payload: { kind: "agentTurn", message: "bad" }, - state: {}, - }, - ], - }), - ); + writeStoreFile(store.storePath, { + version: 1, + jobs: [ + { + id: "job-1", + enabled: true, + createdAtMs: Date.parse("2025-12-13T00:00:00.000Z"), + updatedAtMs: Date.parse("2025-12-13T00:00:00.000Z"), + schedule: { kind: "at", at: new Date(atMs).toISOString() }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "agentTurn", message: "bad" }, + state: {}, + }, + ], + }); const cron = new CronService({ storePath: store.storePath, diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index beb67e7cef..ecfafb34fa 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -1,8 +1,5 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { PassThrough } from "node:stream"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { installLaunchAgent, isLaunchAgentListed, @@ -11,116 +8,69 @@ import { resolveLaunchAgentPlistPath, } from "./launchd.js"; -function parseLaunchctlCalls(raw: string): string[][] { - return raw - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => line.split(/\s+/)); -} +const state = vi.hoisted(() => ({ + launchctlCalls: [] as string[][], + listOutput: "", + dirs: new Set(), + files: new Map(), +})); -async function writeLaunchctlStub(binDir: string) { - if (process.platform === "win32") { - const stubJsPath = path.join(binDir, "launchctl.js"); - await fs.writeFile( - stubJsPath, - [ - 'import fs from "node:fs";', - "const args = process.argv.slice(2);", - "const logPath = process.env.OPENCLAW_TEST_LAUNCHCTL_LOG;", - "if (logPath) {", - ' fs.appendFileSync(logPath, args.join("\\t") + "\\n", "utf8");', - "}", - 'if (args[0] === "list") {', - ' const output = process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT || "";', - " process.stdout.write(output);", - "}", - "process.exit(0);", - "", - ].join("\n"), - "utf8", - ); - await fs.writeFile( - path.join(binDir, "launchctl.cmd"), - `@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`, - "utf8", - ); - return; +function normalizeLaunchctlArgs(file: string, args: string[]): string[] { + if (file === "launchctl") { + return args; } - - const shPath = path.join(binDir, "launchctl"); - await fs.writeFile( - shPath, - [ - "#!/bin/sh", - 'log_path="${OPENCLAW_TEST_LAUNCHCTL_LOG:-}"', - 'if [ -n "$log_path" ]; then', - ' line=""', - ' for arg in "$@"; do', - ' if [ -n "$line" ]; then', - ' line="$line $arg"', - " else", - ' line="$arg"', - " fi", - " done", - ' printf \'%s\\n\' "$line" >> "$log_path"', - "fi", - 'if [ "$1" = "list" ]; then', - " printf '%s' \"${OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT:-}\"", - "fi", - "exit 0", - "", - ].join("\n"), - "utf8", - ); - await fs.chmod(shPath, 0o755); -} - -async function withLaunchctlStub( - options: { listOutput?: string }, - run: (context: { env: Record; logPath: string }) => Promise, -) { - const originalPath = process.env.PATH; - const originalLogPath = process.env.OPENCLAW_TEST_LAUNCHCTL_LOG; - const originalListOutput = process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT; - - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-launchctl-test-")); - try { - const binDir = path.join(tmpDir, "bin"); - const homeDir = path.join(tmpDir, "home"); - const logPath = path.join(tmpDir, "launchctl.log"); - await fs.mkdir(binDir, { recursive: true }); - await fs.mkdir(homeDir, { recursive: true }); - - await writeLaunchctlStub(binDir); - - process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = logPath; - process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT = options.listOutput ?? ""; - process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; - - await run({ - env: { - HOME: homeDir, - OPENCLAW_PROFILE: "default", - }, - logPath, - }); - } finally { - process.env.PATH = originalPath; - if (originalLogPath === undefined) { - delete process.env.OPENCLAW_TEST_LAUNCHCTL_LOG; - } else { - process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = originalLogPath; - } - if (originalListOutput === undefined) { - delete process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT; - } else { - process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT = originalListOutput; - } - await fs.rm(tmpDir, { recursive: true, force: true }); + const idx = args.indexOf("launchctl"); + if (idx >= 0) { + return args.slice(idx + 1); } + return args; } +vi.mock("./exec-file.js", () => ({ + execFileUtf8: vi.fn(async (file: string, args: string[]) => { + const call = normalizeLaunchctlArgs(file, args); + state.launchctlCalls.push(call); + if (call[0] === "list") { + return { stdout: state.listOutput, stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }), +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + const wrapped = { + ...actual, + access: vi.fn(async (p: string) => { + const key = String(p); + if (state.files.has(key) || state.dirs.has(key)) { + return; + } + throw new Error(`ENOENT: no such file or directory, access '${key}'`); + }), + mkdir: vi.fn(async (p: string) => { + state.dirs.add(String(p)); + }), + unlink: vi.fn(async (p: string) => { + state.files.delete(String(p)); + }), + writeFile: vi.fn(async (p: string, data: string) => { + const key = String(p); + state.files.set(key, data); + state.dirs.add(String(key.split("/").slice(0, -1).join("/"))); + }), + }; + return { ...wrapped, default: wrapped }; +}); + +beforeEach(() => { + state.launchctlCalls.length = 0; + state.listOutput = ""; + state.dirs.clear(); + state.files.clear(); + vi.clearAllMocks(); +}); + describe("launchd runtime parsing", () => { it("parses state, pid, and exit status", () => { const output = [ @@ -140,92 +90,66 @@ describe("launchd runtime parsing", () => { describe("launchctl list detection", () => { it("detects the resolved label in launchctl list", async () => { - await withLaunchctlStub({ listOutput: "123 0 ai.openclaw.gateway\n" }, async ({ env }) => { - const listed = await isLaunchAgentListed({ env }); - expect(listed).toBe(true); + state.listOutput = "123 0 ai.openclaw.gateway\n"; + const listed = await isLaunchAgentListed({ + env: { HOME: "/Users/test", OPENCLAW_PROFILE: "default" }, }); + expect(listed).toBe(true); }); it("returns false when the label is missing", async () => { - await withLaunchctlStub({ listOutput: "123 0 com.other.service\n" }, async ({ env }) => { - const listed = await isLaunchAgentListed({ env }); - expect(listed).toBe(false); + state.listOutput = "123 0 com.other.service\n"; + const listed = await isLaunchAgentListed({ + env: { HOME: "/Users/test", OPENCLAW_PROFILE: "default" }, }); + expect(listed).toBe(false); }); }); describe("launchd bootstrap repair", () => { it("bootstraps and kickstarts the resolved label", async () => { - await withLaunchctlStub({}, async ({ env, logPath }) => { - const repair = await repairLaunchAgentBootstrap({ env }); - expect(repair.ok).toBe(true); + const env: Record = { + HOME: "/Users/test", + OPENCLAW_PROFILE: "default", + }; + const repair = await repairLaunchAgentBootstrap({ env }); + expect(repair.ok).toBe(true); - const calls = parseLaunchctlCalls(await fs.readFile(logPath, "utf8")); + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; + const label = "ai.openclaw.gateway"; + const plistPath = resolveLaunchAgentPlistPath(env); - const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; - const label = "ai.openclaw.gateway"; - const plistPath = resolveLaunchAgentPlistPath(env); - - expect(calls).toContainEqual(["bootstrap", domain, plistPath]); - expect(calls).toContainEqual(["kickstart", "-k", `${domain}/${label}`]); - }); + expect(state.launchctlCalls).toContainEqual(["bootstrap", domain, plistPath]); + expect(state.launchctlCalls).toContainEqual(["kickstart", "-k", `${domain}/${label}`]); }); }); describe("launchd install", () => { it("enables service before bootstrap (clears persisted disabled state)", async () => { - const originalPath = process.env.PATH; - const originalLogPath = process.env.OPENCLAW_TEST_LAUNCHCTL_LOG; + const env: Record = { + HOME: "/Users/test", + OPENCLAW_PROFILE: "default", + }; + await installLaunchAgent({ + env, + stdout: new PassThrough(), + programArguments: ["node", "-e", "process.exit(0)"], + }); - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-launchctl-test-")); - try { - const binDir = path.join(tmpDir, "bin"); - const homeDir = path.join(tmpDir, "home"); - const logPath = path.join(tmpDir, "launchctl.log"); - await fs.mkdir(binDir, { recursive: true }); - await fs.mkdir(homeDir, { recursive: true }); + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; + const label = "ai.openclaw.gateway"; + const plistPath = resolveLaunchAgentPlistPath(env); + const serviceId = `${domain}/${label}`; - await writeLaunchctlStub(binDir); - - process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = logPath; - process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; - - const env: Record = { - HOME: homeDir, - OPENCLAW_PROFILE: "default", - }; - await installLaunchAgent({ - env, - stdout: new PassThrough(), - programArguments: ["node", "-e", "process.exit(0)"], - }); - - const calls = parseLaunchctlCalls(await fs.readFile(logPath, "utf8")); - - const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; - const label = "ai.openclaw.gateway"; - const plistPath = resolveLaunchAgentPlistPath(env); - const serviceId = `${domain}/${label}`; - - const enableCalls = calls.filter((c) => c[0] === "enable" && c[1] === serviceId); - expect(enableCalls).toHaveLength(1); - - const enableIndex = calls.findIndex((c) => c[0] === "enable" && c[1] === serviceId); - const bootstrapIndex = calls.findIndex( - (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, - ); - expect(enableIndex).toBeGreaterThanOrEqual(0); - expect(bootstrapIndex).toBeGreaterThanOrEqual(0); - expect(enableIndex).toBeLessThan(bootstrapIndex); - } finally { - process.env.PATH = originalPath; - if (originalLogPath === undefined) { - delete process.env.OPENCLAW_TEST_LAUNCHCTL_LOG; - } else { - process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = originalLogPath; - } - await fs.rm(tmpDir, { recursive: true, force: true }); - } + const enableIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "enable" && c[1] === serviceId, + ); + const bootstrapIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, + ); + expect(enableIndex).toBeGreaterThanOrEqual(0); + expect(bootstrapIndex).toBeGreaterThanOrEqual(0); + expect(enableIndex).toBeLessThan(bootstrapIndex); }); }); diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index d7cf781f5f..8f088545aa 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { archiveSessionTranscripts, readFirstUserMessageFromTranscript, @@ -16,12 +16,12 @@ describe("readFirstUserMessageFromTranscript", () => { let tmpDir: string; let storePath: string; - beforeEach(() => { + beforeAll(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-")); storePath = path.join(tmpDir, "sessions.json"); }); - afterEach(() => { + afterAll(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -183,12 +183,12 @@ describe("readLastMessagePreviewFromTranscript", () => { let tmpDir: string; let storePath: string; - beforeEach(() => { + beforeAll(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-")); storePath = path.join(tmpDir, "sessions.json"); }); - afterEach(() => { + afterAll(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -345,7 +345,7 @@ describe("readLastMessagePreviewFromTranscript", () => { const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const padding = JSON.stringify({ message: { role: "user", content: "x".repeat(500) } }); const lines: string[] = []; - for (let i = 0; i < 50; i++) { + for (let i = 0; i < 30; i++) { lines.push(padding); } lines.push(JSON.stringify({ message: { role: "assistant", content: "Last in large file" } })); @@ -372,12 +372,12 @@ describe("readSessionTitleFieldsFromTranscript cache", () => { let tmpDir: string; let storePath: string; - beforeEach(() => { + beforeAll(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-")); storePath = path.join(tmpDir, "sessions.json"); }); - afterEach(() => { + afterAll(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -400,6 +400,7 @@ describe("readSessionTitleFieldsFromTranscript cache", () => { const second = readSessionTitleFieldsFromTranscript(sessionId, storePath); expect(second).toEqual(first); expect(readSpy.mock.calls.length).toBe(readsAfterFirst); + readSpy.mockRestore(); }); test("invalidates cache when transcript changes", () => { @@ -427,6 +428,7 @@ describe("readSessionTitleFieldsFromTranscript cache", () => { const second = readSessionTitleFieldsFromTranscript(sessionId, storePath); expect(second.lastMessagePreview).toBe("New"); expect(readSpy.mock.calls.length).toBeGreaterThan(readsAfterFirst); + readSpy.mockRestore(); }); }); @@ -434,12 +436,12 @@ describe("readSessionMessages", () => { let tmpDir: string; let storePath: string; - beforeEach(() => { + beforeAll(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-")); storePath = path.join(tmpDir, "sessions.json"); }); - afterEach(() => { + afterAll(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -533,12 +535,12 @@ describe("readSessionPreviewItemsFromTranscript", () => { let tmpDir: string; let storePath: string; - beforeEach(() => { + beforeAll(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-preview-test-")); storePath = path.join(tmpDir, "sessions.json"); }); - afterEach(() => { + afterAll(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -690,13 +692,13 @@ describe("archiveSessionTranscripts", () => { let tmpDir: string; let storePath: string; - beforeEach(() => { + beforeAll(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-archive-test-")); storePath = path.join(tmpDir, "sessions.json"); vi.stubEnv("OPENCLAW_HOME", tmpDir); }); - afterEach(() => { + afterAll(() => { vi.unstubAllEnvs(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); diff --git a/src/hooks/loader.test.ts b/src/hooks/loader.test.ts index 3f7dbe6433..e9299b491f 100644 --- a/src/hooks/loader.test.ts +++ b/src/hooks/loader.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { clearInternalHooks, @@ -12,13 +12,19 @@ import { import { loadInternalHooks } from "./loader.js"; describe("loader", () => { + let fixtureRoot = ""; + let caseId = 0; let tmpDir: string; let originalBundledDir: string | undefined; + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hooks-loader-")); + }); + beforeEach(async () => { clearInternalHooks(); // Create a temp directory for test modules - tmpDir = path.join(os.tmpdir(), `openclaw-test-${Date.now()}`); + tmpDir = path.join(fixtureRoot, `case-${caseId++}`); await fs.mkdir(tmpDir, { recursive: true }); // Disable bundled hooks during tests by setting env var to non-existent directory @@ -34,12 +40,13 @@ describe("loader", () => { } else { process.env.OPENCLAW_BUNDLED_HOOKS_DIR = originalBundledDir; } - // Clean up temp directory - try { - await fs.rm(tmpDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors + }); + + afterAll(async () => { + if (!fixtureRoot) { + return; } + await fs.rm(fixtureRoot, { recursive: true, force: true }); }); describe("loadInternalHooks", () => { diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts index 5673cb2e02..9421cdfb0b 100644 --- a/src/infra/control-ui-assets.test.ts +++ b/src/infra/control-ui-assets.test.ts @@ -1,299 +1,203 @@ -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 { - resolveControlUiDistIndexHealth, - resolveControlUiDistIndexPath, - resolveControlUiDistIndexPathForRoot, - resolveControlUiRepoRoot, - resolveControlUiRootOverrideSync, - resolveControlUiRootSync, -} from "./control-ui-assets.js"; -import { resolveOpenClawPackageRoot } from "./openclaw-root.js"; +import { pathToFileURL } from "node:url"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -/** Try to create a symlink; returns false if the OS denies it (Windows CI without Developer Mode). */ -async function trySymlink(target: string, linkPath: string): Promise { - try { - await fs.symlink(target, linkPath); - return true; - } catch { - return false; - } +type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" }; + +const state = vi.hoisted(() => ({ + entries: new Map(), + realpaths: new Map(), +})); + +const abs = (p: string) => path.resolve(p); + +function setFile(p: string, content = "") { + state.entries.set(abs(p), { kind: "file", content }); } -async function canonicalPath(p: string): Promise { - try { - return await fs.realpath(p); - } catch { - return path.resolve(p); - } +function setDir(p: string) { + state.entries.set(abs(p), { kind: "dir" }); } -describe("control UI assets helpers", () => { - let fixtureRoot = ""; - let caseId = 0; +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + const pathMod = await import("node:path"); + const absInMock = (p: string) => pathMod.resolve(p); + const fixturesRoot = `${absInMock("fixtures")}${pathMod.sep}`; + const isFixturePath = (p: string) => { + const resolved = absInMock(p); + return resolved === fixturesRoot.slice(0, -1) || resolved.startsWith(fixturesRoot); + }; - async function withTempDir(fn: (tmp: string) => Promise): Promise { - const tmp = path.join(fixtureRoot, `case-${caseId++}`); - await fs.mkdir(tmp, { recursive: true }); - return await fn(tmp); - } + const wrapped = { + ...actual, + existsSync: (p: string) => + isFixturePath(p) ? state.entries.has(absInMock(p)) : actual.existsSync(p), + readFileSync: (p: string, encoding?: unknown) => { + if (!isFixturePath(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return actual.readFileSync(p as any, encoding as any) as unknown; + } + const entry = state.entries.get(absInMock(p)); + if (!entry || entry.kind !== "file") { + throw new Error(`ENOENT: no such file, open '${p}'`); + } + return entry.content; + }, + statSync: (p: string) => { + if (!isFixturePath(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return actual.statSync(p as any) as unknown; + } + const entry = state.entries.get(absInMock(p)); + if (!entry) { + throw new Error(`ENOENT: no such file or directory, stat '${p}'`); + } + return { + isFile: () => entry.kind === "file", + isDirectory: () => entry.kind === "dir", + }; + }, + realpathSync: (p: string) => + isFixturePath(p) + ? (state.realpaths.get(absInMock(p)) ?? absInMock(p)) + : actual.realpathSync(p), + }; - beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); - }); + return { ...wrapped, default: wrapped }; +}); - afterAll(async () => { - if (fixtureRoot) { - await fs.rm(fixtureRoot, { recursive: true, force: true }); - } +vi.mock("./openclaw-root.js", () => ({ + resolveOpenClawPackageRoot: vi.fn(async () => null), + resolveOpenClawPackageRootSync: vi.fn(() => null), +})); + +describe("control UI assets helpers (fs-mocked)", () => { + beforeEach(() => { + state.entries.clear(); + state.realpaths.clear(); + vi.clearAllMocks(); }); it("resolves repo root from src argv1", async () => { - await withTempDir(async (tmp) => { - await fs.mkdir(path.join(tmp, "ui"), { recursive: true }); - await fs.writeFile(path.join(tmp, "ui", "vite.config.ts"), "export {};\n"); - await fs.writeFile(path.join(tmp, "package.json"), "{}\n"); - await fs.mkdir(path.join(tmp, "src"), { recursive: true }); - await fs.writeFile(path.join(tmp, "src", "index.ts"), "export {};\n"); + const { resolveControlUiRepoRoot } = await import("./control-ui-assets.js"); - expect(resolveControlUiRepoRoot(path.join(tmp, "src", "index.ts"))).toBe(tmp); - }); + const root = abs("fixtures/ui-src"); + setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n"); + + const argv1 = path.join(root, "src", "index.ts"); + expect(resolveControlUiRepoRoot(argv1)).toBe(root); }); - it("resolves repo root from dist argv1", async () => { - await withTempDir(async (tmp) => { - await fs.mkdir(path.join(tmp, "ui"), { recursive: true }); - await fs.writeFile(path.join(tmp, "ui", "vite.config.ts"), "export {};\n"); - await fs.writeFile(path.join(tmp, "package.json"), "{}\n"); - await fs.mkdir(path.join(tmp, "dist"), { recursive: true }); - await fs.writeFile(path.join(tmp, "dist", "index.js"), "export {};\n"); + it("resolves repo root by traversing up (dist argv1)", async () => { + const { resolveControlUiRepoRoot } = await import("./control-ui-assets.js"); - expect(resolveControlUiRepoRoot(path.join(tmp, "dist", "index.js"))).toBe(tmp); - }); + const root = abs("fixtures/ui-dist"); + setFile(path.join(root, "package.json"), "{}\n"); + setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n"); + + const argv1 = path.join(root, "dist", "index.js"); + expect(resolveControlUiRepoRoot(argv1)).toBe(root); }); it("resolves dist control-ui index path for dist argv1", async () => { - const argv1 = path.resolve("/tmp", "pkg", "dist", "index.js"); + const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js"); + + const argv1 = abs(path.join("fixtures", "pkg", "dist", "index.js")); const distDir = path.dirname(argv1); - expect(await resolveControlUiDistIndexPath(argv1)).toBe( + await expect(resolveControlUiDistIndexPath(argv1)).resolves.toBe( path.join(distDir, "control-ui", "index.html"), ); }); - it("resolves control-ui root for dist bundle argv1", async () => { - await withTempDir(async (tmp) => { - await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true }); - await fs.writeFile(path.join(tmp, "dist", "bundle.js"), "export {};\n"); - await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "\n"); + it("uses resolveOpenClawPackageRoot when available", async () => { + const openclawRoot = await import("./openclaw-root.js"); + const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js"); - expect(resolveControlUiRootSync({ argv1: path.join(tmp, "dist", "bundle.js") })).toBe( - path.join(tmp, "dist", "control-ui"), - ); + const pkgRoot = abs("fixtures/openclaw"); + ( + openclawRoot.resolveOpenClawPackageRoot as unknown as ReturnType + ).mockResolvedValueOnce(pkgRoot); + + await expect(resolveControlUiDistIndexPath(abs("fixtures/bin/openclaw"))).resolves.toBe( + path.join(pkgRoot, "dist", "control-ui", "index.html"), + ); + }); + + it("falls back to package.json name matching when root resolution fails", async () => { + const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js"); + + const root = abs("fixtures/fallback"); + setFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" })); + setFile(path.join(root, "dist", "control-ui", "index.html"), "\n"); + + await expect(resolveControlUiDistIndexPath(path.join(root, "openclaw.mjs"))).resolves.toBe( + path.join(root, "dist", "control-ui", "index.html"), + ); + }); + + it("returns null when fallback package name does not match", async () => { + const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js"); + + const root = abs("fixtures/not-openclaw"); + setFile(path.join(root, "package.json"), JSON.stringify({ name: "malicious-pkg" })); + setFile(path.join(root, "dist", "control-ui", "index.html"), "\n"); + + await expect(resolveControlUiDistIndexPath(path.join(root, "index.mjs"))).resolves.toBeNull(); + }); + + it("reports health for missing + existing dist assets", async () => { + const { resolveControlUiDistIndexHealth } = await import("./control-ui-assets.js"); + + const root = abs("fixtures/health"); + const indexPath = path.join(root, "dist", "control-ui", "index.html"); + + await expect(resolveControlUiDistIndexHealth({ root })).resolves.toEqual({ + indexPath, + exists: false, + }); + + setFile(indexPath, "\n"); + await expect(resolveControlUiDistIndexHealth({ root })).resolves.toEqual({ + indexPath, + exists: true, }); }); - it("resolves control-ui root for dist/gateway bundle argv1", async () => { - await withTempDir(async (tmp) => { - await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" })); - await fs.mkdir(path.join(tmp, "dist", "gateway"), { recursive: true }); - await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true }); - await fs.writeFile(path.join(tmp, "dist", "gateway", "control-ui.js"), "export {};\n"); - await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "\n"); + it("resolves control-ui root from override file or directory", async () => { + const { resolveControlUiRootOverrideSync } = await import("./control-ui-assets.js"); - expect( - resolveControlUiRootSync({ argv1: path.join(tmp, "dist", "gateway", "control-ui.js") }), - ).toBe(path.join(tmp, "dist", "control-ui")); - }); + const root = abs("fixtures/override"); + const uiDir = path.join(root, "dist", "control-ui"); + const indexPath = path.join(uiDir, "index.html"); + + setDir(uiDir); + setFile(indexPath, "\n"); + + expect(resolveControlUiRootOverrideSync(uiDir)).toBe(uiDir); + expect(resolveControlUiRootOverrideSync(indexPath)).toBe(uiDir); + expect(resolveControlUiRootOverrideSync(path.join(uiDir, "missing.html"))).toBeNull(); }); - it("resolves control-ui root from override directory or index.html", async () => { - await withTempDir(async (tmp) => { - const uiDir = path.join(tmp, "dist", "control-ui"); - await fs.mkdir(uiDir, { recursive: true }); - await fs.writeFile(path.join(uiDir, "index.html"), "\n"); + it("resolves control-ui root for dist bundle argv1 and moduleUrl candidates", async () => { + const openclawRoot = await import("./openclaw-root.js"); + const { resolveControlUiRootSync } = await import("./control-ui-assets.js"); - expect(resolveControlUiRootOverrideSync(uiDir)).toBe(uiDir); - expect(resolveControlUiRootOverrideSync(path.join(uiDir, "index.html"))).toBe(uiDir); - expect(resolveControlUiRootOverrideSync(path.join(uiDir, "missing.html"))).toBeNull(); - }); - }); + const pkgRoot = abs("fixtures/openclaw-bundle"); + ( + openclawRoot.resolveOpenClawPackageRootSync as unknown as ReturnType + ).mockReturnValueOnce(pkgRoot); - it("resolves dist control-ui index path from package root argv1", async () => { - await withTempDir(async (tmp) => { - await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" })); - await fs.writeFile(path.join(tmp, "openclaw.mjs"), "export {};\n"); - await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true }); - await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "\n"); + const uiDir = path.join(pkgRoot, "dist", "control-ui"); + setFile(path.join(uiDir, "index.html"), "\n"); - expect(await resolveControlUiDistIndexPath(path.join(tmp, "openclaw.mjs"))).toBe( - path.join(tmp, "dist", "control-ui", "index.html"), - ); - }); - }); + // argv1Dir candidate: /control-ui + expect(resolveControlUiRootSync({ argv1: path.join(pkgRoot, "dist", "bundle.js") })).toBe( + uiDir, + ); - it("resolves control-ui root for package entrypoint argv1", async () => { - await withTempDir(async (tmp) => { - await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" })); - await fs.writeFile(path.join(tmp, "openclaw.mjs"), "export {};\n"); - await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true }); - await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "\n"); - - expect(resolveControlUiRootSync({ argv1: path.join(tmp, "openclaw.mjs") })).toBe( - path.join(tmp, "dist", "control-ui"), - ); - }); - }); - - it("resolves dist control-ui index path from .bin argv1", async () => { - await withTempDir(async (tmp) => { - const binDir = path.join(tmp, "node_modules", ".bin"); - const pkgRoot = path.join(tmp, "node_modules", "openclaw"); - await fs.mkdir(binDir, { recursive: true }); - await fs.mkdir(path.join(pkgRoot, "dist", "control-ui"), { recursive: true }); - await fs.writeFile(path.join(binDir, "openclaw"), "#!/usr/bin/env node\n"); - await fs.writeFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" })); - await fs.writeFile(path.join(pkgRoot, "dist", "control-ui", "index.html"), "\n"); - - expect(await resolveControlUiDistIndexPath(path.join(binDir, "openclaw"))).toBe( - path.join(pkgRoot, "dist", "control-ui", "index.html"), - ); - }); - }); - - it("resolves via fallback when package root resolution fails but package name matches", async () => { - await withTempDir(async (tmp) => { - // Package named "openclaw" but resolveOpenClawPackageRoot failed for other reasons - await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" })); - await fs.writeFile(path.join(tmp, "openclaw.mjs"), "export {};\n"); - await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true }); - await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "\n"); - - expect(await resolveControlUiDistIndexPath(path.join(tmp, "openclaw.mjs"))).toBe( - path.join(tmp, "dist", "control-ui", "index.html"), - ); - }); - }); - - it("returns null when package name does not match openclaw", async () => { - await withTempDir(async (tmp) => { - // Package with different name should not be resolved - await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "malicious-pkg" })); - await fs.writeFile(path.join(tmp, "index.mjs"), "export {};\n"); - await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true }); - await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "\n"); - - expect(await resolveControlUiDistIndexPath(path.join(tmp, "index.mjs"))).toBeNull(); - }); - }); - - it("returns null when no control-ui assets exist", async () => { - await withTempDir(async (tmp) => { - // Just a package.json, no dist/control-ui - await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "some-pkg" })); - await fs.writeFile(path.join(tmp, "index.mjs"), "export {};\n"); - - expect(await resolveControlUiDistIndexPath(path.join(tmp, "index.mjs"))).toBeNull(); - }); - }); - - it("reports health for existing control-ui assets at a known root", async () => { - await withTempDir(async (tmp) => { - const indexPath = resolveControlUiDistIndexPathForRoot(tmp); - await fs.mkdir(path.dirname(indexPath), { recursive: true }); - await fs.writeFile(indexPath, "\n"); - - await expect(resolveControlUiDistIndexHealth({ root: tmp })).resolves.toEqual({ - indexPath, - exists: true, - }); - }); - }); - - it("reports health for missing control-ui assets at a known root", async () => { - await withTempDir(async (tmp) => { - const indexPath = resolveControlUiDistIndexPathForRoot(tmp); - await expect(resolveControlUiDistIndexHealth({ root: tmp })).resolves.toEqual({ - indexPath, - exists: false, - }); - }); - }); - - it("resolves control-ui root when argv1 is a symlink (nvm scenario)", async () => { - await withTempDir(async (tmp) => { - const realPkg = path.join(tmp, "real-pkg"); - const bin = path.join(tmp, "bin"); - await fs.mkdir(realPkg, { recursive: true }); - await fs.mkdir(bin, { recursive: true }); - await fs.writeFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" })); - await fs.writeFile(path.join(realPkg, "openclaw.mjs"), "export {};\n"); - await fs.mkdir(path.join(realPkg, "dist", "control-ui"), { recursive: true }); - await fs.writeFile(path.join(realPkg, "dist", "control-ui", "index.html"), "\n"); - const ok = await trySymlink( - path.join("..", "real-pkg", "openclaw.mjs"), - path.join(bin, "openclaw"), - ); - if (!ok) { - return; // symlinks not supported (Windows CI) - } - - const resolvedRoot = resolveControlUiRootSync({ argv1: path.join(bin, "openclaw") }); - expect(resolvedRoot).not.toBeNull(); - expect(await canonicalPath(resolvedRoot ?? "")).toBe( - await canonicalPath(path.join(realPkg, "dist", "control-ui")), - ); - }); - }); - - it("resolves package root via symlinked argv1", async () => { - await withTempDir(async (tmp) => { - const realPkg = path.join(tmp, "real-pkg"); - const bin = path.join(tmp, "bin"); - await fs.mkdir(realPkg, { recursive: true }); - await fs.mkdir(bin, { recursive: true }); - await fs.writeFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" })); - await fs.writeFile(path.join(realPkg, "openclaw.mjs"), "export {};\n"); - await fs.mkdir(path.join(realPkg, "dist", "control-ui"), { recursive: true }); - await fs.writeFile(path.join(realPkg, "dist", "control-ui", "index.html"), "\n"); - const ok = await trySymlink( - path.join("..", "real-pkg", "openclaw.mjs"), - path.join(bin, "openclaw"), - ); - if (!ok) { - return; // symlinks not supported (Windows CI) - } - - const packageRoot = await resolveOpenClawPackageRoot({ argv1: path.join(bin, "openclaw") }); - expect(packageRoot).not.toBeNull(); - expect(await canonicalPath(packageRoot ?? "")).toBe(await canonicalPath(realPkg)); - }); - }); - - it("resolves dist index path via symlinked argv1 (async)", async () => { - await withTempDir(async (tmp) => { - const realPkg = path.join(tmp, "real-pkg"); - const bin = path.join(tmp, "bin"); - await fs.mkdir(realPkg, { recursive: true }); - await fs.mkdir(bin, { recursive: true }); - await fs.writeFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" })); - await fs.writeFile(path.join(realPkg, "openclaw.mjs"), "export {};\n"); - await fs.mkdir(path.join(realPkg, "dist", "control-ui"), { recursive: true }); - await fs.writeFile(path.join(realPkg, "dist", "control-ui", "index.html"), "\n"); - const ok = await trySymlink( - path.join("..", "real-pkg", "openclaw.mjs"), - path.join(bin, "openclaw"), - ); - if (!ok) { - return; // symlinks not supported (Windows CI) - } - - const indexPath = await resolveControlUiDistIndexPath(path.join(bin, "openclaw")); - expect(indexPath).not.toBeNull(); - expect(await canonicalPath(indexPath ?? "")).toBe( - await canonicalPath(path.join(realPkg, "dist", "control-ui", "index.html")), - ); - }); + // moduleUrl candidate: /control-ui + const moduleUrl = pathToFileURL(path.join(pkgRoot, "dist", "bundle.js")).toString(); + expect(resolveControlUiRootSync({ moduleUrl })).toBe(uiDir); }); }); diff --git a/src/infra/gateway-lock.test.ts b/src/infra/gateway-lock.test.ts index a24e7e35ab..45bac9fd62 100644 --- a/src/infra/gateway-lock.test.ts +++ b/src/infra/gateway-lock.test.ts @@ -3,7 +3,7 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveConfigPath, resolveGatewayLockDir, resolveStateDir } from "../config/paths.js"; import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js"; @@ -67,6 +67,13 @@ describe("gateway lock", () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-lock-")); }); + beforeEach(() => { + // Other suites occasionally leave global spies behind (Date.now, setTimeout, etc.). + // This test relies on fake timers advancing Date.now and setTimeout deterministically. + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + afterAll(async () => { await fs.rm(fixtureRoot, { recursive: true, force: true }); }); @@ -76,30 +83,45 @@ describe("gateway lock", () => { }); it("blocks concurrent acquisition until release", async () => { - vi.useRealTimers(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-06T10:05:00.000Z")); const { env, cleanup } = await makeEnv(); const lock = await acquireGatewayLock({ env, allowInTests: true, - timeoutMs: 80, - pollIntervalMs: 5, + timeoutMs: 20, + pollIntervalMs: 1, }); expect(lock).not.toBeNull(); + let settled = false; const pending = acquireGatewayLock({ env, allowInTests: true, - timeoutMs: 80, - pollIntervalMs: 5, + timeoutMs: 20, + pollIntervalMs: 1, }); + void pending.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + // Drive the retry loop without real sleeping. + for (let i = 0; i < 20 && !settled; i += 1) { + await vi.advanceTimersByTimeAsync(5); + await Promise.resolve(); + } await expect(pending).rejects.toBeInstanceOf(GatewayLockError); await lock?.release(); const lock2 = await acquireGatewayLock({ env, allowInTests: true, - timeoutMs: 80, - pollIntervalMs: 5, + timeoutMs: 20, + pollIntervalMs: 1, }); await lock2?.release(); await cleanup(); @@ -107,6 +129,7 @@ describe("gateway lock", () => { it("treats recycled linux pid as stale when start time mismatches", async () => { vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-06T10:05:00.000Z")); const { env, cleanup } = await makeEnv(); const { lockPath, configPath } = resolveLockPath(env); const payload = { diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 90359fadac..4197a29d60 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -1,24 +1,20 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; -import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; -import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; -import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js"; import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; import * as replyModule from "../auto-reply/reply.js"; +import { whatsappOutbound } from "../channels/plugins/outbound/whatsapp.js"; import { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveMainSessionKey, resolveStorePath, } from "../config/sessions.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createPluginRuntime } from "../plugins/runtime/index.js"; +import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; import { buildAgentPeerSessionKey } from "../routing/session-key.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { isHeartbeatEnabledForAgent, resolveHeartbeatIntervalMs, @@ -33,16 +29,90 @@ import { // Avoid pulling optional runtime deps during isolated runs. vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); +let previousRegistry: ReturnType | null = null; +let testRegistry: ReturnType | null = null; + +let fixtureRoot = ""; +let fixtureCount = 0; + +const createCaseDir = async (prefix: string) => { + const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); + await fs.mkdir(dir, { recursive: true }); + return dir; +}; + +beforeAll(async () => { + previousRegistry = getActivePluginRegistry(); + + const whatsappPlugin = createOutboundTestPlugin({ id: "whatsapp", outbound: whatsappOutbound }); + whatsappPlugin.config = { + ...whatsappPlugin.config, + resolveAllowFrom: ({ cfg }) => + cfg.channels?.whatsapp?.allowFrom?.map((entry) => String(entry)) ?? [], + }; + + const telegramPlugin = createOutboundTestPlugin({ + id: "telegram", + outbound: { + deliveryMode: "direct", + sendText: async ({ to, text, deps, accountId }) => { + if (!deps?.sendTelegram) { + throw new Error("sendTelegram missing"); + } + const res = await deps.sendTelegram(to, text, { + verbose: false, + accountId: accountId ?? undefined, + }); + return { channel: "telegram", messageId: res.messageId, chatId: res.chatId }; + }, + sendMedia: async ({ to, text, mediaUrl, deps, accountId }) => { + if (!deps?.sendTelegram) { + throw new Error("sendTelegram missing"); + } + const res = await deps.sendTelegram(to, text, { + verbose: false, + accountId: accountId ?? undefined, + mediaUrl, + }); + return { channel: "telegram", messageId: res.messageId, chatId: res.chatId }; + }, + }, + }); + telegramPlugin.config = { + ...telegramPlugin.config, + listAccountIds: (cfg) => Object.keys(cfg.channels?.telegram?.accounts ?? {}), + resolveAllowFrom: ({ cfg, accountId }) => { + const channel = cfg.channels?.telegram; + const normalized = accountId?.trim(); + if (normalized && channel?.accounts?.[normalized]?.allowFrom) { + return channel.accounts[normalized].allowFrom?.map((entry) => String(entry)) ?? []; + } + return channel?.allowFrom?.map((entry) => String(entry)) ?? []; + }, + }; + + testRegistry = createTestRegistry([ + { pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" }, + { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, + ]); + setActivePluginRegistry(testRegistry); + + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-heartbeat-suite-")); +}); + beforeEach(() => { - const runtime = createPluginRuntime(); - setTelegramRuntime(runtime); - setWhatsAppRuntime(runtime); - setActivePluginRegistry( - createTestRegistry([ - { pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" }, - { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, - ]), - ); + if (testRegistry) { + setActivePluginRegistry(testRegistry); + } +}); + +afterAll(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + if (previousRegistry) { + setActivePluginRegistry(previousRegistry); + } }); describe("resolveHeartbeatIntervalMs", () => { @@ -397,7 +467,7 @@ describe("runHeartbeatOnce", () => { }); it("uses the last non-empty payload for delivery", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); + const tmpDir = await createCaseDir("hb-last-payload"); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { @@ -415,18 +485,14 @@ describe("runHeartbeatOnce", () => { await fs.writeFile( storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sid", - updatedAt: Date.now(), - lastChannel: "whatsapp", - lastTo: "+1555", - }, + JSON.stringify({ + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", }, - null, - 2, - ), + }), ); replySpy.mockResolvedValue([{ text: "Let me check..." }, { text: "Final alert" }]); @@ -450,12 +516,11 @@ describe("runHeartbeatOnce", () => { expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object)); } finally { replySpy.mockRestore(); - await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("uses per-agent heartbeat overrides and session keys", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); + const tmpDir = await createCaseDir("hb-agent-overrides"); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { @@ -479,18 +544,14 @@ describe("runHeartbeatOnce", () => { await fs.writeFile( storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sid", - updatedAt: Date.now(), - lastChannel: "whatsapp", - lastTo: "+1555", - }, + JSON.stringify({ + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", }, - null, - 2, - ), + }), ); replySpy.mockResolvedValue([{ text: "Final alert" }]); const sendWhatsApp = vi.fn().mockResolvedValue({ @@ -520,12 +581,11 @@ describe("runHeartbeatOnce", () => { ); } finally { replySpy.mockRestore(); - await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("reuses non-default agent sessionFile from templated stores", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); + const tmpDir = await createCaseDir("hb-templated-store"); const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions", "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); const agentId = "ops"; @@ -598,12 +658,11 @@ describe("runHeartbeatOnce", () => { ); } finally { replySpy.mockRestore(); - await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("runs heartbeats in the explicit session key when configured", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); + const tmpDir = await createCaseDir("hb-explicit-session"); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { @@ -635,24 +694,20 @@ describe("runHeartbeatOnce", () => { await fs.writeFile( storePath, - JSON.stringify( - { - [mainSessionKey]: { - sessionId: "sid-main", - updatedAt: Date.now(), - lastChannel: "whatsapp", - lastTo: "+1555", - }, - [groupSessionKey]: { - sessionId: "sid-group", - updatedAt: Date.now() + 10_000, - lastChannel: "whatsapp", - lastTo: groupId, - }, + JSON.stringify({ + [mainSessionKey]: { + sessionId: "sid-main", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", }, - null, - 2, - ), + [groupSessionKey]: { + sessionId: "sid-group", + updatedAt: Date.now() + 10_000, + lastChannel: "whatsapp", + lastTo: groupId, + }, + }), ); replySpy.mockResolvedValue([{ text: "Group alert" }]); @@ -681,12 +736,11 @@ describe("runHeartbeatOnce", () => { ); } finally { replySpy.mockRestore(); - await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("suppresses duplicate heartbeat payloads within 24h", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); + const tmpDir = await createCaseDir("hb-dup-suppress"); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { @@ -704,20 +758,16 @@ describe("runHeartbeatOnce", () => { await fs.writeFile( storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sid", - updatedAt: Date.now(), - lastChannel: "whatsapp", - lastTo: "+1555", - lastHeartbeatText: "Final alert", - lastHeartbeatSentAt: 0, - }, + JSON.stringify({ + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + lastHeartbeatText: "Final alert", + lastHeartbeatSentAt: 0, }, - null, - 2, - ), + }), ); replySpy.mockResolvedValue([{ text: "Final alert" }]); @@ -737,12 +787,11 @@ describe("runHeartbeatOnce", () => { expect(sendWhatsApp).toHaveBeenCalledTimes(0); } finally { replySpy.mockRestore(); - await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("can include reasoning payloads when enabled", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); + const tmpDir = await createCaseDir("hb-reasoning"); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { @@ -764,19 +813,15 @@ describe("runHeartbeatOnce", () => { await fs.writeFile( storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sid", - updatedAt: Date.now(), - lastChannel: "whatsapp", - lastProvider: "whatsapp", - lastTo: "+1555", - }, + JSON.stringify({ + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastProvider: "whatsapp", + lastTo: "+1555", }, - null, - 2, - ), + }), ); replySpy.mockResolvedValue([ @@ -809,12 +854,11 @@ describe("runHeartbeatOnce", () => { expect(sendWhatsApp).toHaveBeenNthCalledWith(2, "+1555", "Final alert", expect.any(Object)); } finally { replySpy.mockRestore(); - await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("delivers reasoning even when the main heartbeat reply is HEARTBEAT_OK", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); + const tmpDir = await createCaseDir("hb-reasoning-heartbeat-ok"); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { @@ -836,19 +880,15 @@ describe("runHeartbeatOnce", () => { await fs.writeFile( storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sid", - updatedAt: Date.now(), - lastChannel: "whatsapp", - lastProvider: "whatsapp", - lastTo: "+1555", - }, + JSON.stringify({ + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastProvider: "whatsapp", + lastTo: "+1555", }, - null, - 2, - ), + }), ); replySpy.mockResolvedValue([ @@ -880,7 +920,6 @@ describe("runHeartbeatOnce", () => { ); } finally { replySpy.mockRestore(); - await fs.rm(tmpDir, { recursive: true, force: true }); } }); diff --git a/src/infra/openclaw-root.test.ts b/src/infra/openclaw-root.test.ts new file mode 100644 index 0000000000..5f2a4e94d6 --- /dev/null +++ b/src/infra/openclaw-root.test.ts @@ -0,0 +1,150 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" }; + +const VITEST_FS_BASE = path.join(path.parse(process.cwd()).root, "__openclaw_vitest__"); +const FIXTURE_BASE = path.join(VITEST_FS_BASE, "openclaw-root"); + +const state = vi.hoisted(() => ({ + entries: new Map(), + realpaths: new Map(), +})); + +const abs = (p: string) => path.resolve(p); +const fx = (...parts: string[]) => path.join(FIXTURE_BASE, ...parts); + +function setFile(p: string, content = "") { + state.entries.set(abs(p), { kind: "file", content }); +} + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + const pathMod = await import("node:path"); + const absInMock = (p: string) => pathMod.resolve(p); + const vitestRoot = `${absInMock(VITEST_FS_BASE)}${pathMod.sep}`; + const isFixturePath = (p: string) => { + const resolved = absInMock(p); + return resolved === vitestRoot.slice(0, -1) || resolved.startsWith(vitestRoot); + }; + const wrapped = { + ...actual, + existsSync: (p: string) => + isFixturePath(p) ? state.entries.has(absInMock(p)) : actual.existsSync(p), + readFileSync: (p: string, encoding?: unknown) => { + if (!isFixturePath(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return actual.readFileSync(p as any, encoding as any) as unknown; + } + const entry = state.entries.get(absInMock(p)); + if (!entry || entry.kind !== "file") { + throw new Error(`ENOENT: no such file, open '${p}'`); + } + return encoding ? entry.content : Buffer.from(entry.content, "utf-8"); + }, + statSync: (p: string) => { + if (!isFixturePath(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return actual.statSync(p as any) as unknown; + } + const entry = state.entries.get(absInMock(p)); + if (!entry) { + throw new Error(`ENOENT: no such file or directory, stat '${p}'`); + } + return { + isFile: () => entry.kind === "file", + isDirectory: () => entry.kind === "dir", + }; + }, + realpathSync: (p: string) => + isFixturePath(p) + ? (state.realpaths.get(absInMock(p)) ?? absInMock(p)) + : actual.realpathSync(p), + }; + return { ...wrapped, default: wrapped }; +}); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + const pathMod = await import("node:path"); + const absInMock = (p: string) => pathMod.resolve(p); + const vitestRoot = `${absInMock(VITEST_FS_BASE)}${pathMod.sep}`; + const isFixturePath = (p: string) => { + const resolved = absInMock(p); + return resolved === vitestRoot.slice(0, -1) || resolved.startsWith(vitestRoot); + }; + const wrapped = { + ...actual, + readFile: async (p: string, encoding?: unknown) => { + if (!isFixturePath(p)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (await actual.readFile(p as any, encoding as any)) as unknown; + } + const entry = state.entries.get(absInMock(p)); + if (!entry || entry.kind !== "file") { + throw new Error(`ENOENT: no such file, open '${p}'`); + } + return entry.content; + }, + }; + return { ...wrapped, default: wrapped }; +}); + +describe("resolveOpenClawPackageRoot", () => { + beforeEach(() => { + state.entries.clear(); + state.realpaths.clear(); + }); + + it("resolves package root from .bin argv1", async () => { + const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js"); + + const project = fx("bin-scenario"); + const argv1 = path.join(project, "node_modules", ".bin", "openclaw"); + const pkgRoot = path.join(project, "node_modules", "openclaw"); + setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" })); + + expect(resolveOpenClawPackageRootSync({ argv1 })).toBe(pkgRoot); + }); + + it("resolves package root via symlinked argv1", async () => { + const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js"); + + const project = fx("symlink-scenario"); + const bin = path.join(project, "bin", "openclaw"); + const realPkg = path.join(project, "real-pkg"); + state.realpaths.set(abs(bin), abs(path.join(realPkg, "openclaw.mjs"))); + setFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" })); + + expect(resolveOpenClawPackageRootSync({ argv1: bin })).toBe(realPkg); + }); + + it("prefers moduleUrl candidates", async () => { + const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js"); + + const pkgRoot = fx("moduleurl"); + setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" })); + const moduleUrl = pathToFileURL(path.join(pkgRoot, "dist", "index.js")).toString(); + + expect(resolveOpenClawPackageRootSync({ moduleUrl })).toBe(pkgRoot); + }); + + it("returns null for non-openclaw package roots", async () => { + const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js"); + + const pkgRoot = fx("not-openclaw"); + setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "not-openclaw" })); + + expect(resolveOpenClawPackageRootSync({ cwd: pkgRoot })).toBeNull(); + }); + + it("async resolver matches sync behavior", async () => { + const { resolveOpenClawPackageRoot } = await import("./openclaw-root.js"); + + const pkgRoot = fx("async"); + setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" })); + + await expect(resolveOpenClawPackageRoot({ cwd: pkgRoot })).resolves.toBe(pkgRoot); + }); +}); diff --git a/src/infra/path-env.test.ts b/src/infra/path-env.test.ts index 924406cca4..a439602d65 100644 --- a/src/infra/path-env.test.ts +++ b/src/infra/path-env.test.ts @@ -1,237 +1,215 @@ -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 { ensureOpenClawCliOnPath } from "./path-env.js"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + dirs: new Set(), + executables: new Set(), +})); + +const abs = (p: string) => path.resolve(p); +const setDir = (p: string) => state.dirs.add(abs(p)); +const setExe = (p: string) => state.executables.add(abs(p)); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + const pathMod = await import("node:path"); + const absInMock = (p: string) => pathMod.resolve(p); + + const wrapped = { + ...actual, + constants: { ...actual.constants, X_OK: actual.constants.X_OK ?? 1 }, + accessSync: (p: string, mode?: number) => { + // `mode` is ignored in tests; we only model "is executable" or "not". + if (!state.executables.has(absInMock(p))) { + throw new Error(`EACCES: permission denied, access '${p}' (mode=${mode ?? 0})`); + } + }, + statSync: (p: string) => ({ + // Avoid throws for non-existent paths; the code under test only cares about isDirectory(). + isDirectory: () => state.dirs.has(absInMock(p)), + }), + }; + + return { ...wrapped, default: wrapped }; +}); + +let ensureOpenClawCliOnPath: typeof import("./path-env.js").ensureOpenClawCliOnPath; describe("ensureOpenClawCliOnPath", () => { - let fixtureRoot = ""; - let fixtureCount = 0; - - async function makeTmpDir(): Promise { - const tmp = path.join(fixtureRoot, `case-${fixtureCount++}`); - await fs.mkdir(tmp); - return tmp; - } + const envKeys = [ + "PATH", + "OPENCLAW_PATH_BOOTSTRAPPED", + "OPENCLAW_ALLOW_PROJECT_LOCAL_BIN", + "MISE_DATA_DIR", + "HOMEBREW_PREFIX", + "HOMEBREW_BREW_FILE", + "XDG_BIN_HOME", + ] as const; + let envSnapshot: Record<(typeof envKeys)[number], string | undefined>; beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-path-")); + ({ ensureOpenClawCliOnPath } = await import("./path-env.js")); }); - afterAll(async () => { - await fs.rm(fixtureRoot, { recursive: true, force: true }); + beforeEach(() => { + envSnapshot = Object.fromEntries(envKeys.map((k) => [k, process.env[k]])) as typeof envSnapshot; + state.dirs.clear(); + state.executables.clear(); + + setDir("/usr/bin"); + setDir("/bin"); + vi.clearAllMocks(); }); - it("prepends the bundled app bin dir when a sibling openclaw exists", async () => { - const tmp = await makeTmpDir(); - const appBinDir = path.join(tmp, "AppBin"); - await fs.mkdir(appBinDir); - const cliPath = path.join(appBinDir, "openclaw"); - await fs.writeFile(cliPath, "#!/bin/sh\necho ok\n", "utf-8"); - await fs.chmod(cliPath, 0o755); - - const originalPath = process.env.PATH; - const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED; - process.env.PATH = "/usr/bin"; - delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; - try { - ensureOpenClawCliOnPath({ - execPath: cliPath, - cwd: tmp, - homeDir: tmp, - platform: "darwin", - }); - const updated = process.env.PATH ?? ""; - expect(updated.split(path.delimiter)[0]).toBe(appBinDir); - } finally { - process.env.PATH = originalPath; - if (originalFlag === undefined) { - delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; + afterEach(() => { + for (const k of envKeys) { + const value = envSnapshot[k]; + if (value === undefined) { + delete process.env[k]; } else { - process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag; + process.env[k] = value; } } }); + it("prepends the bundled app bin dir when a sibling openclaw exists", () => { + const tmp = abs("/tmp/openclaw-path/case-bundled"); + const appBinDir = path.join(tmp, "AppBin"); + const cliPath = path.join(appBinDir, "openclaw"); + setDir(tmp); + setDir(appBinDir); + setExe(cliPath); + + process.env.PATH = "/usr/bin"; + delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; + + ensureOpenClawCliOnPath({ + execPath: cliPath, + cwd: tmp, + homeDir: tmp, + platform: "darwin", + }); + + const updated = process.env.PATH ?? ""; + expect(updated.split(path.delimiter)[0]).toBe(appBinDir); + }); + it("is idempotent", () => { - const originalPath = process.env.PATH; - const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED; process.env.PATH = "/bin"; process.env.OPENCLAW_PATH_BOOTSTRAPPED = "1"; - try { - ensureOpenClawCliOnPath({ - execPath: "/tmp/does-not-matter", - cwd: "/tmp", - homeDir: "/tmp", - platform: "darwin", - }); - expect(process.env.PATH).toBe("/bin"); - } finally { - process.env.PATH = originalPath; - if (originalFlag === undefined) { - delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; - } else { - process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag; - } - } + ensureOpenClawCliOnPath({ + execPath: "/tmp/does-not-matter", + cwd: "/tmp", + homeDir: "/tmp", + platform: "darwin", + }); + expect(process.env.PATH).toBe("/bin"); }); - it("prepends mise shims when available", async () => { - const tmp = await makeTmpDir(); - const originalPath = process.env.PATH; - const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED; - const originalMiseDataDir = process.env.MISE_DATA_DIR; - try { - const appBinDir = path.join(tmp, "AppBin"); - await fs.mkdir(appBinDir); - const appCli = path.join(appBinDir, "openclaw"); - await fs.writeFile(appCli, "#!/bin/sh\necho ok\n", "utf-8"); - await fs.chmod(appCli, 0o755); + it("prepends mise shims when available", () => { + const tmp = abs("/tmp/openclaw-path/case-mise"); + const appBinDir = path.join(tmp, "AppBin"); + const appCli = path.join(appBinDir, "openclaw"); + setDir(tmp); + setDir(appBinDir); + setExe(appCli); - const miseDataDir = path.join(tmp, "mise"); - const shimsDir = path.join(miseDataDir, "shims"); - await fs.mkdir(shimsDir, { recursive: true }); - process.env.MISE_DATA_DIR = miseDataDir; - process.env.PATH = "/usr/bin"; - delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; + const miseDataDir = path.join(tmp, "mise"); + const shimsDir = path.join(miseDataDir, "shims"); + setDir(miseDataDir); + setDir(shimsDir); - ensureOpenClawCliOnPath({ - execPath: appCli, - cwd: tmp, - homeDir: tmp, - platform: "darwin", - }); + process.env.MISE_DATA_DIR = miseDataDir; + process.env.PATH = "/usr/bin"; + delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; - const updated = process.env.PATH ?? ""; - const parts = updated.split(path.delimiter); - const appBinIndex = parts.indexOf(appBinDir); - const shimsIndex = parts.indexOf(shimsDir); - expect(appBinIndex).toBeGreaterThanOrEqual(0); - expect(shimsIndex).toBeGreaterThan(appBinIndex); - } finally { - process.env.PATH = originalPath; - if (originalFlag === undefined) { - delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; - } else { - process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag; - } - if (originalMiseDataDir === undefined) { - delete process.env.MISE_DATA_DIR; - } else { - process.env.MISE_DATA_DIR = originalMiseDataDir; - } - } + ensureOpenClawCliOnPath({ + execPath: appCli, + cwd: tmp, + homeDir: tmp, + platform: "darwin", + }); + + const updated = process.env.PATH ?? ""; + const parts = updated.split(path.delimiter); + const appBinIndex = parts.indexOf(appBinDir); + const shimsIndex = parts.indexOf(shimsDir); + expect(appBinIndex).toBeGreaterThanOrEqual(0); + expect(shimsIndex).toBeGreaterThan(appBinIndex); }); - it("only appends project-local node_modules/.bin when explicitly enabled", async () => { - const tmp = await makeTmpDir(); - const originalPath = process.env.PATH; - const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED; - try { - const appBinDir = path.join(tmp, "AppBin"); - await fs.mkdir(appBinDir); - const appCli = path.join(appBinDir, "openclaw"); - await fs.writeFile(appCli, "#!/bin/sh\necho ok\n", "utf-8"); - await fs.chmod(appCli, 0o755); + it("only appends project-local node_modules/.bin when explicitly enabled", () => { + const tmp = abs("/tmp/openclaw-path/case-project-local"); + const appBinDir = path.join(tmp, "AppBin"); + const appCli = path.join(appBinDir, "openclaw"); + setDir(tmp); + setDir(appBinDir); + setExe(appCli); - const localBinDir = path.join(tmp, "node_modules", ".bin"); - await fs.mkdir(localBinDir, { recursive: true }); - const localCli = path.join(localBinDir, "openclaw"); - await fs.writeFile(localCli, "#!/bin/sh\necho ok\n", "utf-8"); - await fs.chmod(localCli, 0o755); + const localBinDir = path.join(tmp, "node_modules", ".bin"); + const localCli = path.join(localBinDir, "openclaw"); + setDir(path.join(tmp, "node_modules")); + setDir(localBinDir); + setExe(localCli); - process.env.PATH = "/usr/bin"; - delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; + process.env.PATH = "/usr/bin"; + delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; - ensureOpenClawCliOnPath({ - execPath: appCli, - cwd: tmp, - homeDir: tmp, - platform: "darwin", - }); - const withoutOptIn = (process.env.PATH ?? "").split(path.delimiter); - expect(withoutOptIn.includes(localBinDir)).toBe(false); + ensureOpenClawCliOnPath({ + execPath: appCli, + cwd: tmp, + homeDir: tmp, + platform: "darwin", + }); + const withoutOptIn = (process.env.PATH ?? "").split(path.delimiter); + expect(withoutOptIn.includes(localBinDir)).toBe(false); - process.env.PATH = "/usr/bin"; - delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; + process.env.PATH = "/usr/bin"; + delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; - ensureOpenClawCliOnPath({ - execPath: appCli, - cwd: tmp, - homeDir: tmp, - platform: "darwin", - allowProjectLocalBin: true, - }); - const withOptIn = (process.env.PATH ?? "").split(path.delimiter); - const usrBinIndex = withOptIn.indexOf("/usr/bin"); - const localIndex = withOptIn.indexOf(localBinDir); - expect(usrBinIndex).toBeGreaterThanOrEqual(0); - expect(localIndex).toBeGreaterThan(usrBinIndex); - } finally { - process.env.PATH = originalPath; - if (originalFlag === undefined) { - delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; - } else { - process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag; - } - } + ensureOpenClawCliOnPath({ + execPath: appCli, + cwd: tmp, + homeDir: tmp, + platform: "darwin", + allowProjectLocalBin: true, + }); + const withOptIn = (process.env.PATH ?? "").split(path.delimiter); + const usrBinIndex = withOptIn.indexOf("/usr/bin"); + const localIndex = withOptIn.indexOf(localBinDir); + expect(usrBinIndex).toBeGreaterThanOrEqual(0); + expect(localIndex).toBeGreaterThan(usrBinIndex); }); - it("prepends Linuxbrew dirs when present", async () => { - const tmp = await makeTmpDir(); - const originalPath = process.env.PATH; - const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED; - const originalHomebrewPrefix = process.env.HOMEBREW_PREFIX; - const originalHomebrewBrewFile = process.env.HOMEBREW_BREW_FILE; - const originalXdgBinHome = process.env.XDG_BIN_HOME; - try { - const execDir = path.join(tmp, "exec"); - await fs.mkdir(execDir); + it("prepends Linuxbrew dirs when present", () => { + const tmp = abs("/tmp/openclaw-path/case-linuxbrew"); + const execDir = path.join(tmp, "exec"); + setDir(tmp); + setDir(execDir); - const linuxbrewBin = path.join(tmp, ".linuxbrew", "bin"); - const linuxbrewSbin = path.join(tmp, ".linuxbrew", "sbin"); - await fs.mkdir(linuxbrewBin, { recursive: true }); - await fs.mkdir(linuxbrewSbin, { recursive: true }); + const linuxbrewDir = path.join(tmp, ".linuxbrew"); + const linuxbrewBin = path.join(linuxbrewDir, "bin"); + const linuxbrewSbin = path.join(linuxbrewDir, "sbin"); + setDir(linuxbrewDir); + setDir(linuxbrewBin); + setDir(linuxbrewSbin); - process.env.PATH = "/usr/bin"; - delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; - delete process.env.HOMEBREW_PREFIX; - delete process.env.HOMEBREW_BREW_FILE; - delete process.env.XDG_BIN_HOME; + process.env.PATH = "/usr/bin"; + delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; + delete process.env.HOMEBREW_PREFIX; + delete process.env.HOMEBREW_BREW_FILE; + delete process.env.XDG_BIN_HOME; - ensureOpenClawCliOnPath({ - execPath: path.join(execDir, "node"), - cwd: tmp, - homeDir: tmp, - platform: "linux", - }); + ensureOpenClawCliOnPath({ + execPath: path.join(execDir, "node"), + cwd: tmp, + homeDir: tmp, + platform: "linux", + }); - const updated = process.env.PATH ?? ""; - const parts = updated.split(path.delimiter); - expect(parts[0]).toBe(linuxbrewBin); - expect(parts[1]).toBe(linuxbrewSbin); - } finally { - process.env.PATH = originalPath; - if (originalFlag === undefined) { - delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; - } else { - process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag; - } - if (originalHomebrewPrefix === undefined) { - delete process.env.HOMEBREW_PREFIX; - } else { - process.env.HOMEBREW_PREFIX = originalHomebrewPrefix; - } - if (originalHomebrewBrewFile === undefined) { - delete process.env.HOMEBREW_BREW_FILE; - } else { - process.env.HOMEBREW_BREW_FILE = originalHomebrewBrewFile; - } - if (originalXdgBinHome === undefined) { - delete process.env.XDG_BIN_HOME; - } else { - process.env.XDG_BIN_HOME = originalXdgBinHome; - } - } + const updated = process.env.PATH ?? ""; + const parts = updated.split(path.delimiter); + expect(parts[0]).toBe(linuxbrewBin); + expect(parts[1]).toBe(linuxbrewSbin); }); }); diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index 5b193061ec..2adacf9868 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -1,12 +1,71 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { withTempHome } from "../../test/helpers/temp-home.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { resolveProviderAuths } from "./provider-usage.auth.js"; describe("resolveProviderAuths key normalization", () => { + let suiteRoot = ""; + let suiteCase = 0; + + beforeAll(async () => { + suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-auth-suite-")); + }); + + afterAll(async () => { + await fs.rm(suiteRoot, { recursive: true, force: true }); + suiteRoot = ""; + suiteCase = 0; + }); + + async function withSuiteHome( + fn: (home: string) => Promise, + env: Record, + ): Promise { + const base = path.join(suiteRoot, `case-${++suiteCase}`); + await fs.mkdir(base, { recursive: true }); + await fs.mkdir(path.join(base, ".openclaw", "agents", "main", "sessions"), { recursive: true }); + + const keysToRestore = new Set([ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "OPENCLAW_HOME", + "OPENCLAW_STATE_DIR", + ...Object.keys(env), + ]); + const snapshot: Record = {}; + for (const key of keysToRestore) { + snapshot[key] = process.env[key]; + } + + process.env.HOME = base; + process.env.USERPROFILE = base; + delete process.env.OPENCLAW_HOME; + process.env.OPENCLAW_STATE_DIR = path.join(base, ".openclaw"); + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + try { + return await fn(base); + } finally { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + } + it("strips embedded CR/LF from env keys", async () => { - await withTempHome( + await withSuiteHome( async () => { const auths = await resolveProviderAuths({ providers: ["zai", "minimax", "xiaomi"], @@ -18,17 +77,15 @@ describe("resolveProviderAuths key normalization", () => { ]); }, { - env: { - ZAI_API_KEY: "zai-\r\nkey", - MINIMAX_API_KEY: "minimax-\r\nkey", - XIAOMI_API_KEY: "xiaomi-\r\nkey", - }, + ZAI_API_KEY: "zai-\r\nkey", + MINIMAX_API_KEY: "minimax-\r\nkey", + XIAOMI_API_KEY: "xiaomi-\r\nkey", }, ); }); it("strips embedded CR/LF from stored auth profiles (token + api_key)", async () => { - await withTempHome( + await withSuiteHome( async (home) => { const agentDir = path.join(home, ".openclaw", "agents", "main", "agent"); await fs.mkdir(agentDir, { recursive: true }); @@ -57,11 +114,9 @@ describe("resolveProviderAuths key normalization", () => { ]); }, { - env: { - MINIMAX_API_KEY: undefined, - MINIMAX_CODE_PLAN_KEY: undefined, - XIAOMI_API_KEY: undefined, - }, + MINIMAX_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: undefined, + XIAOMI_API_KEY: undefined, }, ); }); diff --git a/src/infra/transport-ready.test.ts b/src/infra/transport-ready.test.ts index 2df90a6420..f2b8d770aa 100644 --- a/src/infra/transport-ready.test.ts +++ b/src/infra/transport-ready.test.ts @@ -1,6 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { waitForTransportReady } from "./transport-ready.js"; +// Perf: `sleepWithAbort` uses `node:timers/promises` which isn't controlled by fake timers. +// Route sleeps through global `setTimeout` so tests can advance time deterministically. +vi.mock("./backoff.js", () => ({ + sleepWithAbort: async (ms: number) => { + if (ms <= 0) { + return; + } + await new Promise((resolve) => setTimeout(resolve, ms)); + }, +})); + describe("waitForTransportReady", () => { beforeEach(() => { vi.useFakeTimers(); @@ -16,7 +27,8 @@ describe("waitForTransportReady", () => { const readyPromise = waitForTransportReady({ label: "test transport", timeoutMs: 220, - logAfterMs: 60, + // Deterministic: first attempt at t=0 won't log; second attempt at t=50 will. + logAfterMs: 1, logIntervalMs: 1_000, pollIntervalMs: 50, runtime, @@ -29,9 +41,7 @@ describe("waitForTransportReady", () => { }, }); - for (let i = 0; i < 3; i += 1) { - await vi.advanceTimersByTimeAsync(50); - } + await vi.advanceTimersByTimeAsync(200); await readyPromise; expect(runtime.error).toHaveBeenCalled(); @@ -48,8 +58,9 @@ describe("waitForTransportReady", () => { runtime, check: async () => ({ ok: false, error: "still down" }), }); + const asserted = expect(waitPromise).rejects.toThrow("test transport not ready"); await vi.advanceTimersByTimeAsync(200); - await expect(waitPromise).rejects.toThrow("test transport not ready"); + await asserted; expect(runtime.error).toHaveBeenCalled(); }); diff --git a/src/memory/index.test.ts b/src/memory/index.test.ts index 4e8d5a8471..4c94ebfc31 100644 --- a/src/memory/index.test.ts +++ b/src/memory/index.test.ts @@ -46,10 +46,8 @@ describe("memory index", () => { let workspaceDir = ""; let memoryDir = ""; let extraDir = ""; - let indexBasicPath = ""; - let indexCachePath = ""; - let indexHybridPath = ""; let indexVectorPath = ""; + let indexMainPath = ""; let indexExtraPath = ""; // Perf: keep managers open across tests, but only reset the one a test uses. @@ -61,13 +59,15 @@ describe("memory index", () => { workspaceDir = path.join(fixtureRoot, "workspace"); memoryDir = path.join(workspaceDir, "memory"); extraDir = path.join(workspaceDir, "extra"); - indexBasicPath = path.join(workspaceDir, "index-basic.sqlite"); - indexCachePath = path.join(workspaceDir, "index-cache.sqlite"); - indexHybridPath = path.join(workspaceDir, "index-hybrid.sqlite"); + indexMainPath = path.join(workspaceDir, "index-main.sqlite"); indexVectorPath = path.join(workspaceDir, "index-vector.sqlite"); indexExtraPath = path.join(workspaceDir, "index-extra.sqlite"); await fs.mkdir(memoryDir, { recursive: true }); + await fs.writeFile( + path.join(memoryDir, "2026-01-12.md"), + "# Log\nAlpha memory line.\nZebra memory line.", + ); }); afterAll(async () => { @@ -83,10 +83,6 @@ describe("memory index", () => { // Keep the workspace stable to allow manager reuse across tests. await fs.mkdir(memoryDir, { recursive: true }); - await fs.writeFile( - path.join(memoryDir, "2026-01-12.md"), - "# Log\nAlpha memory line.\nZebra memory line.", - ); // Clean additional paths that may have been created by earlier cases. await fs.rm(extraDir, { recursive: true, force: true }); @@ -105,6 +101,38 @@ describe("memory index", () => { type TestCfg = Parameters[0]["cfg"]; + function createCfg(params: { + storePath: string; + extraPaths?: string[]; + model?: string; + vectorEnabled?: boolean; + cacheEnabled?: boolean; + hybrid?: { enabled: boolean; vectorWeight?: number; textWeight?: number }; + }): TestCfg { + return { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: params.model ?? "mock-embed", + store: { path: params.storePath, vector: { enabled: params.vectorEnabled ?? false } }, + // Perf: keep test indexes to a single chunk to reduce sqlite work. + chunking: { tokens: 4000, overlap: 0 }, + sync: { watch: false, onSessionStart: false, onSearch: true }, + query: { + minScore: 0, + hybrid: params.hybrid ?? { enabled: false }, + }, + cache: params.cacheEnabled ? { enabled: true } : undefined, + extraPaths: params.extraPaths, + }, + }, + list: [{ id: "main", default: true }], + }, + }; + } + async function getPersistentManager(cfg: TestCfg): Promise { const storePath = cfg.agents?.defaults?.memorySearch?.store?.path; if (!storePath) { @@ -128,24 +156,14 @@ describe("memory index", () => { return manager; } - it("indexes memory files and searches by vector", async () => { - const cfg = { - agents: { - defaults: { - workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { path: indexBasicPath, vector: { enabled: false } }, - sync: { watch: false, onSessionStart: false, onSearch: true }, - query: { minScore: 0, hybrid: { enabled: false } }, - }, - }, - list: [{ id: "main", default: true }], - }, - }; + it("indexes memory files and searches", async () => { + const cfg = createCfg({ + storePath: indexMainPath, + hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 }, + }); const manager = await getPersistentManager(cfg); await manager.sync({ reason: "test" }); + expect(embedBatchCalls).toBeGreaterThan(0); const results = await manager.search("alpha"); expect(results.length).toBeGreaterThan(0); expect(results[0]?.path).toContain("memory/2026-01-12.md"); @@ -162,26 +180,8 @@ describe("memory index", () => { }); it("keeps dirty false in status-only manager after prior indexing", async () => { - const indexStatusPath = path.join(workspaceDir, "index-status.sqlite"); - await fs.rm(indexStatusPath, { force: true }); - await fs.rm(`${indexStatusPath}-shm`, { force: true }); - await fs.rm(`${indexStatusPath}-wal`, { force: true }); - - const cfg = { - agents: { - defaults: { - workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { path: indexStatusPath, vector: { enabled: false } }, - sync: { watch: false, onSessionStart: false, onSearch: true }, - query: { minScore: 0, hybrid: { enabled: false } }, - }, - }, - list: [{ id: "main", default: true }], - }, - }; + const indexStatusPath = path.join(workspaceDir, `index-status-${Date.now()}.sqlite`); + const cfg = createCfg({ storePath: indexStatusPath }); const first = await getMemorySearchManager({ cfg, agentId: "main" }); expect(first.manager).not.toBeNull(); @@ -207,25 +207,8 @@ describe("memory index", () => { }); it("reindexes when the embedding model changes", async () => { - const indexModelPath = path.join(workspaceDir, "index-model-change.sqlite"); - await fs.rm(indexModelPath, { force: true }); - await fs.rm(`${indexModelPath}-shm`, { force: true }); - await fs.rm(`${indexModelPath}-wal`, { force: true }); - - const base = { - agents: { - defaults: { - workspace: workspaceDir, - memorySearch: { - provider: "openai", - store: { path: indexModelPath }, - sync: { watch: false, onSessionStart: false, onSearch: true }, - query: { minScore: 0, hybrid: { enabled: false } }, - }, - }, - list: [{ id: "main", default: true }], - }, - }; + const indexModelPath = path.join(workspaceDir, `index-model-change-${Date.now()}.sqlite`); + const base = createCfg({ storePath: indexModelPath }); const first = await getMemorySearchManager({ cfg: { @@ -279,24 +262,11 @@ describe("memory index", () => { }); it("reuses cached embeddings on forced reindex", async () => { - const cfg = { - agents: { - defaults: { - workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { path: indexCachePath, vector: { enabled: false } }, - sync: { watch: false, onSessionStart: false, onSearch: false }, - query: { minScore: 0, hybrid: { enabled: false } }, - cache: { enabled: true }, - }, - }, - list: [{ id: "main", default: true }], - }, - }; + const cfg = createCfg({ storePath: indexMainPath, cacheEnabled: true }); const manager = await getPersistentManager(cfg); - await manager.sync({ force: true }); + // Seed the embedding cache once, then ensure a forced reindex doesn't + // re-embed when the cache is enabled. + await manager.sync({ reason: "test" }); const afterFirst = embedBatchCalls; expect(afterFirst).toBeGreaterThan(0); @@ -305,24 +275,10 @@ describe("memory index", () => { }); it("finds keyword matches via hybrid search when query embedding is zero", async () => { - const cfg = { - agents: { - defaults: { - workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { path: indexHybridPath, vector: { enabled: false } }, - sync: { watch: false, onSessionStart: false, onSearch: true }, - query: { - minScore: 0, - hybrid: { enabled: true, vectorWeight: 0, textWeight: 1 }, - }, - }, - }, - list: [{ id: "main", default: true }], - }, - }; + const cfg = createCfg({ + storePath: indexMainPath, + hybrid: { enabled: true, vectorWeight: 0, textWeight: 1 }, + }); const manager = await getPersistentManager(cfg); const status = manager.status(); @@ -337,20 +293,7 @@ describe("memory index", () => { }); it("reports vector availability after probe", async () => { - const cfg = { - agents: { - defaults: { - workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { path: indexVectorPath, vector: { enabled: true } }, - sync: { watch: false, onSessionStart: false, onSearch: false }, - }, - }, - list: [{ id: "main", default: true }], - }, - }; + const cfg = createCfg({ storePath: indexVectorPath, vectorEnabled: true }); const manager = await getPersistentManager(cfg); const available = await manager.probeVectorAvailability(); const status = manager.status(); @@ -360,21 +303,7 @@ describe("memory index", () => { }); it("rejects reading non-memory paths", async () => { - const cfg = { - agents: { - defaults: { - workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { path: indexBasicPath, vector: { enabled: false } }, - sync: { watch: false, onSessionStart: false, onSearch: true }, - query: { minScore: 0, hybrid: { enabled: false } }, - }, - }, - list: [{ id: "main", default: true }], - }, - }; + const cfg = createCfg({ storePath: indexMainPath }); const manager = await getPersistentManager(cfg); await expect(manager.readFile({ relPath: "NOTES.md" })).rejects.toThrow("path required"); }); @@ -383,22 +312,7 @@ describe("memory index", () => { await fs.mkdir(extraDir, { recursive: true }); await fs.writeFile(path.join(extraDir, "extra.md"), "Extra content."); - const cfg = { - agents: { - defaults: { - workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { path: indexExtraPath, vector: { enabled: false } }, - sync: { watch: false, onSessionStart: false, onSearch: true }, - query: { minScore: 0, hybrid: { enabled: false } }, - extraPaths: [extraDir], - }, - }, - list: [{ id: "main", default: true }], - }, - }; + const cfg = createCfg({ storePath: indexExtraPath, extraPaths: [extraDir] }); const manager = await getPersistentManager(cfg); await expect(manager.readFile({ relPath: "extra/extra.md" })).resolves.toEqual({ path: "extra/extra.md", diff --git a/src/memory/manager.async-search.test.ts b/src/memory/manager.async-search.test.ts index 7f60ef0ea9..114957bbc4 100644 --- a/src/memory/manager.async-search.test.ts +++ b/src/memory/manager.async-search.test.ts @@ -73,10 +73,19 @@ describe("memory search async sync", () => { const pending = new Promise(() => {}); (manager as unknown as { sync: () => Promise }).sync = vi.fn(async () => pending); - const resolved = await Promise.race([ - manager.search("hello").then(() => true), - new Promise((resolve) => setTimeout(() => resolve(false), 1000)), - ]); + const resolved = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => resolve(false), 1000); + void manager + .search("hello") + .then(() => { + clearTimeout(timeout); + resolve(true); + }) + .catch((err) => { + clearTimeout(timeout); + reject(err); + }); + }); expect(resolved).toBe(true); }); }); diff --git a/src/memory/manager.atomic-reindex.test.ts b/src/memory/manager.atomic-reindex.test.ts index 17434610e1..8979f388b7 100644 --- a/src/memory/manager.atomic-reindex.test.ts +++ b/src/memory/manager.atomic-reindex.test.ts @@ -1,7 +1,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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; let shouldFail = false; @@ -34,15 +34,26 @@ vi.mock("./embeddings.js", () => { }; }); +vi.mock("./sqlite-vec.js", () => ({ + loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }), +})); + describe("memory manager atomic reindex", () => { + let fixtureRoot = ""; + let caseId = 0; let workspaceDir: string; let indexPath: string; let manager: MemoryIndexManager | null = null; + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-atomic-")); + }); + beforeEach(async () => { vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0"); shouldFail = false; - workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-")); + workspaceDir = path.join(fixtureRoot, `case-${caseId++}`); + await fs.mkdir(workspaceDir, { recursive: true }); indexPath = path.join(workspaceDir, "index.sqlite"); await fs.mkdir(path.join(workspaceDir, "memory")); await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Hello memory."); @@ -53,7 +64,13 @@ describe("memory manager atomic reindex", () => { await manager.close(); manager = null; } - await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + afterAll(async () => { + if (!fixtureRoot) { + return; + } + await fs.rm(fixtureRoot, { recursive: true, force: true }); }); it("keeps the prior index when a full reindex fails", async () => { @@ -66,6 +83,8 @@ describe("memory manager atomic reindex", () => { model: "mock-embed", store: { path: indexPath }, cache: { enabled: false }, + // Perf: keep test indexes to a single chunk to reduce sqlite work. + chunking: { tokens: 4000, overlap: 0 }, sync: { watch: false, onSessionStart: false, onSearch: false }, }, }, @@ -81,13 +100,13 @@ describe("memory manager atomic reindex", () => { manager = result.manager; await manager.sync({ force: true }); - const before = await manager.search("Hello"); - expect(before.length).toBeGreaterThan(0); + const beforeStatus = manager.status(); + expect(beforeStatus.chunks).toBeGreaterThan(0); shouldFail = true; await expect(manager.sync({ force: true })).rejects.toThrow("embedding failure"); - const after = await manager.search("Hello"); - expect(after.length).toBeGreaterThan(0); + const afterStatus = manager.status(); + expect(afterStatus.chunks).toBeGreaterThan(0); }); }); diff --git a/src/process/child-process-bridge.test.ts b/src/process/child-process-bridge.test.ts index 855b37ac2e..9a8c2f5078 100644 --- a/src/process/child-process-bridge.test.ts +++ b/src/process/child-process-bridge.test.ts @@ -1,11 +1,10 @@ import { spawn } from "node:child_process"; -import net from "node:net"; import path from "node:path"; import process from "node:process"; import { afterEach, describe, expect, it } from "vitest"; import { attachChildProcessBridge } from "./child-process-bridge.js"; -function waitForLine(stream: NodeJS.ReadableStream, timeoutMs = 10_000): Promise { +function waitForLine(stream: NodeJS.ReadableStream, timeoutMs = 2000): Promise { return new Promise((resolve, reject) => { let buffer = ""; @@ -40,28 +39,6 @@ function waitForLine(stream: NodeJS.ReadableStream, timeoutMs = 10_000): Promise }); } -function canConnect(port: number): Promise { - return new Promise((resolve) => { - const socket = net.createConnection({ host: "127.0.0.1", port }); - socket.once("connect", () => { - socket.end(); - resolve(true); - }); - socket.once("error", () => resolve(false)); - }); -} - -async function waitForPortClosed(port: number, timeoutMs = 1_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - if (!(await canConnect(port))) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error("timeout waiting for port to close"); -} - describe("attachChildProcessBridge", () => { const children: Array<{ kill: (signal?: NodeJS.Signals) => boolean }> = []; const detachments: Array<() => void> = []; @@ -102,11 +79,8 @@ describe("attachChildProcessBridge", () => { if (!child.stdout) { throw new Error("expected stdout"); } - const portLine = await waitForLine(child.stdout); - const port = Number(portLine); - expect(Number.isFinite(port)).toBe(true); - - expect(await canConnect(port)).toBe(true); + const ready = await waitForLine(child.stdout); + expect(ready).toBe("ready"); // Simulate systemd sending SIGTERM to the parent process. if (!addedSigterm) { @@ -121,8 +95,5 @@ describe("attachChildProcessBridge", () => { resolve(); }); }); - - await waitForPortClosed(port); - expect(await canConnect(port)).toBe(false); }, 20_000); }); diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index caad90b1ef..5ee796d531 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1,7 +1,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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { collectPluginsCodeSafetyFindings } from "./audit-extra.js"; @@ -73,6 +73,26 @@ function successfulProbeResult(url: string) { } describe("security audit", () => { + let fixtureRoot = ""; + let caseId = 0; + + const makeTmpDir = async (label: string) => { + const dir = path.join(fixtureRoot, `case-${caseId++}-${label}`); + await fs.mkdir(dir, { recursive: true }); + return dir; + }; + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-")); + }); + + afterAll(async () => { + if (!fixtureRoot) { + return; + } + await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined); + }); + it("includes an attack surface summary (info)", async () => { const cfg: OpenClawConfig = { channels: { whatsapp: { groupPolicy: "open" }, telegram: { groupPolicy: "allowlist" } }, @@ -290,7 +310,7 @@ describe("security audit", () => { }); it("treats Windows ACL-only perms as secure", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-win-")); + const tmp = await makeTmpDir("win"); const stateDir = path.join(tmp, "state"); await fs.mkdir(stateDir, { recursive: true }); const configPath = path.join(stateDir, "openclaw.json"); @@ -327,7 +347,7 @@ describe("security audit", () => { }); it("flags Windows ACLs when Users can read the state dir", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-win-open-")); + const tmp = await makeTmpDir("win-open"); const stateDir = path.join(tmp, "state"); await fs.mkdir(stateDir, { recursive: true }); const configPath = path.join(stateDir, "openclaw.json"); @@ -831,7 +851,7 @@ describe("security audit", () => { it("flags Discord native commands without a guild user allowlist", async () => { const prevStateDir = process.env.OPENCLAW_STATE_DIR; - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-discord-")); + const tmp = await makeTmpDir("discord"); process.env.OPENCLAW_STATE_DIR = tmp; await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 }); try { @@ -878,9 +898,7 @@ describe("security audit", () => { it("does not flag Discord slash commands when dm.allowFrom includes a Discord snowflake id", async () => { const prevStateDir = process.env.OPENCLAW_STATE_DIR; - const tmp = await fs.mkdtemp( - path.join(os.tmpdir(), "openclaw-security-audit-discord-allowfrom-snowflake-"), - ); + const tmp = await makeTmpDir("discord-allowfrom-snowflake"); process.env.OPENCLAW_STATE_DIR = tmp; await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 }); try { @@ -927,7 +945,7 @@ describe("security audit", () => { it("flags Discord slash commands when access-group enforcement is disabled and no users allowlist exists", async () => { const prevStateDir = process.env.OPENCLAW_STATE_DIR; - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-discord-open-")); + const tmp = await makeTmpDir("discord-open"); process.env.OPENCLAW_STATE_DIR = tmp; await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 }); try { @@ -975,7 +993,7 @@ describe("security audit", () => { it("flags Slack slash commands without a channel users allowlist", async () => { const prevStateDir = process.env.OPENCLAW_STATE_DIR; - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-slack-")); + const tmp = await makeTmpDir("slack"); process.env.OPENCLAW_STATE_DIR = tmp; await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 }); try { @@ -1017,7 +1035,7 @@ describe("security audit", () => { it("flags Slack slash commands when access-group enforcement is disabled", async () => { const prevStateDir = process.env.OPENCLAW_STATE_DIR; - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-slack-open-")); + const tmp = await makeTmpDir("slack-open"); process.env.OPENCLAW_STATE_DIR = tmp; await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 }); try { @@ -1060,7 +1078,7 @@ describe("security audit", () => { it("flags Telegram group commands without a sender allowlist", async () => { const prevStateDir = process.env.OPENCLAW_STATE_DIR; - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-telegram-")); + const tmp = await makeTmpDir("telegram"); process.env.OPENCLAW_STATE_DIR = tmp; await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 }); try { @@ -1101,9 +1119,7 @@ describe("security audit", () => { it("warns when Telegram allowFrom entries are non-numeric (legacy @username configs)", async () => { const prevStateDir = process.env.OPENCLAW_STATE_DIR; - const tmp = await fs.mkdtemp( - path.join(os.tmpdir(), "openclaw-security-audit-telegram-invalid-allowfrom-"), - ); + const tmp = await makeTmpDir("telegram-invalid-allowfrom"); process.env.OPENCLAW_STATE_DIR = tmp; await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 }); try { @@ -1413,7 +1429,7 @@ describe("security audit", () => { }); it("flags group/world-readable config include files", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-")); + const tmp = await makeTmpDir("include-perms"); const stateDir = path.join(tmp, "state"); await fs.mkdir(stateDir, { recursive: true, mode: 0o700 }); @@ -1486,7 +1502,7 @@ describe("security audit", () => { delete process.env.TELEGRAM_BOT_TOKEN; delete process.env.SLACK_BOT_TOKEN; delete process.env.SLACK_APP_TOKEN; - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-")); + const tmp = await makeTmpDir("extensions-no-allowlist"); const stateDir = path.join(tmp, "state"); await fs.mkdir(path.join(stateDir, "extensions", "some-plugin"), { recursive: true, @@ -1533,71 +1549,63 @@ describe("security audit", () => { }); it("flags enabled extensions when tool policy can expose plugin tools", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-plugins-")); + const tmp = await makeTmpDir("plugins-reachable"); const stateDir = path.join(tmp, "state"); await fs.mkdir(path.join(stateDir, "extensions", "some-plugin"), { recursive: true, mode: 0o700, }); - try { - const cfg: OpenClawConfig = { - plugins: { allow: ["some-plugin"] }, - }; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - }); + const cfg: OpenClawConfig = { + plugins: { allow: ["some-plugin"] }, + }; + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + }); - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - checkId: "plugins.tools_reachable_permissive_policy", - severity: "warn", - }), - ]), - ); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "plugins.tools_reachable_permissive_policy", + severity: "warn", + }), + ]), + ); }); it("does not flag plugin tool reachability when profile is restrictive", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-plugins-")); + const tmp = await makeTmpDir("plugins-restrictive"); const stateDir = path.join(tmp, "state"); await fs.mkdir(path.join(stateDir, "extensions", "some-plugin"), { recursive: true, mode: 0o700, }); - try { - const cfg: OpenClawConfig = { - plugins: { allow: ["some-plugin"] }, - tools: { profile: "coding" }, - }; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath: path.join(stateDir, "openclaw.json"), - }); + const cfg: OpenClawConfig = { + plugins: { allow: ["some-plugin"] }, + tools: { profile: "coding" }, + }; + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + }); - expect( - res.findings.some((f) => f.checkId === "plugins.tools_reachable_permissive_policy"), - ).toBe(false); - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } + expect( + res.findings.some((f) => f.checkId === "plugins.tools_reachable_permissive_policy"), + ).toBe(false); }); it("flags unallowlisted extensions as critical when native skill commands are exposed", async () => { const prevDiscordToken = process.env.DISCORD_BOT_TOKEN; delete process.env.DISCORD_BOT_TOKEN; - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-")); + const tmp = await makeTmpDir("extensions-critical"); const stateDir = path.join(tmp, "state"); await fs.mkdir(path.join(stateDir, "extensions", "some-plugin"), { recursive: true, @@ -1636,7 +1644,7 @@ describe("security audit", () => { }); it("flags plugins with dangerous code patterns (deep audit)", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-audit-scanner-")); + const tmpDir = await makeTmpDir("audit-scanner-plugin"); const pluginDir = path.join(tmpDir, "extensions", "evil-plugin"); await fs.mkdir(path.join(pluginDir, ".hidden"), { recursive: true }); await fs.writeFile( @@ -1675,12 +1683,10 @@ describe("security audit", () => { (f) => f.checkId === "plugins.code_safety" && f.severity === "critical", ), ).toBe(true); - - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); }); it("reports detailed code-safety issues for both plugins and skills", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-audit-scanner-")); + const tmpDir = await makeTmpDir("audit-scanner-plugin-skill"); const workspaceDir = path.join(tmpDir, "workspace"); const pluginDir = path.join(tmpDir, "extensions", "evil-plugin"); const skillDir = path.join(workspaceDir, "skills", "evil-skill"); @@ -1738,12 +1744,10 @@ description: test skill expect(skillFinding).toBeDefined(); expect(skillFinding?.detail).toContain("dangerous-exec"); expect(skillFinding?.detail).toMatch(/runner\.js:\d+/); - - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); }); it("flags plugin extension entry path traversal in deep audit", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-audit-scanner-")); + const tmpDir = await makeTmpDir("audit-scanner-escape"); const pluginDir = path.join(tmpDir, "extensions", "escape-plugin"); await fs.mkdir(pluginDir, { recursive: true }); await fs.writeFile( @@ -1755,18 +1759,8 @@ description: test skill ); await fs.writeFile(path.join(pluginDir, "index.js"), "export {};"); - const res = await runSecurityAudit({ - config: {}, - includeFilesystem: true, - includeChannelSecurity: false, - deep: true, - stateDir: tmpDir, - probeGatewayFn: async (opts) => successfulProbeResult(opts.url), - }); - - expect(res.findings.some((f) => f.checkId === "plugins.code_safety.entry_escape")).toBe(true); - - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); + const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); + expect(findings.some((f) => f.checkId === "plugins.code_safety.entry_escape")).toBe(true); }); it("reports scan_failed when plugin code scanner throws during deep audit", async () => { @@ -1774,7 +1768,7 @@ description: test skill .spyOn(skillScanner, "scanDirectoryWithSummary") .mockRejectedValueOnce(new Error("boom")); - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-audit-scanner-")); + const tmpDir = await makeTmpDir("audit-scanner-throws"); try { const pluginDir = path.join(tmpDir, "extensions", "scanfail-plugin"); await fs.mkdir(pluginDir, { recursive: true }); @@ -1791,7 +1785,6 @@ description: test skill expect(findings.some((f) => f.checkId === "plugins.code_safety.scan_failed")).toBe(true); } finally { scanSpy.mockRestore(); - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); } }); diff --git a/src/security/fix.test.ts b/src/security/fix.test.ts index 4347f99380..d9f691b47b 100644 --- a/src/security/fix.test.ts +++ b/src/security/fix.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { fixSecurityFootguns } from "./fix.js"; const isWindows = process.platform === "win32"; @@ -15,10 +15,27 @@ const expectPerms = (actual: number, expected: number) => { }; describe("security fix", () => { + let fixtureRoot = ""; + let fixtureCount = 0; + + const createStateDir = async (prefix: string) => { + const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); + await fs.mkdir(dir, { recursive: true }); + return dir; + }; + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-fix-suite-")); + }); + + afterAll(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }); + it("tightens groupPolicy + filesystem perms", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-fix-")); - const stateDir = path.join(tmp, "state"); - await fs.mkdir(stateDir, { recursive: true }); + const stateDir = await createStateDir("tightens"); await fs.chmod(stateDir, 0o755); const configPath = path.join(stateDir, "openclaw.json"); @@ -53,10 +70,10 @@ describe("security fix", () => { const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_CONFIG_PATH: "", + OPENCLAW_CONFIG_PATH: configPath, }; - const res = await fixSecurityFootguns({ env }); + const res = await fixSecurityFootguns({ env, stateDir, configPath }); expect(res.ok).toBe(true); expect(res.configWritten).toBe(true); expect(res.changes).toEqual( @@ -88,9 +105,7 @@ describe("security fix", () => { }); it("applies allowlist per-account and seeds WhatsApp groupAllowFrom from store", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-fix-")); - const stateDir = path.join(tmp, "state"); - await fs.mkdir(stateDir, { recursive: true }); + const stateDir = await createStateDir("per-account"); const configPath = path.join(stateDir, "openclaw.json"); await fs.writeFile( @@ -122,10 +137,10 @@ describe("security fix", () => { const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_CONFIG_PATH: "", + OPENCLAW_CONFIG_PATH: configPath, }; - const res = await fixSecurityFootguns({ env }); + const res = await fixSecurityFootguns({ env, stateDir, configPath }); expect(res.ok).toBe(true); const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record; @@ -138,9 +153,7 @@ describe("security fix", () => { }); it("does not seed WhatsApp groupAllowFrom if allowFrom is set", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-fix-")); - const stateDir = path.join(tmp, "state"); - await fs.mkdir(stateDir, { recursive: true }); + const stateDir = await createStateDir("no-seed"); const configPath = path.join(stateDir, "openclaw.json"); await fs.writeFile( @@ -168,10 +181,10 @@ describe("security fix", () => { const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_CONFIG_PATH: "", + OPENCLAW_CONFIG_PATH: configPath, }; - const res = await fixSecurityFootguns({ env }); + const res = await fixSecurityFootguns({ env, stateDir, configPath }); expect(res.ok).toBe(true); const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record; @@ -181,9 +194,7 @@ describe("security fix", () => { }); it("returns ok=false for invalid config but still tightens perms", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-fix-")); - const stateDir = path.join(tmp, "state"); - await fs.mkdir(stateDir, { recursive: true }); + const stateDir = await createStateDir("invalid-config"); await fs.chmod(stateDir, 0o755); const configPath = path.join(stateDir, "openclaw.json"); @@ -193,10 +204,10 @@ describe("security fix", () => { const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_CONFIG_PATH: "", + OPENCLAW_CONFIG_PATH: configPath, }; - const res = await fixSecurityFootguns({ env }); + const res = await fixSecurityFootguns({ env, stateDir, configPath }); expect(res.ok).toBe(false); const stateMode = (await fs.stat(stateDir)).mode & 0o777; @@ -207,9 +218,7 @@ describe("security fix", () => { }); it("tightens perms for credentials + agent auth/sessions + include files", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-fix-")); - const stateDir = path.join(tmp, "state"); - await fs.mkdir(stateDir, { recursive: true }); + const stateDir = await createStateDir("includes"); const includesDir = path.join(stateDir, "includes"); await fs.mkdir(includesDir, { recursive: true }); @@ -250,10 +259,10 @@ describe("security fix", () => { const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_CONFIG_PATH: "", + OPENCLAW_CONFIG_PATH: configPath, }; - const res = await fixSecurityFootguns({ env }); + const res = await fixSecurityFootguns({ env, stateDir, configPath }); expect(res.ok).toBe(true); expectPerms((await fs.stat(credsDir)).mode & 0o777, 0o700); diff --git a/test/fixtures/child-process-bridge/child.js b/test/fixtures/child-process-bridge/child.js index 9ef083e42b..57c7d703e3 100644 --- a/test/fixtures/child-process-bridge/child.js +++ b/test/fixtures/child-process-bridge/child.js @@ -1,20 +1,10 @@ -import http from "node:http"; +process.stdout.write("ready\n"); -const server = http.createServer((_, res) => { - res.writeHead(200, { "content-type": "text/plain" }); - res.end("ok"); -}); - -server.listen(0, "127.0.0.1", () => { - const addr = server.address(); - if (!addr || typeof addr === "string") { - throw new Error("unexpected address"); - } - process.stdout.write(`${addr.port}\n`); -}); +const keepAlive = setInterval(() => {}, 1000); const shutdown = () => { - server.close(() => process.exit(0)); + clearInterval(keepAlive); + process.exit(0); }; process.on("SIGTERM", shutdown);