Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,19 @@ See [CLAUDE.md](CLAUDE.md) for repo structure and contributor notes.

---

## Diff truncation

Large diffs are automatically truncated before being sent to the LLM to stay within reasonable token budgets:

| Stage | Character limit | Purpose |
|---|---|---|
| Change summarizer | 8 000 chars | Analyzing what changed and suggesting a demo flow |
| Script generator | 10 000 chars | Generating the Playwright interaction script |

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.

---

## Known limitations

- **Single entry point** — only one preview URL or start command per run is supported; multiple entry points are planned.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/publisher/github-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async function findExistingComment(
return existing ? { id: existing.id } : null;
}

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

const changedFilesList = analysis.changedFiles
Expand Down
143 changes: 143 additions & 0 deletions tests/unit/change-summarizer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, it, expect, vi } from 'vitest';
import { summarizeChanges } from '../../packages/core/src/analyzer/change-summarizer.js';
import type { ParsedDiff } from '../../packages/core/src/analyzer/diff-parser.js';
import type { RouteMapping } from '../../packages/core/src/analyzer/route-detector.js';

function makeMockClient(responseText: string) {
return {
messages: {
create: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: responseText }],
}),
},
} as any;
}

const SAMPLE_DIFF: ParsedDiff = {
files: [
{ path: 'app/routes/home.tsx', changeType: 'modified', hunks: [], additions: 5, deletions: 2 },
{ path: 'src/components/Button.tsx', changeType: 'added', hunks: [], additions: 20, deletions: 0 },
],
rawDiff: 'diff --git a/app/routes/home.tsx ...',
};

const SAMPLE_ROUTES: RouteMapping[] = [
{ file: 'app/routes/home.tsx', route: '/', changeType: 'modified' },
];

describe('summarizeChanges', () => {
it('returns parsed description and demo flow from valid JSON response', async () => {
const client = makeMockClient(JSON.stringify({
description: 'Added a new button component',
demoFlow: '1. Go to home\n2. Click the button',
}));

const result = await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');

expect(result.changedFiles).toEqual(['app/routes/home.tsx', 'src/components/Button.tsx']);
expect(result.affectedRoutes).toEqual(SAMPLE_ROUTES);
expect(result.changeDescription).toBe('Added a new button component');
expect(result.suggestedDemoFlow).toBe('1. Go to home\n2. Click the button');
});

it('returns defaults when LLM response is not valid JSON', async () => {
const client = makeMockClient('This is not JSON at all');

const result = await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');

expect(result.changeDescription).toBe('UI changes detected.');
expect(result.suggestedDemoFlow).toBe('Navigate to the affected page and interact with the changes.');
});

it('returns defaults when JSON is missing fields', async () => {
const client = makeMockClient(JSON.stringify({ unrelated: true }));

const result = await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');

expect(result.changeDescription).toBe('UI changes detected.');
expect(result.suggestedDemoFlow).toBe('Navigate to the affected page and interact with the changes.');
});

it('extracts JSON embedded in surrounding text', async () => {
const client = makeMockClient(
'Here is the analysis:\n```json\n{"description": "Modal added", "demoFlow": "Open modal"}\n```'
);

const result = await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');

expect(result.changeDescription).toBe('Modal added');
expect(result.suggestedDemoFlow).toBe('Open modal');
});

it('truncates large diffs before sending to LLM', async () => {
const client = makeMockClient(JSON.stringify({
description: 'Changes',
demoFlow: 'Demo',
}));
const largeDiff: ParsedDiff = {
files: SAMPLE_DIFF.files,
rawDiff: 'x'.repeat(10000),
};

await summarizeChanges(client, largeDiff, SAMPLE_ROUTES, 'claude-sonnet-4-6');

const prompt = client.messages.create.mock.calls[0][0].messages[0].content;
expect(prompt).toContain('(diff truncated)');
// The prompt should contain a truncated diff, not the full 10k chars
expect(prompt.length).toBeLessThan(10000);
});

it('does not truncate short diffs', async () => {
const client = makeMockClient(JSON.stringify({
description: 'Changes',
demoFlow: 'Demo',
}));

await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');

const prompt = client.messages.create.mock.calls[0][0].messages[0].content;
expect(prompt).not.toContain('(diff truncated)');
});

it('includes route list in prompt when routes are provided', async () => {
const client = makeMockClient(JSON.stringify({ description: 'd', demoFlow: 'f' }));

await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');

const prompt = client.messages.create.mock.calls[0][0].messages[0].content;
expect(prompt).toContain('app/routes/home.tsx');
expect(prompt).toContain('/');
});

it('shows fallback when no routes are detected', async () => {
const client = makeMockClient(JSON.stringify({ description: 'd', demoFlow: 'f' }));

await summarizeChanges(client, SAMPLE_DIFF, [], 'claude-sonnet-4-6');

const prompt = client.messages.create.mock.calls[0][0].messages[0].content;
expect(prompt).toContain('no routes detected automatically');
});

it('passes the specified model to the client', async () => {
const client = makeMockClient(JSON.stringify({ description: 'd', demoFlow: 'f' }));

await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-opus-4-6');

expect(client.messages.create).toHaveBeenCalledWith(
expect.objectContaining({ model: 'claude-opus-4-6' })
);
});

it('handles empty content response from LLM', async () => {
const client = {
messages: {
create: vi.fn().mockResolvedValue({ content: [] }),
},
} as any;

const result = await summarizeChanges(client, SAMPLE_DIFF, SAMPLE_ROUTES, 'claude-sonnet-4-6');

expect(result.changeDescription).toBe('UI changes detected.');
expect(result.suggestedDemoFlow).toBe('Navigate to the affected page and interact with the changes.');
});
});
52 changes: 52 additions & 0 deletions tests/unit/defaults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_RECORDING, DEFAULT_LLM, DEFAULT_TRIGGER } from '../../packages/core/src/config/defaults.js';

describe('DEFAULT_RECORDING', () => {
it('has expected viewport dimensions', () => {
expect(DEFAULT_RECORDING.viewport).toEqual({ width: 1280, height: 720 });
});

it('defaults to gif format', () => {
expect(DEFAULT_RECORDING.format).toBe('gif');
});

it('has 30 second max duration', () => {
expect(DEFAULT_RECORDING.maxDuration).toBe(30);
});

it('has 2x device scale factor for retina', () => {
expect(DEFAULT_RECORDING.deviceScaleFactor).toBe(2);
});

it('enables mouse click overlay by default', () => {
expect(DEFAULT_RECORDING.showMouseClicks).toBe(true);
});
});

describe('DEFAULT_LLM', () => {
it('uses anthropic provider', () => {
expect(DEFAULT_LLM.provider).toBe('anthropic');
});

it('uses claude-sonnet-4-6 model', () => {
expect(DEFAULT_LLM.model).toBe('claude-sonnet-4-6');
});
});

describe('DEFAULT_TRIGGER', () => {
it('uses auto mode', () => {
expect(DEFAULT_TRIGGER.mode).toBe('auto');
});

it('has threshold of 5', () => {
expect(DEFAULT_TRIGGER.threshold).toBe(5);
});

it('uses /glimpse as comment command', () => {
expect(DEFAULT_TRIGGER.commentCommand).toBe('/glimpse');
});

it('enables skip comment by default', () => {
expect(DEFAULT_TRIGGER.skipComment).toBe(true);
});
});
111 changes: 111 additions & 0 deletions tests/unit/github-comment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import { buildCommentBody } from '../../packages/core/src/publisher/github-comment.js';
import type { ChangeAnalysis } from '../../packages/core/src/analyzer/change-summarizer.js';

describe('buildCommentBody', () => {
const ANALYSIS: ChangeAnalysis = {
changedFiles: ['app/routes/home.tsx', 'src/components/Button.tsx'],
affectedRoutes: [{ file: 'app/routes/home.tsx', route: '/', changeType: 'modified' }],
changeDescription: 'Added a virtual try-on button',
suggestedDemoFlow: '1. Navigate to home page\n2. Click try-on button',
};

it('includes comment marker for idempotent updates', () => {
const body = buildCommentBody({ analysis: ANALYSIS, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
expect(body).toContain('<!-- git-glimpse-demo -->');
});

it('includes recording URL as image when provided', () => {
const body = buildCommentBody({
analysis: ANALYSIS,
recordingUrl: 'https://example.com/demo.gif',
script: 'demo()',
owner: 'o', repo: 'r', pullNumber: 1,
});
expect(body).toContain('![Demo](https://example.com/demo.gif)');
expect(body).toContain('Open it directly');
});

it('includes screenshots when no recording URL', () => {
const body = buildCommentBody({
analysis: ANALYSIS,
screenshots: ['https://example.com/s1.png', 'https://example.com/s2.png'],
script: 'demo()',
owner: 'o', repo: 'r', pullNumber: 1,
});
expect(body).toContain('Screenshot 1');
expect(body).toContain('Screenshot 2');
expect(body).not.toContain('No recording available');
});

it('shows fallback when neither recording nor screenshots are present', () => {
const body = buildCommentBody({ analysis: ANALYSIS, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
expect(body).toContain('No recording available');
});

it('truncates file list to 5 and shows count of remaining', () => {
const manyFilesAnalysis: ChangeAnalysis = {
...ANALYSIS,
changedFiles: ['a.tsx', 'b.tsx', 'c.tsx', 'd.tsx', 'e.tsx', 'f.tsx', 'g.tsx'],
};
const body = buildCommentBody({ analysis: manyFilesAnalysis, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
expect(body).toContain('`a.tsx`');
expect(body).toContain('`e.tsx`');
expect(body).not.toContain('`f.tsx`');
expect(body).toContain('+2 more');
});

it('shows all files when 5 or fewer', () => {
const body = buildCommentBody({ analysis: ANALYSIS, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
expect(body).toContain('`app/routes/home.tsx`');
expect(body).toContain('`src/components/Button.tsx`');
expect(body).not.toContain('more');
});

it('includes the change description', () => {
const body = buildCommentBody({ analysis: ANALYSIS, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
expect(body).toContain('Added a virtual try-on button');
});

it('wraps script in collapsible details with typescript code block', () => {
const body = buildCommentBody({ analysis: ANALYSIS, script: 'export async function demo(page) {}', owner: 'o', repo: 'r', pullNumber: 1 });
expect(body).toContain('<details>');
expect(body).toContain('Demo script (auto-generated)');
expect(body).toContain('```typescript');
expect(body).toContain('export async function demo(page) {}');
});

it('includes rerun link when provided', () => {
const body = buildCommentBody({
analysis: ANALYSIS,
script: 'demo()',
rerunUrl: 'https://github.com/owner/repo/actions/runs/123',
owner: 'o', repo: 'r', pullNumber: 1,
});
expect(body).toContain('Re-run demo');
expect(body).toContain('https://github.com/owner/repo/actions/runs/123');
});

it('omits rerun link when not provided', () => {
const body = buildCommentBody({ analysis: ANALYSIS, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
expect(body).not.toContain('Re-run demo');
});

it('includes git-glimpse branding', () => {
const body = buildCommentBody({ analysis: ANALYSIS, script: 'demo()', owner: 'o', repo: 'r', pullNumber: 1 });
expect(body).toContain('git-glimpse');
expect(body).toContain('logo_square_small.png');
});

it('prefers recording URL over screenshots', () => {
const body = buildCommentBody({
analysis: ANALYSIS,
recordingUrl: 'https://example.com/demo.gif',
screenshots: ['https://example.com/s1.png'],
script: 'demo()',
owner: 'o', repo: 'r', pullNumber: 1,
});
expect(body).toContain('![Demo]');
expect(body).not.toContain('Screenshot 1');
});
});
Loading
Loading