Initial import: open-design source for helix-mind.ai distribution
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
This repository contains the open-design daemon CLI source code, built and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use by the HelixMind /design slash command. Licenses: Apache-2.0 (root) + MIT (skills/*)
This commit is contained in:
244
apps/web/tests/runtime/exports.test.ts
Normal file
244
apps/web/tests/runtime/exports.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
archiveFilenameFrom,
|
||||
archiveRootFromFilePath,
|
||||
buildSandboxedPreviewDocument,
|
||||
exportAsMd,
|
||||
exportAsPdf,
|
||||
openSandboxedPreviewInNewTab,
|
||||
} from '../../src/runtime/exports';
|
||||
|
||||
function mockResponse(headers: Record<string, string>): Response {
|
||||
return { headers: new Headers(headers) } as Response;
|
||||
}
|
||||
|
||||
describe('archiveRootFromFilePath', () => {
|
||||
it('returns the top-level directory name when present', () => {
|
||||
expect(archiveRootFromFilePath('ui-design/index.html')).toBe('ui-design');
|
||||
expect(archiveRootFromFilePath('ui-design/src/app.css')).toBe('ui-design');
|
||||
});
|
||||
|
||||
it('returns empty for files at the project root', () => {
|
||||
expect(archiveRootFromFilePath('index.html')).toBe('');
|
||||
expect(archiveRootFromFilePath('README.md')).toBe('');
|
||||
});
|
||||
|
||||
it('strips a leading slash before scanning', () => {
|
||||
expect(archiveRootFromFilePath('/ui-design/index.html')).toBe('ui-design');
|
||||
expect(archiveRootFromFilePath('//ui-design/index.html')).toBe('ui-design');
|
||||
});
|
||||
|
||||
it('returns empty for empty/garbage input', () => {
|
||||
expect(archiveRootFromFilePath('')).toBe('');
|
||||
expect(archiveRootFromFilePath('/')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveFilenameFrom', () => {
|
||||
it('decodes the RFC 5987 UTF-8 filename* form (preserves multi-byte chars)', () => {
|
||||
// 'café-design.zip' encoded — the é is a 2-byte UTF-8 sequence (%C3%A9),
|
||||
// which is enough to fail under naive ASCII-only handling.
|
||||
const resp = mockResponse({
|
||||
'content-disposition':
|
||||
"attachment; filename=\"project.zip\"; filename*=UTF-8''caf%C3%A9-design.zip",
|
||||
});
|
||||
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('café-design.zip');
|
||||
});
|
||||
|
||||
it('falls back to the legacy quoted filename= when filename* is absent', () => {
|
||||
const resp = mockResponse({
|
||||
'content-disposition': 'attachment; filename="ui-design.zip"',
|
||||
});
|
||||
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('ui-design.zip');
|
||||
});
|
||||
|
||||
it('falls back to the active root slug when the header is missing', () => {
|
||||
const resp = mockResponse({});
|
||||
expect(archiveFilenameFrom(resp, 'fallback-title', 'ui-design')).toBe('ui-design.zip');
|
||||
});
|
||||
|
||||
it('falls back to the title slug when both header and root are absent', () => {
|
||||
const resp = mockResponse({});
|
||||
expect(archiveFilenameFrom(resp, 'My Artifact', '')).toBe('My-Artifact.zip');
|
||||
});
|
||||
|
||||
it('falls through to the slug when filename* is malformed', () => {
|
||||
// Truncated percent-escape — decodeURIComponent throws; we should not
|
||||
// surface the exception, just fall back to the next strategy.
|
||||
const resp = mockResponse({
|
||||
'content-disposition': "attachment; filename*=UTF-8''%E9%9D",
|
||||
});
|
||||
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('ui-design.zip');
|
||||
});
|
||||
});
|
||||
|
||||
// `exportAsMd` is a pass-through (the file body is the artifact source
|
||||
// verbatim, only the extension and Content-Type flip). Tests exercise it
|
||||
// end-to-end by stubbing the few DOM globals `triggerDownload` touches —
|
||||
// we run under `environment: 'node'`, so `document` and `URL` aren't
|
||||
// available by default. See issue #279.
|
||||
describe('exportAsMd', () => {
|
||||
let capturedBlob: Blob | undefined;
|
||||
let capturedFilename: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedBlob = undefined;
|
||||
capturedFilename = undefined;
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: (blob: Blob) => {
|
||||
capturedBlob = blob;
|
||||
return 'blob:test';
|
||||
},
|
||||
revokeObjectURL: () => {},
|
||||
});
|
||||
vi.stubGlobal('document', {
|
||||
createElement: () => {
|
||||
const anchor = { href: '', click: () => {} } as { href: string; download?: string; click: () => void };
|
||||
Object.defineProperty(anchor, 'download', {
|
||||
set(value: string) {
|
||||
capturedFilename = value;
|
||||
},
|
||||
get() {
|
||||
return capturedFilename ?? '';
|
||||
},
|
||||
});
|
||||
return anchor;
|
||||
},
|
||||
body: { appendChild: () => {}, removeChild: () => {} },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('downloads the source bytes verbatim under a `.md` extension', async () => {
|
||||
const source = '<!doctype html>\n<html lang="en"><body>hi</body></html>\n';
|
||||
|
||||
exportAsMd(source, 'TTC — Seed Round · 2026');
|
||||
|
||||
expect(capturedBlob).toBeDefined();
|
||||
expect(capturedBlob!.type).toBe('text/markdown;charset=utf-8');
|
||||
// Critical: no transformation, no normalization, no trimming. Whatever
|
||||
// the Source view shows is what lands in the .md.
|
||||
expect(await capturedBlob!.text()).toBe(source);
|
||||
expect(capturedFilename).toBe('TTC-Seed-Round-2026.md');
|
||||
});
|
||||
|
||||
it('falls back to "artifact.md" when the title is empty or unsafe', () => {
|
||||
exportAsMd('hello', '');
|
||||
expect(capturedFilename).toBe('artifact.md');
|
||||
|
||||
exportAsMd('hello', '???');
|
||||
expect(capturedFilename).toBe('artifact.md');
|
||||
});
|
||||
|
||||
it('keeps multi-byte content (UTF-8) intact end-to-end', async () => {
|
||||
const source = '# 中文标题\n\n这是 markdown 文件 — でも本当は HTML 源代码 (مرحبا)。\n';
|
||||
|
||||
exportAsMd(source, 'mixed');
|
||||
|
||||
expect(await capturedBlob!.text()).toBe(source);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sandboxed preview Blob exports', () => {
|
||||
let capturedBlob: Blob | undefined;
|
||||
let openedFeatures: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedBlob = undefined;
|
||||
openedFeatures = undefined;
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: (blob: Blob) => {
|
||||
capturedBlob = blob;
|
||||
return 'blob:test';
|
||||
},
|
||||
revokeObjectURL: () => {},
|
||||
});
|
||||
vi.stubGlobal('window', {
|
||||
open: (_url: string, _target: string, features?: string) => {
|
||||
openedFeatures = features;
|
||||
return null;
|
||||
},
|
||||
addEventListener: () => {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('wraps generated HTML in an opaque-origin sandbox for new-tab previews', async () => {
|
||||
openSandboxedPreviewInNewTab('<script>window.parent.localStorage.clear()</script>', 'Unsafe preview');
|
||||
|
||||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||||
expect(capturedBlob).toBeDefined();
|
||||
const wrapper = await capturedBlob!.text();
|
||||
expect(wrapper).toContain('sandbox="allow-scripts"');
|
||||
expect(wrapper).not.toContain('allow-same-origin');
|
||||
expect(wrapper).toContain('<script>window.parent.localStorage.clear()</script>');
|
||||
expect(wrapper).not.toContain('<script>window.parent.localStorage.clear()</script>');
|
||||
});
|
||||
|
||||
it('passes srcdoc options through the sandboxed new-tab wrapper', async () => {
|
||||
openSandboxedPreviewInNewTab('<section class="slide">One</section>', 'Deck preview', {
|
||||
deck: true,
|
||||
baseHref: '/artifacts/project/assets/',
|
||||
initialSlideIndex: 2,
|
||||
});
|
||||
|
||||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||||
expect(capturedBlob).toBeDefined();
|
||||
const wrapper = await capturedBlob!.text();
|
||||
expect(wrapper).toContain('sandbox="allow-scripts"');
|
||||
expect(wrapper).not.toContain('allow-same-origin');
|
||||
expect(wrapper).toContain('<base href="/artifacts/project/assets/">');
|
||||
expect(wrapper).toContain('od:slide');
|
||||
});
|
||||
|
||||
it('can build a print wrapper without granting same-origin access', () => {
|
||||
const wrapper = buildSandboxedPreviewDocument('<!doctype html><title>x</title>', 'Print', {
|
||||
allowModals: true,
|
||||
});
|
||||
|
||||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||||
expect(wrapper).not.toContain('allow-same-origin');
|
||||
});
|
||||
|
||||
it('uses a sandboxed noopener Blob wrapper by default for PDF exports', async () => {
|
||||
exportAsPdf('<script>window.parent.document.body.innerHTML="owned"</script>', 'PDF');
|
||||
|
||||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||||
expect(capturedBlob).toBeDefined();
|
||||
const wrapper = await capturedBlob!.text();
|
||||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||||
expect(wrapper).not.toContain('allow-same-origin');
|
||||
expect(wrapper).toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
|
||||
expect(wrapper).not.toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
|
||||
});
|
||||
|
||||
it('preserves deck print handling inside sandboxed PDF exports', async () => {
|
||||
exportAsPdf('<section class="slide">One</section>', 'Deck PDF', { deck: true });
|
||||
|
||||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||||
expect(capturedBlob).toBeDefined();
|
||||
const wrapper = await capturedBlob!.text();
|
||||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||||
expect(wrapper).not.toContain('allow-same-origin');
|
||||
expect(wrapper).toContain('data-deck-print="injected"');
|
||||
expect(wrapper).toContain('page-break-after: always;');
|
||||
});
|
||||
|
||||
it('allows explicit trusted PDF opt-out without changing the secure default', async () => {
|
||||
exportAsPdf('<main>Trusted local document</main>', 'Trusted PDF', {
|
||||
sandboxedPreview: false,
|
||||
});
|
||||
|
||||
expect(openedFeatures).toBeUndefined();
|
||||
expect(capturedBlob).toBeDefined();
|
||||
const doc = await capturedBlob!.text();
|
||||
expect(doc).not.toContain('sandbox="allow-scripts allow-modals"');
|
||||
expect(doc).toContain('<main>Trusted local document</main>');
|
||||
});
|
||||
});
|
||||
61
apps/web/tests/runtime/react-component.test.ts
Normal file
61
apps/web/tests/runtime/react-component.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildReactComponentSrcdoc, prepareReactComponentSource } from '../../src/runtime/react-component';
|
||||
|
||||
describe('prepareReactComponentSource', () => {
|
||||
it('adapts a default function export for iframe rendering', () => {
|
||||
const out = prepareReactComponentSource(`
|
||||
import React from 'react';
|
||||
export default function Card() {
|
||||
return <div>Card</div>;
|
||||
}
|
||||
`);
|
||||
expect(out).not.toContain('import React');
|
||||
expect(out).toContain('function Card()');
|
||||
expect(out).toContain('window.__OpenDesignComponent');
|
||||
expect(out).toContain("typeof Card !== 'undefined' ? Card : null");
|
||||
});
|
||||
|
||||
it('adapts a named component export for iframe rendering', () => {
|
||||
const out = prepareReactComponentSource('export const Preview = () => <main />;');
|
||||
expect(out).toContain('const Preview =');
|
||||
expect(out).toContain("typeof Preview !== 'undefined' ? Preview : null");
|
||||
});
|
||||
|
||||
it('preserves React hook imports as runtime bindings', () => {
|
||||
const out = prepareReactComponentSource(`
|
||||
import { useState, useEffect as useReactEffect } from 'react';
|
||||
export default function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
useReactEffect(() => setCount(1), []);
|
||||
return <button>{count}</button>;
|
||||
}
|
||||
`);
|
||||
expect(out).not.toContain("import { useState");
|
||||
expect(out).toContain('const { useState, useEffect: useReactEffect } = window.React;');
|
||||
expect(out).toContain('function Counter()');
|
||||
});
|
||||
|
||||
it('detects default re-exports before removing export specifiers', () => {
|
||||
const out = prepareReactComponentSource(`
|
||||
const Foo = () => <main />;
|
||||
export { Foo as default };
|
||||
`);
|
||||
expect(out).not.toContain('export { Foo as default }');
|
||||
expect(out).toContain("typeof Foo !== 'undefined' ? Foo : null");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildReactComponentSrcdoc', () => {
|
||||
it('builds a standalone sandbox document with React runtime scripts', () => {
|
||||
const doc = buildReactComponentSrcdoc('export default function App(){ return <div /> }', {
|
||||
title: 'App',
|
||||
});
|
||||
expect(doc).toContain('<!doctype html>');
|
||||
expect(doc).toContain('react@18/umd/react.development.js');
|
||||
expect(doc).toContain('@babel/standalone');
|
||||
expect(doc).toContain('artifact.tsx');
|
||||
expect(doc).toContain('sandboxed iframe');
|
||||
expect(doc).toContain('(0, eval)(compiled)');
|
||||
});
|
||||
});
|
||||
80
apps/web/tests/runtime/srcdoc.test.ts
Normal file
80
apps/web/tests/runtime/srcdoc.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { buildSrcdoc } from '../../src/runtime/srcdoc';
|
||||
|
||||
const deckHtml = `<!doctype html>
|
||||
<html>
|
||||
<head><title>Deck</title></head>
|
||||
<body>
|
||||
<section class="slide active">One</section>
|
||||
<section class="slide">Two</section>
|
||||
<section class="slide">Three</section>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
describe('buildSrcdoc', () => {
|
||||
it('injects an initial slide index for deck previews', () => {
|
||||
const doc = buildSrcdoc(deckHtml, { deck: true, initialSlideIndex: 2 });
|
||||
|
||||
expect(doc).toContain('var initialSlideIndex = 2;');
|
||||
expect(doc).toContain('setTimeout(restoreInitialSlide, 200)');
|
||||
expect(doc).toContain('setTimeout(restoreInitialSlide, 100)');
|
||||
});
|
||||
|
||||
it('clamps invalid initial slide indices before injecting deck bridge script', () => {
|
||||
const doc = buildSrcdoc(deckHtml, { deck: true, initialSlideIndex: -4 });
|
||||
|
||||
expect(doc).toContain('var initialSlideIndex = 0;');
|
||||
});
|
||||
|
||||
it('only uses directly mutable slide conventions for setActive support', () => {
|
||||
const srcdoc = buildSrcdoc(
|
||||
'<section class="slide">One</section><section class="slide">Two</section>',
|
||||
{ deck: true }
|
||||
);
|
||||
|
||||
const canSetActive = srcdoc.match(/function canSetActive\(list\)\{([\s\S]*?)\n \}/)?.[1] ?? '';
|
||||
|
||||
expect(canSetActive).toContain('findActiveByClass(list) >= 0');
|
||||
expect(canSetActive).toContain("list[i].style.display === 'none'");
|
||||
expect(canSetActive).toContain("list[i].style.visibility === 'hidden'");
|
||||
expect(canSetActive).toContain("list[i].hasAttribute('hidden')");
|
||||
expect(canSetActive).not.toContain('findActiveByVisibility');
|
||||
});
|
||||
|
||||
it('enables the comment bridge immediately when injected', () => {
|
||||
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
|
||||
commentBridge: true,
|
||||
});
|
||||
|
||||
expect(srcdoc).toContain('data-od-comment-bridge');
|
||||
expect(srcdoc).toContain('var enabled = true;');
|
||||
expect(srcdoc).toContain("var mode = 'picker';");
|
||||
expect(srcdoc).toContain("type: 'od:comment-target'");
|
||||
expect(srcdoc).toContain("type: 'od:comment-hover'");
|
||||
expect(srcdoc).toContain("type: 'od:comment-leave'");
|
||||
expect(srcdoc).toContain("type: 'od:comment-targets'");
|
||||
expect(srcdoc).toContain("postStroke('od:pod-stroke')");
|
||||
expect(srcdoc).toContain("postStroke('od:pod-select')");
|
||||
expect(srcdoc).toContain('data-od-comment-mode-kind');
|
||||
expect(srcdoc).toContain("body * { cursor: crosshair !important; }");
|
||||
expect(srcdoc).toContain('MutationObserver(schedulePostTargets)');
|
||||
expect(srcdoc).toContain("document.addEventListener('scroll', schedulePostTargets, true);");
|
||||
expect(srcdoc).toContain('data-od-comment-bridge-style');
|
||||
});
|
||||
|
||||
it('marks source-authored edit targets before runtime scripts can add nodes', () => {
|
||||
const dom = new JSDOM('');
|
||||
globalThis.DOMParser = dom.window.DOMParser;
|
||||
const srcdoc = buildSrcdoc(
|
||||
'<main><h1>Source title</h1><script>document.body.prepend(document.createElement("h1"));</script></main>',
|
||||
{ editBridge: true },
|
||||
);
|
||||
Reflect.deleteProperty(globalThis, 'DOMParser');
|
||||
|
||||
expect(srcdoc).toContain('data-od-source-path="path-0"');
|
||||
expect(srcdoc).toContain('data-od-source-path="path-0-0"');
|
||||
expect(srcdoc).not.toContain('<script data-od-source-path=');
|
||||
expect(srcdoc.indexOf('data-od-source-path="path-0"')).toBeLessThan(srcdoc.indexOf('document.body.prepend'));
|
||||
});
|
||||
});
|
||||
84
apps/web/tests/runtime/todos.test.ts
Normal file
84
apps/web/tests/runtime/todos.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
latestTodosFromEvents,
|
||||
parseTodoWriteInput,
|
||||
unfinishedTodosFromEvents,
|
||||
} from '../../src/runtime/todos';
|
||||
import type { AgentEvent } from '../../src/types';
|
||||
|
||||
const firstTodoInput = {
|
||||
todos: [
|
||||
{ content: 'Draft layout', status: 'completed' },
|
||||
{ content: 'Build components', status: 'in_progress', activeForm: 'Building components' },
|
||||
{ content: 'Run QA', status: 'pending' },
|
||||
{ content: '', status: 'pending' },
|
||||
{ content: 'Unknown status defaults pending', status: 'blocked' },
|
||||
null,
|
||||
],
|
||||
};
|
||||
|
||||
describe('todo event helpers', () => {
|
||||
it('normalizes TodoWrite input and ignores malformed items', () => {
|
||||
expect(parseTodoWriteInput(firstTodoInput)).toEqual([
|
||||
{ content: 'Draft layout', status: 'completed', activeForm: undefined },
|
||||
{
|
||||
content: 'Build components',
|
||||
status: 'in_progress',
|
||||
activeForm: 'Building components',
|
||||
},
|
||||
{ content: 'Run QA', status: 'pending', activeForm: undefined },
|
||||
{
|
||||
content: 'Unknown status defaults pending',
|
||||
status: 'pending',
|
||||
activeForm: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the latest TodoWrite event as the current todo truth', () => {
|
||||
const events: AgentEvent[] = [
|
||||
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
|
||||
{ kind: 'text', text: 'Working...' },
|
||||
{ kind: 'tool_use', id: 'todo-empty', name: 'TodoWrite', input: { todos: [] } },
|
||||
{
|
||||
kind: 'tool_use',
|
||||
id: 'todo-2',
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content: 'Final polish', status: 'pending' }] },
|
||||
},
|
||||
];
|
||||
|
||||
expect(latestTodosFromEvents(events)).toEqual([
|
||||
{ content: 'Final polish', status: 'pending', activeForm: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats an empty latest TodoWrite event as authoritative', () => {
|
||||
const events: AgentEvent[] = [
|
||||
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
|
||||
{ kind: 'text', text: 'All done.' },
|
||||
{ kind: 'tool_use', id: 'todo-empty', name: 'TodoWrite', input: { todos: [] } },
|
||||
];
|
||||
|
||||
expect(latestTodosFromEvents(events)).toEqual([]);
|
||||
expect(unfinishedTodosFromEvents(events)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns only pending and in-progress todos as unfinished', () => {
|
||||
expect(unfinishedTodosFromEvents([
|
||||
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
|
||||
])).toEqual([
|
||||
{
|
||||
content: 'Build components',
|
||||
status: 'in_progress',
|
||||
activeForm: 'Building components',
|
||||
},
|
||||
{ content: 'Run QA', status: 'pending', activeForm: undefined },
|
||||
{
|
||||
content: 'Unknown status defaults pending',
|
||||
status: 'pending',
|
||||
activeForm: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
203
apps/web/tests/runtime/tool-renderers.test.tsx
Normal file
203
apps/web/tests/runtime/tool-renderers.test.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useState } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ToolCard } from '../../src/components/ToolCard';
|
||||
import {
|
||||
clearToolRenderers,
|
||||
deriveToolStatus,
|
||||
getToolRenderer,
|
||||
registerToolRenderer,
|
||||
toRenderProps,
|
||||
} from '../../src/runtime/tool-renderers';
|
||||
import type { ToolRenderProps } from '../../src/runtime/tool-renderers';
|
||||
import type { AgentEvent } from '../../src/types';
|
||||
|
||||
type ToolUse = Extract<AgentEvent, { kind: 'tool_use' }>;
|
||||
type ToolResult = Extract<AgentEvent, { kind: 'tool_result' }>;
|
||||
|
||||
function use(input: unknown, name = 'render_chart', id = 't1'): ToolUse {
|
||||
return { kind: 'tool_use', id, name, input };
|
||||
}
|
||||
|
||||
function ok(content: string, id = 't1'): ToolResult {
|
||||
return { kind: 'tool_result', toolUseId: id, content, isError: false };
|
||||
}
|
||||
|
||||
function err(content: string, id = 't1'): ToolResult {
|
||||
return { kind: 'tool_result', toolUseId: id, content, isError: true };
|
||||
}
|
||||
|
||||
describe('deriveToolStatus', () => {
|
||||
it('returns "executing" while the run is streaming and no result has arrived', () => {
|
||||
expect(deriveToolStatus(undefined, true)).toBe('executing');
|
||||
});
|
||||
|
||||
it('returns "inProgress" when the run died before the tool returned', () => {
|
||||
expect(deriveToolStatus(undefined, false)).toBe('inProgress');
|
||||
});
|
||||
|
||||
it('returns "complete" on a clean tool result', () => {
|
||||
expect(deriveToolStatus(ok('ok'), true)).toBe('complete');
|
||||
});
|
||||
|
||||
it('returns "error" when the tool result carries isError', () => {
|
||||
expect(deriveToolStatus(err('boom'), true)).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toRenderProps', () => {
|
||||
it('packs args / result / isError into the AG-UI render-prop shape', () => {
|
||||
const u = use({ city: 'SF' }, 'get_weather');
|
||||
const props = toRenderProps(u, ok('{"temp":61}'), true);
|
||||
expect(props).toEqual({
|
||||
status: 'complete',
|
||||
name: 'get_weather',
|
||||
args: { city: 'SF' },
|
||||
result: '{"temp":61}',
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits result while the tool is still running', () => {
|
||||
const u = use({ city: 'SF' }, 'get_weather');
|
||||
const props = toRenderProps(u, undefined, true);
|
||||
expect(props.status).toBe('executing');
|
||||
expect(props.result).toBeUndefined();
|
||||
expect(props.isError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool renderer registry', () => {
|
||||
afterEach(() => clearToolRenderers());
|
||||
|
||||
it('registers, looks up, and unregisters renderers', () => {
|
||||
const r = () => null;
|
||||
expect(getToolRenderer('xyz')).toBeUndefined();
|
||||
const dispose = registerToolRenderer('xyz', r);
|
||||
expect(getToolRenderer('xyz')).toBe(r);
|
||||
dispose();
|
||||
expect(getToolRenderer('xyz')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('overwrites on re-registration (last writer wins)', () => {
|
||||
const a = () => null;
|
||||
const b = () => null;
|
||||
registerToolRenderer('xyz', a);
|
||||
registerToolRenderer('xyz', b);
|
||||
expect(getToolRenderer('xyz')).toBe(b);
|
||||
});
|
||||
|
||||
it('does not unregister a renderer that has been overwritten', () => {
|
||||
const a = () => null;
|
||||
const b = () => null;
|
||||
const disposeA = registerToolRenderer('xyz', a);
|
||||
registerToolRenderer('xyz', b);
|
||||
disposeA();
|
||||
expect(getToolRenderer('xyz')).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolCard dispatch', () => {
|
||||
afterEach(() => clearToolRenderers());
|
||||
|
||||
it('routes unknown tool names through the registry', () => {
|
||||
registerToolRenderer('render_chart', ({ status, args }) => (
|
||||
<div data-testid="custom-chart" data-status={status}>
|
||||
{(args as { label?: string }).label}
|
||||
</div>
|
||||
));
|
||||
const markup = renderToStaticMarkup(
|
||||
<ToolCard use={use({ label: 'Q3 revenue' })} runStreaming={true} />,
|
||||
);
|
||||
expect(markup).toContain('data-testid="custom-chart"');
|
||||
expect(markup).toContain('data-status="executing"');
|
||||
expect(markup).toContain('Q3 revenue');
|
||||
});
|
||||
|
||||
it('passes the result content through as the `result` prop on completion', () => {
|
||||
registerToolRenderer('render_chart', ({ status, result }) => (
|
||||
<span data-testid="custom-chart" data-status={status}>
|
||||
{result}
|
||||
</span>
|
||||
));
|
||||
const markup = renderToStaticMarkup(
|
||||
<ToolCard use={use({})} result={ok('payload')} runStreaming={false} />,
|
||||
);
|
||||
expect(markup).toContain('data-status="complete"');
|
||||
expect(markup).toContain('payload');
|
||||
});
|
||||
|
||||
it('falls back to the built-in card when the registered renderer returns null', () => {
|
||||
registerToolRenderer('Bash', () => null);
|
||||
const markup = renderToStaticMarkup(
|
||||
<ToolCard use={use({ command: 'ls' }, 'Bash')} runStreaming={true} />,
|
||||
);
|
||||
expect(markup).toContain('op-bash');
|
||||
expect(markup).toContain('ls');
|
||||
});
|
||||
|
||||
it('lets a registered renderer override a built-in family card', () => {
|
||||
registerToolRenderer('Bash', ({ args }) => (
|
||||
<pre data-testid="custom-bash">{(args as { command?: string }).command}</pre>
|
||||
));
|
||||
const markup = renderToStaticMarkup(
|
||||
<ToolCard use={use({ command: 'whoami' }, 'Bash')} runStreaming={true} />,
|
||||
);
|
||||
expect(markup).toContain('data-testid="custom-bash"');
|
||||
expect(markup).not.toContain('op-bash');
|
||||
});
|
||||
|
||||
it('mounts hookful renderer output as a child component, surviving replace + dispose', () => {
|
||||
// The documented contract: renderers must be hook-free, but they may
|
||||
// return a component *element* whose body uses hooks. That child gets
|
||||
// mounted as its own component, so swapping the renderer (or letting
|
||||
// it return null) does not violate the Rules of Hooks on ToolCard.
|
||||
function HookfulCardA({ args }: ToolRenderProps) {
|
||||
const [count] = useState(() => (args as { start?: number }).start ?? 0);
|
||||
return <span data-testid="hookful-a">A:{count}</span>;
|
||||
}
|
||||
function HookfulCardB({ result }: ToolRenderProps) {
|
||||
const [label] = useState('mounted');
|
||||
return (
|
||||
<span data-testid="hookful-b">
|
||||
B:{label}:{result ?? ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const disposeA = registerToolRenderer('render_chart', (props) => <HookfulCardA {...props} />);
|
||||
const first = renderToStaticMarkup(
|
||||
<ToolCard use={use({ start: 7 })} runStreaming={true} />,
|
||||
);
|
||||
expect(first).toContain('data-testid="hookful-a"');
|
||||
expect(first).toContain('A:7');
|
||||
|
||||
// Swap to a renderer with a different hook shape. If the renderer
|
||||
// were called as a plain function inside ToolCard, this would shift
|
||||
// ToolCard's hook sequence; mounting as a child component isolates
|
||||
// each renderer's hooks to its own fiber.
|
||||
disposeA();
|
||||
registerToolRenderer('render_chart', (props) => <HookfulCardB {...props} />);
|
||||
const second = renderToStaticMarkup(
|
||||
<ToolCard use={use({})} result={ok('payload')} runStreaming={false} />,
|
||||
);
|
||||
expect(second).toContain('data-testid="hookful-b"');
|
||||
expect(second).toContain('B:mounted:payload');
|
||||
expect(second).not.toContain('hookful-a');
|
||||
});
|
||||
|
||||
it('falls back to the built-in card when a registered renderer throws', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
registerToolRenderer('Bash', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const markup = renderToStaticMarkup(
|
||||
<ToolCard use={use({ command: 'ls' }, 'Bash')} runStreaming={true} />,
|
||||
);
|
||||
expect(markup).toContain('op-bash');
|
||||
expect(markup).toContain('ls');
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user