test: dedupe cron and slack monitor test harness setup

This commit is contained in:
Peter Steinberger
2026-02-22 07:52:05 +00:00
parent 3d03375043
commit 7cf280805c
2 changed files with 56 additions and 99 deletions

View File

@@ -11,6 +11,12 @@ const noopLogger = {
error: vi.fn(), error: vi.fn(),
}; };
type IsolatedRunResult = {
status: "ok" | "error" | "skipped";
summary?: string;
error?: string;
};
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> { async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timeout: NodeJS.Timeout | undefined; let timeout: NodeJS.Timeout | undefined;
try { try {
@@ -48,6 +54,27 @@ async function makeStorePath() {
}; };
} }
function createDeferredIsolatedRun() {
let resolveRun: ((value: IsolatedRunResult) => void) | undefined;
let resolveRunStarted: (() => void) | undefined;
const runStarted = new Promise<void>((resolve) => {
resolveRunStarted = resolve;
});
const runIsolatedAgentJob = vi.fn(async () => {
resolveRunStarted?.();
return await new Promise<IsolatedRunResult>((resolve) => {
resolveRun = resolve;
});
});
return {
runIsolatedAgentJob,
runStarted,
completeRun: (result: IsolatedRunResult) => {
resolveRun?.(result);
},
};
}
describe("CronService read ops while job is running", () => { describe("CronService read ops while job is running", () => {
it("keeps list and status responsive during a long isolated run", async () => { it("keeps list and status responsive during a long isolated run", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -60,25 +87,7 @@ describe("CronService read ops while job is running", () => {
resolveFinished = resolve; resolveFinished = resolve;
}); });
let resolveRun: const isolatedRun = createDeferredIsolatedRun();
| ((value: { status: "ok" | "error" | "skipped"; summary?: string; error?: string }) => void)
| undefined;
let resolveRunStarted: (() => void) | undefined;
const runStarted = new Promise<void>((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 cron = new CronService({ const cron = new CronService({
storePath: store.storePath, storePath: store.storePath,
@@ -86,7 +95,7 @@ describe("CronService read ops while job is running", () => {
log: noopLogger, log: noopLogger,
enqueueSystemEvent, enqueueSystemEvent,
requestHeartbeatNow, requestHeartbeatNow,
runIsolatedAgentJob, runIsolatedAgentJob: isolatedRun.runIsolatedAgentJob,
onEvent: (evt) => { onEvent: (evt) => {
if (evt.action === "finished" && evt.status === "ok") { if (evt.action === "finished" && evt.status === "ok") {
resolveFinished?.(); resolveFinished?.();
@@ -115,8 +124,8 @@ describe("CronService read ops while job is running", () => {
vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z"));
await vi.runOnlyPendingTimersAsync(); await vi.runOnlyPendingTimersAsync();
await runStarted; await isolatedRun.runStarted;
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); expect(isolatedRun.runIsolatedAgentJob).toHaveBeenCalledTimes(1);
await expect(cron.list({ includeDisabled: true })).resolves.toBeTypeOf("object"); await expect(cron.list({ includeDisabled: true })).resolves.toBeTypeOf("object");
await expect(cron.status()).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 }); const running = await cron.list({ includeDisabled: true });
expect(running[0]?.state.runningAtMs).toBeTypeOf("number"); 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. // Wait until the scheduler writes the result back to the store.
await finished; await finished;
@@ -182,24 +191,7 @@ describe("CronService read ops while job is running", () => {
"utf-8", "utf-8",
); );
let resolveRun: const isolatedRun = createDeferredIsolatedRun();
| ((value: { status: "ok" | "error" | "skipped"; summary?: string; error?: string }) => void)
| undefined;
let resolveRunStarted: (() => void) | undefined;
const runStarted = new Promise<void>((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 cron = new CronService({ const cron = new CronService({
storePath: store.storePath, storePath: store.storePath,
@@ -208,12 +200,13 @@ describe("CronService read ops while job is running", () => {
nowMs: () => nowMs, nowMs: () => nowMs,
enqueueSystemEvent, enqueueSystemEvent,
requestHeartbeatNow, requestHeartbeatNow,
runIsolatedAgentJob, runIsolatedAgentJob: isolatedRun.runIsolatedAgentJob,
}); });
try { try {
const startPromise = cron.start(); const startPromise = cron.start();
await runStarted; await isolatedRun.runStarted;
expect(isolatedRun.runIsolatedAgentJob).toHaveBeenCalledTimes(1);
await expect( await expect(
withTimeout(cron.list({ includeDisabled: true }), 300, "cron.list during startup"), 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 }), expect.objectContaining({ enabled: true, storePath: store.storePath }),
); );
resolveRun?.({ status: "ok", summary: "done" }); isolatedRun.completeRun({ status: "ok", summary: "done" });
await startPromise; await startPromise;
const jobs = await cron.list({ includeDisabled: true }); const jobs = await cron.list({ includeDisabled: true });

View File

@@ -216,6 +216,7 @@ function createArgMenusHarness() {
const commands = new Map<string, (args: unknown) => Promise<void>>(); const commands = new Map<string, (args: unknown) => Promise<void>>();
const actions = new Map<string, (args: unknown) => Promise<void>>(); const actions = new Map<string, (args: unknown) => Promise<void>>();
const options = new Map<string, (args: unknown) => Promise<void>>(); const options = new Map<string, (args: unknown) => Promise<void>>();
const optionsReceiverContexts: unknown[] = [];
const postEphemeral = vi.fn().mockResolvedValue({ ok: true }); const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
const app = { const app = {
@@ -226,7 +227,8 @@ function createArgMenusHarness() {
action: (id: string, handler: (args: unknown) => Promise<void>) => { action: (id: string, handler: (args: unknown) => Promise<void>) => {
actions.set(id, handler); actions.set(id, handler);
}, },
options: (id: string, handler: (args: unknown) => Promise<void>) => { options: function (this: unknown, id: string, handler: (args: unknown) => Promise<void>) {
optionsReceiverContexts.push(this);
options.set(id, handler); options.set(id, handler);
}, },
}; };
@@ -264,7 +266,16 @@ function createArgMenusHarness() {
config: { commands: { native: true, nativeSkills: false } }, config: { commands: { native: true, nativeSkills: false } },
} as unknown; } as unknown;
return { commands, actions, options, postEphemeral, ctx, account }; return {
commands,
actions,
options,
optionsReceiverContexts,
postEphemeral,
ctx,
account,
app,
};
} }
function requireHandler( function requireHandler(
@@ -379,59 +390,12 @@ describe("Slack native command argument menus", () => {
}); });
it("registers options handlers without losing app receiver binding", async () => { it("registers options handlers without losing app receiver binding", async () => {
const commands = new Map<string, (args: unknown) => Promise<void>>(); const testHarness = createArgMenusHarness();
const actions = new Map<string, (args: unknown) => Promise<void>>(); await registerCommands(testHarness.ctx, testHarness.account);
const options = new Map<string, (args: unknown) => Promise<void>>(); expect(testHarness.commands.size).toBeGreaterThan(0);
const postEphemeral = vi.fn().mockResolvedValue({ ok: true }); expect(testHarness.actions.has("openclaw_cmdarg")).toBe(true);
const app = { expect(testHarness.options.has("openclaw_cmdarg")).toBe(true);
client: { chat: { postEphemeral } }, expect(testHarness.optionsReceiverContexts[0]).toBe(testHarness.app);
command: (name: string, handler: (args: unknown) => Promise<void>) => {
commands.set(name, handler);
},
action: (id: string, handler: (args: unknown) => Promise<void>) => {
actions.set(id, handler);
},
options: function (this: unknown, id: string, handler: (args: unknown) => Promise<void>) {
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);
}); });
it("shows a button menu when required args are omitted", async () => { it("shows a button menu when required args are omitted", async () => {