@prosopo/procaptcha-audio 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +52 -0
  2. package/.turbo/turbo-build$colon$tsc.log +53 -0
  3. package/.turbo/turbo-build.log +54 -0
  4. package/CHANGELOG.md +25 -0
  5. package/LICENSE +201 -0
  6. package/dist/_virtual/_rolldown/runtime.js +3 -0
  7. package/dist/cjs/components/audioPlayer.cjs +275 -0
  8. package/dist/cjs/components/procaptchaAudio.cjs +7 -0
  9. package/dist/cjs/components/procaptchaWidget.cjs +195 -0
  10. package/dist/cjs/index.cjs +8 -0
  11. package/dist/cjs/services/Manager.cjs +218 -0
  12. package/dist/components/audioPlayer.d.ts +18 -0
  13. package/dist/components/audioPlayer.d.ts.map +1 -0
  14. package/dist/components/audioPlayer.js +275 -0
  15. package/dist/components/audioPlayer.js.map +1 -0
  16. package/dist/components/procaptchaAudio.d.ts +7 -0
  17. package/dist/components/procaptchaAudio.d.ts.map +1 -0
  18. package/dist/components/procaptchaAudio.js +6 -0
  19. package/dist/components/procaptchaAudio.js.map +1 -0
  20. package/dist/components/procaptchaWidget.d.ts +7 -0
  21. package/dist/components/procaptchaWidget.d.ts.map +1 -0
  22. package/dist/components/procaptchaWidget.js +195 -0
  23. package/dist/components/procaptchaWidget.js.map +1 -0
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +5 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/services/Manager.d.ts +10 -0
  29. package/dist/services/Manager.d.ts.map +1 -0
  30. package/dist/services/Manager.js +217 -0
  31. package/dist/services/Manager.js.map +1 -0
  32. package/dist/tests/audioPlayer.unit.test.d.ts +2 -0
  33. package/dist/tests/audioPlayer.unit.test.d.ts.map +1 -0
  34. package/dist/tests/audioPlayer.unit.test.js +341 -0
  35. package/dist/tests/audioPlayer.unit.test.js.map +1 -0
  36. package/dist/tests/domHarness.d.ts +20 -0
  37. package/dist/tests/domHarness.d.ts.map +1 -0
  38. package/dist/tests/domHarness.js +56 -0
  39. package/dist/tests/domHarness.js.map +1 -0
  40. package/dist/tests/manager.unit.test.d.ts +2 -0
  41. package/dist/tests/manager.unit.test.d.ts.map +1 -0
  42. package/dist/tests/manager.unit.test.js +627 -0
  43. package/dist/tests/manager.unit.test.js.map +1 -0
  44. package/dist/tests/managerHarness.d.ts +21 -0
  45. package/dist/tests/managerHarness.d.ts.map +1 -0
  46. package/dist/tests/managerHarness.js +67 -0
  47. package/dist/tests/managerHarness.js.map +1 -0
  48. package/dist/tests/procaptchaAudio.test-d.d.ts +2 -0
  49. package/dist/tests/procaptchaAudio.test-d.d.ts.map +1 -0
  50. package/dist/tests/procaptchaAudio.test-d.js +103 -0
  51. package/dist/tests/procaptchaAudio.test-d.js.map +1 -0
  52. package/dist/tests/procaptchaAudio.unit.test.d.ts +2 -0
  53. package/dist/tests/procaptchaAudio.unit.test.d.ts.map +1 -0
  54. package/dist/tests/procaptchaAudio.unit.test.js +93 -0
  55. package/dist/tests/procaptchaAudio.unit.test.js.map +1 -0
  56. package/dist/tests/procaptchaWidget.unit.test.d.ts +2 -0
  57. package/dist/tests/procaptchaWidget.unit.test.d.ts.map +1 -0
  58. package/dist/tests/procaptchaWidget.unit.test.js +597 -0
  59. package/dist/tests/procaptchaWidget.unit.test.js.map +1 -0
  60. package/dist/tests/setup.d.ts +2 -0
  61. package/dist/tests/setup.d.ts.map +1 -0
  62. package/dist/tests/setup.js +12 -0
  63. package/dist/tests/setup.js.map +1 -0
  64. package/package.json +70 -3
  65. package/vite.cjs.config.ts +22 -0
  66. package/vite.esm.config.ts +20 -0
  67. package/vite.test.config.ts +26 -0
  68. package/README.md +0 -3
@@ -0,0 +1,627 @@
1
+ import { ApiParams, CaptchaType, decodeProcaptchaOutput, } from "@prosopo/types";
2
+ import { extractData } from "@prosopo/util";
3
+ import { afterEach, beforeEach, describe, expect, test, vi, } from "vitest";
4
+ import { Manager } from "../services/Manager.js";
5
+ import { OTHER_PROVIDER_URL, PROVIDER_URL, SITE_KEY, USER_ADDRESS, account, accountWithoutExtension, audioEvents, callbacks, challengeResponse, collector, config, frictionless, randomProvider, signRawMock, solutionResponse, state, timerHandle, } from "./managerHarness.js";
6
+ const mocks = vi.hoisted(() => {
7
+ const providerApiConstructions = [];
8
+ const getAudioCaptchaChallenge = vi.fn();
9
+ const submitAudioCaptchaSolution = vi.fn();
10
+ class ProviderApiMock {
11
+ constructor(url, siteKey) {
12
+ this.getAudioCaptchaChallenge = getAudioCaptchaChallenge;
13
+ this.submitAudioCaptchaSolution = submitAudioCaptchaSolution;
14
+ providerApiConstructions.push({ url, siteKey });
15
+ }
16
+ }
17
+ const getAccount = vi.fn();
18
+ class ExtensionMock {
19
+ constructor() {
20
+ this.getAccount = getAccount;
21
+ }
22
+ }
23
+ const extensionLoader = vi.fn();
24
+ const getProcaptchaRandomActiveProvider = vi.fn();
25
+ const getSimdReadingsForSubmit = vi.fn();
26
+ const sleep = vi.fn();
27
+ return {
28
+ providerApiConstructions,
29
+ getAudioCaptchaChallenge,
30
+ submitAudioCaptchaSolution,
31
+ ProviderApiMock,
32
+ getAccount,
33
+ ExtensionMock,
34
+ extensionLoader,
35
+ getProcaptchaRandomActiveProvider,
36
+ getSimdReadingsForSubmit,
37
+ sleep,
38
+ };
39
+ });
40
+ vi.mock("@prosopo/api", async (importOriginal) => {
41
+ const actual = await importOriginal();
42
+ return { ...actual, ProviderApi: mocks.ProviderApiMock };
43
+ });
44
+ vi.mock("@prosopo/procaptcha-common", async (importOriginal) => {
45
+ const actual = await importOriginal();
46
+ return {
47
+ ...actual,
48
+ ExtensionLoader: mocks.extensionLoader,
49
+ getProcaptchaRandomActiveProvider: mocks.getProcaptchaRandomActiveProvider,
50
+ getSimdReadingsForSubmit: mocks.getSimdReadingsForSubmit,
51
+ providerRetry: (currentFn, retryFn, stateReset, attemptCount, retryMax) => actual.providerRetry(currentFn, retryFn, stateReset, attemptCount, retryMax, 0),
52
+ };
53
+ });
54
+ vi.mock("@prosopo/util", async (importOriginal) => {
55
+ const actual = await importOriginal();
56
+ return { ...actual, sleep: mocks.sleep };
57
+ });
58
+ const build = (options = {}) => {
59
+ const currentState = state(options.initialState);
60
+ const updates = [];
61
+ const events = {
62
+ onHuman: vi.fn(),
63
+ onFailed: vi.fn(),
64
+ onExpired: vi.fn(),
65
+ onReset: vi.fn(),
66
+ };
67
+ const restart = vi.fn();
68
+ const callbackInput = callbacks(events);
69
+ const frictionlessState = options.frictionlessState ??
70
+ (options.withFrictionless === false
71
+ ? undefined
72
+ : frictionless({ restart }));
73
+ const manager = Manager(options.configInput ?? config(), currentState, (next) => {
74
+ updates.push({ ...next });
75
+ }, callbackInput, frictionlessState, options.honeypot, options.onReloadRequest);
76
+ return { manager, state: currentState, updates, events, restart };
77
+ };
78
+ const lastUpdate = (harness, key) => {
79
+ const withKey = harness.updates.filter((update) => key in update);
80
+ return withKey.length === 0 ? undefined : withKey[withKey.length - 1]?.[key];
81
+ };
82
+ const submitArgs = (callIndex = 0) => {
83
+ const call = mocks.submitAudioCaptchaSolution.mock.calls[callIndex];
84
+ if (!call)
85
+ throw new Error("expected a solution to have been submitted");
86
+ return call;
87
+ };
88
+ const solve = async (harness) => {
89
+ await harness.manager.start(11, 22);
90
+ return harness.manager.submitSolution("96475", 2, audioEvents());
91
+ };
92
+ const signRaw = signRawMock;
93
+ beforeEach(() => {
94
+ vi.clearAllMocks();
95
+ mocks.providerApiConstructions.length = 0;
96
+ signRaw.mockResolvedValue({ id: 1, signature: "0xuser-signature" });
97
+ mocks.getAccount.mockResolvedValue(account(signRaw));
98
+ mocks.extensionLoader.mockResolvedValue(mocks.ExtensionMock);
99
+ mocks.getProcaptchaRandomActiveProvider.mockResolvedValue(randomProvider());
100
+ mocks.getAudioCaptchaChallenge.mockResolvedValue(challengeResponse());
101
+ mocks.submitAudioCaptchaSolution.mockResolvedValue(solutionResponse());
102
+ mocks.getSimdReadingsForSubmit.mockResolvedValue("simd-readings");
103
+ mocks.sleep.mockResolvedValue(undefined);
104
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
105
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
106
+ });
107
+ afterEach(() => {
108
+ vi.useRealTimers();
109
+ vi.restoreAllMocks();
110
+ });
111
+ describe("the challenge never carries its own answer", () => {
112
+ test("nothing the provider hands back could hold the transcript", async () => {
113
+ const harness = build();
114
+ const challenge = await harness.manager.start();
115
+ if (!challenge)
116
+ throw new Error("expected a challenge");
117
+ expect(Object.keys(challenge)).not.toContain("answer");
118
+ expect(Object.keys(challenge)).not.toContain("solution");
119
+ expect(Object.keys(challenge)).not.toContain("transcript");
120
+ });
121
+ });
122
+ describe("start: the cases it refuses to run", () => {
123
+ test("a solve already in flight is left alone", async () => {
124
+ const harness = build({ initialState: { loading: true } });
125
+ await harness.manager.start();
126
+ expect(mocks.getAudioCaptchaChallenge).not.toHaveBeenCalled();
127
+ expect(harness.updates).toHaveLength(0);
128
+ });
129
+ test("a user already proven human is not asked again", async () => {
130
+ const harness = build({ initialState: { isHuman: true } });
131
+ await harness.manager.start();
132
+ expect(mocks.getAudioCaptchaChallenge).not.toHaveBeenCalled();
133
+ expect(harness.events.onHuman).not.toHaveBeenCalled();
134
+ });
135
+ test("a refused start still resolves, with no challenge to hand back", async () => {
136
+ const harness = build({ initialState: { isHuman: true } });
137
+ await expect(harness.manager.start()).resolves.toBeUndefined();
138
+ });
139
+ });
140
+ describe("start: getting to a challenge", () => {
141
+ test("resets the widget first, so a stale challenge cannot linger", async () => {
142
+ const harness = build({ initialState: { index: 3, showModal: true } });
143
+ await harness.manager.start();
144
+ const resetAt = harness.updates.findIndex((update) => "showModal" in update);
145
+ expect(harness.updates[resetAt]).toMatchObject({
146
+ showModal: false,
147
+ loading: false,
148
+ index: 0,
149
+ isHuman: false,
150
+ });
151
+ expect(harness.events.onReset).toHaveBeenCalled();
152
+ });
153
+ test("counts the attempt, so provider selection can rotate on a retry", async () => {
154
+ const harness = build({ initialState: { attemptCount: 2 } });
155
+ await harness.manager.start();
156
+ expect(lastUpdate(harness, "attemptCount")).toBe(3);
157
+ });
158
+ test("asks the provider named by the frictionless state, when there is one", async () => {
159
+ const harness = build();
160
+ await harness.manager.start();
161
+ expect(mocks.getProcaptchaRandomActiveProvider).not.toHaveBeenCalled();
162
+ expect(mocks.providerApiConstructions).toEqual([
163
+ { url: PROVIDER_URL, siteKey: SITE_KEY },
164
+ ]);
165
+ });
166
+ test("picks a provider itself when the frictionless state has none", async () => {
167
+ const harness = build({
168
+ frictionlessState: frictionless({ provider: undefined }),
169
+ });
170
+ await harness.manager.start();
171
+ expect(mocks.getProcaptchaRandomActiveProvider).toHaveBeenCalledWith("production", undefined, { attempt: 1, excludeUrl: undefined });
172
+ });
173
+ test("excludes the provider that just failed from the next pick", async () => {
174
+ const harness = build({
175
+ frictionlessState: frictionless({ provider: undefined }),
176
+ });
177
+ await harness.manager.start();
178
+ await harness.manager.start();
179
+ expect(mocks.getProcaptchaRandomActiveProvider).toHaveBeenLastCalledWith("production", undefined, expect.objectContaining({ excludeUrl: PROVIDER_URL }));
180
+ });
181
+ test("loads an extension account when there is no frictionless state", async () => {
182
+ const harness = build({ withFrictionless: false });
183
+ await harness.manager.start();
184
+ expect(mocks.extensionLoader).toHaveBeenCalled();
185
+ expect(lastUpdate(harness, "account")).toEqual({
186
+ account: { address: USER_ADDRESS },
187
+ });
188
+ });
189
+ test("records the site key it was configured with as the dapp account", async () => {
190
+ const harness = build();
191
+ await harness.manager.start();
192
+ expect(lastUpdate(harness, "dappAccount")).toBe(SITE_KEY);
193
+ });
194
+ test("passes the session id and prefetched SIMD readings to the challenge call", async () => {
195
+ const getSimdReadings = vi.fn();
196
+ getSimdReadings.mockResolvedValue("prefetched");
197
+ const harness = build({
198
+ frictionlessState: frictionless({
199
+ sessionId: "session-1",
200
+ getSimdReadings,
201
+ }),
202
+ });
203
+ await harness.manager.start();
204
+ expect(mocks.getAudioCaptchaChallenge).toHaveBeenCalledWith(USER_ADDRESS, SITE_KEY, "session-1", "prefetched");
205
+ expect(getSimdReadings).toHaveBeenCalledWith(0);
206
+ });
207
+ test("omits SIMD readings when the frictionless state cannot supply them", async () => {
208
+ const harness = build();
209
+ await harness.manager.start();
210
+ expect(mocks.getAudioCaptchaChallenge).toHaveBeenCalledWith(USER_ADDRESS, SITE_KEY, undefined, undefined);
211
+ });
212
+ test("hands the clip back and drops the loading flag", async () => {
213
+ const harness = build();
214
+ await expect(harness.manager.start()).resolves.toEqual(challengeResponse());
215
+ expect(lastUpdate(harness, "loading")).toBe(false);
216
+ });
217
+ });
218
+ describe("start: a session that has already bought a challenge", () => {
219
+ test("short-circuits instead of re-requesting on the spent id", async () => {
220
+ const harness = build({
221
+ frictionlessState: frictionless({ sessionId: "session-1" }),
222
+ });
223
+ await harness.manager.start();
224
+ await harness.manager.start();
225
+ expect(mocks.getAudioCaptchaChallenge).toHaveBeenCalledTimes(1);
226
+ expect(lastUpdate(harness, "error")).toEqual({
227
+ message: "No session found",
228
+ key: "CAPTCHA.NO_SESSION_FOUND",
229
+ });
230
+ expect(lastUpdate(harness, "loading")).toBe(false);
231
+ });
232
+ test("still fails over to another provider when the request throws", async () => {
233
+ mocks.getAudioCaptchaChallenge
234
+ .mockRejectedValueOnce(new Error("transport"))
235
+ .mockResolvedValue(challengeResponse());
236
+ const harness = build({
237
+ frictionlessState: frictionless({ sessionId: "session-1" }),
238
+ });
239
+ await harness.manager.start();
240
+ expect(mocks.getAudioCaptchaChallenge).toHaveBeenCalledTimes(2);
241
+ expect(lastUpdate(harness, "error")).toBeUndefined();
242
+ });
243
+ test("counts the id as spent when the provider answers with an error", async () => {
244
+ mocks.getAudioCaptchaChallenge.mockResolvedValue({
245
+ ...challengeResponse(),
246
+ error: { message: "nope", key: "API.BAD_REQUEST", code: 400 },
247
+ });
248
+ const harness = build({
249
+ frictionlessState: frictionless({ sessionId: "session-1" }),
250
+ });
251
+ await harness.manager.start();
252
+ mocks.getAudioCaptchaChallenge.mockClear();
253
+ await harness.manager.start();
254
+ expect(mocks.getAudioCaptchaChallenge).not.toHaveBeenCalled();
255
+ });
256
+ test("leaves a sessionless manager free to ask again", async () => {
257
+ const harness = build({ withFrictionless: false });
258
+ await harness.manager.start();
259
+ await harness.manager.start();
260
+ expect(mocks.getAudioCaptchaChallenge).toHaveBeenCalledTimes(2);
261
+ });
262
+ });
263
+ describe("start: when the provider will not issue a challenge", () => {
264
+ test("surfaces the provider's error to the widget", async () => {
265
+ mocks.getAudioCaptchaChallenge.mockResolvedValue(challengeResponse({
266
+ error: {
267
+ message: "no session",
268
+ key: "CAPTCHA.NO_SESSION_FOUND",
269
+ code: 400,
270
+ },
271
+ }));
272
+ const harness = build();
273
+ await harness.manager.start();
274
+ expect(lastUpdate(harness, "error")).toEqual({
275
+ message: "no session",
276
+ key: "CAPTCHA.NO_SESSION_FOUND",
277
+ });
278
+ expect(lastUpdate(harness, "loading")).toBe(false);
279
+ });
280
+ test("labels an error the provider left unkeyed", async () => {
281
+ mocks.getAudioCaptchaChallenge.mockResolvedValue(challengeResponse({ error: { message: "something broke", code: 500 } }));
282
+ const harness = build();
283
+ await harness.manager.start();
284
+ expect(lastUpdate(harness, "error")).toEqual({
285
+ message: "something broke",
286
+ key: "API.UNKNOWN_ERROR",
287
+ });
288
+ });
289
+ test("keeps no challenge, so a submit afterwards cannot pretend one exists", async () => {
290
+ mocks.getAudioCaptchaChallenge.mockResolvedValue(challengeResponse({ error: { message: "no session", code: 400 } }));
291
+ const harness = build();
292
+ await expect(harness.manager.start()).resolves.toBeUndefined();
293
+ await expect(harness.manager.submitSolution("1", 0, [])).rejects.toThrow();
294
+ });
295
+ test("web3 mode without a configured account address refuses to continue", async () => {
296
+ const harness = build({
297
+ configInput: config({ web2: false, userAccountAddress: "" }),
298
+ frictionlessState: frictionless({ provider: undefined }),
299
+ });
300
+ await harness.manager.start();
301
+ expect(mocks.getAudioCaptchaChallenge).not.toHaveBeenCalled();
302
+ });
303
+ test("retries against a fresh provider when the challenge call throws", async () => {
304
+ mocks.getAudioCaptchaChallenge
305
+ .mockRejectedValueOnce(new Error("provider down"))
306
+ .mockResolvedValue(challengeResponse());
307
+ mocks.getProcaptchaRandomActiveProvider
308
+ .mockResolvedValueOnce(randomProvider())
309
+ .mockResolvedValue(randomProvider(OTHER_PROVIDER_URL));
310
+ const harness = build({
311
+ frictionlessState: frictionless({ provider: undefined }),
312
+ });
313
+ await harness.manager.start();
314
+ expect(mocks.getAudioCaptchaChallenge).toHaveBeenCalledTimes(2);
315
+ expect(mocks.providerApiConstructions.map((c) => c.url)).toEqual([
316
+ PROVIDER_URL,
317
+ OTHER_PROVIDER_URL,
318
+ ]);
319
+ });
320
+ test("gives up once the retry budget is spent", async () => {
321
+ mocks.getAudioCaptchaChallenge.mockRejectedValue(new Error("down"));
322
+ const harness = build({
323
+ frictionlessState: frictionless({ provider: undefined }),
324
+ });
325
+ await harness.manager.start();
326
+ expect(mocks.getAudioCaptchaChallenge.mock.calls.length).toBeLessThan(5);
327
+ expect(harness.events.onReset).toHaveBeenCalled();
328
+ });
329
+ });
330
+ describe("submitSolution: refusing to submit without a challenge", () => {
331
+ test("throws when start was never called", async () => {
332
+ const harness = build();
333
+ await expect(harness.manager.submitSolution("96475", 0, audioEvents())).rejects.toThrow();
334
+ });
335
+ test("throws again after a reset has cleared the stored challenge", async () => {
336
+ const harness = build();
337
+ await harness.manager.start();
338
+ harness.manager.resetState();
339
+ await expect(harness.manager.submitSolution("96475", 0, audioEvents())).rejects.toThrow();
340
+ });
341
+ test("refuses when the account carries no signer", async () => {
342
+ const harness = build({
343
+ frictionlessState: frictionless({ userAccount: account() }),
344
+ });
345
+ await harness.manager.start();
346
+ await expect(harness.manager.submitSolution("96475", 0, audioEvents())).rejects.toThrow();
347
+ expect(lastUpdate(harness, "loading")).toBe(false);
348
+ });
349
+ test("refuses when the account has no extension at all", async () => {
350
+ const harness = build({
351
+ frictionlessState: frictionless({
352
+ userAccount: accountWithoutExtension(),
353
+ }),
354
+ });
355
+ await harness.manager.start();
356
+ await expect(harness.manager.submitSolution("96475", 0, audioEvents())).rejects.toThrow();
357
+ });
358
+ test("clears loading and rethrows when the provider call fails", async () => {
359
+ mocks.submitAudioCaptchaSolution.mockRejectedValue(new Error("boom"));
360
+ const harness = build();
361
+ await harness.manager.start();
362
+ await expect(harness.manager.submitSolution("96475", 0, audioEvents())).rejects.toThrow("boom");
363
+ expect(lastUpdate(harness, "loading")).toBe(false);
364
+ });
365
+ });
366
+ describe("submitSolution: what it sends", () => {
367
+ test("sends the typed answer, replay count and event trail as given", async () => {
368
+ const harness = build();
369
+ const events = audioEvents();
370
+ await harness.manager.start();
371
+ await harness.manager.submitSolution("8 3 7 4 4", 3, events);
372
+ const args = submitArgs();
373
+ expect(args[3]).toBe("8 3 7 4 4");
374
+ expect(args[4]).toBe(3);
375
+ expect(args[5]).toEqual(events);
376
+ });
377
+ test("an empty event trail is submitted rather than rejected", async () => {
378
+ const harness = build();
379
+ await harness.manager.start();
380
+ await harness.manager.submitSolution("96475", 0, []);
381
+ expect(submitArgs()[5]).toEqual([]);
382
+ });
383
+ test("signs the challenge timestamp with the user's key", async () => {
384
+ const harness = build();
385
+ await harness.manager.start();
386
+ await harness.manager.submitSolution("96475", 0, audioEvents());
387
+ expect(signRaw).toHaveBeenCalledWith(expect.objectContaining({ address: USER_ADDRESS, type: "bytes" }));
388
+ expect(submitArgs()[6]).toBe("0xuser-signature");
389
+ });
390
+ test("embeds the checkbox coordinates in the salt", async () => {
391
+ const harness = build();
392
+ await solve(harness);
393
+ const salt = submitArgs()[8];
394
+ if (!salt)
395
+ throw new Error("expected a salt");
396
+ expect(extractData(salt)).toEqual([11, 22]);
397
+ });
398
+ test("attaches the SIMD readings gathered at submit time", async () => {
399
+ const harness = build();
400
+ await solve(harness);
401
+ expect(submitArgs()[9]).toBe("simd-readings");
402
+ });
403
+ test("sends the honeypot value when the input was filled", async () => {
404
+ const harness = build({ honeypot: () => "trap" });
405
+ await solve(harness);
406
+ expect(submitArgs()[10]).toEqual({ hp: "trap" });
407
+ });
408
+ test("sends the site's session id so the verify call can correlate it", async () => {
409
+ const harness = build({
410
+ configInput: config({ clientSessionId: "client-session-1" }),
411
+ });
412
+ await solve(harness);
413
+ expect(submitArgs()[10]).toEqual({ clientSessionId: "client-session-1" });
414
+ });
415
+ test("sends no client metadata when the honeypot is empty", async () => {
416
+ const harness = build({ honeypot: () => undefined });
417
+ await solve(harness);
418
+ expect(submitArgs()[10]).toBeUndefined();
419
+ });
420
+ test("sends no client metadata when there is no honeypot at all", async () => {
421
+ const harness = build();
422
+ await solve(harness);
423
+ expect(submitArgs()[10]).toBeUndefined();
424
+ });
425
+ });
426
+ describe("submitSolution: behavioural data", () => {
427
+ const withCollectors = (encryptBehavioralData) => frictionless({
428
+ encryptBehavioralData,
429
+ behaviorCollector1: collector([{ x: 1, y: 2, timestamp: 3 }]),
430
+ deviceCapability: "high",
431
+ });
432
+ test("encrypts and attaches what the collectors gathered", async () => {
433
+ const encrypt = vi.fn();
434
+ encrypt.mockResolvedValue("encrypted");
435
+ const harness = build({ frictionlessState: withCollectors(encrypt) });
436
+ await solve(harness);
437
+ expect(submitArgs()[7]).toBe("encrypted");
438
+ const payload = JSON.parse(encrypt.mock.calls[0]?.[0] ?? "null");
439
+ expect(payload).toMatchObject({
440
+ collector1: [{ x: 1, y: 2, timestamp: 3 }],
441
+ collector2: [],
442
+ collector3: [],
443
+ deviceCapability: "high",
444
+ });
445
+ });
446
+ test("sends the scroll trail even when it is the only one gathered", async () => {
447
+ const encrypt = vi.fn();
448
+ encrypt.mockResolvedValue("encrypted");
449
+ const harness = build({
450
+ frictionlessState: frictionless({
451
+ encryptBehavioralData: encrypt,
452
+ behaviorCollector4: collector([{ x: 0, y: 40, timestamp: 9 }]),
453
+ }),
454
+ });
455
+ await solve(harness);
456
+ expect(submitArgs()[7]).toBe("encrypted");
457
+ const payload = JSON.parse(encrypt.mock.calls[0]?.[0] ?? "null");
458
+ expect(payload).toMatchObject({
459
+ collector4: [{ x: 0, y: 40, timestamp: 9 }],
460
+ });
461
+ });
462
+ test("submits without behavioural data when encryption fails", async () => {
463
+ const encrypt = vi.fn();
464
+ encrypt.mockRejectedValue(new Error("no key"));
465
+ const harness = build({ frictionlessState: withCollectors(encrypt) });
466
+ await expect(solve(harness)).resolves.toBe(true);
467
+ expect(submitArgs()[7]).toBeUndefined();
468
+ });
469
+ test("packs the data first when a packer is provided", async () => {
470
+ const packed = {
471
+ c1: [],
472
+ c2: [],
473
+ c3: [],
474
+ d: "high",
475
+ };
476
+ const encrypt = vi.fn();
477
+ encrypt.mockResolvedValue("encrypted");
478
+ const base = withCollectors(encrypt);
479
+ const harness = build({
480
+ frictionlessState: {
481
+ ...base,
482
+ packBehavioralData: () => packed,
483
+ },
484
+ });
485
+ await solve(harness);
486
+ expect(encrypt).toHaveBeenCalledWith(JSON.stringify(packed));
487
+ });
488
+ test("skips the whole step when no collector is attached", async () => {
489
+ const encrypt = vi.fn();
490
+ const harness = build({
491
+ frictionlessState: frictionless({ encryptBehavioralData: encrypt }),
492
+ });
493
+ await solve(harness);
494
+ expect(encrypt).not.toHaveBeenCalled();
495
+ expect(submitArgs()[7]).toBeUndefined();
496
+ });
497
+ test("skips the whole step when nothing can encrypt", async () => {
498
+ const harness = build({
499
+ frictionlessState: frictionless({
500
+ behaviorCollector1: collector([]),
501
+ }),
502
+ });
503
+ await solve(harness);
504
+ expect(submitArgs()[7]).toBeUndefined();
505
+ });
506
+ });
507
+ describe("submitSolution: the verdict", () => {
508
+ test("marks the user human and emits a token the provider can verify", async () => {
509
+ const harness = build();
510
+ await expect(solve(harness)).resolves.toBe(true);
511
+ expect(lastUpdate(harness, "isHuman")).toBe(true);
512
+ const token = harness.events.onHuman.mock.calls[0]?.[0];
513
+ if (!token)
514
+ throw new Error("expected a token");
515
+ expect(decodeProcaptchaOutput(token)).toMatchObject({
516
+ [ApiParams.providerUrl]: PROVIDER_URL,
517
+ [ApiParams.user]: USER_ADDRESS,
518
+ [ApiParams.dapp]: SITE_KEY,
519
+ [ApiParams.captchaType]: CaptchaType.audio,
520
+ });
521
+ });
522
+ test("expires the human state once the solution timeout elapses", async () => {
523
+ vi.useFakeTimers();
524
+ const harness = build();
525
+ await solve(harness);
526
+ expect(lastUpdate(harness, "successfullChallengeTimeout")).toBeDefined();
527
+ await vi.runOnlyPendingTimersAsync();
528
+ expect(harness.events.onExpired).toHaveBeenCalled();
529
+ expect(lastUpdate(harness, "isHuman")).toBe(false);
530
+ });
531
+ test("a disposed manager never expires the solve", async () => {
532
+ vi.useFakeTimers();
533
+ const harness = build();
534
+ await solve(harness);
535
+ harness.manager.dispose();
536
+ await vi.runOnlyPendingTimersAsync();
537
+ expect(harness.events.onExpired).not.toHaveBeenCalled();
538
+ });
539
+ test("a solve that lands after dispose never reaches the site", async () => {
540
+ const harness = build();
541
+ await harness.manager.start(11, 22);
542
+ harness.manager.dispose();
543
+ await harness.manager.submitSolution("96475", 2, audioEvents());
544
+ expect(harness.events.onHuman).not.toHaveBeenCalled();
545
+ expect(harness.events.onFailed).not.toHaveBeenCalled();
546
+ });
547
+ test("a rejection that lands after dispose asks for no fresh session", async () => {
548
+ mocks.submitAudioCaptchaSolution.mockResolvedValue(solutionResponse({ verified: false }));
549
+ const onReloadRequest = vi.fn();
550
+ const harness = build({ onReloadRequest });
551
+ await harness.manager.start(11, 22);
552
+ harness.manager.dispose();
553
+ await harness.manager.submitSolution("96475", 2, audioEvents());
554
+ expect(onReloadRequest).not.toHaveBeenCalled();
555
+ expect(harness.restart).not.toHaveBeenCalled();
556
+ });
557
+ test("a rejected answer fails the widget and restarts frictionless", async () => {
558
+ mocks.submitAudioCaptchaSolution.mockResolvedValue(solutionResponse({ verified: false }));
559
+ const harness = build();
560
+ await expect(solve(harness)).resolves.toBe(false);
561
+ expect(harness.events.onFailed).toHaveBeenCalled();
562
+ expect(harness.events.onHuman).not.toHaveBeenCalled();
563
+ expect(harness.restart).toHaveBeenCalled();
564
+ expect(lastUpdate(harness, "isHuman")).toBe(false);
565
+ });
566
+ test("a rejected answer asks the wrapper for a fresh session, not a restart", async () => {
567
+ mocks.submitAudioCaptchaSolution.mockResolvedValue(solutionResponse({ verified: false }));
568
+ const onReloadRequest = vi.fn();
569
+ const harness = build({ onReloadRequest });
570
+ await expect(solve(harness)).resolves.toBe(false);
571
+ expect(onReloadRequest).toHaveBeenCalledWith(11, 22);
572
+ expect(harness.restart).not.toHaveBeenCalled();
573
+ expect(harness.events.onFailed).toHaveBeenCalled();
574
+ });
575
+ test("a rejected answer without a frictionless state still resets", async () => {
576
+ mocks.submitAudioCaptchaSolution.mockResolvedValue(solutionResponse({ verified: false }));
577
+ const harness = build({ withFrictionless: false });
578
+ await expect(solve(harness)).resolves.toBe(false);
579
+ expect(harness.events.onReset).toHaveBeenCalled();
580
+ });
581
+ test("a spent challenge cannot be answered twice", async () => {
582
+ const harness = build();
583
+ await solve(harness);
584
+ harness.manager.resetState();
585
+ await expect(harness.manager.submitSolution("96475", 2, audioEvents())).rejects.toThrow();
586
+ expect(mocks.submitAudioCaptchaSolution).toHaveBeenCalledTimes(1);
587
+ });
588
+ });
589
+ describe("resetState", () => {
590
+ test("clears the widget back to its defaults and announces the reset", () => {
591
+ const harness = build();
592
+ harness.manager.resetState();
593
+ expect(harness.updates).toContainEqual(expect.objectContaining({
594
+ showModal: false,
595
+ loading: false,
596
+ index: 0,
597
+ isHuman: false,
598
+ }));
599
+ expect(harness.events.onReset).toHaveBeenCalledTimes(1);
600
+ });
601
+ test("clears both timers so a stale expiry cannot fire later", () => {
602
+ const clear = vi.spyOn(window, "clearTimeout");
603
+ const harness = build({
604
+ initialState: {
605
+ timeout: timerHandle(11),
606
+ successfullChallengeTimeout: timerHandle(22),
607
+ },
608
+ });
609
+ harness.manager.resetState();
610
+ expect(clear).toHaveBeenCalledWith(timerHandle(11));
611
+ expect(clear).toHaveBeenCalledWith(timerHandle(22));
612
+ });
613
+ test("runs the frictionless restart it is handed", () => {
614
+ const restart = vi.fn();
615
+ const harness = build();
616
+ harness.manager.resetState(restart);
617
+ expect(restart).toHaveBeenCalledTimes(1);
618
+ });
619
+ test("a start after a reset re-fetches rather than reusing the old clip", async () => {
620
+ const harness = build();
621
+ await harness.manager.start();
622
+ harness.manager.resetState();
623
+ await harness.manager.start();
624
+ expect(mocks.getAudioCaptchaChallenge).toHaveBeenCalledTimes(2);
625
+ });
626
+ });
627
+ //# sourceMappingURL=manager.unit.test.js.map