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/SKILL.md +543 -0
- package/install.js +92 -0
- package/package.json +47 -0
- package/src/aria.js +1302 -0
- package/src/capture.js +1359 -0
- package/src/cdp.js +905 -0
- package/src/cli.js +244 -0
- package/src/dom.js +3525 -0
- package/src/index.js +155 -0
- package/src/page.js +1720 -0
- package/src/runner.js +2111 -0
- package/src/tests/BrowserClient.test.js +588 -0
- package/src/tests/CDPConnection.test.js +598 -0
- package/src/tests/ChromeDiscovery.test.js +181 -0
- package/src/tests/ConsoleCapture.test.js +302 -0
- package/src/tests/ElementHandle.test.js +586 -0
- package/src/tests/ElementLocator.test.js +586 -0
- package/src/tests/ErrorAggregator.test.js +327 -0
- package/src/tests/InputEmulator.test.js +641 -0
- package/src/tests/NetworkErrorCapture.test.js +458 -0
- package/src/tests/PageController.test.js +822 -0
- package/src/tests/ScreenshotCapture.test.js +356 -0
- package/src/tests/SessionRegistry.test.js +257 -0
- package/src/tests/TargetManager.test.js +274 -0
- package/src/tests/TestRunner.test.js +1529 -0
- package/src/tests/WaitStrategy.test.js +406 -0
- package/src/tests/integration.test.js +431 -0
- package/src/utils.js +1034 -0
- package/uninstall.js +44 -0
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
|
+
}
|