open-design/apps/web/tests/runtime/generation-preview.test.ts
chaoxiaoche 716000e200 feat(web): reveal generation steps progressively as the agent reaches them
- Only render steps the agent has actually reached (drop pending pills)
  with a slide/fade entrance, so the card visibly evolves 1->2->3 instead
  of always showing the same fully-populated row.
- Keep the "understand" step in progress during requesting/starting so a
  fresh run opens with a single step rather than a pre-filled set.
- Stop surfacing status detail (e.g. the model slug from `requesting`) as
  the live activity line; only genuine thinking/output text is shown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:01:35 +08:00

228 lines
7.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
buildGenerationPreviewState,
derivePrototypeGenerationSteps,
formatGenerationElapsed,
workspaceHasPreviewSurface,
} from '../../src/runtime/generation-preview';
import type { AgentEvent, ChatMessage } from '../../src/types';
describe('generation preview helpers', () => {
it('detects when the workspace already has a preview surface', () => {
expect(
workspaceHasPreviewSurface({
activeTab: 'index.html',
projectFiles: [{ name: 'index.html', size: 1, mtime: 1, kind: 'html', mime: 'text/html' }],
liveArtifacts: [],
}),
).toBe(true);
expect(
workspaceHasPreviewSurface({
activeTab: null,
projectFiles: [],
liveArtifacts: [],
streamingArtifactHtml: '<html><body>hi</body></html>',
}),
).toBe(true);
});
it('advances the three prototype steps from streamed events', () => {
const events: AgentEvent[] = [
{ kind: 'status', label: 'thinking' },
{ kind: 'text', text: 'Planning the page.' },
{ kind: 'tool_use', id: '1', name: 'Write', input: { file_path: 'index.html' } },
];
expect(
derivePrototypeGenerationSteps({
events,
hasArtifactHtml: false,
hasPreviewSurface: false,
failed: false,
}),
).toEqual([
{ id: 'understand', status: 'succeeded' },
{ id: 'generate', status: 'succeeded' },
{ id: 'prepare', status: 'running' },
]);
});
it('keeps the understand step in progress while the request is still pending', () => {
// `requesting` only means the request left the client; nothing should
// advance past the first step until real model activity arrives, so the
// UI can reveal steps one at a time.
expect(
derivePrototypeGenerationSteps({
events: [{ kind: 'status', label: 'requesting', detail: 'claude-opus-4-7' }],
hasArtifactHtml: false,
hasPreviewSurface: false,
failed: false,
}),
).toEqual([
{ id: 'understand', status: 'running' },
{ id: 'generate', status: 'pending' },
{ id: 'prepare', status: 'pending' },
]);
});
it('builds preview state for an active assistant run without an open preview tab', () => {
const assistant: ChatMessage = {
id: 'a1',
role: 'assistant',
content: '',
runStatus: 'running',
startedAt: Date.now() - 5_000,
events: [{ kind: 'status', label: 'thinking' }],
};
const state = buildGenerationPreviewState({
designSystemProject: false,
messages: [{ id: 'u1', role: 'user', content: 'Build a landing page' }, assistant],
streaming: true,
activeTab: null,
projectFiles: [],
liveArtifacts: [],
});
expect(state).not.toBeNull();
expect(state?.phase).toBe('generating');
// A `thinking` status is enough evidence that the model started, so the
// first step has already advanced past "running".
expect(state?.steps[0]?.status).toBe('succeeded');
expect(state?.retryTarget).toBeNull();
});
it('surfaces the latest activity snippet while generating', () => {
const assistant: ChatMessage = {
id: 'a1',
role: 'assistant',
content: '',
runStatus: 'running',
startedAt: Date.now(),
events: [
{ kind: 'status', label: 'thinking' },
{ kind: 'thinking', text: 'Sketching the hero section layout' },
],
};
const state = buildGenerationPreviewState({
designSystemProject: false,
messages: [assistant],
streaming: true,
activeTab: null,
projectFiles: [],
liveArtifacts: [],
});
expect(state?.activityLabel).toBe('Sketching the hero section layout');
});
it('keeps a paused surface when the run was stopped without a preview', () => {
const assistant: ChatMessage = {
id: 'a1',
role: 'assistant',
content: 'Partial work',
runStatus: 'canceled',
startedAt: Date.now() - 10_000,
events: [{ kind: 'tool_use', id: '1', name: 'Write', input: {} }],
};
const state = buildGenerationPreviewState({
designSystemProject: false,
messages: [assistant],
streaming: false,
activeTab: null,
projectFiles: [],
liveArtifacts: [],
});
expect(state?.phase).toBe('stopped');
expect(state?.failed).toBe(false);
expect(state?.retryTarget).toBeNull();
});
it('keeps a waiting surface when the agent is asking the user a question', () => {
const assistant: ChatMessage = {
id: 'a1',
role: 'assistant',
content: 'A few quick questions:\n<question-form id="discovery" title="Brief">{"questions":[]}</question-form>',
runStatus: 'succeeded',
startedAt: Date.now() - 4_000,
events: [{ kind: 'text', text: '<question-form id="discovery">{"questions":[]}</question-form>' }],
};
const state = buildGenerationPreviewState({
designSystemProject: false,
messages: [assistant],
streaming: false,
activeTab: null,
projectFiles: [],
liveArtifacts: [],
});
expect(state?.phase).toBe('awaiting-input');
expect(state?.retryTarget).toBeNull();
});
it('returns null for a finished run that produced no question or preview', () => {
const assistant: ChatMessage = {
id: 'a1',
role: 'assistant',
content: 'All done!',
runStatus: 'succeeded',
startedAt: Date.now() - 4_000,
events: [{ kind: 'text', text: 'All done!' }],
};
expect(
buildGenerationPreviewState({
designSystemProject: false,
messages: [assistant],
streaming: false,
activeTab: null,
projectFiles: [],
liveArtifacts: [],
}),
).toBeNull();
});
it('builds a failed state with a retry target', () => {
const assistant: ChatMessage = {
id: 'a1',
role: 'assistant',
content: '',
runStatus: 'failed',
startedAt: Date.now() - 8_000,
events: [{ kind: 'text', text: 'Model request failed' }],
};
const state = buildGenerationPreviewState({
designSystemProject: false,
messages: [assistant],
streaming: false,
activeTab: null,
projectFiles: [],
liveArtifacts: [],
conversationError: 'Network error',
});
expect(state?.phase).toBe('failed');
expect(state?.failed).toBe(true);
expect(state?.errorMessage).toBe('Network error');
expect(state?.retryTarget).toBe(assistant);
});
it('hides preview state once a preview tab is active', () => {
const assistant: ChatMessage = {
id: 'a1',
role: 'assistant',
content: '',
runStatus: 'running',
startedAt: Date.now(),
events: [{ kind: 'tool_use', id: '1', name: 'Write', input: {} }],
};
expect(
buildGenerationPreviewState({
designSystemProject: false,
messages: [assistant],
streaming: true,
activeTab: 'index.html',
projectFiles: [{ name: 'index.html', size: 1, mtime: 1, kind: 'html', mime: 'text/html' }],
liveArtifacts: [],
}),
).toBeNull();
});
it('formats elapsed durations for the meta row', () => {
expect(formatGenerationElapsed(42)).toBe('42s');
expect(formatGenerationElapsed(125)).toBe('2m 5s');
});
});