open-design/apps/web/tests/components/RoutinesSection.test.tsx
Tom Huang 86ec951fb9
[codex] Add automation templates and proposal workflows (#2193)
* feat(web): introduce Automations tab with dual-track capability for routines

This commit adds a new Automations tab that consolidates routines, schedules, and live artifacts, allowing users to manage automations seamlessly. The tab features a modal for creating and editing automations, which supports various scheduling options (hourly, daily, weekdays, weekly) and project modes (create_each_run, reuse). The CLI is also updated to expose automation commands, ensuring consistency between the web UI and CLI interfaces.

Key changes include:
- New `NewAutomationModal` component for automation creation and editing.
- Updated `TasksView` to integrate the new Automations functionality.
- Enhanced styling for the Automations tab to improve user experience.

This implementation aligns with the dual-track capability exposure policy, ensuring all features are accessible via both the web UI and CLI.

* feat(daemon): enhance automation context handling and CLI commands

This commit introduces several improvements to the automation context management and updates the CLI commands accordingly. Key changes include:

- Added support for new context fields (`plugin`, `mcp`, `connector`) in automation commands.
- Updated the CLI to reflect new target options (`new-project`).
- Enhanced error messages for invalid target inputs.
- Introduced functions to handle context selection and normalization for routines, including the ability to parse and store context data in the database.
- Updated the database schema to include a new `context_json` field for routines.
- Improved the handling of context in routine routes and the web interface, ensuring that selected contexts are properly managed and displayed.

These changes aim to provide a more robust and flexible automation experience, aligning with the recent enhancements in the web UI.

* feat(web): enhance TasksView with automation run history and status indicators

This commit introduces several new features to the TasksView component, including:

- Added functionality to display automation run history for each routine, showing metadata such as status, timestamps, and project details.
- Implemented status indicators for routine runs, providing visual feedback on their current state (succeeded, failed, running, queued).
- Enhanced the UI to allow users to expand and view detailed run history, including the ability to open the corresponding project conversation.
- Updated styles to improve the presentation of automation statuses and history.

These changes aim to provide users with better insights into their automation routines and improve overall usability.

* feat(daemon): implement automation ingestion and proposal management

This commit introduces several new features related to automation ingestion and proposal management within the daemon. Key changes include:

- Added new modules for handling automation source packets and proposals, allowing for the storage, retrieval, and management of automation-related data.
- Implemented functions to list, create, and apply automation proposals, enhancing the automation workflow.
- Introduced new CLI commands for interacting with memory entries and automation sources, providing users with more control over their automation processes.
- Enhanced the server routes to support automation source and proposal APIs, enabling seamless integration with the existing system.

These changes aim to improve the overall automation experience, making it easier for users to manage and utilize automation proposals and ingestions effectively.
2026-05-19 16:35:28 +08:00

884 lines
31 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 automation' }));
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 automation' }));
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 automations 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',
conversationId: 'conv-run',
fileName: null,
},
);
});
it('shows persisted failure reasons in the last-run summary and history', async () => {
const failure = 'Agent stalled without emitting any new output for 1s.';
const 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: {
runId: 'run-failed-1',
status: 'failed',
trigger: 'scheduled',
startedAt: Date.now() - 1000,
completedAt: Date.now(),
projectId: 'proj-run',
conversationId: 'conv-run',
agentRunId: 'agent-run-1',
error: failure,
errorCode: 'AGENT_EXECUTION_FAILED',
},
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-failed-1',
routineId: 'routine-1',
trigger: 'scheduled',
status: 'failed',
projectId: 'proj-run',
conversationId: 'conv-run',
agentRunId: 'agent-run-1',
startedAt: Date.now() - 1000,
completedAt: Date.now(),
summary: null,
error: failure,
errorCode: 'AGENT_EXECUTION_FAILED',
},
],
}), {
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')!;
expect(within(row).getByText(failure)).toBeTruthy();
fireEvent.click(within(row).getByRole('button', { name: 'History' }));
await waitFor(() => {
expect(screen.getAllByText(failure)).toHaveLength(2);
});
});
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();
});
it('falls back to the empty history state when loading run history fails', 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({ error: 'history unavailable' }), {
status: 500,
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();
});
it('shows an error alert when the initial routines load fails', async () => {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/routines') {
return new Response(JSON.stringify({ error: 'boom' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects') {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.getByRole('alert').textContent).toContain('routines: 500');
});
it('shows an error alert when creating a routine fails', async () => {
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') {
return new Response(JSON.stringify({ error: 'provider unavailable' }), {
status: 500,
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 automation' }));
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('button', { name: 'Create' }));
expect((await screen.findByRole('alert')).textContent).toContain('provider unavailable');
expect(screen.getByDisplayValue('Weekly digest')).toBeTruthy();
});
it('shows an error alert when running a routine now fails', async () => {
const 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(),
}];
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') {
return new Response(JSON.stringify({ error: 'agent unavailable' }), {
status: 500,
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' }));
expect((await screen.findByRole('alert')).textContent).toContain('agent unavailable');
expect(within(card).queryByRole('button', { name: 'Hide history' })).toBeNull();
});
it('shows an error alert when pausing a routine fails and keeps the current action', async () => {
const 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(),
}];
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') {
return new Response(JSON.stringify({ error: 'scheduler unavailable' }), {
status: 500,
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' }));
expect((await screen.findByRole('alert')).textContent).toContain('scheduler unavailable');
expect(within(card).getByRole('button', { name: 'Pause' })).toBeTruthy();
expect(within(card).queryByRole('button', { name: 'Resume' })).toBeNull();
});
it('edits an existing routine and PATCHes the updated fields', async () => {
let routines: Routine[] = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'reuse', projectId: 'proj-1' },
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: [{ id: 'proj-1', name: 'Routine Test Project' }] }), {
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,
name: body.name ?? current.name,
prompt: body.prompt ?? current.prompt,
schedule: body.schedule ?? current.schedule,
target: body.target ?? current.target,
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')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'Edit' }));
const nameInput = screen.getByLabelText('Name') as HTMLInputElement;
expect(nameInput.value).toBe('Morning briefing');
const promptInput = screen.getByLabelText('Prompt') as HTMLTextAreaElement;
expect(promptInput.value).toBe('Morning summary');
fireEvent.change(nameInput, { target: { value: 'Renamed briefing' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(screen.getByText('Renamed briefing')).toBeTruthy();
});
expect(patchBodies).toHaveLength(1);
const body = patchBodies[0] as Record<string, unknown>;
expect(body.name).toBe('Renamed briefing');
expect(body.prompt).toBe('Morning summary');
expect(body.schedule).toEqual({ kind: 'daily', time: '09:00', timezone: 'UTC' });
expect(body.target).toEqual({ mode: 'reuse', projectId: 'proj-1' });
});
it('shows an error alert when deleting a routine fails', async () => {
const 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(),
}];
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') {
return new Response(JSON.stringify({ error: 'delete failed upstream' }), {
status: 500,
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: 'Delete' }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.getByRole('alert').textContent).toContain('delete failed upstream');
expect(screen.getByText('Morning briefing')).toBeTruthy();
});
});