diff --git a/static/js/render/registry.test.js b/static/js/render/registry.test.js index 28ee6b8..4f7b7b6 100644 --- a/static/js/render/registry.test.js +++ b/static/js/render/registry.test.js @@ -34,6 +34,10 @@ describe('TOOL_USE_RENDERERS', () => { } }); + it('CORE_TOOL_USE stays in sync with registry keys', () => { + expect(new Set(CORE_TOOL_USE)).toEqual(new Set(Object.keys(TOOL_USE_RENDERERS))); + }); + it('does not register the unknown dispatch sentinel as a tool renderer', () => { expect(Object.prototype.hasOwnProperty.call(TOOL_USE_RENDERERS, UNKNOWN_DISPATCH_KEY)).toBe(false); }); @@ -69,6 +73,10 @@ describe('TOOL_RESULT_RENDERERS', () => { } }); + it('CORE_TOOL_RESULT stays in sync with registry keys', () => { + expect(new Set(CORE_TOOL_RESULT)).toEqual(new Set(Object.keys(TOOL_RESULT_RENDERERS))); + }); + it('renderBashResult escapes stdout', () => { const html = renderToolResult({ result_type: 'bash', @@ -188,6 +196,56 @@ describe('renderTodoWriteResult', () => { }); }); +/** Representative fixtures for registry-driven behavioral smoke tests. */ +const TOOL_USE_FIXTURES = { + Bash: { input: { command: 'echo hi', description: 'say hi' } }, + Read: { input: { file_path: 'README.md' } }, + Write: { input: { file_path: 'out.txt', content: 'data' } }, + Edit: { input: { file_path: 'a.js', old_string: 'x', new_string: 'y' } }, + Glob: { input: { pattern: '*.js', path: 'src' } }, + Grep: { input: { pattern: 'TODO', path: 'lib' } }, + Task: { input: { subagent_type: 'explore', description: 'scan', prompt: 'go' } }, + TodoWrite: { input: { todos: [{ status: 'pending', content: 'task' }] } }, + AskUserQuestion: { input: { questions: [{ question: 'OK?' }] } }, + WebFetch: { input: { url: 'https://example.com' } }, + WebSearch: { input: { query: 'vitest' } }, +}; + +const TOOL_RESULT_FIXTURES = { + bash: { exit_code: 0, stdout: 'ok' }, + file_read: { file_path: '/a.txt', num_lines: 10 }, + file_edit: { file_path: 'b.js' }, + file_write: { file_path: 'c.txt' }, + glob: { num_files: 3 }, + grep: { num_files: 2, num_lines: 5 }, + web_search: { query: 'test', result_count: 1 }, + web_fetch: { url: 'https://x.com', status_code: 200 }, + task: { status: 'completed', total_duration_ms: 1000 }, + todo_write: { todos: [{ status: 'pending', content: 'x' }] }, + user_input: { questions: [{ question: 'Q' }], answers: { Q: 'A' } }, + plan: { file_path: 'plan.md' }, +}; + +describe('registry behavioral smoke tests', () => { + it('every registered tool_use renderer produces non-empty HTML', () => { + for (const name of CORE_TOOL_USE) { + expect(TOOL_USE_FIXTURES[name], name).toBeDefined(); + const html = renderToolUse({ name, ...TOOL_USE_FIXTURES[name] }); + expect(html, name).toContain('tool-call'); + expect(html.length, name).toBeGreaterThan(20); + } + }); + + it('every registered tool_result renderer produces non-empty HTML', () => { + for (const rt of CORE_TOOL_RESULT) { + expect(TOOL_RESULT_FIXTURES[rt], rt).toBeDefined(); + const html = renderToolResult({ result_type: rt, ...TOOL_RESULT_FIXTURES[rt] }); + expect(html, rt).toContain('tool-result'); + expect(html.length, rt).toBeGreaterThan(20); + } + }); +}); + describe('renderToolResult fallback', () => { it('renders raw result type and JSON payload for unknown result types', () => { const html = renderToolResult({ diff --git a/static/js/render/test_helpers.js b/static/js/render/test_helpers.js new file mode 100644 index 0000000..7213c86 --- /dev/null +++ b/static/js/render/test_helpers.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { renderToolResult } from './registry.js'; + +/** Common XSS payloads for renderer escaping assertions. */ +export const XSS_SCRIPT = ''; +export const XSS_IMG = ''; + +/** + * Assert raw HTML fragments are escaped (not present verbatim). + */ +export function expectNoRawHtml(html, rawFragments) { + for (const frag of rawFragments) { + expect(html).not.toContain(frag); + } +} + +/** + * Assert an HTML-escaped fragment appears in output. + */ +export function expectEscaped(html, escapedFragment) { + expect(html).toContain(escapedFragment); +} + +/** + * Shared behavioral tests for summary-only tool_result renderers (Edited/Plan/Wrote). + */ +export function describeSummaryOnlyResult( + render, + { suiteName, resultType, label, samplePath }, +) { + describe(suiteName, () => { + it(`renders ${label.toLowerCase()} file path in summary`, () => { + const html = render({ result_type: resultType, file_path: samplePath }); + expect(html).toContain(`${label}: ${samplePath}`); + expect(html).toContain('tool-result'); + }); + + it('handles missing file path', () => { + const html = render({ result_type: resultType }); + expect(html).toContain(`${label}:`); + expect(html).not.toContain('undefined'); + }); + + it('escapes HTML in file path via registry', () => { + const html = renderToolResult({ + result_type: resultType, + file_path: XSS_SCRIPT, + }); + expectNoRawHtml(html, [XSS_SCRIPT]); + }); + }); +} diff --git a/static/js/render/tool_result/bash.test.js b/static/js/render/tool_result/bash.test.js new file mode 100644 index 0000000..75a0ec7 --- /dev/null +++ b/static/js/render/tool_result/bash.test.js @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { renderBashResult } from './bash.js'; +import { renderToolResult } from '../registry.js'; +import { expectNoRawHtml, XSS_IMG } from '../test_helpers.js'; + +describe('renderBashResult', () => { + it('renders success status with stdout', () => { + const html = renderBashResult({ + result_type: 'bash', + exit_code: 0, + stdout: 'hello\nworld', + }); + expect(html).toContain('Bash Result (success)'); + expect(html).toContain('hello'); + expect(html).toContain('stdout'); + expect(html).toContain('tool-result'); + }); + + it('renders error status with stderr', () => { + const html = renderBashResult({ + result_type: 'bash', + exit_code: 1, + is_error: true, + stderr: 'command failed', + }); + expect(html).toContain('error (exit 1)'); + expect(html).toContain('command failed'); + expect(html).toContain('stderr'); + }); + + it('renders interrupted status', () => { + const html = renderBashResult({ result_type: 'bash', interrupted: true }); + expect(html).toContain('Bash Result (interrupted)'); + }); + + it('escapes HTML in stdout and stderr', () => { + const html = renderToolResult({ + result_type: 'bash', + exit_code: 0, + stdout: XSS_IMG, + stderr: '', + }); + expectNoRawHtml(html, [XSS_IMG, '', + }, + }); + expectNoRawHtml(html, [XSS_SCRIPT, '', '