// @vitest-environment jsdom /** * Visibility-gate coverage for the assistant feedback widget. It should * appear after any successfully completed turn, and stay hidden for * streaming turns, failed runs, and empty responses. */ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { AssistantMessage } from '../../src/components/AssistantMessage'; import { CollabProvider } from '../../src/collab/collab-context'; import * as registry from '../../src/providers/registry'; import type { ChatMessage, ProjectFile } from '../../src/types'; import { workspaceContextFixture } from '../helpers/workspace-context'; beforeAll(() => { const store = new Map(); Object.defineProperty(window, 'localStorage', { configurable: true, value: { clear: () => store.clear(), getItem: (key: string) => store.get(key) ?? null, removeItem: (key: string) => store.delete(key), setItem: (key: string, value: string) => store.set(key, value), }, }); }); afterEach(() => { cleanup(); window.localStorage.clear(); window.sessionStorage.clear(); vi.restoreAllMocks(); }); beforeEach(() => { window.localStorage.clear(); window.sessionStorage.clear(); }); /** * 一枚 `` 事件。 * * 产物卡改成 agent **声明**出来的之后,不带声明的回合一张卡都没有。下面凡是 * 讲「这一轮算不算产出了这个文件」的用例,夹具都得先把声明发出来 —— 包括 * 反面用例:声明照发、卡仍然不出,才说明拦住它的是归属判断而不是缺一枚声明。 */ function declareTurnCards(...names: string[]): ChatMessage['events'][number] { return { kind: 'artifact_focus', show: names } as ChatMessage['events'][number]; } function baseMessage(overrides: Partial = {}): ChatMessage { const message = { id: 'msg-1', role: 'assistant', content: 'Done.', runStatus: 'succeeded', startedAt: 1700000000, endedAt: 1700000005, events: [{ kind: 'text', text: 'Done.' } as ChatMessage['events'][number]], producedFiles: [], ...overrides, } as ChatMessage; // 产出清单非空的夹具自动带上声明:那些用例讲的是卡片长相和动作,不是声明协议。 const produced = message.producedFiles ?? []; if (produced.length > 0) { message.events = [ ...(message.events ?? []), declareTurnCards(...produced.map((file) => file.name)), ] as ChatMessage['events']; } return message; } function producedFile(name: string): ProjectFile { return { name, path: name, size: 100, mtime: 1700000005, kind: 'html', mime: 'text/html', } as ProjectFile; } const PROJECT_A_CONTEXT = workspaceContextFixture({ workspaceId: 'workspace-a', workspaceMemberId: 'member-a', }); function projectCollabValue(workspaceContext = PROJECT_A_CONTEXT) { return { workspaceContext, workspaceContextLoading: false, enabled: false, member: null, present: [], publishedVersion: null, syncState: null, viewerOnly: false, isOwner: false, ownerDisplayName: null, ownerRole: null, downloadPending: false, reportChange: vi.fn(), requestPublish: vi.fn(), refreshPresence: vi.fn(), checkStatusNow: vi.fn(), }; } describe('internal control markers', () => { // Acceptance: a resumed CLI repeated the daemon's internal title directive on // a later turn, and `LV奢侈品电商原型` rendered as body text. it('never renders a leaked title marker as prose', () => { const content = [ '我会使用 Open Design 技能把已确认的电商流程整理为可执行的原型计划。', 'LV奢侈品电商原型', '目标已锁定为响应式 LV 奢侈品电商概念原型。', ].join('\n\n'); render( , ); expect(document.body.textContent).not.toContain('od-title'); expect(document.body.textContent).toContain('目标已锁定为响应式'); }); it('never renders OD Next machine protocol blocks as prose', () => { const content = [ 'Plan is frozen.', '{"schema":"open-design.plan-contract/v2"}', '{"schema":"open-design.strategy-state/v2"}', ].join('\n\n'); render( , ); expect(document.body.textContent).not.toContain('open-design-plan-contract'); expect(document.body.textContent).not.toContain('open-design-runtime-state'); expect(document.body.textContent).toContain('Plan is frozen.'); }); }); describe('AssistantMessage feedback gate', () => { // Component 14 / D28: an image, video or HTML artifact is delivered as a // card whose picture IS the open target — the card writes no filename, so // the click lands on the artwork rather than on a text row. it('opens a produced artifact when the user clicks its card', () => { const onRequestOpenFile = vi.fn(); render( , ); expect(screen.queryByText('poster.png')).toBeNull(); fireEvent.click(screen.getByTestId('artifact-card-open-poster.png')); /* * **只传文件名,没有第二个实参。** * * 这里原来钉的是 `('poster.png', undefined)`,并在注释里解释第二个实参是 * 产物的快照身份(设计 §4.2:图片卡点击开当轮那一张)。用户 2026-09-02 * 裁决**缩略图是快照、点开永远是最新**,那条路整条撤掉了,连字段一起 —— * 所以现在不是「传了 undefined」,是**根本没有第二个实参**。 * * `toHaveBeenCalledWith` 对多余实参是敏感的:真要有人把快照身份接回来, * 这条当场红。这正是它该做的事 —— 那个行为已经被否掉了。 */ expect(onRequestOpenFile).toHaveBeenCalledWith('poster.png'); }); it('does not turn the whole assistant reply into a persistent focus target', () => { render( , ); const reply = document.querySelector('[data-assistant-message-id]'); expect(reply?.hasAttribute('tabindex')).toBe(false); expect(screen.getByRole('button', { name: 'Open: poster.png' })).toBeTruthy(); }); it('keeps the artifact card openable from the keyboard', () => { const onRequestOpenFile = vi.fn(); render( , ); // A real