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.
- package/README.md +3 -0
- package/SKILL.md +34 -5
- package/package.json +2 -1
- package/src/capture/console-capture.js +241 -0
- package/src/capture/debug-capture.js +144 -0
- package/src/capture/error-aggregator.js +151 -0
- package/src/capture/eval-serializer.js +320 -0
- package/src/capture/index.js +40 -0
- package/src/capture/network-capture.js +211 -0
- package/src/capture/pdf-capture.js +256 -0
- package/src/capture/screenshot-capture.js +325 -0
- package/src/cdp/browser.js +569 -0
- package/src/cdp/connection.js +369 -0
- package/src/cdp/discovery.js +138 -0
- package/src/cdp/index.js +29 -0
- package/src/cdp/target-and-session.js +439 -0
- package/src/cdp-skill.js +25 -11
- package/src/constants.js +79 -0
- package/src/dom/actionability.js +638 -0
- package/src/dom/click-executor.js +923 -0
- package/src/dom/element-handle.js +496 -0
- package/src/dom/element-locator.js +475 -0
- package/src/dom/element-validator.js +120 -0
- package/src/dom/fill-executor.js +489 -0
- package/src/dom/index.js +248 -0
- package/src/dom/input-emulator.js +406 -0
- package/src/dom/keyboard-executor.js +202 -0
- package/src/dom/quad-helpers.js +89 -0
- package/src/dom/react-filler.js +94 -0
- package/src/dom/wait-executor.js +423 -0
- package/src/index.js +6 -6
- package/src/page/cookie-manager.js +202 -0
- package/src/page/dom-stability.js +181 -0
- package/src/page/index.js +36 -0
- package/src/{page.js → page/page-controller.js} +109 -839
- package/src/page/wait-utilities.js +302 -0
- package/src/page/web-storage-manager.js +108 -0
- package/src/runner/context-helpers.js +224 -0
- package/src/runner/execute-browser.js +518 -0
- package/src/runner/execute-form.js +315 -0
- package/src/runner/execute-input.js +308 -0
- package/src/runner/execute-interaction.js +672 -0
- package/src/runner/execute-navigation.js +180 -0
- package/src/runner/execute-query.js +771 -0
- package/src/runner/index.js +51 -0
- package/src/runner/step-executors.js +421 -0
- package/src/runner/step-validator.js +641 -0
- package/src/tests/Actionability.test.js +613 -0
- package/src/tests/BrowserClient.test.js +1 -1
- package/src/tests/ChromeDiscovery.test.js +1 -1
- package/src/tests/ClickExecutor.test.js +554 -0
- package/src/tests/ConsoleCapture.test.js +1 -1
- package/src/tests/ContextHelpers.test.js +453 -0
- package/src/tests/CookieManager.test.js +450 -0
- package/src/tests/DebugCapture.test.js +307 -0
- package/src/tests/ElementHandle.test.js +1 -1
- package/src/tests/ElementLocator.test.js +1 -1
- package/src/tests/ErrorAggregator.test.js +1 -1
- package/src/tests/EvalSerializer.test.js +391 -0
- package/src/tests/FillExecutor.test.js +611 -0
- package/src/tests/InputEmulator.test.js +1 -1
- package/src/tests/KeyboardExecutor.test.js +430 -0
- package/src/tests/NetworkErrorCapture.test.js +1 -1
- package/src/tests/PageController.test.js +1 -1
- package/src/tests/PdfCapture.test.js +333 -0
- package/src/tests/ScreenshotCapture.test.js +1 -1
- package/src/tests/SessionRegistry.test.js +1 -1
- package/src/tests/StepValidator.test.js +527 -0
- package/src/tests/TargetManager.test.js +1 -1
- package/src/tests/TestRunner.test.js +1 -1
- package/src/tests/WaitStrategy.test.js +1 -1
- package/src/tests/WaitUtilities.test.js +508 -0
- package/src/tests/WebStorageManager.test.js +333 -0
- package/src/types.js +309 -0
- package/src/capture.js +0 -1400
- package/src/cdp.js +0 -1286
- package/src/dom.js +0 -4379
- 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
|
+
*/
|