@prosopo/procaptcha-puzzle 2.10.39 → 2.10.41
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 +13 -11
- package/.turbo/turbo-build$colon$tsc.log +19 -19
- package/.turbo/turbo-build.log +15 -12
- package/CHANGELOG.md +41 -0
- package/dist/_virtual/_rolldown/runtime.js +3 -0
- package/dist/cjs/components/ProcaptchaPuzzle.cjs +6 -9
- package/dist/cjs/components/ProcaptchaWidget.cjs +191 -213
- package/dist/cjs/components/PuzzleCanvas.cjs +228 -270
- package/dist/cjs/index.cjs +2 -4
- package/dist/cjs/services/Manager.cjs +209 -289
- package/dist/components/ProcaptchaPuzzle.js +6 -8
- package/dist/components/ProcaptchaWidget.d.ts.map +1 -1
- package/dist/components/ProcaptchaWidget.js +190 -212
- package/dist/components/ProcaptchaWidget.js.map +1 -1
- package/dist/components/PuzzleCanvas.js +229 -271
- package/dist/index.js +2 -4
- package/dist/services/Manager.d.ts.map +1 -1
- package/dist/services/Manager.js +205 -286
- package/dist/services/Manager.js.map +1 -1
- package/dist/tests/manager.unit.test.d.ts +2 -0
- package/dist/tests/manager.unit.test.d.ts.map +1 -0
- package/dist/tests/manager.unit.test.js +506 -0
- package/dist/tests/manager.unit.test.js.map +1 -0
- package/dist/tests/managerHarness.d.ts +20 -0
- package/dist/tests/managerHarness.d.ts.map +1 -0
- package/dist/tests/managerHarness.js +86 -0
- package/dist/tests/managerHarness.js.map +1 -0
- package/dist/tests/procaptchaPuzzle.test-d.d.ts +2 -0
- package/dist/tests/procaptchaPuzzle.test-d.d.ts.map +1 -0
- package/dist/tests/procaptchaPuzzle.test-d.js +97 -0
- package/dist/tests/procaptchaPuzzle.test-d.js.map +1 -0
- package/dist/tests/procaptchaPuzzle.unit.test.d.ts +2 -0
- package/dist/tests/procaptchaPuzzle.unit.test.d.ts.map +1 -0
- package/dist/tests/procaptchaPuzzle.unit.test.js +81 -0
- package/dist/tests/procaptchaPuzzle.unit.test.js.map +1 -0
- package/dist/tests/procaptchaWidget.unit.test.d.ts +2 -0
- package/dist/tests/procaptchaWidget.unit.test.d.ts.map +1 -0
- package/dist/tests/procaptchaWidget.unit.test.js +547 -0
- package/dist/tests/procaptchaWidget.unit.test.js.map +1 -0
- package/dist/tests/puzzleCanvas.unit.test.d.ts +2 -0
- package/dist/tests/puzzleCanvas.unit.test.d.ts.map +1 -0
- package/dist/tests/puzzleCanvas.unit.test.js +312 -0
- package/dist/tests/puzzleCanvas.unit.test.js.map +1 -0
- package/dist/tests/setup.d.ts +5 -0
- package/dist/tests/setup.d.ts.map +1 -0
- package/dist/tests/setup.js +3 -0
- package/dist/tests/setup.js.map +1 -0
- package/package.json +17 -12
- package/src/components/ProcaptchaWidget.tsx +17 -6
- package/src/services/Manager.ts +14 -10
- package/src/tests/manager.unit.test.ts +763 -0
- package/src/tests/managerHarness.ts +169 -0
- package/src/tests/procaptchaPuzzle.test-d.ts +191 -0
- package/src/tests/procaptchaPuzzle.unit.test.ts +123 -0
- package/src/tests/procaptchaWidget.unit.test.ts +789 -0
- package/src/tests/puzzleCanvas.unit.test.ts +418 -0
- package/src/tests/setup.ts +26 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vite.test.config.ts +27 -0
|
@@ -0,0 +1,789 @@
|
|
|
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 { Ti18n } from "@prosopo/locale";
|
|
16
|
+
import {
|
|
17
|
+
type GetPuzzleCaptchaResponse,
|
|
18
|
+
ModeEnum,
|
|
19
|
+
type ProcaptchaProps,
|
|
20
|
+
type ProcaptchaState,
|
|
21
|
+
type PuzzleEvent,
|
|
22
|
+
} from "@prosopo/types";
|
|
23
|
+
import { type ReactElement, act, createElement } from "react";
|
|
24
|
+
import { type Root, createRoot } from "react-dom/client";
|
|
25
|
+
import {
|
|
26
|
+
type Mock,
|
|
27
|
+
afterEach,
|
|
28
|
+
beforeEach,
|
|
29
|
+
describe,
|
|
30
|
+
expect,
|
|
31
|
+
test,
|
|
32
|
+
vi,
|
|
33
|
+
} from "vitest";
|
|
34
|
+
import Procaptcha from "../components/ProcaptchaWidget.js";
|
|
35
|
+
import { challengeResponse, config, frictionless } from "./managerHarness.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The widget owns the phase machine — checkbox, dragging, submitting — and the
|
|
39
|
+
* wiring between the manager and the canvas. Both of those collaborators are
|
|
40
|
+
* stubbed so each test drives one transition at a time.
|
|
41
|
+
*/
|
|
42
|
+
const mocks = vi.hoisted(() => {
|
|
43
|
+
const start =
|
|
44
|
+
vi.fn<
|
|
45
|
+
(x?: number, y?: number) => Promise<GetPuzzleCaptchaResponse | undefined>
|
|
46
|
+
>();
|
|
47
|
+
const submitSolution =
|
|
48
|
+
vi.fn<(x: number, y: number, events: PuzzleEvent[]) => Promise<boolean>>();
|
|
49
|
+
const resetState = vi.fn<() => void>();
|
|
50
|
+
const constructions: {
|
|
51
|
+
updateState: (next: Partial<ProcaptchaState>) => void;
|
|
52
|
+
getHoneypotValue?: () => string | undefined;
|
|
53
|
+
}[] = [];
|
|
54
|
+
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
|
+
targetX: number;
|
|
72
|
+
targetY: number;
|
|
73
|
+
showRetry: boolean;
|
|
74
|
+
submitting: boolean;
|
|
75
|
+
onComplete: (
|
|
76
|
+
x: number,
|
|
77
|
+
y: number,
|
|
78
|
+
events: PuzzleEvent[],
|
|
79
|
+
) => Promise<void> | void;
|
|
80
|
+
}
|
|
81
|
+
| undefined;
|
|
82
|
+
} = { current: undefined };
|
|
83
|
+
const honeypotQuestions: string[] = [];
|
|
84
|
+
const translationsReady = { current: true };
|
|
85
|
+
return {
|
|
86
|
+
translationsReady,
|
|
87
|
+
start,
|
|
88
|
+
submitSolution,
|
|
89
|
+
resetState,
|
|
90
|
+
constructions,
|
|
91
|
+
loadI18next,
|
|
92
|
+
checkboxProps,
|
|
93
|
+
canvasProps,
|
|
94
|
+
honeypotQuestions,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
vi.mock("../services/Manager.js", () => ({
|
|
99
|
+
Manager: (
|
|
100
|
+
_config: unknown,
|
|
101
|
+
_state: ProcaptchaState,
|
|
102
|
+
updateState: (next: Partial<ProcaptchaState>) => void,
|
|
103
|
+
_callbacks: unknown,
|
|
104
|
+
_frictionlessState: unknown,
|
|
105
|
+
getHoneypotValue?: () => string | undefined,
|
|
106
|
+
) => {
|
|
107
|
+
mocks.constructions.push({ updateState, getHoneypotValue });
|
|
108
|
+
return {
|
|
109
|
+
start: mocks.start,
|
|
110
|
+
submitSolution: mocks.submitSolution,
|
|
111
|
+
resetState: mocks.resetState,
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
// The canvas has its own suite; here it is reduced to a probe so a test can
|
|
117
|
+
// read the props the widget hands it and fire the completion callback without
|
|
118
|
+
// simulating a drag.
|
|
119
|
+
vi.mock("../components/PuzzleCanvas.js", async () => {
|
|
120
|
+
const { createElement: create } = await import("react");
|
|
121
|
+
interface CanvasStubProps {
|
|
122
|
+
originX: number;
|
|
123
|
+
originY: number;
|
|
124
|
+
targetX: number;
|
|
125
|
+
targetY: number;
|
|
126
|
+
showRetry: boolean;
|
|
127
|
+
submitting: boolean;
|
|
128
|
+
onComplete: (
|
|
129
|
+
x: number,
|
|
130
|
+
y: number,
|
|
131
|
+
events: PuzzleEvent[],
|
|
132
|
+
) => Promise<void> | void;
|
|
133
|
+
}
|
|
134
|
+
const PuzzleCanvas = (canvasProps: CanvasStubProps) => {
|
|
135
|
+
mocks.canvasProps.current = canvasProps;
|
|
136
|
+
return create("div", { "data-cy": "canvas-stub" });
|
|
137
|
+
};
|
|
138
|
+
return { PuzzleCanvas };
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// The checkbox and the honeypot are procaptcha-common's, and tested there. The
|
|
142
|
+
// stubs keep their contract — a change callback and a forwarded input ref —
|
|
143
|
+
// while letting a test drive the exact browser event the widget branches on,
|
|
144
|
+
// which jsdom cannot produce (it marks every dispatched event untrusted, and
|
|
145
|
+
// the real checkbox drops those before the widget ever sees them).
|
|
146
|
+
vi.mock("@prosopo/procaptcha-common", async (importOriginal) => {
|
|
147
|
+
const actual =
|
|
148
|
+
await importOriginal<typeof import("@prosopo/procaptcha-common")>();
|
|
149
|
+
const { createElement: create, forwardRef } = await import("react");
|
|
150
|
+
interface CheckboxStubProps {
|
|
151
|
+
checked: boolean;
|
|
152
|
+
loading: boolean;
|
|
153
|
+
labelText: string;
|
|
154
|
+
error?: string;
|
|
155
|
+
onChange: (event: { nativeEvent: unknown }) => Promise<void>;
|
|
156
|
+
}
|
|
157
|
+
const Checkbox = (checkboxProps: CheckboxStubProps) => {
|
|
158
|
+
mocks.checkboxProps.current = checkboxProps;
|
|
159
|
+
return create("input", {
|
|
160
|
+
type: "checkbox",
|
|
161
|
+
readOnly: true,
|
|
162
|
+
checked: checkboxProps.checked,
|
|
163
|
+
"aria-label": checkboxProps.labelText,
|
|
164
|
+
"data-error": checkboxProps.error,
|
|
165
|
+
"data-loading": String(checkboxProps.loading),
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
const Honeypot = forwardRef<HTMLInputElement, { encodedQuestion: string }>(
|
|
169
|
+
({ encodedQuestion }, ref) => {
|
|
170
|
+
mocks.honeypotQuestions.push(encodedQuestion);
|
|
171
|
+
return create("input", { type: "text", ref, name: "honeypot" });
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
return { ...actual, Checkbox, Honeypot };
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
vi.mock("@prosopo/locale", async (importOriginal) => {
|
|
178
|
+
const actual = await importOriginal<typeof import("@prosopo/locale")>();
|
|
179
|
+
return {
|
|
180
|
+
...actual,
|
|
181
|
+
loadI18next: mocks.loadI18next,
|
|
182
|
+
useTranslation: () => ({
|
|
183
|
+
t: (key: string) => key,
|
|
184
|
+
ready: mocks.translationsReady.current,
|
|
185
|
+
}),
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
let container: HTMLDivElement;
|
|
190
|
+
let root: Root;
|
|
191
|
+
|
|
192
|
+
const i18nStub = (
|
|
193
|
+
language: string,
|
|
194
|
+
changeLanguage: Mock<(l: string) => void>,
|
|
195
|
+
) => ({ language, changeLanguage }) as unknown as Ti18n;
|
|
196
|
+
|
|
197
|
+
const props = (overrides: Partial<ProcaptchaProps> = {}): ProcaptchaProps => ({
|
|
198
|
+
config: config(),
|
|
199
|
+
callbacks: {},
|
|
200
|
+
i18n: undefined as unknown as Ti18n,
|
|
201
|
+
...overrides,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const render = (widgetProps: ProcaptchaProps): void => {
|
|
205
|
+
act(() => {
|
|
206
|
+
root.render(createElement(Procaptcha, widgetProps) as ReactElement);
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
vi.clearAllMocks();
|
|
212
|
+
mocks.constructions.length = 0;
|
|
213
|
+
mocks.honeypotQuestions.length = 0;
|
|
214
|
+
mocks.translationsReady.current = true;
|
|
215
|
+
mocks.checkboxProps.current = undefined;
|
|
216
|
+
mocks.canvasProps.current = undefined;
|
|
217
|
+
mocks.start.mockResolvedValue(challengeResponse());
|
|
218
|
+
mocks.submitSolution.mockResolvedValue(true);
|
|
219
|
+
mocks.loadI18next.mockResolvedValue(undefined);
|
|
220
|
+
container = document.createElement("div");
|
|
221
|
+
document.body.appendChild(container);
|
|
222
|
+
act(() => {
|
|
223
|
+
root = createRoot(container);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
afterEach(() => {
|
|
228
|
+
act(() => {
|
|
229
|
+
root.unmount();
|
|
230
|
+
});
|
|
231
|
+
container.remove();
|
|
232
|
+
// React schedules through timers, so a suite that leaves them faked makes
|
|
233
|
+
// every later render land after its assertions.
|
|
234
|
+
vi.useRealTimers();
|
|
235
|
+
vi.restoreAllMocks();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const checkbox = (): HTMLInputElement => {
|
|
239
|
+
const element = container.querySelector<HTMLInputElement>(
|
|
240
|
+
'input[type="checkbox"]',
|
|
241
|
+
);
|
|
242
|
+
if (!element) throw new Error("expected a checkbox to be rendered");
|
|
243
|
+
return element;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const canvas = (): Element | null =>
|
|
247
|
+
container.querySelector('[data-cy="canvas-stub"]');
|
|
248
|
+
|
|
249
|
+
const honeypotInput = (): HTMLInputElement => {
|
|
250
|
+
const element = container.querySelector<HTMLInputElement>(
|
|
251
|
+
'input[name="honeypot"]',
|
|
252
|
+
);
|
|
253
|
+
if (!element) throw new Error("expected a honeypot to be rendered");
|
|
254
|
+
return element;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
interface ClickOptions {
|
|
258
|
+
trusted?: boolean;
|
|
259
|
+
clientX?: number;
|
|
260
|
+
clientY?: number;
|
|
261
|
+
touches?: { clientX: number; clientY: number }[];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Hand the widget the browser event a click on the checkbox would produce. */
|
|
265
|
+
const click = async (options: ClickOptions = {}): Promise<void> => {
|
|
266
|
+
const nativeEvent = {
|
|
267
|
+
isTrusted: options.trusted ?? true,
|
|
268
|
+
clientX: options.clientX ?? 0,
|
|
269
|
+
clientY: options.clientY ?? 0,
|
|
270
|
+
...(options.touches ? { touches: options.touches } : {}),
|
|
271
|
+
};
|
|
272
|
+
await act(async () => {
|
|
273
|
+
await mocks.checkboxProps.current?.onChange({ nativeEvent });
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Start a solve without waiting for it to settle, for the tests that hold the
|
|
279
|
+
* manager's promise open.
|
|
280
|
+
*/
|
|
281
|
+
const clickWithoutWaiting = (): void => {
|
|
282
|
+
act(() => {
|
|
283
|
+
void mocks.checkboxProps.current?.onChange({
|
|
284
|
+
nativeEvent: { isTrusted: true, clientX: 0, clientY: 0 },
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** Fire the canvas completion callback the way a finished drag would. */
|
|
290
|
+
const complete = async (
|
|
291
|
+
x = 200,
|
|
292
|
+
y = 80,
|
|
293
|
+
events: PuzzleEvent[] = [{ x: 200, y: 80, t: 5 }],
|
|
294
|
+
): Promise<void> => {
|
|
295
|
+
await act(async () => {
|
|
296
|
+
await mocks.canvasProps.current?.onComplete(x, y, events);
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
describe("what the widget renders", () => {
|
|
301
|
+
test("a visible widget shows a checkbox", () => {
|
|
302
|
+
render(props());
|
|
303
|
+
expect(checkbox()).toBeDefined();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("an invisible widget shows no checkbox at all", () => {
|
|
307
|
+
render(props({ config: config({ mode: ModeEnum.invisible }) }));
|
|
308
|
+
expect(container.querySelector('input[type="checkbox"]')).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("no puzzle is shown until a challenge has been fetched", () => {
|
|
312
|
+
render(props());
|
|
313
|
+
expect(canvas()).toBeNull();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("a session with a honeypot question renders the honeypot", () => {
|
|
317
|
+
render(props({ frictionlessState: frictionless({ hp: "question" }) }));
|
|
318
|
+
expect(honeypotInput()).toBeDefined();
|
|
319
|
+
expect(mocks.honeypotQuestions.at(-1)).toBe("question");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("a session without one renders no bait at all", () => {
|
|
323
|
+
render(props({ frictionlessState: frictionless() }));
|
|
324
|
+
expect(container.querySelector('input[name="honeypot"]')).toBeNull();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("the manager can read the honeypot input the widget rendered", () => {
|
|
328
|
+
render(props({ frictionlessState: frictionless({ hp: "question" }) }));
|
|
329
|
+
honeypotInput().value = "bot@example.com";
|
|
330
|
+
expect(mocks.constructions[0]?.getHoneypotValue?.()).toBe(
|
|
331
|
+
"bot@example.com",
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("a dark-themed widget still renders its checkbox", () => {
|
|
336
|
+
render(props({ config: config({ theme: "dark" }) }));
|
|
337
|
+
expect(checkbox()).toBeDefined();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("the checkbox is labelled once the translations are ready", () => {
|
|
341
|
+
render(props());
|
|
342
|
+
expect(mocks.checkboxProps.current?.labelText).toBe("WIDGET.I_AM_HUMAN");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("the label is left empty while the translations load", () => {
|
|
346
|
+
mocks.translationsReady.current = false;
|
|
347
|
+
render(props());
|
|
348
|
+
expect(mocks.checkboxProps.current?.labelText).toBe("");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("the widget keeps the manager it started with across re-renders", () => {
|
|
352
|
+
// `useRef(Manager(...))` re-evaluates the argument on every render, so
|
|
353
|
+
// extra managers are constructed and thrown away; what matters is that
|
|
354
|
+
// the widget still listens to the first one.
|
|
355
|
+
render(props());
|
|
356
|
+
render(props());
|
|
357
|
+
act(() => {
|
|
358
|
+
mocks.constructions[0]?.updateState({
|
|
359
|
+
error: {
|
|
360
|
+
message: "from the original manager",
|
|
361
|
+
key: "API.UNKNOWN_ERROR",
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
expect(mocks.checkboxProps.current?.error).toBe(
|
|
366
|
+
"from the original manager",
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe("language", () => {
|
|
372
|
+
test("nothing is loaded when no language is configured", () => {
|
|
373
|
+
render(props());
|
|
374
|
+
expect(mocks.loadI18next).not.toHaveBeenCalled();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("a configured language boots i18next with it", () => {
|
|
378
|
+
render(props({ config: config({ language: "fr" }) }));
|
|
379
|
+
expect(mocks.loadI18next).toHaveBeenCalledWith(false, "fr");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("an injected i18n on another language is switched over", () => {
|
|
383
|
+
const changeLanguage = vi.fn<(l: string) => void>();
|
|
384
|
+
render(
|
|
385
|
+
props({
|
|
386
|
+
config: config({ language: "fr" }),
|
|
387
|
+
i18n: i18nStub("en", changeLanguage),
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
expect(changeLanguage).toHaveBeenCalledWith("fr");
|
|
391
|
+
expect(mocks.loadI18next).not.toHaveBeenCalled();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("an injected i18n already on that language is left alone", () => {
|
|
395
|
+
const changeLanguage = vi.fn<(l: string) => void>();
|
|
396
|
+
render(
|
|
397
|
+
props({
|
|
398
|
+
config: config({ language: "fr" }),
|
|
399
|
+
i18n: i18nStub("fr", changeLanguage),
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
expect(changeLanguage).not.toHaveBeenCalled();
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe("starting from the checkbox", () => {
|
|
407
|
+
test("a click starts the solve and opens the puzzle", async () => {
|
|
408
|
+
render(props());
|
|
409
|
+
await click({ clientX: 12, clientY: 34 });
|
|
410
|
+
expect(mocks.start).toHaveBeenCalledWith(12, 34);
|
|
411
|
+
expect(canvas()).not.toBeNull();
|
|
412
|
+
expect(mocks.canvasProps.current).toMatchObject({
|
|
413
|
+
originX: challengeResponse().originX,
|
|
414
|
+
targetY: challengeResponse().targetY,
|
|
415
|
+
submitting: false,
|
|
416
|
+
showRetry: false,
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("an untrusted click starts a solve but carries no coordinates", async () => {
|
|
421
|
+
render(props());
|
|
422
|
+
await click({ trusted: false, clientX: 12, clientY: 34 });
|
|
423
|
+
expect(mocks.start).toHaveBeenCalledWith(0, 0);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("a tap reports the coordinates of the first touch", async () => {
|
|
427
|
+
render(props());
|
|
428
|
+
await click({ touches: [{ clientX: 7, clientY: 9 }] });
|
|
429
|
+
expect(mocks.start).toHaveBeenCalledWith(7, 9);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("a touch event with no touches falls back to the origin", async () => {
|
|
433
|
+
render(props());
|
|
434
|
+
await click({ touches: [] });
|
|
435
|
+
expect(mocks.start).toHaveBeenCalledWith(0, 0);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("a second click while the first is in flight is ignored", async () => {
|
|
439
|
+
let release: (challenge: GetPuzzleCaptchaResponse) => void = () => {
|
|
440
|
+
// replaced synchronously by the promise executor below
|
|
441
|
+
};
|
|
442
|
+
mocks.start.mockReturnValue(
|
|
443
|
+
new Promise<GetPuzzleCaptchaResponse>((resolve) => {
|
|
444
|
+
release = resolve;
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
447
|
+
render(props());
|
|
448
|
+
// Not awaited: the handler cannot settle until the challenge is
|
|
449
|
+
// released, and awaiting it here would deadlock the test.
|
|
450
|
+
clickWithoutWaiting();
|
|
451
|
+
clickWithoutWaiting();
|
|
452
|
+
expect(mocks.start).toHaveBeenCalledTimes(1);
|
|
453
|
+
await act(async () => {
|
|
454
|
+
release(challengeResponse());
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("the checkbox shows a spinner while the solve is loading", async () => {
|
|
459
|
+
let release: (challenge: GetPuzzleCaptchaResponse) => void = () => {
|
|
460
|
+
// replaced synchronously by the promise executor below
|
|
461
|
+
};
|
|
462
|
+
mocks.start.mockReturnValue(
|
|
463
|
+
new Promise<GetPuzzleCaptchaResponse>((resolve) => {
|
|
464
|
+
release = resolve;
|
|
465
|
+
}),
|
|
466
|
+
);
|
|
467
|
+
render(props());
|
|
468
|
+
clickWithoutWaiting();
|
|
469
|
+
expect(mocks.checkboxProps.current?.loading).toBe(true);
|
|
470
|
+
await act(async () => {
|
|
471
|
+
release(challengeResponse());
|
|
472
|
+
});
|
|
473
|
+
expect(mocks.checkboxProps.current?.loading).toBe(false);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("a start that yields no challenge leaves the checkbox in place", async () => {
|
|
477
|
+
mocks.start.mockResolvedValue(undefined);
|
|
478
|
+
render(props());
|
|
479
|
+
await click();
|
|
480
|
+
expect(canvas()).toBeNull();
|
|
481
|
+
expect(mocks.checkboxProps.current?.loading).toBe(false);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("a start that throws returns the user to a usable checkbox", async () => {
|
|
485
|
+
const onError = vi.fn<(error: Error) => void>();
|
|
486
|
+
mocks.start.mockRejectedValue(new Error("provider down"));
|
|
487
|
+
render(props({ callbacks: { onError } }));
|
|
488
|
+
await click();
|
|
489
|
+
// Without the guard the spinner would stay up for good: nothing awaits
|
|
490
|
+
// the checkbox handler, so the rejection escapes as an unhandled one.
|
|
491
|
+
expect(mocks.checkboxProps.current?.loading).toBe(false);
|
|
492
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("a start that throws without an error callback is still recoverable", async () => {
|
|
496
|
+
mocks.start.mockRejectedValue(new Error("provider down"));
|
|
497
|
+
render(props());
|
|
498
|
+
await click();
|
|
499
|
+
expect(mocks.checkboxProps.current?.loading).toBe(false);
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
describe("autoStart", () => {
|
|
504
|
+
test("fetches a challenge and opens the puzzle without a click", async () => {
|
|
505
|
+
await act(async () => {
|
|
506
|
+
root.render(
|
|
507
|
+
createElement(
|
|
508
|
+
Procaptcha,
|
|
509
|
+
props({ autoStart: true, startCoords: { x: 5, y: 6 } }),
|
|
510
|
+
) as ReactElement,
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
expect(mocks.start).toHaveBeenCalledWith(5, 6);
|
|
514
|
+
expect(canvas()).not.toBeNull();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("starts at the origin when no coordinates were handed over", async () => {
|
|
518
|
+
await act(async () => {
|
|
519
|
+
root.render(
|
|
520
|
+
createElement(Procaptcha, props({ autoStart: true })) as ReactElement,
|
|
521
|
+
);
|
|
522
|
+
});
|
|
523
|
+
expect(mocks.start).toHaveBeenCalledWith(0, 0);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("a failed autoStart leaves the checkbox usable", async () => {
|
|
527
|
+
mocks.start.mockRejectedValue(new Error("nope"));
|
|
528
|
+
await act(async () => {
|
|
529
|
+
root.render(
|
|
530
|
+
createElement(Procaptcha, props({ autoStart: true })) as ReactElement,
|
|
531
|
+
);
|
|
532
|
+
});
|
|
533
|
+
expect(canvas()).toBeNull();
|
|
534
|
+
expect(mocks.checkboxProps.current?.loading).toBe(false);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("no autoStart means no solve until the user acts", () => {
|
|
538
|
+
render(props());
|
|
539
|
+
expect(mocks.start).not.toHaveBeenCalled();
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
describe("finishing the drag", () => {
|
|
544
|
+
const openPuzzle = async (
|
|
545
|
+
widgetProps: ProcaptchaProps = props(),
|
|
546
|
+
): Promise<void> => {
|
|
547
|
+
render(widgetProps);
|
|
548
|
+
await click();
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
test("submits the drop position and the trail the canvas gathered", async () => {
|
|
552
|
+
await openPuzzle();
|
|
553
|
+
const events: PuzzleEvent[] = [{ x: 1, y: 2, t: 3 }];
|
|
554
|
+
await complete(150, 60, events);
|
|
555
|
+
expect(mocks.submitSolution).toHaveBeenCalledWith(150, 60, events);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("a verified solution closes the puzzle", async () => {
|
|
559
|
+
await openPuzzle();
|
|
560
|
+
await complete();
|
|
561
|
+
expect(canvas()).toBeNull();
|
|
562
|
+
expect(mocks.checkboxProps.current?.loading).toBe(false);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("a rejected solution asks for another go on a fresh challenge", async () => {
|
|
566
|
+
mocks.submitSolution.mockResolvedValue(false);
|
|
567
|
+
const retryChallenge = challengeResponse({ originX: 40, targetX: 250 });
|
|
568
|
+
mocks.start
|
|
569
|
+
.mockResolvedValueOnce(challengeResponse())
|
|
570
|
+
.mockResolvedValue(retryChallenge);
|
|
571
|
+
await openPuzzle();
|
|
572
|
+
await complete();
|
|
573
|
+
expect(canvas()).not.toBeNull();
|
|
574
|
+
expect(mocks.canvasProps.current).toMatchObject({
|
|
575
|
+
showRetry: true,
|
|
576
|
+
originX: 40,
|
|
577
|
+
targetX: 250,
|
|
578
|
+
submitting: false,
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("a rejected solution with no replacement challenge closes the puzzle", async () => {
|
|
583
|
+
mocks.submitSolution.mockResolvedValue(false);
|
|
584
|
+
mocks.start
|
|
585
|
+
.mockResolvedValueOnce(challengeResponse())
|
|
586
|
+
.mockResolvedValue(undefined);
|
|
587
|
+
await openPuzzle();
|
|
588
|
+
await complete();
|
|
589
|
+
expect(canvas()).toBeNull();
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("a rejected solution whose retry throws closes the puzzle", async () => {
|
|
593
|
+
mocks.submitSolution.mockResolvedValue(false);
|
|
594
|
+
mocks.start
|
|
595
|
+
.mockResolvedValueOnce(challengeResponse())
|
|
596
|
+
.mockRejectedValue(new Error("provider down"));
|
|
597
|
+
await openPuzzle();
|
|
598
|
+
await complete();
|
|
599
|
+
expect(canvas()).toBeNull();
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("a submit that throws is reported and treated as a failure", async () => {
|
|
603
|
+
const onError = vi.fn<(error: Error) => void>();
|
|
604
|
+
mocks.submitSolution.mockRejectedValue(new Error("boom"));
|
|
605
|
+
await openPuzzle(props({ callbacks: { onError } }));
|
|
606
|
+
await complete();
|
|
607
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
608
|
+
// Failure, so the widget asks for a new challenge rather than passing
|
|
609
|
+
// the user through on an error.
|
|
610
|
+
expect(mocks.start).toHaveBeenCalledTimes(2);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("a submit that throws a non-error still reaches the callback", async () => {
|
|
614
|
+
const onError = vi.fn<(error: Error) => void>();
|
|
615
|
+
mocks.submitSolution.mockRejectedValue("just a string");
|
|
616
|
+
await openPuzzle(props({ callbacks: { onError } }));
|
|
617
|
+
await complete();
|
|
618
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("the puzzle is frozen while the solution is in flight", async () => {
|
|
622
|
+
let release: (verified: boolean) => void = () => {
|
|
623
|
+
// replaced synchronously by the promise executor below
|
|
624
|
+
};
|
|
625
|
+
mocks.submitSolution.mockReturnValue(
|
|
626
|
+
new Promise<boolean>((resolve) => {
|
|
627
|
+
release = resolve;
|
|
628
|
+
}),
|
|
629
|
+
);
|
|
630
|
+
await openPuzzle();
|
|
631
|
+
// Not awaited: the handler cannot settle until the verdict is released.
|
|
632
|
+
act(() => {
|
|
633
|
+
void mocks.canvasProps.current?.onComplete(200, 80, []);
|
|
634
|
+
});
|
|
635
|
+
expect(mocks.canvasProps.current?.submitting).toBe(true);
|
|
636
|
+
expect(mocks.checkboxProps.current?.loading).toBe(true);
|
|
637
|
+
await act(async () => {
|
|
638
|
+
release(true);
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
describe("invisible mode", () => {
|
|
644
|
+
const execute = async (): Promise<void> => {
|
|
645
|
+
await act(async () => {
|
|
646
|
+
document.dispatchEvent(new Event("procaptcha:execute"));
|
|
647
|
+
await Promise.resolve();
|
|
648
|
+
});
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
test("an execute event fetches a challenge and opens the puzzle", async () => {
|
|
652
|
+
render(props({ config: config({ mode: ModeEnum.invisible }) }));
|
|
653
|
+
await execute();
|
|
654
|
+
expect(mocks.start).toHaveBeenCalledTimes(1);
|
|
655
|
+
expect(canvas()).not.toBeNull();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("a visible widget ignores the execute event entirely", async () => {
|
|
659
|
+
render(props());
|
|
660
|
+
await execute();
|
|
661
|
+
expect(mocks.start).not.toHaveBeenCalled();
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("an execute that yields no challenge shows no puzzle", async () => {
|
|
665
|
+
mocks.start.mockResolvedValue(undefined);
|
|
666
|
+
render(props({ config: config({ mode: ModeEnum.invisible }) }));
|
|
667
|
+
await execute();
|
|
668
|
+
expect(canvas()).toBeNull();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("an execute that throws is reported to the host page", async () => {
|
|
672
|
+
const onError = vi.fn<(error: Error) => void>();
|
|
673
|
+
mocks.start.mockRejectedValue(new Error("provider down"));
|
|
674
|
+
render(
|
|
675
|
+
props({
|
|
676
|
+
config: config({ mode: ModeEnum.invisible }),
|
|
677
|
+
callbacks: { onError },
|
|
678
|
+
}),
|
|
679
|
+
);
|
|
680
|
+
await execute();
|
|
681
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("an execute that throws a non-error is still reported", async () => {
|
|
685
|
+
const onError = vi.fn<(error: Error) => void>();
|
|
686
|
+
mocks.start.mockRejectedValue("just a string");
|
|
687
|
+
render(
|
|
688
|
+
props({
|
|
689
|
+
config: config({ mode: ModeEnum.invisible }),
|
|
690
|
+
callbacks: { onError },
|
|
691
|
+
}),
|
|
692
|
+
);
|
|
693
|
+
await execute();
|
|
694
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("the listener is dropped when the widget unmounts", async () => {
|
|
698
|
+
render(props({ config: config({ mode: ModeEnum.invisible }) }));
|
|
699
|
+
act(() => {
|
|
700
|
+
root.unmount();
|
|
701
|
+
});
|
|
702
|
+
act(() => {
|
|
703
|
+
root = createRoot(container);
|
|
704
|
+
});
|
|
705
|
+
await execute();
|
|
706
|
+
expect(mocks.start).not.toHaveBeenCalled();
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
describe("an invalidated session", () => {
|
|
711
|
+
const invalidate = (
|
|
712
|
+
key = "CAPTCHA.NO_SESSION_FOUND",
|
|
713
|
+
message = "session gone",
|
|
714
|
+
): void => {
|
|
715
|
+
act(() => {
|
|
716
|
+
mocks.constructions[0]?.updateState({ error: { message, key } });
|
|
717
|
+
});
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
test("the error is shown on the checkbox and the puzzle is torn down", async () => {
|
|
721
|
+
render(props());
|
|
722
|
+
await click();
|
|
723
|
+
invalidate("API.UNKNOWN_ERROR", "something broke");
|
|
724
|
+
expect(mocks.checkboxProps.current?.error).toBe("something broke");
|
|
725
|
+
expect(canvas()).toBeNull();
|
|
726
|
+
expect(mocks.checkboxProps.current?.loading).toBe(false);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test("the host is told to re-mint, with the coordinates of the original click", async () => {
|
|
730
|
+
const onSessionInvalidated = vi.fn<(x?: number, y?: number) => void>();
|
|
731
|
+
render(props({ onSessionInvalidated }));
|
|
732
|
+
await click({ clientX: 12, clientY: 34 });
|
|
733
|
+
invalidate();
|
|
734
|
+
expect(onSessionInvalidated).toHaveBeenCalledWith(12, 34);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("the host is told only once, however many errors arrive", async () => {
|
|
738
|
+
const onSessionInvalidated = vi.fn<(x?: number, y?: number) => void>();
|
|
739
|
+
render(props({ onSessionInvalidated }));
|
|
740
|
+
await click();
|
|
741
|
+
invalidate("CAPTCHA.NO_SESSION_FOUND", "gone");
|
|
742
|
+
invalidate("CAPTCHA.NO_SESSION_FOUND", "gone again");
|
|
743
|
+
expect(onSessionInvalidated).toHaveBeenCalledTimes(1);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test("with no host handler the frictionless session restarts instead", async () => {
|
|
747
|
+
vi.useFakeTimers();
|
|
748
|
+
const restart = vi.fn<() => void>();
|
|
749
|
+
render(props({ frictionlessState: frictionless({ restart }) }));
|
|
750
|
+
invalidate();
|
|
751
|
+
expect(restart).not.toHaveBeenCalled();
|
|
752
|
+
await act(async () => {
|
|
753
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
754
|
+
});
|
|
755
|
+
expect(restart).toHaveBeenCalledTimes(1);
|
|
756
|
+
vi.useRealTimers();
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("a restart pending at unmount is cancelled", async () => {
|
|
760
|
+
vi.useFakeTimers();
|
|
761
|
+
const restart = vi.fn<() => void>();
|
|
762
|
+
render(props({ frictionlessState: frictionless({ restart }) }));
|
|
763
|
+
invalidate();
|
|
764
|
+
act(() => {
|
|
765
|
+
root.unmount();
|
|
766
|
+
});
|
|
767
|
+
await act(async () => {
|
|
768
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
769
|
+
});
|
|
770
|
+
expect(restart).not.toHaveBeenCalled();
|
|
771
|
+
act(() => {
|
|
772
|
+
root = createRoot(container);
|
|
773
|
+
});
|
|
774
|
+
vi.useRealTimers();
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
test("a lost session with nothing to recover it is simply surfaced", async () => {
|
|
778
|
+
render(props());
|
|
779
|
+
invalidate();
|
|
780
|
+
expect(mocks.checkboxProps.current?.error).toBe("session gone");
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test("no coordinates are reported when the session was never clicked into", () => {
|
|
784
|
+
const onSessionInvalidated = vi.fn<(x?: number, y?: number) => void>();
|
|
785
|
+
render(props({ onSessionInvalidated }));
|
|
786
|
+
invalidate();
|
|
787
|
+
expect(onSessionInvalidated).toHaveBeenCalledWith(undefined, undefined);
|
|
788
|
+
});
|
|
789
|
+
});
|