@prosopo/procaptcha-bundle 4.2.5 → 4.4.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 (59) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +16 -15
  2. package/.turbo/turbo-build$colon$tsc.log +26 -26
  3. package/.turbo/turbo-build.log +16 -15
  4. package/CHANGELOG.md +85 -0
  5. package/dist/cjs/index.cjs +101 -21
  6. package/dist/cjs/util/captcha/captchaRenderer.cjs +20 -1
  7. package/dist/cjs/util/configCreator.cjs +4 -2
  8. package/dist/cjs/util/startMode.cjs +20 -0
  9. package/dist/cjs/util/widgetFactory.cjs +8 -5
  10. package/dist/index.d.ts +4 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +101 -22
  13. package/dist/index.js.map +1 -1
  14. package/dist/tests/bind.unit.test.d.ts +2 -0
  15. package/dist/tests/bind.unit.test.d.ts.map +1 -0
  16. package/dist/tests/bind.unit.test.js +166 -0
  17. package/dist/tests/bind.unit.test.js.map +1 -0
  18. package/dist/tests/detectorPrefetch.unit.test.js.map +1 -1
  19. package/dist/tests/procaptchaReady.unit.test.js +1 -0
  20. package/dist/tests/procaptchaReady.unit.test.js.map +1 -1
  21. package/dist/tests/reset.unit.test.js +6 -2
  22. package/dist/tests/reset.unit.test.js.map +1 -1
  23. package/dist/tests/start.unit.test.d.ts +2 -0
  24. package/dist/tests/start.unit.test.d.ts.map +1 -0
  25. package/dist/tests/start.unit.test.js +103 -0
  26. package/dist/tests/start.unit.test.js.map +1 -0
  27. package/dist/tests/util/startMode.test.d.ts +2 -0
  28. package/dist/tests/util/startMode.test.d.ts.map +1 -0
  29. package/dist/tests/util/startMode.test.js +48 -0
  30. package/dist/tests/util/startMode.test.js.map +1 -0
  31. package/dist/util/captcha/captchaRenderer.d.ts +1 -1
  32. package/dist/util/captcha/captchaRenderer.d.ts.map +1 -1
  33. package/dist/util/captcha/captchaRenderer.js +20 -1
  34. package/dist/util/captcha/captchaRenderer.js.map +1 -1
  35. package/dist/util/configCreator.d.ts +14 -2
  36. package/dist/util/configCreator.d.ts.map +1 -1
  37. package/dist/util/configCreator.js +5 -3
  38. package/dist/util/configCreator.js.map +1 -1
  39. package/dist/util/startMode.d.ts +5 -0
  40. package/dist/util/startMode.d.ts.map +1 -0
  41. package/dist/util/startMode.js +18 -0
  42. package/dist/util/startMode.js.map +1 -0
  43. package/dist/util/widgetFactory.d.ts +7 -2
  44. package/dist/util/widgetFactory.d.ts.map +1 -1
  45. package/dist/util/widgetFactory.js +8 -5
  46. package/dist/util/widgetFactory.js.map +1 -1
  47. package/package.json +12 -12
  48. package/src/index.ts +145 -28
  49. package/src/tests/bind.unit.test.ts +227 -0
  50. package/src/tests/detectorPrefetch.unit.test.ts +2 -2
  51. package/src/tests/procaptchaReady.unit.test.ts +1 -0
  52. package/src/tests/reset.unit.test.ts +9 -3
  53. package/src/tests/start.unit.test.ts +155 -0
  54. package/src/tests/util/startMode.test.ts +113 -0
  55. package/src/util/captcha/captchaRenderer.tsx +33 -13
  56. package/src/util/configCreator.ts +29 -11
  57. package/src/util/startMode.ts +53 -0
  58. package/src/util/widgetFactory.ts +10 -3
  59. package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts CHANGED
@@ -13,11 +13,18 @@
13
13
  // limitations under the License.
14
14
 
15
15
  import { getWindowCallback } from "@prosopo/procaptcha-common";
16
- import type { EnvironmentTypes, ProcaptchaRenderOptions } from "@prosopo/types";
16
+ import {
17
+ type EnvironmentTypes,
18
+ PROCAPTCHA_START_EVENT,
19
+ type ProcaptchaRenderOptions,
20
+ type ProcaptchaStartEventDetail,
21
+ StartModeEnum,
22
+ } from "@prosopo/types";
17
23
  import { at } from "@prosopo/util";
18
24
  import type { Root } from "react-dom/client";
19
25
  import { extractParams, getProcaptchaScript } from "./util/config.js";
20
- import { WidgetFactory } from "./util/widgetFactory.js";
26
+ import { resolveStartMode } from "./util/startMode.js";
27
+ import { type CreatedWidget, WidgetFactory } from "./util/widgetFactory.js";
21
28
  import { WidgetThemeResolver } from "./util/widgetThemeResolver.js";
22
29
 
23
30
  const BUNDLE_NAMES = ["procaptcha.bundle.iife.js", "procaptcha.bundle.js"];
@@ -36,9 +43,12 @@ const BUNDLE_NAMES = ["procaptcha.bundle.iife.js", "procaptcha.bundle.js"];
36
43
  interface WidgetEntry {
37
44
  root: Root;
38
45
  element: Element;
46
+ /** The element the widget listens on; a targeted execute() is dispatched here. */
47
+ target: HTMLElement;
39
48
  renderOptions: ProcaptchaRenderOptions;
40
49
  isWeb2: boolean;
41
50
  invisible: boolean;
51
+ unbindTrigger?: () => void;
42
52
  }
43
53
 
44
54
  const procaptchaWidgets = new Map<string, WidgetEntry>();
@@ -47,17 +57,18 @@ let widgetIdCounter = 0;
47
57
  const nextWidgetId = (): string => `procaptcha-widget-${widgetIdCounter++}`;
48
58
 
49
59
  const registerWidgets = (
50
- roots: Root[],
60
+ widgets: CreatedWidget[],
51
61
  elements: Element[],
52
62
  renderOptions: ProcaptchaRenderOptions,
53
63
  isWeb2: boolean,
54
64
  invisible: boolean,
55
65
  ): string[] =>
56
- roots.map((root, index) => {
66
+ widgets.map(({ root, container }, index) => {
57
67
  const id = nextWidgetId();
58
68
  procaptchaWidgets.set(id, {
59
69
  root,
60
70
  element: at(elements, index),
71
+ target: container,
61
72
  renderOptions,
62
73
  isWeb2,
63
74
  invisible,
@@ -80,9 +91,10 @@ const widgetFactory = new WidgetFactory(new WidgetThemeResolver());
80
91
  */
81
92
  const startDetectorPrefetch = (
82
93
  siteKey: string | null,
83
- flags: { ipv4?: boolean; ipv6?: boolean },
94
+ flags: { ipv4?: boolean; ipv6?: boolean; startMode?: StartModeEnum },
84
95
  ): void => {
85
96
  if (!siteKey) return;
97
+ if (flags.startMode === StartModeEnum.manual) return;
86
98
  void Promise.all([
87
99
  import("@prosopo/procaptcha-frictionless"),
88
100
  import("@prosopo/procaptcha-common"),
@@ -117,6 +129,7 @@ const implicitRender = async () => {
117
129
  const web3 = firstElement.getAttribute("data-web3");
118
130
  const ipv4 = firstElement.getAttribute("data-ipv4") === "true";
119
131
  const ipv6 = firstElement.getAttribute("data-ipv6") === "true";
132
+ const startMode = resolveStartMode(undefined, firstElement);
120
133
  if (!siteKey) {
121
134
  console.error("No site key found");
122
135
  return;
@@ -127,27 +140,35 @@ const implicitRender = async () => {
127
140
  // constant. Start it now so the round-trip overlaps the widget's own
128
141
  // dynamic-import chain instead of queueing behind it —
129
142
  // `customDetectBot` claims the in-flight promise when it eventually runs.
130
- startDetectorPrefetch(siteKey, { ipv4, ipv6 });
143
+ startDetectorPrefetch(siteKey, { ipv4, ipv6, startMode });
131
144
 
132
145
  const implicitRenderOptions: ProcaptchaRenderOptions = {
133
146
  siteKey: siteKey,
134
147
  ipv4,
135
148
  ipv6,
149
+ startMode,
136
150
  };
137
151
 
138
- const root = await widgetFactory.createWidgets(
152
+ const widgets = await widgetFactory.createWidgets(
139
153
  elements,
140
154
  implicitRenderOptions,
141
155
  !(web3 === "true"),
142
156
  );
143
157
 
144
- registerWidgets(
145
- root,
158
+ const ids = registerWidgets(
159
+ widgets,
146
160
  elements,
147
161
  implicitRenderOptions,
148
162
  !(web3 === "true"),
149
163
  false,
150
164
  );
165
+
166
+ // `data-placement` is read per element by the renderer; `data-bind` is
167
+ // wired here because the trigger lives outside the widget.
168
+ ids.forEach((id, index) => {
169
+ const selector = at(elements, index).getAttribute("data-bind");
170
+ if (selector) bindTrigger(id, selector);
171
+ });
151
172
  }
152
173
 
153
174
  // Check for invisible mode indicators (procaptcha class on buttons)
@@ -161,29 +182,37 @@ const implicitRender = async () => {
161
182
  const callback = button.getAttribute("data-callback") || "";
162
183
  const ipv4 = button.getAttribute("data-ipv4") === "true";
163
184
  const ipv6 = button.getAttribute("data-ipv6") === "true";
185
+ const startMode = resolveStartMode(undefined, button);
164
186
 
165
- startDetectorPrefetch(siteKey, { ipv4, ipv6 });
187
+ startDetectorPrefetch(siteKey, { ipv4, ipv6, startMode });
166
188
 
167
189
  const buttonRenderOptions: ProcaptchaRenderOptions = {
168
190
  siteKey: siteKey,
169
191
  callback: callback,
170
192
  ipv4,
171
193
  ipv6,
194
+ startMode,
172
195
  };
173
196
 
174
- const root = await widgetFactory.createWidgets(
197
+ const widgets = await widgetFactory.createWidgets(
175
198
  [button],
176
199
  buttonRenderOptions,
177
200
  true,
178
201
  true,
179
202
  );
180
203
 
181
- registerWidgets(root, [button], buttonRenderOptions, true, true);
204
+ const [id] = registerWidgets(
205
+ widgets,
206
+ [button],
207
+ buttonRenderOptions,
208
+ true,
209
+ true,
210
+ );
182
211
 
183
212
  // Add click event listener to the button
184
213
  button.addEventListener("click", async (event) => {
185
214
  event.preventDefault();
186
- execute();
215
+ execute(id);
187
216
  });
188
217
  }
189
218
  }
@@ -203,7 +232,10 @@ export const render = async (
203
232
  element: Element,
204
233
  renderOptions: ProcaptchaRenderOptions,
205
234
  ): Promise<string | undefined> => {
206
- startDetectorPrefetch(renderOptions.siteKey, renderOptions);
235
+ startDetectorPrefetch(renderOptions.siteKey, {
236
+ ...renderOptions,
237
+ startMode: resolveStartMode(renderOptions, element),
238
+ });
207
239
 
208
240
  const hasInvisibleSize =
209
241
  Object.prototype.hasOwnProperty.call(renderOptions, "size") &&
@@ -213,7 +245,7 @@ export const render = async (
213
245
  const invisible =
214
246
  hasInvisibleSize || element.tagName.toLowerCase() === "button";
215
247
 
216
- const roots = await widgetFactory.createWidgets(
248
+ const widgets = await widgetFactory.createWidgets(
217
249
  [element],
218
250
  renderOptions,
219
251
  isWeb2,
@@ -221,7 +253,7 @@ export const render = async (
221
253
  );
222
254
 
223
255
  const ids = registerWidgets(
224
- roots,
256
+ widgets,
225
257
  [element],
226
258
  renderOptions,
227
259
  isWeb2,
@@ -230,7 +262,10 @@ export const render = async (
230
262
 
231
263
  // Deliberately not `at()`: it throws on an empty array before it consults
232
264
  // `optional`, and zero roots is a legitimate outcome here.
233
- return ids[0];
265
+ const id = ids[0];
266
+ if (id && renderOptions.bind) bindTrigger(id, renderOptions.bind);
267
+
268
+ return id;
234
269
  };
235
270
 
236
271
  export default function ready(fn: () => void) {
@@ -247,8 +282,21 @@ export default function ready(fn: () => void) {
247
282
  }
248
283
  }
249
284
 
250
- export const execute = () => {
251
- const containers = findProcaptchaContainers();
285
+ /**
286
+ * Starts verification. With no id the event goes to `document` and every
287
+ * widget responds; with an id (as returned by `render()`) only that widget
288
+ * runs.
289
+ */
290
+ export const execute = (widgetId?: string) => {
291
+ const targeted =
292
+ undefined === widgetId ? undefined : procaptchaWidgets.get(widgetId);
293
+
294
+ if (undefined !== widgetId && !targeted) {
295
+ console.error(`No Procaptcha widget found with id ${widgetId}`);
296
+ return;
297
+ }
298
+
299
+ const containers = targeted ? [targeted.element] : findProcaptchaContainers();
252
300
 
253
301
  if (containers.length === 0) {
254
302
  console.error("No Procaptcha containers found for execution");
@@ -262,14 +310,77 @@ export const execute = () => {
262
310
  containerCount: containers.length,
263
311
  timestamp: Date.now(),
264
312
  },
265
- bubbles: true,
313
+ // A targeted event must not bubble to document, where every widget listens.
314
+ bubbles: !targeted,
266
315
  cancelable: true,
267
316
  });
268
317
 
318
+ if (targeted) {
319
+ targeted.target.dispatchEvent(executeEvent);
320
+ return;
321
+ }
322
+
269
323
  // Dispatch the event on the document
270
324
  document.dispatchEvent(executeEvent);
271
325
  };
272
326
 
327
+ /**
328
+ * The click's default is prevented so a submit button does not post the form
329
+ * before a token exists.
330
+ */
331
+ const bindTrigger = (widgetId: string, selector: string): void => {
332
+ const entry = procaptchaWidgets.get(widgetId);
333
+ if (!entry) {
334
+ console.error(`No Procaptcha widget found with id ${widgetId}`);
335
+ return;
336
+ }
337
+
338
+ const trigger = document.querySelector(selector);
339
+ if (!trigger) {
340
+ console.error(`Procaptcha: no element matches bind selector ${selector}`);
341
+ return;
342
+ }
343
+
344
+ // Binding the same widget twice would otherwise stack listeners and leave
345
+ // the earlier one behind, since only the last unbind is remembered.
346
+ entry.unbindTrigger?.();
347
+
348
+ const onClick = (event: Event) => {
349
+ event.preventDefault();
350
+ execute(widgetId);
351
+ };
352
+
353
+ trigger.addEventListener("click", onClick);
354
+ entry.unbindTrigger = () => trigger.removeEventListener("click", onClick);
355
+ };
356
+
357
+ /** Starts a `startMode: "manual"` widget; omit the id to start all of them. */
358
+ export const start = (widgetId?: string): void => {
359
+ const ids: string[] =
360
+ undefined === widgetId ? Array.from(procaptchaWidgets.keys()) : [widgetId];
361
+
362
+ if (ids.length === 0) {
363
+ console.error("No Procaptcha widgets found to start");
364
+ return;
365
+ }
366
+
367
+ for (const id of ids) {
368
+ const entry = procaptchaWidgets.get(id);
369
+ if (!entry) {
370
+ console.error(`No Procaptcha widget found with id ${id}`);
371
+ continue;
372
+ }
373
+ const detail: ProcaptchaStartEventDetail = { element: entry.element };
374
+ document.dispatchEvent(
375
+ new CustomEvent<ProcaptchaStartEventDetail>(PROCAPTCHA_START_EVENT, {
376
+ detail,
377
+ bubbles: true,
378
+ cancelable: false,
379
+ }),
380
+ );
381
+ }
382
+ };
383
+
273
384
  function findProcaptchaContainers(): Element[] {
274
385
  const containers: Element[] = [];
275
386
 
@@ -310,11 +421,12 @@ declare global {
310
421
  reset: typeof reset;
311
422
  remove: typeof remove;
312
423
  execute: typeof execute;
424
+ start: typeof start;
313
425
  };
314
426
  }
315
427
  }
316
428
 
317
- const start = () => {
429
+ const boot = () => {
318
430
  // onLoadUrlCallback defines the name of the callback function to be called when the script is loaded
319
431
  // onRenderExplicit takes values of either explicit or implicit
320
432
  const { onloadUrlCallback, renderExplicit } = extractParams(BUNDLE_NAMES);
@@ -352,7 +464,7 @@ const start = () => {
352
464
  * widget id to reset one widget, or omit it to reset every widget on the page.
353
465
  *
354
466
  * This remounts rather than merely unmounting. The previous implementation
355
- * unmounted every root and then called `start()`, which only re-renders when
467
+ * unmounted every root and then called `boot()`, which only re-renders when
356
468
  * the page uses implicit rendering — and even then only via the
357
469
  * `document.readyState` fallback, because the script's `load` event has long
358
470
  * since fired. On an explicitly-rendered page nothing came back at all: the
@@ -360,7 +472,7 @@ const start = () => {
360
472
  * request was ever made. Rebuilding from the stored descriptor makes reset
361
473
  * behave the same way on both paths.
362
474
  *
363
- * `start()` is deliberately not called here any more. It re-renders implicit
475
+ * `boot()` is deliberately not called here any more. It re-renders implicit
364
476
  * widgets, which would double up with the remount below, and it attaches
365
477
  * another `load` listener to the script tag on every call.
366
478
  */
@@ -374,15 +486,19 @@ export const reset = async (widgetId?: string): Promise<void> => {
374
486
 
375
487
  current.root.unmount();
376
488
 
377
- const [root] = await widgetFactory.createWidgets(
489
+ const [widget] = await widgetFactory.createWidgets(
378
490
  [current.element],
379
491
  current.renderOptions,
380
492
  current.isWeb2,
381
493
  current.invisible,
382
494
  );
383
495
 
384
- if (root) {
385
- procaptchaWidgets.set(id, { ...current, root });
496
+ if (widget) {
497
+ procaptchaWidgets.set(id, {
498
+ ...current,
499
+ root: widget.root,
500
+ target: widget.container,
501
+ });
386
502
  } else {
387
503
  procaptchaWidgets.delete(id);
388
504
  }
@@ -402,6 +518,7 @@ export const remove = (widgetId?: string): void => {
402
518
  for (const id of ids) {
403
519
  const entry = procaptchaWidgets.get(id);
404
520
  if (!entry) continue;
521
+ entry.unbindTrigger?.();
405
522
  entry.root.unmount();
406
523
  entry.element.innerHTML = "";
407
524
  procaptchaWidgets.delete(id);
@@ -409,7 +526,7 @@ export const remove = (widgetId?: string): void => {
409
526
  };
410
527
 
411
528
  // set the procaptcha attribute on the window
412
- window.procaptcha = { ready, render, reset, remove, execute };
529
+ window.procaptcha = { ready, render, reset, remove, execute, start };
413
530
 
414
531
  // Dispatch a custom event to notify that window.procaptcha is ready
415
532
  const procaptchaReadyEvent = new CustomEvent(PROCAPTCHA_READY_EVENT, {
@@ -421,4 +538,4 @@ const procaptchaReadyEvent = new CustomEvent(PROCAPTCHA_READY_EVENT, {
421
538
  });
422
539
  document.dispatchEvent(procaptchaReadyEvent);
423
540
 
424
- start();
541
+ boot();
@@ -0,0 +1,227 @@
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 { Root } from "react-dom/client";
16
+ import { beforeEach, describe, expect, it, vi } from "vitest";
17
+ import type { CreatedWidget } from "../util/widgetFactory.js";
18
+
19
+ const mocks = vi.hoisted(() => ({
20
+ prefetchDetector: vi.fn(),
21
+ createWidgets: vi.fn(),
22
+ }));
23
+
24
+ vi.mock("@prosopo/procaptcha-frictionless", () => ({
25
+ prefetchDetector: mocks.prefetchDetector,
26
+ }));
27
+
28
+ vi.mock("@prosopo/procaptcha-common", () => ({
29
+ getWindowCallback: vi.fn(),
30
+ pickIpMode: vi.fn(() => undefined),
31
+ }));
32
+
33
+ vi.mock("../util/widgetFactory.js", () => ({
34
+ WidgetFactory: vi.fn(function () {
35
+ return { createWidgets: mocks.createWidgets };
36
+ }),
37
+ }));
38
+
39
+ const { render, remove, execute } = await import("../index.js");
40
+
41
+ const SITE_KEY = "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP";
42
+ const EXECUTE_EVENT = "procaptcha:execute";
43
+
44
+ const makeRoot = (): Root =>
45
+ ({ unmount: vi.fn(), render: vi.fn() }) as unknown as Root;
46
+
47
+ // Like the real factory, the widget lives in a child of the host element and
48
+ // listens there, so a targeted event must be dispatched on that child.
49
+ const createWidgetsLikeTheFactory = async (
50
+ hosts: Element[],
51
+ ): Promise<CreatedWidget[]> =>
52
+ hosts.map((host) => {
53
+ const container = document.createElement("div");
54
+ host.appendChild(container);
55
+ return { root: makeRoot(), container };
56
+ });
57
+
58
+ const listenLikeAWidget = (
59
+ host: Element,
60
+ ): { calls: () => number; stop: () => void } => {
61
+ const handler = vi.fn();
62
+ const target = host.firstElementChild;
63
+ if (!target) throw new Error("expected the factory to mount a container");
64
+ document.addEventListener(EXECUTE_EVENT, handler);
65
+ target.addEventListener(EXECUTE_EVENT, handler);
66
+ return {
67
+ calls: () => handler.mock.calls.length,
68
+ stop: () => {
69
+ document.removeEventListener(EXECUTE_EVENT, handler);
70
+ target.removeEventListener(EXECUTE_EVENT, handler);
71
+ },
72
+ };
73
+ };
74
+
75
+ beforeEach(async () => {
76
+ vi.clearAllMocks();
77
+ await remove();
78
+ document.body.innerHTML = "";
79
+ mocks.createWidgets.mockImplementation(createWidgetsLikeTheFactory);
80
+ });
81
+
82
+ describe("execute targeting", () => {
83
+ it("reaches every widget when called with no id", async () => {
84
+ const first = document.createElement("div");
85
+ first.className = "p-procaptcha";
86
+ const second = document.createElement("div");
87
+ second.className = "p-procaptcha";
88
+ document.body.append(first, second);
89
+ await render(first, { siteKey: SITE_KEY });
90
+ await render(second, { siteKey: SITE_KEY });
91
+
92
+ const a = listenLikeAWidget(first);
93
+ const b = listenLikeAWidget(second);
94
+ execute();
95
+
96
+ expect(a.calls()).toBe(1);
97
+ expect(b.calls()).toBe(1);
98
+ a.stop();
99
+ b.stop();
100
+ });
101
+
102
+ it("reaches only the named widget when given an id", async () => {
103
+ const first = document.createElement("div");
104
+ const second = document.createElement("div");
105
+ document.body.append(first, second);
106
+ const firstId = await render(first, { siteKey: SITE_KEY });
107
+ await render(second, { siteKey: SITE_KEY });
108
+
109
+ const a = listenLikeAWidget(first);
110
+ const b = listenLikeAWidget(second);
111
+ execute(firstId);
112
+
113
+ expect(a.calls()).toBe(1);
114
+ expect(b.calls()).toBe(0);
115
+ a.stop();
116
+ b.stop();
117
+ });
118
+
119
+ it("does not bubble a targeted event up to document", async () => {
120
+ const element = document.createElement("div");
121
+ document.body.appendChild(element);
122
+ const widgetId = await render(element, { siteKey: SITE_KEY });
123
+
124
+ const documentOnly = vi.fn();
125
+ document.addEventListener(EXECUTE_EVENT, documentOnly);
126
+ execute(widgetId);
127
+
128
+ expect(documentOnly).not.toHaveBeenCalled();
129
+ document.removeEventListener(EXECUTE_EVENT, documentOnly);
130
+ });
131
+
132
+ it("reports an unknown id rather than firing every widget", async () => {
133
+ const element = document.createElement("div");
134
+ document.body.appendChild(element);
135
+ await render(element, { siteKey: SITE_KEY });
136
+
137
+ const error = vi
138
+ .spyOn(console, "error")
139
+ .mockImplementation(() => undefined);
140
+ const listener = listenLikeAWidget(element);
141
+ execute("procaptcha-widget-does-not-exist");
142
+
143
+ expect(listener.calls()).toBe(0);
144
+ expect(error).toHaveBeenCalled();
145
+ listener.stop();
146
+ error.mockRestore();
147
+ });
148
+ });
149
+
150
+ describe("bind", () => {
151
+ it("runs only the bound widget when its button is clicked", async () => {
152
+ const button = document.createElement("button");
153
+ button.id = "pay";
154
+ const bound = document.createElement("div");
155
+ const other = document.createElement("div");
156
+ document.body.append(button, bound, other);
157
+
158
+ await render(bound, { siteKey: SITE_KEY, bind: "#pay" });
159
+ await render(other, { siteKey: SITE_KEY });
160
+
161
+ const a = listenLikeAWidget(bound);
162
+ const b = listenLikeAWidget(other);
163
+ button.click();
164
+
165
+ expect(a.calls()).toBe(1);
166
+ expect(b.calls()).toBe(0);
167
+ a.stop();
168
+ b.stop();
169
+ });
170
+
171
+ it("stops the button's default action", async () => {
172
+ const form = document.createElement("form");
173
+ const button = document.createElement("button");
174
+ button.id = "submit-it";
175
+ button.type = "submit";
176
+ form.appendChild(button);
177
+ const element = document.createElement("div");
178
+ document.body.append(form, element);
179
+
180
+ await render(element, { siteKey: SITE_KEY, bind: "#submit-it" });
181
+
182
+ const event = new MouseEvent("click", {
183
+ bubbles: true,
184
+ cancelable: true,
185
+ });
186
+ button.dispatchEvent(event);
187
+
188
+ expect(event.defaultPrevented).toBe(true);
189
+ });
190
+
191
+ it("detaches the listener when the widget is removed", async () => {
192
+ const button = document.createElement("button");
193
+ button.id = "gone";
194
+ const element = document.createElement("div");
195
+ document.body.append(button, element);
196
+
197
+ await render(element, { siteKey: SITE_KEY, bind: "#gone" });
198
+ await remove();
199
+
200
+ const error = vi
201
+ .spyOn(console, "error")
202
+ .mockImplementation(() => undefined);
203
+ button.click();
204
+
205
+ expect(error).not.toHaveBeenCalled();
206
+ error.mockRestore();
207
+ });
208
+
209
+ it("reports a selector that matches nothing and still renders", async () => {
210
+ const error = vi
211
+ .spyOn(console, "error")
212
+ .mockImplementation(() => undefined);
213
+ const element = document.createElement("div");
214
+ document.body.appendChild(element);
215
+
216
+ const widgetId = await render(element, {
217
+ siteKey: SITE_KEY,
218
+ bind: "#nothing-here",
219
+ });
220
+
221
+ expect(widgetId).toBeTruthy();
222
+ expect(error).toHaveBeenCalledWith(
223
+ expect.stringContaining("#nothing-here"),
224
+ );
225
+ error.mockRestore();
226
+ });
227
+ });
@@ -19,8 +19,8 @@
19
19
  * to assigning a detector inline on the widget's critical path.
20
20
  */
21
21
 
22
- import type { Root } from "react-dom/client";
23
22
  import { beforeEach, describe, expect, it, vi } from "vitest";
23
+ import type { CreatedWidget } from "../util/widgetFactory.js";
24
24
 
25
25
  const mocks = vi.hoisted(() => ({
26
26
  prefetchDetector: vi.fn(),
@@ -59,7 +59,7 @@ const renderAndFlush = async (
59
59
 
60
60
  beforeEach(() => {
61
61
  vi.clearAllMocks();
62
- mocks.createWidgets.mockResolvedValue([] as Root[]);
62
+ mocks.createWidgets.mockResolvedValue([] as CreatedWidget[]);
63
63
  });
64
64
 
65
65
  describe("render", () => {
@@ -50,6 +50,7 @@ describe("procaptcha:ready event", () => {
50
50
  expect(window.procaptcha?.ready).toBeDefined();
51
51
  expect(window.procaptcha?.reset).toBeDefined();
52
52
  expect(window.procaptcha?.execute).toBeDefined();
53
+ expect(window.procaptcha?.start).toBeDefined();
53
54
  });
54
55
 
55
56
  // Import the module which should set window.procaptcha and dispatch the event
@@ -22,6 +22,7 @@
22
22
 
23
23
  import type { Root } from "react-dom/client";
24
24
  import { beforeEach, describe, expect, it, vi } from "vitest";
25
+ import type { CreatedWidget } from "../util/widgetFactory.js";
25
26
 
26
27
  const mocks = vi.hoisted(() => ({
27
28
  prefetchDetector: vi.fn(),
@@ -50,10 +51,15 @@ const SITE_KEY = "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP";
50
51
  const makeRoot = (): Root =>
51
52
  ({ unmount: vi.fn(), render: vi.fn() }) as unknown as Root;
52
53
 
54
+ const makeWidget = (root: Root = makeRoot()): CreatedWidget => ({
55
+ root,
56
+ container: document.createElement("div"),
57
+ });
58
+
53
59
  /** Each createWidgets call yields a distinct root, as the real factory does. */
54
60
  const queueRoots = (...roots: Root[]): void => {
55
61
  for (const root of roots) {
56
- mocks.createWidgets.mockResolvedValueOnce([root]);
62
+ mocks.createWidgets.mockResolvedValueOnce([makeWidget(root)]);
57
63
  }
58
64
  };
59
65
 
@@ -62,7 +68,7 @@ beforeEach(async () => {
62
68
  // Drop any widgets registered by a previous test — module state persists
63
69
  // across tests in the same file.
64
70
  await remove();
65
- mocks.createWidgets.mockResolvedValue([makeRoot()]);
71
+ mocks.createWidgets.mockResolvedValue([makeWidget()]);
66
72
  });
67
73
 
68
74
  describe("render", () => {
@@ -78,7 +84,7 @@ describe("render", () => {
78
84
  });
79
85
 
80
86
  it("returns undefined when the factory creates no widget", async () => {
81
- mocks.createWidgets.mockResolvedValueOnce([] as Root[]);
87
+ mocks.createWidgets.mockResolvedValueOnce([] as CreatedWidget[]);
82
88
 
83
89
  const widgetId = await render(document.createElement("div"), {
84
90
  siteKey: SITE_KEY,