test: 💍 wxWidgets secondary canvas

This commit is contained in:
Istvan Matejcsok 2026-06-18 16:00:06 +02:00
commit 1b1e7884ee
5 changed files with 345 additions and 27 deletions

View file

@ -212,6 +212,7 @@ Organized in `apps/standalone/` folders:
| textdecor/textdecor_test | WORKS | 1/1 | Text underline/strikethrough decorations |
| bitmask/bitmask_test | WORKS | 1/1 | Bitmap masking with wxMask transparency |
| regions/regions_test | WORKS | 1/1 | Non-rectangular region clipping |
| secondary-glcanvas/secondary-glcanvas_test | WORKS | 1/1 | Secondary-window wxGLCanvas is z-lifted above the window chrome (3D-viewer occlusion regression) |
---

View file

@ -834,3 +834,23 @@ $(S)/coroutine-pthread/vcall_repro.html: $(S)/coroutine-pthread/vcall_repro.o $(
coroutine-pthread-vcall: $(S)/coroutine-pthread/vcall_repro.html
.PHONY: coroutine-pthread-vcall
# --- secondary-window wxGLCanvas compositing test (GL-enabled) -----------------
# First GL-linked standalone app. Reproduces the "two canvases over each other"
# bug: a wxGLCanvas in a secondary top-level frame must be z-lifted above that
# frame's own window chrome (wx.js createGLCanvas fix). GL link recipe mirrors
# tests/gal-regression/wasm/Makefile (wx 'gl' lib + -sMAX_WEBGL_VERSION=2).
WX_LDFLAGS_GL := $(shell $(WXCONFIG) --libs base,core,gl)
EM_GL_FLAGS := -sMAX_WEBGL_VERSION=2
LDFLAGS_GL = $(DEBUG_LDFLAGS) $(BASE_LDFLAGS) $(EM_GL_FLAGS) $(WX_LDFLAGS_GL)
$(S)/secondary-glcanvas/secondary-glcanvas_test.o: $(S)/secondary-glcanvas/secondary-glcanvas_test.cpp
$(CXX) -c $(CXXFLAGS) $< -o $@
$(S)/secondary-glcanvas/secondary-glcanvas_test.html: $(S)/secondary-glcanvas/secondary-glcanvas_test.o $(WX_CORE_LIB) $(JS_FILES)
$(CXX) $< $(LDFLAGS_GL) --pre-js $(JS) --shell-file $(HTML) -o $@
secondary-glcanvas: $(S)/secondary-glcanvas/secondary-glcanvas_test.html
# Include the new app in the default `all` build (prerequisites accumulate).
all: $(S)/secondary-glcanvas/secondary-glcanvas_test.html

View file

@ -0,0 +1,99 @@
// Secondary-window wxGLCanvas compositing test (WASM DOM port).
//
// Regression for the "two canvases over each other" bug. The wx DOM port draws
// each top-level window's chrome onto 2D canvases and reveals a wxGLCanvas
// through it. A wxGLCanvas in a *secondary* top-level window was hidden behind
// that window's own opaque chrome: every glcanvas-* was hard-coded to z-index
// 100, but showing the secondary frame raises its window-N chrome div to
// z-index 101 (raiseWindow → maxZ+1 over the visible main canvas at 100), so the
// chrome painted over the GL canvas. The fix (wx.js createGLCanvas) lifts a GL
// canvas created while another GL canvas is already visible to z-index
// 2147483647, above the chrome.
//
// This is the minimal pure-wxWidgets repro: a main frame with a wxGLCanvas plus
// a button that opens a SECOND top-level frame with its own wxGLCanvas. Opening
// the second frame only after the main one is visible mirrors opening KiCad's 3D
// viewer from a menu — and is exactly what makes createGLCanvas's hasVisibleGL
// true and triggers raiseWindow on the secondary frame. The e2e spec asserts the
// secondary GL canvas is z-lifted above the other canvases and the window chrome.
#include "wx/wxprec.h"
#ifndef WX_PRECOMP
#include "wx/wx.h"
#endif
#include "wx/glcanvas.h"
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#endif
namespace
{
// Build a wxGLCanvas with default attributes; the wasm port's
// ConvertWXAttrsToWebGL fills in WebGL2 + preserveDrawingBuffer. No GL drawing is
// needed — createGLCanvas (which assigns the z-index) runs at construction.
wxGLCanvas* MakeGLCanvas( wxWindow* parent )
{
wxGLAttributes attrs;
attrs.PlatformDefaults().Defaults().EndList();
return new wxGLCanvas( parent, attrs, wxID_ANY, wxDefaultPosition, wxSize( 320, 240 ) );
}
} // namespace
// A secondary top-level frame that owns its own wxGLCanvas — the window whose
// chrome used to occlude its GL canvas.
class SecondGLFrame : public wxFrame
{
public:
SecondGLFrame()
: wxFrame( nullptr, wxID_ANY, "Secondary GL Window", wxDefaultPosition, wxSize( 360, 280 ) )
{
wxBoxSizer* sizer = new wxBoxSizer( wxVERTICAL );
sizer->Add( MakeGLCanvas( this ), 1, wxEXPAND );
SetSizer( sizer );
}
};
class MainGLFrame : public wxFrame
{
public:
MainGLFrame()
: wxFrame( nullptr, wxID_ANY, "Main GL Window", wxDefaultPosition, wxSize( 700, 520 ) )
{
wxPanel* panel = new wxPanel( this );
wxBoxSizer* sizer = new wxBoxSizer( wxVERTICAL );
wxButton* open = new wxButton( panel, wxID_ANY, "Open Second Window" );
open->Bind( wxEVT_BUTTON, &MainGLFrame::OnOpenSecond, this );
sizer->Add( open, 0, wxALL, 8 );
sizer->Add( MakeGLCanvas( panel ), 1, wxEXPAND );
panel->SetSizer( sizer );
}
private:
void OnOpenSecond( wxCommandEvent& WXUNUSED( evt ) )
{
// Create the secondary frame's GL canvas only now, with the main canvas
// already visible — the conditions the fix keys on.
( new SecondGLFrame() )->Show( true );
}
};
class SecondaryGLCanvasApp : public wxApp
{
public:
bool OnInit() override
{
if( !wxApp::OnInit() )
return false;
MainGLFrame* frame = new MainGLFrame();
frame->Show( true );
return true;
}
};
wxIMPLEMENT_APP( SecondaryGLCanvasApp );

View file

@ -0,0 +1,96 @@
import { test, expect, waitForApp } from './utils/fixtures';
import { clickByLabel, waitForRegistry } from './utils/element-tracker';
/**
* Regression for the "two canvases over each other" bug — at the wxWidgets layer.
*
* The wasm DOM port draws each top-level window's chrome onto 2D canvases and
* reveals a wxGLCanvas through it. A wxGLCanvas in a SECONDARY top-level window
* was hidden behind that window's own opaque chrome: every `glcanvas-*` was
* hard-coded to z-index 100, but showing the secondary frame raises its
* `#window-N` chrome div to z-index 101 (`raiseWindow` → maxZ+1 over the visible
* main canvas at 100), so the chrome painted over the GL canvas. The fix
* (`wx.js` `createGLCanvas`) lifts a GL canvas created while another GL canvas is
* already visible to z-index 2147483647, above the chrome.
*
* This drives the minimal pure-wx repro app (a main frame with a wxGLCanvas + a
* button that opens a second top-level frame with its own wxGLCanvas) and asserts
* the stacking from computed styles — no pixels/screenshots, so it doesn't depend
* on actual GL rendering. Note `glcanvas-*`, `#window-N` and `.window-canvas` all
* have `pointer-events:none`, so `elementsFromPoint` can't see them; computed
* z-index is the right tool.
*
* Before the fix every `glcanvas-*` is z-index 100, so the secondary canvas is
* not strictly above the main one → FAILS. After the fix it is 2147483647 → PASSES.
*/
test.describe('secondary-window wxGLCanvas compositing', () => {
test('a wxGLCanvas in a secondary frame is z-lifted above the window chrome', async ({ page }) => {
await page.goto('/standalone/secondary-glcanvas/secondary-glcanvas_test.html');
await waitForApp(page);
await waitForRegistry(page);
// The main frame's GL canvas must be present and on-screen before we open
// the second window — that visible-canvas state is what the fix keys on.
await page.waitForFunction(() => {
const c = document.querySelector('#window-container canvas[id^="glcanvas-"]');
return !!c && getComputedStyle(c).display !== 'none'
&& (c as HTMLElement).getBoundingClientRect().width > 0;
}, undefined, { timeout: 30000 });
const before = await page.evaluate(
() => document.querySelectorAll('canvas[id^="glcanvas-"]').length);
// Open the secondary frame (mirrors opening KiCad's 3D viewer).
expect(await clickByLabel(page, 'Open Second Window'),
'the "Open Second Window" button should be clickable').toBe(true);
// Wait for the secondary frame's NEW GL canvas to appear and be on-screen.
await page.waitForFunction((b: number) => {
const list = document.querySelectorAll<HTMLCanvasElement>('canvas[id^="glcanvas-"]');
if (list.length <= b) return false;
const viewer = list[list.length - 1];
return getComputedStyle(viewer).display !== 'none'
&& viewer.getBoundingClientRect().width > 0;
}, before, { timeout: 30000 });
// Stacking order inside #window-container (a single z-index:1 stacking
// context). The secondary canvas is the newest glcanvas-*; it must
// out-stack every other GL canvas and every secondary window-N chrome div.
const stacking = await page.evaluate(() => {
const z = (el: Element) => parseInt(getComputedStyle(el).zIndex, 10) || 0;
const gls = Array.from(document.querySelectorAll('#window-container canvas[id^="glcanvas-"]'));
const windows = Array.from(document.querySelectorAll('#window-container [id^="window-"]'));
const viewer = gls[gls.length - 1];
return {
glCount: gls.length,
viewerId: viewer ? viewer.id : null,
viewerZ: viewer ? z(viewer) : 0,
maxOtherGlZ: Math.max(0, ...gls.slice(0, -1).map(z)),
maxWindowZ: Math.max(0, ...windows.map(z)),
glZ: gls.map((c) => ({ id: c.id, z: z(c) })),
windowZ: windows.map((w) => ({ id: w.id, z: z(w) })),
};
});
console.log(`[TEST] stacking: ${JSON.stringify(stacking)}`);
// Precondition: the secondary window actually opened (main + secondary canvas).
expect(stacking.glCount,
'opening the second window should add a second WebGL canvas').toBeGreaterThanOrEqual(2);
// THE regression assertion. Pre-fix every glcanvas-* shares z-index 100, so the
// secondary canvas is not strictly above the main one and the chrome occludes it.
expect(stacking.viewerZ,
`secondary GL canvas ${stacking.viewerId} (z=${stacking.viewerZ}) must stack strictly above `
+ `the other GL canvases (max z=${stacking.maxOtherGlZ}); an equal z-index means the window `
+ `chrome can occlude it. all=${JSON.stringify(stacking.glZ)}`)
.toBeGreaterThan(stacking.maxOtherGlZ);
// The user-visible symptom: the GL canvas must paint at or above every secondary
// window's opaque 2D chrome. (>= not > to tolerate a window clamped to the same
// 2147483647 CSS z-index ceiling by raiseWindow.)
expect(stacking.viewerZ,
`secondary GL canvas (z=${stacking.viewerZ}) must not sit below any window chrome `
+ `(max window z=${stacking.maxWindowZ}). windows=${JSON.stringify(stacking.windowZ)}`)
.toBeGreaterThanOrEqual(stacking.maxWindowZ);
});
});

View file

@ -74,6 +74,39 @@ function countGlCanvases(page: Page): Promise<number> {
return page.evaluate(() => document.querySelectorAll('canvas[id^="glcanvas-"]').length);
}
// Open the 3D viewer (View → 3D Viewer, with an Alt+3 fallback) and wait for the
// secondary frame + its NEW `glcanvas-*` to appear. The main pcbnew board view is
// itself a wxGLCanvas, so the viewer is detected by the GL-canvas COUNT increasing.
// Returns the glcanvas count after opening. `glBefore` is the count beforehand.
async function openThreeDViewer(page: Page, glBefore: number): Promise<number> {
let opened = false;
if (await clickMenuBarItem(page, 'View')) {
await page.waitForTimeout(400);
opened = await clickMenuItem(page, '3D Viewer');
}
if (!opened) {
console.log('[TEST] View → 3D Viewer not found via menu; trying Alt+3');
await page.keyboard.press('Escape');
await page.waitForTimeout(200);
await page.keyboard.press('Alt+3');
}
await page.waitForFunction(() => {
// A new top-level window div beyond the main pcbnew frame.
return !!document.querySelector('#window-container [id^="window-"]')
|| document.querySelectorAll('canvas[id^="glcanvas-"]').length > 0;
}, null, { timeout: 60000 });
await page.waitForFunction((before: number) =>
document.querySelectorAll('canvas[id^="glcanvas-"]').length > before,
glBefore, { timeout: 60000 });
const glAfter = await countGlCanvases(page);
console.log(`[TEST] glcanvas count after opening 3D viewer: ${glAfter}`);
expect(glAfter, 'a new WebGL canvas should appear for the 3D viewer').toBeGreaterThan(glBefore);
return glAfter;
}
test.describe('3D viewer from pcbnew', () => {
// One 187 MB wasm runtime is already heavy; keep this serial and generous.
test.describe.configure({ mode: 'serial' });
@ -89,33 +122,7 @@ test.describe('3D viewer from pcbnew', () => {
const glBefore = await countGlCanvases(page);
console.log(`[TEST] glcanvas count before opening 3D viewer: ${glBefore}`);
// ── Open the 3D viewer: View → 3D Viewer, with an Alt+3 fallback. ──
let opened = false;
if (await clickMenuBarItem(page, 'View')) {
await page.waitForTimeout(400);
opened = await clickMenuItem(page, '3D Viewer');
}
if (!opened) {
console.log('[TEST] View → 3D Viewer not found via menu; trying Alt+3');
await page.keyboard.press('Escape');
await page.waitForTimeout(200);
await page.keyboard.press('Alt+3');
}
// ── Wait for the secondary frame + a NEW GL canvas to appear. ──────
await page.waitForFunction(() => {
// A new top-level window div beyond the main pcbnew frame.
return !!document.querySelector('#window-container [id^="window-"]')
|| document.querySelectorAll('canvas[id^="glcanvas-"]').length > 0;
}, null, { timeout: 60000 });
await page.waitForFunction((before: number) =>
document.querySelectorAll('canvas[id^="glcanvas-"]').length > before,
glBefore, { timeout: 60000 });
const glAfter = await countGlCanvases(page);
console.log(`[TEST] glcanvas count after opening 3D viewer: ${glAfter}`);
expect(glAfter, 'a new WebGL canvas should appear for the 3D viewer').toBeGreaterThan(glBefore);
await openThreeDViewer(page, glBefore);
// The 3D reload + raytrace run through asyncify; give them time to build
// the scene and render a few progressive passes.
@ -181,4 +188,99 @@ test.describe('3D viewer from pcbnew', () => {
expect(stubbed,
`3D viewer is still stubbed (build not enabled?):\n${stubbed.join('\n')}`).toEqual([]);
});
/**
* Regression for the "two canvases over each other" bug.
*
* The wasm DOM port draws each window's chrome onto 2D canvases and reveals a
* wxGLCanvas through it. The CPU raytracer rendered the board correctly into the
* 3D viewer's own `glcanvas-*`, but on screen the user saw grey: the 3D viewer is
* a SECONDARY top-level frame, and when it is shown wx.js `raiseWindow()` lifts its
* opaque `#window-N` chrome div to z-index 101 (= the main GAL canvas's 100, + 1).
* Every `glcanvas-*` was hard-coded to z-index 100, so the frame's own window
* background painted OVER the GL canvas behind it — two canvases stacked, the
* opaque one on top. (The main editor escapes this because its window keeps
* `#canvas` transparent over the GAL region; a secondary frame's region is opaque.)
*
* The fix (wx.js `createGLCanvas`): a GL canvas created while another GL canvas is
* already visible belongs to a secondary window → lift it to z-index 2147483647 so
* it stacks above the chrome.
*
* This asserts the stacking straight from the DOM/computed-style — no pixels or
* screenshots — so it is independent of the slow raytrace and CI-safe on swiftshader
* (where WebGL-canvas screenshots come back blank). Note `glcanvas-*`, `#window-N`
* and `.window-canvas` all have `pointer-events:none`, so `elementsFromPoint` can't
* see them; computed z-index is the right tool.
*
* Before the fix: every `glcanvas-*` is z-index 100, so the viewer canvas is not
* strictly above the main GAL canvas (100 > 100 is false) → FAILS.
* After the fix: the viewer canvas is 2147483647 → PASSES.
*/
test('reveals the 3D viewer GL canvas above the window chrome (regression: occluded board)',
async ({ page, testLogger }) => {
await page.goto('/kicad/pcbnew.html');
await waitForPcbnew(page);
await loadBoard(page, testLogger);
const glBefore = await countGlCanvases(page);
await openThreeDViewer(page, glBefore);
// The z-index is assigned synchronously when the canvas is created, but wait
// until the newest glcanvas is actually on-screen (setGLCanvasRect ran →
// display:block, non-zero box) so its secondary frame's window-N div has been
// raised by raiseWindow() and the DOM stacking has settled.
await page.waitForFunction((before: number) => {
const list = document.querySelectorAll<HTMLCanvasElement>('canvas[id^="glcanvas-"]');
if (list.length <= before) return false;
const viewer = list[list.length - 1];
return getComputedStyle(viewer).display !== 'none'
&& viewer.getBoundingClientRect().width > 0;
}, glBefore, { timeout: 60000 });
// Stacking order inside #window-container (a single z-index:1 stacking context).
// The 3D viewer canvas is the newest glcanvas-*; it must out-stack every other
// GL canvas and every secondary window-N chrome div, or the opaque chrome occludes it.
const stacking = await page.evaluate(() => {
const z = (el: Element) => parseInt(getComputedStyle(el).zIndex, 10) || 0;
const gls = Array.from(document.querySelectorAll('#window-container canvas[id^="glcanvas-"]'));
const windows = Array.from(document.querySelectorAll('#window-container [id^="window-"]'));
const viewer = gls[gls.length - 1];
return {
glCount: gls.length,
viewerId: viewer ? viewer.id : null,
viewerZ: viewer ? z(viewer) : 0,
maxOtherGlZ: Math.max(0, ...gls.slice(0, -1).map(z)),
maxWindowZ: Math.max(0, ...windows.map(z)),
glZ: gls.map((c) => ({ id: c.id, z: z(c) })),
windowZ: windows.map((w) => ({ id: w.id, z: z(w) })),
};
});
console.log(`[TEST] stacking: ${JSON.stringify(stacking)}`);
// Precondition: the viewer actually opened (main GAL canvas + viewer canvas).
expect(stacking.glCount,
'the 3D viewer should add a second WebGL canvas').toBeGreaterThanOrEqual(2);
// THE regression assertion. Pre-fix every glcanvas-* shares z-index 100, so the
// viewer canvas is not strictly above the main GAL canvas and the chrome occludes it.
expect(stacking.viewerZ,
`3D viewer canvas ${stacking.viewerId} (z=${stacking.viewerZ}) must stack strictly above `
+ `the other GL canvases (max z=${stacking.maxOtherGlZ}); an equal z-index means the `
+ `window chrome can occlude it. all=${JSON.stringify(stacking.glZ)}`)
.toBeGreaterThan(stacking.maxOtherGlZ);
// The user-visible symptom: the GL canvas must paint at or above every secondary
// window's opaque 2D chrome. (>= not > to tolerate a window clamped to the same
// 2147483647 CSS z-index ceiling by raiseWindow.)
expect(stacking.viewerZ,
`3D viewer canvas (z=${stacking.viewerZ}) must not sit below any window chrome `
+ `(max window z=${stacking.maxWindowZ}). windows=${JSON.stringify(stacking.windowZ)}`)
.toBeGreaterThanOrEqual(stacking.maxWindowZ);
// Sanity: opening the viewer didn't blow up the runtime.
const aborts = [...testLogger.consoleLogs, ...testLogger.errors]
.filter((l) => l.includes('Aborted('));
expect(aborts, `WASM aborted while opening the 3D viewer:\n${aborts.join('\n\n')}`).toEqual([]);
});
});