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