@prosopo/procaptcha-puzzle 2.13.3 → 2.14.0

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 (72) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +6 -6
  2. package/.turbo/turbo-build$colon$tsc.log +19 -19
  3. package/.turbo/turbo-build.log +7 -7
  4. package/CHANGELOG.md +41 -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 +450 -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 +450 -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/procaptchaPuzzle.test-d.js +13 -10
  30. package/dist/tests/procaptchaPuzzle.test-d.js.map +1 -1
  31. package/dist/tests/procaptchaPuzzle.unit.test.js +44 -33
  32. package/dist/tests/procaptchaPuzzle.unit.test.js.map +1 -1
  33. package/dist/tests/procaptchaWidget.unit.test.js +108 -175
  34. package/dist/tests/procaptchaWidget.unit.test.js.map +1 -1
  35. package/dist/tests/puzzleCanvas.unit.test.js +47 -80
  36. package/dist/tests/puzzleCanvas.unit.test.js.map +1 -1
  37. package/package.json +8 -12
  38. package/src/components/procaptchaPuzzle.ts +54 -0
  39. package/src/components/procaptchaWidget.ts +392 -0
  40. package/src/components/puzzleCanvas.ts +653 -0
  41. package/src/index.ts +3 -2
  42. package/src/tests/domHarness.ts +122 -0
  43. package/src/tests/procaptchaPuzzle.test-d.ts +33 -17
  44. package/src/tests/procaptchaPuzzle.unit.test.ts +59 -44
  45. package/src/tests/procaptchaWidget.unit.test.ts +145 -258
  46. package/src/tests/puzzleCanvas.unit.test.ts +76 -120
  47. package/tsconfig.cjs.json +1 -2
  48. package/tsconfig.json +1 -2
  49. package/tsconfig.tsbuildinfo +1 -1
  50. package/vite.test.config.ts +3 -4
  51. package/dist/cjs/components/ProcaptchaPuzzle.cjs +0 -7
  52. package/dist/cjs/components/ProcaptchaWidget.cjs +0 -206
  53. package/dist/cjs/components/PuzzleCanvas.cjs +0 -476
  54. package/dist/components/ProcaptchaPuzzle.d.ts +0 -3
  55. package/dist/components/ProcaptchaPuzzle.d.ts.map +0 -1
  56. package/dist/components/ProcaptchaPuzzle.js +0 -7
  57. package/dist/components/ProcaptchaPuzzle.js.map +0 -1
  58. package/dist/components/ProcaptchaWidget.d.ts +0 -4
  59. package/dist/components/ProcaptchaWidget.d.ts.map +0 -1
  60. package/dist/components/ProcaptchaWidget.js +0 -207
  61. package/dist/components/ProcaptchaWidget.js.map +0 -1
  62. package/dist/components/PuzzleCanvas.d.ts.map +0 -1
  63. package/dist/components/PuzzleCanvas.js +0 -476
  64. package/dist/components/PuzzleCanvas.js.map +0 -1
  65. package/dist/tests/setup.d.ts +0 -5
  66. package/dist/tests/setup.d.ts.map +0 -1
  67. package/dist/tests/setup.js +0 -3
  68. package/dist/tests/setup.js.map +0 -1
  69. package/src/components/ProcaptchaPuzzle.tsx +0 -36
  70. package/src/components/ProcaptchaWidget.tsx +0 -320
  71. package/src/components/PuzzleCanvas.tsx +0 -638
  72. 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
+ };
@@ -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
  });