@prosopo/procaptcha-frictionless 2.15.0 → 2.16.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 +11 -10
- package/.turbo/turbo-build$colon$tsc.log +24 -24
- package/.turbo/turbo-build.log +13 -12
- package/CHANGELOG.md +73 -0
- package/dist/AuthenticatedBadge.d.ts +12 -0
- package/dist/AuthenticatedBadge.d.ts.map +1 -0
- package/dist/AuthenticatedBadge.js +75 -0
- package/dist/AuthenticatedBadge.js.map +1 -0
- package/dist/ProcaptchaFrictionless.d.ts.map +1 -1
- package/dist/ProcaptchaFrictionless.js +35 -9
- package/dist/ProcaptchaFrictionless.js.map +1 -1
- package/dist/cjs/AuthenticatedBadge.cjs +75 -0
- package/dist/cjs/ProcaptchaFrictionless.cjs +35 -9
- package/dist/cjs/customDetectBot.cjs +3 -1
- package/dist/cjs/detectorLoader.cjs +42 -13
- package/dist/cjs/sessionInvalidatedRecovery.cjs +16 -9
- package/dist/customDetectBot.d.ts.map +1 -1
- package/dist/customDetectBot.js +3 -1
- package/dist/customDetectBot.js.map +1 -1
- package/dist/detectorLoader.d.ts +2 -1
- package/dist/detectorLoader.d.ts.map +1 -1
- package/dist/detectorLoader.js +42 -13
- package/dist/detectorLoader.js.map +1 -1
- package/dist/sessionInvalidatedRecovery.d.ts +3 -1
- package/dist/sessionInvalidatedRecovery.d.ts.map +1 -1
- package/dist/sessionInvalidatedRecovery.js +16 -9
- package/dist/sessionInvalidatedRecovery.js.map +1 -1
- package/dist/tests/detectorLoader.test.d.ts +2 -0
- package/dist/tests/detectorLoader.test.d.ts.map +1 -0
- package/dist/tests/detectorLoader.test.js +67 -0
- package/dist/tests/detectorLoader.test.js.map +1 -0
- package/dist/tests/escalationHandoff.integration.test.d.ts +5 -0
- package/dist/tests/escalationHandoff.integration.test.d.ts.map +1 -0
- package/dist/tests/escalationHandoff.integration.test.js +216 -0
- package/dist/tests/escalationHandoff.integration.test.js.map +1 -0
- package/dist/tests/sessionInvalidatedRecovery.test.js +41 -21
- package/dist/tests/sessionInvalidatedRecovery.test.js.map +1 -1
- package/dist/tests/sessionInvalidatedRemint.test.d.ts +5 -0
- package/dist/tests/sessionInvalidatedRemint.test.d.ts.map +1 -0
- package/dist/tests/sessionInvalidatedRemint.test.js +150 -0
- package/dist/tests/sessionInvalidatedRemint.test.js.map +1 -0
- package/package.json +8 -8
- package/src/AuthenticatedBadge.tsx +141 -0
- package/src/ProcaptchaFrictionless.tsx +85 -16
- package/src/customDetectBot.ts +2 -0
- package/src/detectorLoader.ts +52 -12
- package/src/sessionInvalidatedRecovery.ts +30 -10
- package/src/tests/detectorLoader.test.ts +108 -0
- package/src/tests/escalationHandoff.integration.test.tsx +354 -0
- package/src/tests/sessionInvalidatedRecovery.test.ts +49 -21
- package/src/tests/sessionInvalidatedRemint.test.tsx +245 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,354 @@
|
|
|
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
|
+
/**
|
|
16
|
+
* Post-PoW escalation, end to end on the client: the real
|
|
17
|
+
* ProcaptchaFrictionless wrapper hands off to the real image widget and its
|
|
18
|
+
* real Manager. Only the network is stubbed, and the stub enforces the
|
|
19
|
+
* provider's actual contract — `checkAndRemoveSession` consumes a session the
|
|
20
|
+
* moment it issues a challenge, so a second `/captcha/image` on the same
|
|
21
|
+
* sessionId is answered with 400 CAPTCHA.NO_SESSION_FOUND.
|
|
22
|
+
*
|
|
23
|
+
* Observed in production on provider 3.8.5:
|
|
24
|
+
*
|
|
25
|
+
* 11:27:49.910 POST /pow/solution 200 escalation envelope returned
|
|
26
|
+
* 11:27:50.263 POST /captcha/image 200 escalation session issued
|
|
27
|
+
* 11:27:55.030 POST /captcha/image 400 CAPTCHA.NO_SESSION_FOUND, same session
|
|
28
|
+
*
|
|
29
|
+
* The PoW manager fires `onEscalate` from inside its `providerRetry`-wrapped
|
|
30
|
+
* `submit()`, so a throw after the handoff re-runs submit and escalates a
|
|
31
|
+
* second time on the same envelope.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { Ti18n } from "@prosopo/locale";
|
|
35
|
+
import {
|
|
36
|
+
type Account,
|
|
37
|
+
ApiParams,
|
|
38
|
+
type BotDetectionFunction,
|
|
39
|
+
type CaptchaResponseBody,
|
|
40
|
+
CaptchaType,
|
|
41
|
+
ModeEnum,
|
|
42
|
+
type ProcaptchaClientConfigInput,
|
|
43
|
+
type ProcaptchaProps,
|
|
44
|
+
type RandomProvider,
|
|
45
|
+
} from "@prosopo/types";
|
|
46
|
+
import { type ReactElement, act, createElement } from "react";
|
|
47
|
+
import { type Root, createRoot } from "react-dom/client";
|
|
48
|
+
import {
|
|
49
|
+
type Mock,
|
|
50
|
+
afterEach,
|
|
51
|
+
beforeEach,
|
|
52
|
+
describe,
|
|
53
|
+
expect,
|
|
54
|
+
it,
|
|
55
|
+
vi,
|
|
56
|
+
} from "vitest";
|
|
57
|
+
|
|
58
|
+
declare global {
|
|
59
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
63
|
+
|
|
64
|
+
const ESCALATION_SESSION_ID = "provider-session-escalated";
|
|
65
|
+
const FIRST_SESSION_ID = "provider-session-1";
|
|
66
|
+
|
|
67
|
+
// The provider's one-shot session contract, as a stub: the first challenge
|
|
68
|
+
// fetch for a sessionId succeeds, every later one fails the way
|
|
69
|
+
// `checkAndRemoveSession` makes it fail.
|
|
70
|
+
const mocks = vi.hoisted(() => {
|
|
71
|
+
const challengeRequests: (string | undefined)[] = [];
|
|
72
|
+
const consumed = new Set<string>();
|
|
73
|
+
|
|
74
|
+
const body = (): CaptchaResponseBody => ({
|
|
75
|
+
captchas: [
|
|
76
|
+
{
|
|
77
|
+
captchaId: "captcha-id-1",
|
|
78
|
+
captchaContentId: "captcha-content-id-1",
|
|
79
|
+
datasetId: "dataset-id",
|
|
80
|
+
salt: "0xsalt",
|
|
81
|
+
target: "cars",
|
|
82
|
+
items: [],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
requestHash: "0xrequest-hash",
|
|
86
|
+
timestamp: "1700000000000",
|
|
87
|
+
signature: { provider: { requestHash: "0xprovider-request-hash" } },
|
|
88
|
+
status: "ok",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const getCaptchaChallenge = async (
|
|
92
|
+
_userAccount: string,
|
|
93
|
+
_provider: unknown,
|
|
94
|
+
sessionId?: string,
|
|
95
|
+
): Promise<CaptchaResponseBody> => {
|
|
96
|
+
challengeRequests.push(sessionId);
|
|
97
|
+
if (sessionId && consumed.has(sessionId)) {
|
|
98
|
+
// A 400 carrying a JSON body is *returned*, not thrown —
|
|
99
|
+
// HttpClientBase only throws when the failure isn't JSON. This is
|
|
100
|
+
// the envelope the provider's ProsopoApiError serialises to, and
|
|
101
|
+
// `error.key` is what the widget's recovery path keys off.
|
|
102
|
+
return {
|
|
103
|
+
...body(),
|
|
104
|
+
captchas: [],
|
|
105
|
+
error: {
|
|
106
|
+
key: "CAPTCHA.NO_SESSION_FOUND",
|
|
107
|
+
message: "No session found",
|
|
108
|
+
code: 400,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (sessionId) consumed.add(sessionId);
|
|
113
|
+
return body();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
class ProviderApiMock {
|
|
117
|
+
public getCaptchaChallenge = getCaptchaChallenge;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { challengeRequests, consumed, ProviderApiMock };
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
vi.mock("@prosopo/api", async (importOriginal) => {
|
|
124
|
+
const actual = await importOriginal<typeof import("@prosopo/api")>();
|
|
125
|
+
return { ...actual, ProviderApi: mocks.ProviderApiMock };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// The image Manager constructs an extension before it can ask for a challenge
|
|
129
|
+
// (it then reads the account off the frictionless state, and nothing in this
|
|
130
|
+
// flow signs anything). `ExtensionLoader` resolves to the class, so the mock
|
|
131
|
+
// has to as well.
|
|
132
|
+
class ExtensionMock {
|
|
133
|
+
public getAccount = async (): Promise<Account> => ({
|
|
134
|
+
account: { address: "user-address" },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
vi.mock("@prosopo/procaptcha-common", async (importOriginal) => {
|
|
139
|
+
const actual =
|
|
140
|
+
await importOriginal<typeof import("@prosopo/procaptcha-common")>();
|
|
141
|
+
return {
|
|
142
|
+
...actual,
|
|
143
|
+
isSecureBrowserContext: () => true,
|
|
144
|
+
ExtensionLoader: async (_web2: boolean) => ExtensionMock,
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
vi.mock("@prosopo/locale", async (importOriginal) => {
|
|
149
|
+
const actual = await importOriginal<typeof import("@prosopo/locale")>();
|
|
150
|
+
return {
|
|
151
|
+
...actual,
|
|
152
|
+
useTranslation: () => ({ t: (key: string) => key, ready: true }),
|
|
153
|
+
loadI18next: async () => undefined,
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// The PoW widget is the escalation *source*, so it stays a stub — the test
|
|
158
|
+
// drives its `onEscalate` callback directly. The image widget below it is real.
|
|
159
|
+
const powMounts: { props: ProcaptchaProps }[] = [];
|
|
160
|
+
vi.mock("@prosopo/procaptcha-pow", () => ({
|
|
161
|
+
ProcaptchaPow: (props: ProcaptchaProps) => {
|
|
162
|
+
powMounts.push({ props });
|
|
163
|
+
return createElement("div", { "data-widget": "pow" });
|
|
164
|
+
},
|
|
165
|
+
}));
|
|
166
|
+
vi.mock("@prosopo/procaptcha-puzzle", () => ({
|
|
167
|
+
ProcaptchaPuzzle: () => createElement("div", { "data-widget": "puzzle" }),
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
const { ProcaptchaFrictionless } = await import("../ProcaptchaFrictionless.js");
|
|
171
|
+
|
|
172
|
+
const provider: RandomProvider = {
|
|
173
|
+
providerAccount: "provider-account",
|
|
174
|
+
provider: { url: "https://provider.test" },
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const userAccount: Account = { account: { address: "user-address" } };
|
|
178
|
+
|
|
179
|
+
const config = (): ProcaptchaClientConfigInput => ({
|
|
180
|
+
account: { address: "5siteKey" },
|
|
181
|
+
userAccountAddress: "",
|
|
182
|
+
web2: true,
|
|
183
|
+
mode: ModeEnum.visible,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const i18nStub = {
|
|
187
|
+
isInitialized: true,
|
|
188
|
+
language: "en",
|
|
189
|
+
t: (key: string) => key,
|
|
190
|
+
changeLanguage: vi.fn(),
|
|
191
|
+
} as unknown as Ti18n;
|
|
192
|
+
|
|
193
|
+
const detectBot: Mock<BotDetectionFunction> = vi.fn(async () => ({
|
|
194
|
+
captchaType: CaptchaType.pow,
|
|
195
|
+
sessionId: FIRST_SESSION_ID,
|
|
196
|
+
status: "ok",
|
|
197
|
+
provider,
|
|
198
|
+
userAccount,
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
let container: HTMLDivElement;
|
|
202
|
+
let root: Root;
|
|
203
|
+
|
|
204
|
+
const lastPowMount = () => {
|
|
205
|
+
const mount = powMounts.at(-1);
|
|
206
|
+
if (!mount) throw new Error("expected the pow widget to have mounted");
|
|
207
|
+
return mount;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Flush React work until the challenge traffic has been quiet for four
|
|
212
|
+
* consecutive polls, so the negative assertions know nothing further is on its
|
|
213
|
+
* way. Paired with `waitForChallenge` below rather than used alone: starting
|
|
214
|
+
* from an already-quiet count, a pure quiescence check can declare "settled"
|
|
215
|
+
* before a request that is still coming has been issued.
|
|
216
|
+
*/
|
|
217
|
+
const settle = async (): Promise<void> => {
|
|
218
|
+
let quiet = 0;
|
|
219
|
+
let previous = mocks.challengeRequests.length;
|
|
220
|
+
for (let poll = 0; poll < 40 && quiet < 4; poll++) {
|
|
221
|
+
await act(async () => {
|
|
222
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
223
|
+
});
|
|
224
|
+
const current = mocks.challengeRequests.length;
|
|
225
|
+
quiet = current === previous ? quiet + 1 : 0;
|
|
226
|
+
previous = current;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const challengeRequestCount = (sessionId: string): number =>
|
|
231
|
+
mocks.challengeRequests.filter((id) => id === sessionId).length;
|
|
232
|
+
|
|
233
|
+
/** Flush until the image Manager has issued its challenge fetch. */
|
|
234
|
+
const waitForChallenge = async (sessionId: string): Promise<void> => {
|
|
235
|
+
for (
|
|
236
|
+
let poll = 0;
|
|
237
|
+
poll < 60 && challengeRequestCount(sessionId) === 0;
|
|
238
|
+
poll++
|
|
239
|
+
) {
|
|
240
|
+
await act(async () => {
|
|
241
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The PoW solution came back with an escalation envelope. `expectRequest`
|
|
248
|
+
* distinguishes a handoff that should reach the network from one the wrapper
|
|
249
|
+
* is expected to swallow — waiting for the request in the first case is what
|
|
250
|
+
* keeps a late fetch from leaking into the next assertion.
|
|
251
|
+
*/
|
|
252
|
+
const escalate = async (
|
|
253
|
+
sessionId: string,
|
|
254
|
+
expectRequest = true,
|
|
255
|
+
): Promise<void> => {
|
|
256
|
+
const { onEscalate } = lastPowMount().props;
|
|
257
|
+
await act(async () => {
|
|
258
|
+
onEscalate?.(CaptchaType.image, sessionId, { x: 120, y: 340 });
|
|
259
|
+
});
|
|
260
|
+
if (expectRequest) await waitForChallenge(sessionId);
|
|
261
|
+
await settle();
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
beforeEach(async () => {
|
|
265
|
+
powMounts.length = 0;
|
|
266
|
+
mocks.challengeRequests.length = 0;
|
|
267
|
+
mocks.consumed.clear();
|
|
268
|
+
detectBot.mockClear();
|
|
269
|
+
container = document.createElement("div");
|
|
270
|
+
document.body.appendChild(container);
|
|
271
|
+
act(() => {
|
|
272
|
+
root = createRoot(container);
|
|
273
|
+
});
|
|
274
|
+
await act(async () => {
|
|
275
|
+
root.render(
|
|
276
|
+
createElement(ProcaptchaFrictionless, {
|
|
277
|
+
config: config(),
|
|
278
|
+
callbacks: {},
|
|
279
|
+
restart: vi.fn<() => void>(),
|
|
280
|
+
i18n: i18nStub,
|
|
281
|
+
detectBot,
|
|
282
|
+
}) as ReactElement,
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
afterEach(() => {
|
|
288
|
+
act(() => {
|
|
289
|
+
root.unmount();
|
|
290
|
+
});
|
|
291
|
+
container.remove();
|
|
292
|
+
vi.clearAllMocks();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("post-PoW escalation handoff", () => {
|
|
296
|
+
it("fetches the image challenge exactly once for the escalation session", async () => {
|
|
297
|
+
await escalate(ESCALATION_SESSION_ID);
|
|
298
|
+
|
|
299
|
+
expect(challengeRequestCount(ESCALATION_SESSION_ID)).toBe(1);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("does not re-request the challenge when the same escalation fires twice", async () => {
|
|
303
|
+
// The PoW manager escalates from inside providerRetry, so a retried
|
|
304
|
+
// submit() replays the same envelope. Before the guard this mounted a
|
|
305
|
+
// second image widget against a session the first one had already
|
|
306
|
+
// spent, and the provider answered 400 CAPTCHA.NO_SESSION_FOUND.
|
|
307
|
+
await escalate(ESCALATION_SESSION_ID);
|
|
308
|
+
await escalate(ESCALATION_SESSION_ID, false);
|
|
309
|
+
|
|
310
|
+
expect(challengeRequestCount(ESCALATION_SESSION_ID)).toBe(1);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("still follows a genuinely new escalation session", async () => {
|
|
314
|
+
await escalate(ESCALATION_SESSION_ID);
|
|
315
|
+
await escalate("provider-session-escalated-2");
|
|
316
|
+
|
|
317
|
+
expect(challengeRequestCount(ESCALATION_SESSION_ID)).toBe(1);
|
|
318
|
+
expect(challengeRequestCount("provider-session-escalated-2")).toBe(1);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("recovers rather than stranding the user when the escalation session is already gone", async () => {
|
|
322
|
+
// Something else consumed the escalation session first (a duplicate
|
|
323
|
+
// POST from a mount storm). The wrapper must re-mint via /frictionless
|
|
324
|
+
// rather than leaving a dead "No session found" checkbox.
|
|
325
|
+
mocks.consumed.add(ESCALATION_SESSION_ID);
|
|
326
|
+
const detectBotCallsBefore = detectBot.mock.calls.length;
|
|
327
|
+
|
|
328
|
+
await escalate(ESCALATION_SESSION_ID);
|
|
329
|
+
|
|
330
|
+
expect(challengeRequestCount(ESCALATION_SESSION_ID)).toBe(1);
|
|
331
|
+
expect(detectBot.mock.calls.length).toBeGreaterThan(detectBotCallsBefore);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("the one-shot session contract this suite stubs", () => {
|
|
336
|
+
it("issues a challenge on the first fetch", async () => {
|
|
337
|
+
const api = new mocks.ProviderApiMock();
|
|
338
|
+
|
|
339
|
+
const challenge = await api.getCaptchaChallenge("user", provider, "fresh");
|
|
340
|
+
|
|
341
|
+
expect(challenge[ApiParams.captchas]).toHaveLength(1);
|
|
342
|
+
expect(challenge[ApiParams.error]).toBeUndefined();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("matches the provider on a second fetch: NO_SESSION_FOUND in the body, not a throw", async () => {
|
|
346
|
+
const api = new mocks.ProviderApiMock();
|
|
347
|
+
await api.getCaptchaChallenge("user", provider, "session-x");
|
|
348
|
+
|
|
349
|
+
const repeat = await api.getCaptchaChallenge("user", provider, "session-x");
|
|
350
|
+
|
|
351
|
+
expect(repeat[ApiParams.error]?.key).toBe("CAPTCHA.NO_SESSION_FOUND");
|
|
352
|
+
expect(repeat[ApiParams.captchas]).toHaveLength(0);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { describe, expect, it } from "vitest";
|
|
16
16
|
import {
|
|
17
|
+
MAX_SESSION_INVALIDATED_RETRIES,
|
|
17
18
|
type MutableRef,
|
|
18
19
|
type RetryCoords,
|
|
19
20
|
consumeRetryMountProps,
|
|
@@ -25,70 +26,97 @@ const ref = <T>(initial: T): MutableRef<T> => ({ current: initial });
|
|
|
25
26
|
|
|
26
27
|
describe("handleSessionInvalidated", () => {
|
|
27
28
|
it("records both coords and signals a restart on the first fire", () => {
|
|
28
|
-
const
|
|
29
|
+
const attemptsRef = ref(0);
|
|
29
30
|
const coordsRef = ref<RetryCoords | null>(null);
|
|
30
31
|
|
|
31
|
-
const result = handleSessionInvalidated(120, 340,
|
|
32
|
+
const result = handleSessionInvalidated(120, 340, attemptsRef, coordsRef);
|
|
32
33
|
|
|
33
|
-
expect(result).toEqual({ shouldRestart: true });
|
|
34
|
-
expect(
|
|
34
|
+
expect(result).toEqual({ shouldRestart: true, exhausted: false });
|
|
35
|
+
expect(attemptsRef.current).toBe(1);
|
|
35
36
|
expect(coordsRef.current).toEqual({ x: 120, y: 340 });
|
|
36
37
|
});
|
|
37
38
|
|
|
38
39
|
it("treats (0, 0) as 'no coords' — that's the autoStart / untrusted-event default, not a real click", () => {
|
|
39
|
-
const
|
|
40
|
+
const attemptsRef = ref(0);
|
|
40
41
|
const coordsRef = ref<RetryCoords | null>(null);
|
|
41
42
|
|
|
42
|
-
const result = handleSessionInvalidated(0, 0,
|
|
43
|
+
const result = handleSessionInvalidated(0, 0, attemptsRef, coordsRef);
|
|
43
44
|
|
|
44
|
-
expect(result).toEqual({ shouldRestart: true });
|
|
45
|
+
expect(result).toEqual({ shouldRestart: true, exhausted: false });
|
|
45
46
|
expect(coordsRef.current).toBeNull();
|
|
46
47
|
});
|
|
47
48
|
|
|
48
49
|
it("carries a real click even if only one axis is at the origin", () => {
|
|
49
|
-
const
|
|
50
|
+
const attemptsRef = ref(0);
|
|
50
51
|
const coordsRef = ref<RetryCoords | null>(null);
|
|
51
52
|
|
|
52
|
-
handleSessionInvalidated(0, 340,
|
|
53
|
+
handleSessionInvalidated(0, 340, attemptsRef, coordsRef);
|
|
53
54
|
|
|
54
55
|
expect(coordsRef.current).toEqual({ x: 0, y: 340 });
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
it("stores no coords when x or y is undefined (autoStart / non-trusted event)", () => {
|
|
58
|
-
const
|
|
59
|
+
const attemptsRef = ref(0);
|
|
59
60
|
const coordsRef = ref<RetryCoords | null>(null);
|
|
60
61
|
|
|
61
62
|
const result = handleSessionInvalidated(
|
|
62
63
|
undefined,
|
|
63
64
|
undefined,
|
|
64
|
-
|
|
65
|
+
attemptsRef,
|
|
65
66
|
coordsRef,
|
|
66
67
|
);
|
|
67
68
|
|
|
68
|
-
expect(result).toEqual({ shouldRestart: true });
|
|
69
|
-
expect(
|
|
69
|
+
expect(result).toEqual({ shouldRestart: true, exhausted: false });
|
|
70
|
+
expect(attemptsRef.current).toBe(1);
|
|
70
71
|
expect(coordsRef.current).toBeNull();
|
|
71
72
|
});
|
|
72
73
|
|
|
73
74
|
it("stores no coords when only one axis is present — never emit NaN into the salt", () => {
|
|
74
|
-
const
|
|
75
|
+
const attemptsRef = ref(0);
|
|
75
76
|
const coordsRef = ref<RetryCoords | null>(null);
|
|
76
77
|
|
|
77
|
-
handleSessionInvalidated(120, undefined,
|
|
78
|
+
handleSessionInvalidated(120, undefined, attemptsRef, coordsRef);
|
|
78
79
|
|
|
79
80
|
expect(coordsRef.current).toBeNull();
|
|
80
81
|
});
|
|
81
82
|
|
|
82
|
-
it("
|
|
83
|
-
const
|
|
83
|
+
it("keeps re-minting up to the retry budget — a reload press is a legitimate new session", () => {
|
|
84
|
+
const attemptsRef = ref(0);
|
|
84
85
|
const coordsRef = ref<RetryCoords | null>(null);
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
for (let i = 0; i < MAX_SESSION_INVALIDATED_RETRIES; i++) {
|
|
88
|
+
expect(
|
|
89
|
+
handleSessionInvalidated(100 + i, 200 + i, attemptsRef, coordsRef),
|
|
90
|
+
).toEqual({ shouldRestart: true, exhausted: false });
|
|
91
|
+
}
|
|
88
92
|
|
|
89
|
-
expect(
|
|
90
|
-
|
|
93
|
+
expect(attemptsRef.current).toBe(MAX_SESSION_INVALIDATED_RETRIES);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("reports exhausted once the budget is spent so the caller can fall over visibly", () => {
|
|
97
|
+
const attemptsRef = ref(MAX_SESSION_INVALIDATED_RETRIES);
|
|
98
|
+
const coordsRef = ref<RetryCoords | null>({ x: 100, y: 200 });
|
|
99
|
+
|
|
100
|
+
const result = handleSessionInvalidated(500, 600, attemptsRef, coordsRef);
|
|
101
|
+
|
|
102
|
+
expect(result).toEqual({ shouldRestart: false, exhausted: true });
|
|
103
|
+
// An exhausted call must not overwrite the pending attempt's coords.
|
|
91
104
|
expect(coordsRef.current).toEqual({ x: 100, y: 200 });
|
|
105
|
+
expect(attemptsRef.current).toBe(MAX_SESSION_INVALIDATED_RETRIES);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("honours a caller-supplied budget", () => {
|
|
109
|
+
const attemptsRef = ref(0);
|
|
110
|
+
const coordsRef = ref<RetryCoords | null>(null);
|
|
111
|
+
|
|
112
|
+
expect(handleSessionInvalidated(1, 2, attemptsRef, coordsRef, 1)).toEqual({
|
|
113
|
+
shouldRestart: true,
|
|
114
|
+
exhausted: false,
|
|
115
|
+
});
|
|
116
|
+
expect(handleSessionInvalidated(3, 4, attemptsRef, coordsRef, 1)).toEqual({
|
|
117
|
+
shouldRestart: false,
|
|
118
|
+
exhausted: true,
|
|
119
|
+
});
|
|
92
120
|
});
|
|
93
121
|
});
|
|
94
122
|
|