@prosopo/procaptcha-puzzle 2.14.0 → 2.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,179 @@
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 { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
24
+ import {
25
+ type ProcaptchaPuzzleHandle,
26
+ mountProcaptchaPuzzleWidget,
27
+ } from "../components/procaptchaWidget.js";
28
+ import { type Mounted, fire, mount, settle } from "./domHarness.js";
29
+ import { challengeResponse, config, frictionless } from "./managerHarness.js";
30
+
31
+ // The canvas is deliberately NOT mocked here: the point of this suite is
32
+ // whether the overlay the user has to drag actually becomes visible. The
33
+ // widget suite stubs the canvas, so it asserts the challenge was handed over,
34
+ // not that anything reached the screen.
35
+ const mocks = vi.hoisted(() => {
36
+ const start =
37
+ vi.fn<
38
+ (x?: number, y?: number) => Promise<GetPuzzleCaptchaResponse | undefined>
39
+ >();
40
+ const submitSolution =
41
+ vi.fn<(x: number, y: number, events: PuzzleEvent[]) => Promise<boolean>>();
42
+ const resetState = vi.fn<() => void>();
43
+ return { start, submitSolution, resetState };
44
+ });
45
+
46
+ vi.mock("../services/Manager.js", () => ({
47
+ Manager: (
48
+ _config: unknown,
49
+ _state: ProcaptchaState,
50
+ _updateState: (next: Partial<ProcaptchaState>) => void,
51
+ ) => ({
52
+ start: mocks.start,
53
+ submitSolution: mocks.submitSolution,
54
+ resetState: mocks.resetState,
55
+ }),
56
+ }));
57
+
58
+ vi.mock("@prosopo/locale", async (importOriginal) => ({
59
+ ...(await importOriginal<typeof import("@prosopo/locale")>()),
60
+ loadI18next: vi.fn(async () => undefined),
61
+ }));
62
+
63
+ const i18nStub = {
64
+ isInitialized: true,
65
+ language: "en",
66
+ t: (key: string) => key,
67
+ changeLanguage: vi.fn(),
68
+ hasLoadedNamespace: () => true,
69
+ on: () => undefined,
70
+ off: () => undefined,
71
+ } as unknown as Ti18n;
72
+
73
+ const props = (overrides: Partial<ProcaptchaProps> = {}): ProcaptchaProps =>
74
+ ({
75
+ config: config({ mode: ModeEnum.visible }),
76
+ callbacks: {},
77
+ i18n: i18nStub,
78
+ frictionlessState: frictionless(),
79
+ ...overrides,
80
+ }) as unknown as ProcaptchaProps;
81
+
82
+ let mounted: Mounted;
83
+ let handle: ProcaptchaPuzzleHandle | undefined;
84
+
85
+ // Class names are hashed by the CSS-module build, so the surface is addressed
86
+ // through the `data-cy` hooks the components ship for exactly this reason.
87
+ const surface = (): HTMLElement | null =>
88
+ document.querySelector<HTMLElement>('[data-cy="challenge-surface"]');
89
+
90
+ // The panel carrying the entrance animation is the only element under the
91
+ // surface with an inline opacity of its own.
92
+ const panelOpacity = (): string | undefined => {
93
+ const content = document.querySelector('[data-cy="challenge-content"]');
94
+ if (!content) return undefined;
95
+ return Array.from(
96
+ content.querySelectorAll<HTMLElement>("[style*='opacity']"),
97
+ ).at(-1)?.style.opacity;
98
+ };
99
+
100
+ // The entrance animation flips opacity inside a requestAnimationFrame, so a
101
+ // test has to let a frame run before reading it.
102
+ const frame = async (): Promise<void> => {
103
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
104
+ await settle();
105
+ };
106
+
107
+ beforeEach(() => {
108
+ mocks.start.mockResolvedValue(challengeResponse());
109
+ mounted = mount();
110
+ });
111
+
112
+ afterEach(() => {
113
+ handle?.destroy();
114
+ handle = undefined;
115
+ mounted.unmount();
116
+ for (const layer of Array.from(
117
+ document.querySelectorAll(".prosopo-challenge-surface"),
118
+ )) {
119
+ layer.remove();
120
+ }
121
+ vi.clearAllMocks();
122
+ });
123
+
124
+ describe("the puzzle overlay actually reaches the screen", () => {
125
+ test("visible mode: the panel is opaque after the entrance frame", async () => {
126
+ handle = mountProcaptchaPuzzleWidget(mounted.container, props());
127
+ const checkbox = mounted.container.querySelector("input");
128
+ expect(checkbox, "the visible widget renders a checkbox").not.toBeNull();
129
+ if (checkbox) fire(checkbox, "click");
130
+ await settle();
131
+ await frame();
132
+
133
+ expect(mocks.start).toHaveBeenCalledTimes(1);
134
+ expect(surface(), "the overlay is mounted").not.toBeNull();
135
+ expect(panelOpacity()).toBe("1");
136
+ });
137
+
138
+ // Invisible mode has no checkbox, so the overlay is opened by the host
139
+ // page's execute(). It still has to reach the screen: the piece is dragged
140
+ // by hand, and a panel left at opacity 0 is a challenge nobody can solve.
141
+ test("invisible mode: the panel is opaque after the entrance frame", async () => {
142
+ handle = mountProcaptchaPuzzleWidget(
143
+ mounted.container,
144
+ props({ config: config({ mode: ModeEnum.invisible }) }),
145
+ );
146
+ document.dispatchEvent(new Event("procaptcha:execute"));
147
+ await settle();
148
+ await frame();
149
+
150
+ expect(mocks.start).toHaveBeenCalledTimes(1);
151
+ expect(surface(), "the overlay is mounted").not.toBeNull();
152
+ expect(panelOpacity()).toBe("1");
153
+ });
154
+
155
+ // The entrance frame is cancelled on teardown, so a canvas destroyed and
156
+ // remounted before it runs would stay transparent for good.
157
+ test("invisible mode: a remounted overlay still becomes opaque", async () => {
158
+ handle = mountProcaptchaPuzzleWidget(
159
+ mounted.container,
160
+ props({ config: config({ mode: ModeEnum.invisible }) }),
161
+ );
162
+ document.dispatchEvent(new Event("procaptcha:execute"));
163
+ await settle();
164
+ await frame();
165
+ expect(panelOpacity()).toBe("1");
166
+
167
+ handle.destroy();
168
+ handle = mountProcaptchaPuzzleWidget(
169
+ mounted.container,
170
+ props({ config: config({ mode: ModeEnum.invisible }) }),
171
+ );
172
+ document.dispatchEvent(new Event("procaptcha:execute"));
173
+ await settle();
174
+ await frame();
175
+
176
+ expect(surface(), "the overlay is mounted again").not.toBeNull();
177
+ expect(panelOpacity()).toBe("1");
178
+ });
179
+ });
@@ -340,11 +340,14 @@ describe("dragging with a mouse", () => {
340
340
  ]);
341
341
  });
342
342
 
343
- test("a grab released without moving reports the origin and no trail", () => {
343
+ // Reporting the origin here spent the challenge on a position that cannot be
344
+ // correct, and the provider rejects the replacement fetch — so a single
345
+ // click that never moved ended the attempt outright.
346
+ test("a grab released without moving reports nothing", () => {
344
347
  render(props());
345
348
  mouseDown(20, 100);
346
349
  mouseUp();
347
- expect(onComplete).toHaveBeenCalledWith(20, 100, []);
350
+ expect(onComplete).not.toHaveBeenCalled();
348
351
  });
349
352
 
350
353
  test("letting go without having grabbed anything reports nothing", () => {
@@ -435,6 +438,19 @@ describe("dragging with a finger", () => {
435
438
  expect(onComplete).not.toHaveBeenCalled();
436
439
  });
437
440
 
441
+ // The panel opens centred, under the finger that just pressed the checkbox,
442
+ // so tapping the piece instead of dragging it is the easy mistake to make on
443
+ // a phone. It used to submit the untouched origin, which fails, and the
444
+ // widget then reset — indistinguishable to the user from the challenge
445
+ // never having worked.
446
+ test("tapping the piece without dragging reports nothing", () => {
447
+ render(props());
448
+ touchStart([{ clientX: 20, clientY: 100 }]);
449
+ touchEnd();
450
+ expect(onComplete).not.toHaveBeenCalled();
451
+ expect(piecePosition()).toEqual({ x: 20, y: 100 });
452
+ });
453
+
438
454
  test("a touchmove carrying no touches is ignored mid-drag", () => {
439
455
  render(props());
440
456
  touchStart([{ clientX: 20, clientY: 100 }]);