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