Playwright Testing & AI-Assisted Debugging Guide
For a quick-reference E2E testing cheat sheet, see
E2E_TESTING_GUIDE.md.
This guide explains how to use Playwright for testing and AI-assisted development with the Luxar viewer.
Table of Contents
Overview
The Luxar viewer uses Playwright for end-to-end testing and AI-assisted debugging. This allows:
Automated Testing: Run tests that verify rendering, data loading, and UI behavior
AI Debugging: Claude Code can “see” and debug the app without a physical monitor
Visual Regression: Catch visual bugs with screenshot comparisons
Performance Monitoring: Track FPS, memory usage, and loading times
Why Playwright for Three.js/WebGL?
Standard DOM testing tools don’t work for WebGL apps because they can’t see inside the <canvas>. Playwright solves this by:
GPU Acceleration: Forces hardware rendering (not software fallback)
State Inspection: Accesses Three.js scene via
window.__luxarDebugScreenshot Testing: Captures actual rendered output
Console Mirroring: Pipes browser logs to terminal
Quick Start
1. Prerequisites
Ensure you have Playwright installed (already done if you cloned the repo), and generate the zarr fixtures 18 specs read — the E2E pre-flight fails the run if they are missing or stale:
cd packages/luxar-viewer
pnpm install
pnpm test:generate-fixtures
make test-e2e regenerates stale or incomplete fixtures automatically. Direct
pnpm test:e2e expects the command above first and deliberately does not run the
1–2 minute generator inside global setup. pnpm test (vitest) also regenerates
the fixtures automatically. pnpm test:e2e:smoke and pnpm test:perf:e2e set LUXAR_E2E_NO_FIXTURES=1 because
none of their specs reads a fixture; set it yourself for a one-off run of specs
you know need none.
2. Run the Agent Driver (AI Debugging)
This is the primary tool for AI-assisted debugging:
# Headless mode (for Claude Code)
pnpm agent:debug
# With visible browser (for manual observation)
pnpm agent:debug:visible
# Custom URL
pnpm agent:debug --url="http://localhost:5173/?src=/data/my-dataset.luxar.zarr&debug"
Output:
Terminal: All browser console logs, errors, and Luxar state
test-results/debug/debug-view.png: Screenshot of current statetest-results/debug/error-state.png: Screenshot on failure (if any)
3. Run E2E Tests
# Run all tests
pnpm test:e2e
# Run with UI (interactive mode)
pnpm test:e2e:ui
# Debug mode (step through tests)
pnpm test:e2e:debug
# View last test report
pnpm test:e2e:report
For AI Agents (Claude Code)
Your New Capabilities
When debugging the Luxar viewer, you can now:
See What’s Happening: Run
pnpm agent:debugto see browser console logsInspect Scene State: View Three.js objects, point counts, camera position
Take Screenshots: Verify visual rendering via
debug-view.pngMonitor Performance: Check FPS, memory usage, loading times
How to Debug
Step 1: Run the Agent Driver
pnpm agent:debug
Step 2: Read the Output
Look for these key sections:
[BROWSER-CONSOLE-LOG] Loading dataset from /data/demo.luxar.zarr...
[BROWSER-CONSOLE-ERROR] Failed to load spatial index
[NETWORK-FAIL] http://localhost:5173/data/demo.luxar.zarr/.zarray - 404
Color Coding:
🔴
[BROWSER-CONSOLE-ERROR]- JavaScript errors🟡
[BROWSER-CONSOLE-WARN]- Warnings🔵
[BROWSER-CONSOLE-INFO]- Info logs⚫
[BROWSER-CONSOLE-LOG]- Debug logs
Step 3: Inspect Luxar State
The driver outputs a JSON dump of the current state:
{
"timestamp": "2025-01-15T10:30:00.000Z",
"scene": {
"totalChildren": 5,
"pointClouds": 3,
"pointCloudDetails": [
{
"name": "cells",
"pointCount": 125000,
"visible": true,
"hasColors": true,
"hasRadii": true
}
]
},
"camera": {
"position": { "x": 0, "y": 0, "z": 10 },
"fov": 47
},
"performance": {
"totalPoints": 125000
}
}
Step 4: Analyze and Fix
Based on the output, you can:
No errors: Everything working correctly
Network errors: Dataset path wrong or server not running
Zero points: Spatial index query failing
Console errors: Logic bugs in the code
Step 5: Verify Fix
After making changes:
# Run driver again
pnpm agent:debug
# Check that errors are gone
# Verify point count is correct
# Check screenshot looks good
Debugging Workflow Example
# Scenario: User reports "Points not loading in 4D dataset"
# 1. Run driver to see current state
pnpm agent:debug
# 2. Check output (see 0 points loaded)
[BROWSER-CONSOLE-LOG] Query result: 0 cells → 0 ranges → 0 points
# 3. Add instrumentation
# Edit src/data/points/points-spatial-index-loader.ts
console.log('[DEBUG] Slice position:', slicePosition);
console.log('[DEBUG] Query tolerance:', queryTolerance);
# 4. Run again
pnpm agent:debug
# 5. Analyze output
[BROWSER-CONSOLE-LOG] [DEBUG] Slice position: [0, 0, 0, 10]
[BROWSER-CONSOLE-LOG] [DEBUG] Query tolerance: [0, 0, 0, 0] ← BUG!
# 6. Fix the bug (tolerance should be > 0 for discrete dims)
# ... make fix ...
# 7. Verify
pnpm agent:debug
[BROWSER-CONSOLE-LOG] Query result: 50 cells → 10 ranges → 12000 points ✅
Important Rules for AI Debugging
Don’t try to find DOM elements: Three.js objects don’t have HTML tags
Use
window.__luxarDebug: Query the scene state via this globalCheck screenshots: Visual bugs need visual verification
Watch for WebGL errors: Look for “context lost” or shader errors
Test with ?debug: Always add
?debugto URL for state inspection
Available Scripts
Agent Driver Scripts
Script |
Purpose |
When to Use |
|---|---|---|
|
Run headless browser, output logs |
AI debugging, CI |
|
Run with visible browser |
Manual verification |
E2E Test Scripts
CI status: GitHub Actions runs the Chromium mobile/touch suite for TypeScript changes. The full rendering-heavy desktop corpus remains on the GPU promotion runner because hosted software WebGL is too slow and unreliable.
Script |
Purpose |
When to Use |
|---|---|---|
|
Run all tests |
Local manual testing |
|
Chromium mobile/touch suite |
Local reproduction of the hosted CI job |
|
Non-GPU smoke subset |
Fast local interaction smoke check |
|
Smoke subset with strict console handling |
CI-like local smoke validation |
|
Run the functional browser smoke matrix with snapshots ignored |
After generating fixtures and installing Firefox/WebKit |
|
Interactive test runner |
Writing new tests |
|
Debug mode |
Debugging failing tests |
|
View last test report |
After test run |
LUXAR_E2E_BROWSERS=all enables the opt-in Firefox and WebKit projects; the
test:e2e:browsers script sets it automatically.
Writing Tests
Best Practices for Test Assertions ⭐
DO:
✅ Use specific wait helpers instead of arbitrary timeouts
✅ Verify actual behavior (point counts, state changes)
✅ Check for both success conditions AND what changed
✅ Use
waitForDataLoaded()after navigation✅ Use
waitForDimensionNavigation()for nD tests
DON’T:
❌ Use
await page.waitForTimeout(3000)- too fragile❌ Only check
expect(state.initialized).toBe(true)- too weak❌ Use
expect(typeof x).toBe('number')- always passes!❌ Hide errors with
.catch(() => false)- masks real issues
Basic Test Structure
// Import from `./fixtures` (NOT @playwright/test directly) so the
// shared console-error / pageerror auto-check runs after each test.
// Specs that need to allow specific noisy messages annotate at the
// test level — see `fixtures.ts:DEFAULT_ALLOWED_CONSOLE_ERRORS`.
import { test, expect } from './fixtures';
import {
waitForLuxarReady,
getLuxarState,
waitForDataLoaded,
waitForDimensionNavigation,
} from './helpers';
test('should load demo dataset', async ({ page }) => {
// Use `?src=` with a full dataset path served by the dev server
// (typical: http://localhost:9000/datasets/examples/...). The
// older `/data/...` path used in some examples is not the
// production convention.
await page.goto('/?src=http://localhost:9000/datasets/examples/demo.luxar.zarr&debug');
// Wait for Luxar to initialize
await waitForLuxarReady(page);
// Wait for data to actually load (better than arbitrary timeout!)
await waitForDataLoaded(page);
// Get current state
const state = await getLuxarState(page);
// Strong assertions
expect(state.totalPoints).toBeGreaterThan(0);
expect(state.pointClouds.length).toBeGreaterThan(0);
expect(state.initialized).toBe(true);
// Visual regression
await expect(page).toHaveScreenshot('demo-loaded.png', {
maxDiffPixelRatio: 0.05, // 5% tolerance for WebGL
threshold: 0.2 // Color tolerance
});
});
Using Helper Functions
import {
waitForLuxarReady,
getLuxarState,
waitForDataLoaded,
waitForDimensionNavigation,
renderOnce,
captureConsoleMessages
} from './helpers';
test('advanced nD navigation test', async ({ page }) => {
// Capture console messages
const console = captureConsoleMessages(page);
await page.goto('/?src=/examples/5d-dataset.luxar.zarr&debug');
await waitForLuxarReady(page);
await waitForDataLoaded(page);
// Get initial state
const initialState = await getLuxarState(page);
const initialPoints = initialState.totalPoints;
// Navigate through dimension
await page.keyboard.press('1'); // Select the first non-displayed dimension
await page.waitForTimeout(300); // Short delay for key processing
await page.keyboard.press(']'); // Navigate forward
// Wait for navigation to complete (robust!)
await waitForDimensionNavigation(page, initialPoints, 8000);
const finalState = await getLuxarState(page);
// Strong assertions
expect(finalState.initialized).toBe(true);
expect(finalState.totalPoints).toBeGreaterThanOrEqual(0);
// Verify something actually changed
const pointsChanged = finalState.totalPoints !== initialPoints;
const hasQueryLogs = console.logs.some(log => log.includes('Query result:'));
expect(pointsChanged || hasQueryLogs).toBe(true);
// Check for errors
expect(console.errors).toEqual([]);
});
Screenshot Testing
test('visual regression', async ({ page }) => {
await page.goto('/?src=/data/demo.luxar.zarr&debug');
await waitForLuxarReady(page);
await waitForPointsLoaded(page, 1000);
// Take baseline screenshot
await expect(page).toHaveScreenshot('baseline.png', {
maxDiffPixelRatio: 0.05, // CRITICAL: Allow 5% pixel difference
threshold: 0.2, // CRITICAL: Allow color variance
animations: 'disabled' // CRITICAL: WebGL doesn't pause
});
});
Why Relaxed Thresholds?
WebGL rendering varies across GPUs
Anti-aliasing differs between hardware
CI servers use different GPUs than dev machines
0% tolerance will fail 100% of the time
Debugging
Debug Individual Tests
# Run single test file
pnpm test:e2e src/tests/e2e/basic-rendering.spec.ts
# Run single test by name
pnpm test:e2e -g "should load viewer"
# Debug mode (pause before each test)
pnpm test:e2e:debug
View Traces
When tests fail, Playwright captures a trace:
# View trace from last run
pnpm test:e2e:report
# Or open specific trace
npx playwright show-trace test-results/trace.zip
Trace Contains:
Video recording of test
DOM snapshots at each step
Network requests/responses
Console logs
Screenshots
Common Issues
Server identity or fixed port is already in use
The E2E config reuses ports 5173 and 9000 only when both servers expose the
deterministic identity marker for the checkout being tested. A server from a
sibling clone/worktree no longer satisfies the readiness probe. Vite is started
with --strictPort, so an occupied viewer port fails immediately instead of
silently shifting to another port or exercising a foreign bundle. Both servers
are pinned to 127.0.0.1 so IPv4 and IPv6 listeners cannot split the probes
between different processes.
Inspect an occupied port before stopping anything; it may belong to another active checkout:
lsof -nP -iTCP:5173 -sTCP:LISTEN
lsof -nP -iTCP:9000 -sTCP:LISTEN
Coordinate with the process owner, stop the stale server, and rerun Playwright. The global setup also compares the exact identity response body and probes each locally present required dataset over HTTP. An unrelated catch-all server or a mis-rooted dataset server therefore fails during preflight rather than surfacing later as a viewer initialization timeout.
1. Test Times Out
Problem: Test exceeds 60-second timeout
Solutions:
Check if dev server is running (
pnpm dev)Increase
waitTimein agent-driver.tsCheck for infinite loops in code
2. Screenshot Doesn’t Match
Problem: Visual regression fails with pixel differences
Solutions:
First run creates baseline (expected to fail)
Re-run to compare against baseline
If legitimate change: Update the Linux baseline with the visual update script
pnpm test:e2e:visual:update
3. GPU Acceleration Not Working
Problem: Tests slow or screenshots look different
Check: Browser flags in playwright.config.ts:
args: [
'--use-gl=egl', // Must be present
'--ignore-gpu-blocklist'
]
Verify: Check test output for “SwiftShader” (software renderer)
4. Debug Mode Not Enabled
Problem: window.__luxarDebug is undefined
Solution: Add ?debug to URL:
await page.goto('/?debug'); // ← Must include
Troubleshooting
“No tests found”
Cause: Test files not in src/tests/e2e/ or don’t match *.spec.ts pattern
Fix:
# Rename files to match pattern
mv my-test.ts my-test.spec.ts
# Or update testDir in playwright.config.ts
“Cannot find module @playwright/test”
Cause: Playwright not installed
Fix:
cd packages/luxar-viewer
pnpm install
npx playwright install chromium
“Page crashed”
Cause: WebGL context lost or out of memory
Fixes:
Close other GPU-intensive apps
Reduce dataset size for tests
Add GPU error handling to code
“Browser context closed”
Cause: Test timeout or crash
Fix:
Check for infinite loops
Increase timeout in
playwright.config.tsCheck console for errors
Best Practices
For AI Agents
Always run agent driver first: Don’t guess, verify actual state
Add targeted logging: Insert console.logs to narrow down issues
Check screenshots: Visual bugs need visual confirmation
Test fixes immediately: Run driver after every change
Clean up debug logs: Remove console.logs after debugging
For Developers
Run tests before committing:
pnpm test:e2eUpdate snapshots carefully: Only when changes are intentional
Write focused tests: One behavior per test
Use helper functions: Wait for
waitForLuxarReady(page)before reading state or dispatching keyboard/mouse inputTest on real data: Don’t rely only on demo datasets
For CI/CD
Run tests in Docker: Consistent GPU environment
Retry flaky tests: WebGL can be non-deterministic (max 2 retries)
Save artifacts: Keep screenshots and traces on failure
Monitor test duration: WebGL tests are slower (60s typical)
Advanced Topics
Coordinate-Based Interactions
Three.js objects don’t have CSS selectors. To click a 3D object:
// Helper to project 3D position to screen coordinates
const getScreenPosition = async (page, objectName) => {
return await page.evaluate((name) => {
const obj = window.__luxarDebug.scene.getObjectByName(name);
const camera = window.__luxarDebug.camera;
// Project to normalized device coordinates
const vector = obj.position.clone();
vector.project(camera);
// Convert to screen pixels
const width = window.innerWidth;
const height = window.innerHeight;
return {
x: (vector.x * 0.5 + 0.5) * width,
y: (-(vector.y * 0.5) + 0.5) * height
};
}, objectName);
};
// Usage
const coords = await getScreenPosition(page, 'my-mesh');
await page.mouse.click(coords.x, coords.y);
Performance Testing
test('performance benchmark', async ({ page }) => {
await page.goto('/?debug');
await waitForLuxarReady(page);
// Measure FPS
const fps = await page.evaluate(async () => {
const start = Date.now();
let frames = 0;
await new Promise(resolve => {
const id = setInterval(() => {
frames++;
if (Date.now() - start > 5000) {
clearInterval(id);
resolve(null);
}
}, 16); // ~60fps
});
return frames / 5; // FPS over 5 seconds
});
expect(fps).toBeGreaterThan(50); // At least 50 FPS
});
Memory Leak Testing
test('no memory leaks', async ({ page }) => {
await page.goto('/?debug');
const initialMemory = await page.evaluate(() => {
return (performance as any).memory?.usedJSHeapSize || 0;
});
// Load and unload dataset 10 times
for (let i = 0; i < 10; i++) {
await page.evaluate(() => window.__luxarDebug.scene.clear());
// Load dataset again...
}
const finalMemory = await page.evaluate(() => {
return (performance as any).memory?.usedJSHeapSize || 0;
});
// Memory should not grow unbounded
const growthMB = (finalMemory - initialMemory) / 1024 / 1024;
expect(growthMB).toBeLessThan(100); // Less than 100MB growth
});
Resources
Support
If you encounter issues:
Check the troubleshooting section above
Run
pnpm agent:debugto see detailed logsCheck
playwright-report/for test tracesOpen an issue with screenshots and error logs
Happy Testing! 🎭