diff --git a/src/cron/service.read-ops-nonblocking.test.ts b/src/cron/service.read-ops-nonblocking.test.ts index a749af09931..120061de448 100644 --- a/src/cron/service.read-ops-nonblocking.test.ts +++ b/src/cron/service.read-ops-nonblocking.test.ts @@ -11,6 +11,12 @@ const noopLogger = { error: vi.fn(), }; +type IsolatedRunResult = { + status: "ok" | "error" | "skipped"; + summary?: string; + error?: string; +}; + async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { let timeout: NodeJS.Timeout | undefined; try { @@ -48,6 +54,27 @@ async function makeStorePath() { }; } +function createDeferredIsolatedRun() { + let resolveRun: ((value: IsolatedRunResult) => void) | undefined; + let resolveRunStarted: (() => void) | undefined; + const runStarted = new Promise((resolve) => { + resolveRunStarted = resolve; + }); + const runIsolatedAgentJob = vi.fn(async () => { + resolveRunStarted?.(); + return await new Promise((resolve) => { + resolveRun = resolve; + }); + }); + return { + runIsolatedAgentJob, + runStarted, + completeRun: (result: IsolatedRunResult) => { + resolveRun?.(result); + }, + }; +} + describe("CronService read ops while job is running", () => { it("keeps list and status responsive during a long isolated run", async () => { vi.useFakeTimers(); @@ -60,25 +87,7 @@ describe("CronService read ops while job is running", () => { resolveFinished = resolve; }); - let resolveRun: - | ((value: { status: "ok" | "error" | "skipped"; summary?: string; error?: string }) => void) - | undefined; - - let resolveRunStarted: (() => void) | undefined; - const runStarted = new Promise((resolve) => { - resolveRunStarted = resolve; - }); - - const runIsolatedAgentJob = vi.fn(async () => { - resolveRunStarted?.(); - return await new Promise<{ - status: "ok" | "error" | "skipped"; - summary?: string; - error?: string; - }>((resolve) => { - resolveRun = resolve; - }); - }); + const isolatedRun = createDeferredIsolatedRun(); const cron = new CronService({ storePath: store.storePath, @@ -86,7 +95,7 @@ describe("CronService read ops while job is running", () => { log: noopLogger, enqueueSystemEvent, requestHeartbeatNow, - runIsolatedAgentJob, + runIsolatedAgentJob: isolatedRun.runIsolatedAgentJob, onEvent: (evt) => { if (evt.action === "finished" && evt.status === "ok") { resolveFinished?.(); @@ -115,8 +124,8 @@ describe("CronService read ops while job is running", () => { vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); await vi.runOnlyPendingTimersAsync(); - await runStarted; - expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); + await isolatedRun.runStarted; + expect(isolatedRun.runIsolatedAgentJob).toHaveBeenCalledTimes(1); await expect(cron.list({ includeDisabled: true })).resolves.toBeTypeOf("object"); await expect(cron.status()).resolves.toBeTypeOf("object"); @@ -124,7 +133,7 @@ describe("CronService read ops while job is running", () => { const running = await cron.list({ includeDisabled: true }); expect(running[0]?.state.runningAtMs).toBeTypeOf("number"); - resolveRun?.({ status: "ok", summary: "done" }); + isolatedRun.completeRun({ status: "ok", summary: "done" }); // Wait until the scheduler writes the result back to the store. await finished; @@ -182,24 +191,7 @@ describe("CronService read ops while job is running", () => { "utf-8", ); - let resolveRun: - | ((value: { status: "ok" | "error" | "skipped"; summary?: string; error?: string }) => void) - | undefined; - let resolveRunStarted: (() => void) | undefined; - const runStarted = new Promise((resolve) => { - resolveRunStarted = resolve; - }); - - const runIsolatedAgentJob = vi.fn(async () => { - resolveRunStarted?.(); - return await new Promise<{ - status: "ok" | "error" | "skipped"; - summary?: string; - error?: string; - }>((resolve) => { - resolveRun = resolve; - }); - }); + const isolatedRun = createDeferredIsolatedRun(); const cron = new CronService({ storePath: store.storePath, @@ -208,12 +200,13 @@ describe("CronService read ops while job is running", () => { nowMs: () => nowMs, enqueueSystemEvent, requestHeartbeatNow, - runIsolatedAgentJob, + runIsolatedAgentJob: isolatedRun.runIsolatedAgentJob, }); try { const startPromise = cron.start(); - await runStarted; + await isolatedRun.runStarted; + expect(isolatedRun.runIsolatedAgentJob).toHaveBeenCalledTimes(1); await expect( withTimeout(cron.list({ includeDisabled: true }), 300, "cron.list during startup"), @@ -222,7 +215,7 @@ describe("CronService read ops while job is running", () => { expect.objectContaining({ enabled: true, storePath: store.storePath }), ); - resolveRun?.({ status: "ok", summary: "done" }); + isolatedRun.completeRun({ status: "ok", summary: "done" }); await startPromise; const jobs = await cron.list({ includeDisabled: true }); diff --git a/src/slack/monitor/slash.test.ts b/src/slack/monitor/slash.test.ts index f265c6efb74..36cbb3b3ed0 100644 --- a/src/slack/monitor/slash.test.ts +++ b/src/slack/monitor/slash.test.ts @@ -216,6 +216,7 @@ function createArgMenusHarness() { const commands = new Map Promise>(); const actions = new Map Promise>(); const options = new Map Promise>(); + const optionsReceiverContexts: unknown[] = []; const postEphemeral = vi.fn().mockResolvedValue({ ok: true }); const app = { @@ -226,7 +227,8 @@ function createArgMenusHarness() { action: (id: string, handler: (args: unknown) => Promise) => { actions.set(id, handler); }, - options: (id: string, handler: (args: unknown) => Promise) => { + options: function (this: unknown, id: string, handler: (args: unknown) => Promise) { + optionsReceiverContexts.push(this); options.set(id, handler); }, }; @@ -264,7 +266,16 @@ function createArgMenusHarness() { config: { commands: { native: true, nativeSkills: false } }, } as unknown; - return { commands, actions, options, postEphemeral, ctx, account }; + return { + commands, + actions, + options, + optionsReceiverContexts, + postEphemeral, + ctx, + account, + app, + }; } function requireHandler( @@ -379,59 +390,12 @@ describe("Slack native command argument menus", () => { }); it("registers options handlers without losing app receiver binding", async () => { - const commands = new Map Promise>(); - const actions = new Map Promise>(); - const options = new Map Promise>(); - const postEphemeral = vi.fn().mockResolvedValue({ ok: true }); - const app = { - client: { chat: { postEphemeral } }, - command: (name: string, handler: (args: unknown) => Promise) => { - commands.set(name, handler); - }, - action: (id: string, handler: (args: unknown) => Promise) => { - actions.set(id, handler); - }, - options: function (this: unknown, id: string, handler: (args: unknown) => Promise) { - expect(this).toBe(app); - options.set(id, handler); - }, - }; - const ctx = { - cfg: { commands: { native: true, nativeSkills: false } }, - runtime: {}, - botToken: "bot-token", - botUserId: "bot", - teamId: "T1", - allowFrom: ["*"], - dmEnabled: true, - dmPolicy: "open", - groupDmEnabled: false, - groupDmChannels: [], - defaultRequireMention: true, - groupPolicy: "open", - useAccessGroups: false, - channelsConfig: undefined, - slashCommand: { - enabled: true, - name: "openclaw", - ephemeral: true, - sessionPrefix: "slack:slash", - }, - textLimit: 4000, - app, - isChannelAllowed: () => true, - resolveChannelName: async () => ({ name: "dm", type: "im" }), - resolveUserName: async () => ({ name: "Ada" }), - } as unknown; - const account = { - accountId: "acct", - config: { commands: { native: true, nativeSkills: false } }, - } as unknown; - - await registerCommands(ctx, account); - expect(commands.size).toBeGreaterThan(0); - expect(actions.has("openclaw_cmdarg")).toBe(true); - expect(options.has("openclaw_cmdarg")).toBe(true); + const testHarness = createArgMenusHarness(); + await registerCommands(testHarness.ctx, testHarness.account); + expect(testHarness.commands.size).toBeGreaterThan(0); + expect(testHarness.actions.has("openclaw_cmdarg")).toBe(true); + expect(testHarness.options.has("openclaw_cmdarg")).toBe(true); + expect(testHarness.optionsReceiverContexts[0]).toBe(testHarness.app); }); it("shows a button menu when required args are omitted", async () => {