feat(wasm-dom): tests for context menus + scrollbars; fix clean wx build

- Standalone wx test apps (contextmenu, scrollbar) + e2e specs proving the
  wxScrollBar control, the wxScrolledWindow gutter, and DoPopupMenu.
- KiCad in-app specs: pl_editor + pcbnew canvas right-click context menus and
  scrolled-panel scrollbar gutters, with baseline screenshots.
- build-wx-wasm.sh: serial -j1 fallback when the parallel build trips the
  intermittent clean-build race (a generated header read mid-rewrite by
  concurrent compiles; never happens at -j1). Recovery resumes from built
  objects; a genuine error still fails the serial pass.
- Bump wxwidgets submodule (context menus + draggable scrollbars).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Vaczi 2026-06-13 23:15:49 +02:00
commit 68430fa87c
19 changed files with 742 additions and 3 deletions

View file

@ -188,7 +188,23 @@ fi
kw_stage wxwidgets-compile kw_stage wxwidgets-compile
echo "" echo ""
echo "=== Building wxWidgets (using ${JOBS} parallel jobs) ===" echo "=== Building wxWidgets (using ${JOBS} parallel jobs) ==="
emmake make -j${JOBS} # A clean -jN build occasionally fails non-deterministically: a burst of GUI
# translation units (toplevel.cpp, dirdlgg.cpp, the generic colour/dir dialogs,
# ...) all abort at once with bogus "incomplete type 'wxBitmap'" / "wxIcon has
# no member IsOk" / "unknown type 'wxTranslations'" errors, while neighbouring
# files compile fine. It never happens at -j1 (the host default) — it's a
# parallel-build race over a generated/regenerated header (config.status can
# re-emit wx/setup.h, and emscripten warms its cache on first use), so some
# compiles read a file mid-rewrite. The same source builds cleanly on a retry.
# Rather than serialize the whole (slow) build, fall back to a serial pass only
# when the parallel one trips: it resumes from the already-built objects, so it
# just recompiles the few that lost the race. A genuine error still fails the
# -j1 pass and surfaces. (Same spirit as the serial PCRE pre-build above.)
if ! emmake make -j${JOBS}; then
echo ""
echo "=== Parallel build failed; retrying serially to clear the clean-build race ==="
emmake make -j1
fi
# Create library symlinks (remove -emscripten suffix for CMake compatibility) # Create library symlinks (remove -emscripten suffix for CMake compatibility)
echo "" echo ""

View file

@ -126,6 +126,8 @@ S = standalone
all: minimal_test.html \ all: minimal_test.html \
$(S)/menu/menu_test.html \ $(S)/menu/menu_test.html \
$(S)/contextmenu/contextmenu_test.html \
$(S)/scrollbar/scrollbar_test.html \
$(S)/clipboard/clipboard_test.html \ $(S)/clipboard/clipboard_test.html \
$(S)/filedialog/filedialog_test.html \ $(S)/filedialog/filedialog_test.html \
$(S)/layout/layout_test.html \ $(S)/layout/layout_test.html \
@ -219,6 +221,20 @@ $(S)/menu/menu_test.o: $(S)/menu/menu_test.cpp
$(S)/menu/menu_test.html: $(S)/menu/menu_test.o $(WX_CORE_LIB) $(JS_FILES) $(S)/menu/menu_test.html: $(S)/menu/menu_test.o $(WX_CORE_LIB) $(JS_FILES)
$(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@ $(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
# Context menu test (no GL)
$(S)/contextmenu/contextmenu_test.o: $(S)/contextmenu/contextmenu_test.cpp
$(CXX) -c $(CXXFLAGS) $< -o $@
$(S)/contextmenu/contextmenu_test.html: $(S)/contextmenu/contextmenu_test.o $(WX_CORE_LIB) $(JS_FILES)
$(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
# Scrollbar test (no GL)
$(S)/scrollbar/scrollbar_test.o: $(S)/scrollbar/scrollbar_test.cpp
$(CXX) -c $(CXXFLAGS) $< -o $@
$(S)/scrollbar/scrollbar_test.html: $(S)/scrollbar/scrollbar_test.o $(WX_CORE_LIB) $(JS_FILES)
$(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
# Clipboard test (no GL) # Clipboard test (no GL)
$(S)/clipboard/clipboard_test.o: $(S)/clipboard/clipboard_test.cpp $(S)/clipboard/clipboard_test.o: $(S)/clipboard/clipboard_test.cpp
$(CXX) -c $(CXXFLAGS) $< -o $@ $(CXX) -c $(CXXFLAGS) $< -o $@
@ -547,6 +563,8 @@ $(S)/asyncify-races/races_test_nosleepfix.html: $(S)/asyncify-races/races_test.o
# Convenience targets # Convenience targets
menu: $(S)/menu/menu_test.html menu: $(S)/menu/menu_test.html
contextmenu: $(S)/contextmenu/contextmenu_test.html
scrollbar: $(S)/scrollbar/scrollbar_test.html
clipboard: $(S)/clipboard/clipboard_test.html clipboard: $(S)/clipboard/clipboard_test.html
filedialog: $(S)/filedialog/filedialog_test.html filedialog: $(S)/filedialog/filedialog_test.html
layout: $(S)/layout/layout_test.html layout: $(S)/layout/layout_test.html
@ -609,7 +627,7 @@ clean:
rm -f $(S)/*/*_test*.html $(S)/*/*_test*.js $(S)/*/*_test*.wasm rm -f $(S)/*/*_test*.html $(S)/*/*_test*.js $(S)/*/*_test*.wasm
rm -f $(S)/*/*_repro*.html $(S)/*/*_repro*.js $(S)/*/*_repro*.wasm rm -f $(S)/*/*_repro*.html $(S)/*/*_repro*.js $(S)/*/*_repro*.wasm
.PHONY: all clean menu clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize threadpool logerror retinascale coroutine coroutine-nested asyncify-races .PHONY: all clean menu contextmenu scrollbar clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize threadpool logerror retinascale coroutine coroutine-nested asyncify-races
# === Coroutine pthread variant — reproduces the KiCad Asyncify-fiber x pthreads crash === # === Coroutine pthread variant — reproduces the KiCad Asyncify-fiber x pthreads crash ===
# Same modal-free harness as `coroutine`, but compiled/linked with pthreads to match # Same modal-free harness as `coroutine`, but compiled/linked with pthreads to match

View file

@ -0,0 +1,141 @@
// Context-menu (DoPopupMenu) Test - right-click popup menus in the WASM DOM
// port. KiCad pops these from its tool framework (frame->PopupMenu) on a
// right-click in the canvas; this exercises the same synchronous PopupMenu.
#include "wx/wxprec.h"
#ifndef WX_PRECOMP
#include "wx/wx.h"
#endif
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#endif
enum {
ID_CTX_CUT = wxID_HIGHEST + 1,
ID_CTX_PASTE,
ID_CTX_SNAP,
ID_CTX_SUB_A,
ID_CTX_SUB_B
};
class CtxApp : public wxApp
{
public:
virtual bool OnInit() override;
};
class CtxFrame : public wxFrame
{
public:
CtxFrame();
private:
wxStaticText* m_status = nullptr;
bool m_snap = false;
void Log(const wxString& msg);
void PopContextMenu();
void OnRightUp(wxMouseEvent& evt);
void OnContextMenu(wxContextMenuEvent& evt);
void OnCut(wxCommandEvent& evt);
void OnPaste(wxCommandEvent& evt);
void OnSnap(wxCommandEvent& evt);
void OnSubA(wxCommandEvent& evt);
void OnSubB(wxCommandEvent& evt);
};
wxIMPLEMENT_APP(CtxApp);
bool CtxApp::OnInit()
{
if (!wxApp::OnInit())
return false;
(new CtxFrame())->Show(true);
return true;
}
CtxFrame::CtxFrame()
: wxFrame(nullptr, wxID_ANY, "Context Menu WASM Test",
wxDefaultPosition, wxSize(640, 480))
{
wxPanel* panel = new wxPanel(this, wxID_ANY);
panel->SetBackgroundColour(wxColour(250, 250, 245));
wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL);
m_status = new wxStaticText(panel, wxID_ANY, "Right-click anywhere");
sizer->Add(m_status, 0, wxALL, 12);
wxStaticText* hint = new wxStaticText(panel, wxID_ANY,
"Right-click this panel to open the context menu.");
sizer->Add(hint, 0, wxLEFT | wxRIGHT | wxBOTTOM, 12);
panel->SetSizer(sizer);
// Right-click anywhere on the panel pops the menu (both the concrete
// mouse event and the higher-level context-menu event are bound; only
// one fires per click in practice).
panel->Bind(wxEVT_RIGHT_UP, &CtxFrame::OnRightUp, this);
panel->Bind(wxEVT_CONTEXT_MENU, &CtxFrame::OnContextMenu, this);
Bind(wxEVT_MENU, &CtxFrame::OnCut, this, ID_CTX_CUT);
Bind(wxEVT_MENU, &CtxFrame::OnPaste, this, ID_CTX_PASTE);
Bind(wxEVT_MENU, &CtxFrame::OnSnap, this, ID_CTX_SNAP);
Bind(wxEVT_MENU, &CtxFrame::OnSubA, this, ID_CTX_SUB_A);
Bind(wxEVT_MENU, &CtxFrame::OnSubB, this, ID_CTX_SUB_B);
Log("app started");
}
void CtxFrame::Log(const wxString& msg)
{
if (m_status)
m_status->SetLabel(msg);
#ifdef __EMSCRIPTEN__
EM_ASM({
console.log('[CTXMENU_EVENT] ' + UTF8ToString($0));
}, (const char*)msg.utf8_str());
#endif
}
void CtxFrame::PopContextMenu()
{
wxMenu menu;
menu.Append(ID_CTX_CUT, "Cut");
menu.AppendSeparator();
wxMenuItem* snap = menu.AppendCheckItem(ID_CTX_SNAP, "Snap to grid");
snap->Check(m_snap);
wxMenuItem* paste = menu.Append(ID_CTX_PASTE, "Paste");
paste->Enable(false); // disabled item
wxMenu* sub = new wxMenu;
sub->Append(ID_CTX_SUB_A, "Sub A");
sub->Append(ID_CTX_SUB_B, "Sub B");
menu.AppendSubMenu(sub, "More");
// No coords: pop at the mouse (the DOM port uses the last pointer pos).
PopupMenu(&menu);
}
void CtxFrame::OnRightUp(wxMouseEvent& WXUNUSED(evt))
{
PopContextMenu();
}
void CtxFrame::OnContextMenu(wxContextMenuEvent& WXUNUSED(evt))
{
PopContextMenu();
}
void CtxFrame::OnCut(wxCommandEvent& WXUNUSED(evt)) { Log("Cut chosen"); }
void CtxFrame::OnPaste(wxCommandEvent& WXUNUSED(evt)) { Log("Paste chosen"); }
void CtxFrame::OnSnap(wxCommandEvent& evt)
{
m_snap = evt.IsChecked();
Log(m_snap ? "Snap to grid ON" : "Snap to grid OFF");
}
void CtxFrame::OnSubA(wxCommandEvent& WXUNUSED(evt)) { Log("Sub A chosen"); }
void CtxFrame::OnSubB(wxCommandEvent& WXUNUSED(evt)) { Log("Sub B chosen"); }

View file

@ -0,0 +1,122 @@
// Scrollbar Test - draggable scrollbars in the WASM DOM port. Exercises BOTH
// the standalone wxScrollBar control AND a wxScrolledWindow's built-in gutters
// (the kind KiCad's scrolled panels use).
#include "wx/wxprec.h"
#ifndef WX_PRECOMP
#include "wx/wx.h"
#endif
#include "wx/scrolbar.h"
#include "wx/scrolwin.h"
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#endif
class ScrollApp : public wxApp
{
public:
virtual bool OnInit() override;
};
class ScrollFrame : public wxFrame
{
public:
ScrollFrame();
private:
wxScrollBar* m_vbar = nullptr;
wxScrollBar* m_hbar = nullptr;
wxScrolledWindow* m_scrolled = nullptr;
wxStaticText* m_status = nullptr;
void Log(const wxString& msg);
void OnBarScroll(wxScrollEvent& evt);
};
wxIMPLEMENT_APP(ScrollApp);
bool ScrollApp::OnInit()
{
if (!wxApp::OnInit())
return false;
(new ScrollFrame())->Show(true);
return true;
}
ScrollFrame::ScrollFrame()
: wxFrame(nullptr, wxID_ANY, "Scrollbar WASM Test",
wxDefaultPosition, wxSize(700, 560))
{
wxBoxSizer* root = new wxBoxSizer(wxVERTICAL);
m_status = new wxStaticText(this, wxID_ANY, "Drag a scrollbar");
root->Add(m_status, 0, wxALL, 8);
// --- Standalone wxScrollBar controls (one of each orientation) ---
wxBoxSizer* bars = new wxBoxSizer(wxHORIZONTAL);
m_vbar = new wxScrollBar(this, wxID_ANY, wxDefaultPosition,
wxSize(20, 140), wxSB_VERTICAL);
m_vbar->SetScrollbar(0, 20, 100, 20);
bars->Add(m_vbar, 0, wxALL, 8);
m_hbar = new wxScrollBar(this, wxID_ANY, wxDefaultPosition,
wxSize(240, 20), wxSB_HORIZONTAL);
m_hbar->SetScrollbar(0, 20, 100, 20);
bars->Add(m_hbar, 0, wxALL | wxALIGN_CENTRE_VERTICAL, 8);
root->Add(bars, 0, wxEXPAND);
m_vbar->Bind(wxEVT_SCROLL_THUMBTRACK, &ScrollFrame::OnBarScroll, this);
m_vbar->Bind(wxEVT_SCROLL_CHANGED, &ScrollFrame::OnBarScroll, this);
m_hbar->Bind(wxEVT_SCROLL_THUMBTRACK, &ScrollFrame::OnBarScroll, this);
m_hbar->Bind(wxEVT_SCROLL_CHANGED, &ScrollFrame::OnBarScroll, this);
// --- wxScrolledWindow with overflowing content (built-in gutters) ---
m_scrolled = new wxScrolledWindow(this, wxID_ANY, wxDefaultPosition,
wxDefaultSize,
wxVSCROLL | wxHSCROLL | wxBORDER_SUNKEN);
m_scrolled->SetScrollRate(10, 10);
wxPanel* content = new wxPanel(m_scrolled, wxID_ANY);
content->SetBackgroundColour(wxColour(230, 235, 250));
content->SetMinSize(wxSize(1400, 1400)); // far larger than the viewport
wxBoxSizer* cs = new wxBoxSizer(wxVERTICAL);
cs->Add(new wxStaticText(content, wxID_ANY,
"Scrolled content (drag the gutter on the right/bottom edge)"),
0, wxALL, 12);
content->SetSizer(cs);
wxBoxSizer* inner = new wxBoxSizer(wxVERTICAL);
inner->Add(content, 1, wxEXPAND);
m_scrolled->SetSizer(inner);
m_scrolled->FitInside();
root->Add(m_scrolled, 1, wxEXPAND | wxALL, 8);
SetSizer(root);
Log("app started");
}
void ScrollFrame::Log(const wxString& msg)
{
if (m_status)
m_status->SetLabel(msg);
#ifdef __EMSCRIPTEN__
EM_ASM({
console.log('[SCROLLBAR_EVENT] ' + UTF8ToString($0));
}, (const char*)msg.utf8_str());
#endif
}
void ScrollFrame::OnBarScroll(wxScrollEvent& evt)
{
Log(wxString::Format("scrollbar pos %d", evt.GetPosition()));
evt.Skip();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -0,0 +1,94 @@
// Context-menu (DoPopupMenu) e2e — right-click pops a DOM popup, items route
// wxEVT_MENU back to the handler, and outside-click/Escape cancels.
import { test, expect, tryLoadApp, getCanvasBox } from './utils/fixtures';
import { findRenderedByType, clickMenuItem } from './utils/element-tracker';
async function rightClickCanvasCentre(page: import('@playwright/test').Page) {
const box = await getCanvasBox(page);
const x = Math.round(box.x + box.width / 2);
const y = Math.round(box.y + box.height / 2);
await page.mouse.click(x, y, { button: 'right' });
return { x, y };
}
async function popupItemCount(page: import('@playwright/test').Page): Promise<number> {
const items = await findRenderedByType(page, 'menuitem', { parentId: 'popupmenu' });
return items.length;
}
test.describe('DOM-port context menu', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/standalone/contextmenu/contextmenu_test.html');
expect(await tryLoadApp(page, 30000), 'context-menu app should load').toBe(true);
await expect
.poll(() => true, { timeout: 1000 })
.toBe(true);
});
test('right-click opens the menu with the expected items', async ({ page, testLogger }) => {
await rightClickCanvasCentre(page);
// The popup builds its rows + registers them in a requestAnimationFrame.
await expect.poll(() => popupItemCount(page), {
timeout: 8000,
message: 'context menu items should register',
}).toBeGreaterThanOrEqual(4); // Cut, Snap to grid, Paste, More (separators excluded)
const items = await findRenderedByType(page, 'menuitem', { parentId: 'popupmenu' });
const labels = items.map((i) => i.label);
expect(labels).toContain('Cut');
expect(labels).toContain('Snap to grid');
expect(labels).toContain('More');
// The disabled "Paste" item registers but is not enabled.
const paste = items.find((i) => i.label === 'Paste');
expect(paste, 'Paste should be present').toBeTruthy();
expect(paste!.enabled, 'Paste should be disabled').toBe(false);
await page.screenshot({ path: 'test-results/contextmenu-01-open.png', fullPage: true });
expect(testLogger.errors.filter((e) => !e.includes('favicon'))).toHaveLength(0);
});
test('choosing an item routes wxEVT_MENU to the handler', async ({ page, testLogger }) => {
await rightClickCanvasCentre(page);
await expect.poll(() => popupItemCount(page), { timeout: 8000 }).toBeGreaterThanOrEqual(4);
expect(await clickMenuItem(page, 'Cut'), 'Cut should be clickable').toBe(true);
await expect.poll(
() => testLogger.consoleLogs.some((l) => l.includes('[CTXMENU_EVENT] Cut chosen')),
{ timeout: 8000, message: 'the Cut handler should fire' },
).toBe(true);
// The popup is dismissed after a choice.
await expect.poll(() => popupItemCount(page), { timeout: 4000 }).toBe(0);
});
test('a checkable item toggles and reports its new state', async ({ page, testLogger }) => {
await rightClickCanvasCentre(page);
await expect.poll(() => popupItemCount(page), { timeout: 8000 }).toBeGreaterThanOrEqual(4);
expect(await clickMenuItem(page, 'Snap to grid')).toBe(true);
await expect.poll(
() => testLogger.consoleLogs.some((l) => l.includes('[CTXMENU_EVENT] Snap to grid ON')),
{ timeout: 8000, message: 'the check item should toggle ON' },
).toBe(true);
});
test('Escape cancels without firing a command', async ({ page, testLogger }) => {
await rightClickCanvasCentre(page);
await expect.poll(() => popupItemCount(page), { timeout: 8000 }).toBeGreaterThanOrEqual(4);
const before = testLogger.consoleLogs.filter((l) => l.includes('chosen')).length;
await page.keyboard.press('Escape');
await expect.poll(() => popupItemCount(page), {
timeout: 4000,
message: 'Escape should dismiss the popup',
}).toBe(0);
const after = testLogger.consoleLogs.filter((l) => l.includes('chosen')).length;
expect(after, 'no command should fire on cancel').toBe(before);
});
});

View file

@ -0,0 +1,63 @@
// Draggable scrollbar e2e — the DOM scrollbar widget reports drags back to
// C++ (wxEVT_SCROLL for the standalone wxScrollBar; wxEVT_SCROLLWIN for the
// wxScrolledWindow gutter, which scrolls the content). The thumb registers as
// 'slider' + 'slidertrack' so Playwright can drag it.
import { test, expect, tryLoadApp } from './utils/fixtures';
import { findRenderedByType } from './utils/element-tracker';
test.describe('DOM-port scrollbars', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/standalone/scrollbar/scrollbar_test.html');
expect(await tryLoadApp(page, 30000), 'scrollbar app should load').toBe(true);
await page.waitForTimeout(500);
});
test('scrollbars render draggable thumbs', async ({ page, testLogger }) => {
await expect.poll(
async () => (await findRenderedByType(page, 'slider')).length,
{ timeout: 8000, message: 'scrollbar thumbs should register' },
).toBeGreaterThanOrEqual(2);
await page.screenshot({ path: 'test-results/scrollbar-01-loaded.png', fullPage: true });
expect(testLogger.errors.filter((e) => !e.includes('favicon'))).toHaveLength(0);
});
test('dragging a thumb scrolls (standalone control fires wxEVT_SCROLL)', async ({
page,
testLogger,
}) => {
await expect.poll(
async () => (await findRenderedByType(page, 'slider')).length,
{ timeout: 8000 },
).toBeGreaterThanOrEqual(2);
const sliders = await findRenderedByType(page, 'slider');
const tracks = await findRenderedByType(page, 'slidertrack');
// Drag every thumb toward the far end of its track. The standalone
// scrollbars log "[SCROLLBAR_EVENT] scrollbar pos N"; the scrolled-window
// gutters scroll the content (captured in the screenshot).
for (let i = 0; i < sliders.length; i++) {
const s = sliders[i];
const t = tracks.find((tr) => tr.parentId === s.parentId) ?? tracks[i];
if (!t) continue;
const horizontal = t.width > t.height;
const tx = horizontal ? t.screenX + t.width * 0.85 : t.centerX;
const ty = horizontal ? t.centerY : t.screenY + t.height * 0.85;
await page.mouse.move(s.centerX, s.centerY);
await page.mouse.down();
await page.mouse.move(tx, ty, { steps: 6 });
await page.mouse.up();
await page.waitForTimeout(150);
}
await page.screenshot({ path: 'test-results/scrollbar-02-dragged.png', fullPage: true });
// At least one standalone scrollbar must have reported a non-zero position.
await expect.poll(
() => testLogger.consoleLogs.some((l) => /\[SCROLLBAR_EVENT\] scrollbar pos [1-9]/.test(l)),
{ timeout: 8000, message: 'a standalone scrollbar drag should report a non-zero position' },
).toBe(true);
});
});

View file

@ -0,0 +1,141 @@
import type { Page } from '@playwright/test';
import { test, expect } from './fixtures';
import { clickByLabel, clickMenuItem, findRenderedByType } from '../e2e/utils/element-tracker';
/**
* Secondary in-app proof on pcbnew (the user's explicit ask): the right-side
* Appearance/Layers panel is a wxScrolledWindow whose layer list always
* overflows, so its built-in scrollbar gutter is a reliable demonstration of
* the draggable scrollbar; the GAL canvas right-click demonstrates the context
* menu. pcbnew is the largest app — this spec is slow by design.
*
* Screenshots: test-results/pcbnew-sidebar-scrollbar.png, pcbnew-context-menu.png.
*/
async function waitForEditor(page: Page): Promise<void> {
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 120000 });
await page.waitForTimeout(2000);
// Dismiss the first-run setup wizard (pcbnew shows one).
for (let i = 0; i < 12; i++) {
const next = await clickByLabel(page, 'Next >');
if (!next) {
await clickByLabel(page, 'Finish');
break;
}
await page.waitForTimeout(400);
}
await page.waitForTimeout(2000);
}
async function getGlBox(page: Page): Promise<{ x: number; y: number; width: number; height: number }> {
const id = await page.evaluate(() => {
const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
.map((c) => c as HTMLCanvasElement)
.find((c) => {
const rect = c.getBoundingClientRect();
return window.getComputedStyle(c).display !== 'none' && rect.width > 0 && rect.height > 0;
});
return (visible ?? (document.querySelector('[id^="glcanvas-"]') as HTMLCanvasElement | null))?.id ?? null;
});
if (!id) throw new Error('No visible GL canvas found');
const box = await page.locator(`#${id}`).boundingBox();
if (!box) throw new Error('GL canvas bounding box unavailable');
return box;
}
test.describe('pcbnew: DOM-port scrollbar + context menu', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/kicad/pcbnew.html');
});
test('the appearance/layers panel shows a draggable scrollbar gutter', async ({ page }) => {
await waitForEditor(page);
await expect.poll(async () => (await findRenderedByType(page, 'slidertrack')).length, {
timeout: 15000,
message: 'the overflowing layers panel should show a built-in scrollbar gutter',
}).toBeGreaterThan(0);
await page.screenshot({ path: 'test-results/pcbnew-sidebar-scrollbar.png', fullPage: true });
const tracks = await findRenderedByType(page, 'slidertrack');
const sliders = await findRenderedByType(page, 'slider');
const vTrack = tracks.find((t) => t.height > t.width) ?? tracks[0];
const vThumb = sliders.find((s) => s.parentId === vTrack.parentId) ?? sliders[0];
expect(vThumb, 'a vertical gutter thumb should exist').toBeTruthy();
await page.mouse.move(vThumb.centerX, vThumb.centerY);
await page.mouse.down();
await page.mouse.move(vTrack.centerX, vTrack.screenY + vTrack.height * 0.7, { steps: 6 });
await page.mouse.up();
await page.waitForTimeout(400);
await page.screenshot({ path: 'test-results/pcbnew-sidebar-scrollbar-dragged.png', fullPage: true });
});
// Labels of the items in the currently-open DOM context-menu popup.
async function popupLabels(page: Page): Promise<string[]> {
const items = await findRenderedByType(page, 'menuitem', { parentId: 'popupmenu' });
return items.map((i) => i.label);
}
test('right-click on the canvas opens the tool-framework context menu', async ({
page,
testLogger,
}) => {
await waitForEditor(page);
// Right-click the drawing canvas. pcbnew's selection tool builds its menu
// (common/tool/tool_manager.cpp) and calls frame->PopupMenu() from inside
// a suspended tool coroutine — the DOM port renders it as a popup at the
// cursor and routes the chosen command back synchronously.
const box = await getGlBox(page);
const x = Math.round(box.x + box.width * 0.5);
const y = Math.round(box.y + box.height * 0.5);
await page.mouse.click(x, y); // activate the selection tool
await page.waitForTimeout(400);
await page.mouse.click(x, y, { button: 'right' });
// The popup items register in the e2e registry under parentId 'popupmenu'.
await expect.poll(async () => (await popupLabels(page)).length, {
timeout: 15000,
message: 'the canvas right-click should open a DOM context menu',
}).toBeGreaterThan(0);
// It must be the REAL pcbnew context menu: Zoom and Grid submenus are
// always present regardless of selection/clipboard state.
const labels = await popupLabels(page);
expect(labels, `menu items were: ${JSON.stringify(labels)}`).toEqual(
expect.arrayContaining(['Zoom', 'Grid']),
);
// Every shown item is enabled and routable.
const items = await findRenderedByType(page, 'menuitem', { parentId: 'popupmenu' });
expect(items.every((i) => i.enabled)).toBe(true);
await page.screenshot({ path: 'test-results/pcbnew-context-menu.png', fullPage: true });
// The menu is interactive: opening the "Zoom" submenu replaces the popup
// with the submenu's items (so the top-level "Grid" entry disappears).
expect(await clickMenuItem(page, 'Zoom'), 'Zoom submenu should be clickable').toBe(true);
await expect.poll(async () => (await popupLabels(page)).includes('Grid'), {
timeout: 5000,
message: 'clicking "Zoom" should descend into its submenu (top-level items gone)',
}).toBe(false);
const subLabels = await popupLabels(page);
expect(subLabels.length, `submenu was: ${JSON.stringify(subLabels)}`).toBeGreaterThan(0);
await page.screenshot({ path: 'test-results/pcbnew-context-submenu.png', fullPage: true });
// Escape dismisses the popup without firing a command (registry clears).
await page.keyboard.press('Escape');
await expect.poll(async () => (await popupLabels(page)).length, {
timeout: 5000,
message: 'Escape should dismiss the context menu',
}).toBe(0);
expect(
testLogger.errors.filter((e) => !e.includes('favicon') && !e.includes('WebGL')),
'no page errors during the context-menu flow',
).toHaveLength(0);
});
});

View file

@ -0,0 +1,144 @@
import type { Page } from '@playwright/test';
import { test, expect } from './fixtures';
import { clickByLabel, findRenderedByType } from '../e2e/utils/element-tracker';
/**
* In-app proof that the two DOM-port features work inside a real KiCad app.
*
* pl_editor is the smallest KiCad editor (fast to build, seeded config so no
* setup wizard). It exercises BOTH features that were stubbed:
* 1. a right-click CONTEXT MENU on the GAL canvas — KiCad's tool framework
* calls frame->PopupMenu() (common/tool/tool_manager.cpp) from inside a
* suspended tool coroutine; this is the hardest DoPopupMenu path.
* 2. a built-in SCROLLBAR gutter on a wxScrolledWindow (the properties
* panel); a short viewport forces it to overflow so the gutter shows.
*
* Screenshots (test-results/pl_editor-*.png) are the deliverable — always
* open them to confirm the menu sits at the cursor and the thumb is visible.
*/
async function waitForEditor(page: Page): Promise<void> {
await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
await page.waitForTimeout(2000);
// pl_editor's seeded config skips the wizard; the loop is a harmless no-op.
for (let i = 0; i < 10; i++) {
const next = await clickByLabel(page, 'Next >');
if (!next) {
await clickByLabel(page, 'Finish');
break;
}
await page.waitForTimeout(400);
}
await page.waitForTimeout(1500);
}
async function getGlBox(page: Page): Promise<{ x: number; y: number; width: number; height: number }> {
const id = await page.evaluate(() => {
const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
.map((c) => c as HTMLCanvasElement)
.find((c) => {
const rect = c.getBoundingClientRect();
return window.getComputedStyle(c).display !== 'none' && rect.width > 0 && rect.height > 0;
});
return (visible ?? (document.querySelector('[id^="glcanvas-"]') as HTMLCanvasElement | null))?.id ?? null;
});
if (!id) throw new Error('No visible GL canvas found');
const box = await page.locator(`#${id}`).boundingBox();
if (!box) throw new Error('GL canvas bounding box unavailable');
return box;
}
async function popupItems(page: Page) {
return findRenderedByType(page, 'menuitem', { parentId: 'popupmenu' });
}
test.describe('pl_editor: DOM-port context menu + scrollbar', () => {
// A short viewport makes the right-side properties panel overflow so its
// built-in scrollbar gutter appears.
test.use({ viewport: { width: 1280, height: 540 } });
test.beforeEach(async ({ page }) => {
await page.goto('/kicad/pl_editor.html');
});
test('right-click on the canvas opens a context menu', async ({ page, testLogger }) => {
await waitForEditor(page);
const box = await getGlBox(page);
const x = Math.round(box.x + box.width * 0.5);
const y = Math.round(box.y + box.height * 0.5);
// Left-click first so the selection tool is active, then right-click.
await page.mouse.click(x, y);
await page.waitForTimeout(300);
await page.mouse.click(x, y, { button: 'right' });
await expect.poll(async () => (await popupItems(page)).length, {
timeout: 12000,
message: 'the canvas right-click should open a DOM context menu',
}).toBeGreaterThan(0);
await page.screenshot({ path: 'test-results/pl_editor-context-menu.png', fullPage: true });
// Dismiss it; the popup should disappear.
await page.keyboard.press('Escape');
await expect.poll(async () => (await popupItems(page)).length, { timeout: 5000 }).toBe(0);
expect(
testLogger.errors.filter((e) => !e.includes('favicon') && !e.includes('WebGL')),
'no page errors during the context-menu flow',
).toHaveLength(0);
});
// Returns the visible (display != none, real size) DOM scrollbar gutters.
async function visibleGutters(page: Page) {
return page.evaluate(() => {
return Array.from(document.querySelectorAll('[data-wx-scrollbar]'))
.map((el) => {
const r = (el as HTMLElement).getBoundingClientRect();
return {
x: Math.round(r.x), y: Math.round(r.y),
w: Math.round(r.width), h: Math.round(r.height),
shown: getComputedStyle(el as HTMLElement).display !== 'none',
};
})
.filter((g) => g.shown && g.w > 0 && g.h > 0);
});
}
test('the properties panel shows a draggable scrollbar gutter', async ({ page }) => {
await waitForEditor(page);
// The right-hand properties panel's "General Options" tab is a
// wxScrolledWindow full of page-setup fields; in a short window it
// overflows, so its built-in scrollbar gutter renders. Switch to it and
// shrink the window.
await page.setViewportSize({ width: 1180, height: 460 });
await page.waitForTimeout(500);
// Click the "Gener..." tab (top-right of the properties panel).
await page.mouse.click(1140, 80);
await page.waitForTimeout(900);
await expect.poll(async () => (await visibleGutters(page)).length, {
timeout: 12000,
message: 'a built-in scrollbar gutter should render on the overflowing properties panel',
}).toBeGreaterThan(0);
await page.screenshot({ path: 'test-results/pl_editor-scrollbar.png', fullPage: true });
// Drag the vertical gutter's thumb and screenshot the move.
const tracks = await findRenderedByType(page, 'slidertrack');
const sliders = await findRenderedByType(page, 'slider');
const vTrack = tracks.find((t) => t.height > t.width && t.height > 0) ?? null;
const vThumb = vTrack ? sliders.find((s) => s.parentId === vTrack.parentId) : null;
if (vThumb && vTrack) {
await page.mouse.move(vThumb.centerX, vThumb.centerY);
await page.mouse.down();
await page.mouse.move(vTrack.centerX, vTrack.screenY + vTrack.height * 0.7, { steps: 6 });
await page.mouse.up();
await page.waitForTimeout(300);
await page.screenshot({ path: 'test-results/pl_editor-scrollbar-dragged.png', fullPage: true });
}
});
});

@ -1 +1 @@
Subproject commit 8814ddba6c2a2d21e2e1044f87d61ce4b9b17b21 Subproject commit 8f86311c06d8853d0b5a48b90cdb698f394e737a