@fasttest-ai/qa-agent 0.4.3 → 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/README.md +1 -27
- package/bin/qa-agent.js +4 -0
- package/dist/cli.js +33 -194
- package/dist/index.js +64 -1906
- package/dist/install.js +39 -570
- package/package.json +5 -2
- package/dist/actions.d.ts +0 -41
- package/dist/actions.js +0 -224
- package/dist/actions.js.map +0 -1
- package/dist/browser.d.ts +0 -77
- package/dist/browser.js +0 -312
- package/dist/browser.js.map +0 -1
- package/dist/cli.d.ts +0 -19
- package/dist/cli.js.map +0 -1
- package/dist/cloud.d.ts +0 -302
- package/dist/cloud.js +0 -261
- package/dist/cloud.js.map +0 -1
- package/dist/config.d.ts +0 -21
- package/dist/config.js +0 -49
- package/dist/config.js.map +0 -1
- package/dist/healer.d.ts +0 -32
- package/dist/healer.js +0 -316
- package/dist/healer.js.map +0 -1
- package/dist/index.d.ts +0 -13
- package/dist/index.js.map +0 -1
- package/dist/install.d.ts +0 -11
- package/dist/install.js.map +0 -1
- package/dist/runner.d.ts +0 -90
- package/dist/runner.js +0 -700
- package/dist/runner.js.map +0 -1
- package/dist/variables.d.ts +0 -30
- package/dist/variables.js +0 -104
- package/dist/variables.js.map +0 -1
package/dist/runner.js
DELETED
|
@@ -1,700 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test execution runner — executes test cases locally via Playwright
|
|
3
|
-
* and reports results back to the Cloud.
|
|
4
|
-
*
|
|
5
|
-
* Flow:
|
|
6
|
-
* 1. Cloud returns test cases (steps + assertions) from POST /run
|
|
7
|
-
* 2. Runner executes each test case sequentially in the browser
|
|
8
|
-
* 3. On selector-based failures, attempts self-healing
|
|
9
|
-
* 4. After each test case, POST results back to Cloud
|
|
10
|
-
* 5. When done, POST /complete to finalize the execution
|
|
11
|
-
*/
|
|
12
|
-
import * as actions from "./actions.js";
|
|
13
|
-
import { healSelector } from "./healer.js";
|
|
14
|
-
import { resolveString, resolveStepVariables, resolveAssertionVariables, collectVariableNames } from "./variables.js";
|
|
15
|
-
/**
|
|
16
|
-
* Execute a full test suite run.
|
|
17
|
-
*/
|
|
18
|
-
export async function executeRun(browserMgr, cloud, options, consoleLogs) {
|
|
19
|
-
// Apply device emulation (or reset to desktop when omitted)
|
|
20
|
-
await browserMgr.setDevice(options.device);
|
|
21
|
-
// 1. Ask Cloud to create execution + return test cases
|
|
22
|
-
const run = await cloud.startRun({
|
|
23
|
-
suite_id: options.suiteId,
|
|
24
|
-
environment_id: options.environmentId,
|
|
25
|
-
browser: "chromium",
|
|
26
|
-
test_case_ids: options.testCaseIds,
|
|
27
|
-
device: options.device,
|
|
28
|
-
});
|
|
29
|
-
const executionId = run.execution_id;
|
|
30
|
-
const testCases = run.test_cases;
|
|
31
|
-
// Resolve {{VAR}} placeholders in baseUrl
|
|
32
|
-
let baseUrl = options.appUrlOverride ?? run.base_url ?? "";
|
|
33
|
-
if (baseUrl) {
|
|
34
|
-
try {
|
|
35
|
-
baseUrl = resolveString(baseUrl);
|
|
36
|
-
}
|
|
37
|
-
catch (err) {
|
|
38
|
-
try {
|
|
39
|
-
await cloud.completeExecution(executionId);
|
|
40
|
-
}
|
|
41
|
-
catch { /* non-fatal */ }
|
|
42
|
-
return {
|
|
43
|
-
execution_id: executionId,
|
|
44
|
-
status: "failed",
|
|
45
|
-
total: testCases.length,
|
|
46
|
-
passed: 0,
|
|
47
|
-
failed: testCases.length,
|
|
48
|
-
skipped: 0,
|
|
49
|
-
duration_ms: 0,
|
|
50
|
-
results: testCases.map((tc) => ({
|
|
51
|
-
id: tc.id, name: tc.name, status: "failed",
|
|
52
|
-
duration_ms: 0, error: String(err), step_results: [],
|
|
53
|
-
})),
|
|
54
|
-
healed: [],
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
// Pre-flight: check that all {{VAR}} placeholders can be resolved
|
|
59
|
-
const allVarNames = [];
|
|
60
|
-
for (const tc of testCases) {
|
|
61
|
-
for (const v of collectVariableNames(tc.steps, tc.assertions)) {
|
|
62
|
-
if (!allVarNames.includes(v))
|
|
63
|
-
allVarNames.push(v);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
if (allVarNames.length > 0) {
|
|
67
|
-
const resolved = [];
|
|
68
|
-
const missing = [];
|
|
69
|
-
for (const v of allVarNames) {
|
|
70
|
-
if (process.env[v] !== undefined) {
|
|
71
|
-
resolved.push(v);
|
|
72
|
-
}
|
|
73
|
-
else {
|
|
74
|
-
missing.push(v);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (resolved.length > 0) {
|
|
78
|
-
process.stderr.write(`Environment variables resolved: ${resolved.join(", ")}\n`);
|
|
79
|
-
}
|
|
80
|
-
if (missing.length > 0) {
|
|
81
|
-
const errMsg = `Missing environment variable(s): ${missing.join(", ")}. ` +
|
|
82
|
-
`Set these before running tests. In GitHub Actions, add them as repository secrets.`;
|
|
83
|
-
process.stderr.write(`ERROR: ${errMsg}\n`);
|
|
84
|
-
try {
|
|
85
|
-
await cloud.completeExecution(executionId);
|
|
86
|
-
}
|
|
87
|
-
catch { /* non-fatal */ }
|
|
88
|
-
return {
|
|
89
|
-
execution_id: executionId,
|
|
90
|
-
status: "failed",
|
|
91
|
-
total: testCases.length,
|
|
92
|
-
passed: 0,
|
|
93
|
-
failed: testCases.length,
|
|
94
|
-
skipped: 0,
|
|
95
|
-
duration_ms: 0,
|
|
96
|
-
results: testCases.map((tc) => ({
|
|
97
|
-
id: tc.id, name: tc.name, status: "failed",
|
|
98
|
-
duration_ms: 0, error: errMsg, step_results: [],
|
|
99
|
-
})),
|
|
100
|
-
healed: [],
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
// 2. Order test cases: topo sort for dependencies, then fail-first
|
|
105
|
-
let sortedTestCases = topoSort(testCases);
|
|
106
|
-
if (run.previous_statuses) {
|
|
107
|
-
sortedTestCases = failFirst(sortedTestCases, run.previous_statuses);
|
|
108
|
-
}
|
|
109
|
-
const results = [];
|
|
110
|
-
const allHealed = [];
|
|
111
|
-
const runStart = Date.now();
|
|
112
|
-
let cancelled = false;
|
|
113
|
-
let reportFailures = 0;
|
|
114
|
-
const passedIds = new Set();
|
|
115
|
-
const runIdSet = new Set(sortedTestCases.map((tc) => tc.id));
|
|
116
|
-
// 3. Execute each test case sequentially (with smart retry)
|
|
117
|
-
for (const tc of sortedTestCases) {
|
|
118
|
-
// Skip if an in-run dependency failed (ignore external deps from other suites)
|
|
119
|
-
if (tc.depends_on && tc.depends_on.length > 0) {
|
|
120
|
-
const unmet = tc.depends_on.filter((depId) => runIdSet.has(depId) && !passedIds.has(depId));
|
|
121
|
-
if (unmet.length > 0) {
|
|
122
|
-
results.push({
|
|
123
|
-
id: tc.id,
|
|
124
|
-
name: tc.name,
|
|
125
|
-
status: "skipped",
|
|
126
|
-
duration_ms: 0,
|
|
127
|
-
error: `Skipped: dependency not met (${unmet.join(", ")})`,
|
|
128
|
-
step_results: [],
|
|
129
|
-
});
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
// Check for pause/cancel from dashboard
|
|
134
|
-
try {
|
|
135
|
-
const controlStatus = await cloud.checkControlStatus(executionId);
|
|
136
|
-
if (controlStatus === "cancelled") {
|
|
137
|
-
cancelled = true;
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
if (controlStatus === "paused") {
|
|
141
|
-
let resumed = false;
|
|
142
|
-
const pauseStart = Date.now();
|
|
143
|
-
const MAX_PAUSE_MS = 30 * 60 * 1000; // 30 minutes
|
|
144
|
-
while (!resumed) {
|
|
145
|
-
if (Date.now() - pauseStart > MAX_PAUSE_MS) {
|
|
146
|
-
process.stderr.write("Pause exceeded 30-minute limit, auto-cancelling.\n");
|
|
147
|
-
cancelled = true;
|
|
148
|
-
break;
|
|
149
|
-
}
|
|
150
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
151
|
-
const s = await cloud.checkControlStatus(executionId);
|
|
152
|
-
if (s === "running") {
|
|
153
|
-
resumed = true;
|
|
154
|
-
}
|
|
155
|
-
if (s === "cancelled") {
|
|
156
|
-
cancelled = true;
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
if (cancelled)
|
|
161
|
-
break;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
// Non-fatal — if we can't check, continue executing
|
|
166
|
-
}
|
|
167
|
-
// Smart retry loop
|
|
168
|
-
const maxRetries = tc.retry_count ?? 0;
|
|
169
|
-
let tcResult;
|
|
170
|
-
let attempt = 0;
|
|
171
|
-
// Notify War Room that this test case is starting (once, before retries)
|
|
172
|
-
await cloud.notifyTestStarted(executionId, tc.id, tc.name);
|
|
173
|
-
while (true) {
|
|
174
|
-
const timeoutMs = (tc.timeout_seconds || 30) * 1000;
|
|
175
|
-
let timerId;
|
|
176
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
177
|
-
timerId = setTimeout(() => reject(new Error(`Test case "${tc.name}" timed out after ${tc.timeout_seconds || 30}s`)), timeoutMs);
|
|
178
|
-
});
|
|
179
|
-
tcResult = await Promise.race([
|
|
180
|
-
executeTestCase(browserMgr, cloud, executionId, tc, baseUrl, consoleLogs, allHealed, options.aiFallback),
|
|
181
|
-
timeoutPromise,
|
|
182
|
-
]).finally(() => clearTimeout(timerId)).catch((err) => ({
|
|
183
|
-
id: tc.id,
|
|
184
|
-
name: tc.name,
|
|
185
|
-
status: "failed",
|
|
186
|
-
duration_ms: timeoutMs,
|
|
187
|
-
error: String(err),
|
|
188
|
-
step_results: [],
|
|
189
|
-
}));
|
|
190
|
-
if (tcResult.status === "passed" || attempt >= maxRetries) {
|
|
191
|
-
break;
|
|
192
|
-
}
|
|
193
|
-
// Failed but have retries left
|
|
194
|
-
attempt++;
|
|
195
|
-
process.stderr.write(`Retrying ${tc.name} (attempt ${attempt}/${maxRetries})...\n`);
|
|
196
|
-
}
|
|
197
|
-
tcResult.retry_attempts = attempt;
|
|
198
|
-
if (tcResult.status === "passed")
|
|
199
|
-
passedIds.add(tc.id);
|
|
200
|
-
results.push(tcResult);
|
|
201
|
-
// Capture network calls for this test case and reset for next one
|
|
202
|
-
const rawNetwork = browserMgr.getNetworkSummary();
|
|
203
|
-
browserMgr.clearNetworkEntries();
|
|
204
|
-
const networkSummary = filterNetworkEntries(rawNetwork);
|
|
205
|
-
// 4. Report result to Cloud
|
|
206
|
-
try {
|
|
207
|
-
await cloud.reportResult(executionId, {
|
|
208
|
-
test_case_id: tc.id,
|
|
209
|
-
status: tcResult.status,
|
|
210
|
-
duration_ms: tcResult.duration_ms,
|
|
211
|
-
error_message: tcResult.error,
|
|
212
|
-
console_logs: consoleLogs.slice(-50),
|
|
213
|
-
retry_attempt: attempt,
|
|
214
|
-
step_results: tcResult.step_results.map((sr) => ({
|
|
215
|
-
step_index: sr.step_index,
|
|
216
|
-
action: sr.action,
|
|
217
|
-
success: sr.success,
|
|
218
|
-
error: sr.error,
|
|
219
|
-
duration_ms: sr.duration_ms,
|
|
220
|
-
screenshot_url: sr.screenshot_url,
|
|
221
|
-
healed: sr.healed,
|
|
222
|
-
heal_details: sr.heal_details,
|
|
223
|
-
})),
|
|
224
|
-
network_summary: networkSummary.length > 0 ? networkSummary : undefined,
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
catch (err) {
|
|
228
|
-
// Non-fatal — log and continue
|
|
229
|
-
reportFailures++;
|
|
230
|
-
process.stderr.write(`Failed to report result for ${tc.name}: ${err}\n`);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
// Mark any unexecuted test cases as skipped (e.g. due to cancellation)
|
|
234
|
-
const completedIds = new Set(results.map((r) => r.id));
|
|
235
|
-
for (const tc of testCases) {
|
|
236
|
-
if (!completedIds.has(tc.id)) {
|
|
237
|
-
results.push({
|
|
238
|
-
id: tc.id,
|
|
239
|
-
name: tc.name,
|
|
240
|
-
status: "skipped",
|
|
241
|
-
duration_ms: 0,
|
|
242
|
-
step_results: [],
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
// 4. Auto-update healed selectors in saved test cases (>= 90% confidence)
|
|
247
|
-
const AUTO_HEAL_THRESHOLD = 0.90;
|
|
248
|
-
if (allHealed.length > 0) {
|
|
249
|
-
// Deduplicate: only one update per (test_case_id, original_selector) pair
|
|
250
|
-
const seen = new Set();
|
|
251
|
-
for (const h of allHealed) {
|
|
252
|
-
if (h.confidence < AUTO_HEAL_THRESHOLD)
|
|
253
|
-
continue;
|
|
254
|
-
const key = `${h.test_case_id}:${h.original_selector}`;
|
|
255
|
-
if (seen.has(key))
|
|
256
|
-
continue;
|
|
257
|
-
seen.add(key);
|
|
258
|
-
try {
|
|
259
|
-
await cloud.applyHealing(h.test_case_id, h.original_selector, h.new_selector);
|
|
260
|
-
process.stderr.write(`Auto-updated selector in "${h.test_case}": ${h.original_selector} → ${h.new_selector}\n`);
|
|
261
|
-
}
|
|
262
|
-
catch {
|
|
263
|
-
// Non-fatal — selector update failure shouldn't block run completion
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
// 5. Complete execution
|
|
268
|
-
const passed = results.filter((r) => r.status === "passed").length;
|
|
269
|
-
const failed = results.filter((r) => r.status === "failed").length;
|
|
270
|
-
const skipped = results.filter((r) => r.status === "skipped").length;
|
|
271
|
-
const durationMs = Date.now() - runStart;
|
|
272
|
-
try {
|
|
273
|
-
await cloud.completeExecution(executionId, cancelled ? "cancelled" : undefined);
|
|
274
|
-
}
|
|
275
|
-
catch (err) {
|
|
276
|
-
process.stderr.write(`Failed to complete execution: ${err}\n`);
|
|
277
|
-
}
|
|
278
|
-
if (reportFailures > 0) {
|
|
279
|
-
process.stderr.write(`Warning: ${reportFailures} result report(s) failed to send to cloud.\n`);
|
|
280
|
-
}
|
|
281
|
-
// Build AI fallback context from the first failure with diagnostic info
|
|
282
|
-
let aiFallback;
|
|
283
|
-
if (options.aiFallback) {
|
|
284
|
-
for (const r of results) {
|
|
285
|
-
if (r.status !== "failed")
|
|
286
|
-
continue;
|
|
287
|
-
const failedStep = r.step_results.find((sr) => !sr.success && sr.ai_context);
|
|
288
|
-
if (failedStep?.ai_context) {
|
|
289
|
-
// Find the original step data from the test case
|
|
290
|
-
const tc = sortedTestCases.find((t) => t.id === r.id);
|
|
291
|
-
const stepData = tc?.steps[failedStep.step_index] ?? {};
|
|
292
|
-
aiFallback = {
|
|
293
|
-
test_case_id: r.id,
|
|
294
|
-
test_case_name: r.name,
|
|
295
|
-
step_index: failedStep.step_index,
|
|
296
|
-
step: stepData,
|
|
297
|
-
intent: failedStep.ai_context.intent,
|
|
298
|
-
error: failedStep.error ?? r.error ?? "Unknown error",
|
|
299
|
-
page_url: failedStep.ai_context.page_url,
|
|
300
|
-
snapshot: failedStep.ai_context.snapshot,
|
|
301
|
-
};
|
|
302
|
-
break;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
return {
|
|
307
|
-
execution_id: executionId,
|
|
308
|
-
status: cancelled ? "cancelled" : (failed === 0 ? "passed" : "failed"),
|
|
309
|
-
total: testCases.length,
|
|
310
|
-
passed,
|
|
311
|
-
failed,
|
|
312
|
-
skipped,
|
|
313
|
-
duration_ms: durationMs,
|
|
314
|
-
results,
|
|
315
|
-
healed: allHealed,
|
|
316
|
-
ai_fallback: aiFallback,
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
/**
|
|
320
|
-
* Execute a single test case.
|
|
321
|
-
*/
|
|
322
|
-
async function executeTestCase(browserMgr, cloud, executionId, tc, baseUrl, consoleLogs, allHealed, captureAiContext) {
|
|
323
|
-
const stepResults = [];
|
|
324
|
-
const start = Date.now();
|
|
325
|
-
try {
|
|
326
|
-
// Fresh context per test case to prevent session/cookie leakage
|
|
327
|
-
const page = await browserMgr.newContext();
|
|
328
|
-
// Execute steps
|
|
329
|
-
for (let i = 0; i < tc.steps.length; i++) {
|
|
330
|
-
const step = tc.steps[i];
|
|
331
|
-
const stepStart = Date.now();
|
|
332
|
-
// Resolve {{VAR}} placeholders from environment
|
|
333
|
-
let resolvedStep;
|
|
334
|
-
try {
|
|
335
|
-
resolvedStep = resolveStepVariables(step);
|
|
336
|
-
}
|
|
337
|
-
catch (err) {
|
|
338
|
-
stepResults.push({
|
|
339
|
-
step_index: i, action: step.action, success: false,
|
|
340
|
-
error: String(err), duration_ms: Date.now() - stepStart,
|
|
341
|
-
});
|
|
342
|
-
return {
|
|
343
|
-
id: tc.id, name: tc.name, status: "failed",
|
|
344
|
-
duration_ms: Date.now() - start,
|
|
345
|
-
error: `Step ${i + 1} (${step.action}) failed: ${String(err)}`,
|
|
346
|
-
step_results: stepResults,
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
let result = await executeStep(page, resolvedStep, baseUrl);
|
|
350
|
-
// Self-healing: if step failed and has a selector, try healing
|
|
351
|
-
if (!result.success && resolvedStep.selector && isSelectorFailure(result.error)) {
|
|
352
|
-
// Notify War Room that healing is starting
|
|
353
|
-
await cloud.notifyHealingStarted(executionId, tc.id, resolvedStep.selector);
|
|
354
|
-
const healResult = await healSelector(page, cloud, resolvedStep.selector, classifyStepError(result.error), result.error ?? "unknown", page.url(), { action: resolvedStep.action, description: resolvedStep.description, intent: resolvedStep.intent });
|
|
355
|
-
if (healResult.healed && healResult.newSelector) {
|
|
356
|
-
// Retry with the healed selector
|
|
357
|
-
const healedStep = { ...resolvedStep, selector: healResult.newSelector };
|
|
358
|
-
result = await executeStep(page, healedStep, baseUrl);
|
|
359
|
-
if (result.success) {
|
|
360
|
-
allHealed.push({
|
|
361
|
-
test_case_id: tc.id,
|
|
362
|
-
test_case: tc.name,
|
|
363
|
-
step_index: i,
|
|
364
|
-
original_selector: step.selector,
|
|
365
|
-
new_selector: healResult.newSelector,
|
|
366
|
-
strategy: healResult.strategy ?? "unknown",
|
|
367
|
-
confidence: healResult.confidence ?? 0,
|
|
368
|
-
});
|
|
369
|
-
// Capture screenshot after healed step
|
|
370
|
-
const capture = await captureStepScreenshot(page);
|
|
371
|
-
stepResults.push({
|
|
372
|
-
step_index: i,
|
|
373
|
-
action: step.action,
|
|
374
|
-
success: true,
|
|
375
|
-
duration_ms: Date.now() - stepStart,
|
|
376
|
-
screenshot_url: capture?.dataUrl,
|
|
377
|
-
healed: true,
|
|
378
|
-
heal_details: {
|
|
379
|
-
original_selector: step.selector,
|
|
380
|
-
new_selector: healResult.newSelector,
|
|
381
|
-
strategy: healResult.strategy ?? "unknown",
|
|
382
|
-
confidence: healResult.confidence ?? 0,
|
|
383
|
-
},
|
|
384
|
-
});
|
|
385
|
-
continue;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
// Capture screenshot after each step (especially important on failure for debugging)
|
|
390
|
-
const capture = await captureStepScreenshot(page);
|
|
391
|
-
// On failure, capture diagnostic snapshot for AI fallback (only when enabled)
|
|
392
|
-
let aiContext;
|
|
393
|
-
if (!result.success && captureAiContext) {
|
|
394
|
-
try {
|
|
395
|
-
const snapshot = await actions.getSnapshot(page);
|
|
396
|
-
aiContext = {
|
|
397
|
-
intent: resolvedStep.intent ?? resolvedStep.description,
|
|
398
|
-
page_url: page.url(),
|
|
399
|
-
snapshot,
|
|
400
|
-
};
|
|
401
|
-
}
|
|
402
|
-
catch {
|
|
403
|
-
// Non-fatal — snapshot may fail if page crashed
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
stepResults.push({
|
|
407
|
-
step_index: i,
|
|
408
|
-
action: step.action,
|
|
409
|
-
success: result.success,
|
|
410
|
-
error: result.error,
|
|
411
|
-
duration_ms: Date.now() - stepStart,
|
|
412
|
-
screenshot_url: capture?.dataUrl,
|
|
413
|
-
ai_context: aiContext,
|
|
414
|
-
});
|
|
415
|
-
if (!result.success) {
|
|
416
|
-
return {
|
|
417
|
-
id: tc.id,
|
|
418
|
-
name: tc.name,
|
|
419
|
-
status: "failed",
|
|
420
|
-
duration_ms: Date.now() - start,
|
|
421
|
-
error: `Step ${i + 1} (${step.action}) failed: ${result.error}`,
|
|
422
|
-
step_results: stepResults,
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
// Execute assertions
|
|
427
|
-
for (let i = 0; i < tc.assertions.length; i++) {
|
|
428
|
-
const assertion = tc.assertions[i];
|
|
429
|
-
const assertStart = Date.now();
|
|
430
|
-
// Resolve {{VAR}} placeholders in assertion values
|
|
431
|
-
let resolvedAssertion;
|
|
432
|
-
try {
|
|
433
|
-
resolvedAssertion = resolveAssertionVariables(assertion);
|
|
434
|
-
}
|
|
435
|
-
catch (err) {
|
|
436
|
-
stepResults.push({
|
|
437
|
-
step_index: tc.steps.length + i, action: `assert:${assertion.type}`,
|
|
438
|
-
success: false, error: String(err), duration_ms: Date.now() - assertStart,
|
|
439
|
-
});
|
|
440
|
-
return {
|
|
441
|
-
id: tc.id, name: tc.name, status: "failed",
|
|
442
|
-
duration_ms: Date.now() - start,
|
|
443
|
-
error: `Assertion ${i + 1} (${assertion.type}) failed: ${String(err)}`,
|
|
444
|
-
step_results: stepResults,
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
const result = await executeAssertion(page, resolvedAssertion);
|
|
448
|
-
stepResults.push({
|
|
449
|
-
step_index: tc.steps.length + i,
|
|
450
|
-
action: `assert:${assertion.type}`,
|
|
451
|
-
success: result.pass,
|
|
452
|
-
error: result.error,
|
|
453
|
-
duration_ms: Date.now() - assertStart,
|
|
454
|
-
});
|
|
455
|
-
if (!result.pass) {
|
|
456
|
-
return {
|
|
457
|
-
id: tc.id,
|
|
458
|
-
name: tc.name,
|
|
459
|
-
status: "failed",
|
|
460
|
-
duration_ms: Date.now() - start,
|
|
461
|
-
error: `Assertion ${i + 1} (${assertion.type}) failed: ${result.error ?? "expected value mismatch"}`,
|
|
462
|
-
step_results: stepResults,
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
return {
|
|
467
|
-
id: tc.id,
|
|
468
|
-
name: tc.name,
|
|
469
|
-
status: "passed",
|
|
470
|
-
duration_ms: Date.now() - start,
|
|
471
|
-
step_results: stepResults,
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
catch (err) {
|
|
475
|
-
return {
|
|
476
|
-
id: tc.id,
|
|
477
|
-
name: tc.name,
|
|
478
|
-
status: "failed",
|
|
479
|
-
duration_ms: Date.now() - start,
|
|
480
|
-
error: String(err),
|
|
481
|
-
step_results: stepResults,
|
|
482
|
-
};
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
/**
|
|
486
|
-
* Capture a screenshot after a test step.
|
|
487
|
-
* Returns { dataUrl } or undefined if capture fails.
|
|
488
|
-
*/
|
|
489
|
-
async function captureStepScreenshot(page) {
|
|
490
|
-
try {
|
|
491
|
-
const b64 = await actions.screenshot(page, false);
|
|
492
|
-
return { dataUrl: `data:image/jpeg;base64,${b64}` };
|
|
493
|
-
}
|
|
494
|
-
catch {
|
|
495
|
-
return undefined;
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
/**
|
|
499
|
-
* Execute a single test step via Playwright.
|
|
500
|
-
*/
|
|
501
|
-
async function executeStep(page, step, baseUrl) {
|
|
502
|
-
const action = step.action;
|
|
503
|
-
try {
|
|
504
|
-
switch (action) {
|
|
505
|
-
case "navigate": {
|
|
506
|
-
let url = step.url ?? step.value ?? "";
|
|
507
|
-
if (url && !url.startsWith("http")) {
|
|
508
|
-
url = baseUrl.replace(/\/$/, "") + url;
|
|
509
|
-
}
|
|
510
|
-
return await actions.navigate(page, url);
|
|
511
|
-
}
|
|
512
|
-
case "click":
|
|
513
|
-
return await actions.click(page, step.selector ?? "");
|
|
514
|
-
case "type":
|
|
515
|
-
case "fill":
|
|
516
|
-
return await actions.fill(page, step.selector ?? "", step.value ?? "");
|
|
517
|
-
case "fill_form": {
|
|
518
|
-
const fields = step.fields ?? {};
|
|
519
|
-
return await actions.fillForm(page, fields);
|
|
520
|
-
}
|
|
521
|
-
case "drag":
|
|
522
|
-
return await actions.drag(page, step.selector ?? "", step.target ?? "");
|
|
523
|
-
case "resize":
|
|
524
|
-
return await actions.resize(page, step.width ?? 1280, step.height ?? 720);
|
|
525
|
-
case "hover":
|
|
526
|
-
return await actions.hover(page, step.selector ?? "");
|
|
527
|
-
case "select":
|
|
528
|
-
return await actions.selectOption(page, step.selector ?? "", step.value ?? "");
|
|
529
|
-
case "wait_for": {
|
|
530
|
-
if (step.condition === "navigation") {
|
|
531
|
-
await page.waitForLoadState("domcontentloaded", { timeout: (step.timeout ?? 10) * 1000 });
|
|
532
|
-
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => { });
|
|
533
|
-
return { success: true };
|
|
534
|
-
}
|
|
535
|
-
return await actions.waitFor(page, step.selector ?? "", (step.timeout ?? 10) * 1000);
|
|
536
|
-
}
|
|
537
|
-
case "scroll": {
|
|
538
|
-
if (step.selector) {
|
|
539
|
-
await page.locator(step.selector).scrollIntoViewIfNeeded();
|
|
540
|
-
}
|
|
541
|
-
else {
|
|
542
|
-
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
543
|
-
}
|
|
544
|
-
return { success: true };
|
|
545
|
-
}
|
|
546
|
-
case "press_key":
|
|
547
|
-
return await actions.pressKey(page, step.key ?? step.value ?? "Enter");
|
|
548
|
-
case "upload_file": {
|
|
549
|
-
const filePaths = step.file_paths ?? (step.value ? [step.value] : null);
|
|
550
|
-
if (!filePaths || filePaths.length === 0) {
|
|
551
|
-
return { success: false, error: "upload_file step missing file_paths" };
|
|
552
|
-
}
|
|
553
|
-
return await actions.uploadFile(page, step.selector ?? "", filePaths);
|
|
554
|
-
}
|
|
555
|
-
case "evaluate":
|
|
556
|
-
return await actions.evaluate(page, step.expression ?? step.value ?? "");
|
|
557
|
-
case "go_back":
|
|
558
|
-
return await actions.goBack(page);
|
|
559
|
-
case "go_forward":
|
|
560
|
-
return await actions.goForward(page);
|
|
561
|
-
case "assert":
|
|
562
|
-
// Step-level assertion — delegate to assertPage
|
|
563
|
-
return executeAssertion(page, step).then((r) => ({
|
|
564
|
-
success: r.pass,
|
|
565
|
-
error: r.error,
|
|
566
|
-
}));
|
|
567
|
-
default:
|
|
568
|
-
return { success: false, error: `Unknown action: ${action}` };
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
catch (err) {
|
|
572
|
-
return { success: false, error: String(err) };
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
/**
|
|
576
|
-
* Execute an assertion.
|
|
577
|
-
*/
|
|
578
|
-
async function executeAssertion(page, assertion) {
|
|
579
|
-
return actions.assertPage(page, {
|
|
580
|
-
type: assertion.type,
|
|
581
|
-
selector: assertion.selector,
|
|
582
|
-
text: assertion.text ?? assertion.expected_value,
|
|
583
|
-
url: assertion.url,
|
|
584
|
-
count: assertion.count,
|
|
585
|
-
attribute: assertion.attribute,
|
|
586
|
-
value: assertion.value ?? assertion.expected_value,
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
// ---------------------------------------------------------------------------
|
|
590
|
-
// Helpers
|
|
591
|
-
// ---------------------------------------------------------------------------
|
|
592
|
-
/** Detect if a step error is selector-related (element not found / timeout). */
|
|
593
|
-
function isSelectorFailure(error) {
|
|
594
|
-
if (!error)
|
|
595
|
-
return false;
|
|
596
|
-
const lower = error.toLowerCase();
|
|
597
|
-
return (lower.includes("selector") ||
|
|
598
|
-
lower.includes("not found") ||
|
|
599
|
-
lower.includes("waiting for selector") ||
|
|
600
|
-
lower.includes("no element") ||
|
|
601
|
-
lower.includes("timeout") ||
|
|
602
|
-
lower.includes("locator"));
|
|
603
|
-
}
|
|
604
|
-
/** Map a step error message to a failure type for the healing service. */
|
|
605
|
-
function classifyStepError(error) {
|
|
606
|
-
if (!error)
|
|
607
|
-
return "UNKNOWN";
|
|
608
|
-
const lower = error.toLowerCase();
|
|
609
|
-
if (lower.includes("timeout"))
|
|
610
|
-
return "TIMEOUT";
|
|
611
|
-
if (lower.includes("not found") || lower.includes("no element") || lower.includes("selector")) {
|
|
612
|
-
return "ELEMENT_NOT_FOUND";
|
|
613
|
-
}
|
|
614
|
-
if (lower.includes("navigation") || lower.includes("net::"))
|
|
615
|
-
return "NAVIGATION_FAILED";
|
|
616
|
-
return "UNKNOWN";
|
|
617
|
-
}
|
|
618
|
-
/** Keep only API/document calls — skip static assets like images, fonts, CSS, JS bundles. */
|
|
619
|
-
function filterNetworkEntries(entries) {
|
|
620
|
-
return entries.filter((e) => {
|
|
621
|
-
const mime = e.mimeType.toLowerCase();
|
|
622
|
-
if (mime.includes("json") || mime.includes("text/html") || mime.includes("text/plain"))
|
|
623
|
-
return true;
|
|
624
|
-
if (e.status >= 400)
|
|
625
|
-
return true;
|
|
626
|
-
return false;
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
/**
|
|
630
|
-
* Reorder test cases so previously-failed ones run first (faster feedback).
|
|
631
|
-
* Preserves relative order within each group. Respects dependency constraints
|
|
632
|
-
* by not moving a test before its dependencies.
|
|
633
|
-
*/
|
|
634
|
-
function failFirst(testCases, previousStatuses) {
|
|
635
|
-
const idSet = new Set(testCases.map((tc) => tc.id));
|
|
636
|
-
const depSet = new Set();
|
|
637
|
-
for (const tc of testCases) {
|
|
638
|
-
if (tc.depends_on) {
|
|
639
|
-
for (const dep of tc.depends_on)
|
|
640
|
-
depSet.add(dep);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
const failed = [];
|
|
644
|
-
const rest = [];
|
|
645
|
-
for (const tc of testCases) {
|
|
646
|
-
const prev = previousStatuses[tc.id];
|
|
647
|
-
// Move to front if previously failed, not a dependency of other tests,
|
|
648
|
-
// and doesn't itself have in-run dependencies (which would need to run first)
|
|
649
|
-
const hasInRunDeps = tc.depends_on?.some((d) => idSet.has(d)) ?? false;
|
|
650
|
-
if (prev === "failed" && !depSet.has(tc.id) && !hasInRunDeps) {
|
|
651
|
-
failed.push(tc);
|
|
652
|
-
}
|
|
653
|
-
else {
|
|
654
|
-
rest.push(tc);
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
return [...failed, ...rest];
|
|
658
|
-
}
|
|
659
|
-
/**
|
|
660
|
-
* Topological sort of test cases based on depends_on.
|
|
661
|
-
* Falls back to original order if no dependencies or on cycles.
|
|
662
|
-
*/
|
|
663
|
-
function topoSort(testCases) {
|
|
664
|
-
const idSet = new Set(testCases.map((tc) => tc.id));
|
|
665
|
-
const hasDeps = testCases.some((tc) => tc.depends_on && tc.depends_on.some((d) => idSet.has(d)));
|
|
666
|
-
if (!hasDeps)
|
|
667
|
-
return testCases;
|
|
668
|
-
const map = new Map(testCases.map((tc) => [tc.id, tc]));
|
|
669
|
-
const visited = new Set();
|
|
670
|
-
const visiting = new Set();
|
|
671
|
-
const sorted = [];
|
|
672
|
-
function visit(id) {
|
|
673
|
-
if (visited.has(id))
|
|
674
|
-
return true;
|
|
675
|
-
if (visiting.has(id))
|
|
676
|
-
return false; // cycle
|
|
677
|
-
visiting.add(id);
|
|
678
|
-
const tc = map.get(id);
|
|
679
|
-
if (tc?.depends_on) {
|
|
680
|
-
for (const dep of tc.depends_on) {
|
|
681
|
-
if (idSet.has(dep) && !visit(dep))
|
|
682
|
-
return false;
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
visiting.delete(id);
|
|
686
|
-
visited.add(id);
|
|
687
|
-
if (tc)
|
|
688
|
-
sorted.push(tc);
|
|
689
|
-
return true;
|
|
690
|
-
}
|
|
691
|
-
for (const tc of testCases) {
|
|
692
|
-
if (!visit(tc.id)) {
|
|
693
|
-
// Cycle detected — fall back to original order
|
|
694
|
-
process.stderr.write("Warning: dependency cycle detected, using original test order.\n");
|
|
695
|
-
return testCases;
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
return sorted;
|
|
699
|
-
}
|
|
700
|
-
//# sourceMappingURL=runner.js.map
|