wip: nested-asyncify fix, wxAuiToolBar registration, tests, research docs
Main-repo side of a multi-part WIP covering the KiCad WASM tool-selection and nested-Asyncify work. Submodule commits are in kicad@f6e9239aaa (libcontext hygiene) and wxwidgets@bb80f91e8b (auibar registration + dialog diagnostics). ## scripts/common/inject-dyncall-shims.sh Wrap Asyncify.handleSleep / allocateData to save-and-restore Asyncify.currData around each EM_ASYNC_JS sleep. This fixes the nested Asyncify collision where a fiber swap that fired during a modal's event loop clobbered currData, and the modal's later doRewind used the fiber's buffer and hit "RuntimeError: index out of bounds". Root cause documented as Emscripten Issue #9153 (wontfix upstream). Diagnostic-rewind logging (forcedBottomOfCallStack, callStack traces) is retained to help future debugging of Asyncify state corruption. ## tests/ - tests/playwright-kicad.config.ts: add `channel: 'chrome'` for the chromium project so --project=chromium --headed uses system Chrome (real GPU) instead of SwiftShader on ARM Mac. Also switch trace to retain-on-failure + screenshot on-failure for easier E2E debugging. - tests/kicad/pcbnew.spec.ts: replace `tool.checked` assertions with a label-suffix check (`[checked]`) since our auibar registration encodes checked state in the label (no schema change to the registry). - tests/apps/Makefile.wasm: add `coroutine-nested` build target + include it in the all: list. - tests/apps/standalone/coroutine/: kicad_coroutine_harness.h + test app reproducing KiCad COROUTINE semantics against real libcontext. - tests/apps/standalone/coroutine-nested/: nested_test.cpp reproduces the EM_ASYNC_JS-modal + fiber-swap nesting bug in isolation. 8 scenarios from baseline_modal_alone through nested_fibers_inside_modal. - tests/e2e/coroutine.spec.ts + coroutine-nested.spec.ts: Playwright specs that load the standalone apps and assert all case cases pass via [COROUTINE_TEST] SUMMARY log parsing. ## research/ and features/browser-tools/ Three background docs capturing the investigation trajectory: - features/browser-tools/0001-kicad-wasm-tool-activation-investigation.md Early investigation: why tools don't activate; initial dynCall-empty- callback hypothesis. - features/browser-tools/0002-wasm-coroutine-deep-dive.md Deep dive on Asyncify internals, fiber API, QEMU's coroutine-wasm reference implementation. - features/browser-tools/0003-wxauitoolbar-registration-fix.md The narrow fix: why wxAuiToolBar needs a registration block, where to add it, what the fallback plan is. - research/threading_1.md: corrected root-cause analysis after reading runtime logs — nested-Asyncify currData collision, Emscripten #9153. - research/threading_2.md: extended research on alternative approaches (JSPI/WasmFX/state-machines) and why they don't help here. ## Submodule pointer updates kicad: f6e9239aaa (wip: libcontext WASM hygiene cleanup) wxwidgets: bb80f91e8b (wip: wxAuiToolBar element-registry registration + dialog diagnostics) ## Open threads not yet in scope - Firefox/Chrome divergent behavior: "indirect call signature mismatch" traps in Firefox vs renderer crash in system Chrome (tracked in plans/peaceful-hugging-pnueli.md and the research docs). - E2E pixel-diff for Draw Lines fails because the test's diff region does not cover where the line is actually drawn; tool activation works, the line is visible in test-results/pcbnew-draw-lines-02-after-drawing.png. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a49ed49d5d
commit
9a04217788
17 changed files with 5190 additions and 46 deletions
105
tests/e2e/coroutine-nested.spec.ts
Normal file
105
tests/e2e/coroutine-nested.spec.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { test, expect, tryLoadApp } from './utils/fixtures';
|
||||
|
||||
const EXPECTED_CASES = [
|
||||
'baseline_modal_alone',
|
||||
'baseline_fiber_alone',
|
||||
'fiber_create_run_destroy_inside_modal',
|
||||
'fiber_multi_swap_inside_modal',
|
||||
'fiber_yield_across_modal_close',
|
||||
'fiber_deep_yield_loop_inside_modal',
|
||||
'modal_fiber_modal_sequence',
|
||||
'nested_fibers_inside_modal',
|
||||
];
|
||||
|
||||
function findSummary(logs: string[]) {
|
||||
return logs.find((log) => log.includes('[COROUTINE_TEST] SUMMARY'));
|
||||
}
|
||||
|
||||
test.describe('Nested Coroutine+Modal Tests', () => {
|
||||
test('nested harness loads and reports its case inventory', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/coroutine-nested/nested_test.html');
|
||||
const loaded = await tryLoadApp(page, 30000);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] CASE ')).length,
|
||||
{ timeout: 45000 }
|
||||
)
|
||||
.toBe(EXPECTED_CASES.length);
|
||||
|
||||
const caseLogs = testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] CASE '));
|
||||
|
||||
for (const caseName of EXPECTED_CASES) {
|
||||
expect(
|
||||
caseLogs.some((log) => log.includes(`[COROUTINE_TEST] CASE ${caseName}`)),
|
||||
`case ${caseName} should appear in logs`
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-results/coroutine-nested-01-loaded.png', fullPage: true });
|
||||
|
||||
expect(loaded, 'Nested harness should load').toBe(true);
|
||||
});
|
||||
|
||||
test('nested suite reports zero failures', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/coroutine-nested/nested_test.html');
|
||||
const loaded = await tryLoadApp(page, 30000);
|
||||
expect(loaded, 'Nested harness should load').toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
|
||||
timeout: 45000,
|
||||
message: 'Nested suite should emit a final summary line',
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
const summary = findSummary(testLogger.consoleLogs)!;
|
||||
const match = summary.match(/total=(\d+)\s+passed=(\d+)\s+failed=(\d+)/);
|
||||
expect(match, 'Nested summary should be parseable').not.toBeNull();
|
||||
|
||||
const total = Number(match![1]);
|
||||
const passed = Number(match![2]);
|
||||
const failed = Number(match![3]);
|
||||
|
||||
const failLogs = testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] FAIL '));
|
||||
const passLogs = testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] PASS '));
|
||||
|
||||
expect(total).toBe(EXPECTED_CASES.length);
|
||||
expect(passed).toBe(EXPECTED_CASES.length);
|
||||
expect(failed).toBe(0);
|
||||
expect(failLogs).toHaveLength(0);
|
||||
expect(passLogs).toHaveLength(EXPECTED_CASES.length);
|
||||
|
||||
// Critical: catch the nested-asyncify crash
|
||||
const indexOobErrors = testLogger.errors.filter((e) =>
|
||||
e.toLowerCase().includes('index out of bounds')
|
||||
);
|
||||
expect(indexOobErrors, 'no index out of bounds errors').toHaveLength(0);
|
||||
|
||||
expect(
|
||||
testLogger.errors.filter((error) => !error.includes('favicon')),
|
||||
'no unexpected page errors'
|
||||
).toHaveLength(0);
|
||||
|
||||
await page.screenshot({ path: 'test-results/coroutine-nested-02-summary.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('per-scenario status (diagnostic)', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/coroutine-nested/nested_test.html');
|
||||
await tryLoadApp(page, 30000);
|
||||
|
||||
await expect
|
||||
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
|
||||
timeout: 45000,
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
// Use soft assertions so we see the full failure map instead of stopping at the first FAIL.
|
||||
for (const name of EXPECTED_CASES) {
|
||||
const passed = testLogger.consoleLogs.some((log) =>
|
||||
log.includes(`[COROUTINE_TEST] PASS ${name}`)
|
||||
);
|
||||
expect.soft(passed, `scenario ${name} should PASS`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
74
tests/e2e/coroutine.spec.ts
Normal file
74
tests/e2e/coroutine.spec.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { test, expect, tryLoadApp } from './utils/fixtures';
|
||||
|
||||
const EXPECTED_CASES = [
|
||||
'first_entry_runs_once',
|
||||
'yield_resume_preserves_state',
|
||||
'deep_stack_preserved_across_yield',
|
||||
'nested_coroutine_call_and_resume',
|
||||
'nested_parent_yield_preserves_suspend',
|
||||
'async_wait_loop_stays_suspended',
|
||||
'async_nested_resume_from_child_tool',
|
||||
'root_bounce_continue_after_root',
|
||||
'completion_returns_control_without_exit',
|
||||
'resume_after_finish_does_not_reenter',
|
||||
'interleaving_multiple_coroutines',
|
||||
'stress_many_round_trips',
|
||||
'transfer_values_round_trip',
|
||||
];
|
||||
|
||||
function findSummary(logs: string[]) {
|
||||
return logs.find((log) => log.includes('[COROUTINE_TEST] SUMMARY'));
|
||||
}
|
||||
|
||||
test.describe('Coroutine Harness Tests', () => {
|
||||
test('coroutine harness loads and reports its case inventory', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/coroutine/coroutine_test.html');
|
||||
const loaded = await tryLoadApp(page, 30000);
|
||||
|
||||
await expect.poll(
|
||||
() => testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] CASE ')).length,
|
||||
{ timeout: 15000 }
|
||||
).toBe(EXPECTED_CASES.length);
|
||||
|
||||
const caseLogs = testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] CASE '));
|
||||
|
||||
for (const caseName of EXPECTED_CASES) {
|
||||
expect(caseLogs.some((log) => log.includes(`[COROUTINE_TEST] CASE ${caseName}`))).toBe(true);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-results/coroutine-01-loaded.png', fullPage: true });
|
||||
|
||||
expect(loaded, 'Coroutine harness should load').toBe(true);
|
||||
});
|
||||
|
||||
test('coroutine stress suite reports zero failures', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/coroutine/coroutine_test.html');
|
||||
const loaded = await tryLoadApp(page, 30000);
|
||||
expect(loaded, 'Coroutine harness should load').toBe(true);
|
||||
|
||||
await expect.poll(
|
||||
() => findSummary(testLogger.consoleLogs) ?? null,
|
||||
{ timeout: 20000, message: 'Coroutine suite should emit a final summary line' }
|
||||
).not.toBeNull();
|
||||
|
||||
const summary = findSummary(testLogger.consoleLogs)!;
|
||||
const match = summary.match(/total=(\d+)\s+passed=(\d+)\s+failed=(\d+)/);
|
||||
expect(match, 'Coroutine summary should be parseable').not.toBeNull();
|
||||
|
||||
const total = Number(match![1]);
|
||||
const passed = Number(match![2]);
|
||||
const failed = Number(match![3]);
|
||||
|
||||
const failLogs = testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] FAIL '));
|
||||
const passLogs = testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] PASS '));
|
||||
|
||||
expect(total).toBe(EXPECTED_CASES.length);
|
||||
expect(passed).toBe(EXPECTED_CASES.length);
|
||||
expect(failed).toBe(0);
|
||||
expect(failLogs).toHaveLength(0);
|
||||
expect(passLogs).toHaveLength(EXPECTED_CASES.length);
|
||||
expect(testLogger.errors.filter((error) => !error.includes('favicon'))).toHaveLength(0);
|
||||
|
||||
await page.screenshot({ path: 'test-results/coroutine-02-summary.png', fullPage: true });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue