Skip to content

Commit 46ae6f0

Browse files
test: add vitest coverage for tool render modules (#123)
* test: add vitest coverage for tool render modules Add per-renderer vitest coverage with XSS escaping assertions, a shared test helper, registry-driven smoke tests, and raised coverage thresholds. * test: renderer test fixes from PR review
1 parent a725bd2 commit 46ae6f0

23 files changed

Lines changed: 763 additions & 4 deletions

static/js/render/registry.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ describe('TOOL_USE_RENDERERS', () => {
3434
}
3535
});
3636

37+
it('CORE_TOOL_USE stays in sync with registry keys', () => {
38+
expect(new Set(CORE_TOOL_USE)).toEqual(new Set(Object.keys(TOOL_USE_RENDERERS)));
39+
});
40+
3741
it('does not register the unknown dispatch sentinel as a tool renderer', () => {
3842
expect(Object.prototype.hasOwnProperty.call(TOOL_USE_RENDERERS, UNKNOWN_DISPATCH_KEY)).toBe(false);
3943
});
@@ -69,6 +73,10 @@ describe('TOOL_RESULT_RENDERERS', () => {
6973
}
7074
});
7175

76+
it('CORE_TOOL_RESULT stays in sync with registry keys', () => {
77+
expect(new Set(CORE_TOOL_RESULT)).toEqual(new Set(Object.keys(TOOL_RESULT_RENDERERS)));
78+
});
79+
7280
it('renderBashResult escapes stdout', () => {
7381
const html = renderToolResult({
7482
result_type: 'bash',
@@ -188,6 +196,56 @@ describe('renderTodoWriteResult', () => {
188196
});
189197
});
190198

199+
/** Representative fixtures for registry-driven behavioral smoke tests. */
200+
const TOOL_USE_FIXTURES = {
201+
Bash: { input: { command: 'echo hi', description: 'say hi' } },
202+
Read: { input: { file_path: 'README.md' } },
203+
Write: { input: { file_path: 'out.txt', content: 'data' } },
204+
Edit: { input: { file_path: 'a.js', old_string: 'x', new_string: 'y' } },
205+
Glob: { input: { pattern: '*.js', path: 'src' } },
206+
Grep: { input: { pattern: 'TODO', path: 'lib' } },
207+
Task: { input: { subagent_type: 'explore', description: 'scan', prompt: 'go' } },
208+
TodoWrite: { input: { todos: [{ status: 'pending', content: 'task' }] } },
209+
AskUserQuestion: { input: { questions: [{ question: 'OK?' }] } },
210+
WebFetch: { input: { url: 'https://example.com' } },
211+
WebSearch: { input: { query: 'vitest' } },
212+
};
213+
214+
const TOOL_RESULT_FIXTURES = {
215+
bash: { exit_code: 0, stdout: 'ok' },
216+
file_read: { file_path: '/a.txt', num_lines: 10 },
217+
file_edit: { file_path: 'b.js' },
218+
file_write: { file_path: 'c.txt' },
219+
glob: { num_files: 3 },
220+
grep: { num_files: 2, num_lines: 5 },
221+
web_search: { query: 'test', result_count: 1 },
222+
web_fetch: { url: 'https://x.com', status_code: 200 },
223+
task: { status: 'completed', total_duration_ms: 1000 },
224+
todo_write: { todos: [{ status: 'pending', content: 'x' }] },
225+
user_input: { questions: [{ question: 'Q' }], answers: { Q: 'A' } },
226+
plan: { file_path: 'plan.md' },
227+
};
228+
229+
describe('registry behavioral smoke tests', () => {
230+
it('every registered tool_use renderer produces non-empty HTML', () => {
231+
for (const name of CORE_TOOL_USE) {
232+
expect(TOOL_USE_FIXTURES[name], name).toBeDefined();
233+
const html = renderToolUse({ name, ...TOOL_USE_FIXTURES[name] });
234+
expect(html, name).toContain('tool-call');
235+
expect(html.length, name).toBeGreaterThan(20);
236+
}
237+
});
238+
239+
it('every registered tool_result renderer produces non-empty HTML', () => {
240+
for (const rt of CORE_TOOL_RESULT) {
241+
expect(TOOL_RESULT_FIXTURES[rt], rt).toBeDefined();
242+
const html = renderToolResult({ result_type: rt, ...TOOL_RESULT_FIXTURES[rt] });
243+
expect(html, rt).toContain('tool-result');
244+
expect(html.length, rt).toBeGreaterThan(20);
245+
}
246+
});
247+
});
248+
191249
describe('renderToolResult fallback', () => {
192250
it('renders raw result type and JSON payload for unknown result types', () => {
193251
const html = renderToolResult({

static/js/render/test_helpers.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { renderToolResult } from './registry.js';
3+
4+
/** Common XSS payloads for renderer escaping assertions. */
5+
export const XSS_SCRIPT = '<script>alert(1)</script>';
6+
export const XSS_IMG = '<img onerror=alert(1)>';
7+
8+
/**
9+
* Assert raw HTML fragments are escaped (not present verbatim).
10+
*/
11+
export function expectNoRawHtml(html, rawFragments) {
12+
for (const frag of rawFragments) {
13+
expect(html).not.toContain(frag);
14+
}
15+
}
16+
17+
/**
18+
* Assert an HTML-escaped fragment appears in output.
19+
*/
20+
export function expectEscaped(html, escapedFragment) {
21+
expect(html).toContain(escapedFragment);
22+
}
23+
24+
/**
25+
* Shared behavioral tests for summary-only tool_result renderers (Edited/Plan/Wrote).
26+
*/
27+
export function describeSummaryOnlyResult(
28+
render,
29+
{ suiteName, resultType, label, samplePath },
30+
) {
31+
describe(suiteName, () => {
32+
it(`renders ${label.toLowerCase()} file path in summary`, () => {
33+
const html = render({ result_type: resultType, file_path: samplePath });
34+
expect(html).toContain(`${label}: ${samplePath}`);
35+
expect(html).toContain('tool-result');
36+
});
37+
38+
it('handles missing file path', () => {
39+
const html = render({ result_type: resultType });
40+
expect(html).toContain(`${label}:`);
41+
expect(html).not.toContain('undefined');
42+
});
43+
44+
it('escapes HTML in file path via registry', () => {
45+
const html = renderToolResult({
46+
result_type: resultType,
47+
file_path: XSS_SCRIPT,
48+
});
49+
expectNoRawHtml(html, [XSS_SCRIPT]);
50+
});
51+
});
52+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { renderBashResult } from './bash.js';
3+
import { renderToolResult } from '../registry.js';
4+
import { expectNoRawHtml, XSS_IMG } from '../test_helpers.js';
5+
6+
describe('renderBashResult', () => {
7+
it('renders success status with stdout', () => {
8+
const html = renderBashResult({
9+
result_type: 'bash',
10+
exit_code: 0,
11+
stdout: 'hello\nworld',
12+
});
13+
expect(html).toContain('Bash Result (success)');
14+
expect(html).toContain('hello');
15+
expect(html).toContain('stdout');
16+
expect(html).toContain('tool-result');
17+
});
18+
19+
it('renders error status with stderr', () => {
20+
const html = renderBashResult({
21+
result_type: 'bash',
22+
exit_code: 1,
23+
is_error: true,
24+
stderr: 'command failed',
25+
});
26+
expect(html).toContain('error (exit 1)');
27+
expect(html).toContain('command failed');
28+
expect(html).toContain('stderr');
29+
});
30+
31+
it('renders interrupted status', () => {
32+
const html = renderBashResult({ result_type: 'bash', interrupted: true });
33+
expect(html).toContain('Bash Result (interrupted)');
34+
});
35+
36+
it('escapes HTML in stdout and stderr', () => {
37+
const html = renderToolResult({
38+
result_type: 'bash',
39+
exit_code: 0,
40+
stdout: XSS_IMG,
41+
stderr: '<script>x</script>',
42+
});
43+
expectNoRawHtml(html, [XSS_IMG, '<script>']);
44+
expect(html).toContain('&lt;img');
45+
});
46+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { renderToolResultFallback } from './fallback.js';
3+
import { renderToolResult } from '../registry.js';
4+
import { expectNoRawHtml, XSS_SCRIPT } from '../test_helpers.js';
5+
6+
describe('renderToolResultFallback', () => {
7+
it('renders unknown result type and JSON payload', () => {
8+
const html = renderToolResultFallback({
9+
result_type: 'custom_widget',
10+
widget_id: 99,
11+
label: 'test',
12+
});
13+
expect(html).toContain('Unknown tool result: custom_widget');
14+
expect(html).toContain('&quot;widget_id&quot;');
15+
expect(html).toContain('99');
16+
expect(html).toContain('tool-result');
17+
});
18+
19+
it('uses unknown dispatch key when result_type is missing', () => {
20+
const html = renderToolResultFallback({ payload: 'data' });
21+
expect(html).toContain('Unknown tool result:');
22+
expect(html).toContain('tool-result');
23+
});
24+
25+
it('escapes HTML in JSON fallback body', () => {
26+
const html = renderToolResult({
27+
result_type: 'mystery',
28+
content: XSS_SCRIPT,
29+
});
30+
expectNoRawHtml(html, [XSS_SCRIPT]);
31+
expect(html).toContain('&lt;script&gt;');
32+
});
33+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { renderFileEditResult } from './file_edit.js';
2+
import { describeSummaryOnlyResult } from '../test_helpers.js';
3+
4+
describeSummaryOnlyResult(renderFileEditResult, {
5+
suiteName: 'renderFileEditResult',
6+
resultType: 'file_edit',
7+
label: 'Edited',
8+
samplePath: 'src/app.js',
9+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { renderFileWriteResult } from './file_write.js';
3+
import { renderToolResult } from '../registry.js';
4+
import { expectNoRawHtml, XSS_SCRIPT } from '../test_helpers.js';
5+
6+
describe('renderFileWriteResult', () => {
7+
it('renders written file path in summary', () => {
8+
const html = renderFileWriteResult({
9+
result_type: 'file_write',
10+
file_path: 'output/data.json',
11+
});
12+
expect(html).toContain('Wrote: output/data.json');
13+
expect(html).toContain('tool-result');
14+
});
15+
16+
it('handles missing file path', () => {
17+
const html = renderFileWriteResult({ result_type: 'file_write' });
18+
expect(html).toContain('Wrote:');
19+
expect(html).not.toContain('undefined');
20+
});
21+
22+
it('escapes HTML in file path via registry', () => {
23+
const html = renderToolResult({
24+
result_type: 'file_write',
25+
file_path: XSS_SCRIPT,
26+
});
27+
expectNoRawHtml(html, [XSS_SCRIPT]);
28+
});
29+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { renderGlobResult } from './glob.js';
3+
4+
describe('renderGlobResult', () => {
5+
it('renders file count in summary', () => {
6+
const html = renderGlobResult({
7+
result_type: 'glob',
8+
num_files: 12,
9+
});
10+
expect(html).toContain('Glob: 12 files found');
11+
expect(html).toContain('tool-result');
12+
});
13+
14+
it('shows truncated flag when set', () => {
15+
const html = renderGlobResult({
16+
result_type: 'glob',
17+
num_files: 100,
18+
truncated: true,
19+
});
20+
expect(html).toContain('(truncated)');
21+
});
22+
23+
it('defaults to zero files when num_files is absent', () => {
24+
const html = renderGlobResult({ result_type: 'glob' });
25+
expect(html).toContain('Glob: 0 files found');
26+
});
27+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { renderGrepResult } from './grep.js';
3+
4+
describe('renderGrepResult', () => {
5+
it('renders file and line counts in summary', () => {
6+
const html = renderGrepResult({
7+
result_type: 'grep',
8+
num_files: 5,
9+
num_lines: 42,
10+
});
11+
expect(html).toContain('Grep: 5 files, 42 lines');
12+
expect(html).toContain('tool-result');
13+
});
14+
15+
it('defaults counts to zero when absent', () => {
16+
const html = renderGrepResult({ result_type: 'grep' });
17+
expect(html).toContain('Grep: 0 files, 0 lines');
18+
});
19+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { renderPlanResult } from './plan.js';
2+
import { describeSummaryOnlyResult } from '../test_helpers.js';
3+
4+
describeSummaryOnlyResult(renderPlanResult, {
5+
suiteName: 'renderPlanResult',
6+
resultType: 'plan',
7+
label: 'Plan',
8+
samplePath: '.cursor/plans/sprint.md',
9+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { renderTaskResult } from './task.js';
3+
import { renderToolResult } from '../registry.js';
4+
import { expectNoRawHtml, XSS_SCRIPT } from '../test_helpers.js';
5+
6+
describe('renderTaskResult', () => {
7+
it('renders completed task with duration and token stats', () => {
8+
const totalTokens = 1500;
9+
const html = renderTaskResult({
10+
result_type: 'task',
11+
status: 'completed',
12+
total_duration_ms: 2500,
13+
total_tokens: totalTokens,
14+
total_tool_use_count: 3,
15+
});
16+
expect(html).toContain('Task completed');
17+
expect(html).toContain('2.5s');
18+
expect(html).toContain(`${totalTokens.toLocaleString()} tokens`);
19+
expect(html).toContain('3 tool calls');
20+
expect(html).toContain('tool-result');
21+
});
22+
23+
it('prefers retrieval status summary when set', () => {
24+
const html = renderTaskResult({
25+
result_type: 'task',
26+
retrieval_status: 'found',
27+
status: 'completed',
28+
});
29+
expect(html).toContain('Task retrieval: found');
30+
expect(html).not.toContain('Task completed');
31+
});
32+
33+
it('prefers description summary when set', () => {
34+
const html = renderTaskResult({
35+
result_type: 'task',
36+
description: 'explore auth module',
37+
});
38+
expect(html).toContain('Task launched: explore auth module');
39+
});
40+
41+
it('escapes HTML in description via registry', () => {
42+
const html = renderToolResult({
43+
result_type: 'task',
44+
description: XSS_SCRIPT,
45+
});
46+
expectNoRawHtml(html, [XSS_SCRIPT]);
47+
});
48+
});

0 commit comments

Comments
 (0)