open-design/apps/web/tests/components/TasksView.templates.test.tsx
lefarcen 5f939ce601
fix(web): remove Ingest source panel from Automations tab (#2711)
* fix(web): remove Ingest source panel from Automations tab

The Automations tab carried a free-form "Ingest source" composer that
let users paste arbitrary content (URL, repo path, connector event,
chat snippet) and turn it into a source packet plus evolution proposals.
The form was confusing next to the routine/template flow on the same
page, exposed an internal canonicalization concept users don't need to
think about, and shipped before the surrounding evolution-proposal flow
was wired into a coherent end-to-end story.

Drop the UI surface only:

- Remove the <section className="automations-ingest"> block, the
  Template / Source / Compression / Connector selects, the title/source
  ref/content fields, the recent-packets list, and the Ingest button.
- Drop the now-dead local state (sourcePackets / sourceForm /
  ingestingSource), the patchSourceForm and submitSourceIngestion
  helpers, the SOURCE_KIND_OPTIONS / COMPRESSION_OPTIONS constants, the
  SourceIngestionForm type and DEFAULT_SOURCE_FORM, the
  /api/automation-source-packets refresh leg, and the sourcePackets
  side-write inside crystallizeRun.
- Remove the matching .automations-ingest / .automation-ingest-* CSS
  block (plus the two responsive overrides) from tasks.css.
- Delete the test case that drove the form in TasksView.templates.test.

Backend stays intact: apps/daemon/src/automation-ingestions.ts, the
POST /api/automation-ingestions route, `od automation ingest` CLI, the
routine-evolution call site, and the AutomationContentPacket /
AutomationSourceKind / AutomationTokenCompressionMode contracts all
remain, since routine scheduling still depends on them.

* fix(web): drop crystallize test assertion on removed packet list

The crystallize test was asserting that the new content packet's title
shows up on the page. That assertion only passed because the daemon
response was being side-written into the deleted sourcePackets state
and rendered in the Ingest source recent-packets strip. With that UI
removed, the packet title has no surface to land on; the proposal title
(`Skill: Artifact polish loop run`) is still asserted and remains the
real signal that crystallize succeeded.
2026-05-22 17:53:27 +08:00

302 lines
11 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';
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('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();
});
});
});