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:

  1. Automated Testing: Run tests that verify rendering, data loading, and UI behavior

  2. AI Debugging: Claude Code can “see” and debug the app without a physical monitor

  3. Visual Regression: Catch visual bugs with screenshot comparisons

  4. 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.__luxarDebug

  • Screenshot 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 state

  • test-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:

  1. See What’s Happening: Run pnpm agent:debug to see browser console logs

  2. Inspect Scene State: View Three.js objects, point counts, camera position

  3. Take Screenshots: Verify visual rendering via debug-view.png

  4. Monitor 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

  1. Don’t try to find DOM elements: Three.js objects don’t have HTML tags

  2. Use window.__luxarDebug: Query the scene state via this global

  3. Check screenshots: Visual bugs need visual verification

  4. Watch for WebGL errors: Look for “context lost” or shader errors

  5. Test with ?debug: Always add ?debug to URL for state inspection


Available Scripts

Agent Driver Scripts

Script

Purpose

When to Use

pnpm agent:debug

Run headless browser, output logs

AI debugging, CI

pnpm agent:debug:visible

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

pnpm test:e2e

Run all tests

Local manual testing

pnpm test:e2e:mobile

Chromium mobile/touch suite

Local reproduction of the hosted CI job

pnpm test:e2e:smoke

Non-GPU smoke subset

Fast local interaction smoke check

pnpm test:e2e:smoke:strict

Smoke subset with strict console handling

CI-like local smoke validation

pnpm test:e2e:browsers

Run the functional browser smoke matrix with snapshots ignored

After generating fixtures and installing Firefox/WebKit

pnpm test:e2e:ui

Interactive test runner

Writing new tests

pnpm test:e2e:debug

Debug mode

Debugging failing tests

pnpm test:e2e:report

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([]);
});

Testing nD Navigation

test('should navigate through dimensions', async ({ page }) => {
  await page.goto('/?src=/data/4d-dataset.luxar.zarr&debug');
  await waitForLuxarReady(page);

  // Press '1' to select the first non-displayed dimension
  await page.keyboard.press('1');

  // Press ']' to navigate forward
  await page.keyboard.press(']');

  // Wait for points to update
  await page.waitForTimeout(1000);

  // Verify new points loaded
  const state = await getLuxarState(page);
  expect(state.totalPoints).toBeGreaterThan(0);
});

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 waitTime in agent-driver.ts

  • Check 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

Timeouts across many unrelated specs at once

Cause: The machine, not the viewer. Each worker costs a Chromium (renderer + GPU process + the viewer’s worker pool) plus a share of the Python dataset server on port 9000, so an already-loaded box services input too slowly for the 10 s action timeout even though the page is healthy.

Fix: Usually none — playwright.config.ts sizes the local worker count via tools/e2e-workers.ts, scaling a ceiling of four by the box’s free fraction (clamp(round(4 * (cpus - load1) / cpus), 1, 4): four while at least 7/8 of the box is free, three down to 5/8, two down to 3/8, one below that — on 16 cores, four up to load 2 and one past load 10; an idle box of any size still gets four), and the global setup prints the run’s worker ceiling as one line — so a busy box produces a slower run rather than a false failure. That stamped number is config.workers; Playwright narrows it to min(workers, maxConcurrentTestGroups) afterwards, so a single-file run can be stamped max 3 and then report “using 1 worker”. The load average lags and is read once at startup, so this is a probability, not a guarantee. Pin it with LUXAR_E2E_WORKERS=N (clamped to [1, cpus] as a typo guard), or with --workers=N which overrides the config outright. Before filing a regression off a page.click: Timeout 10000ms exceeded, re-run the spec at --workers=1.

“Browser context closed”

Cause: Test timeout or crash

Fix:

  • Check for infinite loops

  • Increase timeout in playwright.config.ts

  • Check console for errors


Best Practices

For AI Agents

  1. Always run agent driver first: Don’t guess, verify actual state

  2. Add targeted logging: Insert console.logs to narrow down issues

  3. Check screenshots: Visual bugs need visual confirmation

  4. Test fixes immediately: Run driver after every change

  5. Clean up debug logs: Remove console.logs after debugging

For Developers

  1. Run tests before committing: pnpm test:e2e

  2. Update snapshots carefully: Only when changes are intentional

  3. Write focused tests: One behavior per test

  4. Use helper functions: Wait for waitForLuxarReady(page) before reading state or dispatching keyboard/mouse input

  5. Test on real data: Don’t rely only on demo datasets

For CI/CD

  1. Run tests in Docker: Consistent GPU environment

  2. Retry flaky tests: WebGL can be non-deterministic (max 2 retries)

  3. Save artifacts: Keep screenshots and traces on failure

  4. 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:

  1. Check the troubleshooting section above

  2. Run pnpm agent:debug to see detailed logs

  3. Check playwright-report/ for test traces

  4. Open an issue with screenshots and error logs

Happy Testing! 🎭