cdp-skill 1.0.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/src/utils.js ADDED
@@ -0,0 +1,1034 @@
1
+ /**
2
+ * Shared utilities for CDP browser automation
3
+ * Consolidated: errors, key validation, form validation, device presets
4
+ */
5
+
6
+ import os from 'os';
7
+ import path from 'path';
8
+ import fs from 'fs/promises';
9
+
10
+ // ============================================================================
11
+ // Temp Directory Utilities
12
+ // ============================================================================
13
+
14
+ let _tempDir = null;
15
+
16
+ /**
17
+ * Get the platform-specific temp directory for CDP skill outputs (screenshots, PDFs, etc.)
18
+ * Creates the directory if it doesn't exist
19
+ * @returns {Promise<string>} Absolute path to temp directory
20
+ */
21
+ export async function getTempDir() {
22
+ if (_tempDir) return _tempDir;
23
+
24
+ const baseTemp = os.tmpdir();
25
+ _tempDir = path.join(baseTemp, 'cdp-skill');
26
+
27
+ await fs.mkdir(_tempDir, { recursive: true });
28
+ return _tempDir;
29
+ }
30
+
31
+ /**
32
+ * Get the temp directory synchronously (returns cached value or creates new)
33
+ * Note: First call should use getTempDir() to ensure directory exists
34
+ * @returns {string} Absolute path to temp directory
35
+ */
36
+ export function getTempDirSync() {
37
+ if (_tempDir) return _tempDir;
38
+
39
+ const baseTemp = os.tmpdir();
40
+ _tempDir = path.join(baseTemp, 'cdp-skill');
41
+ return _tempDir;
42
+ }
43
+
44
+ /**
45
+ * Resolve a file path, using temp directory for relative paths
46
+ * @param {string} filePath - File path (relative or absolute)
47
+ * @param {string} [extension] - Default extension to add if missing
48
+ * @returns {Promise<string>} Absolute path
49
+ */
50
+ export async function resolveTempPath(filePath, extension) {
51
+ // If already absolute, use as-is
52
+ if (path.isAbsolute(filePath)) {
53
+ return filePath;
54
+ }
55
+
56
+ // For relative paths, put in temp directory
57
+ const tempDir = await getTempDir();
58
+ let resolved = path.join(tempDir, filePath);
59
+
60
+ // Add extension if missing
61
+ if (extension && !path.extname(resolved)) {
62
+ resolved += extension;
63
+ }
64
+
65
+ return resolved;
66
+ }
67
+
68
+ /**
69
+ * Generate a unique temp file path with timestamp
70
+ * @param {string} prefix - File prefix (e.g., 'screenshot', 'page')
71
+ * @param {string} extension - File extension (e.g., '.png', '.pdf')
72
+ * @returns {Promise<string>} Unique absolute path in temp directory
73
+ */
74
+ export async function generateTempPath(prefix, extension) {
75
+ const tempDir = await getTempDir();
76
+ const timestamp = Date.now();
77
+ const random = Math.random().toString(36).substring(2, 8);
78
+ return path.join(tempDir, `${prefix}-${timestamp}-${random}${extension}`);
79
+ }
80
+
81
+ // ============================================================================
82
+ // Basic Utilities
83
+ // ============================================================================
84
+
85
+ /**
86
+ * Promise-based delay
87
+ * @param {number} ms - Milliseconds to wait
88
+ * @returns {Promise<void>}
89
+ */
90
+ export function sleep(ms) {
91
+ return new Promise(resolve => setTimeout(resolve, ms));
92
+ }
93
+
94
+ // ============================================================================
95
+ // Backoff Sleeper with Jitter (inspired by Rod)
96
+ // ============================================================================
97
+
98
+ /**
99
+ * Create a backoff sleeper with exponential delay and jitter
100
+ * Prevents thundering herd by randomizing retry delays
101
+ * @param {Object} [options] - Configuration options
102
+ * @param {number} [options.initialDelay=100] - Initial delay in ms
103
+ * @param {number} [options.maxDelay=5000] - Maximum delay in ms
104
+ * @param {number} [options.multiplier=2] - Base multiplier for exponential growth
105
+ * @param {number} [options.jitterMin=0.9] - Minimum jitter factor (e.g., 0.9 = 90%)
106
+ * @param {number} [options.jitterMax=1.1] - Maximum jitter factor (e.g., 1.1 = 110%)
107
+ * @returns {Object} Backoff sleeper interface
108
+ */
109
+ export function createBackoffSleeper(options = {}) {
110
+ const {
111
+ initialDelay = 100,
112
+ maxDelay = 5000,
113
+ multiplier = 2,
114
+ jitterMin = 0.9,
115
+ jitterMax = 1.1
116
+ } = options;
117
+
118
+ let attempt = 0;
119
+ let currentDelay = initialDelay;
120
+
121
+ /**
122
+ * Apply jitter to a delay value
123
+ * @param {number} delay - Base delay
124
+ * @returns {number} Delay with jitter applied
125
+ */
126
+ function applyJitter(delay) {
127
+ const jitterRange = jitterMax - jitterMin;
128
+ const jitterFactor = jitterMin + Math.random() * jitterRange;
129
+ return Math.floor(delay * jitterFactor);
130
+ }
131
+
132
+ /**
133
+ * Sleep with exponential backoff and jitter
134
+ * Each call increases the delay exponentially (with random factor)
135
+ * @returns {Promise<number>} The delay that was used
136
+ */
137
+ async function sleep() {
138
+ const delayWithJitter = applyJitter(currentDelay);
139
+ await new Promise(resolve => setTimeout(resolve, delayWithJitter));
140
+
141
+ // Increase delay for next attempt (with random multiplier 1.9-2.1)
142
+ const randomMultiplier = multiplier * (0.95 + Math.random() * 0.1);
143
+ currentDelay = Math.min(currentDelay * randomMultiplier, maxDelay);
144
+ attempt++;
145
+
146
+ return delayWithJitter;
147
+ }
148
+
149
+ /**
150
+ * Reset the backoff state
151
+ */
152
+ function reset() {
153
+ attempt = 0;
154
+ currentDelay = initialDelay;
155
+ }
156
+
157
+ /**
158
+ * Get current attempt count
159
+ * @returns {number}
160
+ */
161
+ function getAttempt() {
162
+ return attempt;
163
+ }
164
+
165
+ /**
166
+ * Get next delay without sleeping (preview)
167
+ * @returns {number}
168
+ */
169
+ function peekDelay() {
170
+ return applyJitter(currentDelay);
171
+ }
172
+
173
+ return {
174
+ sleep,
175
+ reset,
176
+ getAttempt,
177
+ peekDelay
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Sleep with backoff - simple one-shot function
183
+ * @param {number} attempt - Current attempt number (0-based)
184
+ * @param {Object} [options] - Options
185
+ * @param {number} [options.initialDelay=100] - Initial delay
186
+ * @param {number} [options.maxDelay=5000] - Max delay
187
+ * @returns {Promise<number>} The delay used
188
+ */
189
+ export async function sleepWithBackoff(attempt, options = {}) {
190
+ const { initialDelay = 100, maxDelay = 5000 } = options;
191
+
192
+ // Calculate delay: initialDelay * 2^attempt with jitter
193
+ const baseDelay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
194
+
195
+ // Apply jitter (0.9-1.1x)
196
+ const jitter = 0.9 + Math.random() * 0.2;
197
+ const delay = Math.floor(baseDelay * jitter);
198
+
199
+ await sleep(delay);
200
+ return delay;
201
+ }
202
+
203
+ /**
204
+ * Release a CDP object reference to prevent memory leaks
205
+ * @param {Object} session - CDP session
206
+ * @param {string} objectId - Object ID to release
207
+ */
208
+ export async function releaseObject(session, objectId) {
209
+ if (!objectId) return;
210
+ try {
211
+ await session.send('Runtime.releaseObject', { objectId });
212
+ } catch {
213
+ // Ignore errors during cleanup - object may already be released
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Reset input state to ensure no pending mouse/keyboard events
219
+ * Helps prevent timeouts on subsequent operations after failures
220
+ * @param {Object} session - CDP session
221
+ */
222
+ export async function resetInputState(session) {
223
+ try {
224
+ await session.send('Input.dispatchMouseEvent', {
225
+ type: 'mouseReleased',
226
+ x: 0,
227
+ y: 0,
228
+ button: 'left',
229
+ buttons: 0
230
+ });
231
+ } catch {
232
+ // Ignore errors during cleanup
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Scroll alignment strategies for bringing elements into view
238
+ */
239
+ export const SCROLL_STRATEGIES = ['center', 'end', 'start', 'nearest'];
240
+
241
+ /**
242
+ * Action types for actionability checking
243
+ */
244
+ export const ActionTypes = {
245
+ CLICK: 'click',
246
+ HOVER: 'hover',
247
+ FILL: 'fill',
248
+ TYPE: 'type',
249
+ SELECT: 'select'
250
+ };
251
+
252
+ /**
253
+ * Get current page URL
254
+ * @param {Object} session - CDP session
255
+ * @returns {Promise<string|null>}
256
+ */
257
+ export async function getCurrentUrl(session) {
258
+ try {
259
+ const result = await session.send('Runtime.evaluate', {
260
+ expression: 'window.location.href',
261
+ returnByValue: true
262
+ });
263
+ return result.result.value;
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Get element info at a specific point (for debug mode)
271
+ * @param {Object} session - CDP session
272
+ * @param {number} x - X coordinate
273
+ * @param {number} y - Y coordinate
274
+ * @returns {Promise<Object|null>}
275
+ */
276
+ export async function getElementAtPoint(session, x, y) {
277
+ try {
278
+ const result = await session.send('Runtime.evaluate', {
279
+ expression: `
280
+ (function() {
281
+ const el = document.elementFromPoint(${x}, ${y});
282
+ if (!el) return null;
283
+ return {
284
+ tagName: el.tagName.toLowerCase(),
285
+ id: el.id || null,
286
+ className: el.className || null,
287
+ textContent: el.textContent ? el.textContent.trim().substring(0, 50) : null
288
+ };
289
+ })()
290
+ `,
291
+ returnByValue: true
292
+ });
293
+ return result.result.value;
294
+ } catch {
295
+ return null;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Detect navigation by comparing URLs before and after an action
301
+ * @param {Object} session - CDP session
302
+ * @param {string} urlBeforeAction - URL before the action
303
+ * @param {number} timeout - Timeout to wait for navigation
304
+ * @returns {Promise<{navigated: boolean, newUrl?: string}>}
305
+ */
306
+ export async function detectNavigation(session, urlBeforeAction, timeout = 100) {
307
+ await sleep(timeout);
308
+ try {
309
+ const urlAfterAction = await getCurrentUrl(session);
310
+ const navigated = urlAfterAction !== urlBeforeAction;
311
+ return {
312
+ navigated,
313
+ newUrl: navigated ? urlAfterAction : undefined
314
+ };
315
+ } catch {
316
+ // If we can't get URL, page likely navigated
317
+ return { navigated: true };
318
+ }
319
+ }
320
+
321
+ // ============================================================================
322
+ // Error Types and Factory Functions (from errors.js)
323
+ // ============================================================================
324
+
325
+ /**
326
+ * Error types for CDP browser driver operations
327
+ */
328
+ export const ErrorTypes = Object.freeze({
329
+ CONNECTION: 'CDPConnectionError',
330
+ NAVIGATION: 'NavigationError',
331
+ NAVIGATION_ABORTED: 'NavigationAbortedError',
332
+ TIMEOUT: 'TimeoutError',
333
+ ELEMENT_NOT_FOUND: 'ElementNotFoundError',
334
+ ELEMENT_NOT_EDITABLE: 'ElementNotEditableError',
335
+ STALE_ELEMENT: 'StaleElementError',
336
+ PAGE_CRASHED: 'PageCrashedError',
337
+ CONTEXT_DESTROYED: 'ContextDestroyedError',
338
+ STEP_VALIDATION: 'StepValidationError'
339
+ });
340
+
341
+ /**
342
+ * Create a typed error with standard properties
343
+ * @param {string} type - Error type from ErrorTypes
344
+ * @param {string} message - Error message
345
+ * @param {Object} props - Additional properties to attach
346
+ * @returns {Error}
347
+ */
348
+ export function createError(type, message, props = {}) {
349
+ const error = new Error(message);
350
+ error.name = type;
351
+ Object.assign(error, props);
352
+ if (Error.captureStackTrace) {
353
+ Error.captureStackTrace(error, createError);
354
+ }
355
+ return error;
356
+ }
357
+
358
+ /**
359
+ * Create a CDPConnectionError
360
+ * @param {string} message - Error message
361
+ * @param {string} operation - The CDP operation that failed
362
+ * @returns {Error}
363
+ */
364
+ export function connectionError(message, operation) {
365
+ return createError(
366
+ ErrorTypes.CONNECTION,
367
+ `CDP connection error during ${operation}: ${message}`,
368
+ { operation, originalMessage: message }
369
+ );
370
+ }
371
+
372
+ /**
373
+ * Create a NavigationError
374
+ * @param {string} message - Error message
375
+ * @param {string} url - URL that failed to load
376
+ * @returns {Error}
377
+ */
378
+ export function navigationError(message, url) {
379
+ return createError(
380
+ ErrorTypes.NAVIGATION,
381
+ `Navigation to ${url} failed: ${message}`,
382
+ { url, originalMessage: message }
383
+ );
384
+ }
385
+
386
+ /**
387
+ * Create a NavigationAbortedError
388
+ * @param {string} message - Abort reason
389
+ * @param {string} url - URL being navigated to
390
+ * @returns {Error}
391
+ */
392
+ export function navigationAbortedError(message, url) {
393
+ return createError(
394
+ ErrorTypes.NAVIGATION_ABORTED,
395
+ `Navigation to ${url} aborted: ${message}`,
396
+ { url, originalMessage: message }
397
+ );
398
+ }
399
+
400
+ /**
401
+ * Create a TimeoutError
402
+ * @param {string} message - Description of what timed out
403
+ * @param {number} [timeout] - Timeout duration in ms
404
+ * @returns {Error}
405
+ */
406
+ export function timeoutError(message, timeout) {
407
+ return createError(
408
+ ErrorTypes.TIMEOUT,
409
+ message,
410
+ timeout !== undefined ? { timeout } : {}
411
+ );
412
+ }
413
+
414
+ /**
415
+ * Create an ElementNotFoundError
416
+ * @param {string} selector - The selector that wasn't found
417
+ * @param {number} timeout - Timeout duration in ms
418
+ * @returns {Error}
419
+ */
420
+ export function elementNotFoundError(selector, timeout) {
421
+ return createError(
422
+ ErrorTypes.ELEMENT_NOT_FOUND,
423
+ `Element not found: "${selector}" (timeout: ${timeout}ms)`,
424
+ { selector, timeout }
425
+ );
426
+ }
427
+
428
+ /**
429
+ * Create an ElementNotEditableError
430
+ * @param {string} selector - The selector of the non-editable element
431
+ * @param {string} reason - Reason why element is not editable
432
+ * @returns {Error}
433
+ */
434
+ export function elementNotEditableError(selector, reason) {
435
+ return createError(
436
+ ErrorTypes.ELEMENT_NOT_EDITABLE,
437
+ `Element "${selector}" is not editable: ${reason}`,
438
+ { selector, reason }
439
+ );
440
+ }
441
+
442
+ /**
443
+ * Create a StaleElementError
444
+ * @param {string} objectId - CDP object ID of the stale element
445
+ * @param {Object} [options] - Additional options
446
+ * @param {string} [options.operation] - The operation that was attempted
447
+ * @param {string} [options.selector] - Original selector used
448
+ * @param {Error} [options.cause] - Underlying CDP error
449
+ * @returns {Error}
450
+ */
451
+ export function staleElementError(objectId, options = {}) {
452
+ if (typeof options === 'string') {
453
+ options = { operation: options };
454
+ }
455
+ const { operation = null, selector = null, cause = null } = options;
456
+
457
+ let message = 'Element is no longer attached to the DOM';
458
+ const details = [];
459
+ if (selector) details.push(`selector: "${selector}"`);
460
+ if (objectId) details.push(`objectId: ${objectId}`);
461
+ if (operation) details.push(`operation: ${operation}`);
462
+ if (details.length > 0) message += ` (${details.join(', ')})`;
463
+
464
+ const error = createError(ErrorTypes.STALE_ELEMENT, message, {
465
+ objectId,
466
+ operation,
467
+ selector
468
+ });
469
+ if (cause) error.cause = cause;
470
+ return error;
471
+ }
472
+
473
+ /**
474
+ * Create a PageCrashedError
475
+ * @param {string} [message] - Optional message
476
+ * @returns {Error}
477
+ */
478
+ export function pageCrashedError(message = 'Page crashed') {
479
+ return createError(ErrorTypes.PAGE_CRASHED, message);
480
+ }
481
+
482
+ /**
483
+ * Create a ContextDestroyedError
484
+ * @param {string} [message] - Optional message
485
+ * @returns {Error}
486
+ */
487
+ export function contextDestroyedError(message = 'Execution context was destroyed') {
488
+ return createError(ErrorTypes.CONTEXT_DESTROYED, message);
489
+ }
490
+
491
+ /**
492
+ * Create a StepValidationError
493
+ * @param {Array<{index: number, step: Object, errors: string[]}>} invalidSteps
494
+ * @returns {Error}
495
+ */
496
+ export function stepValidationError(invalidSteps) {
497
+ const messages = invalidSteps.map(({ index, errors }) =>
498
+ `Step ${index + 1}: ${errors.join(', ')}`
499
+ );
500
+ return createError(
501
+ ErrorTypes.STEP_VALIDATION,
502
+ `Invalid step definitions:\n${messages.join('\n')}`,
503
+ { invalidSteps }
504
+ );
505
+ }
506
+
507
+ /**
508
+ * Check if an error is of a specific type
509
+ * @param {Error} error - The error to check
510
+ * @param {string} type - Error type from ErrorTypes
511
+ * @returns {boolean}
512
+ */
513
+ export function isErrorType(error, type) {
514
+ return error && error.name === type;
515
+ }
516
+
517
+ // Error message patterns for context destruction detection
518
+ const CONTEXT_DESTROYED_PATTERNS = [
519
+ 'Cannot find context with specified id',
520
+ 'Execution context was destroyed',
521
+ 'Inspected target navigated or closed',
522
+ 'Context was destroyed'
523
+ ];
524
+
525
+ /**
526
+ * Check if an error indicates context destruction
527
+ * @param {Object} [exceptionDetails] - CDP exception details
528
+ * @param {Error} [error] - Error thrown
529
+ * @returns {boolean}
530
+ */
531
+ export function isContextDestroyed(exceptionDetails, error) {
532
+ const message = exceptionDetails?.exception?.description ||
533
+ exceptionDetails?.text ||
534
+ error?.message ||
535
+ '';
536
+ return CONTEXT_DESTROYED_PATTERNS.some(pattern => message.includes(pattern));
537
+ }
538
+
539
+ // Stale element error indicators
540
+ const STALE_ELEMENT_PATTERNS = [
541
+ 'Could not find object with given id',
542
+ 'Object reference not found',
543
+ 'Cannot find context with specified id',
544
+ 'Node with given id does not belong to the document',
545
+ 'No node with given id found',
546
+ 'Object is not available',
547
+ 'No object with given id',
548
+ 'Object with given id not found'
549
+ ];
550
+
551
+ /**
552
+ * Check if an error indicates a stale element
553
+ * @param {Error} error - The error to check
554
+ * @returns {boolean}
555
+ */
556
+ export function isStaleElementError(error) {
557
+ if (!error || !error.message) return false;
558
+ return STALE_ELEMENT_PATTERNS.some(indicator =>
559
+ error.message.toLowerCase().includes(indicator.toLowerCase())
560
+ );
561
+ }
562
+
563
+ // ============================================================================
564
+ // Key Validation (from KeyValidator.js)
565
+ // ============================================================================
566
+
567
+ const VALID_KEY_NAMES = new Set([
568
+ // Standard keys
569
+ 'Enter', 'Tab', 'Escape', 'Backspace', 'Delete', 'Space',
570
+ // Arrow keys
571
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
572
+ // Modifier keys
573
+ 'Shift', 'Control', 'Alt', 'Meta',
574
+ // Function keys
575
+ 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
576
+ // Navigation keys
577
+ 'Home', 'End', 'PageUp', 'PageDown', 'Insert',
578
+ // Additional common keys
579
+ 'CapsLock', 'NumLock', 'ScrollLock', 'Pause', 'PrintScreen',
580
+ 'ContextMenu',
581
+ // Numpad keys
582
+ 'Numpad0', 'Numpad1', 'Numpad2', 'Numpad3', 'Numpad4',
583
+ 'Numpad5', 'Numpad6', 'Numpad7', 'Numpad8', 'Numpad9',
584
+ 'NumpadAdd', 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide',
585
+ 'NumpadDecimal', 'NumpadEnter'
586
+ ]);
587
+
588
+ const MODIFIER_ALIASES = new Set([
589
+ 'control', 'ctrl', 'alt', 'meta', 'cmd', 'command', 'shift'
590
+ ]);
591
+
592
+ /**
593
+ * Create a key validator for validating key names against known CDP key codes
594
+ * @returns {Object} Key validator with validation methods
595
+ */
596
+ export function createKeyValidator() {
597
+ function isKnownKey(keyName) {
598
+ if (!keyName || typeof keyName !== 'string') {
599
+ return false;
600
+ }
601
+ if (VALID_KEY_NAMES.has(keyName)) {
602
+ return true;
603
+ }
604
+ // Check for single character keys (a-z, A-Z, 0-9, punctuation)
605
+ if (keyName.length === 1) {
606
+ return true;
607
+ }
608
+ return false;
609
+ }
610
+
611
+ function isModifierAlias(part) {
612
+ return MODIFIER_ALIASES.has(part.toLowerCase());
613
+ }
614
+
615
+ function getKnownKeysSample() {
616
+ return ['Enter', 'Tab', 'Escape', 'Backspace', 'ArrowUp', 'ArrowDown', 'F1-F12'].join(', ');
617
+ }
618
+
619
+ function validateCombo(combo) {
620
+ const parts = combo.split('+');
621
+ const warnings = [];
622
+ let mainKey = null;
623
+
624
+ for (const part of parts) {
625
+ const trimmed = part.trim();
626
+ if (!trimmed) {
627
+ return {
628
+ valid: false,
629
+ warning: `Invalid key combo "${combo}": empty key part`
630
+ };
631
+ }
632
+
633
+ // Check if it's a modifier
634
+ if (isModifierAlias(trimmed) || VALID_KEY_NAMES.has(trimmed) &&
635
+ ['Shift', 'Control', 'Alt', 'Meta'].includes(trimmed)) {
636
+ continue;
637
+ }
638
+
639
+ // This should be the main key
640
+ if (mainKey !== null) {
641
+ return {
642
+ valid: false,
643
+ warning: `Invalid key combo "${combo}": multiple main keys specified`
644
+ };
645
+ }
646
+ mainKey = trimmed;
647
+
648
+ if (!isKnownKey(trimmed)) {
649
+ warnings.push(`Unknown key "${trimmed}" in combo`);
650
+ }
651
+ }
652
+
653
+ if (mainKey === null) {
654
+ return {
655
+ valid: false,
656
+ warning: `Invalid key combo "${combo}": no main key specified`
657
+ };
658
+ }
659
+
660
+ return {
661
+ valid: true,
662
+ warning: warnings.length > 0 ? warnings.join('; ') : null
663
+ };
664
+ }
665
+
666
+ function validate(keyName) {
667
+ if (!keyName || typeof keyName !== 'string') {
668
+ return {
669
+ valid: false,
670
+ warning: 'Key name must be a non-empty string'
671
+ };
672
+ }
673
+
674
+ // Handle key combos (e.g., "Control+a")
675
+ if (keyName.includes('+')) {
676
+ return validateCombo(keyName);
677
+ }
678
+
679
+ if (isKnownKey(keyName)) {
680
+ return { valid: true, warning: null };
681
+ }
682
+
683
+ return {
684
+ valid: true, // Still allow unknown keys to pass through
685
+ warning: `Unknown key name "${keyName}". Known keys: ${getKnownKeysSample()}`
686
+ };
687
+ }
688
+
689
+ function getValidKeyNames() {
690
+ return new Set(VALID_KEY_NAMES);
691
+ }
692
+
693
+ return {
694
+ isKnownKey,
695
+ isModifierAlias,
696
+ validate,
697
+ validateCombo,
698
+ getKnownKeysSample,
699
+ getValidKeyNames
700
+ };
701
+ }
702
+
703
+ // ============================================================================
704
+ // Form Validation (from FormValidator.js)
705
+ // ============================================================================
706
+
707
+ /**
708
+ * Create a form validator for handling form validation queries and submit operations
709
+ * @param {Object} session - CDP session
710
+ * @param {Object} elementLocator - Element locator instance
711
+ * @returns {Object} Form validator with validation methods
712
+ */
713
+ export function createFormValidator(session, elementLocator) {
714
+ /**
715
+ * Query validation state of an element using HTML5 constraint validation API
716
+ * @param {string} selector - CSS selector for the input/form element
717
+ * @returns {Promise<{valid: boolean, message: string, validity: Object}>}
718
+ */
719
+ async function validateElement(selector) {
720
+ const element = await elementLocator.findElement(selector);
721
+ if (!element) {
722
+ throw new Error(`Element not found: ${selector}`);
723
+ }
724
+
725
+ try {
726
+ const result = await session.send('Runtime.callFunctionOn', {
727
+ objectId: element._handle.objectId,
728
+ functionDeclaration: `function() {
729
+ if (!this.checkValidity) {
730
+ return { valid: true, message: '', validity: null, supported: false };
731
+ }
732
+
733
+ const valid = this.checkValidity();
734
+ const message = this.validationMessage || '';
735
+
736
+ // Get detailed validity state
737
+ const validity = this.validity ? {
738
+ valueMissing: this.validity.valueMissing,
739
+ typeMismatch: this.validity.typeMismatch,
740
+ patternMismatch: this.validity.patternMismatch,
741
+ tooLong: this.validity.tooLong,
742
+ tooShort: this.validity.tooShort,
743
+ rangeUnderflow: this.validity.rangeUnderflow,
744
+ rangeOverflow: this.validity.rangeOverflow,
745
+ stepMismatch: this.validity.stepMismatch,
746
+ badInput: this.validity.badInput,
747
+ customError: this.validity.customError
748
+ } : null;
749
+
750
+ return { valid, message, validity, supported: true };
751
+ }`,
752
+ returnByValue: true
753
+ });
754
+
755
+ return result.result.value;
756
+ } finally {
757
+ await element._handle.dispose();
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Submit a form and report validation errors
763
+ * @param {string} selector - CSS selector for the form element
764
+ * @param {Object} options - Submit options
765
+ * @param {boolean} options.validate - Check validation before submitting (default: true)
766
+ * @param {boolean} options.reportValidity - Show browser validation UI (default: false)
767
+ * @returns {Promise<{submitted: boolean, valid: boolean, errors: Array}>}
768
+ */
769
+ async function submitForm(selector, options = {}) {
770
+ const { validate = true, reportValidity = false } = options;
771
+
772
+ const element = await elementLocator.findElement(selector);
773
+ if (!element) {
774
+ throw new Error(`Form not found: ${selector}`);
775
+ }
776
+
777
+ try {
778
+ const result = await session.send('Runtime.callFunctionOn', {
779
+ objectId: element._handle.objectId,
780
+ functionDeclaration: `function(validate, reportValidity) {
781
+ // Check if this is a form element
782
+ if (this.tagName !== 'FORM') {
783
+ return { submitted: false, error: 'Element is not a form', valid: null, errors: [] };
784
+ }
785
+
786
+ const errors = [];
787
+ let formValid = true;
788
+
789
+ if (validate) {
790
+ // Get all form elements and check validity
791
+ const elements = this.elements;
792
+ for (let i = 0; i < elements.length; i++) {
793
+ const el = elements[i];
794
+ if (el.checkValidity && !el.checkValidity()) {
795
+ formValid = false;
796
+ errors.push({
797
+ name: el.name || el.id || 'unknown',
798
+ type: el.type || el.tagName.toLowerCase(),
799
+ message: el.validationMessage,
800
+ value: el.value
801
+ });
802
+ }
803
+ }
804
+
805
+ if (!formValid) {
806
+ if (reportValidity) {
807
+ this.reportValidity();
808
+ }
809
+ return { submitted: false, valid: false, errors };
810
+ }
811
+ }
812
+
813
+ // Submit the form
814
+ this.submit();
815
+ return { submitted: true, valid: true, errors: [] };
816
+ }`,
817
+ arguments: [
818
+ { value: validate },
819
+ { value: reportValidity }
820
+ ],
821
+ returnByValue: true
822
+ });
823
+
824
+ return result.result.value;
825
+ } finally {
826
+ await element._handle.dispose();
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Get all validation errors for a form
832
+ * @param {string} selector - CSS selector for the form element
833
+ * @returns {Promise<Array<{name: string, type: string, message: string}>>}
834
+ */
835
+ async function getFormErrors(selector) {
836
+ const element = await elementLocator.findElement(selector);
837
+ if (!element) {
838
+ throw new Error(`Form not found: ${selector}`);
839
+ }
840
+
841
+ try {
842
+ const result = await session.send('Runtime.callFunctionOn', {
843
+ objectId: element._handle.objectId,
844
+ functionDeclaration: `function() {
845
+ if (this.tagName !== 'FORM') {
846
+ return { error: 'Element is not a form', errors: [] };
847
+ }
848
+
849
+ const errors = [];
850
+ const elements = this.elements;
851
+
852
+ for (let i = 0; i < elements.length; i++) {
853
+ const el = elements[i];
854
+ if (el.checkValidity && !el.checkValidity()) {
855
+ errors.push({
856
+ name: el.name || el.id || 'unknown',
857
+ type: el.type || el.tagName.toLowerCase(),
858
+ message: el.validationMessage,
859
+ value: el.value
860
+ });
861
+ }
862
+ }
863
+
864
+ return { errors };
865
+ }`,
866
+ returnByValue: true
867
+ });
868
+
869
+ return result.result.value.errors;
870
+ } finally {
871
+ await element._handle.dispose();
872
+ }
873
+ }
874
+
875
+ return {
876
+ validateElement,
877
+ submitForm,
878
+ getFormErrors
879
+ };
880
+ }
881
+
882
+ // ============================================================================
883
+ // Device Presets (from DevicePresets.js)
884
+ // ============================================================================
885
+
886
+ /**
887
+ * Device preset configurations for viewport emulation
888
+ */
889
+ export const DEVICE_PRESETS = new Map([
890
+ // iPhones
891
+ ['iphone-se', { width: 375, height: 667, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
892
+ ['iphone-12', { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
893
+ ['iphone-12-mini', { width: 360, height: 780, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
894
+ ['iphone-12-pro', { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
895
+ ['iphone-12-pro-max', { width: 428, height: 926, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
896
+ ['iphone-13', { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
897
+ ['iphone-13-mini', { width: 375, height: 812, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
898
+ ['iphone-13-pro', { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
899
+ ['iphone-13-pro-max', { width: 428, height: 926, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
900
+ ['iphone-14', { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
901
+ ['iphone-14-plus', { width: 428, height: 926, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
902
+ ['iphone-14-pro', { width: 393, height: 852, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
903
+ ['iphone-14-pro-max', { width: 430, height: 932, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
904
+ ['iphone-15', { width: 393, height: 852, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
905
+ ['iphone-15-plus', { width: 430, height: 932, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
906
+ ['iphone-15-pro', { width: 393, height: 852, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
907
+ ['iphone-15-pro-max', { width: 430, height: 932, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
908
+
909
+ // iPads
910
+ ['ipad', { width: 768, height: 1024, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
911
+ ['ipad-mini', { width: 768, height: 1024, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
912
+ ['ipad-air', { width: 820, height: 1180, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
913
+ ['ipad-pro-11', { width: 834, height: 1194, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
914
+ ['ipad-pro-12.9', { width: 1024, height: 1366, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
915
+
916
+ // Android phones
917
+ ['pixel-5', { width: 393, height: 851, deviceScaleFactor: 2.75, mobile: true, hasTouch: true }],
918
+ ['pixel-6', { width: 412, height: 915, deviceScaleFactor: 2.625, mobile: true, hasTouch: true }],
919
+ ['pixel-7', { width: 412, height: 915, deviceScaleFactor: 2.625, mobile: true, hasTouch: true }],
920
+ ['pixel-7-pro', { width: 412, height: 892, deviceScaleFactor: 3.5, mobile: true, hasTouch: true }],
921
+ ['samsung-galaxy-s21', { width: 360, height: 800, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
922
+ ['samsung-galaxy-s22', { width: 360, height: 780, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
923
+ ['samsung-galaxy-s23', { width: 360, height: 780, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
924
+
925
+ // Android tablets
926
+ ['galaxy-tab-s7', { width: 800, height: 1280, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
927
+
928
+ // Desktop presets
929
+ ['desktop', { width: 1920, height: 1080, deviceScaleFactor: 1, mobile: false, hasTouch: false }],
930
+ ['desktop-hd', { width: 1920, height: 1080, deviceScaleFactor: 1, mobile: false, hasTouch: false }],
931
+ ['desktop-4k', { width: 3840, height: 2160, deviceScaleFactor: 1, mobile: false, hasTouch: false }],
932
+ ['laptop', { width: 1366, height: 768, deviceScaleFactor: 1, mobile: false, hasTouch: false }],
933
+ ['laptop-hd', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false, hasTouch: false }],
934
+ ['macbook-air', { width: 1440, height: 900, deviceScaleFactor: 2, mobile: false, hasTouch: false }],
935
+ ['macbook-pro-13', { width: 1440, height: 900, deviceScaleFactor: 2, mobile: false, hasTouch: false }],
936
+ ['macbook-pro-14', { width: 1512, height: 982, deviceScaleFactor: 2, mobile: false, hasTouch: false }],
937
+ ['macbook-pro-16', { width: 1728, height: 1117, deviceScaleFactor: 2, mobile: false, hasTouch: false }],
938
+
939
+ // Landscape variants (appended with -landscape)
940
+ ['iphone-14-landscape', { width: 844, height: 390, deviceScaleFactor: 3, mobile: true, hasTouch: true, isLandscape: true }],
941
+ ['iphone-14-pro-landscape', { width: 852, height: 393, deviceScaleFactor: 3, mobile: true, hasTouch: true, isLandscape: true }],
942
+ ['ipad-landscape', { width: 1024, height: 768, deviceScaleFactor: 2, mobile: true, hasTouch: true, isLandscape: true }],
943
+ ['ipad-pro-11-landscape', { width: 1194, height: 834, deviceScaleFactor: 2, mobile: true, hasTouch: true, isLandscape: true }],
944
+ ]);
945
+
946
+ /**
947
+ * Get a device preset by name
948
+ * @param {string} name - Device preset name (case-insensitive)
949
+ * @returns {Object|null} Device configuration or null if not found
950
+ */
951
+ export function getDevicePreset(name) {
952
+ const normalizedName = name.toLowerCase().replace(/_/g, '-');
953
+ return DEVICE_PRESETS.get(normalizedName) || null;
954
+ }
955
+
956
+ /**
957
+ * Check if a preset exists
958
+ * @param {string} name - Device preset name
959
+ * @returns {boolean}
960
+ */
961
+ export function hasDevicePreset(name) {
962
+ const normalizedName = name.toLowerCase().replace(/_/g, '-');
963
+ return DEVICE_PRESETS.has(normalizedName);
964
+ }
965
+
966
+ /**
967
+ * Get all available preset names
968
+ * @returns {string[]}
969
+ */
970
+ export function listDevicePresets() {
971
+ return Array.from(DEVICE_PRESETS.keys());
972
+ }
973
+
974
+ /**
975
+ * Get presets by category
976
+ * @param {string} category - 'iphone', 'ipad', 'android', 'desktop', 'landscape'
977
+ * @returns {string[]}
978
+ */
979
+ export function listDevicePresetsByCategory(category) {
980
+ const categoryLower = category.toLowerCase();
981
+ return listDevicePresets().filter(name => {
982
+ if (categoryLower === 'iphone') return name.startsWith('iphone');
983
+ if (categoryLower === 'ipad') return name.startsWith('ipad');
984
+ if (categoryLower === 'android') return name.startsWith('pixel') || name.startsWith('samsung') || name.startsWith('galaxy');
985
+ if (categoryLower === 'desktop') return name.startsWith('desktop') || name.startsWith('laptop') || name.startsWith('macbook');
986
+ if (categoryLower === 'landscape') return name.endsWith('-landscape');
987
+ return false;
988
+ });
989
+ }
990
+
991
+ /**
992
+ * Resolve viewport options - handles both preset strings and explicit configs
993
+ * @param {string|Object} viewport - Either a preset name string or viewport config object
994
+ * @returns {Object} Resolved viewport configuration
995
+ * @throws {Error} If preset not found
996
+ */
997
+ export function resolveViewport(viewport) {
998
+ if (typeof viewport === 'string') {
999
+ const preset = getDevicePreset(viewport);
1000
+ if (!preset) {
1001
+ const available = listDevicePresets().slice(0, 10).join(', ');
1002
+ throw new Error(`Unknown device preset "${viewport}". Available presets include: ${available}...`);
1003
+ }
1004
+ return { ...preset };
1005
+ }
1006
+
1007
+ // It's an object - validate required fields
1008
+ if (!viewport.width || !viewport.height) {
1009
+ throw new Error('Viewport requires width and height');
1010
+ }
1011
+
1012
+ return {
1013
+ width: viewport.width,
1014
+ height: viewport.height,
1015
+ deviceScaleFactor: viewport.deviceScaleFactor || 1,
1016
+ mobile: viewport.mobile || false,
1017
+ hasTouch: viewport.hasTouch || false,
1018
+ isLandscape: viewport.isLandscape || false
1019
+ };
1020
+ }
1021
+
1022
+ /**
1023
+ * Create a device presets manager (for backwards compatibility)
1024
+ * @returns {Object} Device presets manager
1025
+ */
1026
+ export function createDevicePresets() {
1027
+ return {
1028
+ get: getDevicePreset,
1029
+ has: hasDevicePreset,
1030
+ list: listDevicePresets,
1031
+ listByCategory: listDevicePresetsByCategory,
1032
+ resolve: resolveViewport
1033
+ };
1034
+ }