Files
open-design/apps/web/tests/components/RoutinesSection.test.tsx
shangxinyu1andGitHub 0220124e0f test: add Memory and Routines coverage (#1400)
* test: align extended Playwright coverage with current UI behavior

* test: address extended suite review feedback

* test: fix Codex fallback config hydration in e2e

* test: add Memory and Routines coverage

* test: fix Memory and Routines component test typing

* test: include Memory and Routines e2e in extended suite
2026-05-12 17:48:56 +08:00

469 lines
16 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Routine } from '@open-design/contracts';
import { RoutinesSection } from '../../src/components/RoutinesSection';
import * as router from '../../src/router';
const originalFetch = globalThis.fetch;
const originalConfirm = window.confirm;
describe('RoutinesSection', () => {
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
window.confirm = originalConfirm;
vi.restoreAllMocks();
});
it('creates a weekly routine that reuses an existing project', async () => {
let routines: Routine[] = [];
const projects = [{ id: 'proj-1', name: 'Routine Test Project' }];
const createBodies: unknown[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines' && init?.method === 'POST') {
const body = JSON.parse(String(init.body));
createBodies.push(body);
routines = [{
id: 'routine-1',
name: body.name,
prompt: body.prompt,
schedule: body.schedule,
target: body.target,
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
return new Response(JSON.stringify({ routine: routines[0] }), {
status: 201,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
fireEvent.click(await screen.findByRole('button', { name: 'New routine' }));
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'Weekly digest' },
});
fireEvent.change(screen.getByLabelText('Prompt'), {
target: { value: 'Summarize GitHub and design activity.' },
});
fireEvent.click(screen.getByRole('tab', { name: 'Weekly' }));
fireEvent.click(screen.getByRole('button', { name: 'Wed' }));
fireEvent.click(screen.getAllByRole('radio')[1]!);
fireEvent.change(screen.getAllByRole('combobox')[1]!, {
target: { value: 'proj-1' },
});
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(screen.getByText('Weekly digest')).toBeTruthy();
});
expect(createBodies).toEqual([
{
name: 'Weekly digest',
prompt: 'Summarize GitHub and design activity.',
schedule: {
kind: 'weekly',
weekday: 3,
time: '09:00',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
},
target: {
mode: 'reuse',
projectId: 'proj-1',
},
enabled: true,
},
]);
});
it('pauses and resumes an existing routine through PATCH updates', async () => {
let routines: Routine[] = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
const patchBodies: unknown[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1' && init?.method === 'PATCH') {
const body = JSON.parse(String(init.body));
patchBodies.push(body);
const current = routines[0]!;
routines = [{
...current,
enabled: body.enabled,
updatedAt: Date.now(),
}];
return new Response(JSON.stringify({ routine: routines[0] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = await screen.findByText('Morning briefing');
const card = row.closest('li')!;
fireEvent.click(within(card).getByRole('button', { name: 'Pause' }));
await waitFor(() => {
expect(within(card).getByRole('button', { name: 'Resume' })).toBeTruthy();
});
fireEvent.click(within(card).getByRole('button', { name: 'Resume' }));
await waitFor(() => {
expect(within(card).getByRole('button', { name: 'Pause' })).toBeTruthy();
});
expect(patchBodies).toEqual([{ enabled: false }, { enabled: true }]);
});
it('runs a routine now and loads its history', async () => {
let routines: Routine[] = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
const runBodies: string[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1/run' && init?.method === 'POST') {
runBodies.push(url);
const current = routines[0]!;
routines = [{
...current,
lastRun: {
runId: 'run-1',
status: 'queued',
trigger: 'manual',
startedAt: Date.now(),
projectId: 'proj-run',
conversationId: 'conv-run',
agentRunId: 'agent-run-1',
},
}];
return new Response(JSON.stringify({
routine: routines[0],
run: routines[0]!.lastRun,
projectId: 'proj-run',
conversationId: 'conv-run',
agentRunId: 'agent-run-1',
}), {
status: 202,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1/runs?limit=10') {
return new Response(JSON.stringify({
runs: [
{
id: 'run-1',
routineId: 'routine-1',
trigger: 'manual',
status: 'queued',
projectId: 'proj-run',
conversationId: 'conv-run',
agentRunId: 'agent-run-1',
startedAt: Date.now(),
completedAt: null,
summary: null,
error: null,
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = await screen.findByText('Morning briefing');
const card = row.closest('li')!;
fireEvent.click(within(card).getByRole('button', { name: 'Run now' }));
await waitFor(() => {
expect(within(card).getByRole('button', { name: 'Hide history' })).toBeTruthy();
});
expect(await screen.findByText('manual')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Open project' })).toBeTruthy();
expect(runBodies).toEqual(['/api/routines/routine-1/run']);
});
it('shows a validation error when reuse mode is selected without a project', async () => {
const postBodies: unknown[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [{ id: 'proj-1', name: 'Routine Test Project' }] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines' && init?.method === 'POST') {
postBodies.push(JSON.parse(String(init.body)));
return new Response(JSON.stringify({}), { status: 400, headers: { 'content-type': 'application/json' } });
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
fireEvent.click(await screen.findByRole('button', { name: 'New routine' }));
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'Weekly digest' },
});
fireEvent.change(screen.getByLabelText('Prompt'), {
target: { value: 'Summarize GitHub and design activity.' },
});
fireEvent.click(screen.getAllByRole('radio')[1]!);
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Create' })).toBeTruthy();
});
expect(postBodies).toEqual([]);
});
it('deletes a routine after confirmation', async () => {
let routines: Routine[] = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
const deletedUrls: string[] = [];
window.confirm = vi.fn(() => true);
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1' && init?.method === 'DELETE') {
deletedUrls.push(url);
routines = [];
return new Response(null, { status: 204 });
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = (await screen.findByText('Morning briefing')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(screen.getByText('No routines yet.')).toBeTruthy();
});
expect(deletedUrls).toEqual(['/api/routines/routine-1']);
});
it('opens the project referenced by a routine run from history', async () => {
const navigateSpy = vi.spyOn(router, 'navigate').mockImplementation(() => {});
const routines = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1/runs?limit=10') {
return new Response(JSON.stringify({
runs: [
{
id: 'run-1',
routineId: 'routine-1',
trigger: 'manual',
status: 'succeeded',
projectId: 'proj-run',
conversationId: 'conv-run',
agentRunId: 'agent-run-1',
startedAt: Date.now(),
completedAt: Date.now() + 2000,
summary: 'Done',
error: null,
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = (await screen.findByText('Morning briefing')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'History' }));
fireEvent.click(await screen.findByRole('button', { name: 'Open project' }));
expect(navigateSpy).toHaveBeenCalledWith(
{ kind: 'project', projectId: 'proj-run', fileName: null },
);
});
it('shows the empty history state when a routine has never run', async () => {
const routines = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1/runs?limit=10') {
return new Response(JSON.stringify({ runs: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = (await screen.findByText('Morning briefing')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'History' }));
expect(await screen.findByText('No runs yet.')).toBeTruthy();
});
});