import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { diffSnapshots, diffScreenshots } from './diff.js'; import { chromium, type Browser, type BrowserContext, type Page } from 'playwright-core'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; describe('diffSnapshots', () => { it('should report no changes for identical inputs', () => { const text = 'heading "Hello"\nbutton "Submit" [ref=e1]'; const result = diffSnapshots(text, text); expect(result.changed).toBe(false); expect(result.additions).toBe(0); expect(result.removals).toBe(0); expect(result.unchanged).toBe(2); }); it('should report no changes for empty inputs', () => { const result = diffSnapshots('', ''); expect(result.changed).toBe(false); expect(result.additions).toBe(0); expect(result.removals).toBe(0); expect(result.unchanged).toBe(1); }); it('should detect a single-line addition', () => { const before = 'heading "Hello"'; const after = 'heading "Hello"\nbutton "New"'; const result = diffSnapshots(before, after); expect(result.changed).toBe(true); expect(result.additions).toBe(1); expect(result.removals).toBe(0); expect(result.unchanged).toBe(1); expect(result.diff).toContain('+ button "New"'); }); it('should detect a single-line removal', () => { const before = 'heading "Hello"\nbutton "Gone"'; const after = 'heading "Hello"'; const result = diffSnapshots(before, after); expect(result.changed).toBe(true); expect(result.additions).toBe(0); expect(result.removals).toBe(1); expect(result.unchanged).toBe(1); expect(result.diff).toContain('- button "Gone"'); }); it('should detect completely different inputs', () => { const before = 'line A\nline B'; const after = 'line C\nline D'; const result = diffSnapshots(before, after); expect(result.changed).toBe(true); expect(result.additions).toBe(2); expect(result.removals).toBe(2); expect(result.unchanged).toBe(0); }); it('should handle mixed additions, removals, and unchanged lines', () => { const before = [ 'heading "Title"', 'button "Submit" [ref=e2]', 'text "old value"', 'footer "Copyright"', ].join('\n'); const after = [ 'heading "Title"', 'button "Submit" [ref=e2] [disabled]', 'text "new value"', 'link "Help" [ref=e5]', 'footer "Copyright"', ].join('\n'); const result = diffSnapshots(before, after); expect(result.changed).toBe(true); expect(result.additions).toBeGreaterThan(0); expect(result.removals).toBeGreaterThan(0); expect(result.unchanged).toBeGreaterThan(0); expect(result.diff).toContain('+ '); expect(result.diff).toContain('- '); }); it('should use + prefix for insertions and - prefix for deletions', () => { const before = 'alpha'; const after = 'beta'; const result = diffSnapshots(before, after); const lines = result.diff.split('\n'); const deletions = lines.filter((l) => l.startsWith('- ')); const insertions = lines.filter((l) => l.startsWith('+ ')); expect(deletions.length).toBe(1); expect(insertions.length).toBe(1); expect(deletions[0]).toBe('- alpha'); expect(insertions[0]).toBe('+ beta'); }); it('should use two-space prefix for unchanged lines', () => { const text = 'unchanged line'; const result = diffSnapshots(text, text); expect(result.diff).toBe(' unchanged line'); }); it('should handle multiline to empty', () => { const before = 'line 1\nline 2\nline 3'; const after = ''; const result = diffSnapshots(before, after); expect(result.changed).toBe(true); expect(result.removals).toBeGreaterThanOrEqual(3); }); it('should handle empty to multiline', () => { const before = ''; const after = 'line 1\nline 2\nline 3'; const result = diffSnapshots(before, after); expect(result.changed).toBe(true); expect(result.additions).toBeGreaterThanOrEqual(3); }); }); const canLaunchBrowser = await (async () => { try { const b = await chromium.launch({ headless: true }); await b.close(); return true; } catch { return false; } })(); describe.skipIf(!canLaunchBrowser)('diffScreenshots', () => { let browser: Browser; let context: BrowserContext; let page: Page; beforeAll(async () => { browser = await chromium.launch({ headless: true }); context = await browser.newContext({ viewport: { width: 200, height: 200 } }); page = await context.newPage(); }); afterAll(async () => { await browser.close(); }); async function screenshotOfColor(color: string): Promise { await page.setContent(`
`); return await page.screenshot({ type: 'png' }); } it('should report match for identical images', async () => { const img = await screenshotOfColor('red'); const result = await diffScreenshots(context, img, img, {}); expect(result.match).toBe(true); expect(result.differentPixels).toBe(0); expect(result.mismatchPercentage).toBe(0); expect(result.dimensionMismatch).toBeUndefined(); if (result.diffPath) fs.unlinkSync(result.diffPath); }); it('should detect differences between distinct images', async () => { const imgA = await screenshotOfColor('red'); const imgB = await screenshotOfColor('blue'); const result = await diffScreenshots(context, imgA, imgB, {}); expect(result.match).toBe(false); expect(result.differentPixels).toBeGreaterThan(0); expect(result.mismatchPercentage).toBeGreaterThan(0); if (result.diffPath) fs.unlinkSync(result.diffPath); }); it('should detect dimension mismatch', async () => { const imgA = await screenshotOfColor('white'); await page.setViewportSize({ width: 100, height: 100 }); const imgB = await screenshotOfColor('white'); await page.setViewportSize({ width: 200, height: 200 }); const result = await diffScreenshots(context, imgA, imgB, {}); expect(result.dimensionMismatch).toBe(true); expect(result.mismatchPercentage).toBe(100); if (result.diffPath) fs.unlinkSync(result.diffPath); }); it('should write diff image to custom outputPath', async () => { const imgA = await screenshotOfColor('green'); const imgB = await screenshotOfColor('yellow'); const outputPath = path.join(os.tmpdir(), `diff-test-${Date.now()}.png`); const result = await diffScreenshots(context, imgA, imgB, { outputPath }); expect(result.diffPath).toBe(outputPath); expect(fs.existsSync(outputPath)).toBe(true); const stat = fs.statSync(outputPath); expect(stat.size).toBeGreaterThan(0); fs.unlinkSync(outputPath); }); });