feat(webgl): Add WebGL GAL test infrastructure (Phase 1)

Add complete test infrastructure for WebGL GAL visual regression testing:

- scripts/test-gal-regression.sh: Master script that builds both backends,
  runs tests, and performs two-level comparison (native vs baseline,
  webgl vs native)
- scripts/build-gal-webgl-test.sh: WASM build using Makefile with em++
- tests/gal-regression/wasm/: WebGL test harness (stub WEBGL_GAL)
- tests/e2e/gal-webgl.spec.ts: Playwright test for screenshot capture

Fix Homebrew Emscripten environment in scripts/common/env.sh:
- Set EMSDK_PYTHON for Python 3.10+ (em++ reads this, not $PYTHON)
- Add bundled LLVM to PATH (Emscripten needs its clang with WASM backend)

Verified: Native vs Baseline passes (28/28), WebGL generates blank
screenshots as expected (WEBGL_GAL implementation is Phase 2).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Viktor Vaczi 2026-01-07 16:11:31 +01:00
commit a4f444fea8
10 changed files with 1401 additions and 0 deletions

View file

@ -0,0 +1,190 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GAL WebGL Test</title>
<style>
body {
margin: 0;
padding: 20px;
background: #1a1a26;
color: #fff;
font-family: monospace;
}
#canvas-container {
display: inline-block;
border: 2px solid #444;
}
#canvas {
display: block;
}
#controls {
margin-top: 20px;
}
#status {
margin-top: 10px;
padding: 10px;
background: #2a2a3a;
border-radius: 4px;
}
button {
padding: 8px 16px;
margin-right: 10px;
cursor: pointer;
}
select {
padding: 8px;
margin-right: 10px;
}
</style>
</head>
<body>
<h1>GAL WebGL Test</h1>
<div id="canvas-container">
<canvas id="canvas" width="800" height="600"></canvas>
</div>
<div id="controls">
<select id="scenario-select">
<option value="-1">Select scenario...</option>
</select>
<button id="run-btn" disabled>Run Scenario</button>
<button id="run-all-btn" disabled>Run All</button>
</div>
<div id="status">
<div id="status-text">Loading WASM module...</div>
</div>
<script>
// Scenario names (matching native test)
const SCENARIO_NAMES = [
'basic-lines', // 0
'line-widths', // 1
'circles', // 2
'arcs', // 3
'rectangles', // 4
'polygons', // 5
'alpha-blending', // 6
'transforms', // 7
'grid-cursor', // 8
'segments', // 9
'complex-scene', // 10
'bezier-curves', // 11
'arc-segments', // 12
'segment-chain', // 13
'group-caching', // 14
'polylines-multi', // 15
'hole-walls', // 16
'grid-native', // 17
'cursor-native', // 18
'render-targets', // 19
'screen-transform', // 20
'clear-colors', // 21
'depth-testing', // 22
'negative-mode', // 23
'text-attrs', // 24
'glyphs', // 25
'bitmap', // 26
'transform-api' // 27
];
let Module = null;
// Populate scenario dropdown
function populateScenarios() {
const select = document.getElementById('scenario-select');
SCENARIO_NAMES.forEach((name, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `${index}: ${name}`;
select.appendChild(option);
});
}
// Run a single scenario
function runScenario(index) {
if (!Module) return;
const result = Module.ccall('runScenario', 'number', ['number'], [index]);
if (result === 0) {
setStatus(`Rendered scenario ${index}: ${SCENARIO_NAMES[index]}`);
} else {
setStatus(`ERROR: Failed to render scenario ${index}`);
}
}
// Run all scenarios (for automated testing)
async function runAllScenarios() {
if (!Module) return;
const total = Module.ccall('getTotalScenarios', 'number', [], []);
setStatus(`Running all ${total} scenarios...`);
for (let i = 0; i < total; i++) {
runScenario(i);
// Small delay to allow rendering
await new Promise(r => setTimeout(r, 100));
}
setStatus(`Completed all ${total} scenarios`);
}
// Update status display
function setStatus(text) {
document.getElementById('status-text').textContent = text;
console.log('[GAL Test]', text);
}
// Export for Playwright
window.galTest = {
runScenario,
runAllScenarios,
getScenarioName: (index) => SCENARIO_NAMES[index],
getTotalScenarios: () => SCENARIO_NAMES.length
};
// Setup UI after module loads
function onModuleReady() {
setStatus('WASM module loaded. Ready for testing.');
document.getElementById('run-btn').disabled = false;
document.getElementById('run-all-btn').disabled = false;
document.getElementById('run-btn').onclick = () => {
const select = document.getElementById('scenario-select');
const index = parseInt(select.value);
if (index >= 0) {
runScenario(index);
}
};
document.getElementById('run-all-btn').onclick = runAllScenarios;
document.getElementById('scenario-select').onchange = (e) => {
const index = parseInt(e.target.value);
if (index >= 0) {
runScenario(index);
}
};
// Dispatch custom event for Playwright
window.dispatchEvent(new CustomEvent('gal-test-ready'));
}
// Initialize
populateScenarios();
// Load WASM module
createGALTest().then(module => {
Module = module;
onModuleReady();
}).catch(err => {
setStatus('ERROR: Failed to load WASM module: ' + err.message);
console.error(err);
});
</script>
<script src="gal_webgl_test.js"></script>
</body>
</html>

142
tests/e2e/gal-webgl.spec.ts Normal file
View file

@ -0,0 +1,142 @@
/**
* GAL WebGL Regression Test
*
* Runs all 28 GAL test scenarios in WebGL and captures screenshots
* for comparison against native OpenGL rendering.
*/
import { test, expect } from './utils/fixtures';
import * as path from 'path';
import * as fs from 'fs';
// Scenario names (must match native test)
const SCENARIO_NAMES = [
'basic-lines', // 0
'line-widths', // 1
'circles', // 2
'arcs', // 3
'rectangles', // 4
'polygons', // 5
'alpha-blending', // 6
'transforms', // 7
'grid-cursor', // 8
'segments', // 9
'complex-scene', // 10
'bezier-curves', // 11
'arc-segments', // 12
'segment-chain', // 13
'group-caching', // 14
'polylines-multi', // 15
'hole-walls', // 16
'grid-native', // 17
'cursor-native', // 18
'render-targets', // 19
'screen-transform', // 20
'clear-colors', // 21
'depth-testing', // 22
'negative-mode', // 23
'text-attrs', // 24
'glyphs', // 25
'bitmap', // 26
'transform-api' // 27
];
// Output directory for WebGL screenshots
const OUTPUT_DIR = path.join(__dirname, '../gal-regression/output/webgl');
test.describe('GAL WebGL Regression Tests', () => {
test.beforeAll(async () => {
// Ensure output directory exists
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
});
test('Load GAL WebGL test module', async ({ page, testLogger }) => {
await page.goto('/gal-webgl/gal_webgl_test.html');
// Wait for the custom event indicating module is ready
await page.waitForFunction(() => {
return (window as any).galTest !== undefined;
}, { timeout: 60000 });
// Verify module loaded
const totalScenarios = await page.evaluate(() => {
return (window as any).galTest.getTotalScenarios();
});
expect(totalScenarios).toBe(28);
await page.screenshot({
path: path.join(OUTPUT_DIR, 'gal-module-loaded.png'),
fullPage: true
});
console.log(`GAL WebGL test module loaded with ${totalScenarios} scenarios`);
});
// Generate a test for each scenario
for (let i = 0; i < SCENARIO_NAMES.length; i++) {
const scenarioName = SCENARIO_NAMES[i];
const scenarioIndex = i;
test(`Scenario ${scenarioIndex}: ${scenarioName}`, async ({ page, testLogger }) => {
await page.goto('/gal-webgl/gal_webgl_test.html');
// Wait for module to be ready
await page.waitForFunction(() => {
return (window as any).galTest !== undefined;
}, { timeout: 60000 });
// Run the scenario
await page.evaluate((index) => {
(window as any).galTest.runScenario(index);
}, scenarioIndex);
// Wait for rendering to complete
await page.waitForTimeout(100);
// Get the canvas element and take a screenshot of just the canvas
const canvas = await page.locator('#canvas');
await expect(canvas).toBeVisible();
// Screenshot the canvas (matching native 800x600 output)
const screenshotPath = path.join(OUTPUT_DIR, `gal-${scenarioName}.png`);
await canvas.screenshot({ path: screenshotPath });
console.log(`Saved: ${screenshotPath}`);
});
}
test('Run all scenarios sequentially', async ({ page, testLogger }) => {
await page.goto('/gal-webgl/gal_webgl_test.html');
// Wait for module to be ready
await page.waitForFunction(() => {
return (window as any).galTest !== undefined;
}, { timeout: 60000 });
console.log('Running all 28 scenarios...');
for (let i = 0; i < SCENARIO_NAMES.length; i++) {
const scenarioName = SCENARIO_NAMES[i];
// Run scenario
await page.evaluate((index) => {
(window as any).galTest.runScenario(index);
}, i);
// Wait for rendering
await page.waitForTimeout(50);
// Screenshot the canvas
const canvas = await page.locator('#canvas');
const screenshotPath = path.join(OUTPUT_DIR, `gal-${scenarioName}.png`);
await canvas.screenshot({ path: screenshotPath });
console.log(`[${i + 1}/28] ${scenarioName}`);
}
console.log('All scenarios completed');
});
});

View file

@ -0,0 +1,62 @@
# Makefile for GAL WebGL Test (WASM)
#
# Uses direct em++ calls like tests/apps/Makefile.wasm
# Avoids emcmake/cmake which require Python 3.10+
#
# Usage:
# make # Build
# make clean # Clean build artifacts
# make DEBUG=1 # Debug build with source maps
CXX = em++
# Output directory
OUTPUT_DIR = ../../apps/gal-webgl
# Debug or Release build
ifdef DEBUG
CXXFLAGS = -g -O0
DEBUG_LDFLAGS = -g -gsource-map
else
CXXFLAGS = -O2
DEBUG_LDFLAGS =
endif
# Emscripten flags for WebGL 2.0
EM_FLAGS = -sUSE_WEBGL2=1 \
-sFULL_ES3=1 \
-sALLOW_MEMORY_GROWTH=1 \
-sEXPORTED_FUNCTIONS=['_main','_runScenario','_getTotalScenarios','_getCurrentScenario','_getCanvasWidth','_getCanvasHeight'] \
-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap'] \
-sMODULARIZE=1 \
-sEXPORT_NAME='createGALTest' \
-sENVIRONMENT=web
LDFLAGS = $(DEBUG_LDFLAGS) $(EM_FLAGS)
# Source files
SRCS = gal_webgl_test.cpp
OBJS = $(SRCS:.cpp=.o)
# Target
TARGET = $(OUTPUT_DIR)/gal_webgl_test.js
all: $(OUTPUT_DIR) $(TARGET)
$(OUTPUT_DIR):
mkdir -p $(OUTPUT_DIR)
%.o: %.cpp
$(CXX) -c $(CXXFLAGS) $< -o $@
$(TARGET): $(OBJS)
$(CXX) $(OBJS) $(LDFLAGS) -o $@
cp gal_webgl_test.html $(OUTPUT_DIR)/
clean:
rm -f $(OBJS)
rm -f $(OUTPUT_DIR)/gal_webgl_test.js
rm -f $(OUTPUT_DIR)/gal_webgl_test.wasm
rm -f $(OUTPUT_DIR)/gal_webgl_test.html
.PHONY: all clean

View file

@ -0,0 +1,147 @@
/**
* GAL WebGL Test - WASM Entry Point
*
* This is a test harness that renders GAL test scenarios using WEBGL_GAL
* and allows Playwright to capture screenshots for comparison against native.
*
* Phase 1: Stub that initializes WebGL and renders a test pattern
* Phase 2: Full WEBGL_GAL implementation with all scenarios
*/
#include <emscripten.h>
#include <emscripten/html5.h>
#include <GLES3/gl3.h>
#include <cstdio>
#include <cstring>
// Canvas dimensions (matching native test)
static const int CANVAS_WIDTH = 800;
static const int CANVAS_HEIGHT = 600;
// Current scenario index
static int g_currentScenario = -1;
static int g_totalScenarios = 28;
// WebGL context
static EMSCRIPTEN_WEBGL_CONTEXT_HANDLE g_glContext = 0;
/**
* Initialize WebGL context
*/
bool initWebGL() {
EmscriptenWebGLContextAttributes attrs;
emscripten_webgl_init_context_attributes(&attrs);
attrs.majorVersion = 2; // WebGL 2.0
attrs.minorVersion = 0;
attrs.alpha = true;
attrs.depth = true;
attrs.stencil = true;
attrs.antialias = false; // We handle AA ourselves
attrs.premultipliedAlpha = false;
attrs.preserveDrawingBuffer = true; // Needed for screenshots
g_glContext = emscripten_webgl_create_context("#canvas", &attrs);
if (g_glContext <= 0) {
printf("ERROR: Failed to create WebGL 2.0 context: %d\n", g_glContext);
return false;
}
emscripten_webgl_make_context_current(g_glContext);
printf("WebGL 2.0 context created successfully\n");
printf(" GL_VENDOR: %s\n", glGetString(GL_VENDOR));
printf(" GL_RENDERER: %s\n", glGetString(GL_RENDERER));
printf(" GL_VERSION: %s\n", glGetString(GL_VERSION));
return true;
}
/**
* Render a test pattern (stub for Phase 1)
* In Phase 2, this will be replaced with actual WEBGL_GAL rendering
*/
void renderTestPattern(int scenarioIndex) {
// Set viewport
glViewport(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// Clear with KiCad-like dark background
glClearColor(0.102f, 0.102f, 0.149f, 1.0f); // RGB(26, 26, 38)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// TODO Phase 2: Replace with actual WEBGL_GAL rendering
// For now, just render a different colored rectangle for each scenario
// to verify the pipeline works
// This is a placeholder - in Phase 2 we'll call:
// GALTest::RenderScenario(webglGal, scenarioIndex, CANVAS_WIDTH, CANVAS_HEIGHT);
printf("Rendered scenario %d (stub)\n", scenarioIndex);
}
/**
* Run a specific scenario
* Called from JavaScript: Module.ccall('runScenario', 'number', ['number'], [index])
*/
extern "C" {
EMSCRIPTEN_KEEPALIVE
int runScenario(int scenarioIndex) {
if (scenarioIndex < 0 || scenarioIndex >= g_totalScenarios) {
printf("ERROR: Invalid scenario index %d (valid: 0-%d)\n",
scenarioIndex, g_totalScenarios - 1);
return -1;
}
g_currentScenario = scenarioIndex;
renderTestPattern(scenarioIndex);
return 0;
}
EMSCRIPTEN_KEEPALIVE
int getTotalScenarios() {
return g_totalScenarios;
}
EMSCRIPTEN_KEEPALIVE
int getCurrentScenario() {
return g_currentScenario;
}
EMSCRIPTEN_KEEPALIVE
int getCanvasWidth() {
return CANVAS_WIDTH;
}
EMSCRIPTEN_KEEPALIVE
int getCanvasHeight() {
return CANVAS_HEIGHT;
}
} // extern "C"
/**
* Main entry point
*/
int main() {
printf("GAL WebGL Test - Phase 1 Stub\n");
printf("============================\n\n");
// Set canvas size
emscripten_set_canvas_element_size("#canvas", CANVAS_WIDTH, CANVAS_HEIGHT);
// Initialize WebGL
if (!initWebGL()) {
printf("Failed to initialize WebGL\n");
return 1;
}
printf("\nReady for scenarios. Total: %d\n", g_totalScenarios);
printf("Call runScenario(index) from JavaScript to render.\n");
// Don't exit - keep runtime alive for JavaScript calls
emscripten_exit_with_live_runtime();
return 0;
}

View file

@ -0,0 +1,190 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GAL WebGL Test</title>
<style>
body {
margin: 0;
padding: 20px;
background: #1a1a26;
color: #fff;
font-family: monospace;
}
#canvas-container {
display: inline-block;
border: 2px solid #444;
}
#canvas {
display: block;
}
#controls {
margin-top: 20px;
}
#status {
margin-top: 10px;
padding: 10px;
background: #2a2a3a;
border-radius: 4px;
}
button {
padding: 8px 16px;
margin-right: 10px;
cursor: pointer;
}
select {
padding: 8px;
margin-right: 10px;
}
</style>
</head>
<body>
<h1>GAL WebGL Test</h1>
<div id="canvas-container">
<canvas id="canvas" width="800" height="600"></canvas>
</div>
<div id="controls">
<select id="scenario-select">
<option value="-1">Select scenario...</option>
</select>
<button id="run-btn" disabled>Run Scenario</button>
<button id="run-all-btn" disabled>Run All</button>
</div>
<div id="status">
<div id="status-text">Loading WASM module...</div>
</div>
<script>
// Scenario names (matching native test)
const SCENARIO_NAMES = [
'basic-lines', // 0
'line-widths', // 1
'circles', // 2
'arcs', // 3
'rectangles', // 4
'polygons', // 5
'alpha-blending', // 6
'transforms', // 7
'grid-cursor', // 8
'segments', // 9
'complex-scene', // 10
'bezier-curves', // 11
'arc-segments', // 12
'segment-chain', // 13
'group-caching', // 14
'polylines-multi', // 15
'hole-walls', // 16
'grid-native', // 17
'cursor-native', // 18
'render-targets', // 19
'screen-transform', // 20
'clear-colors', // 21
'depth-testing', // 22
'negative-mode', // 23
'text-attrs', // 24
'glyphs', // 25
'bitmap', // 26
'transform-api' // 27
];
let Module = null;
// Populate scenario dropdown
function populateScenarios() {
const select = document.getElementById('scenario-select');
SCENARIO_NAMES.forEach((name, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `${index}: ${name}`;
select.appendChild(option);
});
}
// Run a single scenario
function runScenario(index) {
if (!Module) return;
const result = Module.ccall('runScenario', 'number', ['number'], [index]);
if (result === 0) {
setStatus(`Rendered scenario ${index}: ${SCENARIO_NAMES[index]}`);
} else {
setStatus(`ERROR: Failed to render scenario ${index}`);
}
}
// Run all scenarios (for automated testing)
async function runAllScenarios() {
if (!Module) return;
const total = Module.ccall('getTotalScenarios', 'number', [], []);
setStatus(`Running all ${total} scenarios...`);
for (let i = 0; i < total; i++) {
runScenario(i);
// Small delay to allow rendering
await new Promise(r => setTimeout(r, 100));
}
setStatus(`Completed all ${total} scenarios`);
}
// Update status display
function setStatus(text) {
document.getElementById('status-text').textContent = text;
console.log('[GAL Test]', text);
}
// Export for Playwright
window.galTest = {
runScenario,
runAllScenarios,
getScenarioName: (index) => SCENARIO_NAMES[index],
getTotalScenarios: () => SCENARIO_NAMES.length
};
// Setup UI after module loads
function onModuleReady() {
setStatus('WASM module loaded. Ready for testing.');
document.getElementById('run-btn').disabled = false;
document.getElementById('run-all-btn').disabled = false;
document.getElementById('run-btn').onclick = () => {
const select = document.getElementById('scenario-select');
const index = parseInt(select.value);
if (index >= 0) {
runScenario(index);
}
};
document.getElementById('run-all-btn').onclick = runAllScenarios;
document.getElementById('scenario-select').onchange = (e) => {
const index = parseInt(e.target.value);
if (index >= 0) {
runScenario(index);
}
};
// Dispatch custom event for Playwright
window.dispatchEvent(new CustomEvent('gal-test-ready'));
}
// Initialize
populateScenarios();
// Load WASM module
createGALTest().then(module => {
Module = module;
onModuleReady();
}).catch(err => {
setStatus('ERROR: Failed to load WASM module: ' + err.message);
console.error(err);
});
</script>
<script src="gal_webgl_test.js"></script>
</body>
</html>