Skip to content

Commit f9e9ead

Browse files
DeDuckProjectclaude
andcommitted
test: add integration tests for recorder and post-processor
- Add integration test fixture: minimal HTTP server serving a product page with interactive buttons and a modal (representative of real UI changes) - Add pre-written demo script fixture (used instead of LLM for deterministic testing) - Test Playwright webm recording, GIF conversion, MP4 conversion, and full pipeline - Separate unit vs integration vitest configs (test vs test:integration) - Remove test scripts from workspace packages (all tests run from repo root) - Fix palettegen ffmpeg warning with -update 1 flag Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0c73e55 commit f9e9ead

10 files changed

Lines changed: 271 additions & 8 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"scripts": {
1212
"build": "pnpm -r build",
1313
"test": "vitest run",
14+
"test:integration": "vitest run --config vitest.integration.config.ts",
15+
"test:all": "vitest run && vitest run --config vitest.integration.config.ts",
1416
"typecheck": "pnpm -r typecheck",
1517
"lint": "pnpm -r lint"
1618
},

packages/action/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
"main": "./dist/index.js",
88
"scripts": {
99
"build": "tsc -p tsconfig.json",
10-
"typecheck": "tsc --noEmit",
11-
"test": "vitest run"
10+
"typecheck": "tsc --noEmit"
1211
},
1312
"dependencies": {
1413
"@actions/artifact": "^2.1.11",

packages/cli/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
"main": "./dist/index.js",
1111
"scripts": {
1212
"build": "tsc -p tsconfig.json",
13-
"typecheck": "tsc --noEmit",
14-
"test": "vitest run"
13+
"typecheck": "tsc --noEmit"
1514
},
1615
"dependencies": {
1716
"@git-glimpse/core": "workspace:*",

packages/core/package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@
1515
"files": ["dist"],
1616
"scripts": {
1717
"build": "tsc -p tsconfig.json",
18-
"typecheck": "tsc --noEmit",
19-
"test": "vitest run",
20-
"test:watch": "vitest"
18+
"typecheck": "tsc --noEmit"
2119
},
2220
"dependencies": {
2321
"@anthropic-ai/sdk": "^0.24.3",

packages/core/src/recorder/post-processor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ async function convertToGif(input: string, output: string, viewport: { width: nu
4646
execFileSync('ffmpeg', [
4747
'-i', input,
4848
'-vf', `fps=12,scale=${targetWidth}:-1:flags=lanczos,palettegen=stats_mode=diff`,
49+
'-update', '1',
4950
'-y', palettePath,
5051
]);
5152

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Page } from '@playwright/test';
2+
3+
/**
4+
* Pre-written demo script used for integration testing.
5+
* Demonstrates the "Virtual Try-On" feature on the test product page.
6+
*/
7+
export async function demo(page: Page): Promise<void> {
8+
await page.waitForLoadState('networkidle');
9+
await page.waitForTimeout(500);
10+
11+
// Click "Add to Cart" to show the counter incrementing
12+
await page.click('#add-to-cart');
13+
await page.waitForTimeout(400);
14+
await page.click('#add-to-cart');
15+
await page.waitForTimeout(600);
16+
17+
// Open the Virtual Try-On modal
18+
await page.click('#try-on-btn');
19+
await page.waitForSelector('#modal.open', { state: 'visible' });
20+
await page.waitForTimeout(800);
21+
22+
// Close the modal
23+
await page.click('#close-modal');
24+
await page.waitForTimeout(400);
25+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { createServer, type Server } from 'node:http';
2+
3+
const HTML = `<!DOCTYPE html>
4+
<html lang="en">
5+
<head>
6+
<meta charset="UTF-8">
7+
<title>Test App</title>
8+
<style>
9+
body { font-family: sans-serif; padding: 2rem; background: #f5f5f5; }
10+
h1 { color: #333; }
11+
.card { background: white; border-radius: 8px; padding: 1.5rem; margin: 1rem 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
12+
button { background: #6366f1; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 6px; cursor: pointer; font-size: 1rem; }
13+
button:hover { background: #4f46e5; }
14+
.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); align-items: center; justify-content: center; }
15+
.modal.open { display: flex; }
16+
.modal-content { background: white; border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; }
17+
.close-btn { float: right; background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #666; }
18+
#counter { font-size: 2rem; font-weight: bold; color: #6366f1; margin: 1rem 0; }
19+
</style>
20+
</head>
21+
<body>
22+
<h1>Product Page</h1>
23+
24+
<div class="card">
25+
<h2>Wireless Headphones</h2>
26+
<p>Premium noise-cancelling headphones with 30-hour battery life.</p>
27+
<p id="counter">0</p>
28+
<button id="add-to-cart">Add to Cart</button>
29+
<button id="try-on-btn" style="margin-left:0.5rem; background:#10b981">Virtual Try-On</button>
30+
</div>
31+
32+
<div class="modal" id="modal">
33+
<div class="modal-content">
34+
<button class="close-btn" id="close-modal" aria-label="Close">×</button>
35+
<h2>Virtual Try-On</h2>
36+
<p>See how these headphones look on you using your camera.</p>
37+
<button id="start-tryon">Start Try-On</button>
38+
</div>
39+
</div>
40+
41+
<script>
42+
let count = 0;
43+
document.getElementById('add-to-cart').addEventListener('click', () => {
44+
count++;
45+
document.getElementById('counter').textContent = count;
46+
});
47+
document.getElementById('try-on-btn').addEventListener('click', () => {
48+
document.getElementById('modal').classList.add('open');
49+
});
50+
document.getElementById('close-modal').addEventListener('click', () => {
51+
document.getElementById('modal').classList.remove('open');
52+
});
53+
</script>
54+
</body>
55+
</html>`;
56+
57+
export interface TestServer {
58+
url: string;
59+
close: () => Promise<void>;
60+
}
61+
62+
export function startTestServer(port = 0): Promise<TestServer> {
63+
return new Promise((resolve, reject) => {
64+
const server: Server = createServer((_req, res) => {
65+
res.writeHead(200, { 'Content-Type': 'text/html' });
66+
res.end(HTML);
67+
});
68+
69+
server.listen(port, '127.0.0.1', () => {
70+
const addr = server.address();
71+
if (!addr || typeof addr === 'string') {
72+
reject(new Error('Failed to get server address'));
73+
return;
74+
}
75+
resolve({
76+
url: `http://127.0.0.1:${addr.port}`,
77+
close: () => new Promise((res, rej) => server.close((e) => (e ? rej(e) : res()))),
78+
});
79+
});
80+
81+
server.on('error', reject);
82+
});
83+
}

tests/integration/recorder.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2+
import { existsSync, statSync, rmSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
import { startTestServer, type TestServer } from './fixtures/server.js';
6+
import { demo as demoFn } from './fixtures/demo-script.js';
7+
import { runScriptAndRecord } from '../../packages/core/src/recorder/playwright-runner.js';
8+
import { postProcess } from '../../packages/core/src/recorder/post-processor.js';
9+
10+
// The script as a string — as it would come from the LLM
11+
const DEMO_SCRIPT = `
12+
import type { Page } from '@playwright/test';
13+
14+
export async function demo(page: Page): Promise<void> {
15+
await page.waitForLoadState('networkidle');
16+
await page.waitForTimeout(500);
17+
18+
await page.click('#add-to-cart');
19+
await page.waitForTimeout(400);
20+
await page.click('#add-to-cart');
21+
await page.waitForTimeout(600);
22+
23+
await page.click('#try-on-btn');
24+
await page.waitForSelector('#modal.open', { state: 'visible' });
25+
await page.waitForTimeout(800);
26+
27+
await page.click('#close-modal');
28+
await page.waitForTimeout(400);
29+
}
30+
`;
31+
32+
const RECORDING_CONFIG = {
33+
viewport: { width: 1280, height: 720 },
34+
format: 'gif' as const,
35+
maxDuration: 30,
36+
deviceScaleFactor: 1,
37+
};
38+
39+
let server: TestServer;
40+
let outputDir: string;
41+
42+
beforeAll(async () => {
43+
server = await startTestServer();
44+
outputDir = join(tmpdir(), `git-glimpse-test-${Date.now()}`);
45+
}, 15000);
46+
47+
afterAll(async () => {
48+
await server.close();
49+
if (existsSync(outputDir)) {
50+
rmSync(outputDir, { recursive: true, force: true });
51+
}
52+
});
53+
54+
describe('Playwright recorder', () => {
55+
it('records a webm video from the demo script', async () => {
56+
const result = await runScriptAndRecord({
57+
script: DEMO_SCRIPT,
58+
baseUrl: server.url,
59+
recording: RECORDING_CONFIG,
60+
outputDir,
61+
});
62+
63+
expect(result.videoPath).toMatch(/\.webm$/);
64+
expect(existsSync(result.videoPath)).toBe(true);
65+
expect(statSync(result.videoPath).size).toBeGreaterThan(1000);
66+
expect(result.duration).toBeGreaterThan(0);
67+
expect(result.duration).toBeLessThan(30);
68+
}, 30000);
69+
});
70+
71+
describe('Post-processor', () => {
72+
it('converts webm to gif', async () => {
73+
// First record
74+
const recording = await runScriptAndRecord({
75+
script: DEMO_SCRIPT,
76+
baseUrl: server.url,
77+
recording: RECORDING_CONFIG,
78+
outputDir,
79+
});
80+
81+
// Then convert
82+
const processed = await postProcess({
83+
inputPath: recording.videoPath,
84+
outputDir,
85+
format: 'gif',
86+
viewport: RECORDING_CONFIG.viewport,
87+
});
88+
89+
expect(processed.outputPath).toMatch(/\.gif$/);
90+
expect(existsSync(processed.outputPath)).toBe(true);
91+
expect(processed.sizeMB).toBeGreaterThan(0);
92+
expect(processed.sizeMB).toBeLessThan(20); // sanity cap
93+
}, 60000);
94+
95+
it('converts webm to mp4', async () => {
96+
const recording = await runScriptAndRecord({
97+
script: DEMO_SCRIPT,
98+
baseUrl: server.url,
99+
recording: RECORDING_CONFIG,
100+
outputDir,
101+
});
102+
103+
const processed = await postProcess({
104+
inputPath: recording.videoPath,
105+
outputDir,
106+
format: 'mp4',
107+
viewport: RECORDING_CONFIG.viewport,
108+
});
109+
110+
expect(processed.outputPath).toMatch(/\.mp4$/);
111+
expect(existsSync(processed.outputPath)).toBe(true);
112+
expect(processed.sizeMB).toBeGreaterThan(0);
113+
}, 60000);
114+
});
115+
116+
describe('Full recording pipeline (no LLM)', () => {
117+
it('produces a gif under 5MB from start to finish', async () => {
118+
const pipelineOutputDir = join(tmpdir(), `git-glimpse-pipeline-${Date.now()}`);
119+
120+
try {
121+
const recording = await runScriptAndRecord({
122+
script: DEMO_SCRIPT,
123+
baseUrl: server.url,
124+
recording: RECORDING_CONFIG,
125+
outputDir: pipelineOutputDir,
126+
});
127+
128+
const processed = await postProcess({
129+
inputPath: recording.videoPath,
130+
outputDir: pipelineOutputDir,
131+
format: 'gif',
132+
viewport: RECORDING_CONFIG.viewport,
133+
});
134+
135+
expect(existsSync(processed.outputPath)).toBe(true);
136+
expect(processed.sizeMB).toBeLessThan(5);
137+
} finally {
138+
if (existsSync(pipelineOutputDir)) {
139+
rmSync(pipelineOutputDir, { recursive: true, force: true });
140+
}
141+
}
142+
}, 60000);
143+
});

vitest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config';
22

33
export default defineConfig({
44
test: {
5-
include: ['tests/unit/**/*.test.ts', 'packages/*/src/**/*.test.ts'],
5+
include: ['tests/unit/**/*.test.ts'],
66
globals: true,
77
},
88
});

vitest.integration.config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defineConfig } from 'vitest/config';
2+
3+
export default defineConfig({
4+
test: {
5+
include: ['tests/integration/**/*.test.ts'],
6+
globals: true,
7+
testTimeout: 60000,
8+
hookTimeout: 30000,
9+
// Run integration tests sequentially — they share browser/ffmpeg resources
10+
pool: 'forks',
11+
poolOptions: { forks: { singleFork: true } },
12+
},
13+
});

0 commit comments

Comments
 (0)