446 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|