-
Notifications
You must be signed in to change notification settings - Fork 0
feat(renderer): powerline glyph rendering + fold-in of PR #128 #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
5662a31
feat(renderer): powerline glyph rendering + fold-in of PR #128
sauyon 9bcc0b6
chore(renderer): address gemini review on PR #24
sauyon 846f00a
chore(build,test): post-review nits from code-reviewer
sauyon c5789f8
chore(test): tighten types from gemini review
sauyon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,314 @@ | ||
| #!/usr/bin/env bun | ||
| /** | ||
| * Headless visual regression test runner for the renderer. | ||
| * | ||
| * Usage: | ||
| * bun demo/bin/render-test.ts # Run tests against baselines | ||
| * bun demo/bin/render-test.ts --update # Update baselines from current renders | ||
| * | ||
| * Baselines are stored in demo/baselines/*.png | ||
| */ | ||
|
|
||
| import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; | ||
| import { dirname, join } from 'path'; | ||
| import { fileURLToPath } from 'url'; | ||
| import { decode as decodePng } from 'fast-png'; | ||
|
|
||
| // Get script directory | ||
| const __filename = fileURLToPath(import.meta.url); | ||
| const __dirname = dirname(__filename); | ||
| const DEMO_DIR = dirname(__dirname); | ||
| const BASELINES_DIR = join(DEMO_DIR, 'baselines'); | ||
| const PROJECT_ROOT = dirname(DEMO_DIR); | ||
|
|
||
| // Parse args | ||
| const args = process.argv.slice(2); | ||
| const updateMode = args.includes('--update') || args.includes('-u'); | ||
| const helpMode = args.includes('--help') || args.includes('-h'); | ||
|
|
||
| if (helpMode) { | ||
| console.log(` | ||
| Visual Render Test Runner | ||
|
|
||
| Usage: | ||
| bun demo/bin/render-test.ts [options] | ||
|
|
||
| Options: | ||
| --update, -u Update baselines from current renders | ||
| --help, -h Show this help message | ||
|
|
||
| Baselines are stored in demo/baselines/*.png | ||
| `); | ||
| process.exit(0); | ||
| } | ||
|
|
||
| // Ensure baselines directory exists | ||
| if (!existsSync(BASELINES_DIR)) { | ||
| mkdirSync(BASELINES_DIR, { recursive: true }); | ||
| } | ||
|
|
||
| interface TestResult { | ||
| id: string; | ||
| name: string; | ||
| status: 'pass' | 'fail' | 'new' | 'error'; | ||
| diffPercent?: number; | ||
| error?: string; | ||
| } | ||
|
|
||
| async function main() { | ||
| console.log('🧪 Visual Render Test Runner\n'); | ||
|
|
||
| // Dynamic import puppeteer (install if needed) | ||
| let puppeteer: typeof import('puppeteer'); | ||
| try { | ||
| puppeteer = await import('puppeteer'); | ||
| } catch { | ||
| console.log('📦 Installing puppeteer...'); | ||
| const proc = Bun.spawn(['bun', 'add', '-d', 'puppeteer'], { | ||
| cwd: PROJECT_ROOT, | ||
| stdout: 'inherit', | ||
| stderr: 'inherit', | ||
| }); | ||
| await proc.exited; | ||
| puppeteer = await import('puppeteer'); | ||
| } | ||
|
|
||
| // Start local server | ||
| console.log('🌐 Starting local server...'); | ||
| const server = Bun.serve({ | ||
| port: 0, // Let OS pick a free port | ||
| async fetch(req) { | ||
| const url = new URL(req.url); | ||
| let filePath = join(PROJECT_ROOT, url.pathname); | ||
|
|
||
| // Default to index.html for directories | ||
| if (filePath.endsWith('/')) { | ||
| filePath += 'index.html'; | ||
| } | ||
|
|
||
| try { | ||
| const file = Bun.file(filePath); | ||
| if (await file.exists()) { | ||
| // Set content type based on extension | ||
| const ext = filePath.split('.').pop() || ''; | ||
| const contentTypes: Record<string, string> = { | ||
| html: 'text/html', | ||
| js: 'application/javascript', | ||
| css: 'text/css', | ||
| json: 'application/json', | ||
| wasm: 'application/wasm', | ||
| png: 'image/png', | ||
| ttf: 'font/ttf', | ||
| }; | ||
| return new Response(file, { | ||
| headers: { 'Content-Type': contentTypes[ext] || 'application/octet-stream' }, | ||
| }); | ||
| } | ||
| } catch { | ||
| // Fall through to 404 | ||
| } | ||
| return new Response('Not found', { status: 404 }); | ||
| }, | ||
| }); | ||
|
|
||
| const serverUrl = `http://localhost:${server.port}`; | ||
| console.log(` Server running at ${serverUrl}`); | ||
|
|
||
| // Launch browser | ||
| console.log('🚀 Launching headless browser...'); | ||
| const browser = await puppeteer.default.launch({ | ||
| headless: true, | ||
| args: ['--no-sandbox', '--disable-setuid-sandbox'], | ||
| }); | ||
|
|
||
| const page = await browser.newPage(); | ||
|
|
||
| // Set viewport for consistent rendering | ||
| await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 1 }); | ||
|
|
||
| try { | ||
| // Navigate to test page | ||
| console.log('📄 Loading test page...\n'); | ||
| await page.goto(`${serverUrl}/demo/render-test.html`, { | ||
| waitUntil: 'networkidle0', | ||
| timeout: 30000, | ||
| }); | ||
|
|
||
| // Wait for the page's runAllTests() to complete. | ||
| // render-test.html sets window.__testsComplete = true when done. | ||
| await page.waitForFunction('window.__testsComplete === true', { timeout: 60000 }); | ||
|
|
||
| // Get test cases from the page | ||
| const testCases = await page.evaluate(() => { | ||
| // Access the module's test cases through the window exports | ||
| // We need to extract test info from the DOM since testCases is module-scoped | ||
| const cards = document.querySelectorAll('.test-case'); | ||
| return Array.from(cards).map((card) => { | ||
| const id = card.id.replace('test-', ''); | ||
| const name = card.querySelector('h3')?.textContent || id; | ||
| return { id, name }; | ||
| }); | ||
| }); | ||
|
|
||
| if (testCases.length === 0) { | ||
| throw new Error('No test cases found. Make sure the page loaded correctly.'); | ||
| } | ||
|
|
||
| console.log(`Found ${testCases.length} tests\n`); | ||
|
|
||
| // Run tests and collect results | ||
| const results: TestResult[] = []; | ||
| let passed = 0; | ||
| let failed = 0; | ||
| let newTests = 0; | ||
|
|
||
| for (const test of testCases) { | ||
| const baselinePath = join(BASELINES_DIR, `${test.id}.png`); | ||
| const hasBaseline = existsSync(baselinePath); | ||
|
|
||
| // Get the canvas data URL from the page | ||
| const canvasDataUrl = await page.evaluate((testId: string) => { | ||
| const canvas = document.getElementById(`canvas-${testId}`) as HTMLCanvasElement; | ||
| return canvas?.toDataURL('image/png') || null; | ||
| }, test.id); | ||
|
|
||
| if (!canvasDataUrl) { | ||
| results.push({ id: test.id, name: test.name, status: 'error', error: 'Canvas not found' }); | ||
| console.log(` ❌ ${test.name}: Canvas not found`); | ||
| failed++; | ||
| continue; | ||
| } | ||
|
|
||
| // Convert data URL to buffer | ||
| const base64Data = canvasDataUrl.replace(/^data:image\/png;base64,/, ''); | ||
| const currentBuffer = Buffer.from(base64Data, 'base64'); | ||
|
|
||
| if (updateMode) { | ||
| // Update mode: save current as baseline | ||
| writeFileSync(baselinePath, currentBuffer); | ||
| console.log(` 📸 ${test.name}: Baseline ${hasBaseline ? 'updated' : 'created'}`); | ||
| results.push({ id: test.id, name: test.name, status: 'new' }); | ||
| newTests++; | ||
| } else if (!hasBaseline) { | ||
| // No baseline exists | ||
| console.log(` 🆕 ${test.name}: No baseline (run with --update to create)`); | ||
| results.push({ id: test.id, name: test.name, status: 'new' }); | ||
| newTests++; | ||
| } else { | ||
| // Compare with baseline | ||
| const baselineBuffer = readFileSync(baselinePath); | ||
|
|
||
| // Simple byte comparison first | ||
| if (currentBuffer.equals(baselineBuffer)) { | ||
| console.log(` ✅ ${test.name}: Pass (identical)`); | ||
| results.push({ id: test.id, name: test.name, status: 'pass', diffPercent: 0 }); | ||
| passed++; | ||
| } else { | ||
| // Buffers differ - calculate difference percentage | ||
| const diffPercent = calculateDiffPercent(currentBuffer, baselineBuffer); | ||
|
|
||
| if (diffPercent <= 0.1) { | ||
| // Within threshold | ||
| console.log(` ✅ ${test.name}: Pass (${diffPercent.toFixed(3)}% diff)`); | ||
| results.push({ id: test.id, name: test.name, status: 'pass', diffPercent }); | ||
| passed++; | ||
| } else { | ||
| console.log(` ❌ ${test.name}: Fail (${diffPercent.toFixed(3)}% diff)`); | ||
| results.push({ id: test.id, name: test.name, status: 'fail', diffPercent }); | ||
| failed++; | ||
|
|
||
| // Save the current render for debugging | ||
| const failPath = join(BASELINES_DIR, `${test.id}.fail.png`); | ||
| writeFileSync(failPath, currentBuffer); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Summary | ||
| console.log('\n' + '─'.repeat(50)); | ||
| console.log(`\n📊 Results: ${passed} passed, ${failed} failed, ${newTests} new\n`); | ||
|
|
||
| if (updateMode) { | ||
| console.log(`✨ Baselines ${newTests > 0 ? 'updated' : 'unchanged'} in demo/baselines/\n`); | ||
| } | ||
|
|
||
| // Exit with appropriate code | ||
| await browser.close(); | ||
| server.stop(); | ||
|
|
||
| if (failed > 0) { | ||
| process.exit(1); | ||
| } else if (newTests > 0 && !updateMode) { | ||
| console.log('⚠️ New tests detected. Run with --update to create baselines.\n'); | ||
| process.exit(1); | ||
| } | ||
| } catch (error) { | ||
| console.error('Error:', error); | ||
| await browser.close(); | ||
| server.stop(); | ||
| process.exit(1); | ||
| } | ||
| } | ||
|
|
||
| /** Per-channel tolerance for the pixel diff, in 8-bit units. Tuned to | ||
| * absorb the ~1-pixel anti-aliasing jitter we routinely see when the | ||
| * same canvas path is re-rasterized across different runs (different | ||
| * GPUs, headless-Chrome versions, OSes) while still catching real | ||
| * geometric regressions. */ | ||
| const PIXEL_DIFF_TOLERANCE = 2; | ||
|
|
||
| /** | ||
| * Pixel-level visual diff between two PNG buffers. | ||
| * | ||
| * Both PNGs are decoded to raw RGBA via fast-png (already a project | ||
| * dependency — no extra `pixelmatch`/`pngjs` install needed), then | ||
| * compared pixel-by-pixel. A pixel counts as different if any RGBA | ||
| * channel differs by more than PIXEL_DIFF_TOLERANCE. The percentage | ||
| * returned is `(differing pixels / total pixels) * 100`. | ||
| * | ||
| * The previous implementation compared raw PNG byte buffers — that | ||
| * was flaky because PNG metadata, compression-level changes, and | ||
| * chunk reordering all produce different byte streams from visually | ||
| * identical images, and the byte-count delta has no relationship to | ||
| * the visual delta. | ||
| * | ||
| * A dimension OR channel-layout mismatch is reported as 100% diff: | ||
| * the baseline and the current render disagree on the fundamental | ||
| * shape of the data (canvas size, or RGB vs RGBA), which is by | ||
| * definition a regression. (Returning a partial-pixel score would | ||
| * just hide the real problem.) | ||
| */ | ||
| function calculateDiffPercent(buf1: Buffer, buf2: Buffer): number { | ||
| const a = decodePng(buf1); | ||
| const b = decodePng(buf2); | ||
|
|
||
| // Treat any size or channel-layout disagreement as a full regression. | ||
| // Canvas2D `toDataURL('image/png')` always produces RGBA, so an RGB | ||
| // baseline against an RGBA current means the baseline was created | ||
| // outside the normal pipeline — comparing only the shared channels | ||
| // would silently ignore alpha, and an opaque-vs-transparent pair | ||
| // would then falsely match. | ||
| if (a.width !== b.width || a.height !== b.height || a.channels !== b.channels) return 100; | ||
|
|
||
| const channels = a.channels; | ||
| const totalPixels = a.width * a.height; | ||
|
|
||
| let diffPixels = 0; | ||
| for (let i = 0; i < totalPixels; i++) { | ||
| const base = i * channels; | ||
| for (let c = 0; c < channels; c++) { | ||
| if (Math.abs(a.data[base + c] - b.data[base + c]) > PIXEL_DIFF_TOLERANCE) { | ||
| diffPixels++; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return (diffPixels / totalPixels) * 100; | ||
| } | ||
|
sauyon marked this conversation as resolved.
|
||
|
|
||
| main().catch((e) => { | ||
| console.error(e); | ||
| process.exit(1); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.