@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
@@ -13,6 +13,7 @@
13
13
  // limitations under the License.
14
14
 
15
15
  import type { Ti18n } from "@prosopo/locale";
16
+ import type { Component } from "@prosopo/procaptcha-common";
16
17
  import {
17
18
  type GetPuzzleCaptchaResponse,
18
19
  ModeEnum,
@@ -20,8 +21,6 @@ import {
20
21
  type ProcaptchaState,
21
22
  type PuzzleEvent,
22
23
  } from "@prosopo/types";
23
- import { type ReactElement, act, createElement } from "react";
24
- import { type Root, createRoot } from "react-dom/client";
25
24
  import {
26
25
  type Mock,
27
26
  afterEach,
@@ -31,7 +30,12 @@ import {
31
30
  test,
32
31
  vi,
33
32
  } from "vitest";
34
- import Procaptcha from "../components/ProcaptchaWidget.js";
33
+ import {
34
+ type ProcaptchaPuzzleHandle,
35
+ mountProcaptchaPuzzleWidget,
36
+ } from "../components/procaptchaWidget.js";
37
+ import type { PuzzleCanvasProps } from "../components/puzzleCanvas.js";
38
+ import { type Mounted, fire, mount, settle } from "./domHarness.js";
35
39
  import { challengeResponse, config, frictionless } from "./managerHarness.js";
36
40
 
37
41
  /**
@@ -52,36 +56,9 @@ const mocks = vi.hoisted(() => {
52
56
  getHoneypotValue?: () => string | undefined;
53
57
  }[] = [];
54
58
  const loadI18next = vi.fn<(a?: boolean, b?: string) => Promise<unknown>>();
55
- const checkboxProps: {
56
- current:
57
- | {
58
- checked: boolean;
59
- loading: boolean;
60
- labelText: string;
61
- error?: string;
62
- onChange: (event: { nativeEvent: unknown }) => Promise<void>;
63
- }
64
- | undefined;
65
- } = { current: undefined };
66
- const canvasProps: {
67
- current:
68
- | {
69
- originX: number;
70
- originY: number;
71
- background: string;
72
- piece: string;
73
- pieceSize: number;
74
- showRetry: boolean;
75
- submitting: boolean;
76
- onComplete: (
77
- x: number,
78
- y: number,
79
- events: PuzzleEvent[],
80
- ) => Promise<void> | void;
81
- }
82
- | undefined;
83
- } = { current: undefined };
84
- const honeypotQuestions: string[] = [];
59
+ const canvasProps: { current: PuzzleCanvasProps | undefined } = {
60
+ current: undefined,
61
+ };
85
62
  const translationsReady = { current: true };
86
63
  return {
87
64
  translationsReady,
@@ -90,9 +67,7 @@ const mocks = vi.hoisted(() => {
90
67
  resetState,
91
68
  constructions,
92
69
  loadI18next,
93
- checkboxProps,
94
70
  canvasProps,
95
- honeypotQuestions,
96
71
  };
97
72
  });
98
73
 
@@ -117,79 +92,42 @@ vi.mock("../services/Manager.js", () => ({
117
92
  // The canvas has its own suite; here it is reduced to a probe so a test can
118
93
  // read the props the widget hands it and fire the completion callback without
119
94
  // simulating a drag.
120
- vi.mock("../components/PuzzleCanvas.js", async () => {
121
- const { createElement: create } = await import("react");
122
- interface CanvasStubProps {
123
- originX: number;
124
- originY: number;
125
- background: string;
126
- piece: string;
127
- pieceSize: number;
128
- showRetry: boolean;
129
- submitting: boolean;
130
- onComplete: (
131
- x: number,
132
- y: number,
133
- events: PuzzleEvent[],
134
- ) => Promise<void> | void;
135
- }
136
- const PuzzleCanvas = (canvasProps: CanvasStubProps) => {
95
+ vi.mock("../components/puzzleCanvas.js", () => ({
96
+ mountPuzzleCanvas: (
97
+ canvasProps: PuzzleCanvasProps,
98
+ ): Component<PuzzleCanvasProps> => {
137
99
  mocks.canvasProps.current = canvasProps;
138
- return create("div", { "data-cy": "canvas-stub" });
139
- };
140
- return { PuzzleCanvas };
141
- });
142
-
143
- // The checkbox and the honeypot are procaptcha-common's, and tested there. The
144
- // stubs keep their contract — a change callback and a forwarded input ref —
145
- // while letting a test drive the exact browser event the widget branches on,
146
- // which jsdom cannot produce (it marks every dispatched event untrusted, and
147
- // the real checkbox drops those before the widget ever sees them).
148
- vi.mock("@prosopo/procaptcha-common", async (importOriginal) => {
149
- const actual =
150
- await importOriginal<typeof import("@prosopo/procaptcha-common")>();
151
- const { createElement: create, forwardRef } = await import("react");
152
- interface CheckboxStubProps {
153
- checked: boolean;
154
- loading: boolean;
155
- labelText: string;
156
- error?: string;
157
- onChange: (event: { nativeEvent: unknown }) => Promise<void>;
158
- }
159
- const Checkbox = (checkboxProps: CheckboxStubProps) => {
160
- mocks.checkboxProps.current = checkboxProps;
161
- return create("input", {
162
- type: "checkbox",
163
- readOnly: true,
164
- checked: checkboxProps.checked,
165
- "aria-label": checkboxProps.labelText,
166
- "data-error": checkboxProps.error,
167
- "data-loading": String(checkboxProps.loading),
168
- });
169
- };
170
- const Honeypot = forwardRef<HTMLInputElement, { encodedQuestion: string }>(
171
- ({ encodedQuestion }, ref) => {
172
- mocks.honeypotQuestions.push(encodedQuestion);
173
- return create("input", { type: "text", ref, name: "honeypot" });
174
- },
175
- );
176
- return { ...actual, Checkbox, Honeypot };
177
- });
100
+ const element = document.createElement("div");
101
+ element.setAttribute("data-cy", "canvas-stub");
102
+ document.body.appendChild(element);
103
+ return {
104
+ update: (next: PuzzleCanvasProps) => {
105
+ mocks.canvasProps.current = next;
106
+ },
107
+ destroy: () => {
108
+ element.remove();
109
+ mocks.canvasProps.current = undefined;
110
+ },
111
+ };
112
+ },
113
+ }));
178
114
 
179
115
  vi.mock("@prosopo/locale", async (importOriginal) => {
180
116
  const actual = await importOriginal<typeof import("@prosopo/locale")>();
181
117
  return {
182
118
  ...actual,
183
119
  loadI18next: mocks.loadI18next,
184
- useTranslation: () => ({
120
+ createTranslator: () => ({
185
121
  t: (key: string) => key,
186
- ready: mocks.translationsReady.current,
122
+ isReady: () => mocks.translationsReady.current,
123
+ subscribe: () => () => undefined,
124
+ i18n: undefined,
187
125
  }),
188
126
  };
189
127
  });
190
128
 
191
- let container: HTMLDivElement;
192
- let root: Root;
129
+ let mounted: Mounted;
130
+ let widget: ProcaptchaPuzzleHandle | undefined;
193
131
 
194
132
  const i18nStub = (
195
133
  language: string,
@@ -204,53 +142,52 @@ const props = (overrides: Partial<ProcaptchaProps> = {}): ProcaptchaProps => ({
204
142
  });
205
143
 
206
144
  const render = (widgetProps: ProcaptchaProps): void => {
207
- act(() => {
208
- root.render(createElement(Procaptcha, widgetProps) as ReactElement);
209
- });
145
+ widget = mountProcaptchaPuzzleWidget(mounted.container, widgetProps);
146
+ };
147
+
148
+ const destroy = (): void => {
149
+ widget?.destroy();
150
+ widget = undefined;
210
151
  };
211
152
 
212
153
  beforeEach(() => {
213
154
  vi.clearAllMocks();
214
155
  mocks.constructions.length = 0;
215
- mocks.honeypotQuestions.length = 0;
216
156
  mocks.translationsReady.current = true;
217
- mocks.checkboxProps.current = undefined;
218
157
  mocks.canvasProps.current = undefined;
219
158
  mocks.start.mockResolvedValue(challengeResponse());
220
159
  mocks.submitSolution.mockResolvedValue(true);
221
160
  mocks.loadI18next.mockResolvedValue(undefined);
222
- container = document.createElement("div");
223
- document.body.appendChild(container);
224
- act(() => {
225
- root = createRoot(container);
226
- });
161
+ widget = undefined;
162
+ mounted = mount();
227
163
  });
228
164
 
229
165
  afterEach(() => {
230
- act(() => {
231
- root.unmount();
232
- });
233
- container.remove();
234
- // React schedules through timers, so a suite that leaves them faked makes
235
- // every later render land after its assertions.
166
+ destroy();
167
+ mounted.unmount();
236
168
  vi.useRealTimers();
237
169
  vi.restoreAllMocks();
238
170
  });
239
171
 
240
172
  const checkbox = (): HTMLInputElement => {
241
- const element = container.querySelector<HTMLInputElement>(
173
+ const element = mounted.container.querySelector<HTMLInputElement>(
242
174
  'input[type="checkbox"]',
243
175
  );
244
176
  if (!element) throw new Error("expected a checkbox to be rendered");
245
177
  return element;
246
178
  };
247
179
 
180
+ // The canvas puts itself on the body rather than inside the widget, so it can
181
+ // escape the skeleton's query container.
248
182
  const canvas = (): Element | null =>
249
- container.querySelector('[data-cy="canvas-stub"]');
183
+ document.body.querySelector('[data-cy="canvas-stub"]');
184
+
185
+ const spinner = (): Element | null =>
186
+ mounted.container.querySelector('[role="status"]');
250
187
 
251
188
  const honeypotInput = (): HTMLInputElement => {
252
- const element = container.querySelector<HTMLInputElement>(
253
- 'input[name="honeypot"]',
189
+ const element = document.querySelector<HTMLInputElement>(
190
+ 'input[name="email_confirm"]',
254
191
  );
255
192
  if (!element) throw new Error("expected a honeypot to be rendered");
256
193
  return element;
@@ -263,17 +200,10 @@ interface ClickOptions {
263
200
  touches?: { clientX: number; clientY: number }[];
264
201
  }
265
202
 
266
- /** Hand the widget the browser event a click on the checkbox would produce. */
203
+ /** Click the real checkbox with the browser event a user would produce. */
267
204
  const click = async (options: ClickOptions = {}): Promise<void> => {
268
- const nativeEvent = {
269
- isTrusted: options.trusted ?? true,
270
- clientX: options.clientX ?? 0,
271
- clientY: options.clientY ?? 0,
272
- ...(options.touches ? { touches: options.touches } : {}),
273
- };
274
- await act(async () => {
275
- await mocks.checkboxProps.current?.onChange({ nativeEvent });
276
- });
205
+ fire(checkbox(), "click", options);
206
+ await settle();
277
207
  };
278
208
 
279
209
  /**
@@ -281,11 +211,7 @@ const click = async (options: ClickOptions = {}): Promise<void> => {
281
211
  * manager's promise open.
282
212
  */
283
213
  const clickWithoutWaiting = (): void => {
284
- act(() => {
285
- void mocks.checkboxProps.current?.onChange({
286
- nativeEvent: { isTrusted: true, clientX: 0, clientY: 0 },
287
- });
288
- });
214
+ fire(checkbox(), "click");
289
215
  };
290
216
 
291
217
  /** Fire the canvas completion callback the way a finished drag would. */
@@ -294,9 +220,8 @@ const complete = async (
294
220
  y = 80,
295
221
  events: PuzzleEvent[] = [{ x: 200, y: 80, t: 5 }],
296
222
  ): Promise<void> => {
297
- await act(async () => {
298
- await mocks.canvasProps.current?.onComplete(x, y, events);
299
- });
223
+ await mocks.canvasProps.current?.onComplete(x, y, events);
224
+ await settle();
300
225
  };
301
226
 
302
227
  describe("what the widget renders", () => {
@@ -307,7 +232,9 @@ describe("what the widget renders", () => {
307
232
 
308
233
  test("an invisible widget shows no checkbox at all", () => {
309
234
  render(props({ config: config({ mode: ModeEnum.invisible }) }));
310
- expect(container.querySelector('input[type="checkbox"]')).toBeNull();
235
+ expect(
236
+ mounted.container.querySelector('input[type="checkbox"]'),
237
+ ).toBeNull();
311
238
  });
312
239
 
313
240
  test("no puzzle is shown until a challenge has been fetched", () => {
@@ -316,18 +243,22 @@ describe("what the widget renders", () => {
316
243
  });
317
244
 
318
245
  test("a session with a honeypot question renders the honeypot", () => {
319
- render(props({ frictionlessState: frictionless({ hp: "question" }) }));
246
+ render(
247
+ props({ frictionlessState: frictionless({ hp: btoa("question") }) }),
248
+ );
320
249
  expect(honeypotInput()).toBeDefined();
321
- expect(mocks.honeypotQuestions.at(-1)).toBe("question");
250
+ expect(document.body.textContent).toContain("question");
322
251
  });
323
252
 
324
253
  test("a session without one renders no bait at all", () => {
325
254
  render(props({ frictionlessState: frictionless() }));
326
- expect(container.querySelector('input[name="honeypot"]')).toBeNull();
255
+ expect(document.querySelector('input[name="email_confirm"]')).toBeNull();
327
256
  });
328
257
 
329
258
  test("the manager can read the honeypot input the widget rendered", () => {
330
- render(props({ frictionlessState: frictionless({ hp: "question" }) }));
259
+ render(
260
+ props({ frictionlessState: frictionless({ hp: btoa("question") }) }),
261
+ );
331
262
  honeypotInput().value = "bot@example.com";
332
263
  expect(mocks.constructions[0]?.getHoneypotValue?.()).toBe(
333
264
  "bot@example.com",
@@ -341,32 +272,22 @@ describe("what the widget renders", () => {
341
272
 
342
273
  test("the checkbox is labelled once the translations are ready", () => {
343
274
  render(props());
344
- expect(mocks.checkboxProps.current?.labelText).toBe("WIDGET.I_AM_HUMAN");
275
+ expect(checkbox().getAttribute("aria-label")).toBe("WIDGET.I_AM_HUMAN");
345
276
  });
346
277
 
347
278
  test("the label is left empty while the translations load", () => {
348
279
  mocks.translationsReady.current = false;
349
280
  render(props());
350
- expect(mocks.checkboxProps.current?.labelText).toBe("");
281
+ expect(checkbox().getAttribute("aria-label")).toBe("");
351
282
  });
352
283
 
353
- test("the widget keeps the manager it started with across re-renders", () => {
354
- // `useRef(Manager(...))` re-evaluates the argument on every render, so
355
- // extra managers are constructed and thrown away; what matters is that
356
- // the widget still listens to the first one.
357
- render(props());
284
+ test("the widget builds exactly one manager", async () => {
285
+ // The React version rebuilt the manager on every render and kept the
286
+ // first through a ref, which is what lost the checkbox click coordinates
287
+ // before the ref was introduced. There is one manager per mount now.
358
288
  render(props());
359
- act(() => {
360
- mocks.constructions[0]?.updateState({
361
- error: {
362
- message: "from the original manager",
363
- key: "API.UNKNOWN_ERROR",
364
- },
365
- });
366
- });
367
- expect(mocks.checkboxProps.current?.error).toBe(
368
- "from the original manager",
369
- );
289
+ await click();
290
+ expect(mocks.constructions).toHaveLength(1);
370
291
  });
371
292
  });
372
293
 
@@ -419,10 +340,12 @@ describe("starting from the checkbox", () => {
419
340
  });
420
341
  });
421
342
 
422
- test("an untrusted click starts a solve but carries no coordinates", async () => {
343
+ test("a synthetic click never reaches the manager", async () => {
344
+ // The checkbox drops untrusted events before the widget sees them, so an
345
+ // automated solver cannot open a puzzle at all.
423
346
  render(props());
424
347
  await click({ trusted: false, clientX: 12, clientY: 34 });
425
- expect(mocks.start).toHaveBeenCalledWith(0, 0);
348
+ expect(mocks.start).not.toHaveBeenCalled();
426
349
  });
427
350
 
428
351
  test("a tap reports the coordinates of the first touch", async () => {
@@ -431,7 +354,7 @@ describe("starting from the checkbox", () => {
431
354
  expect(mocks.start).toHaveBeenCalledWith(7, 9);
432
355
  });
433
356
 
434
- test("a touch event with no touches falls back to the origin", async () => {
357
+ test("a touch event with no touches falls back to the pointer", async () => {
435
358
  render(props());
436
359
  await click({ touches: [] });
437
360
  expect(mocks.start).toHaveBeenCalledWith(0, 0);
@@ -442,9 +365,11 @@ describe("starting from the checkbox", () => {
442
365
  // replaced synchronously by the promise executor below
443
366
  };
444
367
  mocks.start.mockReturnValue(
445
- new Promise<GetPuzzleCaptchaResponse>((resolve) => {
446
- release = resolve;
447
- }),
368
+ new Promise<GetPuzzleCaptchaResponse>(
369
+ (resolve: (challenge: GetPuzzleCaptchaResponse) => void) => {
370
+ release = resolve;
371
+ },
372
+ ),
448
373
  );
449
374
  render(props());
450
375
  // Not awaited: the handler cannot settle until the challenge is
@@ -452,9 +377,8 @@ describe("starting from the checkbox", () => {
452
377
  clickWithoutWaiting();
453
378
  clickWithoutWaiting();
454
379
  expect(mocks.start).toHaveBeenCalledTimes(1);
455
- await act(async () => {
456
- release(challengeResponse());
457
- });
380
+ release(challengeResponse());
381
+ await settle();
458
382
  });
459
383
 
460
384
  test("the checkbox shows a spinner while the solve is loading", async () => {
@@ -462,17 +386,19 @@ describe("starting from the checkbox", () => {
462
386
  // replaced synchronously by the promise executor below
463
387
  };
464
388
  mocks.start.mockReturnValue(
465
- new Promise<GetPuzzleCaptchaResponse>((resolve) => {
466
- release = resolve;
467
- }),
389
+ new Promise<GetPuzzleCaptchaResponse>(
390
+ (resolve: (challenge: GetPuzzleCaptchaResponse) => void) => {
391
+ release = resolve;
392
+ },
393
+ ),
468
394
  );
469
395
  render(props());
470
396
  clickWithoutWaiting();
471
- expect(mocks.checkboxProps.current?.loading).toBe(true);
472
- await act(async () => {
473
- release(challengeResponse());
474
- });
475
- expect(mocks.checkboxProps.current?.loading).toBe(false);
397
+ await settle();
398
+ expect(spinner()).not.toBeNull();
399
+ release(challengeResponse());
400
+ await settle();
401
+ expect(spinner()).toBeNull();
476
402
  });
477
403
 
478
404
  test("a start that yields no challenge leaves the checkbox in place", async () => {
@@ -480,7 +406,7 @@ describe("starting from the checkbox", () => {
480
406
  render(props());
481
407
  await click();
482
408
  expect(canvas()).toBeNull();
483
- expect(mocks.checkboxProps.current?.loading).toBe(false);
409
+ expect(spinner()).toBeNull();
484
410
  });
485
411
 
486
412
  test("a start that throws returns the user to a usable checkbox", async () => {
@@ -490,7 +416,7 @@ describe("starting from the checkbox", () => {
490
416
  await click();
491
417
  // Without the guard the spinner would stay up for good: nothing awaits
492
418
  // the checkbox handler, so the rejection escapes as an unhandled one.
493
- expect(mocks.checkboxProps.current?.loading).toBe(false);
419
+ expect(spinner()).toBeNull();
494
420
  expect(onError).toHaveBeenCalledWith(expect.any(Error));
495
421
  });
496
422
 
@@ -498,42 +424,30 @@ describe("starting from the checkbox", () => {
498
424
  mocks.start.mockRejectedValue(new Error("provider down"));
499
425
  render(props());
500
426
  await click();
501
- expect(mocks.checkboxProps.current?.loading).toBe(false);
427
+ expect(spinner()).toBeNull();
502
428
  });
503
429
  });
504
430
 
505
431
  describe("autoStart", () => {
506
432
  test("fetches a challenge and opens the puzzle without a click", async () => {
507
- await act(async () => {
508
- root.render(
509
- createElement(
510
- Procaptcha,
511
- props({ autoStart: true, startCoords: { x: 5, y: 6 } }),
512
- ) as ReactElement,
513
- );
514
- });
433
+ render(props({ autoStart: true, startCoords: { x: 5, y: 6 } }));
434
+ await settle();
515
435
  expect(mocks.start).toHaveBeenCalledWith(5, 6);
516
436
  expect(canvas()).not.toBeNull();
517
437
  });
518
438
 
519
439
  test("starts at the origin when no coordinates were handed over", async () => {
520
- await act(async () => {
521
- root.render(
522
- createElement(Procaptcha, props({ autoStart: true })) as ReactElement,
523
- );
524
- });
440
+ render(props({ autoStart: true }));
441
+ await settle();
525
442
  expect(mocks.start).toHaveBeenCalledWith(0, 0);
526
443
  });
527
444
 
528
445
  test("a failed autoStart leaves the checkbox usable", async () => {
529
446
  mocks.start.mockRejectedValue(new Error("nope"));
530
- await act(async () => {
531
- root.render(
532
- createElement(Procaptcha, props({ autoStart: true })) as ReactElement,
533
- );
534
- });
447
+ render(props({ autoStart: true }));
448
+ await settle();
535
449
  expect(canvas()).toBeNull();
536
- expect(mocks.checkboxProps.current?.loading).toBe(false);
450
+ expect(spinner()).toBeNull();
537
451
  });
538
452
 
539
453
  test("no autoStart means no solve until the user acts", () => {
@@ -561,7 +475,7 @@ describe("finishing the drag", () => {
561
475
  await openPuzzle();
562
476
  await complete();
563
477
  expect(canvas()).toBeNull();
564
- expect(mocks.checkboxProps.current?.loading).toBe(false);
478
+ expect(spinner()).toBeNull();
565
479
  });
566
480
 
567
481
  test("a rejected solution asks for another go on a fresh challenge", async () => {
@@ -628,29 +542,25 @@ describe("finishing the drag", () => {
628
542
  // replaced synchronously by the promise executor below
629
543
  };
630
544
  mocks.submitSolution.mockReturnValue(
631
- new Promise<boolean>((resolve) => {
545
+ new Promise<boolean>((resolve: (verified: boolean) => void) => {
632
546
  release = resolve;
633
547
  }),
634
548
  );
635
549
  await openPuzzle();
636
550
  // Not awaited: the handler cannot settle until the verdict is released.
637
- act(() => {
638
- void mocks.canvasProps.current?.onComplete(200, 80, []);
639
- });
551
+ void mocks.canvasProps.current?.onComplete(200, 80, []);
552
+ await settle();
640
553
  expect(mocks.canvasProps.current?.submitting).toBe(true);
641
- expect(mocks.checkboxProps.current?.loading).toBe(true);
642
- await act(async () => {
643
- release(true);
644
- });
554
+ expect(spinner()).not.toBeNull();
555
+ release(true);
556
+ await settle();
645
557
  });
646
558
  });
647
559
 
648
560
  describe("invisible mode", () => {
649
561
  const execute = async (): Promise<void> => {
650
- await act(async () => {
651
- document.dispatchEvent(new Event("procaptcha:execute"));
652
- await Promise.resolve();
653
- });
562
+ document.dispatchEvent(new Event("procaptcha:execute"));
563
+ await settle();
654
564
  };
655
565
 
656
566
  test("an execute event fetches a challenge and opens the puzzle", async () => {
@@ -669,10 +579,8 @@ describe("invisible mode", () => {
669
579
  test("a targeted execute on the container runs a visible widget", async () => {
670
580
  const target = document.createElement("div");
671
581
  render(props({ container: target }));
672
- await act(async () => {
673
- target.dispatchEvent(new Event("procaptcha:execute"));
674
- await Promise.resolve();
675
- });
582
+ target.dispatchEvent(new Event("procaptcha:execute"));
583
+ await settle();
676
584
  expect(mocks.start).toHaveBeenCalledTimes(1);
677
585
  expect(canvas()).not.toBeNull();
678
586
  });
@@ -685,10 +593,8 @@ describe("invisible mode", () => {
685
593
  container: target,
686
594
  }),
687
595
  );
688
- await act(async () => {
689
- target.dispatchEvent(new Event("procaptcha:execute"));
690
- await Promise.resolve();
691
- });
596
+ target.dispatchEvent(new Event("procaptcha:execute"));
597
+ await settle();
692
598
  expect(mocks.start).toHaveBeenCalledTimes(1);
693
599
  });
694
600
 
@@ -725,43 +631,37 @@ describe("invisible mode", () => {
725
631
  expect(onError).toHaveBeenCalledWith(expect.any(Error));
726
632
  });
727
633
 
728
- test("the listener is dropped when the widget unmounts", async () => {
634
+ test("the listener is dropped when the widget is destroyed", async () => {
729
635
  render(props({ config: config({ mode: ModeEnum.invisible }) }));
730
- act(() => {
731
- root.unmount();
732
- });
733
- act(() => {
734
- root = createRoot(container);
735
- });
636
+ destroy();
736
637
  await execute();
737
638
  expect(mocks.start).not.toHaveBeenCalled();
738
639
  });
739
640
  });
740
641
 
741
642
  describe("an invalidated session", () => {
742
- const invalidate = (
643
+ const invalidate = async (
743
644
  key = "CAPTCHA.NO_SESSION_FOUND",
744
645
  message = "session gone",
745
- ): void => {
746
- act(() => {
747
- mocks.constructions[0]?.updateState({ error: { message, key } });
748
- });
646
+ ): Promise<void> => {
647
+ mocks.constructions[0]?.updateState({ error: { message, key } });
648
+ await settle();
749
649
  };
750
650
 
751
651
  test("the error is shown on the checkbox and the puzzle is torn down", async () => {
752
652
  render(props());
753
653
  await click();
754
- invalidate("API.UNKNOWN_ERROR", "something broke");
755
- expect(mocks.checkboxProps.current?.error).toBe("something broke");
654
+ await invalidate("API.UNKNOWN_ERROR", "something broke");
655
+ expect(mounted.container.textContent).toContain("something broke");
756
656
  expect(canvas()).toBeNull();
757
- expect(mocks.checkboxProps.current?.loading).toBe(false);
657
+ expect(spinner()).toBeNull();
758
658
  });
759
659
 
760
660
  test("the host is told to re-mint, with the coordinates of the original click", async () => {
761
661
  const onSessionInvalidated = vi.fn<(x?: number, y?: number) => void>();
762
662
  render(props({ onSessionInvalidated }));
763
663
  await click({ clientX: 12, clientY: 34 });
764
- invalidate();
664
+ await invalidate();
765
665
  expect(onSessionInvalidated).toHaveBeenCalledWith(12, 34);
766
666
  });
767
667
 
@@ -769,52 +669,39 @@ describe("an invalidated session", () => {
769
669
  const onSessionInvalidated = vi.fn<(x?: number, y?: number) => void>();
770
670
  render(props({ onSessionInvalidated }));
771
671
  await click();
772
- invalidate("CAPTCHA.NO_SESSION_FOUND", "gone");
773
- invalidate("CAPTCHA.NO_SESSION_FOUND", "gone again");
672
+ await invalidate("CAPTCHA.NO_SESSION_FOUND", "gone");
673
+ await invalidate("CAPTCHA.NO_SESSION_FOUND", "gone again");
774
674
  expect(onSessionInvalidated).toHaveBeenCalledTimes(1);
775
675
  });
776
676
 
777
677
  test("with no host handler the frictionless session restarts instead", async () => {
778
- vi.useFakeTimers();
779
678
  const restart = vi.fn<() => void>();
780
679
  render(props({ frictionlessState: frictionless({ restart }) }));
781
- invalidate();
680
+ await invalidate();
782
681
  expect(restart).not.toHaveBeenCalled();
783
- await act(async () => {
784
- await vi.advanceTimersByTimeAsync(100);
785
- });
682
+ await new Promise<void>((resolve: () => void) => setTimeout(resolve, 150));
786
683
  expect(restart).toHaveBeenCalledTimes(1);
787
- vi.useRealTimers();
788
684
  });
789
685
 
790
- test("a restart pending at unmount is cancelled", async () => {
791
- vi.useFakeTimers();
686
+ test("a restart pending at destroy is cancelled", async () => {
792
687
  const restart = vi.fn<() => void>();
793
688
  render(props({ frictionlessState: frictionless({ restart }) }));
794
- invalidate();
795
- act(() => {
796
- root.unmount();
797
- });
798
- await act(async () => {
799
- await vi.advanceTimersByTimeAsync(200);
800
- });
689
+ await invalidate();
690
+ destroy();
691
+ await new Promise<void>((resolve: () => void) => setTimeout(resolve, 200));
801
692
  expect(restart).not.toHaveBeenCalled();
802
- act(() => {
803
- root = createRoot(container);
804
- });
805
- vi.useRealTimers();
806
693
  });
807
694
 
808
695
  test("a lost session with nothing to recover it is simply surfaced", async () => {
809
696
  render(props());
810
- invalidate();
811
- expect(mocks.checkboxProps.current?.error).toBe("session gone");
697
+ await invalidate();
698
+ expect(mounted.container.textContent).toContain("session gone");
812
699
  });
813
700
 
814
- test("no coordinates are reported when the session was never clicked into", () => {
701
+ test("no coordinates are reported when the session was never clicked into", async () => {
815
702
  const onSessionInvalidated = vi.fn<(x?: number, y?: number) => void>();
816
703
  render(props({ onSessionInvalidated }));
817
- invalidate();
704
+ await invalidate();
818
705
  expect(onSessionInvalidated).toHaveBeenCalledWith(undefined, undefined);
819
706
  });
820
707
  });