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
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Temp Directory Utilities
3
+ * Handles temp directory creation and path resolution for CDP skill outputs
4
+ */
5
+
6
+ import os from 'os';
7
+ import path from 'path';
8
+ import fs from 'fs/promises';
9
+
10
+ let _tempDir = null;
11
+
12
+ /**
13
+ * Get the platform-specific temp directory for CDP skill outputs (screenshots, PDFs, etc.)
14
+ * Creates the directory if it doesn't exist
15
+ * @returns {Promise<string>} Absolute path to temp directory
16
+ */
17
+ export async function getTempDir() {
18
+ if (_tempDir) return _tempDir;
19
+
20
+ const baseTemp = os.tmpdir();
21
+ _tempDir = path.join(baseTemp, 'cdp-skill');
22
+
23
+ await fs.mkdir(_tempDir, { recursive: true });
24
+ return _tempDir;
25
+ }
26
+
27
+ /**
28
+ * Get the temp directory synchronously (returns cached value or creates new)
29
+ * Note: First call should use getTempDir() to ensure directory exists
30
+ * @returns {string} Absolute path to temp directory
31
+ */
32
+ export function getTempDirSync() {
33
+ if (_tempDir) return _tempDir;
34
+
35
+ const baseTemp = os.tmpdir();
36
+ _tempDir = path.join(baseTemp, 'cdp-skill');
37
+ return _tempDir;
38
+ }
39
+
40
+ /**
41
+ * Resolve a file path, using temp directory for relative paths
42
+ * @param {string} filePath - File path (relative or absolute)
43
+ * @param {string} [extension] - Default extension to add if missing
44
+ * @returns {Promise<string>} Absolute path
45
+ */
46
+ export async function resolveTempPath(filePath, extension) {
47
+ // If already absolute, use as-is
48
+ if (path.isAbsolute(filePath)) {
49
+ return filePath;
50
+ }
51
+
52
+ // For relative paths, put in temp directory
53
+ const tempDir = await getTempDir();
54
+ let resolved = path.join(tempDir, filePath);
55
+
56
+ // Add extension if missing
57
+ if (extension && !path.extname(resolved)) {
58
+ resolved += extension;
59
+ }
60
+
61
+ return resolved;
62
+ }
63
+
64
+ /**
65
+ * Generate a unique temp file path with timestamp
66
+ * @param {string} prefix - File prefix (e.g., 'screenshot', 'page')
67
+ * @param {string} extension - File extension (e.g., '.png', '.pdf')
68
+ * @returns {Promise<string>} Unique absolute path in temp directory
69
+ */
70
+ export async function generateTempPath(prefix, extension) {
71
+ const tempDir = await getTempDir();
72
+ const timestamp = Date.now();
73
+ const random = Math.random().toString(36).substring(2, 8);
74
+ return path.join(tempDir, `${prefix}-${timestamp}-${random}${extension}`);
75
+ }
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Validation Utilities
3
+ * Key validation and form validation helpers
4
+ */
5
+
6
+ const VALID_KEY_NAMES = new Set([
7
+ // Standard keys
8
+ 'Enter', 'Tab', 'Escape', 'Backspace', 'Delete', 'Space',
9
+ // Arrow keys
10
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
11
+ // Modifier keys
12
+ 'Shift', 'Control', 'Alt', 'Meta',
13
+ // Function keys
14
+ 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
15
+ // Navigation keys
16
+ 'Home', 'End', 'PageUp', 'PageDown', 'Insert',
17
+ // Additional common keys
18
+ 'CapsLock', 'NumLock', 'ScrollLock', 'Pause', 'PrintScreen',
19
+ 'ContextMenu',
20
+ // Numpad keys
21
+ 'Numpad0', 'Numpad1', 'Numpad2', 'Numpad3', 'Numpad4',
22
+ 'Numpad5', 'Numpad6', 'Numpad7', 'Numpad8', 'Numpad9',
23
+ 'NumpadAdd', 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide',
24
+ 'NumpadDecimal', 'NumpadEnter'
25
+ ]);
26
+
27
+ const MODIFIER_ALIASES = new Set([
28
+ 'control', 'ctrl', 'alt', 'meta', 'cmd', 'command', 'shift'
29
+ ]);
30
+
31
+ /**
32
+ * Create a key validator for validating key names against known CDP key codes
33
+ * @returns {Object} Key validator with validation methods
34
+ */
35
+ export function createKeyValidator() {
36
+ function isKnownKey(keyName) {
37
+ if (!keyName || typeof keyName !== 'string') {
38
+ return false;
39
+ }
40
+ if (VALID_KEY_NAMES.has(keyName)) {
41
+ return true;
42
+ }
43
+ // Check for single character keys (a-z, A-Z, 0-9, punctuation)
44
+ if (keyName.length === 1) {
45
+ return true;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ function isModifierAlias(part) {
51
+ return MODIFIER_ALIASES.has(part.toLowerCase());
52
+ }
53
+
54
+ function getKnownKeysSample() {
55
+ return ['Enter', 'Tab', 'Escape', 'Backspace', 'ArrowUp', 'ArrowDown', 'F1-F12'].join(', ');
56
+ }
57
+
58
+ function validateCombo(combo) {
59
+ const parts = combo.split('+');
60
+ const warnings = [];
61
+ let mainKey = null;
62
+
63
+ for (const part of parts) {
64
+ const trimmed = part.trim();
65
+ if (!trimmed) {
66
+ return {
67
+ valid: false,
68
+ warning: `Invalid key combo "${combo}": empty key part`
69
+ };
70
+ }
71
+
72
+ // Check if it's a modifier
73
+ if (isModifierAlias(trimmed) || VALID_KEY_NAMES.has(trimmed) &&
74
+ ['Shift', 'Control', 'Alt', 'Meta'].includes(trimmed)) {
75
+ continue;
76
+ }
77
+
78
+ // This should be the main key
79
+ if (mainKey !== null) {
80
+ return {
81
+ valid: false,
82
+ warning: `Invalid key combo "${combo}": multiple main keys specified`
83
+ };
84
+ }
85
+ mainKey = trimmed;
86
+
87
+ if (!isKnownKey(trimmed)) {
88
+ warnings.push(`Unknown key "${trimmed}" in combo`);
89
+ }
90
+ }
91
+
92
+ if (mainKey === null) {
93
+ return {
94
+ valid: false,
95
+ warning: `Invalid key combo "${combo}": no main key specified`
96
+ };
97
+ }
98
+
99
+ return {
100
+ valid: true,
101
+ warning: warnings.length > 0 ? warnings.join('; ') : null
102
+ };
103
+ }
104
+
105
+ function validate(keyName) {
106
+ if (!keyName || typeof keyName !== 'string') {
107
+ return {
108
+ valid: false,
109
+ warning: 'Key name must be a non-empty string'
110
+ };
111
+ }
112
+
113
+ // Handle key combos (e.g., "Control+a")
114
+ if (keyName.includes('+')) {
115
+ return validateCombo(keyName);
116
+ }
117
+
118
+ if (isKnownKey(keyName)) {
119
+ return { valid: true, warning: null };
120
+ }
121
+
122
+ return {
123
+ valid: true, // Still allow unknown keys to pass through
124
+ warning: `Unknown key name "${keyName}". Known keys: ${getKnownKeysSample()}`
125
+ };
126
+ }
127
+
128
+ function getValidKeyNames() {
129
+ return new Set(VALID_KEY_NAMES);
130
+ }
131
+
132
+ return {
133
+ isKnownKey,
134
+ isModifierAlias,
135
+ validate,
136
+ validateCombo,
137
+ getKnownKeysSample,
138
+ getValidKeyNames
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Create a form validator for handling form validation queries and submit operations
144
+ * @param {Object} session - CDP session
145
+ * @param {Object} elementLocator - Element locator instance
146
+ * @returns {Object} Form validator with validation methods
147
+ */
148
+ export function createFormValidator(session, elementLocator) {
149
+ /**
150
+ * Query validation state of an element using HTML5 constraint validation API
151
+ * @param {string} selector - CSS selector for the input/form element
152
+ * @returns {Promise<{valid: boolean, message: string, validity: Object}>}
153
+ */
154
+ async function validateElement(selector) {
155
+ const element = await elementLocator.findElement(selector);
156
+ if (!element) {
157
+ throw new Error(`Element not found: ${selector}`);
158
+ }
159
+
160
+ try {
161
+ const result = await session.send('Runtime.callFunctionOn', {
162
+ objectId: element._handle.objectId,
163
+ functionDeclaration: `function() {
164
+ if (!this.checkValidity) {
165
+ return { valid: true, message: '', validity: null, supported: false };
166
+ }
167
+
168
+ const valid = this.checkValidity();
169
+ const message = this.validationMessage || '';
170
+
171
+ // Get detailed validity state
172
+ const validity = this.validity ? {
173
+ valueMissing: this.validity.valueMissing,
174
+ typeMismatch: this.validity.typeMismatch,
175
+ patternMismatch: this.validity.patternMismatch,
176
+ tooLong: this.validity.tooLong,
177
+ tooShort: this.validity.tooShort,
178
+ rangeUnderflow: this.validity.rangeUnderflow,
179
+ rangeOverflow: this.validity.rangeOverflow,
180
+ stepMismatch: this.validity.stepMismatch,
181
+ badInput: this.validity.badInput,
182
+ customError: this.validity.customError
183
+ } : null;
184
+
185
+ return { valid, message, validity, supported: true };
186
+ }`,
187
+ returnByValue: true
188
+ });
189
+
190
+ return result.result.value;
191
+ } finally {
192
+ await element._handle.dispose();
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Submit a form and report validation errors
198
+ * @param {string} selector - CSS selector for the form element
199
+ * @param {Object} options - Submit options
200
+ * @param {boolean} options.validate - Check validation before submitting (default: true)
201
+ * @param {boolean} options.reportValidity - Show browser validation UI (default: false)
202
+ * @returns {Promise<{submitted: boolean, valid: boolean, errors: Array}>}
203
+ */
204
+ async function submitForm(selector, options = {}) {
205
+ const { validate = true, reportValidity = false } = options;
206
+
207
+ const element = await elementLocator.findElement(selector);
208
+ if (!element) {
209
+ throw new Error(`Form not found: ${selector}`);
210
+ }
211
+
212
+ try {
213
+ const result = await session.send('Runtime.callFunctionOn', {
214
+ objectId: element._handle.objectId,
215
+ functionDeclaration: `function(validate, reportValidity) {
216
+ // Check if this is a form element
217
+ if (this.tagName !== 'FORM') {
218
+ return { submitted: false, error: 'Element is not a form', valid: null, errors: [] };
219
+ }
220
+
221
+ const errors = [];
222
+ let formValid = true;
223
+
224
+ if (validate) {
225
+ // Get all form elements and check validity
226
+ const elements = this.elements;
227
+ for (let i = 0; i < elements.length; i++) {
228
+ const el = elements[i];
229
+ if (el.checkValidity && !el.checkValidity()) {
230
+ formValid = false;
231
+ errors.push({
232
+ name: el.name || el.id || 'unknown',
233
+ type: el.type || el.tagName.toLowerCase(),
234
+ message: el.validationMessage,
235
+ value: el.value
236
+ });
237
+ }
238
+ }
239
+
240
+ if (!formValid) {
241
+ if (reportValidity) {
242
+ this.reportValidity();
243
+ }
244
+ return { submitted: false, valid: false, errors };
245
+ }
246
+ }
247
+
248
+ // Submit the form
249
+ this.submit();
250
+ return { submitted: true, valid: true, errors: [] };
251
+ }`,
252
+ arguments: [
253
+ { value: validate },
254
+ { value: reportValidity }
255
+ ],
256
+ returnByValue: true
257
+ });
258
+
259
+ return result.result.value;
260
+ } finally {
261
+ await element._handle.dispose();
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Get all validation errors for a form
267
+ * @param {string} selector - CSS selector for the form element
268
+ * @returns {Promise<Array<{name: string, type: string, message: string}>>}
269
+ */
270
+ async function getFormErrors(selector) {
271
+ const element = await elementLocator.findElement(selector);
272
+ if (!element) {
273
+ throw new Error(`Form not found: ${selector}`);
274
+ }
275
+
276
+ try {
277
+ const result = await session.send('Runtime.callFunctionOn', {
278
+ objectId: element._handle.objectId,
279
+ functionDeclaration: `function() {
280
+ if (this.tagName !== 'FORM') {
281
+ return { error: 'Element is not a form', errors: [] };
282
+ }
283
+
284
+ const errors = [];
285
+ const elements = this.elements;
286
+
287
+ for (let i = 0; i < elements.length; i++) {
288
+ const el = elements[i];
289
+ if (el.checkValidity && !el.checkValidity()) {
290
+ errors.push({
291
+ name: el.name || el.id || 'unknown',
292
+ type: el.type || el.tagName.toLowerCase(),
293
+ message: el.validationMessage,
294
+ value: el.value
295
+ });
296
+ }
297
+ }
298
+
299
+ return { errors };
300
+ }`,
301
+ returnByValue: true
302
+ });
303
+
304
+ return result.result.value.errors;
305
+ } finally {
306
+ await element._handle.dispose();
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Get complete form state including all fields and their values
312
+ * @param {string} selector - CSS selector for the form element
313
+ * @returns {Promise<Object>} Form state object
314
+ */
315
+ async function getFormState(selector) {
316
+ const element = await elementLocator.findElement(selector);
317
+ if (!element) {
318
+ throw new Error(`Form not found: ${selector}`);
319
+ }
320
+
321
+ try {
322
+ const result = await session.send('Runtime.callFunctionOn', {
323
+ objectId: element._handle.objectId,
324
+ functionDeclaration: `function() {
325
+ if (this.tagName !== 'FORM') {
326
+ return { error: 'Element is not a form' };
327
+ }
328
+
329
+ const form = this;
330
+ const fields = [];
331
+ let formValid = true;
332
+
333
+ // Get form attributes
334
+ const action = form.action || '';
335
+ const method = (form.method || 'get').toUpperCase();
336
+ const enctype = form.enctype || 'application/x-www-form-urlencoded';
337
+
338
+ // Get associated label for an element
339
+ function getLabel(el) {
340
+ // Try label with for attribute (use CSS.escape to prevent selector injection)
341
+ if (el.id) {
342
+ const label = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
343
+ if (label) return label.textContent.trim();
344
+ }
345
+ // Try parent label
346
+ const parentLabel = el.closest('label');
347
+ if (parentLabel) {
348
+ // Get text content excluding the input's text
349
+ const clone = parentLabel.cloneNode(true);
350
+ const inputs = clone.querySelectorAll('input, textarea, select');
351
+ inputs.forEach(i => i.remove());
352
+ return clone.textContent.trim();
353
+ }
354
+ // Try aria-label
355
+ if (el.getAttribute('aria-label')) {
356
+ return el.getAttribute('aria-label');
357
+ }
358
+ // Try placeholder
359
+ if (el.placeholder) {
360
+ return el.placeholder;
361
+ }
362
+ return null;
363
+ }
364
+
365
+ const elements = form.elements;
366
+ for (let i = 0; i < elements.length; i++) {
367
+ const el = elements[i];
368
+ const tagName = el.tagName.toLowerCase();
369
+ const type = el.type ? el.type.toLowerCase() : tagName;
370
+
371
+ // Skip buttons and hidden fields
372
+ if (type === 'submit' || type === 'reset' || type === 'button' || type === 'hidden') {
373
+ continue;
374
+ }
375
+
376
+ // Get validation state
377
+ const valid = el.checkValidity ? el.checkValidity() : true;
378
+ if (!valid) formValid = false;
379
+
380
+ // Get value (mask passwords)
381
+ let value;
382
+ if (type === 'password') {
383
+ value = el.value ? '••••••••' : '';
384
+ } else if (type === 'checkbox' || type === 'radio') {
385
+ value = el.checked;
386
+ } else if (tagName === 'select') {
387
+ const selected = [];
388
+ for (let j = 0; j < el.selectedOptions.length; j++) {
389
+ selected.push(el.selectedOptions[j].text);
390
+ }
391
+ value = el.multiple ? selected : (selected[0] || '');
392
+ } else {
393
+ value = el.value || '';
394
+ }
395
+
396
+ fields.push({
397
+ name: el.name || el.id || null,
398
+ type: type,
399
+ label: getLabel(el),
400
+ value: value,
401
+ required: el.required || false,
402
+ valid: valid,
403
+ validationMessage: el.validationMessage || null,
404
+ disabled: el.disabled || false,
405
+ readOnly: el.readOnly || false
406
+ });
407
+ }
408
+
409
+ return {
410
+ action,
411
+ method,
412
+ enctype,
413
+ fields,
414
+ valid: formValid,
415
+ fieldCount: fields.length
416
+ };
417
+ }`,
418
+ returnByValue: true
419
+ });
420
+
421
+ return result.result.value;
422
+ } finally {
423
+ await element._handle.dispose();
424
+ }
425
+ }
426
+
427
+ return {
428
+ validateElement,
429
+ submitForm,
430
+ getFormErrors,
431
+ getFormState
432
+ };
433
+ }