open-design/apps/web/tests/components/TasksView.templates.test.tsx

468 lines
17 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
AutomationEvolutionProposal,
AutomationTemplate as ContractAutomationTemplate,
} from '@open-design/contracts';
import { TasksView } from '../../src/components/TasksView';
import { I18nProvider } from '../../src/i18n';
const originalFetch = globalThis.fetch;
const daemonTemplate: ContractAutomationTemplate = {
id: 'extract-design-system',
title: 'Extract design system',
description: 'Draft a DESIGN.md from brand docs, screenshots, repos, connectors, websites, or strong artifacts.',
purpose: 'Make the design-system tree evolve from real source material and successful outputs.',
triggerKinds: ['manual', 'connector', 'project-event'],
sourceKinds: ['upload', 'url', 'repo', 'connector', 'artifact'],
stages: [
{ id: 'ingest', kind: 'ingest', title: 'Capture design source' },
{ id: 'compress', kind: 'compress', title: 'Compact source context' },
{ id: 'agent-run', kind: 'agent-run', title: 'Draft DESIGN.md' },
{ id: 'propose', kind: 'propose', title: 'Create design-system proposal' },
],
outputSinks: ['design-system', 'memory'],
reviewPolicy: 'always',
tokenCompression: 'balanced',
tags: ['design-system', 'self-evolution'],
};
const memoryProposal: AutomationEvolutionProposal = {
id: 'proposal-memory-1',
title: 'Project memory from connector digest',
summary: 'Preserve a durable project decision found by an automation.',
targetKind: 'memory-node',
action: 'create',
status: 'pending-review',
reviewPolicy: 'always',
createdAt: '2026-05-18T00:00:00.000Z',
updatedAt: '2026-05-18T00:00:00.000Z',
sourcePacketIds: ['packet-1'],
patch: {
format: 'markdown',
after: '- Decision: keep design-system extraction behind review.',
diffSummary: 'Adds one project memory node.',
},
};
describe('TasksView automation templates', () => {
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
it('shows daemon automation templates and seeds the create modal', 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/automation-templates' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ templates: [daemonTemplate] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/plugins' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ plugins: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/mcp/servers' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ servers: [], templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<TasksView />);
const templateCard = await screen.findByRole('button', { name: /Extract design system/i });
fireEvent.click(templateCard);
await waitFor(() => {
expect((screen.getByLabelText('Automation title') as HTMLInputElement).value).toBe(
'Extract design system',
);
});
const prompt = screen.getByTestId('automation-modal-prompt') as HTMLTextAreaElement;
expect(prompt.value).toContain('Use Automation template "extract-design-system".');
expect(prompt.value).toContain('Outputs: design-system, memory.');
});
it('shows pending evolution proposals and applies them through the review gate', async () => {
let proposals = [memoryProposal];
const applyCalls: 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/automation-templates' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals?status=pending-review' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ proposals }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals/proposal-memory-1/apply' && init?.method === 'POST') {
applyCalls.push(url);
proposals = [];
return new Response(JSON.stringify({
proposal: { ...memoryProposal, status: 'applied' },
result: { memoryId: 'project_connector_decision' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<TasksView />);
expect(await screen.findByText('Evolution proposals')).toBeTruthy();
expect(screen.getByText('Project memory from connector digest')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /Apply/i }));
await waitFor(() => {
expect(applyCalls).toEqual(['/api/automation-proposals/proposal-memory-1/apply']);
expect(screen.queryByText('Project memory from connector digest')).toBeNull();
});
});
it('localizes zh-CN proposal review policy labels for contract values', async () => {
const proposals: AutomationEvolutionProposal[] = [
{
...memoryProposal,
id: 'proposal-trusted-source-1',
title: 'Trusted connector update',
reviewPolicy: 'trusted-source',
},
{
...memoryProposal,
id: 'proposal-auto-apply-1',
title: 'Auto-apply release note',
reviewPolicy: 'auto-apply',
},
];
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/automation-templates' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals?status=pending-review' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ proposals }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(
<I18nProvider initial="zh-CN">
<TasksView />
</I18nProvider>,
);
expect(await screen.findByText('Trusted connector update')).toBeTruthy();
expect(screen.getByText('Auto-apply release note')).toBeTruthy();
expect(screen.getByText('可信来源')).toBeTruthy();
expect(screen.getByText('自动应用')).toBeTruthy();
expect(screen.queryByText('trusted-source')).toBeNull();
expect(screen.queryByText('auto-apply')).toBeNull();
});
it('ingests pasted source content into source packets and proposals', async () => {
const postBodies: unknown[] = [];
let proposals: AutomationEvolutionProposal[] = [];
let packets = [] as Array<{
id: string;
sourceKind: string;
title: string;
capturedAt: string;
tokenStats: { originalTokens: number };
}>;
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/automation-templates' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ templates: [daemonTemplate] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals?status=pending-review' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ proposals }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-source-packets?limit=3' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ packets }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-ingestions' && init?.method === 'POST') {
postBodies.push(JSON.parse(String(init.body)));
const packet = {
id: 'packet-1',
sourceKind: 'repo',
sourceRef: 'https://github.com/acme/design',
title: 'Acme source',
capturedAt: '2026-05-18T00:00:00.000Z',
bodyMarkdown: 'Primary color #335CFF',
provenance: [],
attachments: [],
sensitivity: 'workspace',
capabilityHints: [],
tokenStats: { originalTokens: 6 },
candidateSinks: ['memory', 'design-system'],
};
proposals = [{ ...memoryProposal, id: 'proposal-ingested-1', title: 'Memory: Acme source' }];
packets = [packet];
return new Response(JSON.stringify({
packet,
compressionReport: {
mode: 'balanced',
status: 'skipped',
beforeTokens: 6,
afterTokens: 6,
summary: 'Already compact',
preservedSourcePacketId: 'packet-1',
},
proposals,
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<TasksView />);
await screen.findByText('Ingest source');
fireEvent.change(screen.getByLabelText('Title'), {
target: { value: 'Acme source' },
});
fireEvent.change(screen.getByLabelText('Source ref'), {
target: { value: 'https://github.com/acme/design' },
});
fireEvent.change(screen.getByLabelText('Content'), {
target: { value: 'Primary color #335CFF' },
});
fireEvent.click(screen.getByRole('button', { name: /^Ingest$/i }));
await waitFor(() => {
expect(postBodies).toHaveLength(1);
expect(postBodies[0]).toMatchObject({
templateId: 'ingest-source-memory-tree',
sourceKind: 'connector',
title: 'Acme source',
sourceRef: 'https://github.com/acme/design',
bodyMarkdown: 'Primary color #335CFF',
});
expect(screen.getByText('Memory: Acme source')).toBeTruthy();
expect(screen.getByText('Acme source')).toBeTruthy();
});
});
it('crystallizes a successful automation run into reviewable proposals', async () => {
const crystallizeCalls: string[] = [];
let proposals: AutomationEvolutionProposal[] = [];
let packets = [] as Array<{
id: string;
sourceKind: string;
title: string;
capturedAt: string;
tokenStats: { originalTokens: number };
}>;
const routine = {
id: 'routine-1',
name: 'Artifact polish loop',
prompt: 'Review generated artifacts and extract durable layout guidance.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
context: {},
enabled: true,
nextRunAt: null,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
};
const run = {
id: 'run-succeeded-1',
routineId: 'routine-1',
trigger: 'manual',
status: 'succeeded',
projectId: 'proj-1',
conversationId: 'conv-1',
agentRunId: 'agent-run-1',
startedAt: Date.now() - 1_000,
completedAt: Date.now(),
summary: 'Promote compact controls and repeatable QA steps.',
error: null,
};
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: [routine] }), {
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/automation-templates' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals?status=pending-review' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ proposals }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-source-packets?limit=3' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ packets }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1/runs?limit=10' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ runs: [run] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (
url === '/api/routines/routine-1/runs/run-succeeded-1/crystallize' &&
init?.method === 'POST'
) {
crystallizeCalls.push(url);
const packet = {
id: 'packet-run-1',
sourceKind: 'chat',
sourceRef: 'routine-run:run-succeeded-1',
title: 'Artifact polish loop run',
capturedAt: '2026-05-18T00:00:00.000Z',
bodyMarkdown: 'Promote compact controls and repeatable QA steps.',
provenance: [],
attachments: [],
sensitivity: 'workspace',
capabilityHints: [],
tokenStats: { originalTokens: 12 },
candidateSinks: ['skill', 'memory'],
};
proposals = [{
...memoryProposal,
id: 'proposal-skill-1',
title: 'Skill: Artifact polish loop run',
targetKind: 'skill',
sourcePacketIds: ['packet-run-1'],
}];
packets = [packet];
return new Response(JSON.stringify({
routineId: 'routine-1',
runId: 'run-succeeded-1',
packet,
compressionReport: {
mode: 'balanced',
status: 'skipped',
beforeTokens: 12,
afterTokens: 12,
summary: 'Already compact',
preservedSourcePacketId: 'packet-run-1',
},
proposals,
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<TasksView />);
fireEvent.click(await screen.findByRole('button', { name: /History/i }));
fireEvent.click(await screen.findByRole('button', { name: /Crystallize/i }));
await waitFor(() => {
expect(crystallizeCalls).toEqual([
'/api/routines/routine-1/runs/run-succeeded-1/crystallize',
]);
expect(screen.getByText('Skill: Artifact polish loop run')).toBeTruthy();
});
});
});