diff --git a/benchmarking.mjs b/benchmarking.mjs
index 578b9e2..a8c4e12 100644
--- a/benchmarking.mjs
+++ b/benchmarking.mjs
@@ -488,7 +488,17 @@ async function main(navigator) {
}),
...(("fx" in plot || "fy" in plot) && { grid: true }),
color: { type: "ordinal", legend: true },
- width: 1280,
+ /* Fit the plot to its container so a phone gets a readable chart
+ instead of a page that scrolls sideways. Falls back to the fixed
+ 1280 wherever there is no laid-out container to measure (no DOM,
+ or a container reporting 0 width). */
+ width: (() => {
+ const avail =
+ typeof document === "undefined"
+ ? 0
+ : document.querySelector("#plot")?.clientWidth ?? 0;
+ return avail > 0 ? Math.min(1280, avail) : 1280;
+ })(),
title: plot?.title,
subtitle: plot?.subtitle,
caption: plot?.caption,
diff --git a/demos/interactive_demo.html b/demos/interactive_demo.html
index e16603c..0f8437b 100644
--- a/demos/interactive_demo.html
+++ b/demos/interactive_demo.html
@@ -26,6 +26,9 @@
height: 100%;
cursor: crosshair;
display: block;
+ /* Claim drag gestures for the simulation instead of letting the
+ browser scroll/zoom the page with them. */
+ touch-action: none;
}
#controls {
@@ -38,10 +41,20 @@
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
z-index: 10;
+ min-width: 180px;
+ }
+
+ .panel-body {
display: flex;
flex-direction: column;
gap: 10px;
- min-width: 180px;
+ }
+
+ /* Disclosure control: hidden entirely on large screens, where the
+ panel is always open. */
+ .panel-trigger,
+ .panel-toggle {
+ display: none;
}
.control-group {
@@ -188,17 +201,68 @@
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
+ .mode-btn {
+ border: 1px solid rgba(255, 255, 255, 0.25);
+ border-radius: 6px;
+ animation: none;
+ font-size: 13px;
+ padding: 10px;
+ min-height: 44px;
+ }
+
+ .mode-btn[aria-pressed="true"] {
+ border-color: #764ba2;
+ background: rgba(118, 75, 162, 0.25);
+ }
+
+ /* Small screens: collapse the panel to a single button instead of
+ shrinking the controls. The open panel previously ate roughly a
+ quarter of a phone screen; folding it away costs nothing, whereas
+ shrinking tap targets below ~44px trades a mis-tap for a few
+ reclaimed pixels - and each of these buttons launches a slow GPU
+ operation. */
@media (height < 600px) or (width < 600px) {
#controls {
- gap: 5px;
+ top: 10px;
+ right: 10px;
+ padding: 8px;
+ min-width: 0;
+ }
- .button-group {
- gap: 0px;
+ .panel-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 44px;
+ color: #fff;
+ cursor: pointer;
+ border-radius: 8px;
+ }
+
+ .panel-body {
+ display: none;
+ }
+
+ .panel-trigger:checked ~ .panel-body {
+ display: flex;
+ padding-top: 8px;
+ min-width: 200px;
+ max-height: calc(100dvh - 80px);
+ overflow-y: auto;
+ }
+
+ .button-group {
+ flex-direction: row;
+ gap: 8px;
+ margin-top: 4px;
+ }
- button {
- padding: 5px;
- }
- }
+ .button-group button {
+ flex: 1;
+ padding: 12px 4px;
+ /* Explicit floor rather than relying on font metrics. */
+ min-height: 44px;
}
}
@@ -208,19 +272,34 @@
-
+
+
+
-
-
-
-
-
+
+
-
Click: attract • Shift: repel
+
+
+
+
+
+
+
+
+
Drag to move particles • Mode or Shift flips pull/push
+
diff --git a/demos/interactive_demo.mjs b/demos/interactive_demo.mjs
index 54f99be..f8d9f2a 100644
--- a/demos/interactive_demo.mjs
+++ b/demos/interactive_demo.mjs
@@ -39,9 +39,17 @@ context.configure({
let renderUniformBuffer = null;
+/* Backing-store pixels per CSS pixel. Capped at 2 so a 3x phone screen
+ doesn't cost 9x the fill rate for a barely visible gain. Everything in
+ the simulation (particle positions, pointer position, influence radius)
+ lives in backing-store pixels, so this is the one factor that converts
+ between CSS/event coordinates and simulation coordinates. */
+let dpr = 1;
+
function resizeCanvas() {
- canvas.width = window.innerWidth;
- canvas.height = window.innerHeight;
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
+ canvas.width = Math.round(window.innerWidth * dpr);
+ canvas.height = Math.round(window.innerHeight * dpr);
if (device && renderUniformBuffer) {
device.queue.writeBuffer(renderUniformBuffer, 0, new Float32Array([canvas.width, canvas.height, 0, 0]));
}
@@ -57,12 +65,16 @@ let mouseX = -1000;
let mouseY = -1000;
let mouseDown = false;
let attractMode = true;
+/* The mode the toggle button selects. attractMode is what the shader
+ reads for the current gesture; shift-drag inverts it for mouse users. */
+let attractDefault = true;
const starSlider = document.getElementById("starSlider");
const starCountDisplay = document.getElementById("starCount");
const sortBtn = document.getElementById("sortBtn");
const scanBtn = document.getElementById("scanBtn");
const reduceBtn = document.getElementById("reduceBtn");
+const modeBtn = document.getElementById("modeBtn");
// Colors (HSL to RGB conversion for shader)
const COLORS = [
@@ -176,7 +188,8 @@ function initGPUResources(count) {
// Simulation Uniform Buffer
simulationUniformBuffer = device.createBuffer({
- size: 32, // mousePos (vec2f), canvasSize (vec2f), mouseDown (u32), attractMode (u32), isOperating (u32), padding (f32) = 32 bytes
+ // Layout is defined once by PARAMS_STRUCT_WGSL / P, below.
+ size: PARAMS_BYTE_LENGTH,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
@@ -252,17 +265,38 @@ function initGPUResources(count) {
}
// Shader Declarations
-const simulationWGSL = `
- struct Particle {
+
+/* Shared struct declarations.
+ *
+ * Both structs describe memory that every pipeline in this demo reads:
+ * Particle is the layout of the particle storage buffer, Params the
+ * layout of simulationUniformBuffer. WGSL has no include mechanism, so
+ * each shader has to declare them in full, and all the copies must agree
+ * byte for byte with each other and with updateUniforms() below.
+ *
+ * They used to be written out by hand in each shader - 7 copies of
+ * Particle and 4 of Params. That is a live hazard: editing one copy and
+ * missing another is either a compile error or, worse, silently misread
+ * memory, and it has already caused one bug here (a field renamed in the
+ * declarations but not in the shader bodies that used it). Declaring them
+ * once and interpolating means the copies cannot drift.
+ */
+const PARTICLE_STRUCT_WGSL = ` struct Particle {
pos: vec2f,
origPos: vec2f,
vel: vec2f,
destination: vec2f,
colorIndex: u32,
size: f32,
- }
-
- struct Params {
+ }`;
+
+/* layoutInset is the margin, in backing-store pixels, that the sort and
+ * scan arrangements keep clear around the edge of the canvas. It was
+ * called `padding` and was mistaken for struct alignment padding, hence
+ * the rename - the trailing _pad* fields are the actual alignment padding,
+ * rounding the struct up to the 16-byte uniform stride.
+ */
+const PARAMS_STRUCT_WGSL = ` struct Params {
mouseX: f32,
mouseY: f32,
canvasWidth: f32,
@@ -270,8 +304,35 @@ const simulationWGSL = `
mouseDown: u32,
attractMode: u32,
isOperating: u32,
- padding: f32,
- }
+ layoutInset: f32,
+ pointerRadius: f32,
+ _pad0: f32,
+ _pad1: f32,
+ _pad2: f32,
+ }`;
+
+/* Slot indices into simulationUniformBuffer, in the field order of
+ PARAMS_STRUCT_WGSL. The shaders read this buffer by field name and
+ updateUniforms() writes it by offset; nothing checks that the two agree,
+ so the names here exist to make a mismatch visible at the call site. */
+const P = {
+ mouseX: 0,
+ mouseY: 1,
+ canvasWidth: 2,
+ canvasHeight: 3,
+ mouseDown: 4,
+ attractMode: 5,
+ isOperating: 6,
+ layoutInset: 7,
+ pointerRadius: 8,
+};
+// 9 x 4 bytes = 36, rounded up to the 16-byte uniform stride.
+const PARAMS_BYTE_LENGTH = 48;
+
+const simulationWGSL = `
+${PARTICLE_STRUCT_WGSL}
+
+${PARAMS_STRUCT_WGSL}
@group(0) @binding(0) var particles: array;
@group(0) @binding(1) var params: Params;
@@ -288,8 +349,9 @@ const simulationWGSL = `
let dy = params.mouseY - p.pos.y;
let dist = sqrt(dx * dx + dy * dy);
- if (params.mouseDown == 1u && dist < 150.0 && dist > 0.1) {
- let force = ((150.0 - dist) / 150.0) * select(-0.4, 0.4, params.attractMode == 1u);
+ let radius = params.pointerRadius;
+ if (params.mouseDown == 1u && dist < radius && dist > 0.1) {
+ let force = ((radius - dist) / radius) * select(-0.4, 0.4, params.attractMode == 1u);
p.vel.x += (dx / dist) * force;
p.vel.y += (dy / dist) * force;
}
@@ -314,14 +376,7 @@ const simulationWGSL = `
`;
const extractKeysWGSL = `
- struct Particle {
- pos: vec2f,
- origPos: vec2f,
- vel: vec2f,
- destination: vec2f,
- colorIndex: u32,
- size: f32,
- }
+${PARTICLE_STRUCT_WGSL}
@group(0) @binding(0) var particles: array;
@group(0) @binding(1) var keys: array;
@@ -338,25 +393,9 @@ const extractKeysWGSL = `
`;
const applySortedWGSL = `
- struct Particle {
- pos: vec2f,
- origPos: vec2f,
- vel: vec2f,
- destination: vec2f,
- colorIndex: u32,
- size: f32,
- }
+${PARTICLE_STRUCT_WGSL}
- struct Params {
- mouseX: f32,
- mouseY: f32,
- canvasWidth: f32,
- canvasHeight: f32,
- mouseDown: u32,
- attractMode: u32,
- isOperating: u32,
- padding: f32,
- }
+${PARAMS_STRUCT_WGSL}
@group(0) @binding(0) var particles: array;
@group(0) @binding(1) var sortedIndices: array;
@@ -382,24 +421,17 @@ const applySortedWGSL = `
let origIdx = sortedIndices[rank];
let progress = f32(rank) / f32(count);
- let usableWidth = params.canvasWidth - params.padding * 2.0;
- let usableHeight = params.canvasHeight - params.padding * 2.0;
+ let usableWidth = params.canvasWidth - params.layoutInset * 2.0;
+ let usableHeight = params.canvasHeight - params.layoutInset * 2.0;
- particles[origIdx].destination.x = params.padding + progress * usableWidth;
- particles[origIdx].destination.y = params.padding + (hash(rank) * 0.5 + 0.25) * usableHeight;
+ particles[origIdx].destination.x = params.layoutInset + progress * usableWidth;
+ particles[origIdx].destination.y = params.layoutInset + (hash(rank) * 0.5 + 0.25) * usableHeight;
particles[origIdx].vel = vec2f(0.0, 0.0);
}
`;
const resetTargetsWGSL = `
- struct Particle {
- pos: vec2f,
- origPos: vec2f,
- vel: vec2f,
- destination: vec2f,
- colorIndex: u32,
- size: f32,
- }
+${PARTICLE_STRUCT_WGSL}
@group(0) @binding(0) var particles: array;
@compute @workgroup_size(256)
@@ -411,25 +443,9 @@ const resetTargetsWGSL = `
`;
const applyScanWGSL = `
- struct Particle {
- pos: vec2f,
- origPos: vec2f,
- vel: vec2f,
- destination: vec2f,
- colorIndex: u32,
- size: f32,
- }
+${PARTICLE_STRUCT_WGSL}
- struct Params {
- mouseX: f32,
- mouseY: f32,
- canvasWidth: f32,
- canvasHeight: f32,
- mouseDown: u32,
- attractMode: u32,
- isOperating: u32,
- padding: f32,
- }
+${PARAMS_STRUCT_WGSL}
@group(0) @binding(0) var particles: array;
@group(0) @binding(1) var scannedValues: array>;
@@ -450,34 +466,18 @@ const applyScanWGSL = `
let normalized = f32(val) / f32(maxValue);
let wavePhase = normalized * 3.14159265 * 12.0;
- let usableWidth = params.canvasWidth - params.padding * 2.0;
+ let usableWidth = params.canvasWidth - params.layoutInset * 2.0;
- particles[idx].destination.x = params.padding + normalized * usableWidth;
+ particles[idx].destination.x = params.layoutInset + normalized * usableWidth;
particles[idx].destination.y = params.canvasHeight / 2.0 + sin(wavePhase) * (params.canvasHeight * 0.3);
particles[idx].vel = vec2f(0.0, 0.0);
}
`;
const applyReduceWGSL = `
- struct Particle {
- pos: vec2f,
- origPos: vec2f,
- vel: vec2f,
- destination: vec2f,
- colorIndex: u32,
- size: f32,
- }
+${PARTICLE_STRUCT_WGSL}
- struct Params {
- mouseX: f32,
- mouseY: f32,
- canvasWidth: f32,
- canvasHeight: f32,
- mouseDown: u32,
- attractMode: u32,
- isOperating: u32,
- padding: f32,
- }
+${PARAMS_STRUCT_WGSL}
@group(0) @binding(0) var particles: array;
@group(0) @binding(1) var reduceResult: array;
@@ -519,14 +519,7 @@ const applyReduceWGSL = `
`;
const renderWGSL = `
- struct Particle {
- pos: vec2f,
- origPos: vec2f,
- vel: vec2f,
- destination: vec2f,
- colorIndex: u32,
- size: f32,
- }
+${PARTICLE_STRUCT_WGSL}
struct VertexOutput {
@builtin(position) pos: vec4f,
@@ -746,19 +739,25 @@ function buildBindGroups() {
}
function updateUniforms() {
- const uniformData = new ArrayBuffer(32);
+ const uniformData = new ArrayBuffer(PARAMS_BYTE_LENGTH);
const f32 = new Float32Array(uniformData);
const u32 = new Uint32Array(uniformData);
-
- f32[0] = mouseX;
- f32[1] = mouseY;
- f32[2] = canvas.width;
- f32[3] = canvas.height;
- u32[4] = mouseDown ? 1 : 0;
- u32[5] = attractMode ? 1 : 0;
- u32[6] = isOperating ? 1 : 0;
- f32[7] = 100.0; // padding
-
+
+ f32[P.mouseX] = mouseX;
+ f32[P.mouseY] = mouseY;
+ f32[P.canvasWidth] = canvas.width;
+ f32[P.canvasHeight] = canvas.height;
+ u32[P.mouseDown] = mouseDown ? 1 : 0;
+ u32[P.attractMode] = attractMode ? 1 : 0;
+ u32[P.isOperating] = isOperating ? 1 : 0;
+ // Margin the sort/scan arrangements keep clear, in backing-store pixels.
+ f32[P.layoutInset] = 100.0 * dpr;
+ // Influence radius, also in backing-store pixels. Scaling by dpr keeps the
+ // felt radius constant in CSS pixels across displays; the extra bump on
+ // coarse pointers accounts for a fingertip being blunter than a cursor.
+ f32[P.pointerRadius] =
+ 150.0 * dpr * (matchMedia("(pointer: coarse)").matches ? 1.5 : 1.0);
+
device.queue.writeBuffer(simulationUniformBuffer, 0, uniformData);
}
@@ -976,13 +975,39 @@ function render() {
}
// Event Listeners
-canvas.addEventListener("mousemove", (e) => {
- mouseX = e.clientX;
- mouseY = e.clientY;
-});
-canvas.addEventListener("mouseleave", () => {
+//
+// Pointer events rather than mouse events: mobile browsers synthesize a
+// click from a tap but never a mousemove stream from a drag, so the
+// mouse-only version was inert on touch. clientX/Y are CSS pixels and the
+// simulation works in backing-store pixels, hence the dpr scaling.
+function setPointer(e) {
+ mouseX = e.clientX * dpr;
+ mouseY = e.clientY * dpr;
+}
+function clearPointer() {
mouseX = -1000;
mouseY = -1000;
+ mouseDown = false;
+}
+
+canvas.addEventListener("pointermove", setPointer);
+canvas.addEventListener("pointerdown", (e) => {
+ setPointer(e);
+ mouseDown = true;
+ // Shift still works for mouse users; the toggle button drives touch.
+ attractMode = e.shiftKey ? !attractDefault : attractDefault;
+ // Keep receiving moves even if the finger slides off the canvas.
+ canvas.setPointerCapture?.(e.pointerId);
+});
+canvas.addEventListener("pointerup", (e) => {
+ mouseDown = false;
+ canvas.releasePointerCapture?.(e.pointerId);
+ // A finger has no hover state, so drop the influence point on release.
+ if (e.pointerType !== "mouse") clearPointer();
+});
+canvas.addEventListener("pointercancel", clearPointer);
+canvas.addEventListener("pointerleave", (e) => {
+ if (e.pointerType === "mouse") clearPointer();
});
starSlider.addEventListener("input", (e) => {
particleCount = parseInt(e.target.value);
@@ -999,12 +1024,12 @@ starSlider.addEventListener("change", (e) => {
initGPUResources(particleCount);
}, 100);
});
-canvas.addEventListener("mousedown", (e) => {
- mouseDown = true;
- attractMode = !e.shiftKey;
-});
-canvas.addEventListener("mouseup", () => {
- mouseDown = false;
+// Mode toggle: the only way to reach repel without a keyboard.
+modeBtn.addEventListener("click", () => {
+ attractDefault = !attractDefault;
+ attractMode = attractDefault;
+ modeBtn.textContent = attractDefault ? "Mode: Attract" : "Mode: Repel";
+ modeBtn.setAttribute("aria-pressed", String(!attractDefault));
});
function formatStarCount(count) {
diff --git a/examples/gridwise.css b/examples/gridwise.css
index d059d29..520c1de 100644
--- a/examples/gridwise.css
+++ b/examples/gridwise.css
@@ -55,6 +55,16 @@ hr {
#plot {
margin-top: 1em;
+ /* Safety net: if a chart still ends up wider than the page (a long
+ legend, a narrow phone), it scrolls inside this box rather than
+ making the whole page scroll sideways. */
+ overflow-x: auto;
+}
+
+#plot figure {
+ margin-left: 0;
+ margin-right: 0;
+ max-width: 100%;
}
#webgpu-results {
diff --git a/misc/run_headless_tests.js b/misc/run_headless_tests.js
index a6e3487..725cfdb 100644
--- a/misc/run_headless_tests.js
+++ b/misc/run_headless_tests.js
@@ -59,11 +59,146 @@ function startServer(root, port = 8000) {
});
}
+async function runRegressionTests(browser) {
+ const page = await browser.newPage();
+
+ // Redirect browser console logs to node console
+ page.on('console', msg => console.log(`[Browser Console] ${msg.text()}`));
+
+ console.log("Navigating to regression tests page...");
+ await page.goto('http://127.0.0.1:8000/examples/regression.html', {
+ waitUntil: 'domcontentloaded',
+ timeout: 30000
+ });
+
+ console.log("Waiting for tests to complete...");
+ // Wait for #summary to change from "Running…"
+ await page.waitForFunction(() => {
+ const summary = document.getElementById('summary');
+ return summary && summary.textContent !== 'Running…';
+ }, { timeout: 60000 });
+
+ const summaryText = await page.evaluate(() => {
+ return document.getElementById('summary').textContent;
+ });
+
+ console.log("\n================ TEST SUMMARY ================");
+ console.log(summaryText);
+ console.log("==============================================\n");
+
+ const failedTests = await page.evaluate(() => {
+ const fails = Array.from(document.querySelectorAll('#test-list li.fail'));
+ return fails.map(el => el.textContent);
+ });
+
+ await page.close();
+ return failedTests;
+}
+
+/*
+ * Smoke test for demos/interactive_demo.html.
+ *
+ * The demo is not covered by the regression suite and is not shipped in
+ * the npm package, so it can rot silently. This is deliberately shallow:
+ * it does not check that the simulation is correct, only that the page
+ * comes up, every shader compiles, and each of the three primitive
+ * buttons runs without raising an error.
+ *
+ * That is enough to catch the class of breakage that is easy to cause and
+ * easy to miss: the demo declares its Particle and Params structs once
+ * and interpolates them into seven shaders, so a field renamed or a
+ * uniform resized in one place and not another is a compile error at
+ * page load. Clicking Sort and Scan matters specifically because those
+ * are the shaders that read Params.layoutInset - a field that no other
+ * code path touches.
+ */
+async function runDemoSmokeTest(browser) {
+ const page = await browser.newPage();
+ const failures = [];
+
+ page.on('pageerror', err => failures.push(`uncaught exception: ${err.message}`));
+ page.on('console', msg => {
+ if (msg.type() === 'error' && !msg.text().includes('favicon')) {
+ failures.push(`console error: ${msg.text()}`);
+ }
+ });
+
+ console.log("Navigating to interactive demo...");
+ await page.goto('http://127.0.0.1:8000/demos/interactive_demo.html', {
+ waitUntil: 'domcontentloaded',
+ timeout: 30000
+ });
+
+ // Give the demo time to request a device, compile all seven shaders and
+ // build its buffers before looking at anything.
+ await new Promise(r => setTimeout(r, 5000));
+
+ const hasWebGPU = await page.evaluate(() => !!navigator.gpu);
+ if (!hasWebGPU) {
+ console.log("WebGPU unavailable in this browser; skipping demo smoke test.");
+ await page.close();
+ return [];
+ }
+
+ // A non-zero backing store means resizeCanvas ran and the context was
+ // configured; zero means init bailed out somewhere.
+ const canvas = await page.evaluate(() => {
+ const c = document.getElementById('canvas');
+ return { w: c.width, h: c.height };
+ });
+ if (!canvas.w || !canvas.h) {
+ failures.push(`canvas has no backing store (${canvas.w}x${canvas.h})`);
+ }
+
+ // Every control the demo wires up must exist, or an addEventListener
+ // call threw and the rest of the module never ran.
+ const missing = await page.evaluate(() => {
+ const ids = ['canvas', 'starSlider', 'starCount', 'sortBtn', 'scanBtn', 'reduceBtn', 'modeBtn'];
+ return ids.filter(id => !document.getElementById(id));
+ });
+ if (missing.length) failures.push(`missing elements: ${missing.join(', ')}`);
+
+ // Prove the module actually ran to completion before trusting anything
+ // below. The event listeners are registered at the very bottom of
+ // interactive_demo.mjs, so if init threw part way - a shader that failed
+ // to compile, say - nothing is wired up and every check after this point
+ // would pass vacuously. Toggling the mode button is the cheapest probe:
+ // its label only changes if its listener exists.
+ const modeLabel = await page.evaluate(() => {
+ const b = document.getElementById('modeBtn');
+ const before = b.textContent;
+ b.click();
+ return { before, after: b.textContent };
+ });
+ if (modeLabel.before === modeLabel.after) {
+ failures.push(
+ `demo did not finish initializing: mode button inert (still "${modeLabel.after}")`
+ );
+ }
+
+ // Run each primitive. Sort and Scan exercise the shaders that read
+ // Params.layoutInset; Reduce exercises the third pipeline.
+ for (const id of ['sortBtn', 'scanBtn', 'reduceBtn']) {
+ await page.evaluate(btn => document.getElementById(btn).click(), id);
+ await new Promise(r => setTimeout(r, 2500));
+ const err = await page.evaluate(() => {
+ const el = document.getElementById('errorDisplay');
+ return getComputedStyle(el).display !== 'none' ? el.textContent : null;
+ });
+ if (err) failures.push(`${id} raised: ${err}`);
+ else console.log(` ${id}: ok`);
+ }
+
+ await page.close();
+ return failures;
+}
+
async function main() {
console.log("Starting local HTTP server...");
const server = await startServer(projectRoot, 8000);
let browser;
+ let exitCode = 0;
try {
console.log("Launching headless browser with WebGPU support...");
browser = await puppeteer.launch({
@@ -75,46 +210,27 @@ async function main() {
]
});
- const page = await browser.newPage();
-
- // Redirect browser console logs to node console
- page.on('console', msg => console.log(`[Browser Console] ${msg.text()}`));
-
- console.log("Navigating to regression tests page...");
- await page.goto('http://127.0.0.1:8000/examples/regression.html', {
- waitUntil: 'domcontentloaded',
- timeout: 30000
- });
-
- console.log("Waiting for tests to complete...");
- // Wait for #summary to change from "Running…"
- await page.waitForFunction(() => {
- const summary = document.getElementById('summary');
- return summary && summary.textContent !== 'Running…';
- }, { timeout: 60000 });
-
- const summaryText = await page.evaluate(() => {
- return document.getElementById('summary').textContent;
- });
-
- console.log("\n================ TEST SUMMARY ================");
- console.log(summaryText);
- console.log("==============================================\n");
-
- const failedTests = await page.evaluate(() => {
- const fails = Array.from(document.querySelectorAll('#test-list li.fail'));
- return fails.map(el => el.textContent);
- });
-
+ const failedTests = await runRegressionTests(browser);
if (failedTests.length > 0) {
console.error("Failed tests:");
failedTests.forEach(t => console.error(`- ${t}`));
- process.exit(1);
+ exitCode = 1;
} else {
- console.log("All tests passed successfully!");
- process.exit(0);
+ console.log("All regression tests passed successfully!");
}
+ console.log("\n=========== INTERACTIVE DEMO SMOKE ===========");
+ const demoFailures = await runDemoSmokeTest(browser);
+ if (demoFailures.length > 0) {
+ console.error("Interactive demo smoke test failed:");
+ demoFailures.forEach(f => console.error(`- ${f}`));
+ exitCode = 1;
+ } else {
+ console.log("Interactive demo smoke test passed.");
+ }
+ console.log("==============================================\n");
+
+ process.exit(exitCode);
} catch (error) {
console.error("An error occurred during test execution:", error);
process.exit(1);