diff --git a/packages/problem-utils-browser/README.md b/packages/problem-utils-browser/README.md index 1dd44f6..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)` 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 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 277c0ca..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, @@ -12,6 +13,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,33 +134,98 @@ 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); - return await button.evaluate((button, key) => { - const form = (button as HTMLButtonElement).form; - if (!form) return false; - const earlyEvents = (globalThis as unknown as Record | undefined>)[key]; - earlyEvents?.delete(form); - const submission: { event?: Event; canceled?: boolean } = {}; - 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); + if (!(await button.isVisible())) return false; + 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); + }, + }; + }, + { key: submitEventsKey, bindingName } + ); try { - (button as HTMLElement).click(); - return submission.canceled ?? (submission.event ?? earlyEvents?.get(form))?.defaultPrevented ?? false; + 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 { - globalThis.removeEventListener('submit', onSubmit, true); - globalThis.removeEventListener('submit', preventNavigation); - earlyEvents?.delete(form); + await page + .evaluate((state) => state?.cleanup(), observer) + .catch(() => { + // Navigation or page closure discards the document's temporary listeners. + }); } - }, submitEventsKey); + } finally { + await session.send('Runtime.removeBinding', { name: bindingName }).catch(() => { + // A closed target already discards the binding. + }); + await page + .evaluate((name) => { + delete (globalThis as unknown as Record)[name]; + }, bindingName) + .catch(() => { + // A closed page has no surviving global binding to remove. + }); + await session.detach().catch(() => { + // The target may have closed during navigation. + }); + } } export interface CapturedFormRequest { diff --git a/test/e2e/browserInteractions.test.ts b/test/e2e/browserInteractions.test.ts index 0abe519..41c6716 100644 --- a/test/e2e/browserInteractions.test.ts +++ b/test/e2e/browserInteractions.test.ts @@ -77,11 +77,11 @@ 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(); - await page.setContent('
'); + await page.setContent('
'); await page.evaluate( "document.querySelector('form').addEventListener('submit', (event) => event.preventDefault(), { once: true })" ); @@ -176,3 +176,116 @@ 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'); + 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 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([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('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 === '/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', '/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', + { 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'); + } +); 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 }; }, ],