@prosopo/procaptcha-puzzle 2.14.7 → 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.
Files changed (45) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +6 -9
  2. package/.turbo/turbo-build$colon$tsc.log +19 -22
  3. package/.turbo/turbo-build.log +7 -13
  4. package/CHANGELOG.md +65 -0
  5. package/LICENSE +201 -0
  6. package/dist/cjs/components/procaptchaPuzzle.cjs +2 -17
  7. package/dist/cjs/components/procaptchaWidget.cjs +22 -19
  8. package/dist/cjs/components/puzzleCanvas.cjs +35 -5
  9. package/dist/cjs/services/Manager.cjs +10 -44
  10. package/dist/components/procaptchaPuzzle.d.ts +3 -3
  11. package/dist/components/procaptchaPuzzle.d.ts.map +1 -1
  12. package/dist/components/procaptchaPuzzle.js +2 -17
  13. package/dist/components/procaptchaPuzzle.js.map +1 -1
  14. package/dist/components/procaptchaWidget.d.ts.map +1 -1
  15. package/dist/components/procaptchaWidget.js +21 -18
  16. package/dist/components/procaptchaWidget.js.map +1 -1
  17. package/dist/components/puzzleCanvas.d.ts +4 -2
  18. package/dist/components/puzzleCanvas.d.ts.map +1 -1
  19. package/dist/components/puzzleCanvas.js +36 -6
  20. package/dist/components/puzzleCanvas.js.map +1 -1
  21. package/dist/services/Manager.d.ts.map +1 -1
  22. package/dist/services/Manager.js +11 -45
  23. package/dist/services/Manager.js.map +1 -1
  24. package/dist/tests/procaptchaWidget.unit.test.js +91 -0
  25. package/dist/tests/procaptchaWidget.unit.test.js.map +1 -1
  26. package/dist/tests/puzzleCanvas.unit.test.js +69 -0
  27. package/dist/tests/puzzleCanvas.unit.test.js.map +1 -1
  28. package/package.json +72 -71
  29. package/src/components/procaptchaPuzzle.ts +0 -54
  30. package/src/components/procaptchaWidget.ts +0 -429
  31. package/src/components/puzzleCanvas.ts +0 -658
  32. package/src/index.ts +0 -16
  33. package/src/services/Manager.ts +0 -529
  34. package/src/tests/domHarness.ts +0 -122
  35. package/src/tests/invisibleCanvasVisibility.unit.test.ts +0 -181
  36. package/src/tests/manager.unit.test.ts +0 -840
  37. package/src/tests/managerHarness.ts +0 -169
  38. package/src/tests/procaptchaPuzzle.test-d.ts +0 -212
  39. package/src/tests/procaptchaPuzzle.unit.test.ts +0 -138
  40. package/src/tests/procaptchaWidget.unit.test.ts +0 -777
  41. package/src/tests/puzzleCanvas.unit.test.ts +0 -662
  42. package/tsconfig.cjs.json +0 -43
  43. package/tsconfig.json +0 -44
  44. package/tsconfig.tsbuildinfo +0 -1
  45. package/tsconfig.types.json +0 -9
@@ -1,529 +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 { stringToHex } from "@polkadot/util/string";
16
- import { ProviderApi } from "@prosopo/api";
17
- import { ProsopoEnvError } from "@prosopo/common";
18
- import {
19
- ExtensionLoader,
20
- buildClientMetaData,
21
- buildUpdateState,
22
- getDefaultEvents,
23
- getProcaptchaRandomActiveProvider,
24
- getSimdReadingsForSubmit,
25
- pickIpMode,
26
- providerRetry,
27
- } from "@prosopo/procaptcha-common";
28
- import {
29
- type Account,
30
- ApiParams,
31
- CaptchaType,
32
- type FrictionlessState,
33
- type GetPuzzleCaptchaResponse,
34
- type ProcaptchaCallbacks,
35
- type ProcaptchaClientConfigInput,
36
- ProcaptchaConfigSchema,
37
- type ProcaptchaState,
38
- type ProcaptchaStateUpdateFn,
39
- type PuzzleEvent,
40
- encodeProcaptchaOutput,
41
- } from "@prosopo/types";
42
- import { embedData, sleep } from "@prosopo/util";
43
- import { randomAsHex } from "@prosopo/util-crypto";
44
-
45
- interface PuzzleManagerHandle {
46
- start: (
47
- x?: number,
48
- y?: number,
49
- ) => Promise<GetPuzzleCaptchaResponse | undefined>;
50
- submitSolution: (
51
- finalX: number,
52
- finalY: number,
53
- puzzleEvents: PuzzleEvent[],
54
- ) => Promise<boolean>;
55
- resetState: (frictionlessRestart?: () => void) => void;
56
- dispose: () => void;
57
- }
58
-
59
- export const Manager = (
60
- configInput: ProcaptchaClientConfigInput,
61
- state: ProcaptchaState,
62
- onStateUpdate: ProcaptchaStateUpdateFn,
63
- callbacks: ProcaptchaCallbacks,
64
- frictionlessState?: FrictionlessState,
65
- // Reads the live honeypot input value at submit time. Returns undefined
66
- // when the honeypot is disabled or the input hasn't been filled.
67
- getHoneypotValue?: () => string | undefined,
68
- ): PuzzleManagerHandle => {
69
- const events = getDefaultEvents(callbacks);
70
-
71
- // Closure variables to share state between start and submitSolution
72
- let storedChallengeResponse: GetPuzzleCaptchaResponse | undefined;
73
- let storedProviderApi: ProviderApi | undefined;
74
- let storedProviderUrl: string | undefined;
75
- let storedUser: Account | undefined;
76
- // Checkbox click coords, captured by start() and embedded into the
77
- // solution salt at submit time — same shape as the POW flow so the
78
- // provider records identical entry-point telemetry for both types.
79
- let storedClickX: number | undefined;
80
- let storedClickY: number | undefined;
81
-
82
- // URL of the provider used on the previous attempt. Kept outside the
83
- // resetState-cleared closure state so a retry can exclude it from the
84
- // candidate pool and land on a different provider.
85
- let previousProviderUrl: string | undefined;
86
- // The sessionId this manager has already exchanged for a challenge. The
87
- // provider consumes a session the moment it issues a challenge against it
88
- // (`checkAndRemoveSession`), so asking for a second challenge with the same
89
- // id is a guaranteed 400 CAPTCHA.NO_SESSION_FOUND. Mirrors the image
90
- // manager's guard: the id lives on `frictionlessState`, which `resetState()`
91
- // does not own, so every path that re-enters `start()` — a wrong answer, a
92
- // providerRetry after a late failure, a `procaptcha:execute` event — would
93
- // otherwise re-send it.
94
- let spentSessionId: string | undefined;
95
-
96
- const defaultState = (): Partial<ProcaptchaState> => {
97
- return {
98
- // note order matters! see buildUpdateState. These fields are set in order, so disable modal first, then set loading to false, etc.
99
- showModal: false,
100
- loading: false,
101
- index: 0,
102
- challenge: undefined,
103
- solutions: undefined,
104
- isHuman: false,
105
- captchaApi: undefined,
106
- account: undefined,
107
- // don't handle timeout here, this should be handled by the state management
108
- };
109
- };
110
-
111
- const clearTimeout = () => {
112
- // clear the timeout
113
- window.clearTimeout(Number(state.timeout));
114
- // then clear the timeout from the state
115
- updateState({ timeout: undefined });
116
- };
117
-
118
- const onFailed = () => {
119
- updateState({
120
- isHuman: false,
121
- loading: false,
122
- });
123
- events.onFailed();
124
- resetState(frictionlessState?.restart);
125
- };
126
-
127
- const clearSuccessfulChallengeTimeout = () => {
128
- // clear the timeout
129
- window.clearTimeout(Number(state.successfullChallengeTimeout));
130
- // then clear the timeout from the state
131
- updateState({ successfullChallengeTimeout: undefined });
132
- };
133
-
134
- const getConfig = () => {
135
- const config: ProcaptchaClientConfigInput = {
136
- userAccountAddress: configInput.userAccountAddress || "",
137
- ...configInput,
138
- };
139
-
140
- // overwrite the account in use with the one in state if it exists. Reduces likelihood of bugs where the user
141
- // changes account in the middle of the captcha process.
142
- if (state.account) {
143
- config.userAccountAddress = state.account.account.address;
144
- }
145
-
146
- return ProcaptchaConfigSchema.parse(config);
147
- };
148
-
149
- const getAccount = () => {
150
- if (!state.account) {
151
- throw new ProsopoEnvError("GENERAL.ACCOUNT_NOT_FOUND", {
152
- context: { error: "Account not loaded" },
153
- });
154
- }
155
- const account: Account = state.account;
156
- return { account };
157
- };
158
-
159
- const getDappAccount = () => {
160
- if (!state.dappAccount) {
161
- throw new ProsopoEnvError("GENERAL.SITE_KEY_MISSING");
162
- }
163
-
164
- const dappAccount: string = state.dappAccount;
165
- return dappAccount;
166
- };
167
-
168
- // get the state update mechanism
169
- const updateState = buildUpdateState(state, onStateUpdate);
170
-
171
- const resetState = (frictionlessRestart?: () => void) => {
172
- // clear timeout just in case a timer is still active (shouldn't be)
173
- clearTimeout();
174
- clearSuccessfulChallengeTimeout();
175
- updateState(defaultState());
176
- events.onReset();
177
- // reset the frictionless state if necessary
178
- if (frictionlessRestart) {
179
- frictionlessRestart();
180
- }
181
- // clear closure state
182
- storedChallengeResponse = undefined;
183
- storedProviderApi = undefined;
184
- storedProviderUrl = undefined;
185
- storedUser = undefined;
186
- storedClickX = undefined;
187
- storedClickY = undefined;
188
- };
189
-
190
- const setValidChallengeTimeout = () => {
191
- const timeMillis: number = getConfig().captchas.puzzle.solutionTimeout;
192
- const successfullChallengeTimeout = setTimeout(() => {
193
- if (disposed) return;
194
- // Human state expired, disallow user's claim to be human
195
- updateState({ isHuman: false });
196
-
197
- events.onExpired();
198
- resetState(frictionlessState?.restart);
199
- }, timeMillis);
200
-
201
- updateState({ successfullChallengeTimeout });
202
- };
203
-
204
- const start = async (
205
- x = 0,
206
- y = 0,
207
- ): Promise<GetPuzzleCaptchaResponse | undefined> => {
208
- await providerRetry(
209
- async () => {
210
- if (state.loading) {
211
- return;
212
- }
213
- if (state.isHuman) {
214
- return;
215
- }
216
-
217
- // reset the state to defaults - do not reset the frictionless state
218
- resetState();
219
-
220
- // Persist click coords on every entry so retries inherit the
221
- // trusted coordinates captured by the widget on initial click.
222
- // Set after the reset, which clears the closure state: setting
223
- // them before it meant the salt never carried the coordinates.
224
- storedClickX = x;
225
- storedClickY = y;
226
-
227
- // set the loading flag to true (allow UI to show some sort of loading / pending indicator while we get the captcha process going)
228
- updateState({
229
- loading: true,
230
- });
231
- updateState({ attemptCount: state.attemptCount + 1 });
232
-
233
- const config = getConfig();
234
-
235
- // check if account exists in extension
236
- const selectAccount = async () => {
237
- if (frictionlessState) {
238
- return frictionlessState.userAccount;
239
- }
240
- const ext = new (await ExtensionLoader(config.web2))();
241
- return ext.getAccount(config);
242
- };
243
-
244
- // use the passed in account (could be web3) or create a new account
245
- const user = await selectAccount();
246
- const userAccount = user.account.address;
247
-
248
- // set the account created or injected by the extension
249
- updateState({
250
- account: { account: { address: userAccount } },
251
- });
252
-
253
- // snapshot the config into the state
254
- updateState({ dappAccount: config.account.address });
255
-
256
- // allow UI to catch up with the loading state
257
- await sleep(100);
258
-
259
- // check if account has been provided in config (doesn't matter in web2 mode)
260
- if (!config.web2 && !config.userAccountAddress) {
261
- throw new ProsopoEnvError("GENERAL.ACCOUNT_NOT_FOUND", {
262
- context: {
263
- error: "Account address has not been set for web3 mode",
264
- },
265
- });
266
- }
267
-
268
- let getRandomProviderResponse = undefined;
269
-
270
- if (frictionlessState?.provider) {
271
- getRandomProviderResponse = frictionlessState.provider;
272
- } else {
273
- const currentConfig = getConfig();
274
- getRandomProviderResponse = await getProcaptchaRandomActiveProvider(
275
- currentConfig.defaultEnvironment,
276
- pickIpMode(currentConfig),
277
- { attempt: state.attemptCount, excludeUrl: previousProviderUrl },
278
- );
279
- }
280
-
281
- const providerUrl = getRandomProviderResponse.provider.url;
282
- previousProviderUrl = providerUrl;
283
-
284
- const providerApi = new ProviderApi(providerUrl, getDappAccount());
285
-
286
- // Short-circuit a challenge fetch we already know the provider
287
- // will reject, and route straight to the recovery path the
288
- // wrapper listens for — re-minting a session is the only way
289
- // forward, and the doomed round trip only delays it.
290
- const challengeSessionId = frictionlessState?.sessionId;
291
- if (challengeSessionId && challengeSessionId === spentSessionId) {
292
- updateState({
293
- loading: false,
294
- error: {
295
- message: "No session found",
296
- key: "CAPTCHA.NO_SESSION_FOUND",
297
- },
298
- });
299
- events.onError(new Error("No session found"));
300
- return;
301
- }
302
-
303
- // Non-blocking check — attach SIMD readings only if the
304
- // prefetched benchmark has already resolved.
305
- const simdReadingsOnChallenge = frictionlessState?.getSimdReadings
306
- ? await frictionlessState.getSimdReadings(0)
307
- : undefined;
308
- const challenge = await providerApi.getPuzzleCaptchaChallenge(
309
- userAccount,
310
- getDappAccount(),
311
- challengeSessionId,
312
- simdReadingsOnChallenge,
313
- );
314
- // The provider answered, so it has seen the id and consumed it —
315
- // a challenge and a 4xx mean the same thing here. Marked after
316
- // the await rather than before it on purpose: a throw is the one
317
- // case where the request may never have landed, and that is
318
- // exactly when `providerRetry` re-enters `start()` to fail over
319
- // onto a different provider. Marking it spent up front would
320
- // turn every transport blip into "No session found".
321
- if (challengeSessionId) spentSessionId = challengeSessionId;
322
-
323
- if (challenge.error) {
324
- updateState({
325
- loading: false,
326
- error: {
327
- message: challenge.error.message,
328
- key: challenge.error.key || "API.UNKNOWN_ERROR",
329
- },
330
- });
331
- return;
332
- }
333
-
334
- // Store closure state for submitSolution
335
- storedChallengeResponse = challenge;
336
- storedProviderApi = providerApi;
337
- storedProviderUrl = providerUrl;
338
- storedUser = user;
339
-
340
- // Set loading to false to signal the widget to show the puzzle canvas
341
- updateState({
342
- loading: false,
343
- });
344
- },
345
- async () => {
346
- // Carry the original coords into the retry: re-entering with the
347
- // defaults would replace the user's real click with (0, 0).
348
- await start(x, y);
349
- },
350
- () => {
351
- resetState();
352
- },
353
- state.attemptCount,
354
- 3,
355
- );
356
-
357
- // Return the stored challenge so retries (which re-enter `start`)
358
- // still surface the resolved challenge to the original caller.
359
- return storedChallengeResponse;
360
- };
361
-
362
- const submitSolution = async (
363
- finalX: number,
364
- finalY: number,
365
- puzzleEvents: PuzzleEvent[],
366
- ): Promise<boolean> => {
367
- if (
368
- !storedChallengeResponse ||
369
- !storedProviderApi ||
370
- !storedProviderUrl ||
371
- !storedUser
372
- ) {
373
- throw new ProsopoEnvError("GENERAL.ACCOUNT_NOT_FOUND", {
374
- context: { error: "No challenge data available. Call start() first." },
375
- });
376
- }
377
-
378
- updateState({ loading: true });
379
-
380
- try {
381
- const challenge = storedChallengeResponse;
382
- const providerApi = storedProviderApi;
383
- const providerUrl = storedProviderUrl;
384
- const user = storedUser;
385
- const config = getConfig();
386
-
387
- const signer = user.extension?.signer;
388
-
389
- if (!signer || !signer.signRaw) {
390
- throw new ProsopoEnvError("GENERAL.CANT_FIND_KEYRINGPAIR", {
391
- context: {
392
- error:
393
- "Signer is not defined, cannot sign message to prove account ownership",
394
- },
395
- });
396
- }
397
-
398
- const userTimestampSignature = await signer.signRaw({
399
- address: user.account.address,
400
- data: stringToHex(challenge[ApiParams.timestamp].toString()),
401
- type: "bytes",
402
- });
403
-
404
- let encryptedBehavioralData: string | undefined;
405
-
406
- // Collect and encrypt behavioral data before submission
407
- if (
408
- frictionlessState?.encryptBehavioralData &&
409
- (frictionlessState?.behaviorCollector1 ||
410
- frictionlessState?.behaviorCollector2 ||
411
- frictionlessState?.behaviorCollector3 ||
412
- frictionlessState?.behaviorCollector4)
413
- ) {
414
- try {
415
- const behavioralData = {
416
- collector1: frictionlessState.behaviorCollector1?.getData() || [],
417
- collector2: frictionlessState.behaviorCollector2?.getData() || [],
418
- collector3: frictionlessState.behaviorCollector3?.getData() || [],
419
- collector4: frictionlessState.behaviorCollector4?.getData() || [],
420
- deviceCapability: frictionlessState.deviceCapability || "unknown",
421
- };
422
-
423
- // Pack the behavioral data before stringifying
424
- const dataToEncrypt = frictionlessState.packBehavioralData
425
- ? frictionlessState.packBehavioralData(behavioralData)
426
- : behavioralData;
427
-
428
- encryptedBehavioralData =
429
- await frictionlessState.encryptBehavioralData(
430
- JSON.stringify(dataToEncrypt),
431
- );
432
- } catch {
433
- // Silently ignore behavioral data errors - captcha should still work
434
- }
435
- }
436
-
437
- // Encode the checkbox click coordinates into a random salt, same
438
- // shape as the POW flow. The provider decodes this on submit and
439
- // records the (x, y) on the puzzle captcha record for telemetry.
440
- let salt: string | undefined;
441
- if (storedClickX !== undefined && storedClickY !== undefined) {
442
- const coords = [storedClickX, storedClickY];
443
- const randomSalt = randomAsHex(
444
- coords
445
- .map((coord) => coord.toString(16).length + 4)
446
- .reduce((acc, curr) => acc + curr, 0),
447
- );
448
- salt = embedData(randomSalt, coords);
449
- }
450
-
451
- // Wait 5 secs for ongoing SIMD, else submit without
452
- const simdReadings = await getSimdReadingsForSubmit(frictionlessState);
453
- const clientMetaData = buildClientMetaData(
454
- getHoneypotValue?.(),
455
- getConfig().clientSessionId,
456
- );
457
- const verifiedSolution = await providerApi.submitPuzzleCaptchaSolution(
458
- challenge,
459
- getAccount().account.account.address,
460
- getDappAccount(),
461
- finalX,
462
- finalY,
463
- puzzleEvents,
464
- userTimestampSignature.signature.toString(),
465
- encryptedBehavioralData,
466
- salt,
467
- simdReadings,
468
- clientMetaData,
469
- );
470
- if (disposed) return false;
471
-
472
- if (verifiedSolution[ApiParams.verified]) {
473
- updateState({
474
- isHuman: true,
475
- loading: false,
476
- });
477
-
478
- events.onHuman(
479
- encodeProcaptchaOutput({
480
- [ApiParams.providerUrl]: providerUrl,
481
- [ApiParams.user]: getAccount().account.account.address,
482
- [ApiParams.dapp]: getDappAccount(),
483
- [ApiParams.challenge]: challenge.challenge,
484
- [ApiParams.timestamp]: challenge.timestamp,
485
- [ApiParams.signature]: {
486
- [ApiParams.provider]: challenge.signature.provider,
487
- [ApiParams.user]: {
488
- [ApiParams.timestamp]:
489
- userTimestampSignature.signature.toString(),
490
- },
491
- },
492
- [ApiParams.captchaType]: CaptchaType.puzzle,
493
- }),
494
- );
495
- setValidChallengeTimeout();
496
- return true;
497
- }
498
- onFailed();
499
- return false;
500
- } catch (error) {
501
- updateState({ loading: false });
502
- throw error;
503
- }
504
- };
505
-
506
- // Set once the widget that owns this manager is torn down. A solve still in
507
- // flight can land afterwards, so the timer callbacks check it as well as
508
- // being cleared here.
509
- let disposed = false;
510
-
511
- /**
512
- * Stops this manager's challenge and solution-expiry timers without firing
513
- * any event. Left running after the widget is destroyed (reset(), a
514
- * restart, an SPA route change) they fired onExpired/onReset later on,
515
- * which also cleared the replacement widget's token from the form.
516
- */
517
- const dispose = () => {
518
- disposed = true;
519
- window.clearTimeout(Number(state.timeout));
520
- window.clearTimeout(Number(state.successfullChallengeTimeout));
521
- };
522
-
523
- return {
524
- start,
525
- submitSolution,
526
- resetState,
527
- dispose,
528
- };
529
- };
@@ -1,122 +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
- /**
16
- * A minimal mount/unmount harness. The suite deliberately drives real DOM
17
- * events rather than calling handlers directly, because every interactive
18
- * element in this package drops untrusted events — behaviour that only shows
19
- * up when a real event carries the flag.
20
- */
21
- export interface Mounted {
22
- container: HTMLDivElement;
23
- unmount: () => void;
24
- }
25
-
26
- export const mount = (): Mounted => {
27
- const container = document.createElement("div");
28
- document.body.appendChild(container);
29
- return {
30
- container,
31
- unmount: () => {
32
- container.remove();
33
- },
34
- };
35
- };
36
-
37
- interface FireOptions {
38
- trusted?: boolean;
39
- clientX?: number;
40
- clientY?: number;
41
- touches?: { clientX: number; clientY: number }[];
42
- key?: string;
43
- }
44
-
45
- /**
46
- * jsdom marks everything it dispatches untrusted, and exposes `isTrusted` as a
47
- * non-configurable accessor onto its internal implementation object, so the
48
- * flag has to be set there rather than shadowed on the event. Anything the
49
- * components filter on untrusted events is untestable otherwise.
50
- */
51
- const setTrusted = (event: Event, trusted: boolean): void => {
52
- for (const symbol of Object.getOwnPropertySymbols(event)) {
53
- const impl: unknown = Reflect.get(event, symbol);
54
- if (impl && typeof impl === "object" && "isTrusted" in impl) {
55
- // A plain assignment would not survive: dispatchEvent stamps
56
- // isTrusted back to false on the way in, so the flag is pinned with
57
- // an accessor that swallows the write.
58
- Object.defineProperty(impl, "isTrusted", {
59
- configurable: true,
60
- get: () => trusted,
61
- set: () => undefined,
62
- });
63
- return;
64
- }
65
- }
66
- throw new Error("could not reach the jsdom event implementation");
67
- };
68
-
69
- const build = (type: string, options: FireOptions): Event => {
70
- if (undefined !== options.key) {
71
- return new KeyboardEvent(type, {
72
- bubbles: true,
73
- cancelable: true,
74
- key: options.key,
75
- });
76
- }
77
- return new MouseEvent(type, {
78
- bubbles: true,
79
- cancelable: true,
80
- clientX: options.clientX ?? 0,
81
- clientY: options.clientY ?? 0,
82
- });
83
- };
84
-
85
- /**
86
- * Dispatches a real event at the element. Touch points are plain objects:
87
- * jsdom has no Touch constructor.
88
- */
89
- export const fire = (
90
- element: Element,
91
- type: string,
92
- options: FireOptions = {},
93
- ): void => {
94
- fireAndReturn(element, type, options);
95
- };
96
-
97
- /** Like `fire`, but hands back the event so a test can inspect what the
98
- * handler did to it — whether it prevented the default form submission, say. */
99
- export const fireAndReturn = (
100
- element: Element,
101
- type: string,
102
- options: FireOptions = {},
103
- ): Event => {
104
- const event = build(type, options);
105
- setTrusted(event, options.trusted ?? true);
106
- if (options.touches) {
107
- Object.defineProperty(event, "touches", { value: options.touches });
108
- }
109
- element.dispatchEvent(event);
110
- return event;
111
- };
112
-
113
- /**
114
- * Renders coalesce onto the microtask queue, so a state change is not visible
115
- * in the DOM until it has drained.
116
- */
117
- export const settle = async (): Promise<void> => {
118
- // A macrotask turn, so a pending dynamic import resolves too — draining the
119
- // microtask queue alone is not enough for the code-split boundaries.
120
- await new Promise<void>((resolve: () => void) => setTimeout(resolve, 0));
121
- await Promise.resolve();
122
- };