@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
@@ -12,10 +12,10 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
 
15
+ import type { Translator } from "@prosopo/locale";
16
+ import type { Component } from "@prosopo/procaptcha-common";
15
17
  import type { PuzzleEvent } from "@prosopo/types";
16
- import { type Theme, lightTheme } from "@prosopo/widget-skeleton";
17
- import { type ReactElement, act, createElement } from "react";
18
- import { type Root, createRoot } from "react-dom/client";
18
+ import { lightTheme } from "@prosopo/widget-skeleton";
19
19
  import {
20
20
  type Mock,
21
21
  afterEach,
@@ -25,7 +25,10 @@ import {
25
25
  test,
26
26
  vi,
27
27
  } from "vitest";
28
- import { PuzzleCanvas } from "../components/PuzzleCanvas.js";
28
+ import {
29
+ type PuzzleCanvasProps,
30
+ mountPuzzleCanvas,
31
+ } from "../components/puzzleCanvas.js";
29
32
 
30
33
  /**
31
34
  * The canvas is the only piece of the puzzle flow the user actually touches:
@@ -34,73 +37,68 @@ import { PuzzleCanvas } from "../components/PuzzleCanvas.js";
34
37
  * against a real render rather than calling the handlers directly.
35
38
  */
36
39
 
40
+ const CONTAINER_WIDTH = 300;
41
+ const CONTAINER_HEIGHT = 200;
42
+ const PIECE_SIZE = 44;
43
+
37
44
  /**
38
- * The real locale package reaches for an http backend the moment a component
39
- * asks it for a string, which jsdom refuses. The English defaults the canvas
40
- * ships stand in instead, interpolated the way i18next would, so the
41
- * assertions below read as the copy a user is actually given.
45
+ * The real translator reaches for an http backend the moment it is asked for a
46
+ * string, which jsdom refuses. The English defaults the canvas ships stand in
47
+ * instead, interpolated the way i18next would, so the assertions below read as
48
+ * the copy a user is actually given.
42
49
  */
43
- vi.mock("@prosopo/locale", async (importOriginal) => {
44
- const actual = await importOriginal<typeof import("@prosopo/locale")>();
45
- const t = (
46
- key: string,
47
- options?: { defaultValue?: string } & Record<string, unknown>,
48
- ): string =>
49
- (options?.defaultValue ?? key).replace(
50
+ const translator = (): Translator => ({
51
+ t: (key: string, options?: Record<string, unknown>): string => {
52
+ const template = (options?.defaultValue as string | undefined) ?? key;
53
+ return template.replace(
50
54
  /{{(\w+)}}/g,
51
55
  (placeholder: string, name: string) =>
52
56
  options && name in options ? String(options[name]) : placeholder,
53
57
  );
54
- return { ...actual, useTranslation: () => ({ t, ready: true }) };
58
+ },
59
+ isReady: () => true,
60
+ subscribe: () => () => undefined,
61
+ i18n: {} as Translator["i18n"],
55
62
  });
56
63
 
57
- const CONTAINER_WIDTH = 300;
58
- const CONTAINER_HEIGHT = 200;
59
- const PIECE_SIZE = 44;
60
-
61
- interface CanvasProps {
62
- originX: number;
63
- originY: number;
64
- background: string;
65
- piece: string;
66
- pieceSize: number;
67
- onComplete: Mock<
68
- (finalX: number, finalY: number, puzzleEvents: PuzzleEvent[]) => void
69
- >;
70
- showRetry: boolean;
71
- submitting: boolean;
72
- theme: Theme;
73
- }
74
-
75
- let container: HTMLDivElement;
76
- let root: Root;
64
+ let canvas: Component<PuzzleCanvasProps> | undefined;
77
65
  let onComplete: Mock<
78
66
  (finalX: number, finalY: number, puzzleEvents: PuzzleEvent[]) => void
79
67
  >;
80
68
 
81
- const props = (overrides: Partial<CanvasProps> = {}): CanvasProps => ({
69
+ const props = (
70
+ overrides: Partial<PuzzleCanvasProps> = {},
71
+ ): PuzzleCanvasProps => ({
82
72
  originX: 20,
83
73
  originY: 100,
84
74
  background: "data:image/webp;base64,UklGRg==",
85
75
  piece: "data:image/webp;base64,UklGRg==",
86
- pieceSize: 44,
76
+ pieceSize: PIECE_SIZE,
87
77
  onComplete,
88
78
  showRetry: false,
89
79
  submitting: false,
90
80
  theme: lightTheme,
81
+ translator: translator(),
91
82
  ...overrides,
92
83
  });
93
84
 
94
- const render = (canvasProps: CanvasProps): void => {
95
- act(() => {
96
- root.render(createElement(PuzzleCanvas, canvasProps) as ReactElement);
97
- });
85
+ const render = (canvasProps: PuzzleCanvasProps): void => {
86
+ if (canvas) {
87
+ canvas.update(canvasProps);
88
+ } else {
89
+ canvas = mountPuzzleCanvas(canvasProps);
90
+ }
91
+ };
92
+
93
+ const destroy = (): void => {
94
+ canvas?.destroy();
95
+ canvas = undefined;
98
96
  };
99
97
 
100
98
  /**
101
- * The canvas portals itself onto the body — it has to escape the query
102
- * container the widget skeleton wraps it in — so it is never inside the render
103
- * container, and everything that reads the rendered output reads the body.
99
+ * The canvas puts itself on the body — it has to escape the query container
100
+ * the widget skeleton wraps it in — so everything that reads the rendered
101
+ * output reads the body.
104
102
  */
105
103
  const overlay = (): HTMLElement => document.body;
106
104
 
@@ -119,25 +117,19 @@ const piecePosition = (): { x: number; y: number } => ({
119
117
  });
120
118
 
121
119
  const mouseDown = (clientX: number, clientY: number): void => {
122
- act(() => {
123
- piece().dispatchEvent(
124
- new MouseEvent("mousedown", { bubbles: true, clientX, clientY }),
125
- );
126
- });
120
+ piece().dispatchEvent(
121
+ new MouseEvent("mousedown", { bubbles: true, clientX, clientY }),
122
+ );
127
123
  };
128
124
 
129
125
  const mouseMove = (clientX: number, clientY: number): void => {
130
- act(() => {
131
- document.dispatchEvent(
132
- new MouseEvent("mousemove", { bubbles: true, clientX, clientY }),
133
- );
134
- });
126
+ document.dispatchEvent(
127
+ new MouseEvent("mousemove", { bubbles: true, clientX, clientY }),
128
+ );
135
129
  };
136
130
 
137
131
  const mouseUp = (): void => {
138
- act(() => {
139
- document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
140
- });
132
+ document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
141
133
  };
142
134
 
143
135
  /**
@@ -154,34 +146,26 @@ const touchEvent = (
154
146
  };
155
147
 
156
148
  const touchStart = (touches: { clientX: number; clientY: number }[]): void => {
157
- act(() => {
158
- piece().dispatchEvent(touchEvent("touchstart", touches));
159
- });
149
+ piece().dispatchEvent(touchEvent("touchstart", touches));
160
150
  };
161
151
 
162
152
  const touchMove = (touches: { clientX: number; clientY: number }[]): void => {
163
- act(() => {
164
- document.dispatchEvent(touchEvent("touchmove", touches));
165
- });
153
+ document.dispatchEvent(touchEvent("touchmove", touches));
166
154
  };
167
155
 
168
156
  const touchEnd = (): void => {
169
- act(() => {
170
- document.dispatchEvent(touchEvent("touchend", []));
171
- });
157
+ document.dispatchEvent(touchEvent("touchend", []));
172
158
  };
173
159
 
174
160
  const keyDown = (key: string, options: { shiftKey?: boolean } = {}): void => {
175
- act(() => {
176
- piece().dispatchEvent(
177
- new KeyboardEvent("keydown", {
178
- key,
179
- bubbles: true,
180
- cancelable: true,
181
- shiftKey: options.shiftKey ?? false,
182
- }),
183
- );
184
- });
161
+ piece().dispatchEvent(
162
+ new KeyboardEvent("keydown", {
163
+ key,
164
+ bubbles: true,
165
+ cancelable: true,
166
+ shiftKey: options.shiftKey ?? false,
167
+ }),
168
+ );
185
169
  };
186
170
 
187
171
  /**
@@ -222,18 +206,11 @@ beforeEach(() => {
222
206
  vi.fn<
223
207
  (finalX: number, finalY: number, puzzleEvents: PuzzleEvent[]) => void
224
208
  >();
225
- container = document.createElement("div");
226
- document.body.appendChild(container);
227
- act(() => {
228
- root = createRoot(container);
229
- });
209
+ canvas = undefined;
230
210
  });
231
211
 
232
212
  afterEach(() => {
233
- act(() => {
234
- root.unmount();
235
- });
236
- container.remove();
213
+ destroy();
237
214
  vi.useRealTimers();
238
215
  vi.unstubAllGlobals();
239
216
  vi.restoreAllMocks();
@@ -291,9 +268,7 @@ describe("what it puts on screen", () => {
291
268
  test("the shake on a retry stops on its own", () => {
292
269
  vi.useFakeTimers();
293
270
  render(props({ showRetry: true }));
294
- act(() => {
295
- vi.advanceTimersByTime(600);
296
- });
271
+ vi.advanceTimersByTime(600);
297
272
  // Nothing to assert beyond survival: the timer fires into a live
298
273
  // component rather than leaking past the shake.
299
274
  expect(piecePosition()).toEqual({ x: 20, y: 100 });
@@ -302,15 +277,8 @@ describe("what it puts on screen", () => {
302
277
  test("unmounting mid-shake cancels the timer", () => {
303
278
  vi.useFakeTimers();
304
279
  render(props({ showRetry: true }));
305
- act(() => {
306
- root.unmount();
307
- });
308
- act(() => {
309
- vi.advanceTimersByTime(600);
310
- });
311
- act(() => {
312
- root = createRoot(container);
313
- });
280
+ destroy();
281
+ vi.advanceTimersByTime(600);
314
282
  expect(
315
283
  overlay().querySelector('[data-cy="prosopo-puzzle-piece"]'),
316
284
  ).toBeNull();
@@ -366,17 +334,20 @@ describe("dragging with a mouse", () => {
366
334
  const [finalX, finalY, events] = onComplete.mock.calls[0] ?? [];
367
335
  expect(finalX).toBe(200);
368
336
  expect(finalY).toBe(80);
369
- expect(events?.map((event) => [event.x, event.y])).toEqual([
337
+ expect(events?.map((event: PuzzleEvent) => [event.x, event.y])).toEqual([
370
338
  [100, 95],
371
339
  [200, 80],
372
340
  ]);
373
341
  });
374
342
 
375
- test("a grab released without moving reports the origin and no trail", () => {
343
+ // Reporting the origin here spent the challenge on a position that cannot be
344
+ // correct, and the provider rejects the replacement fetch — so a single
345
+ // click that never moved ended the attempt outright.
346
+ test("a grab released without moving reports nothing", () => {
376
347
  render(props());
377
348
  mouseDown(20, 100);
378
349
  mouseUp();
379
- expect(onComplete).toHaveBeenCalledWith(20, 100, []);
350
+ expect(onComplete).not.toHaveBeenCalled();
380
351
  });
381
352
 
382
353
  test("letting go without having grabbed anything reports nothing", () => {
@@ -412,7 +383,9 @@ describe("dragging with a mouse", () => {
412
383
  mouseMove(200, 80);
413
384
  mouseUp();
414
385
  const events = onComplete.mock.calls[1]?.[2];
415
- expect(events?.map((event) => [event.x, event.y])).toEqual([[200, 80]]);
386
+ expect(events?.map((event: PuzzleEvent) => [event.x, event.y])).toEqual([
387
+ [200, 80],
388
+ ]);
416
389
  });
417
390
 
418
391
  test("the trail is timestamped in order", () => {
@@ -465,6 +438,19 @@ describe("dragging with a finger", () => {
465
438
  expect(onComplete).not.toHaveBeenCalled();
466
439
  });
467
440
 
441
+ // The panel opens centred, under the finger that just pressed the checkbox,
442
+ // so tapping the piece instead of dragging it is the easy mistake to make on
443
+ // a phone. It used to submit the untouched origin, which fails, and the
444
+ // widget then reset — indistinguishable to the user from the challenge
445
+ // never having worked.
446
+ test("tapping the piece without dragging reports nothing", () => {
447
+ render(props());
448
+ touchStart([{ clientX: 20, clientY: 100 }]);
449
+ touchEnd();
450
+ expect(onComplete).not.toHaveBeenCalled();
451
+ expect(piecePosition()).toEqual({ x: 20, y: 100 });
452
+ });
453
+
468
454
  test("a touchmove carrying no touches is ignored mid-drag", () => {
469
455
  render(props());
470
456
  touchStart([{ clientX: 20, clientY: 100 }]);
@@ -586,11 +572,9 @@ describe("driving it from the keyboard", () => {
586
572
 
587
573
  test("tab cannot leave the dialog for the page behind it", () => {
588
574
  render(props());
589
- act(() => {
590
- document.dispatchEvent(
591
- new KeyboardEvent("keydown", { key: "Tab", bubbles: true }),
592
- );
593
- });
575
+ document.dispatchEvent(
576
+ new KeyboardEvent("keydown", { key: "Tab", bubbles: true }),
577
+ );
594
578
  expect(document.activeElement).toBe(piece());
595
579
  });
596
580
 
@@ -602,15 +586,10 @@ describe("driving it from the keyboard", () => {
602
586
  render(props());
603
587
  expect(document.activeElement).toBe(piece());
604
588
 
605
- act(() => {
606
- root.unmount();
607
- });
589
+ destroy();
608
590
  expect(document.activeElement).toBe(opener);
609
591
 
610
592
  opener.remove();
611
- act(() => {
612
- root = createRoot(container);
613
- });
614
593
  });
615
594
  });
616
595
 
@@ -647,9 +626,7 @@ describe("what it says to a screen reader", () => {
647
626
  keyDown("ArrowRight");
648
627
  keyDown("ArrowRight");
649
628
  expect(liveRegion().textContent).toBe("7 percent across, 50 percent down");
650
- act(() => {
651
- vi.advanceTimersByTime(500);
652
- });
629
+ vi.advanceTimersByTime(500);
653
630
  expect(liveRegion().textContent).toBe("13 percent across, 50 percent down");
654
631
  });
655
632
 
@@ -677,14 +654,9 @@ describe("after it goes away", () => {
677
654
  test("its document listeners go with it", () => {
678
655
  render(props());
679
656
  mouseDown(20, 100);
680
- act(() => {
681
- root.unmount();
682
- });
657
+ destroy();
683
658
  mouseMove(150, 90);
684
659
  mouseUp();
685
660
  expect(onComplete).not.toHaveBeenCalled();
686
- act(() => {
687
- root = createRoot(container);
688
- });
689
661
  });
690
662
  });
package/tsconfig.cjs.json CHANGED
@@ -3,8 +3,7 @@
3
3
  "compilerOptions": {
4
4
  "rootDir": "./src",
5
5
  "outDir": "./dist/cjs",
6
- "lib": ["es6", "dom"],
7
- "jsxImportSource": "@emotion/react"
6
+ "lib": ["es6", "dom"]
8
7
  },
9
8
  "include": [
10
9
  "./src/**/*.ts",
package/tsconfig.json CHANGED
@@ -3,8 +3,7 @@
3
3
  "compilerOptions": {
4
4
  "rootDir": "./src",
5
5
  "outDir": "./dist",
6
- "lib": ["es6", "dom"],
7
- "jsxImportSource": "@emotion/react"
6
+ "lib": ["es6", "dom"]
8
7
  },
9
8
  "include": [
10
9
  "src",