diff --git a/src/auto-reply/reply/session-resets.test.ts b/src/auto-reply/reply/session-resets.test.ts index 16c44172fa3..9c105c0307b 100644 --- a/src/auto-reply/reply/session-resets.test.ts +++ b/src/auto-reply/reply/session-resets.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { buildModelAliasIndex } from "../../agents/model-selection.js"; +import { saveSessionStore } from "../../config/sessions.js"; import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js"; import { applyResetModelOverride } from "./session-reset-model.js"; @@ -32,7 +33,7 @@ afterAll(async () => { async function createStorePath(prefix: string): Promise { const root = path.join(suiteRoot, `${prefix}${++suiteCase}`); - await fs.mkdir(root, { recursive: true }); + await fs.mkdir(root); return path.join(root, "sessions.json"); } @@ -42,7 +43,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => { sessionKey: string; sessionId: string; }): Promise { - const { saveSessionStore } = await import("../../config/sessions.js"); await saveSessionStore(params.storePath, { [params.sessionKey]: { sessionId: params.sessionId, diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index ba239f61062..b1215603737 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { saveSessionStore } from "../../config/sessions.js"; import { initSessionState } from "./session.js"; @@ -21,7 +21,7 @@ afterAll(async () => { async function makeCaseDir(prefix: string): Promise { const dir = path.join(suiteRoot, `${prefix}${++suiteCase}`); - await fs.mkdir(dir, { recursive: true }); + await fs.mkdir(dir); return dir; } @@ -29,7 +29,7 @@ describe("initSessionState thread forking", () => { it("forks a new session from the parent session file", async () => { const root = await makeCaseDir("openclaw-thread-session-"); const sessionsDir = path.join(root, "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); + await fs.mkdir(sessionsDir); const parentSessionId = "parent-session"; const parentSessionFile = path.join(sessionsDir, "parent.jsonl"); @@ -258,240 +258,213 @@ describe("initSessionState RawBody", () => { }); describe("initSessionState reset policy", () => { - it("defaults to daily reset at 4am local time", async () => { + beforeEach(() => { vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("defaults to daily reset at 4am local time", async () => { vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); - try { - const root = await makeCaseDir("openclaw-reset-daily-"); - const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:whatsapp:dm:s1"; - const existingSessionId = "daily-session-id"; + const root = await makeCaseDir("openclaw-reset-daily-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:s1"; + const existingSessionId = "daily-session-id"; - await saveSessionStore(storePath, { - [sessionKey]: { - sessionId: existingSessionId, - updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), - }, - }); + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + }, + }); - const cfg = { session: { store: storePath } } as OpenClawConfig; - const result = await initSessionState({ - ctx: { Body: "hello", SessionKey: sessionKey }, - cfg, - commandAuthorized: true, - }); + const cfg = { session: { store: storePath } } as OpenClawConfig; + const result = await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - } finally { - vi.useRealTimers(); - } + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); }); it("treats sessions as stale before the daily reset when updated before yesterday's boundary", async () => { - vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 0, 18, 3, 0, 0)); - try { - const root = await makeCaseDir("openclaw-reset-daily-edge-"); - const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:whatsapp:dm:s-edge"; - const existingSessionId = "daily-edge-session"; + const root = await makeCaseDir("openclaw-reset-daily-edge-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:s-edge"; + const existingSessionId = "daily-edge-session"; - await saveSessionStore(storePath, { - [sessionKey]: { - sessionId: existingSessionId, - updatedAt: new Date(2026, 0, 17, 3, 30, 0).getTime(), - }, - }); + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 17, 3, 30, 0).getTime(), + }, + }); - const cfg = { session: { store: storePath } } as OpenClawConfig; - const result = await initSessionState({ - ctx: { Body: "hello", SessionKey: sessionKey }, - cfg, - commandAuthorized: true, - }); + const cfg = { session: { store: storePath } } as OpenClawConfig; + const result = await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - } finally { - vi.useRealTimers(); - } + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); }); it("expires sessions when idle timeout wins over daily reset", async () => { - vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0)); - try { - const root = await makeCaseDir("openclaw-reset-idle-"); - const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:whatsapp:dm:s2"; - const existingSessionId = "idle-session-id"; + const root = await makeCaseDir("openclaw-reset-idle-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:s2"; + const existingSessionId = "idle-session-id"; - await saveSessionStore(storePath, { - [sessionKey]: { - sessionId: existingSessionId, - updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(), - }, - }); + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(), + }, + }); - const cfg = { - session: { - store: storePath, - reset: { mode: "daily", atHour: 4, idleMinutes: 30 }, - }, - } as OpenClawConfig; - const result = await initSessionState({ - ctx: { Body: "hello", SessionKey: sessionKey }, - cfg, - commandAuthorized: true, - }); + const cfg = { + session: { + store: storePath, + reset: { mode: "daily", atHour: 4, idleMinutes: 30 }, + }, + } as OpenClawConfig; + const result = await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - } finally { - vi.useRealTimers(); - } + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); }); it("uses per-type overrides for thread sessions", async () => { - vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); - try { - const root = await makeCaseDir("openclaw-reset-thread-"); - const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:slack:channel:c1:thread:123"; - const existingSessionId = "thread-session-id"; + const root = await makeCaseDir("openclaw-reset-thread-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:slack:channel:c1:thread:123"; + const existingSessionId = "thread-session-id"; - await saveSessionStore(storePath, { - [sessionKey]: { - sessionId: existingSessionId, - updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), - }, - }); + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + }, + }); - const cfg = { - session: { - store: storePath, - reset: { mode: "daily", atHour: 4 }, - resetByType: { thread: { mode: "idle", idleMinutes: 180 } }, - }, - } as OpenClawConfig; - const result = await initSessionState({ - ctx: { Body: "reply", SessionKey: sessionKey, ThreadLabel: "Slack thread" }, - cfg, - commandAuthorized: true, - }); + const cfg = { + session: { + store: storePath, + reset: { mode: "daily", atHour: 4 }, + resetByType: { thread: { mode: "idle", idleMinutes: 180 } }, + }, + } as OpenClawConfig; + const result = await initSessionState({ + ctx: { Body: "reply", SessionKey: sessionKey, ThreadLabel: "Slack thread" }, + cfg, + commandAuthorized: true, + }); - expect(result.isNewSession).toBe(false); - expect(result.sessionId).toBe(existingSessionId); - } finally { - vi.useRealTimers(); - } + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); }); it("detects thread sessions without thread key suffix", async () => { - vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); - try { - const root = await makeCaseDir("openclaw-reset-thread-nosuffix-"); - const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:discord:channel:c1"; - const existingSessionId = "thread-nosuffix"; + const root = await makeCaseDir("openclaw-reset-thread-nosuffix-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:discord:channel:c1"; + const existingSessionId = "thread-nosuffix"; - await saveSessionStore(storePath, { - [sessionKey]: { - sessionId: existingSessionId, - updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), - }, - }); + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + }, + }); - const cfg = { - session: { - store: storePath, - resetByType: { thread: { mode: "idle", idleMinutes: 180 } }, - }, - } as OpenClawConfig; - const result = await initSessionState({ - ctx: { Body: "reply", SessionKey: sessionKey, ThreadLabel: "Discord thread" }, - cfg, - commandAuthorized: true, - }); + const cfg = { + session: { + store: storePath, + resetByType: { thread: { mode: "idle", idleMinutes: 180 } }, + }, + } as OpenClawConfig; + const result = await initSessionState({ + ctx: { Body: "reply", SessionKey: sessionKey, ThreadLabel: "Discord thread" }, + cfg, + commandAuthorized: true, + }); - expect(result.isNewSession).toBe(false); - expect(result.sessionId).toBe(existingSessionId); - } finally { - vi.useRealTimers(); - } + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); }); it("defaults to daily resets when only resetByType is configured", async () => { - vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); - try { - const root = await makeCaseDir("openclaw-reset-type-default-"); - const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:whatsapp:dm:s4"; - const existingSessionId = "type-default-session"; + const root = await makeCaseDir("openclaw-reset-type-default-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:s4"; + const existingSessionId = "type-default-session"; - await saveSessionStore(storePath, { - [sessionKey]: { - sessionId: existingSessionId, - updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), - }, - }); + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + }, + }); - const cfg = { - session: { - store: storePath, - resetByType: { thread: { mode: "idle", idleMinutes: 60 } }, - }, - } as OpenClawConfig; - const result = await initSessionState({ - ctx: { Body: "hello", SessionKey: sessionKey }, - cfg, - commandAuthorized: true, - }); + const cfg = { + session: { + store: storePath, + resetByType: { thread: { mode: "idle", idleMinutes: 60 } }, + }, + } as OpenClawConfig; + const result = await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - } finally { - vi.useRealTimers(); - } + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); }); it("keeps legacy idleMinutes behavior without reset config", async () => { - vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); - try { - const root = await makeCaseDir("openclaw-reset-legacy-"); - const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:whatsapp:dm:s3"; - const existingSessionId = "legacy-session-id"; + const root = await makeCaseDir("openclaw-reset-legacy-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:s3"; + const existingSessionId = "legacy-session-id"; - await saveSessionStore(storePath, { - [sessionKey]: { - sessionId: existingSessionId, - updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(), - }, - }); + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(), + }, + }); - const cfg = { - session: { - store: storePath, - idleMinutes: 240, - }, - } as OpenClawConfig; - const result = await initSessionState({ - ctx: { Body: "hello", SessionKey: sessionKey }, - cfg, - commandAuthorized: true, - }); + const cfg = { + session: { + store: storePath, + idleMinutes: 240, + }, + } as OpenClawConfig; + const result = await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); - expect(result.isNewSession).toBe(false); - expect(result.sessionId).toBe(existingSessionId); - } finally { - vi.useRealTimers(); - } + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); }); }); diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 737500e013b..72aa073bd8c 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const githubCopilotLoginCommand = vi.fn(); const modelsStatusCommand = vi.fn().mockResolvedValue(undefined); @@ -32,18 +32,28 @@ vi.mock("../commands/models.js", () => ({ })); describe("models cli", () => { + let Command: typeof import("commander").Command; + let registerModelsCli: (typeof import("./models-cli.js"))["registerModelsCli"]; + + beforeAll(async () => { + // Load once; vi.mock above ensures command handlers are already mocked. + ({ Command } = await import("commander")); + ({ registerModelsCli } = await import("./models-cli.js")); + }); + beforeEach(() => { githubCopilotLoginCommand.mockClear(); modelsStatusCommand.mockClear(); }); - it("registers github-copilot login command", { timeout: 60_000 }, async () => { - const { Command } = await import("commander"); - const { registerModelsCli } = await import("./models-cli.js"); - + function createProgram() { const program = new Command(); registerModelsCli(program); + return program; + } + it("registers github-copilot login command", async () => { + const program = createProgram(); const models = program.commands.find((cmd) => cmd.name() === "models"); expect(models).toBeTruthy(); @@ -65,11 +75,7 @@ describe("models cli", () => { }); it("passes --agent to models status", async () => { - const { Command } = await import("commander"); - const { registerModelsCli } = await import("./models-cli.js"); - - const program = new Command(); - registerModelsCli(program); + const program = createProgram(); await program.parseAsync(["models", "status", "--agent", "poe"], { from: "user" }); @@ -80,11 +86,7 @@ describe("models cli", () => { }); it("passes parent --agent to models status", async () => { - const { Command } = await import("commander"); - const { registerModelsCli } = await import("./models-cli.js"); - - const program = new Command(); - registerModelsCli(program); + const program = createProgram(); await program.parseAsync(["models", "--agent", "poe", "status"], { from: "user" }); @@ -95,9 +97,6 @@ describe("models cli", () => { }); it("shows help for models auth without error exit", async () => { - const { Command } = await import("commander"); - const { registerModelsCli } = await import("./models-cli.js"); - const program = new Command(); program.exitOverride(); registerModelsCli(program); 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 4a0e356fb2d..70d78721bd3 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 @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js"; -import type { CronJob } from "./types.js"; +import type { CronEvent } from "./service.js"; import { CronService } from "./service.js"; import { createCronStoreHarness, @@ -14,16 +14,46 @@ const noopLogger = createNoopLogger(); const { makeStorePath } = createCronStoreHarness(); installCronTestHooks({ logger: noopLogger }); -async function waitForJobs(cron: CronService, predicate: (jobs: CronJob[]) => boolean) { - let latest: CronJob[] = []; - for (let i = 0; i < 30; i++) { - latest = await cron.list({ includeDisabled: true }); - if (predicate(latest)) { - return latest; +function 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 }; +} + +function createCronEventHarness() { + const events: CronEvent[] = []; + const waiters: Array<{ + predicate: (evt: CronEvent) => boolean; + deferred: ReturnType>; + }> = []; + + const onEvent = (evt: CronEvent) => { + events.push(evt); + for (let i = waiters.length - 1; i >= 0; i -= 1) { + const waiter = waiters[i]; + if (waiter && waiter.predicate(evt)) { + waiters.splice(i, 1); + waiter.deferred.resolve(evt); + } } - await vi.runOnlyPendingTimersAsync(); - } - return latest; + }; + + const waitFor = (predicate: (evt: CronEvent) => boolean) => { + for (const evt of events) { + if (predicate(evt)) { + return Promise.resolve(evt); + } + } + const deferred = createDeferred(); + waiters.push({ predicate, deferred }); + return deferred.promise; + }; + + return { onEvent, waitFor, events }; } describe("CronService", () => { @@ -31,6 +61,7 @@ describe("CronService", () => { const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); + const events = createCronEventHarness(); const cron = new CronService({ storePath: store.storePath, @@ -39,6 +70,7 @@ describe("CronService", () => { enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + onEvent: events.onEvent, }); await cron.start(); @@ -57,10 +89,9 @@ describe("CronService", () => { vi.setSystemTime(new Date("2025-12-13T00:00:02.000Z")); await vi.runOnlyPendingTimersAsync(); + await events.waitFor((evt) => evt.jobId === job.id && evt.action === "finished"); - const jobs = await waitForJobs(cron, (items) => - items.some((item) => item.id === job.id && !item.enabled), - ); + const jobs = await cron.list({ includeDisabled: true }); const updated = jobs.find((j) => j.id === job.id); expect(updated?.enabled).toBe(false); expect(enqueueSystemEvent).toHaveBeenCalledWith("hello", { @@ -77,6 +108,7 @@ describe("CronService", () => { const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); + const events = createCronEventHarness(); const cron = new CronService({ storePath: store.storePath, @@ -85,6 +117,7 @@ describe("CronService", () => { enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + onEvent: events.onEvent, }); await cron.start(); @@ -100,8 +133,9 @@ describe("CronService", () => { vi.setSystemTime(new Date("2025-12-13T00:00:02.000Z")); await vi.runOnlyPendingTimersAsync(); + await events.waitFor((evt) => evt.jobId === job.id && evt.action === "removed"); - const jobs = await waitForJobs(cron, (items) => !items.some((item) => item.id === job.id)); + const jobs = await cron.list({ includeDisabled: true }); expect(jobs.find((j) => j.id === job.id)).toBeUndefined(); expect(enqueueSystemEvent).toHaveBeenCalledWith("hello", { agentId: undefined, @@ -282,6 +316,7 @@ describe("CronService", () => { status: "ok" as const, summary: "done", })); + const events = createCronEventHarness(); const cron = new CronService({ storePath: store.storePath, @@ -290,11 +325,12 @@ describe("CronService", () => { enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob, + onEvent: events.onEvent, }); await cron.start(); const atMs = Date.parse("2025-12-13T00:00:01.000Z"); - await cron.add({ + const job = await cron.add({ enabled: true, name: "weekly", schedule: { kind: "at", at: new Date(atMs).toISOString() }, @@ -307,7 +343,9 @@ describe("CronService", () => { vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); await vi.runOnlyPendingTimersAsync(); - await waitForJobs(cron, (items) => items.some((item) => item.state.lastStatus === "ok")); + await events.waitFor( + (evt) => evt.jobId === job.id && evt.action === "finished" && evt.status === "ok", + ); expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); expect(enqueueSystemEvent).toHaveBeenCalledWith("Cron: done", { agentId: undefined, @@ -326,6 +364,7 @@ describe("CronService", () => { summary: "done", delivered: true, })); + const events = createCronEventHarness(); const cron = new CronService({ storePath: store.storePath, @@ -334,11 +373,12 @@ describe("CronService", () => { enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob, + onEvent: events.onEvent, }); await cron.start(); const atMs = Date.parse("2025-12-13T00:00:01.000Z"); - await cron.add({ + const job = await cron.add({ enabled: true, name: "weekly delivered", schedule: { kind: "at", at: new Date(atMs).toISOString() }, @@ -351,7 +391,9 @@ describe("CronService", () => { vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); await vi.runOnlyPendingTimersAsync(); - await waitForJobs(cron, (items) => items.some((item) => item.state.lastStatus === "ok")); + await events.waitFor( + (evt) => evt.jobId === job.id && evt.action === "finished" && evt.status === "ok", + ); expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); expect(enqueueSystemEvent).not.toHaveBeenCalled(); expect(requestHeartbeatNow).not.toHaveBeenCalled(); @@ -473,6 +515,7 @@ describe("CronService", () => { summary: "last output", error: "boom", })); + const events = createCronEventHarness(); const cron = new CronService({ storePath: store.storePath, @@ -481,11 +524,12 @@ describe("CronService", () => { enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob, + onEvent: events.onEvent, }); await cron.start(); const atMs = Date.parse("2025-12-13T00:00:01.000Z"); - await cron.add({ + const job = await cron.add({ name: "isolated error test", enabled: true, schedule: { kind: "at", at: new Date(atMs).toISOString() }, @@ -497,7 +541,9 @@ describe("CronService", () => { vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); await vi.runOnlyPendingTimersAsync(); - await waitForJobs(cron, (items) => items.some((item) => item.state.lastStatus === "error")); + await events.waitFor( + (evt) => evt.jobId === job.id && evt.action === "finished" && evt.status === "error", + ); expect(enqueueSystemEvent).toHaveBeenCalledWith("Cron (error): last output", { agentId: undefined, @@ -551,6 +597,7 @@ describe("CronService", () => { 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 }); @@ -581,17 +628,21 @@ describe("CronService", () => { enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + onEvent: events.onEvent, }); await cron.start(); vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); await vi.runOnlyPendingTimersAsync(); + await events.waitFor( + (evt) => evt.jobId === "job-1" && evt.action === "finished" && evt.status === "skipped", + ); expect(enqueueSystemEvent).not.toHaveBeenCalled(); expect(requestHeartbeatNow).not.toHaveBeenCalled(); - const jobs = await waitForJobs(cron, (items) => items[0]?.state.lastStatus === "skipped"); + const jobs = await cron.list({ includeDisabled: true }); expect(jobs[0]?.state.lastStatus).toBe("skipped"); expect(jobs[0]?.state.lastError).toMatch(/main job requires/i); diff --git a/src/docker-setup.test.ts b/src/docker-setup.test.ts index 4f77f261134..a0ff66350a4 100644 --- a/src/docker-setup.test.ts +++ b/src/docker-setup.test.ts @@ -62,15 +62,26 @@ function createEnv( sandbox: DockerSetupSandbox, overrides: Record = {}, ): NodeJS.ProcessEnv { - return { - ...process.env, + const env: NodeJS.ProcessEnv = { PATH: `${sandbox.binDir}:${process.env.PATH ?? ""}`, + HOME: process.env.HOME ?? sandbox.rootDir, + LANG: process.env.LANG, + LC_ALL: process.env.LC_ALL, + TMPDIR: process.env.TMPDIR, DOCKER_STUB_LOG: sandbox.logPath, OPENCLAW_GATEWAY_TOKEN: "test-token", OPENCLAW_CONFIG_DIR: join(sandbox.rootDir, "config"), OPENCLAW_WORKSPACE_DIR: join(sandbox.rootDir, "openclaw"), - ...overrides, }; + + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) { + delete env[key]; + } else { + env[key] = value; + } + } + return env; } function resolveBashForCompatCheck(): string | null { diff --git a/src/infra/path-env.test.ts b/src/infra/path-env.test.ts index 0f3aed6368f..924406cca40 100644 --- a/src/infra/path-env.test.ts +++ b/src/infra/path-env.test.ts @@ -8,6 +8,12 @@ describe("ensureOpenClawCliOnPath", () => { let fixtureRoot = ""; let fixtureCount = 0; + async function makeTmpDir(): Promise { + const tmp = path.join(fixtureRoot, `case-${fixtureCount++}`); + await fs.mkdir(tmp); + return tmp; + } + beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-path-")); }); @@ -17,9 +23,9 @@ describe("ensureOpenClawCliOnPath", () => { }); it("prepends the bundled app bin dir when a sibling openclaw exists", async () => { - const tmp = path.join(fixtureRoot, `case-${fixtureCount++}`); + const tmp = await makeTmpDir(); const appBinDir = path.join(tmp, "AppBin"); - await fs.mkdir(appBinDir, { recursive: true }); + 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); @@ -71,13 +77,13 @@ describe("ensureOpenClawCliOnPath", () => { }); it("prepends mise shims when available", async () => { - const tmp = path.join(fixtureRoot, `case-${fixtureCount++}`); + 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, { recursive: true }); + 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); @@ -118,12 +124,12 @@ describe("ensureOpenClawCliOnPath", () => { }); it("only appends project-local node_modules/.bin when explicitly enabled", async () => { - const tmp = path.join(fixtureRoot, `case-${fixtureCount++}`); + 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, { recursive: true }); + 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); @@ -172,7 +178,7 @@ describe("ensureOpenClawCliOnPath", () => { }); it("prepends Linuxbrew dirs when present", async () => { - const tmp = path.join(fixtureRoot, `case-${fixtureCount++}`); + const tmp = await makeTmpDir(); const originalPath = process.env.PATH; const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED; const originalHomebrewPrefix = process.env.HOMEBREW_PREFIX; @@ -180,7 +186,7 @@ describe("ensureOpenClawCliOnPath", () => { const originalXdgBinHome = process.env.XDG_BIN_HOME; try { const execDir = path.join(tmp, "exec"); - await fs.mkdir(execDir, { recursive: true }); + await fs.mkdir(execDir); const linuxbrewBin = path.join(tmp, ".linuxbrew", "bin"); const linuxbrewSbin = path.join(tmp, ".linuxbrew", "sbin"); diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index 6e3d174ea86..64dcc25f95b 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -47,7 +47,7 @@ describe("update-startup", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-17T10:00:00Z")); tempDir = path.join(suiteRoot, `case-${++suiteCase}`); - await fs.mkdir(tempDir, { recursive: true }); + await fs.mkdir(tempDir); hadStateDir = Object.prototype.hasOwnProperty.call(process.env, "OPENCLAW_STATE_DIR"); prevStateDir = process.env.OPENCLAW_STATE_DIR; process.env.OPENCLAW_STATE_DIR = tempDir; @@ -87,7 +87,6 @@ describe("update-startup", () => { } else { delete process.env.VITEST; } - await fs.rm(tempDir, { recursive: true, force: true }); }); afterAll(async () => { diff --git a/src/media/server.test.ts b/src/media/server.test.ts index fda4c0486ac..ffda31f76da 100644 --- a/src/media/server.test.ts +++ b/src/media/server.test.ts @@ -1,9 +1,10 @@ import type { AddressInfo } from "node:net"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -const MEDIA_DIR = path.join(process.cwd(), "tmp-media-test"); +let MEDIA_DIR = ""; const cleanOldMedia = vi.fn().mockResolvedValue(undefined); vi.mock("./store.js", async (importOriginal) => { @@ -18,39 +19,41 @@ vi.mock("./store.js", async (importOriginal) => { const { startMediaServer } = await import("./server.js"); const { MEDIA_MAX_BYTES } = await import("./store.js"); -const waitForFileRemoval = async (file: string, timeoutMs = 200) => { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { +async function waitForFileRemoval(filePath: string, maxTicks = 1000) { + for (let tick = 0; tick < maxTicks; tick += 1) { try { - await fs.stat(file); + await fs.stat(filePath); } catch { return; } - await new Promise((resolve) => setTimeout(resolve, 5)); + await new Promise((resolve) => setImmediate(resolve)); } - throw new Error(`timed out waiting for ${file} removal`); -}; + throw new Error(`timed out waiting for ${filePath} removal`); +} describe("media server", () => { + let server: Awaited>; + let port = 0; + beforeAll(async () => { - await fs.rm(MEDIA_DIR, { recursive: true, force: true }); - await fs.mkdir(MEDIA_DIR, { recursive: true }); + MEDIA_DIR = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-test-")); + server = await startMediaServer(0, 1_000); + port = (server.address() as AddressInfo).port; }); afterAll(async () => { + await new Promise((r) => server.close(r)); await fs.rm(MEDIA_DIR, { recursive: true, force: true }); + MEDIA_DIR = ""; }); it("serves media and cleans up after send", async () => { const file = path.join(MEDIA_DIR, "file1"); await fs.writeFile(file, "hello"); - const server = await startMediaServer(0, 5_000); - const port = (server.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${port}/media/file1`); expect(res.status).toBe(200); expect(await res.text()).toBe("hello"); await waitForFileRemoval(file); - await new Promise((r) => server.close(r)); }); it("expires old media", async () => { @@ -58,22 +61,16 @@ describe("media server", () => { await fs.writeFile(file, "stale"); const past = Date.now() - 10_000; await fs.utimes(file, past / 1000, past / 1000); - const server = await startMediaServer(0, 1_000); - const port = (server.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${port}/media/old`); expect(res.status).toBe(410); await expect(fs.stat(file)).rejects.toThrow(); - await new Promise((r) => server.close(r)); }); it("blocks path traversal attempts", async () => { - const server = await startMediaServer(0, 5_000); - const port = (server.address() as AddressInfo).port; // URL-encoded "../" to bypass client-side path normalization const res = await fetch(`http://127.0.0.1:${port}/media/%2e%2e%2fpackage.json`); expect(res.status).toBe(400); expect(await res.text()).toBe("invalid path"); - await new Promise((r) => server.close(r)); }); it("blocks symlink escaping outside media dir", async () => { @@ -81,34 +78,25 @@ describe("media server", () => { const link = path.join(MEDIA_DIR, "link-out"); await fs.symlink(target, link); - const server = await startMediaServer(0, 5_000); - const port = (server.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${port}/media/link-out`); expect(res.status).toBe(400); expect(await res.text()).toBe("invalid path"); - await new Promise((r) => server.close(r)); }); it("rejects invalid media ids", async () => { const file = path.join(MEDIA_DIR, "file2"); await fs.writeFile(file, "hello"); - const server = await startMediaServer(0, 5_000); - const port = (server.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${port}/media/invalid%20id`); expect(res.status).toBe(400); expect(await res.text()).toBe("invalid path"); - await new Promise((r) => server.close(r)); }); it("rejects oversized media files", async () => { const file = path.join(MEDIA_DIR, "big"); await fs.writeFile(file, ""); await fs.truncate(file, MEDIA_MAX_BYTES + 1); - const server = await startMediaServer(0, 5_000); - const port = (server.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${port}/media/big`); expect(res.status).toBe(413); expect(await res.text()).toBe("too large"); - await new Promise((r) => server.close(r)); }); }); diff --git a/src/media/server.ts b/src/media/server.ts index 62dd5ef2d7d..31d956f74b0 100644 --- a/src/media/server.ts +++ b/src/media/server.ts @@ -63,9 +63,15 @@ export function attachMediaRoutes( res.send(data); // best-effort single-use cleanup after response ends res.on("finish", () => { - setTimeout(() => { - fs.rm(realPath).catch(() => {}); - }, 50); + const cleanup = () => { + void fs.rm(realPath).catch(() => {}); + }; + // Tests should not pay for time-based cleanup delays. + if (process.env.VITEST || process.env.NODE_ENV === "test") { + queueMicrotask(cleanup); + return; + } + setTimeout(cleanup, 50); }); } catch (err) { if (err instanceof SafeOpenError) { diff --git a/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts b/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts index e349d993c24..4e2023da6a2 100644 --- a/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts +++ b/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts @@ -2,7 +2,7 @@ import type { App } from "@slack/bolt"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; import type { RuntimeEnv } from "../../../runtime.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; @@ -14,6 +14,29 @@ import { createSlackMonitorContext } from "../context.js"; import { prepareSlackMessage } from "./prepare.js"; describe("slack prepareSlackMessage inbound contract", () => { + let fixtureRoot = ""; + let caseId = 0; + + function makeTmpStorePath() { + if (!fixtureRoot) { + throw new Error("fixtureRoot missing"); + } + const dir = path.join(fixtureRoot, `case-${caseId++}`); + fs.mkdirSync(dir); + return { dir, storePath: path.join(dir, "sessions.json") }; + } + + beforeAll(() => { + fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-thread-")); + }); + + afterAll(() => { + if (fixtureRoot) { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + fixtureRoot = ""; + } + }); + function createDefaultSlackCtx() { const slackCtx = createSlackMonitorContext({ cfg: { @@ -301,119 +324,109 @@ describe("slack prepareSlackMessage inbound contract", () => { }); it("marks first thread turn and injects thread history for a new thread session", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-thread-")); - const storePath = path.join(tmpDir, "sessions.json"); - try { - const replies = vi - .fn() - .mockResolvedValueOnce({ - messages: [{ text: "starter", user: "U2", ts: "100.000" }], - }) - .mockResolvedValueOnce({ - messages: [ - { text: "starter", user: "U2", ts: "100.000" }, - { text: "assistant reply", bot_id: "B1", ts: "100.500" }, - { text: "follow-up question", user: "U1", ts: "100.800" }, - { text: "current message", user: "U1", ts: "101.000" }, - ], - response_metadata: { next_cursor: "" }, - }); - const slackCtx = createThreadSlackCtx({ - cfg: { - session: { store: storePath }, - channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } }, - } as OpenClawConfig, - replies, + const { storePath } = makeTmpStorePath(); + const replies = vi + .fn() + .mockResolvedValueOnce({ + messages: [{ text: "starter", user: "U2", ts: "100.000" }], + }) + .mockResolvedValueOnce({ + messages: [ + { text: "starter", user: "U2", ts: "100.000" }, + { text: "assistant reply", bot_id: "B1", ts: "100.500" }, + { text: "follow-up question", user: "U1", ts: "100.800" }, + { text: "current message", user: "U1", ts: "101.000" }, + ], + response_metadata: { next_cursor: "" }, }); - slackCtx.resolveUserName = async (id: string) => ({ - name: id === "U1" ? "Alice" : "Bob", - }); - slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" }); + const slackCtx = createThreadSlackCtx({ + cfg: { + session: { store: storePath }, + channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } }, + } as OpenClawConfig, + replies, + }); + slackCtx.resolveUserName = async (id: string) => ({ + name: id === "U1" ? "Alice" : "Bob", + }); + slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" }); - const account = createThreadAccount(); + const account = createThreadAccount(); - const message: SlackMessageEvent = { - channel: "C123", - channel_type: "channel", - user: "U1", - text: "current message", - ts: "101.000", - thread_ts: "100.000", - } as SlackMessageEvent; + const message: SlackMessageEvent = { + channel: "C123", + channel_type: "channel", + user: "U1", + text: "current message", + ts: "101.000", + thread_ts: "100.000", + } as SlackMessageEvent; - const prepared = await prepareSlackMessage({ - ctx: slackCtx, - account, - message, - opts: { source: "message" }, - }); + const prepared = await prepareSlackMessage({ + ctx: slackCtx, + account, + message, + opts: { source: "message" }, + }); - expect(prepared).toBeTruthy(); - expect(prepared!.ctxPayload.IsFirstThreadTurn).toBe(true); - expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("assistant reply"); - expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("follow-up question"); - expect(prepared!.ctxPayload.ThreadHistoryBody).not.toContain("current message"); - expect(replies).toHaveBeenCalledTimes(2); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } + expect(prepared).toBeTruthy(); + expect(prepared!.ctxPayload.IsFirstThreadTurn).toBe(true); + expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("assistant reply"); + expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("follow-up question"); + expect(prepared!.ctxPayload.ThreadHistoryBody).not.toContain("current message"); + expect(replies).toHaveBeenCalledTimes(2); }); it("does not mark first thread turn when thread session already exists in store", async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-thread-")); - const storePath = path.join(tmpDir, "sessions.json"); - try { - const cfg = { - session: { store: storePath }, - channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } }, - } as OpenClawConfig; - const route = resolveAgentRoute({ - cfg, - channel: "slack", - accountId: "default", - teamId: "T1", - peer: { kind: "channel", id: "C123" }, - }); - const threadKeys = resolveThreadSessionKeys({ - baseSessionKey: route.sessionKey, - threadId: "200.000", - }); - fs.writeFileSync( - storePath, - JSON.stringify({ [threadKeys.sessionKey]: { updatedAt: Date.now() } }, null, 2), - ); + const { storePath } = makeTmpStorePath(); + const cfg = { + session: { store: storePath }, + channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } }, + } as OpenClawConfig; + const route = resolveAgentRoute({ + cfg, + channel: "slack", + accountId: "default", + teamId: "T1", + peer: { kind: "channel", id: "C123" }, + }); + const threadKeys = resolveThreadSessionKeys({ + baseSessionKey: route.sessionKey, + threadId: "200.000", + }); + fs.writeFileSync( + storePath, + JSON.stringify({ [threadKeys.sessionKey]: { updatedAt: Date.now() } }, null, 2), + ); - const replies = vi.fn().mockResolvedValue({ - messages: [{ text: "starter", user: "U2", ts: "200.000" }], - }); - const slackCtx = createThreadSlackCtx({ cfg, replies }); - slackCtx.resolveUserName = async () => ({ name: "Alice" }); - slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" }); + const replies = vi.fn().mockResolvedValue({ + messages: [{ text: "starter", user: "U2", ts: "200.000" }], + }); + const slackCtx = createThreadSlackCtx({ cfg, replies }); + slackCtx.resolveUserName = async () => ({ name: "Alice" }); + slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" }); - const account = createThreadAccount(); + const account = createThreadAccount(); - const message: SlackMessageEvent = { - channel: "C123", - channel_type: "channel", - user: "U1", - text: "reply in old thread", - ts: "201.000", - thread_ts: "200.000", - } as SlackMessageEvent; + const message: SlackMessageEvent = { + channel: "C123", + channel_type: "channel", + user: "U1", + text: "reply in old thread", + ts: "201.000", + thread_ts: "200.000", + } as SlackMessageEvent; - const prepared = await prepareSlackMessage({ - ctx: slackCtx, - account, - message, - opts: { source: "message" }, - }); + const prepared = await prepareSlackMessage({ + ctx: slackCtx, + account, + message, + opts: { source: "message" }, + }); - expect(prepared).toBeTruthy(); - expect(prepared!.ctxPayload.IsFirstThreadTurn).toBeUndefined(); - expect(prepared!.ctxPayload.ThreadHistoryBody).toBeUndefined(); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } + expect(prepared).toBeTruthy(); + expect(prepared!.ctxPayload.IsFirstThreadTurn).toBeUndefined(); + expect(prepared!.ctxPayload.ThreadHistoryBody).toBeUndefined(); }); it("includes thread_ts and parent_user_id metadata in thread replies", async () => {