Files
NianToB/tests/unit/cron-routes.test.ts

446 lines
14 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IncomingMessage, ServerResponse } from 'http';
const parseJsonBodyMock = vi.fn();
const sendJsonMock = vi.fn();
const { readFileMock } = vi.hoisted(() => ({
readFileMock: vi.fn(),
}));
vi.mock('@electron/api/route-utils', () => ({
parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args),
sendJson: (...args: unknown[]) => sendJsonMock(...args),
}));
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');
return {
...actual,
readFile: readFileMock,
default: {
...actual,
readFile: readFileMock,
},
};
});
describe('handleCronRoutes', () => {
beforeEach(() => {
vi.resetAllMocks();
readFileMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
it('creates cron jobs with external delivery configuration', async () => {
parseJsonBodyMock.mockResolvedValue({
name: 'Weather delivery',
message: 'Summarize today',
schedule: '0 9 * * *',
delivery: {
mode: 'announce',
channel: 'feishu',
to: 'user:ou_weather',
},
enabled: true,
});
const rpc = vi.fn().mockResolvedValue({
id: 'job-1',
name: 'Weather delivery',
enabled: true,
createdAtMs: 1,
updatedAtMs: 2,
schedule: { kind: 'cron', expr: '0 9 * * *' },
payload: { kind: 'agentTurn', message: 'Summarize today' },
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' },
state: {},
});
const { handleCronRoutes } = await import('@electron/api/routes/cron');
const handled = await handleCronRoutes(
{ method: 'POST' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:13210/api/cron/jobs'),
{
gatewayManager: { rpc },
} as never,
);
expect(handled).toBe(true);
expect(rpc).toHaveBeenCalledWith('cron.add', expect.objectContaining({
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' },
}));
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({
id: 'job-1',
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' },
}),
);
});
it('updates cron jobs with transformed payload and delivery fields', async () => {
parseJsonBodyMock.mockResolvedValue({
message: 'Updated prompt',
delivery: {
mode: 'announce',
channel: 'feishu',
to: 'user:ou_next',
},
});
const rpc = vi.fn().mockResolvedValue({
id: 'job-2',
name: 'Updated job',
enabled: true,
createdAtMs: 1,
updatedAtMs: 3,
schedule: { kind: 'cron', expr: '0 9 * * *' },
payload: { kind: 'agentTurn', message: 'Updated prompt' },
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' },
state: {},
});
const { handleCronRoutes } = await import('@electron/api/routes/cron');
await handleCronRoutes(
{ method: 'PUT' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:13210/api/cron/jobs/job-2'),
{
gatewayManager: { rpc },
} as never,
);
expect(rpc).toHaveBeenCalledWith('cron.update', {
id: 'job-2',
patch: {
payload: { kind: 'agentTurn', message: 'Updated prompt' },
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' },
},
});
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({
id: 'job-2',
message: 'Updated prompt',
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' },
}),
);
});
it('passes through delivery.accountId for multi-account cron jobs', async () => {
parseJsonBodyMock.mockResolvedValue({
delivery: {
mode: 'announce',
channel: 'feishu',
to: 'user:ou_owner',
accountId: 'feishu-0d009958',
},
});
const rpc = vi.fn().mockResolvedValue({
id: 'job-account',
name: 'Account job',
enabled: true,
createdAtMs: 1,
updatedAtMs: 4,
schedule: { kind: 'cron', expr: '0 9 * * *' },
payload: { kind: 'agentTurn', message: 'Prompt' },
delivery: { mode: 'announce', channel: 'feishu', accountId: 'feishu-0d009958', to: 'user:ou_owner' },
state: {},
});
const { handleCronRoutes } = await import('@electron/api/routes/cron');
await handleCronRoutes(
{ method: 'PUT' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:13210/api/cron/jobs/job-account'),
{
gatewayManager: { rpc },
} as never,
);
expect(rpc).toHaveBeenCalledWith('cron.update', {
id: 'job-account',
patch: {
delivery: {
mode: 'announce',
channel: 'feishu',
to: 'user:ou_owner',
accountId: 'feishu-0d009958',
},
},
});
});
it('allows WeChat scheduled delivery', async () => {
parseJsonBodyMock.mockResolvedValue({
name: 'WeChat delivery',
message: 'Send update',
schedule: '0 10 * * *',
delivery: {
mode: 'announce',
channel: 'wechat',
to: 'wechat:wxid_target',
accountId: 'wechat-bot',
},
enabled: true,
});
const rpc = vi.fn().mockResolvedValue({
id: 'job-wechat',
name: 'WeChat delivery',
enabled: true,
createdAtMs: 1,
updatedAtMs: 2,
schedule: { kind: 'cron', expr: '0 10 * * *' },
payload: { kind: 'agentTurn', message: 'Send update' },
delivery: { mode: 'announce', channel: 'openclaw-weixin', to: 'wechat:wxid_target', accountId: 'wechat-bot' },
state: {},
});
const { handleCronRoutes } = await import('@electron/api/routes/cron');
const handled = await handleCronRoutes(
{ method: 'POST' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:13210/api/cron/jobs'),
{
gatewayManager: { rpc },
} as never,
);
expect(handled).toBe(true);
expect(rpc).toHaveBeenCalledWith('cron.add', expect.objectContaining({
delivery: expect.objectContaining({ mode: 'announce', to: 'wechat:wxid_target' }),
}));
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({
id: 'job-wechat',
}),
);
});
it('builds failed cron run fallback messages as visible assistant messages', async () => {
const { buildCronSessionFallbackMessages } = await import('@electron/api/routes/cron');
const messages = buildCronSessionFallbackMessages({
sessionKey: 'agent:main:cron:job-failed',
runs: [{
jobId: 'job-failed',
status: 'error',
error: '模型超时',
ts: 1773281732751,
}],
limit: 20,
});
expect(messages).toContainEqual(expect.objectContaining({
id: 'cron-run-1773281732751',
role: 'assistant',
content: 'Run failed: 模型超时',
isError: true,
}));
});
it('builds cron fallback query and running messages as visible chat messages', async () => {
const { buildCronSessionFallbackMessages } = await import('@electron/api/routes/cron');
const messages = buildCronSessionFallbackMessages({
sessionKey: 'agent:main:cron:job-running',
job: {
name: '写日报',
payload: { kind: 'agentTurn', message: '生成今天的经营日报' },
state: { runningAtMs: 1778650080011 },
},
runs: [],
limit: 20,
});
expect(messages).toContainEqual(expect.objectContaining({
id: 'cron-query-job-running-1778650080011',
role: 'user',
content: '生成今天的经营日报',
}));
expect(messages).toContainEqual(expect.objectContaining({
id: 'cron-running-job-running',
role: 'assistant',
content: '任务正在执行,完成后会自动在这里显示结果。',
}));
});
it('reconciles a failed cron state when the run trajectory finished successfully', async () => {
const rpc = vi.fn().mockResolvedValue({
jobs: [{
id: 'job-recovered',
name: 'Recovered job',
enabled: true,
createdAtMs: 1,
updatedAtMs: 2,
schedule: { kind: 'cron', expr: '0 9 * * *' },
payload: { kind: 'agentTurn', message: 'Create a document' },
delivery: { mode: 'none' },
agentId: 'main',
state: {
lastRunAtMs: 1778650080011,
lastStatus: 'error',
lastError: 'Edit failed',
lastDurationMs: 77388,
},
}],
});
readFileMock.mockImplementation(async (path: unknown) => {
const textPath = String(path);
if (textPath.endsWith('/cron/runs/job-recovered.jsonl')) {
return JSON.stringify({
ts: 1778650157404,
jobId: 'job-recovered',
action: 'finished',
status: 'error',
error: 'Edit failed',
summary: 'Edit failed',
sessionId: 'run-1',
runAtMs: 1778650080011,
});
}
if (textPath.endsWith('/agents/main/sessions/run-1.trajectory.jsonl')) {
return JSON.stringify({
type: 'trace.artifacts',
data: {
finalStatus: 'success',
assistantTexts: ['Done. Saved to the desktop.'],
},
});
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const { handleCronRoutes } = await import('@electron/api/routes/cron');
const handled = await handleCronRoutes(
{ method: 'GET' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:13210/api/cron/jobs'),
{
gatewayManager: { rpc },
} as never,
);
expect(handled).toBe(true);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
[expect.objectContaining({
id: 'job-recovered',
lastRun: expect.objectContaining({
success: true,
reconciled: true,
warning: 'Edit failed',
}),
})],
);
const response = sendJsonMock.mock.calls.at(-1)?.[2] as Array<{ lastRun?: { error?: string } }>;
expect(response[0]?.lastRun?.error).toBeUndefined();
});
it('returns a successful trigger response when cron.run reports a stale tool error after recovery', async () => {
parseJsonBodyMock.mockResolvedValue({ id: 'job-recovered' });
const rpc = vi.fn()
.mockRejectedValueOnce(new Error('Edit failed'))
.mockResolvedValueOnce({
jobs: [{
id: 'job-recovered',
name: 'Recovered job',
enabled: true,
createdAtMs: 1,
updatedAtMs: 2,
schedule: { kind: 'cron', expr: '0 9 * * *' },
payload: { kind: 'agentTurn', message: 'Create a document' },
delivery: { mode: 'none' },
agentId: 'main',
state: {
lastRunAtMs: 1778650080011,
lastStatus: 'error',
lastError: 'Edit failed',
lastDurationMs: 77388,
},
}],
});
readFileMock.mockImplementation(async (path: unknown) => {
const textPath = String(path);
if (textPath.endsWith('/cron/runs/job-recovered.jsonl')) {
return JSON.stringify({
ts: 1778650157404,
jobId: 'job-recovered',
action: 'finished',
status: 'error',
error: 'Edit failed',
summary: 'Edit failed',
sessionId: 'run-1',
runAtMs: 1778650080011,
});
}
if (textPath.endsWith('/agents/main/sessions/run-1.trajectory.jsonl')) {
return JSON.stringify({
type: 'trace.artifacts',
data: {
finalStatus: 'success',
assistantTexts: ['Done. Saved to the desktop.'],
},
});
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const { handleCronRoutes } = await import('@electron/api/routes/cron');
const handled = await handleCronRoutes(
{ method: 'POST' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:13210/api/cron/trigger'),
{
gatewayManager: { rpc },
} as never,
);
expect(handled).toBe(true);
expect(rpc).toHaveBeenNthCalledWith(1, 'cron.run', { id: 'job-recovered', mode: 'force' });
expect(rpc).toHaveBeenNthCalledWith(2, 'cron.list', { includeDisabled: true }, 8000);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({
success: true,
recovered: true,
warning: 'Edit failed',
summary: 'Done. Saved to the desktop.',
triggerWarning: 'Error: Edit failed',
}),
);
});
it('uses recovered trajectory summaries for cron fallback messages', async () => {
const { buildCronSessionFallbackMessages } = await import('@electron/api/routes/cron');
const messages = buildCronSessionFallbackMessages({
sessionKey: 'agent:main:cron:job-recovered',
runs: [{
jobId: 'job-recovered',
status: 'error',
resolvedStatus: 'ok',
error: 'Edit failed',
resolvedSummary: 'Done. Saved to the desktop.',
ts: 1778650157404,
}],
limit: 20,
});
expect(messages).toContainEqual(expect.objectContaining({
id: 'cron-run-1778650157404',
role: 'assistant',
content: 'Done. Saved to the desktop.',
}));
expect(messages.find((message) => message.id === 'cron-run-1778650157404')?.isError).toBeUndefined();
});
});