From a9e42f3e4c6337a7baaaa3228462ea281faacdc0 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 13 Sep 2026 01:15:00 +0900 Subject: [PATCH 1/7] fix: activate submit controls through native Puppeteer clicks Co-authored-by: WillBooster (Codex CLI) --- packages/problem-utils-browser/README.md | 2 +- packages/problem-utils-browser/src/browser.ts | 31 +++++++++++++------ test/e2e/browserInteractions.test.ts | 25 +++++++++++++++ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/packages/problem-utils-browser/README.md b/packages/problem-utils-browser/README.md index 1dd44f6..229d53b 100644 --- a/packages/problem-utils-browser/README.md +++ b/packages/problem-utils-browser/README.md @@ -5,6 +5,6 @@ Install this package's matching Chrome headless shell with `bun run exercode-bro See the [repository documentation](https://github.com/WillBooster/exercode-problem-utils). -`clickAndDetectCanceledSubmit(page, buttonSelector)` activates a native form control and reports whether the page cancels its submit event. It supports delegated document/window handlers and observes the form associated with the control. The temporary bubbling guard prevents default navigation when reached; it does not replace the page’s submit handler. Use this when an exercise requires the learner to cancel form submission before inspecting the resulting DOM. Create the page with `createBrowserPage` before navigating to learner content (as the presets do): it observes submit events before learner scripts, including window capture handlers that stop immediate propagation. Load this interactive content with `page.goto`: the initial blank document is not instrumented, and `page.setContent`/`document.open` replace document listeners and lose the early observer. +`clickAndDetectCanceledSubmit(page, buttonSelector)` uses a native Puppeteer pointer click to activate a visible form control and reports whether the page cancels its submit event. It supports delegated document/window handlers and observes the form associated with the control. The temporary bubbling guard prevents default navigation when reached; it does not replace the page’s submit handler. Use this when an exercise requires the learner to cancel form submission before inspecting the resulting DOM. Create the page with `createBrowserPage` before navigating to learner content (as the presets do): it observes submit events before learner scripts, including window capture handlers that stop immediate propagation. Load this interactive content with `page.goto`: the initial blank document is not instrumented, and `page.setContent`/`document.open` replace document listeners and lose the early observer. `submitFormAndCaptureRequest(page, selector, timeoutMs)` clicks a form control and captures its main-frame navigation as `{ method, path, params }` without sending the submission to the server. It supports URL-encoded GET/POST fields and returns `undefined` when no navigation occurs before the timeout (2 seconds by default). Use it on a page without another request-interception handler. Its temporary interception is removed after success, timeout, or click failure. diff --git a/packages/problem-utils-browser/src/browser.ts b/packages/problem-utils-browser/src/browser.ts index 277c0ca..7497b8c 100644 --- a/packages/problem-utils-browser/src/browser.ts +++ b/packages/problem-utils-browser/src/browser.ts @@ -133,9 +133,10 @@ export async function createBrowserPage(browser: Browser | BrowserContext): Prom /** Activates a form control and reports whether the submitted event was canceled by the page. */ export async function clickAndDetectCanceledSubmit(page: Page, buttonSelector: string): Promise { await using button = await requirePageElement(page, buttonSelector); - return await button.evaluate((button, key) => { + if (!(await button.isVisible())) return false; + await using observer = await button.evaluateHandle((button, key) => { const form = (button as HTMLButtonElement).form; - if (!form) return false; + if (!form) return; const earlyEvents = (globalThis as unknown as Record | undefined>)[key]; earlyEvents?.delete(form); const submission: { event?: Event; canceled?: boolean } = {}; @@ -150,15 +151,25 @@ export async function clickAndDetectCanceledSubmit(page: Page, buttonSelector: s globalThis.addEventListener('submit', onSubmit, true); // Delegated document/window handlers must run before cancellation is inspected. globalThis.addEventListener('submit', preventNavigation); - try { - (button as HTMLElement).click(); - return submission.canceled ?? (submission.event ?? earlyEvents?.get(form))?.defaultPrevented ?? false; - } finally { - globalThis.removeEventListener('submit', onSubmit, true); - globalThis.removeEventListener('submit', preventNavigation); - earlyEvents?.delete(form); - } + return { + read: () => submission.canceled ?? (submission.event ?? earlyEvents?.get(form))?.defaultPrevented ?? false, + cleanup: () => { + globalThis.removeEventListener('submit', onSubmit, true); + globalThis.removeEventListener('submit', preventNavigation); + earlyEvents?.delete(form); + }, + }; }, submitEventsKey); + try { + await button.click(); + return await page.evaluate((state) => state?.read() ?? false, observer); + } finally { + await page + .evaluate((state) => state?.cleanup(), observer) + .catch(() => { + // A navigation or closed page already discards the document's temporary listeners. + }); + } } export interface CapturedFormRequest { diff --git a/test/e2e/browserInteractions.test.ts b/test/e2e/browserInteractions.test.ts index 0abe519..429783c 100644 --- a/test/e2e/browserInteractions.test.ts +++ b/test/e2e/browserInteractions.test.ts @@ -176,3 +176,28 @@ test( expect(await page.$eval('output', (element) => element.textContent)).toBe('submitted'); } ); + +test('form grading activates only controls reachable by a native pointer click', { timeout: 30_000 }, async () => { + await using browser = await launchBrowser(); + const page = await createBrowserPage(browser); + await page.goto( + `data:text/html,${encodeURIComponent(`
+ `)}` + ); + expect(await clickAndDetectCanceledSubmit(page, '#add')).toBe(false); + expect(await page.$eval('output', (element) => element.textContent)).toBe(''); + await page.$eval('button', (button) => { + button.hidden = false; + button.style.pointerEvents = 'none'; + }); + expect(await clickAndDetectCanceledSubmit(page, '#add')).toBe(false); + expect(await page.$eval('output', (element) => element.textContent)).toBe(''); + await page.$eval('button', (button) => button.style.removeProperty('pointer-events')); + expect(await clickAndDetectCanceledSubmit(page, '#add')).toBe(true); + expect(await page.$eval('output', (element) => element.textContent)).toBe('clicked'); +}); From 64173ed37c7cd38277243f2c5f9fca0dbc84e8ea Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 13 Sep 2026 01:37:51 +0900 Subject: [PATCH 2/7] fix: leave controls without an associated form untouched Co-authored-by: WillBooster (Codex CLI) --- packages/problem-utils-browser/src/browser.ts | 1 + test/e2e/browserInteractions.test.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/problem-utils-browser/src/browser.ts b/packages/problem-utils-browser/src/browser.ts index 7497b8c..c446cc2 100644 --- a/packages/problem-utils-browser/src/browser.ts +++ b/packages/problem-utils-browser/src/browser.ts @@ -161,6 +161,7 @@ export async function clickAndDetectCanceledSubmit(page: Page, buttonSelector: s }; }, submitEventsKey); try { + if (!(await page.evaluate((state) => state !== undefined, observer))) return false; await button.click(); return await page.evaluate((state) => state?.read() ?? false, observer); } finally { diff --git a/test/e2e/browserInteractions.test.ts b/test/e2e/browserInteractions.test.ts index 429783c..6068888 100644 --- a/test/e2e/browserInteractions.test.ts +++ b/test/e2e/browserInteractions.test.ts @@ -200,4 +200,7 @@ test('form grading activates only controls reachable by a native pointer click', await page.$eval('button', (button) => button.style.removeProperty('pointer-events')); expect(await clickAndDetectCanceledSubmit(page, '#add')).toBe(true); expect(await page.$eval('output', (element) => element.textContent)).toBe('clicked'); + await page.$eval('button', (button) => button.ownerDocument.body.append(button)); + expect(await clickAndDetectCanceledSubmit(page, '#add')).toBe(false); + expect(await page.$eval('output', (element) => element.textContent)).toBe('clicked'); }); From a688aef4b6d52bbbb7a1d6883b464343d58295ca Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 13 Sep 2026 01:54:42 +0900 Subject: [PATCH 3/7] fix: preserve submit verdicts across navigation and concurrent checks Co-authored-by: WillBooster (Codex CLI) --- packages/problem-utils-browser/README.md | 2 +- packages/problem-utils-browser/src/browser.ts | 24 +++++++ test/e2e/browserInteractions.test.ts | 70 ++++++++++++++++++- 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/problem-utils-browser/README.md b/packages/problem-utils-browser/README.md index 229d53b..7ae447b 100644 --- a/packages/problem-utils-browser/README.md +++ b/packages/problem-utils-browser/README.md @@ -5,6 +5,6 @@ Install this package's matching Chrome headless shell with `bun run exercode-bro See the [repository documentation](https://github.com/WillBooster/exercode-problem-utils). -`clickAndDetectCanceledSubmit(page, buttonSelector)` uses a native Puppeteer pointer click to activate a visible form control and reports whether the page cancels its submit event. It supports delegated document/window handlers and observes the form associated with the control. The temporary bubbling guard prevents default navigation when reached; it does not replace the page’s submit handler. Use this when an exercise requires the learner to cancel form submission before inspecting the resulting DOM. Create the page with `createBrowserPage` before navigating to learner content (as the presets do): it observes submit events before learner scripts, including window capture handlers that stop immediate propagation. Load this interactive content with `page.goto`: the initial blank document is not instrumented, and `page.setContent`/`document.open` replace document listeners and lose the early observer. +`clickAndDetectCanceledSubmit(page, buttonSelector)` uses a native Puppeteer pointer click to activate a visible form control and reports whether the page cancels its submit event. It supports delegated document/window handlers and observes the form associated with the control. Checks on the same page run sequentially so their navigation guards do not affect each other. Invisible or unclickable controls return `false`. The temporary bubbling guard prevents default navigation when reached; it does not replace the page’s submit handler. If an uncanceled submission navigates before its result can be read, the helper returns `false`. Use this when an exercise requires the learner to cancel form submission before inspecting the resulting DOM. Create the page with `createBrowserPage` before navigating to learner content (as the presets do): it observes submit events before learner scripts, including window capture handlers that stop immediate propagation. Load this interactive content with `page.goto`: the initial blank document is not instrumented, and `page.setContent`/`document.open` replace document listeners and lose the early observer. `submitFormAndCaptureRequest(page, selector, timeoutMs)` clicks a form control and captures its main-frame navigation as `{ method, path, params }` without sending the submission to the server. It supports URL-encoded GET/POST fields and returns `undefined` when no navigation occurs before the timeout (2 seconds by default). Use it on a page without another request-interception handler. Its temporary interception is removed after success, timeout, or click failure. diff --git a/packages/problem-utils-browser/src/browser.ts b/packages/problem-utils-browser/src/browser.ts index c446cc2..b024481 100644 --- a/packages/problem-utils-browser/src/browser.ts +++ b/packages/problem-utils-browser/src/browser.ts @@ -12,6 +12,7 @@ import { } from 'puppeteer'; const submitEventsKey = '@exercode/problem-utils-browser/submit-events'; +const pendingSubmitChecks = new WeakMap>(); /** Launches the Chrome headless shell installed for this Puppeteer version. */ export async function launchBrowser(options: LaunchOptions = {}): Promise { @@ -132,6 +133,19 @@ export async function createBrowserPage(browser: Browser | BrowserContext): Prom /** Activates a form control and reports whether the submitted event was canceled by the page. */ export async function clickAndDetectCanceledSubmit(page: Page, buttonSelector: string): Promise { + const previous = pendingSubmitChecks.get(page); + const { promise, resolve } = Promise.withResolvers(); + pendingSubmitChecks.set(page, promise); + try { + await previous; + return await checkCanceledSubmit(page, buttonSelector); + } finally { + resolve(); + if (pendingSubmitChecks.get(page) === promise) pendingSubmitChecks.delete(page); + } +} + +async function checkCanceledSubmit(page: Page, buttonSelector: string): Promise { await using button = await requirePageElement(page, buttonSelector); if (!(await button.isVisible())) return false; await using observer = await button.evaluateHandle((button, key) => { @@ -164,6 +178,16 @@ export async function clickAndDetectCanceledSubmit(page: Page, buttonSelector: s if (!(await page.evaluate((state) => state !== undefined, observer))) return false; await button.click(); return await page.evaluate((state) => state?.read() ?? false, observer); + } catch (error) { + if ( + error instanceof Error && + (error.message === 'Node is either not clickable or not an Element' || + error.message === 'Execution context was destroyed, most likely because of a navigation.' || + error.message === 'Protocol error (Runtime.callFunctionOn): Could not find object with given id') + ) { + return false; + } + throw error; } finally { await page .evaluate((state) => state?.cleanup(), observer) diff --git a/test/e2e/browserInteractions.test.ts b/test/e2e/browserInteractions.test.ts index 6068888..16f4257 100644 --- a/test/e2e/browserInteractions.test.ts +++ b/test/e2e/browserInteractions.test.ts @@ -197,10 +197,78 @@ test('form grading activates only controls reachable by a native pointer click', }); expect(await clickAndDetectCanceledSubmit(page, '#add')).toBe(false); expect(await page.$eval('output', (element) => element.textContent)).toBe(''); - await page.$eval('button', (button) => button.style.removeProperty('pointer-events')); + await page.$eval('button', (button) => { + button.style.removeProperty('pointer-events'); + button.style.position = 'absolute'; + button.style.left = '-9999px'; + }); + expect(await clickAndDetectCanceledSubmit(page, '#add')).toBe(false); + expect(await page.$eval('output', (element) => element.textContent)).toBe(''); + await page.$eval('button', (button) => button.removeAttribute('style')); expect(await clickAndDetectCanceledSubmit(page, '#add')).toBe(true); expect(await page.$eval('output', (element) => element.textContent)).toBe('clicked'); await page.$eval('button', (button) => button.ownerDocument.body.append(button)); expect(await clickAndDetectCanceledSubmit(page, '#add')).toBe(false); expect(await page.$eval('output', (element) => element.textContent)).toBe('clicked'); }); + +test( + 'concurrent form checks do not attribute the navigation guard to learner cancellation', + { timeout: 30_000 }, + async () => { + await using browser = await launchBrowser(); + const page = await createBrowserPage(browser); + await page.setContent('
'); + await page.evaluate(`document.querySelector('form').addEventListener('submit', event => { + document.querySelector('output').textContent += event.submitter.textContent; + })`); + expect( + await Promise.all([clickAndDetectCanceledSubmit(page, '#a'), clickAndDetectCanceledSubmit(page, '#b')]) + ).toEqual([false, false]); + expect(await page.$eval('output', (element) => element.textContent)).toBe('AB'); + await expect(clickAndDetectCanceledSubmit(page, '#missing')).rejects.toThrow('要素が見つかりません'); + expect(await clickAndDetectCanceledSubmit(page, '#a')).toBe(false); + expect(await page.$eval('output', (element) => element.textContent)).toBe('ABA'); + } +); + +test( + 'uncanceled submissions return a normal verdict when navigation replaces the document', + { timeout: 30_000 }, + async () => { + await using browser = await launchBrowser({ slowMo: 5 }); + const server = createServer((request, response) => { + response.setHeader('Content-Type', 'text/html'); + if (request.url?.startsWith('/done')) { + response.end('submitted'); + return; + } + const handler = + request.url === '/capture' + ? `window.addEventListener('submit', event => event.stopImmediatePropagation(), true)` + : `document.querySelector('form').addEventListener('submit', event => { + event.stopPropagation(); + ${request.url === '/redirect' ? "location.replace('/done')" : ''} + })`; + response.end(`
`); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP address'); + try { + const page = await createBrowserPage(browser); + for (const path of ['/form', '/capture', '/redirect']) { + await page.goto(`http://127.0.0.1:${address.port}${path}`); + const [canceled] = await Promise.all([ + clickAndDetectCanceledSubmit(page, '#add'), + page.waitForNavigation({ waitUntil: 'load' }), + ]); + expect(canceled).toBe(false); + expect(await page.$eval('output', (element) => element.textContent)).toBe('submitted'); + } + await page.close(); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } + } +); From fff005116e650ef3350346c3b48196ed41f7013b Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 13 Sep 2026 02:10:50 +0900 Subject: [PATCH 4/7] docs: clarify native pointer visibility semantics Co-authored-by: WillBooster (Codex CLI) --- packages/problem-utils-browser/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/problem-utils-browser/README.md b/packages/problem-utils-browser/README.md index 7ae447b..d970d63 100644 --- a/packages/problem-utils-browser/README.md +++ b/packages/problem-utils-browser/README.md @@ -5,6 +5,6 @@ Install this package's matching Chrome headless shell with `bun run exercode-bro See the [repository documentation](https://github.com/WillBooster/exercode-problem-utils). -`clickAndDetectCanceledSubmit(page, buttonSelector)` uses a native Puppeteer pointer click to activate a visible form control and reports whether the page cancels its submit event. It supports delegated document/window handlers and observes the form associated with the control. Checks on the same page run sequentially so their navigation guards do not affect each other. Invisible or unclickable controls return `false`. The temporary bubbling guard prevents default navigation when reached; it does not replace the page’s submit handler. If an uncanceled submission navigates before its result can be read, the helper returns `false`. Use this when an exercise requires the learner to cancel form submission before inspecting the resulting DOM. Create the page with `createBrowserPage` before navigating to learner content (as the presets do): it observes submit events before learner scripts, including window capture handlers that stop immediate propagation. Load this interactive content with `page.goto`: the initial blank document is not instrumented, and `page.setContent`/`document.open` replace document listeners and lose the early observer. +`clickAndDetectCanceledSubmit(page, buttonSelector)` uses a native Puppeteer pointer click to activate a form control and reports whether the page cancels its submit event. It supports delegated document/window handlers and observes the form associated with the control. Checks on the same page run sequentially so their navigation guards do not affect each other. Controls that fail Puppeteer's visibility check or native clickability calculation return `false`; transparent controls such as `opacity: 0` remain pointer-reachable and are activated normally. The temporary bubbling guard prevents default navigation when reached; it does not replace the page’s submit handler. If an uncanceled submission navigates before its result can be read, the helper returns `false`. Use this when an exercise requires the learner to cancel form submission before inspecting the resulting DOM. Create the page with `createBrowserPage` before navigating to learner content (as the presets do): it observes submit events before learner scripts, including window capture handlers that stop immediate propagation. Load this interactive content with `page.goto`: the initial blank document is not instrumented, and `page.setContent`/`document.open` replace document listeners and lose the early observer. `submitFormAndCaptureRequest(page, selector, timeoutMs)` clicks a form control and captures its main-frame navigation as `{ method, path, params }` without sending the submission to the server. It supports URL-encoded GET/POST fields and returns `undefined` when no navigation occurs before the timeout (2 seconds by default). Use it on a page without another request-interception handler. Its temporary interception is removed after success, timeout, or click failure. From 3e6324b4802a31fddf537e803635f33ae6b33df7 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 13 Sep 2026 02:43:35 +0900 Subject: [PATCH 5/7] fix: observe final submit cancellation after native pointer handlers Co-authored-by: WillBooster (Codex CLI) --- packages/problem-utils-browser/README.md | 2 +- packages/problem-utils-browser/src/browser.ts | 13 +---- test/e2e/browserInteractions.test.ts | 56 +++++++++++++------ 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/packages/problem-utils-browser/README.md b/packages/problem-utils-browser/README.md index d970d63..fbafb36 100644 --- a/packages/problem-utils-browser/README.md +++ b/packages/problem-utils-browser/README.md @@ -5,6 +5,6 @@ Install this package's matching Chrome headless shell with `bun run exercode-bro See the [repository documentation](https://github.com/WillBooster/exercode-problem-utils). -`clickAndDetectCanceledSubmit(page, buttonSelector)` uses a native Puppeteer pointer click to activate a form control and reports whether the page cancels its submit event. It supports delegated document/window handlers and observes the form associated with the control. Checks on the same page run sequentially so their navigation guards do not affect each other. Controls that fail Puppeteer's visibility check or native clickability calculation return `false`; transparent controls such as `opacity: 0` remain pointer-reachable and are activated normally. The temporary bubbling guard prevents default navigation when reached; it does not replace the page’s submit handler. If an uncanceled submission navigates before its result can be read, the helper returns `false`. Use this when an exercise requires the learner to cancel form submission before inspecting the resulting DOM. Create the page with `createBrowserPage` before navigating to learner content (as the presets do): it observes submit events before learner scripts, including window capture handlers that stop immediate propagation. Load this interactive content with `page.goto`: the initial blank document is not instrumented, and `page.setContent`/`document.open` replace document listeners and lose the early observer. +`clickAndDetectCanceledSubmit(page, buttonSelector)` uses a native Puppeteer pointer click to activate a form control and reports whether the page cancels its submit event. It supports delegated document/window handlers and observes the form associated with the control. Checks on the same page run sequentially so each pointer action has its own cancellation result. Controls that fail Puppeteer's visibility check or native clickability calculation return `false`; transparent controls such as `opacity: 0` remain pointer-reachable and are activated normally. Uncanceled submissions may navigate; the helper returns `false` even if that navigation invalidates the result handle, and graders should stop on that verdict. Use this when an exercise requires the learner to cancel form submission before inspecting the resulting DOM. Create the page with `createBrowserPage` before navigating to learner content (as the presets do): it observes submit events before learner scripts, including window capture handlers that stop immediate propagation. Load this interactive content with `page.goto`: the initial blank document is not instrumented, and `page.setContent`/`document.open` replace document listeners and lose the early observer. `submitFormAndCaptureRequest(page, selector, timeoutMs)` clicks a form control and captures its main-frame navigation as `{ method, path, params }` without sending the submission to the server. It supports URL-encoded GET/POST fields and returns `undefined` when no navigation occurs before the timeout (2 seconds by default). Use it on a page without another request-interception handler. Its temporary interception is removed after success, timeout, or click failure. diff --git a/packages/problem-utils-browser/src/browser.ts b/packages/problem-utils-browser/src/browser.ts index b024481..2455f6e 100644 --- a/packages/problem-utils-browser/src/browser.ts +++ b/packages/problem-utils-browser/src/browser.ts @@ -153,23 +153,15 @@ async function checkCanceledSubmit(page: Page, buttonSelector: string): Promise< if (!form) return; const earlyEvents = (globalThis as unknown as Record | undefined>)[key]; earlyEvents?.delete(form); - const submission: { event?: Event; canceled?: boolean } = {}; + const submission: { event?: Event } = {}; const onSubmit = (event: Event): void => { if (event.target === form) submission.event = event; }; - const preventNavigation = (event: Event): void => { - if (event !== submission.event) return; - submission.canceled = event.defaultPrevented; - event.preventDefault(); - }; globalThis.addEventListener('submit', onSubmit, true); - // Delegated document/window handlers must run before cancellation is inspected. - globalThis.addEventListener('submit', preventNavigation); return { - read: () => submission.canceled ?? (submission.event ?? earlyEvents?.get(form))?.defaultPrevented ?? false, + read: () => (submission.event ?? earlyEvents?.get(form))?.defaultPrevented ?? false, cleanup: () => { globalThis.removeEventListener('submit', onSubmit, true); - globalThis.removeEventListener('submit', preventNavigation); earlyEvents?.delete(form); }, }; @@ -182,6 +174,7 @@ async function checkCanceledSubmit(page: Page, buttonSelector: string): Promise< if ( error instanceof Error && (error.message === 'Node is either not clickable or not an Element' || + error.message === 'Node is detached from document' || error.message === 'Execution context was destroyed, most likely because of a navigation.' || error.message === 'Protocol error (Runtime.callFunctionOn): Could not find object with given id') ) { diff --git a/test/e2e/browserInteractions.test.ts b/test/e2e/browserInteractions.test.ts index 16f4257..4538448 100644 --- a/test/e2e/browserInteractions.test.ts +++ b/test/e2e/browserInteractions.test.ts @@ -81,7 +81,7 @@ test('form observers are removed between canceled and uncanceled submissions', { const browser = await launchBrowser(); try { const page = await browser.newPage(); - await page.setContent('
'); + await page.setContent('
'); await page.evaluate( "document.querySelector('form').addEventListener('submit', (event) => event.preventDefault(), { once: true })" ); @@ -212,25 +212,24 @@ test('form grading activates only controls reachable by a native pointer click', expect(await page.$eval('output', (element) => element.textContent)).toBe('clicked'); }); -test( - 'concurrent form checks do not attribute the navigation guard to learner cancellation', - { timeout: 30_000 }, - async () => { - await using browser = await launchBrowser(); - const page = await createBrowserPage(browser); - await page.setContent('
'); - await page.evaluate(`document.querySelector('form').addEventListener('submit', event => { +test('concurrent form checks preserve independent cancellation results', { timeout: 30_000 }, async () => { + await using browser = await launchBrowser(); + const page = await createBrowserPage(browser); + await page.setContent( + '
' + ); + await page.evaluate(`document.querySelector('form').addEventListener('submit', event => { document.querySelector('output').textContent += event.submitter.textContent; + if (event.submitter.id === 'a') event.preventDefault(); })`); - expect( - await Promise.all([clickAndDetectCanceledSubmit(page, '#a'), clickAndDetectCanceledSubmit(page, '#b')]) - ).toEqual([false, false]); - expect(await page.$eval('output', (element) => element.textContent)).toBe('AB'); - await expect(clickAndDetectCanceledSubmit(page, '#missing')).rejects.toThrow('要素が見つかりません'); - expect(await clickAndDetectCanceledSubmit(page, '#a')).toBe(false); - expect(await page.$eval('output', (element) => element.textContent)).toBe('ABA'); - } -); + expect( + await Promise.all([clickAndDetectCanceledSubmit(page, '#a'), clickAndDetectCanceledSubmit(page, '#b')]) + ).toEqual([true, false]); + expect(await page.$eval('output', (element) => element.textContent)).toBe('AB'); + await expect(clickAndDetectCanceledSubmit(page, '#missing')).rejects.toThrow('要素が見つかりません'); + expect(await clickAndDetectCanceledSubmit(page, '#a')).toBe(true); + expect(await page.$eval('output', (element) => element.textContent)).toBe('ABA'); +}); test( 'uncanceled submissions return a normal verdict when navigation replaces the document', @@ -272,3 +271,24 @@ test( } } ); + +test( + 'submit handlers installed during pointer activation determine the cancellation verdict', + { timeout: 30_000 }, + async () => { + await using browser = await launchBrowser(); + const page = await createBrowserPage(browser); + await page.goto( + `data:text/html,${encodeURIComponent(`
`)}` + ); + expect(await clickAndDetectCanceledSubmit(page, 'button')).toBe(true); + expect(await page.$eval('output', (element) => element.textContent)).toBe('canceled'); + } +); From 377bcee960b11bfd520eeea337ce51b53705c2fc Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 13 Sep 2026 03:07:58 +0900 Subject: [PATCH 6/7] fix: preserve submission cancellation across navigation Co-authored-by: WillBooster (Codex CLI) --- packages/problem-utils-browser/README.md | 2 +- packages/problem-utils-browser/src/browser.ts | 102 ++++++++++++------ test/e2e/browserInteractions.test.ts | 71 ++++++------ 3 files changed, 105 insertions(+), 70 deletions(-) diff --git a/packages/problem-utils-browser/README.md b/packages/problem-utils-browser/README.md index fbafb36..535e2e5 100644 --- a/packages/problem-utils-browser/README.md +++ b/packages/problem-utils-browser/README.md @@ -5,6 +5,6 @@ Install this package's matching Chrome headless shell with `bun run exercode-bro See the [repository documentation](https://github.com/WillBooster/exercode-problem-utils). -`clickAndDetectCanceledSubmit(page, buttonSelector)` uses a native Puppeteer pointer click to activate a form control and reports whether the page cancels its submit event. It supports delegated document/window handlers and observes the form associated with the control. Checks on the same page run sequentially so each pointer action has its own cancellation result. Controls that fail Puppeteer's visibility check or native clickability calculation return `false`; transparent controls such as `opacity: 0` remain pointer-reachable and are activated normally. Uncanceled submissions may navigate; the helper returns `false` even if that navigation invalidates the result handle, and graders should stop on that verdict. Use this when an exercise requires the learner to cancel form submission before inspecting the resulting DOM. Create the page with `createBrowserPage` before navigating to learner content (as the presets do): it observes submit events before learner scripts, including window capture handlers that stop immediate propagation. Load this interactive content with `page.goto`: the initial blank document is not instrumented, and `page.setContent`/`document.open` replace document listeners and lose the early observer. +`clickAndDetectCanceledSubmit(page, buttonSelector)` uses a native Puppeteer pointer click to activate a form control and reports whether the page cancels its submit event. It supports delegated document/window handlers and observes the form associated with the control when the check starts. Checks on the same page run sequentially so each pointer action has its own cancellation result. Controls that fail Puppeteer's visibility check or native clickability calculation return `false`; transparent controls such as `opacity: 0` remain pointer-reachable and are activated normally. Submissions may navigate; a temporary CDP binding preserves the cancellation state before the document unloads, including when a canceled submission explicitly redirects. Uncanceled submissions return `false`, and graders should stop on that verdict. Use this when an exercise requires the learner to cancel form submission before inspecting the resulting DOM. Create the page with `createBrowserPage` before navigating to learner content (as the presets do): it observes submit events before learner scripts, including window capture handlers that stop immediate propagation. Load this interactive content with `page.goto`: the initial blank document is not instrumented, and `page.setContent`/`document.open` replace document listeners and lose the early observer. `submitFormAndCaptureRequest(page, selector, timeoutMs)` clicks a form control and captures its main-frame navigation as `{ method, path, params }` without sending the submission to the server. It supports URL-encoded GET/POST fields and returns `undefined` when no navigation occurs before the timeout (2 seconds by default). Use it on a page without another request-interception handler. Its temporary interception is removed after success, timeout, or click failure. diff --git a/packages/problem-utils-browser/src/browser.ts b/packages/problem-utils-browser/src/browser.ts index 2455f6e..1dcb3cb 100644 --- a/packages/problem-utils-browser/src/browser.ts +++ b/packages/problem-utils-browser/src/browser.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import type { TestCaseResult } from '@exercode/problem-utils'; import { launch, @@ -148,45 +149,82 @@ export async function clickAndDetectCanceledSubmit(page: Page, buttonSelector: s async function checkCanceledSubmit(page: Page, buttonSelector: string): Promise { await using button = await requirePageElement(page, buttonSelector); if (!(await button.isVisible())) return false; - await using observer = await button.evaluateHandle((button, key) => { - const form = (button as HTMLButtonElement).form; - if (!form) return; - const earlyEvents = (globalThis as unknown as Record | undefined>)[key]; - earlyEvents?.delete(form); - const submission: { event?: Event } = {}; - const onSubmit = (event: Event): void => { - if (event.target === form) submission.event = event; - }; - globalThis.addEventListener('submit', onSubmit, true); - return { - read: () => (submission.event ?? earlyEvents?.get(form))?.defaultPrevented ?? false, - cleanup: () => { - globalThis.removeEventListener('submit', onSubmit, true); + const session = await page.createCDPSession(); + const bindingName = `__exercode_submit_${randomUUID().replaceAll('-', '')}`; + let canceledBeforeNavigation = false; + session.on('Runtime.bindingCalled', (event) => { + if (event.name === bindingName) canceledBeforeNavigation = event.payload === 'true'; + }); + try { + await session.send('Runtime.addBinding', { name: bindingName }); + await using observer = await button.evaluateHandle( + (button, { key, bindingName }) => { + const form = (button as HTMLButtonElement).form; + if (!form) return; + const earlyEvents = (globalThis as unknown as Record | undefined>)[key]; earlyEvents?.delete(form); + const submission: { event?: Event } = {}; + const onSubmit = (event: Event): void => { + if (event.target === form) submission.event = event; + }; + const read = (): boolean => (submission.event ?? earlyEvents?.get(form))?.defaultPrevented ?? false; + const beforeUnload = (): void => { + (globalThis as unknown as Record void>)[bindingName]!(read() ? 'true' : 'false'); + }; + globalThis.addEventListener('submit', onSubmit, true); + // Keep the verdict when an explicit redirect replaces the document before the result read. + globalThis.addEventListener('beforeunload', beforeUnload, true); + return { + read, + cleanup: () => { + globalThis.removeEventListener('submit', onSubmit, true); + globalThis.removeEventListener('beforeunload', beforeUnload, true); + earlyEvents?.delete(form); + }, + }; }, - }; - }, submitEventsKey); - try { - if (!(await page.evaluate((state) => state !== undefined, observer))) return false; - await button.click(); - return await page.evaluate((state) => state?.read() ?? false, observer); - } catch (error) { - if ( - error instanceof Error && - (error.message === 'Node is either not clickable or not an Element' || - error.message === 'Node is detached from document' || - error.message === 'Execution context was destroyed, most likely because of a navigation.' || - error.message === 'Protocol error (Runtime.callFunctionOn): Could not find object with given id') - ) { - return false; + { key: submitEventsKey, bindingName } + ); + try { + if (!(await page.evaluate((state) => state !== undefined, observer))) return false; + await button.click(); + return await page.evaluate((state) => state?.read() ?? false, observer); + } catch (error) { + if (error instanceof Error) { + if ( + error.message === 'Node is either not clickable or not an Element' || + error.message === 'Node is detached from document' + ) + return false; + if ( + error.message === 'Execution context was destroyed, most likely because of a navigation.' || + error.message === 'Protocol error (Runtime.callFunctionOn): Could not find object with given id' + ) { + return canceledBeforeNavigation; + } + } + throw error; + } finally { + await page + .evaluate((state) => state?.cleanup(), observer) + .catch(() => { + // Navigation or page closure discards the document's temporary listeners. + }); } - throw error; } finally { + await session.send('Runtime.removeBinding', { name: bindingName }).catch(() => { + // A closed target already discards the binding. + }); await page - .evaluate((state) => state?.cleanup(), observer) + .evaluate((name) => { + delete (globalThis as unknown as Record)[name]; + }, bindingName) .catch(() => { - // A navigation or closed page already discards the document's temporary listeners. + // A closed page has no surviving global binding to remove. }); + await session.detach().catch(() => { + // The target may have closed during navigation. + }); } } diff --git a/test/e2e/browserInteractions.test.ts b/test/e2e/browserInteractions.test.ts index 4538448..41c6716 100644 --- a/test/e2e/browserInteractions.test.ts +++ b/test/e2e/browserInteractions.test.ts @@ -77,7 +77,7 @@ for (const target of ['form', 'document', 'window', 'stopped-form', 'capturing-d }); } -test('form observers are removed between canceled and uncanceled submissions', { timeout: 30_000 }, async () => { +test('form checks distinguish canceled and uncanceled submissions on the same page', { timeout: 30_000 }, async () => { const browser = await launchBrowser(); try { const page = await browser.newPage(); @@ -231,46 +231,43 @@ test('concurrent form checks preserve independent cancellation results', { timeo expect(await page.$eval('output', (element) => element.textContent)).toBe('ABA'); }); -test( - 'uncanceled submissions return a normal verdict when navigation replaces the document', - { timeout: 30_000 }, - async () => { - await using browser = await launchBrowser({ slowMo: 5 }); - const server = createServer((request, response) => { - response.setHeader('Content-Type', 'text/html'); - if (request.url?.startsWith('/done')) { - response.end('submitted'); - return; - } - const handler = - request.url === '/capture' - ? `window.addEventListener('submit', event => event.stopImmediatePropagation(), true)` - : `document.querySelector('form').addEventListener('submit', event => { +test('submission cancellation is preserved when navigation replaces the document', { timeout: 30_000 }, async () => { + await using browser = await launchBrowser({ slowMo: 5 }); + const server = createServer((request, response) => { + response.setHeader('Content-Type', 'text/html'); + if (request.url?.startsWith('/done')) { + response.end('submitted'); + return; + } + const handler = + request.url === '/capture' + ? `window.addEventListener('submit', event => event.stopImmediatePropagation(), true)` + : `document.querySelector('form').addEventListener('submit', event => { event.stopPropagation(); - ${request.url === '/redirect' ? "location.replace('/done')" : ''} + ${request.url === '/canceled-redirect' ? 'event.preventDefault();' : ''} + ${request.url?.includes('redirect') ? "location.replace('/done')" : ''} })`; - response.end(`
`); - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('Expected TCP address'); - try { - const page = await createBrowserPage(browser); - for (const path of ['/form', '/capture', '/redirect']) { - await page.goto(`http://127.0.0.1:${address.port}${path}`); - const [canceled] = await Promise.all([ - clickAndDetectCanceledSubmit(page, '#add'), - page.waitForNavigation({ waitUntil: 'load' }), - ]); - expect(canceled).toBe(false); - expect(await page.$eval('output', (element) => element.textContent)).toBe('submitted'); - } - await page.close(); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + response.end(`
`); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP address'); + try { + const page = await createBrowserPage(browser); + for (const path of ['/form', '/capture', '/redirect', '/canceled-redirect']) { + await page.goto(`http://127.0.0.1:${address.port}${path}`); + const [canceled] = await Promise.all([ + clickAndDetectCanceledSubmit(page, '#add'), + page.waitForNavigation({ waitUntil: 'load' }), + ]); + expect(canceled).toBe(path === '/canceled-redirect'); + expect(await page.$eval('output', (element) => element.textContent)).toBe('submitted'); } + await page.close(); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); } -); +}); test( 'submit handlers installed during pointer activation determine the cancellation verdict', From 936585aacedc4f85626ebad85a2494fbc729ca21 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Sun, 13 Sep 2026 03:24:02 +0900 Subject: [PATCH 7/7] test: limit the diagnostic timeout to the missing selector Co-authored-by: WillBooster (Codex CLI) --- test/fixtures/browserUncaughtTimeout.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/fixtures/browserUncaughtTimeout.ts b/test/fixtures/browserUncaughtTimeout.ts index a0ce64e..802a3ed 100644 --- a/test/fixtures/browserUncaughtTimeout.ts +++ b/test/fixtures/browserUncaughtTimeout.ts @@ -2,12 +2,11 @@ import { DecisionCode } from '@exercode/problem-utils'; import { browserJudgePreset } from '@exercode/problem-utils-browser'; await browserJudgePreset({ - timeoutMs: 100, testCases: [ [ 'missing', async (page) => { - await page.waitForSelector('#missing-submission-element'); + await page.waitForSelector('#missing-submission-element', { timeout: 100 }); return { decisionCode: DecisionCode.ACCEPTED }; }, ],