@prosopo/procaptcha-puzzle 2.13.3 → 2.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +6 -6
  2. package/.turbo/turbo-build$colon$tsc.log +20 -20
  3. package/.turbo/turbo-build.log +7 -7
  4. package/CHANGELOG.md +66 -0
  5. package/dist/cjs/components/procaptchaPuzzle.cjs +22 -0
  6. package/dist/cjs/components/procaptchaWidget.cjs +225 -0
  7. package/dist/cjs/components/puzzleCanvas.cjs +451 -0
  8. package/dist/cjs/index.cjs +7 -2
  9. package/dist/components/procaptchaPuzzle.d.ts +7 -0
  10. package/dist/components/procaptchaPuzzle.d.ts.map +1 -0
  11. package/dist/components/procaptchaPuzzle.js +21 -0
  12. package/dist/components/procaptchaPuzzle.js.map +1 -0
  13. package/dist/components/procaptchaWidget.d.ts +7 -0
  14. package/dist/components/procaptchaWidget.d.ts.map +1 -0
  15. package/dist/components/procaptchaWidget.js +225 -0
  16. package/dist/components/procaptchaWidget.js.map +1 -0
  17. package/dist/components/{PuzzleCanvas.d.ts → puzzleCanvas.d.ts} +6 -4
  18. package/dist/components/puzzleCanvas.d.ts.map +1 -0
  19. package/dist/components/puzzleCanvas.js +451 -0
  20. package/dist/components/puzzleCanvas.js.map +1 -0
  21. package/dist/index.d.ts +3 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/tests/domHarness.d.ts +20 -0
  26. package/dist/tests/domHarness.d.ts.map +1 -0
  27. package/dist/tests/domHarness.js +56 -0
  28. package/dist/tests/domHarness.js.map +1 -0
  29. package/dist/tests/invisibleCanvasVisibility.unit.test.d.ts +2 -0
  30. package/dist/tests/invisibleCanvasVisibility.unit.test.d.ts.map +1 -0
  31. package/dist/tests/invisibleCanvasVisibility.unit.test.js +102 -0
  32. package/dist/tests/invisibleCanvasVisibility.unit.test.js.map +1 -0
  33. package/dist/tests/procaptchaPuzzle.test-d.js +13 -10
  34. package/dist/tests/procaptchaPuzzle.test-d.js.map +1 -1
  35. package/dist/tests/procaptchaPuzzle.unit.test.js +44 -33
  36. package/dist/tests/procaptchaPuzzle.unit.test.js.map +1 -1
  37. package/dist/tests/procaptchaWidget.unit.test.js +108 -175
  38. package/dist/tests/procaptchaWidget.unit.test.js.map +1 -1
  39. package/dist/tests/puzzleCanvas.unit.test.js +56 -82
  40. package/dist/tests/puzzleCanvas.unit.test.js.map +1 -1
  41. package/package.json +10 -14
  42. package/src/components/procaptchaPuzzle.ts +54 -0
  43. package/src/components/procaptchaWidget.ts +392 -0
  44. package/src/components/puzzleCanvas.ts +658 -0
  45. package/src/index.ts +3 -2
  46. package/src/tests/domHarness.ts +122 -0
  47. package/src/tests/invisibleCanvasVisibility.unit.test.ts +179 -0
  48. package/src/tests/procaptchaPuzzle.test-d.ts +33 -17
  49. package/src/tests/procaptchaPuzzle.unit.test.ts +59 -44
  50. package/src/tests/procaptchaWidget.unit.test.ts +145 -258
  51. package/src/tests/puzzleCanvas.unit.test.ts +94 -122
  52. package/tsconfig.cjs.json +1 -2
  53. package/tsconfig.json +1 -2
  54. package/tsconfig.tsbuildinfo +1 -1
  55. package/vite.test.config.ts +3 -4
  56. package/dist/cjs/components/ProcaptchaPuzzle.cjs +0 -7
  57. package/dist/cjs/components/ProcaptchaWidget.cjs +0 -206
  58. package/dist/cjs/components/PuzzleCanvas.cjs +0 -476
  59. package/dist/components/ProcaptchaPuzzle.d.ts +0 -3
  60. package/dist/components/ProcaptchaPuzzle.d.ts.map +0 -1
  61. package/dist/components/ProcaptchaPuzzle.js +0 -7
  62. package/dist/components/ProcaptchaPuzzle.js.map +0 -1
  63. package/dist/components/ProcaptchaWidget.d.ts +0 -4
  64. package/dist/components/ProcaptchaWidget.d.ts.map +0 -1
  65. package/dist/components/ProcaptchaWidget.js +0 -207
  66. package/dist/components/ProcaptchaWidget.js.map +0 -1
  67. package/dist/components/PuzzleCanvas.d.ts.map +0 -1
  68. package/dist/components/PuzzleCanvas.js +0 -476
  69. package/dist/components/PuzzleCanvas.js.map +0 -1
  70. package/dist/tests/setup.d.ts +0 -5
  71. package/dist/tests/setup.d.ts.map +0 -1
  72. package/dist/tests/setup.js +0 -3
  73. package/dist/tests/setup.js.map +0 -1
  74. package/src/components/ProcaptchaPuzzle.tsx +0 -36
  75. package/src/components/ProcaptchaWidget.tsx +0 -320
  76. package/src/components/PuzzleCanvas.tsx +0 -638
  77. package/src/tests/setup.ts +0 -26
@@ -0,0 +1,122 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ /**
16
+ * A minimal mount/unmount harness. The suite deliberately drives real DOM
17
+ * events rather than calling handlers directly, because every interactive
18
+ * element in this package drops untrusted events — behaviour that only shows
19
+ * up when a real event carries the flag.
20
+ */
21
+ export interface Mounted {
22
+ container: HTMLDivElement;
23
+ unmount: () => void;
24
+ }
25
+
26
+ export const mount = (): Mounted => {
27
+ const container = document.createElement("div");
28
+ document.body.appendChild(container);
29
+ return {
30
+ container,
31
+ unmount: () => {
32
+ container.remove();
33
+ },
34
+ };
35
+ };
36
+
37
+ interface FireOptions {
38
+ trusted?: boolean;
39
+ clientX?: number;
40
+ clientY?: number;
41
+ touches?: { clientX: number; clientY: number }[];
42
+ key?: string;
43
+ }
44
+
45
+ /**
46
+ * jsdom marks everything it dispatches untrusted, and exposes `isTrusted` as a
47
+ * non-configurable accessor onto its internal implementation object, so the
48
+ * flag has to be set there rather than shadowed on the event. Anything the
49
+ * components filter on untrusted events is untestable otherwise.
50
+ */
51
+ const setTrusted = (event: Event, trusted: boolean): void => {
52
+ for (const symbol of Object.getOwnPropertySymbols(event)) {
53
+ const impl: unknown = Reflect.get(event, symbol);
54
+ if (impl && typeof impl === "object" && "isTrusted" in impl) {
55
+ // A plain assignment would not survive: dispatchEvent stamps
56
+ // isTrusted back to false on the way in, so the flag is pinned with
57
+ // an accessor that swallows the write.
58
+ Object.defineProperty(impl, "isTrusted", {
59
+ configurable: true,
60
+ get: () => trusted,
61
+ set: () => undefined,
62
+ });
63
+ return;
64
+ }
65
+ }
66
+ throw new Error("could not reach the jsdom event implementation");
67
+ };
68
+
69
+ const build = (type: string, options: FireOptions): Event => {
70
+ if (undefined !== options.key) {
71
+ return new KeyboardEvent(type, {
72
+ bubbles: true,
73
+ cancelable: true,
74
+ key: options.key,
75
+ });
76
+ }
77
+ return new MouseEvent(type, {
78
+ bubbles: true,
79
+ cancelable: true,
80
+ clientX: options.clientX ?? 0,
81
+ clientY: options.clientY ?? 0,
82
+ });
83
+ };
84
+
85
+ /**
86
+ * Dispatches a real event at the element. Touch points are plain objects:
87
+ * jsdom has no Touch constructor.
88
+ */
89
+ export const fire = (
90
+ element: Element,
91
+ type: string,
92
+ options: FireOptions = {},
93
+ ): void => {
94
+ fireAndReturn(element, type, options);
95
+ };
96
+
97
+ /** Like `fire`, but hands back the event so a test can inspect what the
98
+ * handler did to it — whether it prevented the default form submission, say. */
99
+ export const fireAndReturn = (
100
+ element: Element,
101
+ type: string,
102
+ options: FireOptions = {},
103
+ ): Event => {
104
+ const event = build(type, options);
105
+ setTrusted(event, options.trusted ?? true);
106
+ if (options.touches) {
107
+ Object.defineProperty(event, "touches", { value: options.touches });
108
+ }
109
+ element.dispatchEvent(event);
110
+ return event;
111
+ };
112
+
113
+ /**
114
+ * Renders coalesce onto the microtask queue, so a state change is not visible
115
+ * in the DOM until it has drained.
116
+ */
117
+ export const settle = async (): Promise<void> => {
118
+ // A macrotask turn, so a pending dynamic import resolves too — draining the
119
+ // microtask queue alone is not enough for the code-split boundaries.
120
+ await new Promise<void>((resolve: () => void) => setTimeout(resolve, 0));
121
+ await Promise.resolve();
122
+ };
@@ -0,0 +1,179 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import type { Ti18n } from "@prosopo/locale";
16
+ import {
17
+ type GetPuzzleCaptchaResponse,
18
+ ModeEnum,
19
+ type ProcaptchaProps,
20
+ type ProcaptchaState,
21
+ type PuzzleEvent,
22
+ } from "@prosopo/types";
23
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
24
+ import {
25
+ type ProcaptchaPuzzleHandle,
26
+ mountProcaptchaPuzzleWidget,
27
+ } from "../components/procaptchaWidget.js";
28
+ import { type Mounted, fire, mount, settle } from "./domHarness.js";
29
+ import { challengeResponse, config, frictionless } from "./managerHarness.js";
30
+
31
+ // The canvas is deliberately NOT mocked here: the point of this suite is
32
+ // whether the overlay the user has to drag actually becomes visible. The
33
+ // widget suite stubs the canvas, so it asserts the challenge was handed over,
34
+ // not that anything reached the screen.
35
+ const mocks = vi.hoisted(() => {
36
+ const start =
37
+ vi.fn<
38
+ (x?: number, y?: number) => Promise<GetPuzzleCaptchaResponse | undefined>
39
+ >();
40
+ const submitSolution =
41
+ vi.fn<(x: number, y: number, events: PuzzleEvent[]) => Promise<boolean>>();
42
+ const resetState = vi.fn<() => void>();
43
+ return { start, submitSolution, resetState };
44
+ });
45
+
46
+ vi.mock("../services/Manager.js", () => ({
47
+ Manager: (
48
+ _config: unknown,
49
+ _state: ProcaptchaState,
50
+ _updateState: (next: Partial<ProcaptchaState>) => void,
51
+ ) => ({
52
+ start: mocks.start,
53
+ submitSolution: mocks.submitSolution,
54
+ resetState: mocks.resetState,
55
+ }),
56
+ }));
57
+
58
+ vi.mock("@prosopo/locale", async (importOriginal) => ({
59
+ ...(await importOriginal<typeof import("@prosopo/locale")>()),
60
+ loadI18next: vi.fn(async () => undefined),
61
+ }));
62
+
63
+ const i18nStub = {
64
+ isInitialized: true,
65
+ language: "en",
66
+ t: (key: string) => key,
67
+ changeLanguage: vi.fn(),
68
+ hasLoadedNamespace: () => true,
69
+ on: () => undefined,
70
+ off: () => undefined,
71
+ } as unknown as Ti18n;
72
+
73
+ const props = (overrides: Partial<ProcaptchaProps> = {}): ProcaptchaProps =>
74
+ ({
75
+ config: config({ mode: ModeEnum.visible }),
76
+ callbacks: {},
77
+ i18n: i18nStub,
78
+ frictionlessState: frictionless(),
79
+ ...overrides,
80
+ }) as unknown as ProcaptchaProps;
81
+
82
+ let mounted: Mounted;
83
+ let handle: ProcaptchaPuzzleHandle | undefined;
84
+
85
+ // Class names are hashed by the CSS-module build, so the surface is addressed
86
+ // through the `data-cy` hooks the components ship for exactly this reason.
87
+ const surface = (): HTMLElement | null =>
88
+ document.querySelector<HTMLElement>('[data-cy="challenge-surface"]');
89
+
90
+ // The panel carrying the entrance animation is the only element under the
91
+ // surface with an inline opacity of its own.
92
+ const panelOpacity = (): string | undefined => {
93
+ const content = document.querySelector('[data-cy="challenge-content"]');
94
+ if (!content) return undefined;
95
+ return Array.from(
96
+ content.querySelectorAll<HTMLElement>("[style*='opacity']"),
97
+ ).at(-1)?.style.opacity;
98
+ };
99
+
100
+ // The entrance animation flips opacity inside a requestAnimationFrame, so a
101
+ // test has to let a frame run before reading it.
102
+ const frame = async (): Promise<void> => {
103
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
104
+ await settle();
105
+ };
106
+
107
+ beforeEach(() => {
108
+ mocks.start.mockResolvedValue(challengeResponse());
109
+ mounted = mount();
110
+ });
111
+
112
+ afterEach(() => {
113
+ handle?.destroy();
114
+ handle = undefined;
115
+ mounted.unmount();
116
+ for (const layer of Array.from(
117
+ document.querySelectorAll(".prosopo-challenge-surface"),
118
+ )) {
119
+ layer.remove();
120
+ }
121
+ vi.clearAllMocks();
122
+ });
123
+
124
+ describe("the puzzle overlay actually reaches the screen", () => {
125
+ test("visible mode: the panel is opaque after the entrance frame", async () => {
126
+ handle = mountProcaptchaPuzzleWidget(mounted.container, props());
127
+ const checkbox = mounted.container.querySelector("input");
128
+ expect(checkbox, "the visible widget renders a checkbox").not.toBeNull();
129
+ if (checkbox) fire(checkbox, "click");
130
+ await settle();
131
+ await frame();
132
+
133
+ expect(mocks.start).toHaveBeenCalledTimes(1);
134
+ expect(surface(), "the overlay is mounted").not.toBeNull();
135
+ expect(panelOpacity()).toBe("1");
136
+ });
137
+
138
+ // Invisible mode has no checkbox, so the overlay is opened by the host
139
+ // page's execute(). It still has to reach the screen: the piece is dragged
140
+ // by hand, and a panel left at opacity 0 is a challenge nobody can solve.
141
+ test("invisible mode: the panel is opaque after the entrance frame", async () => {
142
+ handle = mountProcaptchaPuzzleWidget(
143
+ mounted.container,
144
+ props({ config: config({ mode: ModeEnum.invisible }) }),
145
+ );
146
+ document.dispatchEvent(new Event("procaptcha:execute"));
147
+ await settle();
148
+ await frame();
149
+
150
+ expect(mocks.start).toHaveBeenCalledTimes(1);
151
+ expect(surface(), "the overlay is mounted").not.toBeNull();
152
+ expect(panelOpacity()).toBe("1");
153
+ });
154
+
155
+ // The entrance frame is cancelled on teardown, so a canvas destroyed and
156
+ // remounted before it runs would stay transparent for good.
157
+ test("invisible mode: a remounted overlay still becomes opaque", async () => {
158
+ handle = mountProcaptchaPuzzleWidget(
159
+ mounted.container,
160
+ props({ config: config({ mode: ModeEnum.invisible }) }),
161
+ );
162
+ document.dispatchEvent(new Event("procaptcha:execute"));
163
+ await settle();
164
+ await frame();
165
+ expect(panelOpacity()).toBe("1");
166
+
167
+ handle.destroy();
168
+ handle = mountProcaptchaPuzzleWidget(
169
+ mounted.container,
170
+ props({ config: config({ mode: ModeEnum.invisible }) }),
171
+ );
172
+ document.dispatchEvent(new Event("procaptcha:execute"));
173
+ await settle();
174
+ await frame();
175
+
176
+ expect(surface(), "the overlay is mounted again").not.toBeNull();
177
+ expect(panelOpacity()).toBe("1");
178
+ });
179
+ });
@@ -12,7 +12,7 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
 
15
- import type { Ti18n } from "@prosopo/locale";
15
+ import type { Ti18n, Translator } from "@prosopo/locale";
16
16
  import type {
17
17
  FrictionlessState,
18
18
  GetPuzzleCaptchaResponse,
@@ -24,11 +24,13 @@ import type {
24
24
  PuzzleEvent,
25
25
  } from "@prosopo/types";
26
26
  import { lightTheme } from "@prosopo/widget-skeleton";
27
- import type { ReactElement } from "react";
28
27
  import { assertType, describe, expectTypeOf, test } from "vitest";
29
- import { PuzzleCanvas } from "../components/PuzzleCanvas.js";
28
+ import {
29
+ type PuzzleCanvasProps,
30
+ mountPuzzleCanvas,
31
+ } from "../components/puzzleCanvas.js";
30
32
  import type * as entrypoint from "../index.js";
31
- import { ProcaptchaPuzzle } from "../index.js";
33
+ import { mountProcaptchaPuzzle } from "../index.js";
32
34
  import { Manager } from "../services/Manager.js";
33
35
  import {
34
36
  challengeResponse,
@@ -42,17 +44,25 @@ import {
42
44
  const i18n = (): Ti18n => undefined as unknown as Ti18n;
43
45
 
44
46
  describe("the package entrypoint's types", () => {
45
- test("ProcaptchaPuzzle takes the shared widget props and renders an element", () => {
46
- expectTypeOf(ProcaptchaPuzzle).parameters.toEqualTypeOf<
47
- [ProcaptchaProps]
47
+ test("mountProcaptchaPuzzle takes a host element and the shared widget props", () => {
48
+ expectTypeOf(mountProcaptchaPuzzle).parameters.toEqualTypeOf<
49
+ [HTMLElement, ProcaptchaProps]
48
50
  >();
49
- expectTypeOf(ProcaptchaPuzzle).returns.toExtend<ReactElement>();
51
+ expectTypeOf(mountProcaptchaPuzzle).returns.toExtend<{
52
+ destroy: () => void;
53
+ }>();
50
54
  });
51
55
 
52
- test("the inner widget's default export is not re-exported", () => {
53
- // `export *` skips default exports, so consumers can only reach the lazy
54
- // wrapper — the one that works without a code-splitting bundler.
55
- expectTypeOf<keyof typeof entrypoint>().toEqualTypeOf<"ProcaptchaPuzzle">();
56
+ test("the entrypoint exposes the lazy wrapper and the widget itself", () => {
57
+ // The lazy wrapper is what works without a code-splitting bundler; the
58
+ // direct mount is what ProcaptchaFrictionless imports once it has already
59
+ // paid for the dynamic import of this package.
60
+ expectTypeOf<keyof typeof entrypoint>().toEqualTypeOf<
61
+ | "mountProcaptchaPuzzle"
62
+ | "loadProcaptchaPuzzle"
63
+ | "mountProcaptchaPuzzleWidget"
64
+ | "mountPuzzleCanvas"
65
+ >();
56
66
  });
57
67
 
58
68
  test("config, callbacks and i18n are all required", () => {
@@ -140,6 +150,7 @@ describe("Manager's types", () => {
140
150
  });
141
151
 
142
152
  describe("PuzzleCanvas' types", () => {
153
+ const translator = (): Translator => undefined as unknown as Translator;
143
154
  const onComplete = (
144
155
  _finalX: number,
145
156
  _finalY: number,
@@ -148,9 +159,9 @@ describe("PuzzleCanvas' types", () => {
148
159
 
149
160
  test("every prop is required, since none has a sensible default", () => {
150
161
  // @ts-expect-error - a board with no imagery cannot be rendered.
151
- PuzzleCanvas({ originX: 0, originY: 0 });
162
+ mountPuzzleCanvas({ originX: 0, originY: 0 });
152
163
  // @ts-expect-error - `submitting` gates the drag; omitting it unlocks it.
153
- PuzzleCanvas({
164
+ mountPuzzleCanvas({
154
165
  originX: 0,
155
166
  originY: 0,
156
167
  background: "data:image/webp;base64,UklGRg==",
@@ -159,12 +170,13 @@ describe("PuzzleCanvas' types", () => {
159
170
  onComplete,
160
171
  showRetry: false,
161
172
  theme: lightTheme,
173
+ translator: translator(),
162
174
  });
163
175
  });
164
176
 
165
- test("the full prop set renders an element", () => {
177
+ test("the full prop set mounts a component that can be updated and torn down", () => {
166
178
  expectTypeOf(
167
- PuzzleCanvas({
179
+ mountPuzzleCanvas({
168
180
  originX: 0,
169
181
  originY: 0,
170
182
  background: "data:image/webp;base64,UklGRg==",
@@ -174,8 +186,12 @@ describe("PuzzleCanvas' types", () => {
174
186
  showRetry: false,
175
187
  submitting: false,
176
188
  theme: lightTheme,
189
+ translator: translator(),
177
190
  }),
178
- ).toExtend<ReactElement>();
191
+ ).toExtend<{
192
+ update: (props: PuzzleCanvasProps) => void;
193
+ destroy: () => void;
194
+ }>();
179
195
  });
180
196
 
181
197
  test("the drop is reported synchronously, not as a promise", () => {
@@ -14,34 +14,43 @@
14
14
 
15
15
  import type { Ti18n } from "@prosopo/locale";
16
16
  import type { ProcaptchaProps } from "@prosopo/types";
17
- import { type ReactElement, act, createElement } from "react";
18
- import { type Root, createRoot } from "react-dom/client";
19
17
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
18
+ import type { ProcaptchaPuzzleHandle } from "../components/procaptchaWidget.js";
20
19
  import * as entrypoint from "../index.js";
20
+ import { type Mounted, mount, settle } from "./domHarness.js";
21
21
  import { config } from "./managerHarness.js";
22
22
 
23
23
  /**
24
- * The outer component exists only to lazy-load the widget and hand it the props
25
- * it was given, so what is worth pinning down is that nothing is dropped on the
26
- * way through — a wrapper that names props one at a time silently loses the
27
- * ones added later.
24
+ * The outer entry point exists only to lazy-load the widget and hand it the
25
+ * props it was given, so what is worth pinning down is that nothing is dropped
26
+ * on the way through — a wrapper that names props one at a time silently loses
27
+ * the ones added later.
28
28
  */
29
29
  const mocks = vi.hoisted(() => ({
30
30
  received: [] as ProcaptchaProps[],
31
+ destroyed: { count: 0 },
31
32
  }));
32
33
 
33
- vi.mock("../components/ProcaptchaWidget.js", async () => {
34
- const { createElement: create } = await import("react");
35
- return {
36
- default: (props: ProcaptchaProps) => {
37
- mocks.received.push(props);
38
- return create("div", { "data-testid": "widget" });
39
- },
40
- };
41
- });
34
+ vi.mock("../components/procaptchaWidget.js", () => ({
35
+ mountProcaptchaPuzzleWidget: (
36
+ container: HTMLElement,
37
+ props: ProcaptchaProps,
38
+ ): ProcaptchaPuzzleHandle => {
39
+ mocks.received.push(props);
40
+ const element = document.createElement("div");
41
+ element.setAttribute("data-testid", "widget");
42
+ container.appendChild(element);
43
+ return {
44
+ destroy: () => {
45
+ element.remove();
46
+ mocks.destroyed.count += 1;
47
+ },
48
+ };
49
+ },
50
+ }));
42
51
 
43
- let container: HTMLDivElement;
44
- let root: Root;
52
+ let mounted: Mounted;
53
+ let widget: ProcaptchaPuzzleHandle | undefined;
45
54
 
46
55
  const props = (overrides: Partial<ProcaptchaProps> = {}): ProcaptchaProps => ({
47
56
  config: config(),
@@ -51,33 +60,29 @@ const props = (overrides: Partial<ProcaptchaProps> = {}): ProcaptchaProps => ({
51
60
  });
52
61
 
53
62
  const render = async (widgetProps: ProcaptchaProps): Promise<void> => {
54
- await act(async () => {
55
- root.render(
56
- createElement(entrypoint.ProcaptchaPuzzle, widgetProps) as ReactElement,
57
- );
58
- });
63
+ widget = entrypoint.mountProcaptchaPuzzle(mounted.container, widgetProps);
64
+ await settle();
59
65
  };
60
66
 
61
67
  beforeEach(() => {
62
68
  mocks.received.length = 0;
63
- container = document.createElement("div");
64
- document.body.appendChild(container);
65
- act(() => {
66
- root = createRoot(container);
67
- });
69
+ mocks.destroyed.count = 0;
70
+ widget = undefined;
71
+ mounted = mount();
68
72
  });
69
73
 
70
74
  afterEach(() => {
71
- act(() => {
72
- root.unmount();
73
- });
74
- container.remove();
75
+ widget?.destroy();
76
+ widget = undefined;
77
+ mounted.unmount();
75
78
  });
76
79
 
77
- describe("ProcaptchaPuzzle", () => {
80
+ describe("mountProcaptchaPuzzle", () => {
78
81
  test("renders the widget once it has loaded", async () => {
79
82
  await render(props());
80
- expect(container.querySelector('[data-testid="widget"]')).not.toBeNull();
83
+ expect(
84
+ mounted.container.querySelector('[data-testid="widget"]'),
85
+ ).not.toBeNull();
81
86
  });
82
87
 
83
88
  test("passes on the props the widget needs but never names", async () => {
@@ -103,21 +108,31 @@ describe("ProcaptchaPuzzle", () => {
103
108
  expect(mocks.received[0]?.config).toEqual(config());
104
109
  });
105
110
 
106
- test("mounts one widget per render, not one per prop change", async () => {
111
+ test("tears the loaded widget down", async () => {
107
112
  await render(props());
108
- await render(props({ autoStart: true }));
109
- expect(container.querySelectorAll('[data-testid="widget"]')).toHaveLength(
110
- 1,
111
- );
113
+ widget?.destroy();
114
+ widget = undefined;
115
+ expect(mocks.destroyed.count).toBe(1);
116
+ expect(
117
+ mounted.container.querySelector('[data-testid="widget"]'),
118
+ ).toBeNull();
119
+ });
120
+
121
+ test("cancels a mount that is still loading", async () => {
122
+ // Otherwise a widget torn down mid-load reappears as an orphan that
123
+ // nothing holds a handle to.
124
+ widget = entrypoint.mountProcaptchaPuzzle(mounted.container, props());
125
+ widget.destroy();
126
+ widget = undefined;
127
+ await settle();
128
+ expect(mocks.received).toHaveLength(0);
112
129
  });
113
130
  });
114
131
 
115
132
  describe("the package entrypoint", () => {
116
- test("exports the wrapper a consumer mounts", () => {
117
- // The inner ProcaptchaWidget is a default export, which `export *` does
118
- // not re-export: consumers get the lazy-loading wrapper only, which is
119
- // the one that works outside a bundler that can code-split.
120
- expect(typeof entrypoint.ProcaptchaPuzzle).toBe("function");
121
- expect(Object.keys(entrypoint)).toEqual(["ProcaptchaPuzzle"]);
133
+ test("exports the mount functions a consumer needs", () => {
134
+ expect(typeof entrypoint.mountProcaptchaPuzzle).toBe("function");
135
+ expect(typeof entrypoint.loadProcaptchaPuzzle).toBe("function");
136
+ expect(typeof entrypoint.mountProcaptchaPuzzleWidget).toBe("function");
122
137
  });
123
138
  });