From 5ba6f15e566cafd5f53a5da47d0e95e4615f8247 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 12 Nov 2026 16:49:59 +0000 Subject: [PATCH 1/3] Forward framed checkbox input to the host page --- .changeset/framed-checkbox-forward-input.md | 5 + .../src/components/framedCheckbox.ts | 145 ++++++++++++++++-- .../src/tests/framedCheckbox.unit.test.ts | 28 +++- 3 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 .changeset/framed-checkbox-forward-input.md diff --git a/.changeset/framed-checkbox-forward-input.md b/.changeset/framed-checkbox-forward-input.md new file mode 100644 index 0000000000..1915a55775 --- /dev/null +++ b/.changeset/framed-checkbox-forward-input.md @@ -0,0 +1,5 @@ +--- +"@prosopo/procaptcha-common": patch +--- + +The framed checkbox forwards its mouse, press and touch input to the host page. diff --git a/packages/procaptcha-common/src/components/framedCheckbox.ts b/packages/procaptcha-common/src/components/framedCheckbox.ts index 9adf570b91..7e93768a4a 100644 --- a/packages/procaptcha-common/src/components/framedCheckbox.ts +++ b/packages/procaptcha-common/src/components/framedCheckbox.ts @@ -34,10 +34,37 @@ const FRAME_HEIGHT_PX = 58; /** Everything the frame needs to draw the checkbox. Functions do not cross. */ export type CheckboxFrameProps = Omit; -export type CheckboxFrameParentMessage = { +export type CheckboxFrameParentMessage = + | { pf: string; t: "props"; props: CheckboxFrameProps } + | { pf: string; t: "offset"; left: number; top: number }; + +/** A touch the frame forwards, already in the page's coordinates. */ +export type ForwardedTouch = { + x: number; + y: number; + id: number; + force?: number; + rx?: number; + ry?: number; + rot?: number; +}; + +/** + * Input inside the frame never reaches the page's own behaviour collection, so + * the frame forwards it in page coordinates. + */ +export type CheckboxFrameInputMessage = { pf: string; - t: "props"; - props: CheckboxFrameProps; + t: "input"; + type: string; + x?: number; + y?: number; + button?: number; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + touches?: ForwardedTouch[]; + changed?: ForwardedTouch[]; }; export type CheckboxFrameMessage = @@ -45,7 +72,8 @@ export type CheckboxFrameMessage = | { pf: string; t: "size"; w: number; h: number } | { pf: string; t: "drawn" } | ({ pf: string; t: "activate" } & Activation) - | { pf: string; t: "pointer"; type: string; pointerType: string }; + | { pf: string; t: "pointer"; type: string; pointerType: string } + | CheckboxFrameInputMessage; const createNonce = (): string => Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte: number) => @@ -126,6 +154,35 @@ export const mountFramedCheckbox = ( teardown.add(() => clearTimeout(timer)); teardown.addEventListener(frame, "error", dropFrame); + // The frame adds its own position on the page to the input it forwards. + const postOffset = (): void => { + const rect = frame.getBoundingClientRect(); + const message: CheckboxFrameParentMessage = { + pf: nonce, + t: "offset", + left: rect.left, + top: rect.top, + }; + frame.contentWindow?.postMessage(message, src.origin); + }; + let offsetQueued = false; + const queueOffset = (): void => { + if (offsetQueued || !ready) { + return; + } + offsetQueued = true; + requestAnimationFrame(() => { + offsetQueued = false; + postOffset(); + }); + }; + const viewportChange: AddEventListenerOptions = { + capture: true, + passive: true, + }; + teardown.addEventListener(window, "scroll", queueOffset, viewportChange); + teardown.addEventListener(window, "resize", queueOffset, viewportChange); + const showFrame = (): void => { shown = true; clearTimeout(timer); @@ -136,6 +193,7 @@ export const mountFramedCheckbox = ( }); standIn?.destroy(); standIn = undefined; + postOffset(); }; const postProps = (): void => { @@ -172,6 +230,7 @@ export const mountFramedCheckbox = ( const data = message.data; if ("ready" === data.t) { ready = true; + postOffset(); postProps(); } else if ("size" === data.t) { frame.style.width = `${data.w}px`; @@ -199,6 +258,22 @@ export const mountFramedCheckbox = ( }; }; +const MOUSE_INPUT = [ + "mousemove", + "mousedown", + "mouseup", + "click", + "dblclick", + "contextmenu", +] as const; + +const TOUCH_INPUT = [ + "touchstart", + "touchmove", + "touchend", + "touchcancel", +] as const; + const POINTER_EVENTS = [ "pointerover", "pointerout", @@ -236,6 +311,7 @@ export const runCheckboxFrame = (): void => { }; let checkbox: Component | undefined; + let offset = { left: 0, top: 0 }; // The frame page lays its body out inline-block, so the body's box is the // checkbox's size rather than the frame's. @@ -247,11 +323,14 @@ export const runCheckboxFrame = (): void => { addEventListener( "message", (event: MessageEvent) => { - if ( - event.source !== window.parent || - event.data?.pf !== nonce || - "props" !== event.data.t - ) { + if (event.source !== window.parent || event.data?.pf !== nonce) { + return; + } + if ("offset" === event.data.t) { + offset = { left: event.data.left, top: event.data.top }; + return; + } + if ("props" !== event.data.t) { return; } // Without a matching color-scheme Chrome paints the frame's canvas @@ -297,5 +376,53 @@ export const runCheckboxFrame = (): void => { ); } + const forwardTouch = (touch: Touch): ForwardedTouch => ({ + x: touch.clientX + offset.left, + y: touch.clientY + offset.top, + id: touch.identifier, + force: touch.force, + rx: touch.radiusX, + ry: touch.radiusY, + rot: touch.rotationAngle, + }); + for (const type of MOUSE_INPUT) { + addEventListener( + type, + (event: MouseEvent) => { + if (event.isTrusted) { + post({ + pf: nonce, + t: "input", + type, + x: event.clientX + offset.left, + y: event.clientY + offset.top, + button: event.button, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + }); + } + }, + { capture: true, passive: true }, + ); + } + for (const type of TOUCH_INPUT) { + addEventListener( + type, + (event: TouchEvent) => { + if (event.isTrusted) { + post({ + pf: nonce, + t: "input", + type, + touches: Array.from(event.touches, forwardTouch), + changed: Array.from(event.changedTouches, forwardTouch), + }); + } + }, + { capture: true, passive: true }, + ); + } + post({ pf: nonce, t: "ready" }); }; diff --git a/packages/procaptcha-common/src/tests/framedCheckbox.unit.test.ts b/packages/procaptcha-common/src/tests/framedCheckbox.unit.test.ts index 40c7b8a1e6..03b1cde467 100644 --- a/packages/procaptcha-common/src/tests/framedCheckbox.unit.test.ts +++ b/packages/procaptcha-common/src/tests/framedCheckbox.unit.test.ts @@ -119,18 +119,38 @@ describe("talking to the frame", () => { const post = vi.spyOn(frame().contentWindow as Window, "postMessage"); fromFrame({ t: "ready" }); - expect(post).toHaveBeenCalledTimes(1); - const [message, origin] = post.mock.calls[0] ?? []; + const propsCalls = post.mock.calls.filter(([m]) => "props" === m.t); + expect(propsCalls).toHaveLength(1); + const [message, origin] = propsCalls[0] ?? []; expect(origin).toBe(FRAME_URL.origin); expect(message).toMatchObject({ pf: nonce(), t: "props" }); expect(message.props).not.toHaveProperty("onChange"); checkbox?.update(props({ checked: true, theme: darkTheme })); - expect(post).toHaveBeenCalledTimes(2); - expect(post.mock.calls[1]?.[0].props.checked).toBe(true); + const afterUpdate = post.mock.calls.filter(([m]) => "props" === m.t); + expect(afterUpdate).toHaveLength(2); + expect(afterUpdate[1]?.[0].props.checked).toBe(true); expect(frame().style.colorScheme).toBe("dark"); }); + test("tells the frame where it sits so it can forward input in page coordinates", () => { + const post = vi.spyOn(frame().contentWindow as Window, "postMessage"); + vi.spyOn(frame(), "getBoundingClientRect").mockReturnValue({ + left: 120, + top: 340, + } as DOMRect); + fromFrame({ t: "ready" }); + + expect(post.mock.calls.map(([message]) => message.t)).toEqual([ + "offset", + "props", + ]); + expect(post.mock.calls[0]?.[0]).toMatchObject({ left: 120, top: 340 }); + + fromFrame({ t: "drawn" }); + expect(post.mock.calls.at(-1)?.[0]).toMatchObject({ t: "offset" }); + }); + test("reports a pointer activation at its position in the page", () => { vi.spyOn(frame(), "getBoundingClientRect").mockReturnValue({ left: 100, From 727e1a39a75ec8ca7ac8674cd40a3969958c8117 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 12 Nov 2026 17:13:56 +0000 Subject: [PATCH 2/3] Send a removed frame's input again so early taps are kept --- .../src/components/framedCheckbox.ts | 53 ++++++++++++++++--- .../src/tests/framedCheckbox.unit.test.ts | 15 ++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/packages/procaptcha-common/src/components/framedCheckbox.ts b/packages/procaptcha-common/src/components/framedCheckbox.ts index 7e93768a4a..860731cfe4 100644 --- a/packages/procaptcha-common/src/components/framedCheckbox.ts +++ b/packages/procaptcha-common/src/components/framedCheckbox.ts @@ -29,6 +29,12 @@ export const CHECKBOX_FRAME_FILE = "checkbox-frame.html"; export const FRAME_READY_TIMEOUT_MS = 3000; const FRAME_WIDTH_PX = 300; + +/** How long a removed frame is kept, hidden, to send its input again. */ +export const FLUSH_MS = 250; + +/** How much forwarded input a frame keeps to send again. */ +const MAX_BUFFERED_INPUT = 300; const FRAME_HEIGHT_PX = 58; /** Everything the frame needs to draw the checkbox. Functions do not cross. */ @@ -36,7 +42,8 @@ export type CheckboxFrameProps = Omit; export type CheckboxFrameParentMessage = | { pf: string; t: "props"; props: CheckboxFrameProps } - | { pf: string; t: "offset"; left: number; top: number }; + | { pf: string; t: "offset"; left: number; top: number } + | { pf: string; t: "flush" }; /** A touch the frame forwards, already in the page's coordinates. */ export type ForwardedTouch = { @@ -56,6 +63,8 @@ export type ForwardedTouch = { export type CheckboxFrameInputMessage = { pf: string; t: "input"; + /** Counts up per frame, so input sent again on a flush is recorded once. */ + seq: number; type: string; x?: number; y?: number; @@ -253,7 +262,16 @@ export const mountFramedCheckbox = ( } }, destroy(): void { - teardown.run(); + // Input forwarded before the page's collector was listening would go + // with the frame, so the frame sends it again before it is removed. + if (!ready || !frame.isConnected) { + teardown.run(); + return; + } + applyStyles(host, { display: "none" }); + const flush: CheckboxFrameParentMessage = { pf: nonce, t: "flush" }; + frame.contentWindow?.postMessage(flush, src.origin); + setTimeout(() => teardown.run(), FLUSH_MS); }, }; }; @@ -312,6 +330,23 @@ export const runCheckboxFrame = (): void => { let checkbox: Component | undefined; let offset = { left: 0, top: 0 }; + const forwarded: CheckboxFrameInputMessage[] = []; + let nextSeq = 0; + const forward = ( + input: Omit, + ): void => { + const message: CheckboxFrameInputMessage = { + pf: nonce, + t: "input", + seq: nextSeq++, + ...input, + }; + forwarded.push(message); + if (forwarded.length > MAX_BUFFERED_INPUT) { + forwarded.shift(); + } + post(message); + }; // The frame page lays its body out inline-block, so the body's box is the // checkbox's size rather than the frame's. @@ -330,6 +365,12 @@ export const runCheckboxFrame = (): void => { offset = { left: event.data.left, top: event.data.top }; return; } + if ("flush" === event.data.t) { + for (const message of forwarded) { + post(message); + } + return; + } if ("props" !== event.data.t) { return; } @@ -390,9 +431,7 @@ export const runCheckboxFrame = (): void => { type, (event: MouseEvent) => { if (event.isTrusted) { - post({ - pf: nonce, - t: "input", + forward({ type, x: event.clientX + offset.left, y: event.clientY + offset.top, @@ -411,9 +450,7 @@ export const runCheckboxFrame = (): void => { type, (event: TouchEvent) => { if (event.isTrusted) { - post({ - pf: nonce, - t: "input", + forward({ type, touches: Array.from(event.touches, forwardTouch), changed: Array.from(event.changedTouches, forwardTouch), diff --git a/packages/procaptcha-common/src/tests/framedCheckbox.unit.test.ts b/packages/procaptcha-common/src/tests/framedCheckbox.unit.test.ts index 03b1cde467..57f9b9f7e6 100644 --- a/packages/procaptcha-common/src/tests/framedCheckbox.unit.test.ts +++ b/packages/procaptcha-common/src/tests/framedCheckbox.unit.test.ts @@ -18,6 +18,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { CheckboxProps } from "../components/checkbox.js"; import { type CheckboxFrameMessage, + FLUSH_MS, FRAME_READY_TIMEOUT_MS, mountFramedCheckbox, } from "../components/framedCheckbox.js"; @@ -234,6 +235,20 @@ describe("talking to the frame", () => { }); describe("tearing down", () => { + test("asks a ready frame to send its input again, then removes it once that has had time", () => { + const post = vi.spyOn(frame().contentWindow as Window, "postMessage"); + fromFrame({ t: "ready" }); + fromFrame({ t: "drawn" }); + checkbox?.destroy(); + checkbox = undefined; + + expect(post.mock.calls.at(-1)?.[0]).toMatchObject({ t: "flush" }); + expect(mounted.container.querySelector("iframe")).not.toBeNull(); + + vi.advanceTimersByTime(FLUSH_MS); + expect(mounted.container.querySelector("iframe")).toBeNull(); + }); + test("removes the frame and stops listening", () => { const contentWindow = frame().contentWindow; checkbox?.destroy(); From 87c023cc9d9b21a9aca91ad14f556835307d354d Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 12 Nov 2026 17:36:27 +0000 Subject: [PATCH 3/3] Keep early framed checkbox taps in the page capture The frame is removed as soon as the widget redraws, so it can never answer a flush. The host now records forwarded input into the page's early capture while it is still recording, which every solve already merges. Co-Authored-By: Claude Opus 5.5 --- .changeset/framed-checkbox-forward-input.md | 2 +- .../src/components/framedCheckbox.ts | 131 ++++++++++-------- .../procaptcha-common/src/pageBehaviour.ts | 22 ++- .../src/tests/framedCheckbox.unit.test.ts | 81 +++++++++-- 4 files changed, 167 insertions(+), 69 deletions(-) diff --git a/.changeset/framed-checkbox-forward-input.md b/.changeset/framed-checkbox-forward-input.md index 1915a55775..c767ea1794 100644 --- a/.changeset/framed-checkbox-forward-input.md +++ b/.changeset/framed-checkbox-forward-input.md @@ -2,4 +2,4 @@ "@prosopo/procaptcha-common": patch --- -The framed checkbox forwards its mouse, press and touch input to the host page. +The framed checkbox forwards its mouse, press and touch input to the host page. Input that arrives before the detector is listening, such as a tap the moment the page loads, is kept in the page's early behaviour capture so it is not lost when the frame is redrawn. diff --git a/packages/procaptcha-common/src/components/framedCheckbox.ts b/packages/procaptcha-common/src/components/framedCheckbox.ts index 860731cfe4..ec40d2662b 100644 --- a/packages/procaptcha-common/src/components/framedCheckbox.ts +++ b/packages/procaptcha-common/src/components/framedCheckbox.ts @@ -16,6 +16,7 @@ import { type Component, Teardown } from "../dom/component.js"; import { applyStyles } from "../dom/element.js"; import type { Activation } from "../events/activation.js"; import { noteInputMethod, watchInputMethod } from "../events/inputModality.js"; +import { addPageBehaviour } from "../pageBehaviour.js"; import { type CheckboxProps, mountCheckbox } from "./checkbox.js"; /** The page the bundle ships beside its chunks to host the checkbox. */ @@ -29,12 +30,6 @@ export const CHECKBOX_FRAME_FILE = "checkbox-frame.html"; export const FRAME_READY_TIMEOUT_MS = 3000; const FRAME_WIDTH_PX = 300; - -/** How long a removed frame is kept, hidden, to send its input again. */ -export const FLUSH_MS = 250; - -/** How much forwarded input a frame keeps to send again. */ -const MAX_BUFFERED_INPUT = 300; const FRAME_HEIGHT_PX = 58; /** Everything the frame needs to draw the checkbox. Functions do not cross. */ @@ -42,8 +37,7 @@ export type CheckboxFrameProps = Omit; export type CheckboxFrameParentMessage = | { pf: string; t: "props"; props: CheckboxFrameProps } - | { pf: string; t: "offset"; left: number; top: number } - | { pf: string; t: "flush" }; + | { pf: string; t: "offset"; left: number; top: number }; /** A touch the frame forwards, already in the page's coordinates. */ export type ForwardedTouch = { @@ -56,25 +50,30 @@ export type ForwardedTouch = { rot?: number; }; +type ForwardedMouseInput = { + type: (typeof MOUSE_INPUT)[number]; + x: number; + y: number; + button: number; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; +}; + +type ForwardedTouchInput = { + type: (typeof TOUCH_INPUT)[number]; + touches: ForwardedTouch[]; + changed: ForwardedTouch[]; +}; + /** * Input inside the frame never reaches the page's own behaviour collection, so * the frame forwards it in page coordinates. */ -export type CheckboxFrameInputMessage = { - pf: string; - t: "input"; - /** Counts up per frame, so input sent again on a flush is recorded once. */ - seq: number; - type: string; - x?: number; - y?: number; - button?: number; - ctrlKey?: boolean; - shiftKey?: boolean; - altKey?: boolean; - touches?: ForwardedTouch[]; - changed?: ForwardedTouch[]; -}; +export type CheckboxFrameInputMessage = { pf: string; t: "input" } & ( + | ForwardedMouseInput + | ForwardedTouchInput +); export type CheckboxFrameMessage = | { pf: string; t: "ready" } @@ -248,6 +247,8 @@ export const mountFramedCheckbox = ( showFrame(); } else if ("activate" === data.t && shown) { activate(data); + } else if ("input" === data.t) { + recordForwardedInput(data); } }); @@ -262,20 +263,53 @@ export const mountFramedCheckbox = ( } }, destroy(): void { - // Input forwarded before the page's collector was listening would go - // with the frame, so the frame sends it again before it is removed. - if (!ready || !frame.isConnected) { - teardown.run(); - return; - } - applyStyles(host, { display: "none" }); - const flush: CheckboxFrameParentMessage = { pf: nonce, t: "flush" }; - frame.contentWindow?.postMessage(flush, src.origin); - setTimeout(() => teardown.run(), FLUSH_MS); + teardown.run(); }, }; }; +/** + * The detector only starts listening for the frame's input once it has loaded, + * and a tap before then would be lost with the frame when the widget next + * redraws, so the page's early capture keeps it. + */ +const recordForwardedInput = (input: CheckboxFrameInputMessage): void => { + const timestamp = Date.now(); + if ("touches" in input) { + const touch = input.touches[0] ?? input.changed[0]; + if (touch) { + addPageBehaviour("touch", { + x: touch.x, + y: touch.y, + timestamp, + eventType: input.type, + touchCount: input.touches.length, + force: touch.force, + touchId: touch.id, + radiusX: touch.rx, + radiusY: touch.ry, + rotationAngle: touch.rot, + }); + } + return; + } + if ("mousemove" === input.type) { + addPageBehaviour("mouse", { x: input.x, y: input.y, timestamp }); + return; + } + addPageBehaviour("press", { + x: input.x, + y: input.y, + timestamp, + eventType: input.type, + button: input.button, + targetElement: "IFRAME", + ctrlKey: input.ctrlKey, + shiftKey: input.shiftKey, + altKey: input.altKey, + }); +}; + const MOUSE_INPUT = [ "mousemove", "mousedown", @@ -330,23 +364,6 @@ export const runCheckboxFrame = (): void => { let checkbox: Component | undefined; let offset = { left: 0, top: 0 }; - const forwarded: CheckboxFrameInputMessage[] = []; - let nextSeq = 0; - const forward = ( - input: Omit, - ): void => { - const message: CheckboxFrameInputMessage = { - pf: nonce, - t: "input", - seq: nextSeq++, - ...input, - }; - forwarded.push(message); - if (forwarded.length > MAX_BUFFERED_INPUT) { - forwarded.shift(); - } - post(message); - }; // The frame page lays its body out inline-block, so the body's box is the // checkbox's size rather than the frame's. @@ -365,12 +382,6 @@ export const runCheckboxFrame = (): void => { offset = { left: event.data.left, top: event.data.top }; return; } - if ("flush" === event.data.t) { - for (const message of forwarded) { - post(message); - } - return; - } if ("props" !== event.data.t) { return; } @@ -431,7 +442,9 @@ export const runCheckboxFrame = (): void => { type, (event: MouseEvent) => { if (event.isTrusted) { - forward({ + post({ + pf: nonce, + t: "input", type, x: event.clientX + offset.left, y: event.clientY + offset.top, @@ -450,7 +463,9 @@ export const runCheckboxFrame = (): void => { type, (event: TouchEvent) => { if (event.isTrusted) { - forward({ + post({ + pf: nonce, + t: "input", type, touches: Array.from(event.touches, forwardTouch), changed: Array.from(event.changedTouches, forwardTouch), diff --git a/packages/procaptcha-common/src/pageBehaviour.ts b/packages/procaptcha-common/src/pageBehaviour.ts index 8291d30097..917697b61f 100644 --- a/packages/procaptcha-common/src/pageBehaviour.ts +++ b/packages/procaptcha-common/src/pageBehaviour.ts @@ -39,6 +39,7 @@ export interface PageBehaviour { interface PageBehaviourCapture { behaviour: PageBehaviour; + recording: boolean; stop: () => void; } @@ -167,9 +168,11 @@ export const startPageBehaviourCapture = (): void => { } window.addEventListener("scroll", onScroll, LISTENER_OPTIONS); - captureHost[CAPTURE_KEY] = { + const capture: PageBehaviourCapture = { behaviour, + recording: true, stop: () => { + capture.recording = false; window.removeEventListener("mousemove", onMouseMove, LISTENER_OPTIONS); for (const type of PRESS_EVENTS) { window.removeEventListener(type, onPress, LISTENER_OPTIONS); @@ -180,6 +183,23 @@ export const startPageBehaviourCapture = (): void => { window.removeEventListener("scroll", onScroll, LISTENER_OPTIONS); }, }; + captureHost[CAPTURE_KEY] = capture; +}; + +/** + * Records input the page's listeners cannot see, such as taps inside the + * framed checkbox, for as long as the capture is still recording. + */ +export const addPageBehaviour = ( + kind: K, + point: PageBehaviour[K][number], +): void => { + const capture = captureHost[CAPTURE_KEY]; + if (!capture?.recording) { + return; + } + const points: PageBehaviour[K][number][] = capture.behaviour[kind]; + pushCapped(points, point, PAGE_BEHAVIOUR_LIMITS[kind]); }; /** diff --git a/packages/procaptcha-common/src/tests/framedCheckbox.unit.test.ts b/packages/procaptcha-common/src/tests/framedCheckbox.unit.test.ts index 57f9b9f7e6..9c110ebcad 100644 --- a/packages/procaptcha-common/src/tests/framedCheckbox.unit.test.ts +++ b/packages/procaptcha-common/src/tests/framedCheckbox.unit.test.ts @@ -18,11 +18,16 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { CheckboxProps } from "../components/checkbox.js"; import { type CheckboxFrameMessage, - FLUSH_MS, FRAME_READY_TIMEOUT_MS, mountFramedCheckbox, } from "../components/framedCheckbox.js"; import type { Component } from "../dom/component.js"; +import { + type PageBehaviour, + getPageBehaviour, + startPageBehaviourCapture, + stopPageBehaviourCapture, +} from "../pageBehaviour.js"; import { type Mounted, mount } from "./domHarness.js"; const FRAME_URL = new URL("https://js.example.test/js/checkbox-frame.html"); @@ -234,21 +239,79 @@ describe("talking to the frame", () => { }); }); -describe("tearing down", () => { - test("asks a ready frame to send its input again, then removes it once that has had time", () => { - const post = vi.spyOn(frame().contentWindow as Window, "postMessage"); +describe("forwarded input", () => { + const captureHost: Record = globalThis as unknown as Record< + symbol, + unknown + >; + + const captured = (): PageBehaviour => { + const behaviour = getPageBehaviour(); + if (!behaviour) throw new Error("capture not started"); + return behaviour; + }; + + const tap = (): void => { + const touch = { x: 40, y: 360, id: 0, force: 1 }; + fromFrame({ + t: "input", + type: "touchstart", + touches: [touch], + changed: [touch], + }); + fromFrame({ t: "input", type: "touchend", touches: [], changed: [touch] }); + fromFrame({ + t: "input", + type: "click", + x: 40, + y: 360, + button: 0, + ctrlKey: false, + shiftKey: false, + altKey: false, + }); + }; + + beforeEach(() => { + startPageBehaviourCapture(); + }); + + afterEach(() => { + stopPageBehaviourCapture(); + delete captureHost[Symbol.for("prosopo.pageBehaviour")]; + }); + + test("keeps a tap made before the detector is listening, after the frame is gone", () => { fromFrame({ t: "ready" }); - fromFrame({ t: "drawn" }); + tap(); checkbox?.destroy(); checkbox = undefined; - expect(post.mock.calls.at(-1)?.[0]).toMatchObject({ t: "flush" }); - expect(mounted.container.querySelector("iframe")).not.toBeNull(); + expect(captured().touch.map((point) => point.eventType)).toEqual([ + "touchstart", + "touchend", + ]); + expect(captured().touch[0]).toMatchObject({ x: 40, y: 360, touchCount: 1 }); + expect(captured().press).toEqual([ + expect.objectContaining({ + eventType: "click", + x: 40, + y: 360, + targetElement: "IFRAME", + }), + ]); + }); - vi.advanceTimersByTime(FLUSH_MS); - expect(mounted.container.querySelector("iframe")).toBeNull(); + test("leaves later input to the detector", () => { + stopPageBehaviourCapture(); + tap(); + + expect(captured().touch).toEqual([]); + expect(captured().press).toEqual([]); }); +}); +describe("tearing down", () => { test("removes the frame and stops listening", () => { const contentWindow = frame().contentWindow; checkbox?.destroy();