mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
503 lines
17 KiB
TypeScript
503 lines
17 KiB
TypeScript
// @vitest-environment jsdom
|
||
/**
|
||
* @license
|
||
* Copyright 2025 Qwen Team
|
||
* SPDX-License-Identifier: Apache-2.0
|
||
*/
|
||
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { act } from 'react';
|
||
import { createRoot, type Root } from 'react-dom/client';
|
||
|
||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||
|
||
if (!Element.prototype.scrollIntoView) {
|
||
Element.prototype.scrollIntoView = () => {};
|
||
}
|
||
|
||
interface MockTask {
|
||
id: string;
|
||
name: string | null;
|
||
cron: string;
|
||
prompt: string;
|
||
mentions?: Array<{
|
||
kind: 'skill' | 'mcp' | 'extension' | 'file';
|
||
id: string;
|
||
label?: string;
|
||
value?: string;
|
||
serialized: string;
|
||
}>;
|
||
recurring: boolean;
|
||
enabled: boolean;
|
||
createdAt: number;
|
||
lastFiredAt: number | null;
|
||
nextRunAt: number | null;
|
||
sessionId: string | null;
|
||
runs: Array<{ at: number; kind?: 'scheduled' | 'catch-up' }>;
|
||
}
|
||
|
||
const { actions } = vi.hoisted(() => ({
|
||
actions: {
|
||
listScheduledTasks: vi.fn(),
|
||
createScheduledTask: vi.fn(),
|
||
updateScheduledTask: vi.fn(),
|
||
runScheduledTask: vi.fn(),
|
||
deleteScheduledTask: vi.fn(),
|
||
},
|
||
}));
|
||
|
||
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
|
||
useWorkspaceActions: () => actions,
|
||
useOptionalWorkspace: () => ({ actions }),
|
||
}));
|
||
|
||
const { ScheduledTasksDialog } = await import('./ScheduledTasksDialog');
|
||
const { I18nProvider } = await import('../../i18n');
|
||
|
||
let container: HTMLDivElement | null = null;
|
||
let root: Root | null = null;
|
||
|
||
async function mount(
|
||
tasks: MockTask[],
|
||
opts: {
|
||
onOpenSession?: (sessionId: string) => void;
|
||
onRunPrompt?: (
|
||
prompt: string,
|
||
sessionId: string | null,
|
||
) => void | Promise<void>;
|
||
onError?: (error: unknown, message: string) => void;
|
||
} = {},
|
||
) {
|
||
actions.listScheduledTasks.mockResolvedValue(tasks);
|
||
actions.updateScheduledTask.mockResolvedValue(tasks[0]);
|
||
actions.runScheduledTask.mockResolvedValue(tasks[0]);
|
||
container = document.createElement('div');
|
||
document.body.appendChild(container);
|
||
root = createRoot(container);
|
||
await act(async () => {
|
||
root!.render(
|
||
<I18nProvider language="en">
|
||
<ScheduledTasksDialog
|
||
skills={[]}
|
||
onRunPrompt={opts.onRunPrompt ?? vi.fn()}
|
||
onCreateViaChat={vi.fn()}
|
||
onOpenSession={opts.onOpenSession}
|
||
onError={opts.onError ?? vi.fn()}
|
||
/>
|
||
</I18nProvider>,
|
||
);
|
||
});
|
||
await flush();
|
||
}
|
||
|
||
// Flush the async list load (and any post-action reload) so state settles.
|
||
async function flush() {
|
||
await act(async () => {
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
}
|
||
|
||
function click(el: Element | null | undefined) {
|
||
if (!el) throw new Error('click target not found');
|
||
act(() => {
|
||
el.dispatchEvent(
|
||
new MouseEvent('click', { bubbles: true, cancelable: true }),
|
||
);
|
||
});
|
||
}
|
||
|
||
function findButton(label: string): HTMLButtonElement | undefined {
|
||
return Array.from(document.querySelectorAll('button')).find(
|
||
(b) => b.textContent?.trim() === label,
|
||
);
|
||
}
|
||
|
||
afterEach(() => {
|
||
act(() => root?.unmount());
|
||
container?.remove();
|
||
root = null;
|
||
container = null;
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
const baseTask = (over: Partial<MockTask>): MockTask => ({
|
||
id: 't1',
|
||
name: 'Digest',
|
||
cron: '30 12 * * 1-5',
|
||
prompt: 'summarize the day',
|
||
recurring: true,
|
||
enabled: true,
|
||
createdAt: 1_700_000_000_000,
|
||
lastFiredAt: null,
|
||
nextRunAt: null,
|
||
sessionId: null,
|
||
runs: [],
|
||
...over,
|
||
});
|
||
|
||
describe('ScheduledTasksDialog editing', () => {
|
||
it('prefills the form from the task and saves via updateScheduledTask', async () => {
|
||
await mount([baseTask({})]);
|
||
|
||
// Open the edit form for the (only) task.
|
||
click(document.querySelector('[aria-label="Edit"]'));
|
||
|
||
// The cron reverses onto the structured pickers (weekdays @ 12:30) and the
|
||
// name/prompt are prefilled — not left blank as they would be for create.
|
||
const name = document.querySelector<HTMLInputElement>('input[type="text"]');
|
||
const frequency = document.querySelector<HTMLSelectElement>('select');
|
||
const time = document.querySelector<HTMLInputElement>('input[type="time"]');
|
||
expect(name?.value).toBe('Digest');
|
||
expect(document.body.textContent).toContain('summarize the day');
|
||
expect(frequency?.value).toBe('weekdays');
|
||
expect(time?.value).toBe('12:30');
|
||
|
||
// Saving routes through update (not create), sending only the editable
|
||
// fields so recurring/enabled are left untouched.
|
||
click(findButton('Save'));
|
||
await flush();
|
||
|
||
expect(actions.updateScheduledTask).toHaveBeenCalledWith('t1', {
|
||
cron: '30 12 * * 1-5',
|
||
prompt: 'summarize the day',
|
||
mentions: [],
|
||
name: 'Digest',
|
||
});
|
||
expect(actions.createScheduledTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('an unrepresentable cron lands in the custom field, losslessly', async () => {
|
||
await mount([baseTask({ cron: '0 9 * * 1,3,5' })]); // day-of-week list
|
||
|
||
click(document.querySelector('[aria-label="Edit"]'));
|
||
const frequency = document.querySelector<HTMLSelectElement>('select');
|
||
expect(frequency?.value).toBe('custom');
|
||
|
||
click(findButton('Save'));
|
||
await flush();
|
||
// The raw expression round-trips unchanged rather than being rewritten.
|
||
expect(actions.updateScheduledTask).toHaveBeenCalledWith(
|
||
't1',
|
||
expect.objectContaining({ cron: '0 9 * * 1,3,5' }),
|
||
);
|
||
});
|
||
|
||
it('round-trips stored mentions metadata on save', async () => {
|
||
await mount([
|
||
baseTask({
|
||
prompt: '/loop summarize the day',
|
||
mentions: [
|
||
{
|
||
kind: 'skill',
|
||
id: 'skill:loop',
|
||
label: 'loop',
|
||
serialized: '/loop',
|
||
},
|
||
],
|
||
}),
|
||
]);
|
||
|
||
click(document.querySelector('[aria-label="Edit"]'));
|
||
click(findButton('Save'));
|
||
await flush();
|
||
|
||
expect(actions.updateScheduledTask).toHaveBeenCalledWith(
|
||
't1',
|
||
expect.objectContaining({
|
||
prompt: '/loop\n\nsummarize the day',
|
||
mentions: [
|
||
{
|
||
kind: 'skill',
|
||
id: 'skill:loop',
|
||
label: 'loop',
|
||
serialized: '/loop',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('ScheduledTasksDialog run history', () => {
|
||
it('toggles the run list open, newest-first, tagging late fires', async () => {
|
||
await mount([
|
||
baseTask({
|
||
runs: [
|
||
{ at: 1_700_000_100_000, kind: 'scheduled' },
|
||
{ at: 1_700_000_200_000, kind: 'catch-up' },
|
||
],
|
||
}),
|
||
]);
|
||
|
||
// Collapsed by default — no list items rendered.
|
||
expect(document.querySelectorAll('li').length).toBe(0);
|
||
const toggle = document.querySelector<HTMLButtonElement>(
|
||
'button[aria-expanded]',
|
||
);
|
||
expect(toggle?.textContent).toBe('Run history (2)');
|
||
|
||
click(toggle);
|
||
const items = Array.from(document.querySelectorAll('li'));
|
||
expect(items).toHaveLength(2);
|
||
// Newest first: the catch-up fire (later timestamp) leads and is tagged.
|
||
expect(items[0]!.textContent).toContain('late');
|
||
expect(items[1]!.textContent).not.toContain('late');
|
||
});
|
||
|
||
it('shows no run-history toggle for a task that has never fired', async () => {
|
||
await mount([baseTask({ runs: [] })]);
|
||
expect(document.querySelector('button[aria-expanded]')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('ScheduledTasksDialog run now', () => {
|
||
it('records the run and executes the prompt in the task’s bound session', async () => {
|
||
const onRunPrompt = vi.fn();
|
||
await mount([baseTask({ sessionId: 'sess-9', prompt: 'do it' })], {
|
||
onRunPrompt,
|
||
});
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
// Server-side run record (updates last-run) + client run in the bound session.
|
||
expect(actions.runScheduledTask).toHaveBeenCalledWith('t1');
|
||
expect(onRunPrompt).toHaveBeenCalledWith('do it', 'sess-9');
|
||
});
|
||
|
||
it('passes a null sessionId through for an unbound task', async () => {
|
||
const onRunPrompt = vi.fn();
|
||
await mount([baseTask({ sessionId: null, prompt: 'legacy' })], {
|
||
onRunPrompt,
|
||
});
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
expect(onRunPrompt).toHaveBeenCalledWith('legacy', null);
|
||
});
|
||
|
||
it('does not run a DISABLED task (button disabled, prompt never enqueued)', async () => {
|
||
// The server /run guard only refuses the record — the prompt must not even
|
||
// be enqueued, or a disabled task would execute unrecorded.
|
||
const onRunPrompt = vi.fn();
|
||
await mount([baseTask({ enabled: false, sessionId: 'sess-9' })], {
|
||
onRunPrompt,
|
||
});
|
||
const btn = document.querySelector(
|
||
'[aria-label="Run now"]',
|
||
) as HTMLButtonElement;
|
||
expect(btn.disabled).toBe(true);
|
||
click(btn); // disabled + handler guard → no-op
|
||
await flush();
|
||
expect(onRunPrompt).not.toHaveBeenCalled();
|
||
expect(actions.runScheduledTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('does NOT record the run when the bound session fails to open', async () => {
|
||
// onRunPrompt rejects (session archived/deleted): the prompt never ran, so
|
||
// the run must not be recorded — otherwise history shows a phantom run.
|
||
const onRunPrompt = vi.fn().mockRejectedValue(new Error('archived'));
|
||
const onError = vi.fn();
|
||
await mount([baseTask({ sessionId: 'sess-9', prompt: 'do it' })], {
|
||
onRunPrompt,
|
||
onError,
|
||
});
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
expect(onRunPrompt).toHaveBeenCalledWith('do it', 'sess-9');
|
||
expect(actions.runScheduledTask).not.toHaveBeenCalled(); // no phantom record
|
||
expect(onError).toHaveBeenCalled();
|
||
});
|
||
|
||
it('re-checks server state and does NOT enqueue a task disabled after load', async () => {
|
||
// The dialog loaded the task as enabled, but another tab/API disabled it
|
||
// since. The server-authoritative re-check must catch that BEFORE enqueuing,
|
||
// or a disabled task's prompt runs unrecorded.
|
||
const onRunPrompt = vi.fn();
|
||
await mount(
|
||
[baseTask({ enabled: true, sessionId: 'sess-9', prompt: 'do it' })],
|
||
{
|
||
onRunPrompt,
|
||
},
|
||
);
|
||
// Server now reports it disabled (the re-check reload sees this).
|
||
actions.listScheduledTasks.mockResolvedValue([
|
||
baseTask({ enabled: false, sessionId: 'sess-9', prompt: 'do it' }),
|
||
]);
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
expect(onRunPrompt).not.toHaveBeenCalled(); // never executed
|
||
expect(actions.runScheduledTask).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('consumes a bound ONE-SHOT before enqueuing (record → enqueue)', async () => {
|
||
// /run deletes a one-shot (its single fire). Consuming it BEFORE the run
|
||
// means a failed enqueue leaves a recoverable "recorded but never ran", not
|
||
// a silent double execution at the task's own slot.
|
||
const order: string[] = [];
|
||
const onRunPrompt = vi.fn(() => {
|
||
order.push('enqueue');
|
||
});
|
||
await mount(
|
||
[baseTask({ recurring: false, enabled: true, sessionId: 'sess-9' })],
|
||
{ onRunPrompt },
|
||
);
|
||
actions.runScheduledTask.mockImplementation(async () => {
|
||
order.push('record');
|
||
return baseTask({});
|
||
});
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
expect(order).toEqual(['record', 'enqueue']);
|
||
});
|
||
|
||
it('surfaces a consumed-but-failed error when a ONE-SHOT delivery fails', async () => {
|
||
// The one-shot was deleted before the run; if delivery then rejects it is
|
||
// gone AND un-run, so the error must say so — not the generic "run failed",
|
||
// which would hide that the task no longer exists.
|
||
const onRunPrompt = vi.fn().mockRejectedValue(new Error('switch timeout'));
|
||
const onError = vi.fn();
|
||
await mount(
|
||
[
|
||
baseTask({
|
||
recurring: false,
|
||
enabled: true,
|
||
sessionId: 'sess-9',
|
||
prompt: 'do it',
|
||
}),
|
||
],
|
||
{ onRunPrompt, onError },
|
||
);
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
expect(actions.runScheduledTask).toHaveBeenCalledWith('t1'); // consumed
|
||
expect(onRunPrompt).toHaveBeenCalledWith('do it', 'sess-9');
|
||
expect(onError).toHaveBeenCalledWith(
|
||
expect.any(Error),
|
||
expect.stringContaining('never ran'),
|
||
);
|
||
});
|
||
|
||
it('records a RECURRING task after enqueuing (enqueue → record)', async () => {
|
||
const order: string[] = [];
|
||
const onRunPrompt = vi.fn(() => {
|
||
order.push('enqueue');
|
||
});
|
||
await mount(
|
||
[baseTask({ recurring: true, enabled: true, sessionId: 'sess-9' })],
|
||
{ onRunPrompt },
|
||
);
|
||
actions.runScheduledTask.mockImplementation(async () => {
|
||
order.push('record');
|
||
return baseTask({});
|
||
});
|
||
click(document.querySelector('[aria-label="Run now"]'));
|
||
await flush();
|
||
expect(order).toEqual(['enqueue', 'record']);
|
||
});
|
||
|
||
it('serializes run now: a second click while one is pending is ignored', async () => {
|
||
// Hold the first run pending (session still switching), then click again —
|
||
// the button is disabled + the handler guards, so no second prompt/record.
|
||
let resolveRun: (() => void) | undefined;
|
||
const onRunPrompt = vi.fn().mockImplementation(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
resolveRun = resolve;
|
||
}),
|
||
);
|
||
await mount([baseTask({ sessionId: 'sess-9', prompt: 'do it' })], {
|
||
onRunPrompt,
|
||
});
|
||
const btn = document.querySelector('[aria-label="Run now"]');
|
||
click(btn);
|
||
await flush(); // first run pending → button disabled
|
||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||
click(btn); // ignored while pending
|
||
await flush();
|
||
expect(onRunPrompt).toHaveBeenCalledTimes(1);
|
||
resolveRun?.();
|
||
await flush();
|
||
expect(actions.runScheduledTask).toHaveBeenCalledTimes(1); // one record
|
||
});
|
||
});
|
||
|
||
describe('ScheduledTasksDialog far-future countdown', () => {
|
||
it('clamps the reload timer so a months-away schedule cannot overflow setTimeout', async () => {
|
||
const spy = vi.spyOn(window, 'setTimeout');
|
||
try {
|
||
// ~100 days out: the raw delay exceeds setTimeout's 32-bit ceiling, which
|
||
// would fire immediately and spin a reload loop. It must be clamped.
|
||
const farFuture = Date.now() + 100 * 86_400_000;
|
||
await mount([baseTask({ nextRunAt: farFuture })]);
|
||
const delays = spy.mock.calls.map((c) => Number(c[1] ?? 0));
|
||
expect(delays.every((d) => d <= 2_147_483_647)).toBe(true);
|
||
expect(delays).toContain(2_147_483_647); // clamped to the ceiling
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('ScheduledTasksDialog view-history (bound session)', () => {
|
||
it('opens the bound session when its history control is clicked', async () => {
|
||
const onOpenSession = vi.fn();
|
||
await mount(
|
||
[
|
||
baseTask({
|
||
sessionId: 'sess-42',
|
||
runs: [{ at: 1_700_000_100_000, kind: 'scheduled' }],
|
||
}),
|
||
],
|
||
{ onOpenSession },
|
||
);
|
||
// A bound task shows a "View conversation" control (with a run count) that
|
||
// opens its session transcript — not the inline expand toggle.
|
||
const btn = findButton('View conversation (1)');
|
||
expect(btn).toBeDefined();
|
||
expect(document.querySelector('button[aria-expanded]')).toBeNull();
|
||
click(btn);
|
||
expect(onOpenSession).toHaveBeenCalledWith('sess-42');
|
||
});
|
||
|
||
it('shows the view-history control even before the first run (empty state)', async () => {
|
||
const onOpenSession = vi.fn();
|
||
await mount([baseTask({ sessionId: 'sess-42', runs: [] })], {
|
||
onOpenSession,
|
||
});
|
||
// Discoverable even with zero runs — the original "can't find history" pain.
|
||
const btn = findButton('View conversation');
|
||
expect(btn).toBeDefined();
|
||
click(btn);
|
||
expect(onOpenSession).toHaveBeenCalledWith('sess-42');
|
||
});
|
||
|
||
it('falls back to the inline toggle when session opening is not wired', async () => {
|
||
// No onOpenSession (e.g. a minimal embed): a bound task with runs still gets
|
||
// the inline timestamp list rather than a dead open button.
|
||
await mount([
|
||
baseTask({ sessionId: 'sess-42', runs: [{ at: 1_700_000_100_000 }] }),
|
||
]);
|
||
expect(findButton('View conversation (1)')).toBeUndefined();
|
||
expect(document.querySelector('button[aria-expanded]')).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('ScheduledTasksDialog next-run countdown', () => {
|
||
it('renders a countdown pill for a task with a next run', async () => {
|
||
// ~3h12m in the future — the hours unit is stable across the test's few ms.
|
||
await mount([
|
||
baseTask({ nextRunAt: Date.now() + 3 * 3_600_000 + 12 * 60_000 }),
|
||
]);
|
||
const pill = document.querySelector(
|
||
'[data-testid="scheduled-task-next-run"]',
|
||
);
|
||
expect(pill).not.toBeNull();
|
||
expect(pill!.textContent).toMatch(/3h/);
|
||
});
|
||
|
||
it('omits the countdown when there is no next run (disabled task)', async () => {
|
||
await mount([baseTask({ enabled: false, nextRunAt: null })]);
|
||
expect(
|
||
document.querySelector('[data-testid="scheduled-task-next-run"]'),
|
||
).toBeNull();
|
||
});
|
||
});
|