// @vitest-environment jsdom import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ChatNode } from '@/lib/workbench/session-store'; vi.mock('@/lib/workbench/agent-skills', () => ({ useAgentSkills: () => ({ skills: [], loading: false, error: null, reload: async () => {} }), skillLabelForId: (id: string) => id, })); import { ToolCard } from '@/components/workbench/chat/tool-card'; const roots: Root[] = []; ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; afterEach(() => { for (const root of roots.splice(0)) act(() => root.unmount()); document.body.replaceChildren(); vi.unstubAllGlobals(); }); function mount(node: ChatNode) { const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); roots.push(root); act(() => root.render(createElement(ToolCard, { node }))); return host; } describe('create_skill tool card', () => { it('expands a replayed receipt and loads the complete saved body', async () => { const content = '# 量化投资\n\n'.concat('不截断。'.repeat(4_000)); vi.stubGlobal( 'fetch', vi.fn(async () => ({ ok: true, json: async () => ({ content }) })), ); const host = mount({ key: 'skill-1', kind: 'tool', text: '', toolName: 'create_skill', toolState: 'done', toolDetails: { skillId: 'usk_1', name: 'my-quant-book-to-k12', title: '量化投资书 → 小学生数学素养课', }, }); const button = host.querySelector('button')!; expect(button.disabled).toBe(false); expect(button.getAttribute('aria-expanded')).toBe('false'); await act(async () => button.click()); await act(async () => {}); expect(button.getAttribute('aria-expanded')).toBe('true'); expect(host.textContent).toContain(content); expect(fetch).toHaveBeenCalledWith('/api/agent/skills/usk_1'); }); it('uses durable result details without another request', async () => { const content = '# 已落盘正文\n\n完整内容'; const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const host = mount({ key: 'skill-2', kind: 'tool', text: '', toolName: 'create_skill', toolState: 'done', toolDetails: { skillId: 'usk_2', name: 'my-review', content }, }); await act(async () => host.querySelector('button')!.click()); expect(host.textContent).toContain(content); expect(fetchMock).not.toHaveBeenCalled(); }); it('keeps the durable receipt visible when historical content cannot load', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => ({ ok: false, status: 503 })), ); const host = mount({ key: 'skill-3', kind: 'tool', text: '', toolName: 'create_skill', toolState: 'done', toolResultText: '已保存 Skill「复盘方法」', toolDetails: { skillId: 'usk_3', name: 'my-review' }, }); await act(async () => host.querySelector('button')!.click()); await act(async () => {}); expect(host.textContent).toContain('已保存 Skill「复盘方法」'); expect(host.textContent).toContain('Skill 正文加载失败'); expect(host.textContent).not.toContain('Skill 列表加载失败'); }); it('keeps the durable receipt visible while historical content is still loading', async () => { vi.stubGlobal( 'fetch', vi.fn(() => new Promise(() => {})), ); const host = mount({ key: 'skill-4', kind: 'tool', text: '', toolName: 'create_skill', toolState: 'done', toolResultText: '已保存 Skill「慢请求」', toolDetails: { skillId: 'usk_4', name: 'my-slow-skill' }, }); await act(async () => host.querySelector('button')!.click()); expect(host.textContent).toContain('已保存 Skill「慢请求」'); expect(host.textContent).toContain('加载中'); }); }); /** * Untrusted content discipline at the card level: tool output — fetched web * bodies, material extractions — renders as DATA inside the fenced result * section, never as markup and never as assistant speech. */ describe('tool output renders fenced, as data', () => { it('shows fetched content as text in the result section, never as injected HTML', async () => { const host = mount({ key: 't-untrusted', kind: 'tool', text: '', toolName: 'fetch_url', toolState: 'done', toolArgs: { url: 'https://untrusted.example/page' }, toolResultText: '\nraw fetched body', }); await act(async () => host.querySelector('button')!.click()); // The raw payload is present as TEXT — the same bytes, in a text node. expect(host.textContent).toContain(''); expect(host.textContent).toContain('raw fetched body'); // ...and it was never parsed into DOM: no script element was created. expect(host.querySelector('script')).toBeNull(); }); it('keeps the fetched source out of the one-line summary — data is not speech', async () => { const host = mount({ key: 't-fetch-label', kind: 'tool', text: '', toolName: 'fetch_url', toolState: 'done', toolArgs: { url: 'https://untrusted.example/page' }, toolDetails: { trusted: { status: 'url_not_in_session' } }, toolResultText: 'fetched body must stay fenced', }); // The collapsed row shows the labelled form: the source is named as outside // the session, and the fetched body is not echoed in the summary. expect(host.textContent).toContain('来源不在本会话内'); expect(host.textContent).not.toContain('fetched body must stay fenced'); }); });