Skip to content

Commit cc374c8

Browse files
Merge pull request #50 from DeDuckProject/claude/add-unit-tests-ohwMG
Add missing unit tests for core modules
2 parents 659d267 + dc65bed commit cc374c8

File tree

9 files changed

+1040
-1
lines changed

9 files changed

+1040
-1
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,19 @@ See [CLAUDE.md](CLAUDE.md) for repo structure and contributor notes.
535535

536536
---
537537

538+
## Diff truncation
539+
540+
Large diffs are automatically truncated before being sent to the LLM to stay within reasonable token budgets:
541+
542+
| Stage | Character limit | Purpose |
543+
|---|---|---|
544+
| Change summarizer | 8 000 chars | Analyzing what changed and suggesting a demo flow |
545+
| Script generator | 10 000 chars | Generating the Playwright interaction script |
546+
547+
When a diff exceeds the limit, it is cut at the threshold and a `... (diff truncated)` marker is appended so the LLM knows the input is incomplete. In practice this means very large PRs may produce less accurate scripts — consider using `routeMap` or `app.hint` to give the LLM additional context when working with big diffs.
548+
549+
---
550+
538551
## Known limitations
539552

540553
- **Single entry point** — only one preview URL or start command per run is supported; multiple entry points are planned.

packages/core/src/publisher/github-comment.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ async function findExistingComment(
103103
return existing ? { id: existing.id } : null;
104104
}
105105

106-
function buildCommentBody(options: CommentOptions): string {
106+
export function buildCommentBody(options: CommentOptions): string {
107107
const { analysis, recordingUrl, screenshots, script, rerunUrl } = options;
108108

109109
const changedFilesList = analysis.changedFiles
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { summarizeChanges } from '../../packages/core/src/analyzer/change-summarizer.js';
3+
import type { ParsedDiff } from '../../packages/core/src/analyzer/diff-parser.js';
4+
import type { RouteMapping } from '../../packages/core/src/analyzer/route-detector.js';
5+
6+
function makeMockClient(responseText: string) {
7+
return {
8+
messages: {
9+
create: vi.fn().mockResolvedValue({
10+
content: [{ type: 'text', text: responseText }],
11+
}),
12+
},
13+
} as any;
14+
}
15+
16+
const SAMPLE_DIFF: ParsedDiff = {
17+
files: [
18+
{ path: 'app/routes/home.tsx', changeType: 'modified', hunks: [], additions: 5, deletions: 2 },
19+
{ path: 'src/components/Button.tsx', changeType: 'added', hunks: [], additions: 20, deletions: 0 },
20+
],
21+
rawDiff: 'diff --git a/app/routes/home.tsx ...',
22+
};
23+
24+
const SAMPLE_ROUTES: RouteMapping[] = [
25+
{ file: 'app/routes/home.tsx', route: '/', changeType: 'modified' },
26+
];
27+
28+
describe('summarizeChanges', () => {
29+
it('returns parsed description and demo flow from valid JSON response', async () => {
30+
const client = makeMockClient(JSON.stringify({
31+
description: 'Added a new button component',
32+
demoFlow: '1. Go to home\n2. Click the button',
33+
}));
34+
35+
const result = await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');
36+
37+
expect(result.changedFiles).toEqual(['app/routes/home.tsx', 'src/components/Button.tsx']);
38+
expect(result.affectedRoutes).toEqual(SAMPLE_ROUTES);
39+
expect(result.changeDescription).toBe('Added a new button component');
40+
expect(result.suggestedDemoFlow).toBe('1. Go to home\n2. Click the button');
41+
});
42+
43+
it('returns defaults when LLM response is not valid JSON', async () => {
44+
const client = makeMockClient('This is not JSON at all');
45+
46+
const result = await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');
47+
48+
expect(result.changeDescription).toBe('UI changes detected.');
49+
expect(result.suggestedDemoFlow).toBe('Navigate to the affected page and interact with the changes.');
50+
});
51+
52+
it('returns defaults when JSON is missing fields', async () => {
53+
const client = makeMockClient(JSON.stringify({ unrelated: true }));
54+
55+
const result = await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');
56+
57+
expect(result.changeDescription).toBe('UI changes detected.');
58+
expect(result.suggestedDemoFlow).toBe('Navigate to the affected page and interact with the changes.');
59+
});
60+
61+
it('extracts JSON embedded in surrounding text', async () => {
62+
const client = makeMockClient(
63+
'Here is the analysis:\n```json\n{"description": "Modal added", "demoFlow": "Open modal"}\n```'
64+
);
65+
66+
const result = await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');
67+
68+
expect(result.changeDescription).toBe('Modal added');
69+
expect(result.suggestedDemoFlow).toBe('Open modal');
70+
});
71+
72+
it('truncates large diffs before sending to LLM', async () => {
73+
const client = makeMockClient(JSON.stringify({
74+
description: 'Changes',
75+
demoFlow: 'Demo',
76+
}));
77+
const largeDiff: ParsedDiff = {
78+
files: SAMPLE_DIFF.files,
79+
rawDiff: 'x'.repeat(10000),
80+
};
81+
82+
await summarizeChanges(client, largeDiff, SAMPLE_ROUTES, 'claude-sonnet-4-6');
83+
84+
const prompt = client.messages.create.mock.calls[0][0].messages[0].content;
85+
expect(prompt).toContain('(diff truncated)');
86+
// The prompt should contain a truncated diff, not the full 10k chars
87+
expect(prompt.length).toBeLessThan(10000);
88+
});
89+
90+
it('does not truncate short diffs', async () => {
91+
const client = makeMockClient(JSON.stringify({
92+
description: 'Changes',
93+
demoFlow: 'Demo',
94+
}));
95+
96+
await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');
97+
98+
const prompt = client.messages.create.mock.calls[0][0].messages[0].content;
99+
expect(prompt).not.toContain('(diff truncated)');
100+
});
101+
102+
it('includes route list in prompt when routes are provided', async () => {
103+
const client = makeMockClient(JSON.stringify({ description: 'd', demoFlow: 'f' }));
104+
105+
await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');
106+
107+
const prompt = client.messages.create.mock.calls[0][0].messages[0].content;
108+
expect(prompt).toContain('app/routes/home.tsx');
109+
expect(prompt).toContain('/');
110+
});
111+
112+
it('shows fallback when no routes are detected', async () => {
113+
const client = makeMockClient(JSON.stringify({ description: 'd', demoFlow: 'f' }));
114+
115+
await summarizeChanges(client, SAMPLE_DIFF, [], 'claude-sonnet-4-6');
116+
117+
const prompt = client.messages.create.mock.calls[0][0].messages[0].content;
118+
expect(prompt).toContain('no routes detected automatically');
119+
});
120+
121+
it('passes the specified model to the client', async () => {
122+
const client = makeMockClient(JSON.stringify({ description: 'd', demoFlow: 'f' }));
123+
124+
await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-opus-4-6');
125+
126+
expect(client.messages.create).toHaveBeenCalledWith(
127+
expect.objectContaining({ model: 'claude-opus-4-6' })
128+
);
129+
});
130+
131+
it('handles empty content response from LLM', async () => {
132+
const client = {
133+
messages: {
134+
create: vi.fn().mockResolvedValue({ content: [] }),
135+
},
136+
} as any;
137+
138+
const result = await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');
139+
140+
expect(result.changeDescription).toBe('UI changes detected.');
141+
expect(result.suggestedDemoFlow).toBe('Navigate to the affected page and interact with the changes.');
142+
});
143+
});

tests/unit/defaults.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { DEFAULT_RECORDING, DEFAULT_LLM, DEFAULT_TRIGGER } from '../../packages/core/src/config/defaults.js';
3+
4+
describe('DEFAULT_RECORDING', () => {
5+
it('has expected viewport dimensions', () => {
6+
expect(DEFAULT_RECORDING.viewport).toEqual({ width: 1280, height: 720 });
7+
});
8+
9+
it('defaults to gif format', () => {
10+
expect(DEFAULT_RECORDING.format).toBe('gif');
11+
});
12+
13+
it('has 30 second max duration', () => {
14+
expect(DEFAULT_RECORDING.maxDuration).toBe(30);
15+
});
16+
17+
it('has 2x device scale factor for retina', () => {
18+
expect(DEFAULT_RECORDING.deviceScaleFactor).toBe(2);
19+
});
20+
21+
it('enables mouse click overlay by default', () => {
22+
expect(DEFAULT_RECORDING.showMouseClicks).toBe(true);
23+
});
24+
});
25+
26+
describe('DEFAULT_LLM', () => {
27+
it('uses anthropic provider', () => {
28+
expect(DEFAULT_LLM.provider).toBe('anthropic');
29+
});
30+
31+
it('uses claude-sonnet-4-6 model', () => {
32+
expect(DEFAULT_LLM.model).toBe('claude-sonnet-4-6');
33+
});
34+
});
35+
36+
describe('DEFAULT_TRIGGER', () => {
37+
it('uses auto mode', () => {
38+
expect(DEFAULT_TRIGGER.mode).toBe('auto');
39+
});
40+
41+
it('has threshold of 5', () => {
42+
expect(DEFAULT_TRIGGER.threshold).toBe(5);
43+
});
44+
45+
it('uses /glimpse as comment command', () => {
46+
expect(DEFAULT_TRIGGER.commentCommand).toBe('/glimpse');
47+
});
48+
49+
it('enables skip comment by default', () => {
50+
expect(DEFAULT_TRIGGER.skipComment).toBe(true);
51+
});
52+
});

tests/unit/github-comment.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { buildCommentBody } from '../../packages/core/src/publisher/github-comment.js';
3+
import type { ChangeAnalysis } from '../../packages/core/src/analyzer/change-summarizer.js';
4+
5+
describe('buildCommentBody', () => {
6+
const ANALYSIS: ChangeAnalysis = {
7+
changedFiles: ['app/routes/home.tsx', 'src/components/Button.tsx'],
8+
affectedRoutes: [{ file: 'app/routes/home.tsx', route: '/', changeType: 'modified' }],
9+
changeDescription: 'Added a virtual try-on button',
10+
suggestedDemoFlow: '1. Navigate to home page\n2. Click try-on button',
11+
};
12+
13+
it('includes comment marker for idempotent updates', () => {
14+
const body = buildCommentBody({ analysis: ANALYSIS, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
15+
expect(body).toContain('<!-- git-glimpse-demo -->');
16+
});
17+
18+
it('includes recording URL as image when provided', () => {
19+
const body = buildCommentBody({
20+
analysis: ANALYSIS,
21+
recordingUrl: 'https://example.com/demo.gif',
22+
script: 'demo()',
23+
owner: 'o', repo: 'r', pullNumber: 1,
24+
});
25+
expect(body).toContain('![Demo](https://example.com/demo.gif)');
26+
expect(body).toContain('Open it directly');
27+
});
28+
29+
it('includes screenshots when no recording URL', () => {
30+
const body = buildCommentBody({
31+
analysis: ANALYSIS,
32+
screenshots: ['https://example.com/s1.png', 'https://example.com/s2.png'],
33+
script: 'demo()',
34+
owner: 'o', repo: 'r', pullNumber: 1,
35+
});
36+
expect(body).toContain('Screenshot 1');
37+
expect(body).toContain('Screenshot 2');
38+
expect(body).not.toContain('No recording available');
39+
});
40+
41+
it('shows fallback when neither recording nor screenshots are present', () => {
42+
const body = buildCommentBody({ analysis: ANALYSIS, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
43+
expect(body).toContain('No recording available');
44+
});
45+
46+
it('truncates file list to 5 and shows count of remaining', () => {
47+
const manyFilesAnalysis: ChangeAnalysis = {
48+
...ANALYSIS,
49+
changedFiles: ['a.tsx', 'b.tsx', 'c.tsx', 'd.tsx', 'e.tsx', 'f.tsx', 'g.tsx'],
50+
};
51+
const body = buildCommentBody({ analysis: manyFilesAnalysis, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
52+
expect(body).toContain('`a.tsx`');
53+
expect(body).toContain('`e.tsx`');
54+
expect(body).not.toContain('`f.tsx`');
55+
expect(body).toContain('+2 more');
56+
});
57+
58+
it('shows all files when 5 or fewer', () => {
59+
const body = buildCommentBody({ analysis: ANALYSIS, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
60+
expect(body).toContain('`app/routes/home.tsx`');
61+
expect(body).toContain('`src/components/Button.tsx`');
62+
expect(body).not.toContain('more');
63+
});
64+
65+
it('includes the change description', () => {
66+
const body = buildCommentBody({ analysis: ANALYSIS, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
67+
expect(body).toContain('Added a virtual try-on button');
68+
});
69+
70+
it('wraps script in collapsible details with typescript code block', () => {
71+
const body = buildCommentBody({ analysis: ANALYSIS, script: 'export async function demo(page) {}', owner: 'o', repo: 'r', pullNumber: 1 });
72+
expect(body).toContain('<details>');
73+
expect(body).toContain('Demo script (auto-generated)');
74+
expect(body).toContain('```typescript');
75+
expect(body).toContain('export async function demo(page) {}');
76+
});
77+
78+
it('includes rerun link when provided', () => {
79+
const body = buildCommentBody({
80+
analysis: ANALYSIS,
81+
script: 'demo()',
82+
rerunUrl: 'https://github.com/owner/repo/actions/runs/123',
83+
owner: 'o', repo: 'r', pullNumber: 1,
84+
});
85+
expect(body).toContain('Re-run demo');
86+
expect(body).toContain('https://github.com/owner/repo/actions/runs/123');
87+
});
88+
89+
it('omits rerun link when not provided', () => {
90+
const body = buildCommentBody({ analysis: ANALYSIS, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
91+
expect(body).not.toContain('Re-run demo');
92+
});
93+
94+
it('includes git-glimpse branding', () => {
95+
const body = buildCommentBody({ analysis: ANALYSIS, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
96+
expect(body).toContain('git-glimpse');
97+
expect(body).toContain('logo_square_small.png');
98+
});
99+
100+
it('prefers recording URL over screenshots', () => {
101+
const body = buildCommentBody({
102+
analysis: ANALYSIS,
103+
recordingUrl: 'https://example.com/demo.gif',
104+
screenshots: ['https://example.com/s1.png'],
105+
script: 'demo()',
106+
owner: 'o', repo: 'r', pullNumber: 1,
107+
});
108+
expect(body).toContain('![Demo]');
109+
expect(body).not.toContain('Screenshot 1');
110+
});
111+
});

0 commit comments

Comments
 (0)