cdp-skill 1.0.7 → 1.0.14

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 (47) hide show
  1. package/README.md +80 -35
  2. package/SKILL.md +198 -1344
  3. package/install.js +1 -0
  4. package/package.json +1 -1
  5. package/src/aria/index.js +8 -0
  6. package/src/aria/output-processor.js +173 -0
  7. package/src/aria/role-query.js +1229 -0
  8. package/src/aria/snapshot.js +459 -0
  9. package/src/aria.js +237 -43
  10. package/src/cdp/browser.js +22 -4
  11. package/src/cdp-skill.js +268 -68
  12. package/src/dom/click-executor.js +240 -76
  13. package/src/dom/element-locator.js +34 -25
  14. package/src/dom/fill-executor.js +55 -27
  15. package/src/page/dialog-handler.js +119 -0
  16. package/src/page/page-controller.js +190 -3
  17. package/src/runner/context-helpers.js +33 -55
  18. package/src/runner/execute-dynamic.js +34 -143
  19. package/src/runner/execute-form.js +11 -11
  20. package/src/runner/execute-input.js +2 -2
  21. package/src/runner/execute-interaction.js +99 -120
  22. package/src/runner/execute-navigation.js +11 -26
  23. package/src/runner/execute-query.js +8 -5
  24. package/src/runner/step-executors.js +256 -95
  25. package/src/runner/step-registry.js +1064 -0
  26. package/src/runner/step-validator.js +16 -740
  27. package/src/tests/Aria.test.js +1025 -0
  28. package/src/tests/ContextHelpers.test.js +39 -28
  29. package/src/tests/ExecuteBrowser.test.js +572 -0
  30. package/src/tests/ExecuteDynamic.test.js +34 -736
  31. package/src/tests/ExecuteForm.test.js +700 -0
  32. package/src/tests/ExecuteInput.test.js +540 -0
  33. package/src/tests/ExecuteInteraction.test.js +319 -0
  34. package/src/tests/ExecuteQuery.test.js +820 -0
  35. package/src/tests/FillExecutor.test.js +2 -2
  36. package/src/tests/StepValidator.test.js +222 -76
  37. package/src/tests/TestRunner.test.js +36 -25
  38. package/src/tests/integration.test.js +2 -1
  39. package/src/types.js +9 -9
  40. package/src/utils/backoff.js +118 -0
  41. package/src/utils/cdp-helpers.js +130 -0
  42. package/src/utils/devices.js +140 -0
  43. package/src/utils/errors.js +242 -0
  44. package/src/utils/index.js +65 -0
  45. package/src/utils/temp.js +75 -0
  46. package/src/utils/validators.js +433 -0
  47. package/src/utils.js +14 -1142
package/src/utils.js CHANGED
@@ -1,1144 +1,16 @@
1
1
  /**
2
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
- // Validate coordinates are numbers to prevent injection
278
- const safeX = Number(x);
279
- const safeY = Number(y);
280
- if (!Number.isFinite(safeX) || !Number.isFinite(safeY)) {
281
- return null;
282
- }
283
- try {
284
- const result = await session.send('Runtime.evaluate', {
285
- expression: `
286
- (function() {
287
- const el = document.elementFromPoint(${safeX}, ${safeY});
288
- if (!el) return null;
289
- return {
290
- tagName: el.tagName.toLowerCase(),
291
- id: el.id || null,
292
- className: el.className || null,
293
- textContent: el.textContent ? el.textContent.trim().substring(0, 50) : null
294
- };
295
- })()
296
- `,
297
- returnByValue: true
298
- });
299
- return result.result.value;
300
- } catch {
301
- return null;
302
- }
303
- }
304
-
305
- /**
306
- * Detect navigation by comparing URLs before and after an action
307
- * @param {Object} session - CDP session
308
- * @param {string} urlBeforeAction - URL before the action
309
- * @param {number} timeout - Timeout to wait for navigation
310
- * @returns {Promise<{navigated: boolean, newUrl?: string}>}
311
- */
312
- export async function detectNavigation(session, urlBeforeAction, timeout = 100) {
313
- await sleep(timeout);
314
- try {
315
- const urlAfterAction = await getCurrentUrl(session);
316
- const navigated = urlAfterAction !== urlBeforeAction;
317
- return {
318
- navigated,
319
- newUrl: navigated ? urlAfterAction : undefined
320
- };
321
- } catch {
322
- // If we can't get URL, page likely navigated
323
- return { navigated: true };
324
- }
325
- }
326
-
327
- // ============================================================================
328
- // Error Types and Factory Functions (from errors.js)
329
- // ============================================================================
330
-
331
- /**
332
- * Error types for CDP browser driver operations
333
- */
334
- export const ErrorTypes = Object.freeze({
335
- CONNECTION: 'CDPConnectionError',
336
- NAVIGATION: 'NavigationError',
337
- NAVIGATION_ABORTED: 'NavigationAbortedError',
338
- TIMEOUT: 'TimeoutError',
339
- ELEMENT_NOT_FOUND: 'ElementNotFoundError',
340
- ELEMENT_NOT_EDITABLE: 'ElementNotEditableError',
341
- STALE_ELEMENT: 'StaleElementError',
342
- PAGE_CRASHED: 'PageCrashedError',
343
- CONTEXT_DESTROYED: 'ContextDestroyedError',
344
- STEP_VALIDATION: 'StepValidationError'
345
- });
346
-
347
- /**
348
- * Create a typed error with standard properties
349
- * @param {string} type - Error type from ErrorTypes
350
- * @param {string} message - Error message
351
- * @param {Object} props - Additional properties to attach
352
- * @returns {Error}
353
- */
354
- export function createError(type, message, props = {}) {
355
- const error = new Error(message);
356
- error.name = type;
357
- Object.assign(error, props);
358
- if (Error.captureStackTrace) {
359
- Error.captureStackTrace(error, createError);
360
- }
361
- return error;
362
- }
363
-
364
- /**
365
- * Create a CDPConnectionError
366
- * @param {string} message - Error message
367
- * @param {string} operation - The CDP operation that failed
368
- * @returns {Error}
369
- */
370
- export function connectionError(message, operation) {
371
- return createError(
372
- ErrorTypes.CONNECTION,
373
- `CDP connection error during ${operation}: ${message}`,
374
- { operation, originalMessage: message }
375
- );
376
- }
377
-
378
- /**
379
- * Create a NavigationError
380
- * @param {string} message - Error message
381
- * @param {string} url - URL that failed to load
382
- * @returns {Error}
383
- */
384
- export function navigationError(message, url) {
385
- return createError(
386
- ErrorTypes.NAVIGATION,
387
- `Navigation to ${url} failed: ${message}`,
388
- { url, originalMessage: message }
389
- );
390
- }
391
-
392
- /**
393
- * Create a NavigationAbortedError
394
- * @param {string} message - Abort reason
395
- * @param {string} url - URL being navigated to
396
- * @returns {Error}
397
- */
398
- export function navigationAbortedError(message, url) {
399
- return createError(
400
- ErrorTypes.NAVIGATION_ABORTED,
401
- `Navigation to ${url} aborted: ${message}`,
402
- { url, originalMessage: message }
403
- );
404
- }
405
-
406
- /**
407
- * Create a TimeoutError
408
- * @param {string} message - Description of what timed out
409
- * @param {number} [timeout] - Timeout duration in ms
410
- * @returns {Error}
411
- */
412
- export function timeoutError(message, timeout) {
413
- return createError(
414
- ErrorTypes.TIMEOUT,
415
- message,
416
- timeout !== undefined ? { timeout } : {}
417
- );
418
- }
419
-
420
- /**
421
- * Create an ElementNotFoundError
422
- * @param {string} selector - The selector that wasn't found
423
- * @param {number} timeout - Timeout duration in ms
424
- * @returns {Error}
425
- */
426
- export function elementNotFoundError(selector, timeout) {
427
- return createError(
428
- ErrorTypes.ELEMENT_NOT_FOUND,
429
- `Element not found: "${selector}" (timeout: ${timeout}ms)`,
430
- { selector, timeout }
431
- );
432
- }
433
-
434
- /**
435
- * Create an ElementNotEditableError
436
- * @param {string} selector - The selector of the non-editable element
437
- * @param {string} reason - Reason why element is not editable
438
- * @returns {Error}
439
- */
440
- export function elementNotEditableError(selector, reason) {
441
- return createError(
442
- ErrorTypes.ELEMENT_NOT_EDITABLE,
443
- `Element "${selector}" is not editable: ${reason}`,
444
- { selector, reason }
445
- );
446
- }
447
-
448
- /**
449
- * Create a StaleElementError
450
- * @param {string} objectId - CDP object ID of the stale element
451
- * @param {Object} [options] - Additional options
452
- * @param {string} [options.operation] - The operation that was attempted
453
- * @param {string} [options.selector] - Original selector used
454
- * @param {Error} [options.cause] - Underlying CDP error
455
- * @returns {Error}
456
- */
457
- export function staleElementError(objectId, options = {}) {
458
- if (typeof options === 'string') {
459
- options = { operation: options };
460
- }
461
- const { operation = null, selector = null, cause = null } = options;
462
-
463
- let message = 'Element is no longer attached to the DOM';
464
- const details = [];
465
- if (selector) details.push(`selector: "${selector}"`);
466
- if (objectId) details.push(`objectId: ${objectId}`);
467
- if (operation) details.push(`operation: ${operation}`);
468
- if (details.length > 0) message += ` (${details.join(', ')})`;
469
-
470
- const error = createError(ErrorTypes.STALE_ELEMENT, message, {
471
- objectId,
472
- operation,
473
- selector
474
- });
475
- if (cause) error.cause = cause;
476
- return error;
477
- }
478
-
479
- /**
480
- * Create a PageCrashedError
481
- * @param {string} [message] - Optional message
482
- * @returns {Error}
483
- */
484
- export function pageCrashedError(message = 'Page crashed') {
485
- return createError(ErrorTypes.PAGE_CRASHED, message);
486
- }
487
-
488
- /**
489
- * Create a ContextDestroyedError
490
- * @param {string} [message] - Optional message
491
- * @returns {Error}
492
- */
493
- export function contextDestroyedError(message = 'Execution context was destroyed') {
494
- return createError(ErrorTypes.CONTEXT_DESTROYED, message);
495
- }
496
-
497
- /**
498
- * Create a StepValidationError
499
- * @param {Array<{index: number, step: Object, errors: string[]}>} invalidSteps
500
- * @returns {Error}
501
- */
502
- export function stepValidationError(invalidSteps) {
503
- const messages = invalidSteps.map(({ index, errors }) =>
504
- `Step ${index + 1}: ${errors.join(', ')}`
505
- );
506
- return createError(
507
- ErrorTypes.STEP_VALIDATION,
508
- `Invalid step definitions:\n${messages.join('\n')}`,
509
- { invalidSteps }
510
- );
511
- }
512
-
513
- /**
514
- * Check if an error is of a specific type
515
- * @param {Error} error - The error to check
516
- * @param {string} type - Error type from ErrorTypes
517
- * @returns {boolean}
518
- */
519
- export function isErrorType(error, type) {
520
- return error && error.name === type;
521
- }
522
-
523
- // Error message patterns for context destruction detection
524
- const CONTEXT_DESTROYED_PATTERNS = [
525
- 'Cannot find context with specified id',
526
- 'Execution context was destroyed',
527
- 'Inspected target navigated or closed',
528
- 'Context was destroyed'
529
- ];
530
-
531
- /**
532
- * Check if an error indicates context destruction
533
- * @param {Object} [exceptionDetails] - CDP exception details
534
- * @param {Error} [error] - Error thrown
535
- * @returns {boolean}
536
- */
537
- export function isContextDestroyed(exceptionDetails, error) {
538
- const message = exceptionDetails?.exception?.description ||
539
- exceptionDetails?.text ||
540
- error?.message ||
541
- '';
542
- return CONTEXT_DESTROYED_PATTERNS.some(pattern => message.includes(pattern));
543
- }
544
-
545
- // Stale element error indicators
546
- const STALE_ELEMENT_PATTERNS = [
547
- 'Could not find object with given id',
548
- 'Object reference not found',
549
- 'Cannot find context with specified id',
550
- 'Node with given id does not belong to the document',
551
- 'No node with given id found',
552
- 'Object is not available',
553
- 'No object with given id',
554
- 'Object with given id not found'
555
- ];
556
-
557
- /**
558
- * Check if an error indicates a stale element
559
- * @param {Error} error - The error to check
560
- * @returns {boolean}
561
- */
562
- export function isStaleElementError(error) {
563
- if (!error || !error.message) return false;
564
- return STALE_ELEMENT_PATTERNS.some(indicator =>
565
- error.message.toLowerCase().includes(indicator.toLowerCase())
566
- );
567
- }
568
-
569
- // ============================================================================
570
- // Key Validation (from KeyValidator.js)
571
- // ============================================================================
572
-
573
- const VALID_KEY_NAMES = new Set([
574
- // Standard keys
575
- 'Enter', 'Tab', 'Escape', 'Backspace', 'Delete', 'Space',
576
- // Arrow keys
577
- 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
578
- // Modifier keys
579
- 'Shift', 'Control', 'Alt', 'Meta',
580
- // Function keys
581
- 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
582
- // Navigation keys
583
- 'Home', 'End', 'PageUp', 'PageDown', 'Insert',
584
- // Additional common keys
585
- 'CapsLock', 'NumLock', 'ScrollLock', 'Pause', 'PrintScreen',
586
- 'ContextMenu',
587
- // Numpad keys
588
- 'Numpad0', 'Numpad1', 'Numpad2', 'Numpad3', 'Numpad4',
589
- 'Numpad5', 'Numpad6', 'Numpad7', 'Numpad8', 'Numpad9',
590
- 'NumpadAdd', 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide',
591
- 'NumpadDecimal', 'NumpadEnter'
592
- ]);
593
-
594
- const MODIFIER_ALIASES = new Set([
595
- 'control', 'ctrl', 'alt', 'meta', 'cmd', 'command', 'shift'
596
- ]);
597
-
598
- /**
599
- * Create a key validator for validating key names against known CDP key codes
600
- * @returns {Object} Key validator with validation methods
601
- */
602
- export function createKeyValidator() {
603
- function isKnownKey(keyName) {
604
- if (!keyName || typeof keyName !== 'string') {
605
- return false;
606
- }
607
- if (VALID_KEY_NAMES.has(keyName)) {
608
- return true;
609
- }
610
- // Check for single character keys (a-z, A-Z, 0-9, punctuation)
611
- if (keyName.length === 1) {
612
- return true;
613
- }
614
- return false;
615
- }
616
-
617
- function isModifierAlias(part) {
618
- return MODIFIER_ALIASES.has(part.toLowerCase());
619
- }
620
-
621
- function getKnownKeysSample() {
622
- return ['Enter', 'Tab', 'Escape', 'Backspace', 'ArrowUp', 'ArrowDown', 'F1-F12'].join(', ');
623
- }
624
-
625
- function validateCombo(combo) {
626
- const parts = combo.split('+');
627
- const warnings = [];
628
- let mainKey = null;
629
-
630
- for (const part of parts) {
631
- const trimmed = part.trim();
632
- if (!trimmed) {
633
- return {
634
- valid: false,
635
- warning: `Invalid key combo "${combo}": empty key part`
636
- };
637
- }
638
-
639
- // Check if it's a modifier
640
- if (isModifierAlias(trimmed) || VALID_KEY_NAMES.has(trimmed) &&
641
- ['Shift', 'Control', 'Alt', 'Meta'].includes(trimmed)) {
642
- continue;
643
- }
644
-
645
- // This should be the main key
646
- if (mainKey !== null) {
647
- return {
648
- valid: false,
649
- warning: `Invalid key combo "${combo}": multiple main keys specified`
650
- };
651
- }
652
- mainKey = trimmed;
653
-
654
- if (!isKnownKey(trimmed)) {
655
- warnings.push(`Unknown key "${trimmed}" in combo`);
656
- }
657
- }
658
-
659
- if (mainKey === null) {
660
- return {
661
- valid: false,
662
- warning: `Invalid key combo "${combo}": no main key specified`
663
- };
664
- }
665
-
666
- return {
667
- valid: true,
668
- warning: warnings.length > 0 ? warnings.join('; ') : null
669
- };
670
- }
671
-
672
- function validate(keyName) {
673
- if (!keyName || typeof keyName !== 'string') {
674
- return {
675
- valid: false,
676
- warning: 'Key name must be a non-empty string'
677
- };
678
- }
679
-
680
- // Handle key combos (e.g., "Control+a")
681
- if (keyName.includes('+')) {
682
- return validateCombo(keyName);
683
- }
684
-
685
- if (isKnownKey(keyName)) {
686
- return { valid: true, warning: null };
687
- }
688
-
689
- return {
690
- valid: true, // Still allow unknown keys to pass through
691
- warning: `Unknown key name "${keyName}". Known keys: ${getKnownKeysSample()}`
692
- };
693
- }
694
-
695
- function getValidKeyNames() {
696
- return new Set(VALID_KEY_NAMES);
697
- }
698
-
699
- return {
700
- isKnownKey,
701
- isModifierAlias,
702
- validate,
703
- validateCombo,
704
- getKnownKeysSample,
705
- getValidKeyNames
706
- };
707
- }
708
-
709
- // ============================================================================
710
- // Form Validation (from FormValidator.js)
711
- // ============================================================================
712
-
713
- /**
714
- * Create a form validator for handling form validation queries and submit operations
715
- * @param {Object} session - CDP session
716
- * @param {Object} elementLocator - Element locator instance
717
- * @returns {Object} Form validator with validation methods
718
- */
719
- export function createFormValidator(session, elementLocator) {
720
- /**
721
- * Query validation state of an element using HTML5 constraint validation API
722
- * @param {string} selector - CSS selector for the input/form element
723
- * @returns {Promise<{valid: boolean, message: string, validity: Object}>}
724
- */
725
- async function validateElement(selector) {
726
- const element = await elementLocator.findElement(selector);
727
- if (!element) {
728
- throw new Error(`Element not found: ${selector}`);
729
- }
730
-
731
- try {
732
- const result = await session.send('Runtime.callFunctionOn', {
733
- objectId: element._handle.objectId,
734
- functionDeclaration: `function() {
735
- if (!this.checkValidity) {
736
- return { valid: true, message: '', validity: null, supported: false };
737
- }
738
-
739
- const valid = this.checkValidity();
740
- const message = this.validationMessage || '';
741
-
742
- // Get detailed validity state
743
- const validity = this.validity ? {
744
- valueMissing: this.validity.valueMissing,
745
- typeMismatch: this.validity.typeMismatch,
746
- patternMismatch: this.validity.patternMismatch,
747
- tooLong: this.validity.tooLong,
748
- tooShort: this.validity.tooShort,
749
- rangeUnderflow: this.validity.rangeUnderflow,
750
- rangeOverflow: this.validity.rangeOverflow,
751
- stepMismatch: this.validity.stepMismatch,
752
- badInput: this.validity.badInput,
753
- customError: this.validity.customError
754
- } : null;
755
-
756
- return { valid, message, validity, supported: true };
757
- }`,
758
- returnByValue: true
759
- });
760
-
761
- return result.result.value;
762
- } finally {
763
- await element._handle.dispose();
764
- }
765
- }
766
-
767
- /**
768
- * Submit a form and report validation errors
769
- * @param {string} selector - CSS selector for the form element
770
- * @param {Object} options - Submit options
771
- * @param {boolean} options.validate - Check validation before submitting (default: true)
772
- * @param {boolean} options.reportValidity - Show browser validation UI (default: false)
773
- * @returns {Promise<{submitted: boolean, valid: boolean, errors: Array}>}
774
- */
775
- async function submitForm(selector, options = {}) {
776
- const { validate = true, reportValidity = false } = options;
777
-
778
- const element = await elementLocator.findElement(selector);
779
- if (!element) {
780
- throw new Error(`Form not found: ${selector}`);
781
- }
782
-
783
- try {
784
- const result = await session.send('Runtime.callFunctionOn', {
785
- objectId: element._handle.objectId,
786
- functionDeclaration: `function(validate, reportValidity) {
787
- // Check if this is a form element
788
- if (this.tagName !== 'FORM') {
789
- return { submitted: false, error: 'Element is not a form', valid: null, errors: [] };
790
- }
791
-
792
- const errors = [];
793
- let formValid = true;
794
-
795
- if (validate) {
796
- // Get all form elements and check validity
797
- const elements = this.elements;
798
- for (let i = 0; i < elements.length; i++) {
799
- const el = elements[i];
800
- if (el.checkValidity && !el.checkValidity()) {
801
- formValid = false;
802
- errors.push({
803
- name: el.name || el.id || 'unknown',
804
- type: el.type || el.tagName.toLowerCase(),
805
- message: el.validationMessage,
806
- value: el.value
807
- });
808
- }
809
- }
810
-
811
- if (!formValid) {
812
- if (reportValidity) {
813
- this.reportValidity();
814
- }
815
- return { submitted: false, valid: false, errors };
816
- }
817
- }
818
-
819
- // Submit the form
820
- this.submit();
821
- return { submitted: true, valid: true, errors: [] };
822
- }`,
823
- arguments: [
824
- { value: validate },
825
- { value: reportValidity }
826
- ],
827
- returnByValue: true
828
- });
829
-
830
- return result.result.value;
831
- } finally {
832
- await element._handle.dispose();
833
- }
834
- }
835
-
836
- /**
837
- * Get all validation errors for a form
838
- * @param {string} selector - CSS selector for the form element
839
- * @returns {Promise<Array<{name: string, type: string, message: string}>>}
840
- */
841
- async function getFormErrors(selector) {
842
- const element = await elementLocator.findElement(selector);
843
- if (!element) {
844
- throw new Error(`Form not found: ${selector}`);
845
- }
846
-
847
- try {
848
- const result = await session.send('Runtime.callFunctionOn', {
849
- objectId: element._handle.objectId,
850
- functionDeclaration: `function() {
851
- if (this.tagName !== 'FORM') {
852
- return { error: 'Element is not a form', errors: [] };
853
- }
854
-
855
- const errors = [];
856
- const elements = this.elements;
857
-
858
- for (let i = 0; i < elements.length; i++) {
859
- const el = elements[i];
860
- if (el.checkValidity && !el.checkValidity()) {
861
- errors.push({
862
- name: el.name || el.id || 'unknown',
863
- type: el.type || el.tagName.toLowerCase(),
864
- message: el.validationMessage,
865
- value: el.value
866
- });
867
- }
868
- }
869
-
870
- return { errors };
871
- }`,
872
- returnByValue: true
873
- });
874
-
875
- return result.result.value.errors;
876
- } finally {
877
- await element._handle.dispose();
878
- }
879
- }
880
-
881
- /**
882
- * Get complete form state including all fields and their values (Feature 12)
883
- * @param {string} selector - CSS selector for the form element
884
- * @returns {Promise<Object>} Form state object
885
- */
886
- async function getFormState(selector) {
887
- const element = await elementLocator.findElement(selector);
888
- if (!element) {
889
- throw new Error(`Form not found: ${selector}`);
890
- }
891
-
892
- try {
893
- const result = await session.send('Runtime.callFunctionOn', {
894
- objectId: element._handle.objectId,
895
- functionDeclaration: `function() {
896
- if (this.tagName !== 'FORM') {
897
- return { error: 'Element is not a form' };
898
- }
899
-
900
- const form = this;
901
- const fields = [];
902
- let formValid = true;
903
-
904
- // Get form attributes
905
- const action = form.action || '';
906
- const method = (form.method || 'get').toUpperCase();
907
- const enctype = form.enctype || 'application/x-www-form-urlencoded';
908
-
909
- // Get associated label for an element
910
- function getLabel(el) {
911
- // Try label with for attribute (use CSS.escape to prevent selector injection)
912
- if (el.id) {
913
- const label = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
914
- if (label) return label.textContent.trim();
915
- }
916
- // Try parent label
917
- const parentLabel = el.closest('label');
918
- if (parentLabel) {
919
- // Get text content excluding the input's text
920
- const clone = parentLabel.cloneNode(true);
921
- const inputs = clone.querySelectorAll('input, textarea, select');
922
- inputs.forEach(i => i.remove());
923
- return clone.textContent.trim();
924
- }
925
- // Try aria-label
926
- if (el.getAttribute('aria-label')) {
927
- return el.getAttribute('aria-label');
928
- }
929
- // Try placeholder
930
- if (el.placeholder) {
931
- return el.placeholder;
932
- }
933
- return null;
934
- }
935
-
936
- const elements = form.elements;
937
- for (let i = 0; i < elements.length; i++) {
938
- const el = elements[i];
939
- const tagName = el.tagName.toLowerCase();
940
- const type = el.type ? el.type.toLowerCase() : tagName;
941
-
942
- // Skip buttons and hidden fields
943
- if (type === 'submit' || type === 'reset' || type === 'button' || type === 'hidden') {
944
- continue;
945
- }
946
-
947
- // Get validation state
948
- const valid = el.checkValidity ? el.checkValidity() : true;
949
- if (!valid) formValid = false;
950
-
951
- // Get value (mask passwords)
952
- let value;
953
- if (type === 'password') {
954
- value = el.value ? '••••••••' : '';
955
- } else if (type === 'checkbox' || type === 'radio') {
956
- value = el.checked;
957
- } else if (tagName === 'select') {
958
- const selected = [];
959
- for (let j = 0; j < el.selectedOptions.length; j++) {
960
- selected.push(el.selectedOptions[j].text);
961
- }
962
- value = el.multiple ? selected : (selected[0] || '');
963
- } else {
964
- value = el.value || '';
965
- }
966
-
967
- fields.push({
968
- name: el.name || el.id || null,
969
- type: type,
970
- label: getLabel(el),
971
- value: value,
972
- required: el.required || false,
973
- valid: valid,
974
- validationMessage: el.validationMessage || null,
975
- disabled: el.disabled || false,
976
- readOnly: el.readOnly || false
977
- });
978
- }
979
-
980
- return {
981
- action,
982
- method,
983
- enctype,
984
- fields,
985
- valid: formValid,
986
- fieldCount: fields.length
987
- };
988
- }`,
989
- returnByValue: true
990
- });
991
-
992
- return result.result.value;
993
- } finally {
994
- await element._handle.dispose();
995
- }
996
- }
997
-
998
- return {
999
- validateElement,
1000
- submitForm,
1001
- getFormErrors,
1002
- getFormState
1003
- };
1004
- }
1005
-
1006
- // ============================================================================
1007
- // Device Presets (from DevicePresets.js)
1008
- // ============================================================================
1009
-
1010
- /**
1011
- * Device preset configurations for viewport emulation
1012
- */
1013
- export const DEVICE_PRESETS = new Map([
1014
- // iPhones
1015
- ['iphone-se', { width: 375, height: 667, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
1016
- ['iphone-12', { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1017
- ['iphone-12-mini', { width: 360, height: 780, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1018
- ['iphone-12-pro', { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1019
- ['iphone-12-pro-max', { width: 428, height: 926, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1020
- ['iphone-13', { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1021
- ['iphone-13-mini', { width: 375, height: 812, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1022
- ['iphone-13-pro', { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1023
- ['iphone-13-pro-max', { width: 428, height: 926, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1024
- ['iphone-14', { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1025
- ['iphone-14-plus', { width: 428, height: 926, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1026
- ['iphone-14-pro', { width: 393, height: 852, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1027
- ['iphone-14-pro-max', { width: 430, height: 932, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1028
- ['iphone-15', { width: 393, height: 852, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1029
- ['iphone-15-plus', { width: 430, height: 932, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1030
- ['iphone-15-pro', { width: 393, height: 852, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1031
- ['iphone-15-pro-max', { width: 430, height: 932, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1032
-
1033
- // iPads
1034
- ['ipad', { width: 768, height: 1024, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
1035
- ['ipad-mini', { width: 768, height: 1024, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
1036
- ['ipad-air', { width: 820, height: 1180, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
1037
- ['ipad-pro-11', { width: 834, height: 1194, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
1038
- ['ipad-pro-12.9', { width: 1024, height: 1366, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
1039
-
1040
- // Android phones
1041
- ['pixel-5', { width: 393, height: 851, deviceScaleFactor: 2.75, mobile: true, hasTouch: true }],
1042
- ['pixel-6', { width: 412, height: 915, deviceScaleFactor: 2.625, mobile: true, hasTouch: true }],
1043
- ['pixel-7', { width: 412, height: 915, deviceScaleFactor: 2.625, mobile: true, hasTouch: true }],
1044
- ['pixel-7-pro', { width: 412, height: 892, deviceScaleFactor: 3.5, mobile: true, hasTouch: true }],
1045
- ['samsung-galaxy-s21', { width: 360, height: 800, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1046
- ['samsung-galaxy-s22', { width: 360, height: 780, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1047
- ['samsung-galaxy-s23', { width: 360, height: 780, deviceScaleFactor: 3, mobile: true, hasTouch: true }],
1048
-
1049
- // Android tablets
1050
- ['galaxy-tab-s7', { width: 800, height: 1280, deviceScaleFactor: 2, mobile: true, hasTouch: true }],
1051
-
1052
- // Desktop presets
1053
- ['desktop', { width: 1920, height: 1080, deviceScaleFactor: 1, mobile: false, hasTouch: false }],
1054
- ['desktop-hd', { width: 1920, height: 1080, deviceScaleFactor: 1, mobile: false, hasTouch: false }],
1055
- ['desktop-4k', { width: 3840, height: 2160, deviceScaleFactor: 1, mobile: false, hasTouch: false }],
1056
- ['laptop', { width: 1366, height: 768, deviceScaleFactor: 1, mobile: false, hasTouch: false }],
1057
- ['laptop-hd', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false, hasTouch: false }],
1058
- ['macbook-air', { width: 1440, height: 900, deviceScaleFactor: 2, mobile: false, hasTouch: false }],
1059
- ['macbook-pro-13', { width: 1440, height: 900, deviceScaleFactor: 2, mobile: false, hasTouch: false }],
1060
- ['macbook-pro-14', { width: 1512, height: 982, deviceScaleFactor: 2, mobile: false, hasTouch: false }],
1061
- ['macbook-pro-16', { width: 1728, height: 1117, deviceScaleFactor: 2, mobile: false, hasTouch: false }],
1062
-
1063
- // Landscape variants (appended with -landscape)
1064
- ['iphone-14-landscape', { width: 844, height: 390, deviceScaleFactor: 3, mobile: true, hasTouch: true, isLandscape: true }],
1065
- ['iphone-14-pro-landscape', { width: 852, height: 393, deviceScaleFactor: 3, mobile: true, hasTouch: true, isLandscape: true }],
1066
- ['ipad-landscape', { width: 1024, height: 768, deviceScaleFactor: 2, mobile: true, hasTouch: true, isLandscape: true }],
1067
- ['ipad-pro-11-landscape', { width: 1194, height: 834, deviceScaleFactor: 2, mobile: true, hasTouch: true, isLandscape: true }],
1068
- ]);
1069
-
1070
- /**
1071
- * Get a device preset by name
1072
- * @param {string} name - Device preset name (case-insensitive)
1073
- * @returns {Object|null} Device configuration or null if not found
1074
- */
1075
- export function getDevicePreset(name) {
1076
- const normalizedName = name.toLowerCase().replace(/_/g, '-');
1077
- return DEVICE_PRESETS.get(normalizedName) || null;
1078
- }
1079
-
1080
- /**
1081
- * Check if a preset exists
1082
- * @param {string} name - Device preset name
1083
- * @returns {boolean}
1084
- */
1085
- export function hasDevicePreset(name) {
1086
- const normalizedName = name.toLowerCase().replace(/_/g, '-');
1087
- return DEVICE_PRESETS.has(normalizedName);
1088
- }
1089
-
1090
- /**
1091
- * Get all available preset names
1092
- * @returns {string[]}
1093
- */
1094
- export function listDevicePresets() {
1095
- return Array.from(DEVICE_PRESETS.keys());
1096
- }
1097
-
1098
- /**
1099
- * Get presets by category
1100
- * @param {string} category - 'iphone', 'ipad', 'android', 'desktop', 'landscape'
1101
- * @returns {string[]}
1102
- */
1103
- export function listDevicePresetsByCategory(category) {
1104
- const categoryLower = category.toLowerCase();
1105
- return listDevicePresets().filter(name => {
1106
- if (categoryLower === 'iphone') return name.startsWith('iphone');
1107
- if (categoryLower === 'ipad') return name.startsWith('ipad');
1108
- if (categoryLower === 'android') return name.startsWith('pixel') || name.startsWith('samsung') || name.startsWith('galaxy');
1109
- if (categoryLower === 'desktop') return name.startsWith('desktop') || name.startsWith('laptop') || name.startsWith('macbook');
1110
- if (categoryLower === 'landscape') return name.endsWith('-landscape');
1111
- return false;
1112
- });
1113
- }
1114
-
1115
- /**
1116
- * Resolve viewport options - handles both preset strings and explicit configs
1117
- * @param {string|Object} viewport - Either a preset name string or viewport config object
1118
- * @returns {Object} Resolved viewport configuration
1119
- * @throws {Error} If preset not found
1120
- */
1121
- export function resolveViewport(viewport) {
1122
- if (typeof viewport === 'string') {
1123
- const preset = getDevicePreset(viewport);
1124
- if (!preset) {
1125
- const available = listDevicePresets().slice(0, 10).join(', ');
1126
- throw new Error(`Unknown device preset "${viewport}". Available presets include: ${available}...`);
1127
- }
1128
- return { ...preset };
1129
- }
1130
-
1131
- // It's an object - validate required fields
1132
- if (!viewport.width || !viewport.height) {
1133
- throw new Error('Viewport requires width and height');
1134
- }
1135
-
1136
- return {
1137
- width: viewport.width,
1138
- height: viewport.height,
1139
- deviceScaleFactor: viewport.deviceScaleFactor || 1,
1140
- mobile: viewport.mobile || false,
1141
- hasTouch: viewport.hasTouch || false,
1142
- isLandscape: viewport.isLandscape || false
1143
- };
1144
- }
3
+ *
4
+ * This file re-exports all utility functions from the utils/ directory
5
+ * for backward compatibility with existing imports.
6
+ *
7
+ * Original large file split into:
8
+ * - utils/temp.js - Temp directory utilities
9
+ * - utils/backoff.js - Backoff sleeper utilities
10
+ * - utils/cdp-helpers.js - CDP helper functions
11
+ * - utils/errors.js - Error types and factories
12
+ * - utils/validators.js - Key and form validators
13
+ * - utils/devices.js - Device presets
14
+ */
15
+
16
+ export * from './utils/index.js';