@prosopo/procaptcha-pow 2.10.24 → 2.10.25

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 (42) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +4 -4
  2. package/.turbo/turbo-build$colon$tsc.log +19 -19
  3. package/.turbo/turbo-build.log +6 -6
  4. package/CHANGELOG.md +11 -0
  5. package/dist/cjs/components/ProcaptchaWidget.cjs +11 -3
  6. package/dist/cjs/services/Manager.cjs +1 -1
  7. package/dist/components/ProcaptchaWidget.d.ts.map +1 -1
  8. package/dist/components/ProcaptchaWidget.js +11 -3
  9. package/dist/components/ProcaptchaWidget.js.map +1 -1
  10. package/dist/services/Manager.d.ts.map +1 -1
  11. package/dist/services/Manager.js +1 -1
  12. package/dist/services/Manager.js.map +1 -1
  13. package/dist/tests/manager.unit.test.d.ts +2 -0
  14. package/dist/tests/manager.unit.test.d.ts.map +1 -0
  15. package/dist/tests/manager.unit.test.js +752 -0
  16. package/dist/tests/manager.unit.test.js.map +1 -0
  17. package/dist/tests/managerHarness.d.ts +17 -0
  18. package/dist/tests/managerHarness.d.ts.map +1 -0
  19. package/dist/tests/managerHarness.js +70 -0
  20. package/dist/tests/managerHarness.js.map +1 -0
  21. package/dist/tests/procaptchaPoW.unit.test.d.ts +2 -0
  22. package/dist/tests/procaptchaPoW.unit.test.d.ts.map +1 -0
  23. package/dist/tests/procaptchaPoW.unit.test.js +79 -0
  24. package/dist/tests/procaptchaPoW.unit.test.js.map +1 -0
  25. package/dist/tests/procaptchaPow.test-d.d.ts +2 -0
  26. package/dist/tests/procaptchaPow.test-d.d.ts.map +1 -0
  27. package/dist/tests/procaptchaPow.test-d.js +62 -0
  28. package/dist/tests/procaptchaPow.test-d.js.map +1 -0
  29. package/dist/tests/procaptchaWidget.unit.test.d.ts +2 -0
  30. package/dist/tests/procaptchaWidget.unit.test.d.ts.map +1 -0
  31. package/dist/tests/procaptchaWidget.unit.test.js +410 -0
  32. package/dist/tests/procaptchaWidget.unit.test.js.map +1 -0
  33. package/package.json +9 -4
  34. package/src/components/ProcaptchaWidget.tsx +22 -5
  35. package/src/services/Manager.ts +4 -1
  36. package/src/tests/manager.unit.test.ts +1105 -0
  37. package/src/tests/managerHarness.ts +135 -0
  38. package/src/tests/procaptchaPoW.unit.test.ts +120 -0
  39. package/src/tests/procaptchaPow.test-d.ts +122 -0
  40. package/src/tests/procaptchaWidget.unit.test.ts +571 -0
  41. package/tsconfig.tsbuildinfo +1 -1
  42. package/vite.test.config.ts +25 -0
@@ -0,0 +1,571 @@
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
+ ModeEnum,
18
+ type ProcaptchaProps,
19
+ type ProcaptchaState,
20
+ } from "@prosopo/types";
21
+ import { type ReactElement, act, createElement } from "react";
22
+ import { type Root, createRoot } from "react-dom/client";
23
+ import {
24
+ type Mock,
25
+ afterEach,
26
+ beforeEach,
27
+ describe,
28
+ expect,
29
+ test,
30
+ vi,
31
+ } from "vitest";
32
+ import Procaptcha from "../components/ProcaptchaWidget.js";
33
+ import { config, frictionless } from "./managerHarness.js";
34
+
35
+ /**
36
+ * The widget is a thin shell over the manager: these tests cover the wiring it
37
+ * owns — when a solve is triggered, what coordinates it carries, and what it
38
+ * does with an invalidated session — with the manager itself stubbed out.
39
+ */
40
+ const mocks = vi.hoisted(() => {
41
+ const start = vi.fn<(x?: number, y?: number) => Promise<void>>();
42
+ const resetState = vi.fn<() => void>();
43
+ const constructions: {
44
+ updateState: (next: Partial<ProcaptchaState>) => void;
45
+ getHoneypotValue?: () => string | undefined;
46
+ }[] = [];
47
+ const loadI18next = vi.fn<(a?: boolean, b?: string) => Promise<unknown>>();
48
+ const checkboxProps: {
49
+ current:
50
+ | {
51
+ checked: boolean;
52
+ loading: boolean;
53
+ labelText: string;
54
+ error?: string;
55
+ onChange: (event: { nativeEvent: unknown }) => Promise<void>;
56
+ }
57
+ | undefined;
58
+ } = { current: undefined };
59
+ const honeypotQuestions: string[] = [];
60
+ const translationsReady = { current: true };
61
+ return {
62
+ translationsReady,
63
+ start,
64
+ resetState,
65
+ constructions,
66
+ loadI18next,
67
+ checkboxProps,
68
+ honeypotQuestions,
69
+ };
70
+ });
71
+
72
+ vi.mock("../services/Manager.js", () => ({
73
+ Manager: (
74
+ _config: unknown,
75
+ _state: ProcaptchaState,
76
+ updateState: (next: Partial<ProcaptchaState>) => void,
77
+ _callbacks: unknown,
78
+ _frictionlessState: unknown,
79
+ _onEscalate: unknown,
80
+ getHoneypotValue?: () => string | undefined,
81
+ ) => {
82
+ mocks.constructions.push({ updateState, getHoneypotValue });
83
+ return { start: mocks.start, resetState: mocks.resetState };
84
+ },
85
+ }));
86
+
87
+ // The checkbox and the honeypot are procaptcha-common's, and tested there. The
88
+ // stubs keep their contract — a change callback and a forwarded input ref —
89
+ // while letting a test drive the exact browser event the widget branches on,
90
+ // which jsdom cannot produce (it marks every dispatched event untrusted, and
91
+ // the real checkbox drops those before the widget ever sees them).
92
+ vi.mock("@prosopo/procaptcha-common", async (importOriginal) => {
93
+ const actual =
94
+ await importOriginal<typeof import("@prosopo/procaptcha-common")>();
95
+ const { createElement, forwardRef } = await import("react");
96
+ interface CheckboxStubProps {
97
+ checked: boolean;
98
+ loading: boolean;
99
+ labelText: string;
100
+ error?: string;
101
+ onChange: (event: { nativeEvent: unknown }) => Promise<void>;
102
+ }
103
+ const Checkbox = (props: CheckboxStubProps) => {
104
+ mocks.checkboxProps.current = props;
105
+ return createElement("input", {
106
+ type: "checkbox",
107
+ readOnly: true,
108
+ checked: props.checked,
109
+ "aria-label": props.labelText,
110
+ "data-error": props.error,
111
+ "data-loading": String(props.loading),
112
+ });
113
+ };
114
+ const Honeypot = forwardRef<HTMLInputElement, { encodedQuestion: string }>(
115
+ ({ encodedQuestion }, ref) => {
116
+ mocks.honeypotQuestions.push(encodedQuestion);
117
+ return createElement("input", { type: "text", ref, name: "honeypot" });
118
+ },
119
+ );
120
+ return { ...actual, Checkbox, Honeypot };
121
+ });
122
+
123
+ vi.mock("@prosopo/locale", async (importOriginal) => {
124
+ const actual = await importOriginal<typeof import("@prosopo/locale")>();
125
+ return {
126
+ ...actual,
127
+ loadI18next: mocks.loadI18next,
128
+ useTranslation: () => ({
129
+ t: (key: string) => key,
130
+ ready: mocks.translationsReady.current,
131
+ }),
132
+ };
133
+ });
134
+
135
+ let container: HTMLDivElement;
136
+ let root: Root;
137
+
138
+ const i18nStub = (
139
+ language: string,
140
+ changeLanguage: Mock<(l: string) => void>,
141
+ ) => ({ language, changeLanguage }) as unknown as Ti18n;
142
+
143
+ const props = (overrides: Partial<ProcaptchaProps> = {}): ProcaptchaProps => ({
144
+ config: config(),
145
+ callbacks: {},
146
+ i18n: undefined as unknown as Ti18n,
147
+ ...overrides,
148
+ });
149
+
150
+ const render = (widgetProps: ProcaptchaProps): void => {
151
+ act(() => {
152
+ root.render(createElement(Procaptcha, widgetProps) as ReactElement);
153
+ });
154
+ };
155
+
156
+ beforeEach(() => {
157
+ vi.clearAllMocks();
158
+ mocks.constructions.length = 0;
159
+ mocks.honeypotQuestions.length = 0;
160
+ mocks.translationsReady.current = true;
161
+ mocks.checkboxProps.current = undefined;
162
+ mocks.start.mockResolvedValue(undefined);
163
+ mocks.loadI18next.mockResolvedValue(undefined);
164
+ container = document.createElement("div");
165
+ document.body.appendChild(container);
166
+ act(() => {
167
+ root = createRoot(container);
168
+ });
169
+ });
170
+
171
+ afterEach(() => {
172
+ act(() => {
173
+ root.unmount();
174
+ });
175
+ container.remove();
176
+ vi.restoreAllMocks();
177
+ });
178
+
179
+ const checkbox = (): HTMLInputElement => {
180
+ const element = container.querySelector<HTMLInputElement>(
181
+ 'input[type="checkbox"]',
182
+ );
183
+ if (!element) throw new Error("expected a checkbox to be rendered");
184
+ return element;
185
+ };
186
+
187
+ const honeypotInput = (): HTMLInputElement => {
188
+ const element = container.querySelector<HTMLInputElement>(
189
+ 'input[name="honeypot"]',
190
+ );
191
+ if (!element) throw new Error("expected a honeypot to be rendered");
192
+ return element;
193
+ };
194
+
195
+ interface ClickOptions {
196
+ trusted?: boolean;
197
+ clientX?: number;
198
+ clientY?: number;
199
+ touches?: { clientX: number; clientY: number }[];
200
+ }
201
+
202
+ /** Hand the widget the browser event a click on the checkbox would produce. */
203
+ const click = (options: ClickOptions = {}): void => {
204
+ const nativeEvent = {
205
+ isTrusted: options.trusted ?? true,
206
+ clientX: options.clientX ?? 0,
207
+ clientY: options.clientY ?? 0,
208
+ ...(options.touches ? { touches: options.touches } : {}),
209
+ };
210
+ act(() => {
211
+ void mocks.checkboxProps.current?.onChange({ nativeEvent });
212
+ });
213
+ };
214
+
215
+ describe("what the widget renders", () => {
216
+ test("a visible widget shows a checkbox", () => {
217
+ render(props());
218
+ expect(checkbox()).toBeDefined();
219
+ });
220
+
221
+ test("an invisible widget shows no checkbox at all", () => {
222
+ render(props({ config: config({ mode: ModeEnum.invisible }) }));
223
+ expect(container.querySelector('[aria-label="human checkbox"]')).toBeNull();
224
+ });
225
+
226
+ test("a session with a honeypot question renders the honeypot", () => {
227
+ render(props({ frictionlessState: frictionless({ hp: "question" }) }));
228
+ expect(honeypotInput()).toBeDefined();
229
+ expect(mocks.honeypotQuestions.at(-1)).toBe("question");
230
+ });
231
+
232
+ test("a session without one renders no bait at all", () => {
233
+ render(props({ frictionlessState: frictionless() }));
234
+ expect(container.querySelector('input[name="honeypot"]')).toBeNull();
235
+ });
236
+
237
+ test("an invisible widget still renders the honeypot, as bait", () => {
238
+ render(
239
+ props({
240
+ config: config({ mode: ModeEnum.invisible }),
241
+ frictionlessState: frictionless({ hp: "question" }),
242
+ }),
243
+ );
244
+ expect(honeypotInput()).toBeDefined();
245
+ });
246
+
247
+ test("the manager can read the honeypot input the widget rendered", () => {
248
+ render(props({ frictionlessState: frictionless({ hp: "question" }) }));
249
+ honeypotInput().value = "bot@example.com";
250
+ expect(mocks.constructions[0]?.getHoneypotValue?.()).toBe(
251
+ "bot@example.com",
252
+ );
253
+ });
254
+
255
+ test("a dark-themed widget still renders its checkbox", () => {
256
+ render(props({ config: config({ theme: "dark" }) }));
257
+ expect(checkbox()).toBeDefined();
258
+ });
259
+
260
+ test("the checkbox is labelled once the translations are ready", () => {
261
+ render(props());
262
+ expect(mocks.checkboxProps.current?.labelText).toBe("WIDGET.I_AM_HUMAN");
263
+ });
264
+
265
+ test("the checkbox is left unlabelled until the translations load", () => {
266
+ // A key like WIDGET.I_AM_HUMAN rendered raw is worse than no label.
267
+ mocks.translationsReady.current = false;
268
+ render(props());
269
+ expect(mocks.checkboxProps.current?.labelText).toBe("");
270
+ });
271
+
272
+ test("a page that registered no callbacks still renders", () => {
273
+ // Consumers reach this component from plain JavaScript, where the prop
274
+ // can simply be absent.
275
+ render(
276
+ props({
277
+ callbacks: undefined as unknown as ProcaptchaProps["callbacks"],
278
+ }),
279
+ );
280
+ expect(checkbox()).toBeDefined();
281
+ });
282
+
283
+ test("the checkbox shows the current error", () => {
284
+ render(props());
285
+ act(() => {
286
+ mocks.constructions[0]?.updateState({
287
+ error: { message: "no session", key: "API.UNKNOWN_ERROR" },
288
+ });
289
+ });
290
+ expect(mocks.checkboxProps.current?.error).toBe("no session");
291
+ });
292
+
293
+ test("an unfilled honeypot reads as nothing, not as an empty answer", () => {
294
+ render(props({ frictionlessState: frictionless({ hp: "question" }) }));
295
+ expect(mocks.constructions[0]?.getHoneypotValue?.()).toBeUndefined();
296
+ });
297
+
298
+ test("with no honeypot rendered the reader still answers safely", () => {
299
+ render(props());
300
+ expect(mocks.constructions[0]?.getHoneypotValue?.()).toBeUndefined();
301
+ });
302
+ });
303
+
304
+ describe("starting a solve", () => {
305
+ test("a click starts the manager with the coordinates of the click", () => {
306
+ render(props());
307
+ click({ clientX: 12, clientY: 34 });
308
+ expect(mocks.start).toHaveBeenCalledWith(12, 34);
309
+ });
310
+
311
+ test("a tap is read from the touch that produced it", () => {
312
+ render(props());
313
+ click({ touches: [{ clientX: 7, clientY: 9 }] });
314
+ expect(mocks.start).toHaveBeenCalledWith(7, 9);
315
+ });
316
+
317
+ test("a tap with no touches falls back to the pointer coordinates", () => {
318
+ render(props());
319
+ click({ touches: [], clientX: 3, clientY: 4 });
320
+ expect(mocks.start).toHaveBeenCalledWith(3, 4);
321
+ });
322
+
323
+ test("an untrusted click solves, but its coordinates are discarded", () => {
324
+ // A synthetic click is what an automated solver produces; recording its
325
+ // coordinates would launder them into the salt as real telemetry.
326
+ render(props());
327
+ click({ trusted: false, clientX: 12, clientY: 34 });
328
+ expect(mocks.start).toHaveBeenCalledWith(0, 0);
329
+ });
330
+
331
+ test("an event that reports no position at all is treated as the origin", () => {
332
+ render(props());
333
+ act(() => {
334
+ void mocks.checkboxProps.current?.onChange({
335
+ nativeEvent: { isTrusted: true },
336
+ });
337
+ });
338
+ expect(mocks.start).toHaveBeenCalledWith(0, 0);
339
+ });
340
+
341
+ test("the checkbox shows a spinner for as long as the solve runs", async () => {
342
+ let release = (): void => undefined;
343
+ mocks.start.mockImplementation(
344
+ () =>
345
+ new Promise<void>((resolve) => {
346
+ release = () => resolve();
347
+ }),
348
+ );
349
+ render(props());
350
+ click();
351
+ expect(mocks.checkboxProps.current?.loading).toBe(true);
352
+ await act(async () => {
353
+ release();
354
+ });
355
+ expect(mocks.checkboxProps.current?.loading).toBe(false);
356
+ });
357
+
358
+ test("a solve that rejects still clears the spinner", async () => {
359
+ // Nothing else would: React ignores the promise, so the rejection used to
360
+ // escape as an unhandled rejection with the spinner still turning.
361
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
362
+ mocks.start.mockRejectedValue(new Error("provider down"));
363
+ render(props({ autoStart: true }));
364
+ await act(async () => undefined);
365
+ expect(mocks.checkboxProps.current?.loading).toBe(false);
366
+ });
367
+
368
+ test("a click whose solve rejects clears the spinner too", async () => {
369
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
370
+ mocks.start.mockRejectedValue(new Error("provider down"));
371
+ render(props());
372
+ await act(async () => {
373
+ await mocks.checkboxProps.current?.onChange({
374
+ nativeEvent: { isTrusted: true, clientX: 1, clientY: 2 },
375
+ });
376
+ });
377
+ expect(mocks.checkboxProps.current?.loading).toBe(false);
378
+ });
379
+
380
+ test("a second click while the first solve is running is ignored", async () => {
381
+ let release = (): void => undefined;
382
+ mocks.start.mockImplementation(
383
+ () =>
384
+ new Promise<void>((resolve) => {
385
+ release = () => resolve();
386
+ }),
387
+ );
388
+ render(props());
389
+ click();
390
+ click();
391
+ expect(mocks.start).toHaveBeenCalledTimes(1);
392
+ await act(async () => {
393
+ release();
394
+ });
395
+ });
396
+
397
+ test("an autoStart widget solves without waiting for a click", () => {
398
+ render(props({ autoStart: true }));
399
+ expect(mocks.start).toHaveBeenCalledWith(0, 0);
400
+ });
401
+
402
+ test("an autoStart widget resumes with the coordinates it was handed", () => {
403
+ render(props({ autoStart: true, startCoords: { x: 5, y: 6 } }));
404
+ expect(mocks.start).toHaveBeenCalledWith(5, 6);
405
+ });
406
+
407
+ test("a widget that was not asked to autoStart stays idle", () => {
408
+ render(props());
409
+ expect(mocks.start).not.toHaveBeenCalled();
410
+ });
411
+ });
412
+
413
+ describe("the invisible-mode execute event", () => {
414
+ const execute = (): void => {
415
+ act(() => {
416
+ document.dispatchEvent(new Event("procaptcha:execute"));
417
+ });
418
+ };
419
+
420
+ test("starts a solve", () => {
421
+ render(props({ config: config({ mode: ModeEnum.invisible }) }));
422
+ execute();
423
+ expect(mocks.start).toHaveBeenCalledTimes(1);
424
+ });
425
+
426
+ test("is ignored by a visible widget", () => {
427
+ render(props());
428
+ execute();
429
+ expect(mocks.start).not.toHaveBeenCalled();
430
+ });
431
+
432
+ test("stops being listened for once the widget is gone", () => {
433
+ render(props({ config: config({ mode: ModeEnum.invisible }) }));
434
+ act(() => {
435
+ root.unmount();
436
+ root = createRoot(container);
437
+ });
438
+ execute();
439
+ expect(mocks.start).not.toHaveBeenCalled();
440
+ });
441
+
442
+ test("a manager that throws on start does not take the page down with it", () => {
443
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
444
+ mocks.start.mockImplementation(() => {
445
+ throw new Error("no provider");
446
+ });
447
+ render(props({ config: config({ mode: ModeEnum.invisible }) }));
448
+ expect(execute).not.toThrow();
449
+ });
450
+
451
+ test("a solve that rejects is reported rather than left unhandled", async () => {
452
+ const reported = vi
453
+ .spyOn(console, "error")
454
+ .mockImplementation(() => undefined);
455
+ mocks.start.mockRejectedValue(new Error("no provider"));
456
+ render(props({ config: config({ mode: ModeEnum.invisible }) }));
457
+ execute();
458
+ await act(async () => undefined);
459
+ expect(reported).toHaveBeenCalled();
460
+ });
461
+ });
462
+
463
+ describe("an invalidated session", () => {
464
+ const fail = (key: string): void => {
465
+ act(() => {
466
+ mocks.constructions[0]?.updateState({
467
+ error: { message: "boom", key },
468
+ });
469
+ });
470
+ };
471
+
472
+ test("is handed to the recovery-aware parent, with the original click", () => {
473
+ const onSessionInvalidated = vi.fn<(x?: number, y?: number) => void>();
474
+ render(props({ onSessionInvalidated }));
475
+ click({ clientX: 9, clientY: 8 });
476
+ fail("CAPTCHA.NO_SESSION_FOUND");
477
+ expect(onSessionInvalidated).toHaveBeenCalledWith(9, 8);
478
+ });
479
+
480
+ test("is escalated once only, so a failing retry cannot loop", () => {
481
+ const onSessionInvalidated = vi.fn<(x?: number, y?: number) => void>();
482
+ const restart = vi.fn<() => void>();
483
+ vi.useFakeTimers();
484
+ render(
485
+ props({
486
+ onSessionInvalidated,
487
+ frictionlessState: frictionless({ restart }),
488
+ }),
489
+ );
490
+ fail("CAPTCHA.NO_SESSION_FOUND");
491
+ act(() => {
492
+ mocks.constructions[0]?.updateState({ error: undefined });
493
+ });
494
+ fail("CAPTCHA.NO_SESSION_FOUND");
495
+ expect(onSessionInvalidated).toHaveBeenCalledTimes(1);
496
+ act(() => {
497
+ vi.advanceTimersByTime(100);
498
+ });
499
+ expect(restart).toHaveBeenCalledTimes(1);
500
+ vi.useRealTimers();
501
+ });
502
+
503
+ test("without a recovery-aware parent the frictionless session restarts", () => {
504
+ const restart = vi.fn<() => void>();
505
+ vi.useFakeTimers();
506
+ render(props({ frictionlessState: frictionless({ restart }) }));
507
+ fail("CAPTCHA.NO_SESSION_FOUND");
508
+ expect(restart).not.toHaveBeenCalled();
509
+ act(() => {
510
+ vi.advanceTimersByTime(100);
511
+ });
512
+ expect(restart).toHaveBeenCalledTimes(1);
513
+ vi.useRealTimers();
514
+ });
515
+
516
+ test("any other error is left for the widget to display", () => {
517
+ const onSessionInvalidated = vi.fn<(x?: number, y?: number) => void>();
518
+ const restart = vi.fn<() => void>();
519
+ render(
520
+ props({
521
+ onSessionInvalidated,
522
+ frictionlessState: frictionless({ restart }),
523
+ }),
524
+ );
525
+ fail("API.UNKNOWN_ERROR");
526
+ expect(onSessionInvalidated).not.toHaveBeenCalled();
527
+ expect(restart).not.toHaveBeenCalled();
528
+ });
529
+
530
+ test("an error with nowhere to escalate to is simply shown", () => {
531
+ render(props());
532
+ expect(() => fail("CAPTCHA.NO_SESSION_FOUND")).not.toThrow();
533
+ });
534
+ });
535
+
536
+ describe("language", () => {
537
+ test("a configured language with no i18n instance boots one", () => {
538
+ render(props({ config: config({ language: "fr" }) }));
539
+ expect(mocks.loadI18next).toHaveBeenCalledWith(false, "fr");
540
+ });
541
+
542
+ test("an existing i18n instance is switched rather than re-booted", () => {
543
+ const changeLanguage = vi.fn<(l: string) => void>();
544
+ render(
545
+ props({
546
+ config: config({ language: "fr" }),
547
+ i18n: i18nStub("en", changeLanguage),
548
+ }),
549
+ );
550
+ expect(changeLanguage).toHaveBeenCalledWith("fr");
551
+ expect(mocks.loadI18next).not.toHaveBeenCalled();
552
+ });
553
+
554
+ test("an instance already in the right language is left alone", () => {
555
+ const changeLanguage = vi.fn<(l: string) => void>();
556
+ render(
557
+ props({
558
+ config: config({ language: "fr" }),
559
+ i18n: i18nStub("fr", changeLanguage),
560
+ }),
561
+ );
562
+ expect(changeLanguage).not.toHaveBeenCalled();
563
+ });
564
+
565
+ test("no configured language means the page's own choice stands", () => {
566
+ const changeLanguage = vi.fn<(l: string) => void>();
567
+ render(props({ i18n: i18nStub("en", changeLanguage) }));
568
+ expect(changeLanguage).not.toHaveBeenCalled();
569
+ expect(mocks.loadI18next).not.toHaveBeenCalled();
570
+ });
571
+ });