@prosopo/procaptcha-bundle 4.3.0 → 4.4.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.
- package/.turbo/turbo-build$colon$cjs.log +6 -6
- package/.turbo/turbo-build$colon$tsc.log +26 -26
- package/.turbo/turbo-build.log +15 -15
- package/CHANGELOG.md +33 -0
- package/dist/cjs/index.cjs +56 -11
- package/dist/cjs/util/captcha/captchaRenderer.cjs +18 -1
- package/dist/cjs/util/configCreator.cjs +4 -2
- package/dist/cjs/util/widgetFactory.cjs +8 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +56 -11
- package/dist/index.js.map +1 -1
- package/dist/tests/bind.unit.test.d.ts +2 -0
- package/dist/tests/bind.unit.test.d.ts.map +1 -0
- package/dist/tests/bind.unit.test.js +166 -0
- package/dist/tests/bind.unit.test.js.map +1 -0
- package/dist/tests/detectorPrefetch.unit.test.js.map +1 -1
- package/dist/tests/reset.unit.test.js +6 -2
- package/dist/tests/reset.unit.test.js.map +1 -1
- package/dist/tests/start.unit.test.js +6 -3
- package/dist/tests/start.unit.test.js.map +1 -1
- package/dist/util/captcha/captchaRenderer.d.ts +1 -1
- package/dist/util/captcha/captchaRenderer.d.ts.map +1 -1
- package/dist/util/captcha/captchaRenderer.js +18 -1
- package/dist/util/captcha/captchaRenderer.js.map +1 -1
- package/dist/util/configCreator.d.ts +14 -2
- package/dist/util/configCreator.d.ts.map +1 -1
- package/dist/util/configCreator.js +5 -3
- package/dist/util/configCreator.js.map +1 -1
- package/dist/util/widgetFactory.d.ts +7 -2
- package/dist/util/widgetFactory.d.ts.map +1 -1
- package/dist/util/widgetFactory.js +8 -5
- package/dist/util/widgetFactory.js.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +92 -18
- package/src/tests/bind.unit.test.ts +227 -0
- package/src/tests/detectorPrefetch.unit.test.ts +2 -2
- package/src/tests/reset.unit.test.ts +9 -3
- package/src/tests/start.unit.test.ts +7 -4
- package/src/util/captcha/captchaRenderer.tsx +31 -13
- package/src/util/configCreator.ts +29 -11
- package/src/util/widgetFactory.ts +10 -3
- package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { at } from "@prosopo/util";
|
|
|
24
24
|
import type { Root } from "react-dom/client";
|
|
25
25
|
import { extractParams, getProcaptchaScript } from "./util/config.js";
|
|
26
26
|
import { resolveStartMode } from "./util/startMode.js";
|
|
27
|
-
import { WidgetFactory } from "./util/widgetFactory.js";
|
|
27
|
+
import { type CreatedWidget, WidgetFactory } from "./util/widgetFactory.js";
|
|
28
28
|
import { WidgetThemeResolver } from "./util/widgetThemeResolver.js";
|
|
29
29
|
|
|
30
30
|
const BUNDLE_NAMES = ["procaptcha.bundle.iife.js", "procaptcha.bundle.js"];
|
|
@@ -43,9 +43,12 @@ const BUNDLE_NAMES = ["procaptcha.bundle.iife.js", "procaptcha.bundle.js"];
|
|
|
43
43
|
interface WidgetEntry {
|
|
44
44
|
root: Root;
|
|
45
45
|
element: Element;
|
|
46
|
+
/** The element the widget listens on; a targeted execute() is dispatched here. */
|
|
47
|
+
target: HTMLElement;
|
|
46
48
|
renderOptions: ProcaptchaRenderOptions;
|
|
47
49
|
isWeb2: boolean;
|
|
48
50
|
invisible: boolean;
|
|
51
|
+
unbindTrigger?: () => void;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
const procaptchaWidgets = new Map<string, WidgetEntry>();
|
|
@@ -54,17 +57,18 @@ let widgetIdCounter = 0;
|
|
|
54
57
|
const nextWidgetId = (): string => `procaptcha-widget-${widgetIdCounter++}`;
|
|
55
58
|
|
|
56
59
|
const registerWidgets = (
|
|
57
|
-
|
|
60
|
+
widgets: CreatedWidget[],
|
|
58
61
|
elements: Element[],
|
|
59
62
|
renderOptions: ProcaptchaRenderOptions,
|
|
60
63
|
isWeb2: boolean,
|
|
61
64
|
invisible: boolean,
|
|
62
65
|
): string[] =>
|
|
63
|
-
|
|
66
|
+
widgets.map(({ root, container }, index) => {
|
|
64
67
|
const id = nextWidgetId();
|
|
65
68
|
procaptchaWidgets.set(id, {
|
|
66
69
|
root,
|
|
67
70
|
element: at(elements, index),
|
|
71
|
+
target: container,
|
|
68
72
|
renderOptions,
|
|
69
73
|
isWeb2,
|
|
70
74
|
invisible,
|
|
@@ -145,19 +149,26 @@ const implicitRender = async () => {
|
|
|
145
149
|
startMode,
|
|
146
150
|
};
|
|
147
151
|
|
|
148
|
-
const
|
|
152
|
+
const widgets = await widgetFactory.createWidgets(
|
|
149
153
|
elements,
|
|
150
154
|
implicitRenderOptions,
|
|
151
155
|
!(web3 === "true"),
|
|
152
156
|
);
|
|
153
157
|
|
|
154
|
-
registerWidgets(
|
|
155
|
-
|
|
158
|
+
const ids = registerWidgets(
|
|
159
|
+
widgets,
|
|
156
160
|
elements,
|
|
157
161
|
implicitRenderOptions,
|
|
158
162
|
!(web3 === "true"),
|
|
159
163
|
false,
|
|
160
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
|
+
});
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
// Check for invisible mode indicators (procaptcha class on buttons)
|
|
@@ -183,19 +194,25 @@ const implicitRender = async () => {
|
|
|
183
194
|
startMode,
|
|
184
195
|
};
|
|
185
196
|
|
|
186
|
-
const
|
|
197
|
+
const widgets = await widgetFactory.createWidgets(
|
|
187
198
|
[button],
|
|
188
199
|
buttonRenderOptions,
|
|
189
200
|
true,
|
|
190
201
|
true,
|
|
191
202
|
);
|
|
192
203
|
|
|
193
|
-
|
|
204
|
+
const [id] = registerWidgets(
|
|
205
|
+
widgets,
|
|
206
|
+
[button],
|
|
207
|
+
buttonRenderOptions,
|
|
208
|
+
true,
|
|
209
|
+
true,
|
|
210
|
+
);
|
|
194
211
|
|
|
195
212
|
// Add click event listener to the button
|
|
196
213
|
button.addEventListener("click", async (event) => {
|
|
197
214
|
event.preventDefault();
|
|
198
|
-
execute();
|
|
215
|
+
execute(id);
|
|
199
216
|
});
|
|
200
217
|
}
|
|
201
218
|
}
|
|
@@ -228,7 +245,7 @@ export const render = async (
|
|
|
228
245
|
const invisible =
|
|
229
246
|
hasInvisibleSize || element.tagName.toLowerCase() === "button";
|
|
230
247
|
|
|
231
|
-
const
|
|
248
|
+
const widgets = await widgetFactory.createWidgets(
|
|
232
249
|
[element],
|
|
233
250
|
renderOptions,
|
|
234
251
|
isWeb2,
|
|
@@ -236,7 +253,7 @@ export const render = async (
|
|
|
236
253
|
);
|
|
237
254
|
|
|
238
255
|
const ids = registerWidgets(
|
|
239
|
-
|
|
256
|
+
widgets,
|
|
240
257
|
[element],
|
|
241
258
|
renderOptions,
|
|
242
259
|
isWeb2,
|
|
@@ -245,7 +262,10 @@ export const render = async (
|
|
|
245
262
|
|
|
246
263
|
// Deliberately not `at()`: it throws on an empty array before it consults
|
|
247
264
|
// `optional`, and zero roots is a legitimate outcome here.
|
|
248
|
-
|
|
265
|
+
const id = ids[0];
|
|
266
|
+
if (id && renderOptions.bind) bindTrigger(id, renderOptions.bind);
|
|
267
|
+
|
|
268
|
+
return id;
|
|
249
269
|
};
|
|
250
270
|
|
|
251
271
|
export default function ready(fn: () => void) {
|
|
@@ -262,8 +282,21 @@ export default function ready(fn: () => void) {
|
|
|
262
282
|
}
|
|
263
283
|
}
|
|
264
284
|
|
|
265
|
-
|
|
266
|
-
|
|
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();
|
|
267
300
|
|
|
268
301
|
if (containers.length === 0) {
|
|
269
302
|
console.error("No Procaptcha containers found for execution");
|
|
@@ -277,14 +310,50 @@ export const execute = () => {
|
|
|
277
310
|
containerCount: containers.length,
|
|
278
311
|
timestamp: Date.now(),
|
|
279
312
|
},
|
|
280
|
-
|
|
313
|
+
// A targeted event must not bubble to document, where every widget listens.
|
|
314
|
+
bubbles: !targeted,
|
|
281
315
|
cancelable: true,
|
|
282
316
|
});
|
|
283
317
|
|
|
318
|
+
if (targeted) {
|
|
319
|
+
targeted.target.dispatchEvent(executeEvent);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
284
323
|
// Dispatch the event on the document
|
|
285
324
|
document.dispatchEvent(executeEvent);
|
|
286
325
|
};
|
|
287
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
|
+
|
|
288
357
|
/** Starts a `startMode: "manual"` widget; omit the id to start all of them. */
|
|
289
358
|
export const start = (widgetId?: string): void => {
|
|
290
359
|
const ids: string[] =
|
|
@@ -417,15 +486,19 @@ export const reset = async (widgetId?: string): Promise<void> => {
|
|
|
417
486
|
|
|
418
487
|
current.root.unmount();
|
|
419
488
|
|
|
420
|
-
const [
|
|
489
|
+
const [widget] = await widgetFactory.createWidgets(
|
|
421
490
|
[current.element],
|
|
422
491
|
current.renderOptions,
|
|
423
492
|
current.isWeb2,
|
|
424
493
|
current.invisible,
|
|
425
494
|
);
|
|
426
495
|
|
|
427
|
-
if (
|
|
428
|
-
procaptchaWidgets.set(id, {
|
|
496
|
+
if (widget) {
|
|
497
|
+
procaptchaWidgets.set(id, {
|
|
498
|
+
...current,
|
|
499
|
+
root: widget.root,
|
|
500
|
+
target: widget.container,
|
|
501
|
+
});
|
|
429
502
|
} else {
|
|
430
503
|
procaptchaWidgets.delete(id);
|
|
431
504
|
}
|
|
@@ -445,6 +518,7 @@ export const remove = (widgetId?: string): void => {
|
|
|
445
518
|
for (const id of ids) {
|
|
446
519
|
const entry = procaptchaWidgets.get(id);
|
|
447
520
|
if (!entry) continue;
|
|
521
|
+
entry.unbindTrigger?.();
|
|
448
522
|
entry.root.unmount();
|
|
449
523
|
entry.element.innerHTML = "";
|
|
450
524
|
procaptchaWidgets.delete(id);
|
|
@@ -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
|
|
62
|
+
mocks.createWidgets.mockResolvedValue([] as CreatedWidget[]);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
describe("render", () => {
|
|
@@ -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([
|
|
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
|
|
87
|
+
mocks.createWidgets.mockResolvedValueOnce([] as CreatedWidget[]);
|
|
82
88
|
|
|
83
89
|
const widgetId = await render(document.createElement("div"), {
|
|
84
90
|
siteKey: SITE_KEY,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "@prosopo/types";
|
|
20
20
|
import type { Root } from "react-dom/client";
|
|
21
21
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
22
|
+
import type { CreatedWidget } from "../util/widgetFactory.js";
|
|
22
23
|
|
|
23
24
|
const mocks = vi.hoisted(() => ({
|
|
24
25
|
prefetchDetector: vi.fn(),
|
|
@@ -44,8 +45,10 @@ const { render, remove, start } = await import("../index.js");
|
|
|
44
45
|
|
|
45
46
|
const SITE_KEY = "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP";
|
|
46
47
|
|
|
47
|
-
const
|
|
48
|
-
|
|
48
|
+
const makeWidget = (): CreatedWidget => ({
|
|
49
|
+
root: { unmount: vi.fn(), render: vi.fn() } as unknown as Root,
|
|
50
|
+
container: document.createElement("div"),
|
|
51
|
+
});
|
|
49
52
|
|
|
50
53
|
const flush = (): Promise<void> =>
|
|
51
54
|
new Promise((resolve) => setTimeout(resolve, 0));
|
|
@@ -53,7 +56,7 @@ const flush = (): Promise<void> =>
|
|
|
53
56
|
const renderWidget = async (
|
|
54
57
|
options: Partial<Parameters<typeof render>[1]> = {},
|
|
55
58
|
): Promise<{ element: HTMLDivElement; id: string }> => {
|
|
56
|
-
mocks.createWidgets.mockResolvedValueOnce([
|
|
59
|
+
mocks.createWidgets.mockResolvedValueOnce([makeWidget()]);
|
|
57
60
|
const element = document.createElement("div");
|
|
58
61
|
const id = await render(element, { siteKey: SITE_KEY, ...options });
|
|
59
62
|
if (!id) throw new Error("expected render to return a widget id");
|
|
@@ -141,7 +144,7 @@ describe("detector prefetch", () => {
|
|
|
141
144
|
});
|
|
142
145
|
|
|
143
146
|
it("is skipped when the element asks for manual mode", async () => {
|
|
144
|
-
mocks.createWidgets.mockResolvedValueOnce([
|
|
147
|
+
mocks.createWidgets.mockResolvedValueOnce([makeWidget()]);
|
|
145
148
|
const element = document.createElement("div");
|
|
146
149
|
element.setAttribute("data-start-mode", "manual");
|
|
147
150
|
await render(element, { siteKey: SITE_KEY });
|
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
import createCache, { type EmotionCache } from "@emotion/cache";
|
|
16
16
|
import { CacheProvider } from "@emotion/react";
|
|
17
17
|
import type { Ti18n } from "@prosopo/locale";
|
|
18
|
-
import
|
|
19
|
-
Callbacks,
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
import {
|
|
19
|
+
type Callbacks,
|
|
20
|
+
Placement,
|
|
21
|
+
type PlacementType,
|
|
22
|
+
type ProcaptchaClientConfigOutput,
|
|
23
|
+
type ProcaptchaRenderOptions,
|
|
22
24
|
} from "@prosopo/types";
|
|
23
25
|
import type { ReactNode } from "react";
|
|
24
26
|
import { type Root, createRoot } from "react-dom/client";
|
|
@@ -29,6 +31,18 @@ import { setStartMode } from "../startMode.js";
|
|
|
29
31
|
import { setValidChallengeLength } from "../timeout.js";
|
|
30
32
|
import { BundleCaptcha } from "./components/bundleCaptcha.js";
|
|
31
33
|
|
|
34
|
+
const resolveRequestedPlacement = (
|
|
35
|
+
element: Element,
|
|
36
|
+
renderOptions: ProcaptchaRenderOptions,
|
|
37
|
+
): PlacementType | undefined => {
|
|
38
|
+
const requested =
|
|
39
|
+
renderOptions.placement ?? element.getAttribute("data-placement");
|
|
40
|
+
if (!requested) return undefined;
|
|
41
|
+
|
|
42
|
+
const parsed = Placement.safeParse(requested);
|
|
43
|
+
return parsed.success ? parsed.data : undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
32
46
|
interface RenderSettings {
|
|
33
47
|
identifierPrefix: string;
|
|
34
48
|
emotionCacheKey: string;
|
|
@@ -47,16 +61,20 @@ class CaptchaRenderer {
|
|
|
47
61
|
widgetContainer: HTMLElement,
|
|
48
62
|
sourceElement?: Element,
|
|
49
63
|
): Root {
|
|
50
|
-
const config = createConfig(
|
|
51
|
-
renderOptions.siteKey,
|
|
52
|
-
renderOptions.theme,
|
|
53
|
-
renderOptions.language,
|
|
54
|
-
isWeb2,
|
|
64
|
+
const config = createConfig({
|
|
65
|
+
siteKey: renderOptions.siteKey,
|
|
66
|
+
theme: renderOptions.theme,
|
|
67
|
+
language: renderOptions.language,
|
|
68
|
+
web2: isWeb2,
|
|
55
69
|
invisible,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
placement: resolveRequestedPlacement(
|
|
71
|
+
sourceElement || container,
|
|
72
|
+
renderOptions,
|
|
73
|
+
),
|
|
74
|
+
userAccountAddress: renderOptions.userAccountAddress,
|
|
75
|
+
ipv4: renderOptions.ipv4,
|
|
76
|
+
ipv6: renderOptions.ipv6,
|
|
77
|
+
});
|
|
60
78
|
this.readAndValidateSettings(
|
|
61
79
|
sourceElement || container,
|
|
62
80
|
config,
|
|
@@ -15,23 +15,39 @@
|
|
|
15
15
|
import type { Languages } from "@prosopo/locale";
|
|
16
16
|
import {
|
|
17
17
|
EnvironmentTypesSchema,
|
|
18
|
+
type PlacementType,
|
|
18
19
|
type ProcaptchaClientConfigOutput,
|
|
19
20
|
ProcaptchaConfigSchema,
|
|
21
|
+
resolvePlacement,
|
|
20
22
|
} from "@prosopo/types";
|
|
21
23
|
|
|
24
|
+
interface CreateConfigOptions {
|
|
25
|
+
siteKey?: string;
|
|
26
|
+
theme?: "light" | "dark";
|
|
27
|
+
language?: (typeof Languages)[keyof typeof Languages];
|
|
28
|
+
web2?: boolean;
|
|
29
|
+
invisible?: boolean;
|
|
30
|
+
placement?: PlacementType;
|
|
31
|
+
userAccountAddress?: string;
|
|
32
|
+
ipv4?: boolean;
|
|
33
|
+
ipv6?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
function createConfig(
|
|
23
|
-
|
|
24
|
-
theme: "light" | "dark" = "light",
|
|
25
|
-
language?: (typeof Languages)[keyof typeof Languages],
|
|
26
|
-
web2 = true,
|
|
27
|
-
invisible = false,
|
|
28
|
-
userAccountAddress?: string,
|
|
29
|
-
ipv4 = false,
|
|
30
|
-
ipv6 = false,
|
|
37
|
+
options: CreateConfigOptions = {},
|
|
31
38
|
): ProcaptchaClientConfigOutput {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
const {
|
|
40
|
+
theme = "light",
|
|
41
|
+
language,
|
|
42
|
+
web2 = true,
|
|
43
|
+
invisible = false,
|
|
44
|
+
placement,
|
|
45
|
+
userAccountAddress,
|
|
46
|
+
ipv4 = false,
|
|
47
|
+
ipv6 = false,
|
|
48
|
+
} = options;
|
|
49
|
+
|
|
50
|
+
const siteKey = options.siteKey || process.env.PROSOPO_SITE_KEY || "";
|
|
35
51
|
|
|
36
52
|
return ProcaptchaConfigSchema.parse({
|
|
37
53
|
defaultEnvironment: process.env.PROSOPO_DEFAULT_ENVIRONMENT
|
|
@@ -45,6 +61,7 @@ function createConfig(
|
|
|
45
61
|
mongoAtlasUri: process.env.PROSOPO_MONGO_EVENTS_URI || "",
|
|
46
62
|
web2,
|
|
47
63
|
mode: invisible ? "invisible" : "visible",
|
|
64
|
+
placement: resolvePlacement(placement, invisible),
|
|
48
65
|
theme,
|
|
49
66
|
language,
|
|
50
67
|
ipv4,
|
|
@@ -53,3 +70,4 @@ function createConfig(
|
|
|
53
70
|
}
|
|
54
71
|
|
|
55
72
|
export { createConfig };
|
|
73
|
+
export type { CreateConfigOptions };
|