Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/problem-utils-browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
113 changes: 90 additions & 23 deletions packages/problem-utils-browser/src/browser.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import type { TestCaseResult } from '@exercode/problem-utils';
import {
launch,
Expand All @@ -12,6 +13,7 @@ import {
} from 'puppeteer';

const submitEventsKey = '@exercode/problem-utils-browser/submit-events';
const pendingSubmitChecks = new WeakMap<Page, Promise<void>>();

/** Launches the Chrome headless shell installed for this Puppeteer version. */
export async function launchBrowser(options: LaunchOptions = {}): Promise<Browser> {
Expand Down Expand Up @@ -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<boolean> {
const previous = pendingSubmitChecks.get(page);
const { promise, resolve } = Promise.withResolvers<void>();
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<boolean> {
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<string, WeakMap<EventTarget, Event> | 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<string, WeakMap<EventTarget, Event> | 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<string, (payload: string) => 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<string, unknown>)[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 {
Expand Down
117 changes: 115 additions & 2 deletions test/e2e/browserInteractions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<form><button id="add">Add</button></form>');
await page.setContent('<form action="javascript:void(0)"><button id="add">Add</button></form>');
await page.evaluate(
"document.querySelector('form').addEventListener('submit', (event) => event.preventDefault(), { once: true })"
);
Expand Down Expand Up @@ -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(`<form><button id="add" hidden>Add</button></form><output></output>
<script>
document.querySelector('button').addEventListener('click', (event) => {
document.querySelector('output').textContent += event.isTrusted ? 'clicked' : 'synthetic';
});
document.querySelector('form').addEventListener('submit', (event) => event.preventDefault());
</script>`)}`
);
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(
'<form action="javascript:void(0)"><button id="a">A</button><button id="b">B</button></form><output></output>'
);
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('<output>submitted</output>');
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(`<form action="/done"><button id="add">Add</button></form><script>${handler}</script>`);
});
await new Promise<void>((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<void>((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(`<form><button>Add</button></form><output></output><script>
document.querySelector('button').addEventListener('mousedown', () => {
window.addEventListener('submit', event => {
event.preventDefault();
document.querySelector('output').textContent = 'canceled';
});
}, { once: true });
</script>`)}`
);
expect(await clickAndDetectCanceledSubmit(page, 'button')).toBe(true);
expect(await page.$eval('output', (element) => element.textContent)).toBe('canceled');
}
);
3 changes: 1 addition & 2 deletions test/fixtures/browserUncaughtTimeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
},
],
Expand Down
Loading