cdp-skill 1.0.2 → 1.0.4

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 (78) hide show
  1. package/README.md +3 -0
  2. package/SKILL.md +34 -5
  3. package/package.json +2 -1
  4. package/src/capture/console-capture.js +241 -0
  5. package/src/capture/debug-capture.js +144 -0
  6. package/src/capture/error-aggregator.js +151 -0
  7. package/src/capture/eval-serializer.js +320 -0
  8. package/src/capture/index.js +40 -0
  9. package/src/capture/network-capture.js +211 -0
  10. package/src/capture/pdf-capture.js +256 -0
  11. package/src/capture/screenshot-capture.js +325 -0
  12. package/src/cdp/browser.js +569 -0
  13. package/src/cdp/connection.js +369 -0
  14. package/src/cdp/discovery.js +138 -0
  15. package/src/cdp/index.js +29 -0
  16. package/src/cdp/target-and-session.js +439 -0
  17. package/src/cdp-skill.js +25 -11
  18. package/src/constants.js +79 -0
  19. package/src/dom/actionability.js +638 -0
  20. package/src/dom/click-executor.js +923 -0
  21. package/src/dom/element-handle.js +496 -0
  22. package/src/dom/element-locator.js +475 -0
  23. package/src/dom/element-validator.js +120 -0
  24. package/src/dom/fill-executor.js +489 -0
  25. package/src/dom/index.js +248 -0
  26. package/src/dom/input-emulator.js +406 -0
  27. package/src/dom/keyboard-executor.js +202 -0
  28. package/src/dom/quad-helpers.js +89 -0
  29. package/src/dom/react-filler.js +94 -0
  30. package/src/dom/wait-executor.js +423 -0
  31. package/src/index.js +6 -6
  32. package/src/page/cookie-manager.js +202 -0
  33. package/src/page/dom-stability.js +181 -0
  34. package/src/page/index.js +36 -0
  35. package/src/{page.js → page/page-controller.js} +109 -839
  36. package/src/page/wait-utilities.js +302 -0
  37. package/src/page/web-storage-manager.js +108 -0
  38. package/src/runner/context-helpers.js +224 -0
  39. package/src/runner/execute-browser.js +518 -0
  40. package/src/runner/execute-form.js +315 -0
  41. package/src/runner/execute-input.js +308 -0
  42. package/src/runner/execute-interaction.js +672 -0
  43. package/src/runner/execute-navigation.js +180 -0
  44. package/src/runner/execute-query.js +771 -0
  45. package/src/runner/index.js +51 -0
  46. package/src/runner/step-executors.js +421 -0
  47. package/src/runner/step-validator.js +641 -0
  48. package/src/tests/Actionability.test.js +613 -0
  49. package/src/tests/BrowserClient.test.js +1 -1
  50. package/src/tests/ChromeDiscovery.test.js +1 -1
  51. package/src/tests/ClickExecutor.test.js +554 -0
  52. package/src/tests/ConsoleCapture.test.js +1 -1
  53. package/src/tests/ContextHelpers.test.js +453 -0
  54. package/src/tests/CookieManager.test.js +450 -0
  55. package/src/tests/DebugCapture.test.js +307 -0
  56. package/src/tests/ElementHandle.test.js +1 -1
  57. package/src/tests/ElementLocator.test.js +1 -1
  58. package/src/tests/ErrorAggregator.test.js +1 -1
  59. package/src/tests/EvalSerializer.test.js +391 -0
  60. package/src/tests/FillExecutor.test.js +611 -0
  61. package/src/tests/InputEmulator.test.js +1 -1
  62. package/src/tests/KeyboardExecutor.test.js +430 -0
  63. package/src/tests/NetworkErrorCapture.test.js +1 -1
  64. package/src/tests/PageController.test.js +1 -1
  65. package/src/tests/PdfCapture.test.js +333 -0
  66. package/src/tests/ScreenshotCapture.test.js +1 -1
  67. package/src/tests/SessionRegistry.test.js +1 -1
  68. package/src/tests/StepValidator.test.js +527 -0
  69. package/src/tests/TargetManager.test.js +1 -1
  70. package/src/tests/TestRunner.test.js +1 -1
  71. package/src/tests/WaitStrategy.test.js +1 -1
  72. package/src/tests/WaitUtilities.test.js +508 -0
  73. package/src/tests/WebStorageManager.test.js +333 -0
  74. package/src/types.js +309 -0
  75. package/src/capture.js +0 -1400
  76. package/src/cdp.js +0 -1286
  77. package/src/dom.js +0 -4379
  78. package/src/runner.js +0 -3676
@@ -0,0 +1,518 @@
1
+ /**
2
+ * Browser Executors
3
+ * PDF, eval, cookies, tabs, and console step executors
4
+ *
5
+ * EXPORTS:
6
+ * - executePdf(pdfCapture, elementLocator, params) → Promise<Object>
7
+ * - executeEval(pageController, params) → Promise<Object>
8
+ * - executeCookies(cookieManager, pageController, params) → Promise<Object>
9
+ * - executeListTabs(browser) → Promise<Array>
10
+ * - executeCloseTab(browser, targetId) → Promise<Object>
11
+ * - executeConsole(consoleCapture, params) → Promise<Object>
12
+ * - formatCommandConsole(consoleCapture, messageCountBefore) → Object|null
13
+ *
14
+ * DEPENDENCIES:
15
+ * - ../capture.js: createEvalSerializer
16
+ * - ../utils.js: resolveTempPath
17
+ */
18
+
19
+ import { createEvalSerializer } from '../capture/index.js';
20
+ import { resolveTempPath, getCurrentUrl } from '../utils.js';
21
+
22
+ export async function executePdf(pdfCapture, elementLocator, params) {
23
+ if (!pdfCapture) {
24
+ throw new Error('PDF capture not available');
25
+ }
26
+
27
+ const rawPath = typeof params === 'string' ? params : params.path;
28
+ const options = typeof params === 'object' ? params : {};
29
+
30
+ // Resolve path - relative paths go to platform temp directory
31
+ const resolvedPath = await resolveTempPath(rawPath, '.pdf');
32
+
33
+ // Pass elementLocator for element PDFs
34
+ return pdfCapture.saveToFile(resolvedPath, options, elementLocator);
35
+ }
36
+
37
+ /**
38
+ * Execute an eval step - executes JavaScript in the page context
39
+ * Enhanced with serialization for non-JSON values (FR-039, FR-040, FR-041)
40
+ * and optional timeout for async operations (FR-042)
41
+ */
42
+
43
+ export async function executeEval(pageController, params) {
44
+ const expression = typeof params === 'string' ? params : params.expression;
45
+ const awaitPromise = typeof params === 'object' && params.await === true;
46
+ const serialize = typeof params === 'object' && params.serialize !== false;
47
+ const evalTimeout = typeof params === 'object' && typeof params.timeout === 'number' ? params.timeout : null;
48
+
49
+ // Validate the expression
50
+ if (!expression || typeof expression !== 'string') {
51
+ throw new Error('Eval requires a non-empty expression string');
52
+ }
53
+
54
+ // Check for common shell escaping issues
55
+ const hasUnbalancedQuotes = (expression.match(/"/g) || []).length % 2 !== 0 ||
56
+ (expression.match(/'/g) || []).length % 2 !== 0;
57
+ const hasUnbalancedBraces = (expression.match(/\{/g) || []).length !== (expression.match(/\}/g) || []).length;
58
+ const hasUnbalancedParens = (expression.match(/\(/g) || []).length !== (expression.match(/\)/g) || []).length;
59
+
60
+ if (hasUnbalancedQuotes || hasUnbalancedBraces || hasUnbalancedParens) {
61
+ const issues = [];
62
+ if (hasUnbalancedQuotes) issues.push('unbalanced quotes');
63
+ if (hasUnbalancedBraces) issues.push('unbalanced braces {}');
64
+ if (hasUnbalancedParens) issues.push('unbalanced parentheses ()');
65
+
66
+ throw new Error(
67
+ `Eval expression appears malformed (${issues.join(', ')}). ` +
68
+ `This often happens due to shell escaping. Expression preview: "${expression.substring(0, 100)}${expression.length > 100 ? '...' : ''}". ` +
69
+ `Tip: Use heredoc syntax or a JSON file to pass complex expressions.`
70
+ );
71
+ }
72
+
73
+ // Build the wrapped expression for serialization
74
+ let wrappedExpression;
75
+ if (serialize) {
76
+ // Use EvalSerializer for enhanced value handling
77
+ const evalSerializer = createEvalSerializer();
78
+ const serializerFn = evalSerializer.getSerializationFunction();
79
+ wrappedExpression = `(${serializerFn})(${expression})`;
80
+ } else {
81
+ wrappedExpression = expression;
82
+ }
83
+
84
+ // Create the eval promise - use evaluateInFrame to respect frame context (Bug #9 fix)
85
+ const evalPromise = pageController.evaluateInFrame(wrappedExpression, {
86
+ returnByValue: true,
87
+ awaitPromise
88
+ });
89
+
90
+ // Apply timeout if specified (FR-042)
91
+ let result;
92
+ if (evalTimeout !== null && evalTimeout > 0) {
93
+ let evalTimeoutId;
94
+ const timeoutPromise = new Promise((_, reject) => {
95
+ evalTimeoutId = setTimeout(() => {
96
+ reject(new Error(`Eval timed out after ${evalTimeout}ms`));
97
+ }, evalTimeout);
98
+ });
99
+ result = await Promise.race([evalPromise, timeoutPromise]);
100
+ clearTimeout(evalTimeoutId);
101
+ } else {
102
+ result = await evalPromise;
103
+ }
104
+
105
+ if (result.exceptionDetails) {
106
+ const errorText = result.exceptionDetails.exception?.description ||
107
+ result.exceptionDetails.text ||
108
+ 'Unknown eval error';
109
+
110
+ // Provide more context for syntax errors
111
+ if (errorText.includes('SyntaxError')) {
112
+ throw new Error(
113
+ `Eval syntax error: ${errorText}. ` +
114
+ `Expression was: "${expression.substring(0, 150)}${expression.length > 150 ? '...' : ''}". ` +
115
+ `Tip: Check for shell escaping issues or use a JSON file for complex expressions.`
116
+ );
117
+ }
118
+
119
+ throw new Error(`Eval error: ${errorText}`);
120
+ }
121
+
122
+ // Process serialized result if serialization was used
123
+ if (serialize && result.result.value && typeof result.result.value === 'object') {
124
+ const evalSerializer = createEvalSerializer();
125
+ return evalSerializer.processResult(result.result.value);
126
+ }
127
+
128
+ return {
129
+ value: result.result.value,
130
+ type: result.result.type
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Execute a snapshot step - generates accessibility tree snapshot
136
+ */
137
+
138
+ /**
139
+ * Parse human-readable expiration string to Unix timestamp
140
+ * Supports: "1h" (hours), "7d" (days), "30m" (minutes), "1w" (weeks), "1y" (years)
141
+ * @param {string|number} expires - Expiration value
142
+ * @returns {number} Unix timestamp in seconds
143
+ */
144
+ export function parseExpiration(expires) {
145
+ if (typeof expires === 'number') {
146
+ return expires;
147
+ }
148
+
149
+ if (typeof expires !== 'string') {
150
+ return undefined;
151
+ }
152
+
153
+ const match = expires.match(/^(\d+)([mhdwy])$/i);
154
+ if (!match) {
155
+ // Try parsing as number string
156
+ const num = parseInt(expires, 10);
157
+ if (!isNaN(num)) return num;
158
+ return undefined;
159
+ }
160
+
161
+ const value = parseInt(match[1], 10);
162
+ const unit = match[2].toLowerCase();
163
+ const now = Math.floor(Date.now() / 1000);
164
+
165
+ switch (unit) {
166
+ case 'm': return now + value * 60; // minutes
167
+ case 'h': return now + value * 60 * 60; // hours
168
+ case 'd': return now + value * 60 * 60 * 24; // days
169
+ case 'w': return now + value * 60 * 60 * 24 * 7; // weeks
170
+ case 'y': return now + value * 60 * 60 * 24 * 365; // years
171
+ default: return undefined;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Execute a cookies step - get, set, or clear cookies
177
+ * By default, only returns cookies for the current tab's domain
178
+ */
179
+
180
+ export async function executeCookies(cookieManager, pageController, params) {
181
+ if (!cookieManager) {
182
+ throw new Error('Cookie manager not available');
183
+ }
184
+
185
+ // Get current page URL for domain filtering
186
+ const currentUrl = await getCurrentUrl(pageController.session);
187
+
188
+ // Determine the action
189
+ if (params.get !== undefined || params.action === 'get') {
190
+ // Default to current page URL if no URLs specified
191
+ const urls = Array.isArray(params.get) && params.get.length > 0
192
+ ? params.get
193
+ : (params.urls && params.urls.length > 0 ? params.urls : [currentUrl]);
194
+ let cookies = await cookieManager.getCookies(urls);
195
+
196
+ // Filter by name if specified
197
+ if (params.name) {
198
+ const names = Array.isArray(params.name) ? params.name : [params.name];
199
+ cookies = cookies.filter(c => names.includes(c.name));
200
+ }
201
+
202
+ return { action: 'get', cookies };
203
+ }
204
+
205
+ if (params.set !== undefined || params.action === 'set') {
206
+ const cookies = params.set || params.cookies || [];
207
+ if (!Array.isArray(cookies)) {
208
+ throw new Error('cookies set requires an array of cookie objects');
209
+ }
210
+
211
+ // Process cookies to convert human-readable expires values
212
+ const processedCookies = cookies.map(cookie => {
213
+ const processed = { ...cookie };
214
+ if (processed.expires !== undefined) {
215
+ processed.expires = parseExpiration(processed.expires);
216
+ }
217
+ return processed;
218
+ });
219
+
220
+ await cookieManager.setCookies(processedCookies);
221
+ return { action: 'set', count: processedCookies.length };
222
+ }
223
+
224
+ if (params.clear !== undefined || params.action === 'clear') {
225
+ const urls = Array.isArray(params.clear) ? params.clear : [];
226
+ const options = {};
227
+ if (params.domain) options.domain = params.domain;
228
+ const result = await cookieManager.clearCookies(urls, options);
229
+ return { action: 'clear', count: result.count, ...(params.domain ? { domain: params.domain } : {}) };
230
+ }
231
+
232
+ if (params.delete !== undefined || params.action === 'delete') {
233
+ const names = params.delete || params.names;
234
+ if (!names) {
235
+ throw new Error('cookies delete requires cookie name(s)');
236
+ }
237
+ const options = {};
238
+ if (params.domain) options.domain = params.domain;
239
+ if (params.path) options.path = params.path;
240
+ const result = await cookieManager.deleteCookies(names, options);
241
+ return { action: 'delete', count: result.count };
242
+ }
243
+
244
+ throw new Error('cookies requires action: get, set, clear, or delete');
245
+ }
246
+
247
+ /**
248
+ * Execute a formState step - dump form field state (Feature 12)
249
+ * @param {Object} formValidator - Form validator instance
250
+ * @param {string} selector - CSS selector for the form
251
+ * @returns {Promise<Object>} Form state
252
+ */
253
+
254
+ export async function executeListTabs(browser) {
255
+ if (!browser) {
256
+ throw new Error('Browser not available for listTabs');
257
+ }
258
+
259
+ const pages = await browser.getPages();
260
+ const tabs = pages.map(page => ({
261
+ targetId: page.targetId,
262
+ url: page.url,
263
+ title: page.title
264
+ }));
265
+
266
+ return {
267
+ count: tabs.length,
268
+ tabs
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Execute a closeTab step - closes a browser tab by targetId
274
+ */
275
+
276
+ export async function executeCloseTab(browser, targetId) {
277
+ if (!browser) {
278
+ throw new Error('Browser not available for closeTab');
279
+ }
280
+
281
+ await browser.closePage(targetId);
282
+ return { closed: targetId };
283
+ }
284
+
285
+ /**
286
+ * Format a stack trace for output
287
+ * @param {Object} stackTrace - CDP stack trace object
288
+ * @returns {Array|null} Formatted stack frames or null
289
+ */
290
+
291
+ export function formatStackTrace(stackTrace) {
292
+ if (!stackTrace || !stackTrace.callFrames) {
293
+ return null;
294
+ }
295
+
296
+ return stackTrace.callFrames.map(frame => ({
297
+ functionName: frame.functionName || '(anonymous)',
298
+ url: frame.url || null,
299
+ lineNumber: frame.lineNumber,
300
+ columnNumber: frame.columnNumber
301
+ }));
302
+ }
303
+
304
+ /**
305
+ * Execute a console step - retrieves browser console logs
306
+ *
307
+ * Note: Console logs are captured from the moment startCapture() is called
308
+ * (typically at session start). Logs do NOT persist across separate CLI invocations.
309
+ * Each invocation starts with an empty log buffer.
310
+ */
311
+
312
+ export async function executeConsole(consoleCapture, params) {
313
+ if (!consoleCapture) {
314
+ return { error: 'Console capture not available', messages: [] };
315
+ }
316
+
317
+ const limit = (typeof params === 'object' && params.limit) || 50;
318
+ const level = typeof params === 'object' ? params.level : null;
319
+ const type = typeof params === 'object' ? params.type : null;
320
+ const since = typeof params === 'object' ? params.since : null;
321
+ const clear = typeof params === 'object' && params.clear === true;
322
+ const includeStackTrace = typeof params === 'object' && params.stackTrace === true;
323
+
324
+ let messages;
325
+ // FR-036: Filter by type (console vs exception)
326
+ if (type) {
327
+ messages = consoleCapture.getMessagesByType(type);
328
+ } else if (level) {
329
+ messages = consoleCapture.getMessagesByLevel(level);
330
+ } else {
331
+ messages = consoleCapture.getMessages();
332
+ }
333
+
334
+ // FR-038: Filter by "since" timestamp
335
+ if (since) {
336
+ messages = messages.filter(m => m.timestamp >= since);
337
+ }
338
+
339
+ // Get the most recent messages up to limit
340
+ const recentMessages = messages.slice(-limit);
341
+
342
+ // Format messages for output
343
+ const formatted = recentMessages.map(m => {
344
+ const formatted = {
345
+ level: m.level,
346
+ text: m.text ? m.text.substring(0, 500) : '',
347
+ type: m.type,
348
+ url: m.url || null,
349
+ line: m.line || null,
350
+ timestamp: m.timestamp || null
351
+ };
352
+
353
+ // Include stack trace if requested
354
+ if (includeStackTrace && m.stackTrace) {
355
+ formatted.stackTrace = formatStackTrace(m.stackTrace);
356
+ }
357
+
358
+ return formatted;
359
+ });
360
+
361
+ if (clear) {
362
+ consoleCapture.clear();
363
+ }
364
+
365
+ return {
366
+ total: messages.length,
367
+ showing: formatted.length,
368
+ messages: formatted
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Execute a scroll step
374
+ */
375
+
376
+ export function formatCommandConsole(consoleCapture, messageCountBefore) {
377
+ if (!consoleCapture) return null;
378
+
379
+ const allMessages = consoleCapture.getMessages();
380
+ const newMessages = allMessages.slice(messageCountBefore);
381
+
382
+ // Filter to errors and warnings only
383
+ const relevant = newMessages.filter(m =>
384
+ m.level === 'error' || m.level === 'warning'
385
+ );
386
+
387
+ // Dedupe consecutive identical messages
388
+ const deduped = relevant.filter((m, i) =>
389
+ i === 0 || m.text !== relevant[i - 1].text
390
+ );
391
+
392
+ if (deduped.length === 0) return null;
393
+
394
+ return {
395
+ errors: deduped.filter(m => m.level === 'error').length,
396
+ warnings: deduped.filter(m => m.level === 'warning').length,
397
+ messages: deduped.map(m => ({
398
+ level: m.level,
399
+ text: m.text,
400
+ source: m.url ? `${m.url.split('/').pop()}:${m.line}` : undefined
401
+ }))
402
+ };
403
+ }
404
+
405
+ /**
406
+ * Run an array of test steps
407
+ * @param {Object} deps - Dependencies
408
+ * @param {Array<Object>} steps - Array of step definitions
409
+ * @param {Object} [options] - Execution options
410
+ * @param {boolean} [options.stopOnError=true] - Stop on first error
411
+ * @param {number} [options.stepTimeout=30000] - Timeout per step
412
+ * @returns {Promise<{status: string, steps: Array, errors: Array}>}
413
+ */
414
+ export async function runSteps(deps, steps, options = {}) {
415
+ const validation = validateSteps(steps);
416
+ if (!validation.valid) {
417
+ throw stepValidationError(validation.errors);
418
+ }
419
+
420
+ const stopOnError = options.stopOnError !== false;
421
+ const result = {
422
+ status: 'ok',
423
+ steps: [],
424
+ errors: []
425
+ };
426
+
427
+ // Capture console message count before command starts
428
+ const consoleCountBefore = deps.consoleCapture ? deps.consoleCapture.getMessages().length : 0;
429
+
430
+ // Feature 8.1: Capture BEFORE state at command start (for diff baseline)
431
+ let beforeUrl, beforeViewport, beforeSnapshot;
432
+ const contextCapture = deps.pageController ? createContextCapture(deps.pageController.session) : null;
433
+
434
+ if (deps.ariaSnapshot && contextCapture) {
435
+ try {
436
+ beforeUrl = await getCurrentUrl(deps.pageController.session);
437
+ // Capture viewport-only snapshot for command-level diff
438
+ beforeViewport = await deps.ariaSnapshot.generate({ mode: 'ai', viewportOnly: true });
439
+ } catch {
440
+ // Ignore initial snapshot errors - will just skip diff comparison
441
+ }
442
+ }
443
+
444
+ for (const step of steps) {
445
+ const stepResult = await executeStep(deps, step, options);
446
+ result.steps.push(stepResult);
447
+
448
+ if (stepResult.status === 'error') {
449
+ result.status = 'error';
450
+ result.errors.push({
451
+ step: result.steps.length,
452
+ action: stepResult.action,
453
+ error: stepResult.error
454
+ });
455
+
456
+ if (stopOnError) {
457
+ break;
458
+ }
459
+ }
460
+ // 'skipped' (optional) steps don't fail the run
461
+ }
462
+
463
+ // Wait for async console messages after steps complete
464
+ if (deps.consoleCapture) {
465
+ await sleep(250);
466
+ const consoleSummary = formatCommandConsole(deps.consoleCapture, consoleCountBefore);
467
+ if (consoleSummary) {
468
+ result.console = consoleSummary;
469
+ }
470
+ }
471
+
472
+ // Feature 8.1: Capture AFTER state and compute command-level diff
473
+ if (deps.ariaSnapshot && contextCapture && beforeViewport) {
474
+ try {
475
+ const afterUrl = await getCurrentUrl(deps.pageController.session);
476
+ const afterContext = await contextCapture.captureContext();
477
+
478
+ // Capture both viewport and full page snapshots
479
+ const afterViewport = await deps.ariaSnapshot.generate({ mode: 'ai', viewportOnly: true });
480
+ const afterFull = await deps.ariaSnapshot.generate({ mode: 'ai', viewportOnly: false });
481
+
482
+ const navigated = contextCapture.isNavigation(beforeUrl, afterUrl);
483
+
484
+ // Save full page snapshot to file (use tabAlias for filename)
485
+ const fullSnapshotPath = await resolveTempPath(`${options.tabAlias || 'command'}.after.yaml`, '.yaml');
486
+ await fs.writeFile(fullSnapshotPath, afterFull.yaml || '', 'utf8');
487
+
488
+ // Add command-level results
489
+ result.navigated = navigated;
490
+ result.fullSnapshot = fullSnapshotPath;
491
+ result.context = afterContext;
492
+
493
+ // Always include viewport snapshot inline
494
+ result.viewportSnapshot = afterViewport.yaml;
495
+ result.truncated = afterViewport.truncated || false;
496
+
497
+ // For same-page interactions, compute viewport diff
498
+ if (!navigated && beforeViewport?.yaml) {
499
+ const differ = createSnapshotDiffer();
500
+ const viewportDiff = differ.computeDiff(beforeViewport.yaml, afterViewport.yaml);
501
+
502
+ // Report changes if any significant changes found
503
+ if (differ.hasSignificantChanges(viewportDiff)) {
504
+ const actionContext = buildCommandContext(steps);
505
+ result.changes = differ.formatDiff(viewportDiff, { actionContext });
506
+ }
507
+ }
508
+ } catch (e) {
509
+ result.viewportSnapshotError = e.message;
510
+ }
511
+ }
512
+
513
+ return result;
514
+ }
515
+
516
+ /**
517
+ * Execute a validate step - query validation state of an element
518
+ */