mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
468 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|