@ruakit/runtime 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +4 -0
  3. package/dist/action-feedback.d.ts +4 -0
  4. package/dist/action-feedback.d.ts.map +1 -0
  5. package/dist/action-feedback.js +3 -0
  6. package/dist/action-feedback.js.map +7 -0
  7. package/dist/dom.d.ts +17 -0
  8. package/dist/dom.d.ts.map +1 -0
  9. package/dist/dom.js +3 -0
  10. package/dist/dom.js.map +7 -0
  11. package/dist/errors.d.ts +6 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +3 -0
  14. package/dist/errors.js.map +7 -0
  15. package/dist/experimental/index.d.ts +24 -0
  16. package/dist/experimental/index.d.ts.map +1 -0
  17. package/dist/experimental/index.js +3 -0
  18. package/dist/experimental/index.js.map +7 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +3 -0
  22. package/dist/index.js.map +7 -0
  23. package/dist/operations.d.ts +5 -0
  24. package/dist/operations.d.ts.map +1 -0
  25. package/dist/operations.js +3 -0
  26. package/dist/operations.js.map +7 -0
  27. package/dist/runtime.d.ts +4 -0
  28. package/dist/runtime.d.ts.map +1 -0
  29. package/dist/runtime.js +3 -0
  30. package/dist/runtime.js.map +7 -0
  31. package/dist/screenshot.d.ts +3 -0
  32. package/dist/screenshot.d.ts.map +1 -0
  33. package/dist/screenshot.js +5 -0
  34. package/dist/screenshot.js.map +7 -0
  35. package/dist/targets.d.ts +15 -0
  36. package/dist/targets.d.ts.map +1 -0
  37. package/dist/targets.js +3 -0
  38. package/dist/targets.js.map +7 -0
  39. package/dist/types.d.ts +121 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +2 -0
  42. package/dist/types.js.map +7 -0
  43. package/package.json +37 -3
  44. package/src/action-feedback.ts +85 -0
  45. package/src/dom.ts +307 -0
  46. package/src/errors.ts +14 -0
  47. package/src/experimental/index.ts +192 -0
  48. package/src/index.ts +21 -0
  49. package/src/operations.ts +67 -0
  50. package/src/runtime.ts +541 -0
  51. package/src/screenshot.ts +172 -0
  52. package/src/targets.ts +141 -0
  53. package/src/types.ts +135 -0
  54. package/README.md +0 -3
package/src/runtime.ts ADDED
@@ -0,0 +1,541 @@
1
+ // Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
2
+ import {
3
+ activeElement,
4
+ configuration,
5
+ contains,
6
+ isField,
7
+ isVisible,
8
+ observeControl,
9
+ } from "./dom.js";
10
+ import type { NativeField } from "./dom.js";
11
+ import { RuntimeError } from "./errors.js";
12
+ import { pause, validateTimeout, withDeadline } from "./operations.js";
13
+ import { Targets } from "./targets.js";
14
+ import { withActionFeedback } from "./action-feedback.js";
15
+ import { captureScreenshot } from "./screenshot.js";
16
+ import type {
17
+ ActionOptions,
18
+ PageState,
19
+ Runtime,
20
+ RuntimeAction,
21
+ RuntimeOptions,
22
+ Target,
23
+ } from "./types.js";
24
+
25
+ type Verification = () => void;
26
+ interface Edit {
27
+ apply(signal: AbortSignal): void;
28
+ accepted(): boolean;
29
+ }
30
+
31
+ interface FieldSnapshot {
32
+ configuration: string;
33
+ form: HTMLFormElement | null;
34
+ parent: HTMLElement | null;
35
+ }
36
+
37
+ const textTypes = new Set([
38
+ "text",
39
+ "search",
40
+ "email",
41
+ "tel",
42
+ "url",
43
+ "number",
44
+ "date",
45
+ "datetime-local",
46
+ "time",
47
+ "month",
48
+ "week",
49
+ "color",
50
+ "range",
51
+ ]);
52
+
53
+ function assignValue(
54
+ element: HTMLInputElement | HTMLTextAreaElement,
55
+ value: string,
56
+ ): void {
57
+ const view = element.ownerDocument.defaultView!;
58
+ const prototype =
59
+ element.tagName === "TEXTAREA"
60
+ ? view.HTMLTextAreaElement.prototype
61
+ : view.HTMLInputElement.prototype;
62
+ // Bypass React's value tracker so its input handler receives the new value.
63
+ Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(
64
+ element,
65
+ value,
66
+ );
67
+ }
68
+
69
+ /** Create this after mounting. Importing the package itself is safe during server rendering. */
70
+ export function createRuntime(options: RuntimeOptions = {}): Runtime {
71
+ const root = options.root === undefined ? globalThis.document : options.root;
72
+ if (!root) {
73
+ throw new TypeError(
74
+ "Create the runtime in a browser, after the app has mounted.",
75
+ );
76
+ }
77
+
78
+ const targets = new Targets(root);
79
+ const document = targets.document;
80
+ const view = document.defaultView!;
81
+ const timeoutMs = options.timeoutMs ?? 2000;
82
+
83
+ validateTimeout(timeoutMs);
84
+ let busy = false;
85
+
86
+ async function run<T>(
87
+ settings: ActionOptions,
88
+ operation: (signal: AbortSignal) => Promise<T>,
89
+ ): Promise<T> {
90
+ if (busy) {
91
+ throw new RuntimeError(
92
+ "BUSY",
93
+ "Another operation is running. Await it before starting the next one.",
94
+ );
95
+ }
96
+
97
+ busy = true;
98
+ try {
99
+ return await withDeadline(
100
+ settings.timeoutMs ?? timeoutMs,
101
+ settings.signal,
102
+ operation,
103
+ );
104
+ } finally {
105
+ busy = false;
106
+ }
107
+ }
108
+
109
+ function act(
110
+ settings: ActionOptions,
111
+ effect: (
112
+ signal: AbortSignal,
113
+ ) => void | Verification | Promise<void | Verification>,
114
+ feedback?: {
115
+ action: RuntimeAction;
116
+ target: Target;
117
+ requireEnabled?: boolean;
118
+ },
119
+ ): Promise<PageState> {
120
+ return run(settings, async (signal) => {
121
+ const execute = async (): Promise<PageState> => {
122
+ const verify = await effect(signal);
123
+
124
+ // Give controlled inputs and ordinary React event updates a turn to commit.
125
+ await pause(signal);
126
+ signal.throwIfAborted();
127
+ verify?.();
128
+
129
+ return targets.getState();
130
+ };
131
+
132
+ return feedback
133
+ ? withActionFeedback(
134
+ options,
135
+ feedback.action,
136
+ () => targets.resolve(feedback.target, feedback.requireEnabled),
137
+ signal,
138
+ execute,
139
+ )
140
+ : execute();
141
+ });
142
+ }
143
+
144
+ function focusTarget(target: Target, signal: AbortSignal): HTMLElement {
145
+ const element = targets.resolve(target);
146
+ element.focus({ preventScroll: true });
147
+ signal.throwIfAborted();
148
+ targets.resolve(target);
149
+ return element;
150
+ }
151
+
152
+ function verifyEdit(
153
+ field: NativeField,
154
+ before: FieldSnapshot,
155
+ change: Edit,
156
+ ): void {
157
+ // A field replaced or reconfigured during the edit is no longer the same target.
158
+ const fieldChanged =
159
+ !contains(root, field) ||
160
+ !isVisible(field) ||
161
+ !observeControl(field, root).enabled ||
162
+ configuration(field) !== before.configuration ||
163
+ field.form !== before.form ||
164
+ field.parentElement !== before.parent;
165
+
166
+ if (fieldChanged || !change.accepted()) {
167
+ throw new RuntimeError(
168
+ "VALUE_REJECTED",
169
+ "The application did not accept the requested field value.",
170
+ );
171
+ }
172
+ }
173
+
174
+ function edit(
175
+ action: "fill" | "select" | "check",
176
+ target: Target,
177
+ settings: ActionOptions,
178
+ prepare: (field: NativeField) => Edit,
179
+ ): Promise<PageState> {
180
+ return act(
181
+ settings,
182
+ (signal) => {
183
+ const element = targets.resolve(target);
184
+ if (!isField(element)) {
185
+ throw new RuntimeError(
186
+ "UNSUPPORTED",
187
+ "This operation needs a native input, textarea or select.",
188
+ );
189
+ }
190
+
191
+ if (observeControl(element, root).readOnly) {
192
+ throw new RuntimeError("READ_ONLY", "The field is read-only.");
193
+ }
194
+
195
+ const change = prepare(element);
196
+ focusTarget(target, signal);
197
+
198
+ const before: FieldSnapshot = {
199
+ configuration: configuration(element),
200
+ form: element.form,
201
+ parent: element.parentElement,
202
+ };
203
+
204
+ change.apply(signal);
205
+
206
+ // Check the result after the application has processed the field events.
207
+ return () => verifyEdit(element, before, change);
208
+ },
209
+ { action, target },
210
+ );
211
+ }
212
+
213
+ function fieldEvents(element: NativeField, signal: AbortSignal): void {
214
+ element.dispatchEvent(new view.Event("input", { bubbles: true }));
215
+ signal.throwIfAborted();
216
+ element.dispatchEvent(new view.Event("change", { bubbles: true }));
217
+ }
218
+
219
+ return {
220
+ getState: () => targets.getState(),
221
+ screenshot(settings = {}) {
222
+ return run(settings, (signal) => captureScreenshot(root, signal));
223
+ },
224
+
225
+ click(target, settings = {}) {
226
+ return act(settings, () => targets.resolve(target).click(), {
227
+ action: "click",
228
+ target,
229
+ });
230
+ },
231
+
232
+ focus(target, settings = {}) {
233
+ return act(
234
+ settings,
235
+ (signal) => {
236
+ const element = focusTarget(target, signal);
237
+ return () => {
238
+ if (activeElement(document) !== element) {
239
+ throw new RuntimeError(
240
+ "UNSUPPORTED",
241
+ "The control did not receive focus.",
242
+ );
243
+ }
244
+ };
245
+ },
246
+ { action: "focus", target },
247
+ );
248
+ },
249
+
250
+ blur(target, settings = {}) {
251
+ return act(settings, () => targets.resolve(target).blur(), {
252
+ action: "blur",
253
+ target,
254
+ });
255
+ },
256
+
257
+ setValue(target, value, settings = {}) {
258
+ return edit("fill", target, settings, (field) => {
259
+ if (
260
+ field.tagName === "SELECT" ||
261
+ (field.tagName === "INPUT" &&
262
+ !textTypes.has((field as HTMLInputElement).type))
263
+ ) {
264
+ throw new RuntimeError(
265
+ "UNSUPPORTED",
266
+ "Use setValue with a text or value input or a textarea.",
267
+ );
268
+ }
269
+
270
+ const element = field as HTMLInputElement | HTMLTextAreaElement;
271
+ const clone = element.cloneNode(true) as typeof element;
272
+
273
+ // Check the browser's normalization before changing the real field.
274
+ assignValue(clone, value);
275
+ if (
276
+ clone.value !== value ||
277
+ (element.maxLength >= 0 && value.length > element.maxLength)
278
+ ) {
279
+ throw new RuntimeError(
280
+ "VALUE_REJECTED",
281
+ "The requested value does not fit this field.",
282
+ );
283
+ }
284
+
285
+ return {
286
+ apply(signal) {
287
+ assignValue(element, value);
288
+ fieldEvents(element, signal);
289
+ },
290
+ accepted: () => element.value === value,
291
+ };
292
+ });
293
+ },
294
+
295
+ selectOption(target, values, settings = {}) {
296
+ return edit("select", target, settings, (field) => {
297
+ if (field.tagName !== "SELECT") {
298
+ throw new RuntimeError(
299
+ "UNSUPPORTED",
300
+ "Use selectOption with a native select.",
301
+ );
302
+ }
303
+
304
+ const select = field as HTMLSelectElement;
305
+ const options = [...select.options];
306
+
307
+ if (
308
+ new Set(values).size !== values.length ||
309
+ (!select.multiple && values.length > 1) ||
310
+ values.some(
311
+ (value) => !options.some((option) => option.value === value),
312
+ ) ||
313
+ options.some(
314
+ (option) =>
315
+ values.includes(option.value) &&
316
+ (option.disabled || option.closest("optgroup[disabled]")),
317
+ )
318
+ ) {
319
+ throw new RuntimeError(
320
+ "UNSUPPORTED",
321
+ "Choose available option values from getState().",
322
+ );
323
+ }
324
+
325
+ return {
326
+ apply(signal) {
327
+ select.selectedIndex = -1;
328
+ for (const option of options) {
329
+ option.selected = values.includes(option.value);
330
+ }
331
+
332
+ fieldEvents(select, signal);
333
+ },
334
+ accepted: () =>
335
+ select.selectedOptions.length === values.length &&
336
+ [...select.selectedOptions].every((option) =>
337
+ values.includes(option.value),
338
+ ),
339
+ };
340
+ });
341
+ },
342
+
343
+ setChecked(target, checked, settings = {}) {
344
+ return edit("check", target, settings, (field) => {
345
+ const input = field as HTMLInputElement;
346
+ if (
347
+ field.tagName !== "INPUT" ||
348
+ !["checkbox", "radio"].includes(input.type)
349
+ ) {
350
+ throw new RuntimeError(
351
+ "UNSUPPORTED",
352
+ "Use setChecked with a native checkbox or radio.",
353
+ );
354
+ }
355
+
356
+ if (input.type === "radio" && input.checked && !checked) {
357
+ throw new RuntimeError(
358
+ "UNSUPPORTED",
359
+ "Select another radio in the group to change its value.",
360
+ );
361
+ }
362
+
363
+ return {
364
+ apply() {
365
+ if (input.checked !== checked) {
366
+ input.click();
367
+ }
368
+ },
369
+ accepted: () => input.checked === checked,
370
+ };
371
+ });
372
+ },
373
+
374
+ insertText(target, text, settings = {}) {
375
+ return act(
376
+ settings,
377
+ (signal) => {
378
+ const element = targets.resolve(target);
379
+ const data = observeControl(element, root);
380
+
381
+ if (data.readOnly) {
382
+ throw new RuntimeError("READ_ONLY", "The editor is read-only.");
383
+ }
384
+
385
+ if (!data.contentEditable && !element.matches("input, textarea")) {
386
+ throw new RuntimeError(
387
+ "UNSUPPORTED",
388
+ "Insert text requires a text field or contenteditable surface.",
389
+ );
390
+ }
391
+
392
+ focusTarget(target, signal);
393
+ signal.throwIfAborted();
394
+
395
+ // Rich editors own their document and intercept beforeinput. Let them
396
+ // handle insertion before falling back to the browser editing command.
397
+ const input = new view.InputEvent("beforeinput", {
398
+ inputType: "insertText",
399
+ data: text,
400
+ bubbles: true,
401
+ cancelable: true,
402
+ composed: true,
403
+ });
404
+
405
+ if (!element.dispatchEvent(input)) {
406
+ signal.throwIfAborted();
407
+ return () => {
408
+ if (text && observeControl(element, root).value === data.value) {
409
+ throw new RuntimeError(
410
+ "VALUE_REJECTED",
411
+ "The editor did not accept text insertion.",
412
+ );
413
+ }
414
+ };
415
+ }
416
+
417
+ signal.throwIfAborted();
418
+ targets.resolve(target);
419
+
420
+ if (typeof document.execCommand !== "function") {
421
+ throw new RuntimeError(
422
+ "UNSUPPORTED",
423
+ "This browser does not support the text insertion command.",
424
+ );
425
+ }
426
+
427
+ if (!document.execCommand("insertText", false, text)) {
428
+ throw new RuntimeError(
429
+ "VALUE_REJECTED",
430
+ "The browser or editor rejected text insertion.",
431
+ );
432
+ }
433
+ },
434
+ { action: "insertText", target },
435
+ );
436
+ },
437
+
438
+ pressKey(target, key, settings = {}) {
439
+ return act(
440
+ settings,
441
+ (signal) => {
442
+ if (!key) {
443
+ throw new TypeError(
444
+ "Provide a keyboard key, such as Enter or Escape.",
445
+ );
446
+ }
447
+
448
+ const element = focusTarget(target, signal);
449
+ const event = {
450
+ key,
451
+ bubbles: true,
452
+ cancelable: true,
453
+ altKey: settings.altKey ?? false,
454
+ ctrlKey: settings.ctrlKey ?? false,
455
+ metaKey: settings.metaKey ?? false,
456
+ shiftKey: settings.shiftKey ?? false,
457
+ };
458
+
459
+ element.dispatchEvent(new view.KeyboardEvent("keydown", event));
460
+ signal.throwIfAborted();
461
+ if (element.isConnected) {
462
+ element.dispatchEvent(new view.KeyboardEvent("keyup", event));
463
+ }
464
+ },
465
+ { action: "key", target },
466
+ );
467
+ },
468
+
469
+ scrollIntoView(target, settings = {}) {
470
+ return act(
471
+ settings,
472
+ () => {
473
+ const element = targets.resolve(target, false);
474
+ if (typeof element.scrollIntoView !== "function") {
475
+ throw new RuntimeError(
476
+ "UNSUPPORTED",
477
+ "This environment does not support scrollIntoView.",
478
+ );
479
+ }
480
+
481
+ element.scrollIntoView({
482
+ block: "nearest",
483
+ inline: "nearest",
484
+ behavior: "instant",
485
+ });
486
+ },
487
+ { action: "scroll", target, requireEnabled: false },
488
+ );
489
+ },
490
+
491
+ goto(path, settings = {}) {
492
+ return act(settings, async (signal) => {
493
+ const navigate = options.navigate;
494
+ if (!navigate) {
495
+ throw new RuntimeError(
496
+ "UNSUPPORTED",
497
+ "Pass your router navigation callback to createRuntime.",
498
+ );
499
+ }
500
+
501
+ const url = new URL(path, document.URL);
502
+ if (
503
+ url.origin !== document.location.origin ||
504
+ !["http:", "https:"].includes(url.protocol)
505
+ ) {
506
+ throw new RuntimeError(
507
+ "UNSUPPORTED",
508
+ "Navigation must stay within this application origin.",
509
+ );
510
+ }
511
+
512
+ await navigate(url.pathname + url.search + url.hash, signal);
513
+ signal.throwIfAborted();
514
+
515
+ return () => {
516
+ if (document.URL !== url.href) {
517
+ throw new RuntimeError(
518
+ "VALUE_REJECTED",
519
+ "The router did not reach the requested URL.",
520
+ );
521
+ }
522
+ };
523
+ });
524
+ },
525
+
526
+ waitFor(condition, settings = {}) {
527
+ return run(settings, async (signal) => {
528
+ while (true) {
529
+ signal.throwIfAborted();
530
+ const state = targets.getState();
531
+
532
+ if (condition(state)) {
533
+ return state;
534
+ }
535
+
536
+ await pause(signal, 50);
537
+ }
538
+ });
539
+ },
540
+ };
541
+ }
@@ -0,0 +1,172 @@
1
+ // Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
2
+ import { RuntimeError } from './errors.js';
3
+ import type { Screenshot } from './types.js';
4
+
5
+ const privateSelector =
6
+ 'input[type="password"], input[type="file"], [data-ruakit-ignore], [data-ruakit-toolbar]';
7
+ const maxDimension = 8192;
8
+ const maxPixels = 4 * 1024 * 1024;
9
+ const maxBytes = 5 * 1024 * 1024;
10
+ const pngPrefix = 'data:image/png;base64,';
11
+
12
+ function excluded(element: Element): boolean {
13
+ let ancestor: Element | null = element;
14
+ while (ancestor) {
15
+ if (ancestor.closest(privateSelector)) return true;
16
+
17
+ const scope = ancestor.getRootNode();
18
+ ancestor = 'host' in scope ? (scope as ShadowRoot).host : null;
19
+ }
20
+
21
+ if (['SCRIPT', 'NOSCRIPT', 'HEAD', 'IFRAME'].includes(element.tagName.toUpperCase())) {
22
+ return true;
23
+ }
24
+
25
+ // SVGs and <use> references bypass html-to-image's child filter.
26
+ return (
27
+ element.namespaceURI === 'http://www.w3.org/2000/svg' &&
28
+ Boolean(element.querySelector(privateSelector) || element.querySelector('use'))
29
+ );
30
+ }
31
+
32
+ function pngData(canvas: HTMLCanvasElement, signal: AbortSignal): string {
33
+ const dataUrl = canvas.toDataURL('image/png');
34
+ signal.throwIfAborted();
35
+
36
+ const data = dataUrl.slice(pngPrefix.length);
37
+ if (
38
+ !dataUrl.startsWith(pngPrefix) ||
39
+ !data.length ||
40
+ data.length % 4 !== 0 ||
41
+ data.length > Math.ceil(maxBytes / 3) * 4 ||
42
+ !/^[A-Za-z0-9+/]+={0,2}$/.test(data)
43
+ ) {
44
+ throw new RuntimeError('UNSUPPORTED', 'The screenshot must be a PNG image within 5 MiB.');
45
+ }
46
+
47
+ const bytes = globalThis.atob(data);
48
+ if (bytes.length > maxBytes || !bytes.startsWith('\x89PNG\r\n\x1a\n')) {
49
+ throw new RuntimeError('UNSUPPORTED', 'The screenshot must be a PNG image within 5 MiB.');
50
+ }
51
+
52
+ return data;
53
+ }
54
+
55
+ export async function captureScreenshot(
56
+ root: Document | HTMLElement,
57
+ signal: AbortSignal,
58
+ ): Promise<Screenshot> {
59
+ signal.throwIfAborted();
60
+
61
+ const document = root.ownerDocument ?? (root as Document);
62
+ const element =
63
+ root.nodeType === 9 ? document.documentElement : (root as HTMLElement);
64
+ const url = document.URL;
65
+
66
+ if (!element?.isConnected) {
67
+ throw new RuntimeError(
68
+ 'STALE_TARGET',
69
+ 'The screenshot root is disconnected.',
70
+ );
71
+ }
72
+ if (excluded(element)) {
73
+ throw new RuntimeError(
74
+ 'UNSUPPORTED',
75
+ 'The screenshot root is excluded from observation.',
76
+ );
77
+ }
78
+
79
+ const rect = element.getBoundingClientRect();
80
+ const width = Math.ceil(
81
+ Math.max(element.offsetWidth, element.clientWidth) || rect.width,
82
+ );
83
+ const height = Math.ceil(
84
+ Math.max(element.offsetHeight, element.clientHeight) || rect.height,
85
+ );
86
+
87
+ if (
88
+ !Number.isFinite(width) ||
89
+ !Number.isFinite(height) ||
90
+ width < 1 ||
91
+ height < 1
92
+ ) {
93
+ throw new RuntimeError(
94
+ 'UNSUPPORTED',
95
+ 'The screenshot root has no rendered area.',
96
+ );
97
+ }
98
+
99
+ const scale = Math.min(
100
+ 1,
101
+ maxDimension / width,
102
+ maxDimension / height,
103
+ Math.sqrt(maxPixels / width / height),
104
+ );
105
+ const style = document.defaultView?.getComputedStyle(element);
106
+
107
+ // Import only on demand. Observation, actions and server rendering do not load the renderer.
108
+ const { toCanvas } = await import('html-to-image');
109
+ signal.throwIfAborted();
110
+
111
+ const canvas = await toCanvas(element, {
112
+ width,
113
+ height,
114
+ canvasWidth: Math.max(1, Math.floor(width * scale)),
115
+ canvasHeight: Math.max(1, Math.floor(height * scale)),
116
+ pixelRatio: 1,
117
+ includeQueryParams: true,
118
+ fetchRequestInit: { signal },
119
+ // Normalize the root's placement in the exported image without changing the live page.
120
+ style: {
121
+ margin: '0',
122
+ top: '0',
123
+ left: '0',
124
+ transform: 'none',
125
+ width: style?.width || `${width}px`,
126
+ height: style?.height || `${height}px`,
127
+ },
128
+ filter: (node) => {
129
+ signal.throwIfAborted();
130
+ return node.nodeType !== 1 || !excluded(node);
131
+ },
132
+ });
133
+
134
+ signal.throwIfAborted();
135
+ if (document.URL !== url || !element.isConnected) {
136
+ throw new RuntimeError(
137
+ 'STALE_TARGET',
138
+ 'The page changed during screenshot capture.',
139
+ );
140
+ }
141
+ if (excluded(element)) {
142
+ throw new RuntimeError(
143
+ 'STALE_TARGET',
144
+ 'The screenshot root became excluded during capture.',
145
+ );
146
+ }
147
+
148
+ if (
149
+ !Number.isInteger(canvas.width) ||
150
+ canvas.width < 1 ||
151
+ canvas.width > maxDimension ||
152
+ !Number.isInteger(canvas.height) ||
153
+ canvas.height < 1 ||
154
+ canvas.height > maxDimension ||
155
+ canvas.width * canvas.height > maxPixels
156
+ ) {
157
+ throw new RuntimeError(
158
+ 'UNSUPPORTED',
159
+ 'The screenshot exceeds the image dimensions limit.',
160
+ );
161
+ }
162
+ const data = pngData(canvas, signal);
163
+
164
+ return {
165
+ data,
166
+ mimeType: 'image/png',
167
+ width: canvas.width,
168
+ height: canvas.height,
169
+ capturedAt: Date.now(),
170
+ url,
171
+ };
172
+ }