@tangle-network/browser-agent-driver 0.6.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.
Files changed (208) hide show
  1. package/LICENSE +196 -0
  2. package/README.md +149 -0
  3. package/dist/artifacts/composite-sink.d.ts +15 -0
  4. package/dist/artifacts/composite-sink.d.ts.map +1 -0
  5. package/dist/artifacts/composite-sink.js +27 -0
  6. package/dist/artifacts/composite-sink.js.map +1 -0
  7. package/dist/artifacts/filesystem-sink.d.ts +26 -0
  8. package/dist/artifacts/filesystem-sink.d.ts.map +1 -0
  9. package/dist/artifacts/filesystem-sink.js +81 -0
  10. package/dist/artifacts/filesystem-sink.js.map +1 -0
  11. package/dist/artifacts/types.d.ts +97 -0
  12. package/dist/artifacts/types.d.ts.map +1 -0
  13. package/dist/artifacts/types.js +9 -0
  14. package/dist/artifacts/types.js.map +1 -0
  15. package/dist/artifacts/webhook-sink.d.ts +71 -0
  16. package/dist/artifacts/webhook-sink.d.ts.map +1 -0
  17. package/dist/artifacts/webhook-sink.js +117 -0
  18. package/dist/artifacts/webhook-sink.js.map +1 -0
  19. package/dist/brain/index.d.ts +149 -0
  20. package/dist/brain/index.d.ts.map +1 -0
  21. package/dist/brain/index.js +1030 -0
  22. package/dist/brain/index.js.map +1 -0
  23. package/dist/browser-launch.d.ts +27 -0
  24. package/dist/browser-launch.d.ts.map +1 -0
  25. package/dist/browser-launch.js +92 -0
  26. package/dist/browser-launch.js.map +1 -0
  27. package/dist/cli.d.ts +14 -0
  28. package/dist/cli.d.ts.map +1 -0
  29. package/dist/cli.js +1035 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/config.d.ts +143 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +205 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/context-budget.d.ts +14 -0
  36. package/dist/context-budget.d.ts.map +1 -0
  37. package/dist/context-budget.js +39 -0
  38. package/dist/context-budget.js.map +1 -0
  39. package/dist/design-audit.d.ts +48 -0
  40. package/dist/design-audit.d.ts.map +1 -0
  41. package/dist/design-audit.js +172 -0
  42. package/dist/design-audit.js.map +1 -0
  43. package/dist/domain-policy.d.ts +4 -0
  44. package/dist/domain-policy.d.ts.map +1 -0
  45. package/dist/domain-policy.js +69 -0
  46. package/dist/domain-policy.js.map +1 -0
  47. package/dist/drivers/block-patterns.d.ts +11 -0
  48. package/dist/drivers/block-patterns.d.ts.map +1 -0
  49. package/dist/drivers/block-patterns.js +123 -0
  50. package/dist/drivers/block-patterns.js.map +1 -0
  51. package/dist/drivers/cdp-page-state.d.ts +22 -0
  52. package/dist/drivers/cdp-page-state.d.ts.map +1 -0
  53. package/dist/drivers/cdp-page-state.js +38 -0
  54. package/dist/drivers/cdp-page-state.js.map +1 -0
  55. package/dist/drivers/cdp-snapshot.d.ts +32 -0
  56. package/dist/drivers/cdp-snapshot.d.ts.map +1 -0
  57. package/dist/drivers/cdp-snapshot.js +120 -0
  58. package/dist/drivers/cdp-snapshot.js.map +1 -0
  59. package/dist/drivers/playwright.d.ts +84 -0
  60. package/dist/drivers/playwright.d.ts.map +1 -0
  61. package/dist/drivers/playwright.js +385 -0
  62. package/dist/drivers/playwright.js.map +1 -0
  63. package/dist/drivers/snapshot.d.ts +134 -0
  64. package/dist/drivers/snapshot.d.ts.map +1 -0
  65. package/dist/drivers/snapshot.js +354 -0
  66. package/dist/drivers/snapshot.js.map +1 -0
  67. package/dist/drivers/types.d.ts +37 -0
  68. package/dist/drivers/types.d.ts.map +1 -0
  69. package/dist/drivers/types.js +5 -0
  70. package/dist/drivers/types.js.map +1 -0
  71. package/dist/env-loader.d.ts +2 -0
  72. package/dist/env-loader.d.ts.map +1 -0
  73. package/dist/env-loader.js +35 -0
  74. package/dist/env-loader.js.map +1 -0
  75. package/dist/formatters/slack.d.ts +37 -0
  76. package/dist/formatters/slack.d.ts.map +1 -0
  77. package/dist/formatters/slack.js +97 -0
  78. package/dist/formatters/slack.js.map +1 -0
  79. package/dist/index.d.ts +60 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +53 -0
  82. package/dist/index.js.map +1 -0
  83. package/dist/memory/analyzer.d.ts +67 -0
  84. package/dist/memory/analyzer.d.ts.map +1 -0
  85. package/dist/memory/analyzer.js +265 -0
  86. package/dist/memory/analyzer.js.map +1 -0
  87. package/dist/memory/knowledge.d.ts +55 -0
  88. package/dist/memory/knowledge.d.ts.map +1 -0
  89. package/dist/memory/knowledge.js +115 -0
  90. package/dist/memory/knowledge.js.map +1 -0
  91. package/dist/memory/project-store.d.ts +43 -0
  92. package/dist/memory/project-store.d.ts.map +1 -0
  93. package/dist/memory/project-store.js +119 -0
  94. package/dist/memory/project-store.js.map +1 -0
  95. package/dist/memory/selectors.d.ts +46 -0
  96. package/dist/memory/selectors.d.ts.map +1 -0
  97. package/dist/memory/selectors.js +89 -0
  98. package/dist/memory/selectors.js.map +1 -0
  99. package/dist/memory/store.d.ts +39 -0
  100. package/dist/memory/store.d.ts.map +1 -0
  101. package/dist/memory/store.js +193 -0
  102. package/dist/memory/store.js.map +1 -0
  103. package/dist/model-pricing.d.ts +41 -0
  104. package/dist/model-pricing.d.ts.map +1 -0
  105. package/dist/model-pricing.js +197 -0
  106. package/dist/model-pricing.js.map +1 -0
  107. package/dist/multi-actor.d.ts +108 -0
  108. package/dist/multi-actor.d.ts.map +1 -0
  109. package/dist/multi-actor.js +192 -0
  110. package/dist/multi-actor.js.map +1 -0
  111. package/dist/override-pipeline.d.ts +57 -0
  112. package/dist/override-pipeline.d.ts.map +1 -0
  113. package/dist/override-pipeline.js +26 -0
  114. package/dist/override-pipeline.js.map +1 -0
  115. package/dist/personas.d.ts +19 -0
  116. package/dist/personas.d.ts.map +1 -0
  117. package/dist/personas.js +86 -0
  118. package/dist/personas.js.map +1 -0
  119. package/dist/preview.d.ts +32 -0
  120. package/dist/preview.d.ts.map +1 -0
  121. package/dist/preview.js +138 -0
  122. package/dist/preview.js.map +1 -0
  123. package/dist/provider-defaults.d.ts +6 -0
  124. package/dist/provider-defaults.d.ts.map +1 -0
  125. package/dist/provider-defaults.js +40 -0
  126. package/dist/provider-defaults.js.map +1 -0
  127. package/dist/providers/sandbox-backend.d.ts +22 -0
  128. package/dist/providers/sandbox-backend.d.ts.map +1 -0
  129. package/dist/providers/sandbox-backend.js +259 -0
  130. package/dist/providers/sandbox-backend.js.map +1 -0
  131. package/dist/recovery.d.ts +91 -0
  132. package/dist/recovery.d.ts.map +1 -0
  133. package/dist/recovery.js +500 -0
  134. package/dist/recovery.js.map +1 -0
  135. package/dist/reporters/junit.d.ts +8 -0
  136. package/dist/reporters/junit.d.ts.map +1 -0
  137. package/dist/reporters/junit.js +99 -0
  138. package/dist/reporters/junit.js.map +1 -0
  139. package/dist/run-metrics.d.ts +4 -0
  140. package/dist/run-metrics.d.ts.map +1 -0
  141. package/dist/run-metrics.js +48 -0
  142. package/dist/run-metrics.js.map +1 -0
  143. package/dist/run-state.d.ts +22 -0
  144. package/dist/run-state.d.ts.map +1 -0
  145. package/dist/run-state.js +33 -0
  146. package/dist/run-state.js.map +1 -0
  147. package/dist/runner/goal-verification.d.ts +13 -0
  148. package/dist/runner/goal-verification.d.ts.map +1 -0
  149. package/dist/runner/goal-verification.js +152 -0
  150. package/dist/runner/goal-verification.js.map +1 -0
  151. package/dist/runner/index.d.ts +12 -0
  152. package/dist/runner/index.d.ts.map +1 -0
  153. package/dist/runner/index.js +18 -0
  154. package/dist/runner/index.js.map +1 -0
  155. package/dist/runner/overrides.d.ts +75 -0
  156. package/dist/runner/overrides.d.ts.map +1 -0
  157. package/dist/runner/overrides.js +385 -0
  158. package/dist/runner/overrides.js.map +1 -0
  159. package/dist/runner/page-analysis.d.ts +23 -0
  160. package/dist/runner/page-analysis.d.ts.map +1 -0
  161. package/dist/runner/page-analysis.js +91 -0
  162. package/dist/runner/page-analysis.js.map +1 -0
  163. package/dist/runner/runner.d.ts +66 -0
  164. package/dist/runner/runner.d.ts.map +1 -0
  165. package/dist/runner/runner.js +1314 -0
  166. package/dist/runner/runner.js.map +1 -0
  167. package/dist/runner/scout.d.ts +29 -0
  168. package/dist/runner/scout.d.ts.map +1 -0
  169. package/dist/runner/scout.js +81 -0
  170. package/dist/runner/scout.js.map +1 -0
  171. package/dist/runner/search-guidance.d.ts +31 -0
  172. package/dist/runner/search-guidance.d.ts.map +1 -0
  173. package/dist/runner/search-guidance.js +210 -0
  174. package/dist/runner/search-guidance.js.map +1 -0
  175. package/dist/runner/utils.d.ts +28 -0
  176. package/dist/runner/utils.d.ts.map +1 -0
  177. package/dist/runner/utils.js +119 -0
  178. package/dist/runner/utils.js.map +1 -0
  179. package/dist/runner.d.ts +8 -0
  180. package/dist/runner.d.ts.map +1 -0
  181. package/dist/runner.js +21 -0
  182. package/dist/runner.js.map +1 -0
  183. package/dist/supervisor/critic.d.ts +29 -0
  184. package/dist/supervisor/critic.d.ts.map +1 -0
  185. package/dist/supervisor/critic.js +320 -0
  186. package/dist/supervisor/critic.js.map +1 -0
  187. package/dist/supervisor/policy.d.ts +11 -0
  188. package/dist/supervisor/policy.d.ts.map +1 -0
  189. package/dist/supervisor/policy.js +118 -0
  190. package/dist/supervisor/policy.js.map +1 -0
  191. package/dist/test-report.d.ts +15 -0
  192. package/dist/test-report.d.ts.map +1 -0
  193. package/dist/test-report.js +241 -0
  194. package/dist/test-report.js.map +1 -0
  195. package/dist/test-runner.d.ts +99 -0
  196. package/dist/test-runner.d.ts.map +1 -0
  197. package/dist/test-runner.js +1075 -0
  198. package/dist/test-runner.js.map +1 -0
  199. package/dist/types.d.ts +493 -0
  200. package/dist/types.d.ts.map +1 -0
  201. package/dist/types.js +5 -0
  202. package/dist/types.js.map +1 -0
  203. package/dist/wallet/automation.d.ts +52 -0
  204. package/dist/wallet/automation.d.ts.map +1 -0
  205. package/dist/wallet/automation.js +507 -0
  206. package/dist/wallet/automation.js.map +1 -0
  207. package/package.json +101 -0
  208. package/scripts/postinstall-provider-patches.mjs +52 -0
@@ -0,0 +1,1075 @@
1
+ /**
2
+ * Test Runner — dependency-aware orchestration with ground-truth verification.
3
+ *
4
+ * Sequential mode (default): reuses a single driver, resolves dependencies.
5
+ * Parallel mode: spawns up to N concurrent workers, each with its own driver.
6
+ */
7
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { AgentRunner } from './runner.js';
11
+ import { Brain } from './brain/index.js';
12
+ import { TrajectoryStore } from './memory/store.js';
13
+ import { TrajectoryAnalyzer } from './memory/analyzer.js';
14
+ import { AppKnowledge } from './memory/knowledge.js';
15
+ import { generateReport } from './test-report.js';
16
+ const DEFAULT_MAX_TURNS = 30;
17
+ import { loadPricing, calculateCost } from './model-pricing.js';
18
+ function combineAbortSignals(signals) {
19
+ const active = signals.filter((s) => !!s);
20
+ if (active.length === 0)
21
+ return undefined;
22
+ if (active.length === 1)
23
+ return active[0];
24
+ const controller = new AbortController();
25
+ const onAbort = (event) => {
26
+ const target = event.target;
27
+ controller.abort(target.reason || 'Cancelled');
28
+ };
29
+ for (const signal of active) {
30
+ if (signal.aborted) {
31
+ controller.abort(signal.reason || 'Cancelled');
32
+ return controller.signal;
33
+ }
34
+ signal.addEventListener('abort', onAbort, { once: true });
35
+ }
36
+ return controller.signal;
37
+ }
38
+ function getOrigin(url) {
39
+ if (!url)
40
+ return undefined;
41
+ try {
42
+ return new URL(url).origin;
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ export class TestRunner {
49
+ config;
50
+ driver;
51
+ driverFactory;
52
+ concurrency;
53
+ stopOnFailure;
54
+ store;
55
+ projectStore;
56
+ feedbackHints;
57
+ screenshotInterval;
58
+ analyzer;
59
+ onTestStart;
60
+ onTestComplete;
61
+ onTurn;
62
+ artifactSink;
63
+ onProgress;
64
+ workerTimeoutMs;
65
+ defaultTimeoutMs;
66
+ pendingArtifactOps = new Set();
67
+ constructor(options) {
68
+ if (!options.driver && !options.driverFactory) {
69
+ throw new Error('TestRunnerOptions requires either driver or driverFactory');
70
+ }
71
+ this.config = options.config || {};
72
+ this.driver = options.driver;
73
+ this.driverFactory = options.driverFactory;
74
+ this.concurrency = options.concurrency ?? 1;
75
+ this.stopOnFailure = options.stopOnFailure ?? false;
76
+ this.projectStore = options.projectStore;
77
+ this.store = options.enableMemory
78
+ ? new TrajectoryStore(options.trajectoryStorePath, {
79
+ enableScoring: this.config.traceScoring === true,
80
+ ttlDays: this.config.traceTtlDays ?? 30,
81
+ })
82
+ : null;
83
+ // Auto-load hints from project store if available
84
+ this.feedbackHints = options.feedbackHints ?? options.projectStore?.loadHints() ?? undefined;
85
+ this.screenshotInterval = options.screenshotInterval ?? 0;
86
+ this.analyzer = new TrajectoryAnalyzer();
87
+ this.onTestStart = options.onTestStart;
88
+ this.onTestComplete = options.onTestComplete;
89
+ this.onTurn = options.onTurn;
90
+ this.artifactSink = options.artifactSink;
91
+ this.onProgress = options.onProgress;
92
+ this.workerTimeoutMs = options.workerTimeoutMs;
93
+ // Backward-compat: honor non-typed timeoutMs on config if explicitly set by library users.
94
+ this.defaultTimeoutMs = options.defaultTimeoutMs
95
+ ?? (options.config?.timeoutMs);
96
+ }
97
+ /** Run a single test case */
98
+ async runTest(testCase, driver, options) {
99
+ const activeDriver = driver || this.driver;
100
+ if (!activeDriver) {
101
+ throw new Error('No driver available — provide driver or driverFactory');
102
+ }
103
+ const startedAt = new Date();
104
+ const screenshots = [];
105
+ this.onTestStart?.(testCase);
106
+ this.onProgress?.({ type: 'test:start', testId: testCase.id, testName: testCase.name, workerId: 0 });
107
+ // Run setup hook
108
+ const page = activeDriver.getPage?.();
109
+ const runtimeObservability = await this.startRuntimeObservability(testCase, page, activeDriver);
110
+ let runtimeOutcome;
111
+ const runStartedAtMs = Date.now();
112
+ let timeToFirstTurnMs;
113
+ const observedTurns = [];
114
+ const partialPhaseTimings = {};
115
+ if (testCase.setup && page) {
116
+ await testCase.setup(page);
117
+ }
118
+ try {
119
+ // Look up reference trajectory
120
+ let referenceTrajectory;
121
+ if (this.store) {
122
+ const match = this.store.findBestMatch(testCase.goal, {
123
+ origin: getOrigin(testCase.startUrl),
124
+ });
125
+ if (match) {
126
+ referenceTrajectory = this.store.formatAsReference(match);
127
+ }
128
+ }
129
+ // Build combined context: reference trajectory + feedback hints
130
+ let combinedReference = referenceTrajectory;
131
+ if (this.feedbackHints) {
132
+ combinedReference = combinedReference
133
+ ? `${combinedReference}\n\n${this.feedbackHints}`
134
+ : this.feedbackHints;
135
+ }
136
+ const runner = new AgentRunner({
137
+ driver: activeDriver,
138
+ config: this.config,
139
+ referenceTrajectory: combinedReference,
140
+ projectStore: this.projectStore,
141
+ onTurn: (turn) => {
142
+ if (timeToFirstTurnMs === undefined) {
143
+ timeToFirstTurnMs = Math.max(0, Date.now() - runStartedAtMs);
144
+ }
145
+ observedTurns.push(structuredClone(turn));
146
+ this.onTurn?.(testCase, turn);
147
+ this.onProgress?.({
148
+ type: 'test:turn',
149
+ testId: testCase.id,
150
+ turn: turn.turn,
151
+ action: turn.action.action,
152
+ durationMs: turn.durationMs,
153
+ tokensUsed: turn.tokensUsed,
154
+ modelUsed: turn.modelUsed,
155
+ });
156
+ if (this.screenshotInterval > 0 && turn.turn % this.screenshotInterval === 0) {
157
+ if (turn.state.screenshot) {
158
+ screenshots.push({ turn: turn.turn, base64: turn.state.screenshot });
159
+ // Emit screenshot through artifact sink
160
+ if (this.artifactSink) {
161
+ const buf = Buffer.from(turn.state.screenshot, 'base64');
162
+ const pending = this.artifactSink.put({
163
+ type: 'screenshot',
164
+ testId: testCase.id,
165
+ name: `turn-${String(turn.turn).padStart(3, '0')}.jpg`,
166
+ data: buf,
167
+ contentType: 'image/jpeg',
168
+ metadata: { turn: String(turn.turn), action: turn.action.action },
169
+ }).then(uri => {
170
+ this.onProgress?.({ type: 'test:artifact', testId: testCase.id, artifactType: 'screenshot', uri });
171
+ }).catch(() => { }); // Best-effort
172
+ this.trackArtifactOp(pending);
173
+ }
174
+ }
175
+ }
176
+ },
177
+ onPhaseTiming: (phase, durationMs) => {
178
+ if (phase === 'navigate')
179
+ partialPhaseTimings.initialNavigateMs ??= durationMs;
180
+ if (phase === 'observe')
181
+ partialPhaseTimings.firstObserveMs ??= durationMs;
182
+ if (phase === 'decide')
183
+ partialPhaseTimings.firstDecideMs ??= durationMs;
184
+ if (phase === 'execute')
185
+ partialPhaseTimings.firstExecuteMs ??= durationMs;
186
+ },
187
+ });
188
+ // Build goal with success description
189
+ let goal = testCase.goal;
190
+ if (testCase.successDescription) {
191
+ goal += `\n\nSuccess criteria: ${testCase.successDescription}`;
192
+ }
193
+ // Run with timeout + abort signal
194
+ const timeoutMs = this.resolveTimeoutMs(testCase);
195
+ const timeoutAbortController = timeoutMs && timeoutMs > 0 ? new AbortController() : undefined;
196
+ const combinedSignal = combineAbortSignals([timeoutAbortController?.signal, options?.signal]);
197
+ const runPromise = runner.run({
198
+ goal,
199
+ startUrl: testCase.startUrl,
200
+ allowedDomains: testCase.allowedDomains,
201
+ maxTurns: testCase.maxTurns ?? DEFAULT_MAX_TURNS,
202
+ signal: combinedSignal,
203
+ });
204
+ let agentResult;
205
+ if (timeoutMs && timeoutMs > 0 && timeoutAbortController) {
206
+ let timer;
207
+ const timeoutPromise = new Promise((resolve) => {
208
+ timer = setTimeout(() => {
209
+ const timedOutBeforeFirstTurn = timeToFirstTurnMs === undefined;
210
+ const reason = timedOutBeforeFirstTurn
211
+ ? `Pre-first-turn timeout after ${timeoutMs}ms`
212
+ : `Test timed out after ${timeoutMs}ms`;
213
+ timeoutAbortController.abort(reason);
214
+ resolve({
215
+ success: false,
216
+ reason,
217
+ turns: [...observedTurns],
218
+ totalMs: timeoutMs,
219
+ phaseTimings: partialPhaseTimings,
220
+ startupDiagnostics: timedOutBeforeFirstTurn
221
+ ? {
222
+ firstTurnSeen: false,
223
+ zeroTurnFailureClass: 'pre_first_turn_timeout',
224
+ startupReason: reason,
225
+ }
226
+ : {
227
+ firstTurnSeen: true,
228
+ timeToFirstTurnMs,
229
+ },
230
+ wasteMetrics: {
231
+ repeatedQueryCount: 0,
232
+ verificationRejectionCount: 0,
233
+ turnsAfterSufficientEvidence: 0,
234
+ errorTurns: 0,
235
+ },
236
+ });
237
+ }, timeoutMs);
238
+ });
239
+ agentResult = await Promise.race([runPromise, timeoutPromise])
240
+ .finally(() => { if (timer)
241
+ clearTimeout(timer); });
242
+ }
243
+ else {
244
+ agentResult = await runPromise;
245
+ }
246
+ agentResult = attachStartupDiagnostics(agentResult, timeToFirstTurnMs);
247
+ // Ground-truth verification
248
+ // Default to agent outcome when no explicit criteria are provided.
249
+ let verified = agentResult.success;
250
+ let criteriaResults;
251
+ if (testCase.successCriteria?.length && page) {
252
+ criteriaResults = await this.verifyCriteria(testCase.successCriteria, page);
253
+ verified = agentResult.success && criteriaResults.every((c) => c.passed);
254
+ }
255
+ if (!agentResult.success) {
256
+ verified = false;
257
+ }
258
+ // Save trajectory if memory enabled
259
+ const tokensUsed = agentResult.turns.reduce((sum, t) => sum + (t.tokensUsed || 0), 0);
260
+ const totalInputTokens = agentResult.turns.reduce((sum, t) => sum + (t.inputTokens || 0), 0);
261
+ const totalOutputTokens = agentResult.turns.reduce((sum, t) => sum + (t.outputTokens || 0), 0);
262
+ if (this.store) {
263
+ this.store.save(testCase.goal, agentResult.turns, verified && agentResult.success, this.config.model || 'unknown', { origin: getOrigin(testCase.startUrl) });
264
+ }
265
+ const endedAt = new Date();
266
+ const verdict = this.buildVerdict(agentResult, verified, criteriaResults);
267
+ // Calculate cost from LiteLLM's pricing database
268
+ const modelName = this.config.model || 'gpt-5.4';
269
+ const estimatedCostUsd = calculateCost(modelName, totalInputTokens, totalOutputTokens);
270
+ const result = {
271
+ testCase,
272
+ agentResult,
273
+ agentSuccess: agentResult.success,
274
+ verified,
275
+ criteriaResults,
276
+ verdict,
277
+ turnsUsed: agentResult.turns.length,
278
+ tokensUsed,
279
+ inputTokens: totalInputTokens || undefined,
280
+ outputTokens: totalOutputTokens || undefined,
281
+ estimatedCostUsd: estimatedCostUsd || undefined,
282
+ durationMs: endedAt.getTime() - startedAt.getTime(),
283
+ phaseTimings: agentResult.phaseTimings,
284
+ startupDiagnostics: agentResult.startupDiagnostics,
285
+ wasteMetrics: agentResult.wasteMetrics,
286
+ startedAt,
287
+ endedAt,
288
+ screenshots: screenshots.length > 0 ? screenshots : undefined,
289
+ runtime: this.buildRuntimeConfig(),
290
+ };
291
+ // Emit video artifact if available
292
+ if (this.artifactSink && page) {
293
+ try {
294
+ const videoPath = await page.video?.()?.path?.();
295
+ if (videoPath) {
296
+ const fs = await import('node:fs');
297
+ if (fs.existsSync(videoPath)) {
298
+ const videoData = fs.readFileSync(videoPath);
299
+ const uri = await this.artifactSink.put({
300
+ type: 'video',
301
+ testId: testCase.id,
302
+ name: 'recording.webm',
303
+ data: videoData,
304
+ contentType: 'video/webm',
305
+ metadata: { durationMs: String(result.durationMs), turnsUsed: String(result.turnsUsed) },
306
+ });
307
+ this.onProgress?.({ type: 'test:artifact', testId: testCase.id, artifactType: 'video', uri });
308
+ }
309
+ }
310
+ }
311
+ catch {
312
+ // Video capture is best-effort
313
+ }
314
+ }
315
+ this.onTestComplete?.(result);
316
+ this.onProgress?.({
317
+ type: 'test:complete',
318
+ testId: testCase.id,
319
+ passed: result.verified && result.agentSuccess,
320
+ verdict: result.verdict,
321
+ durationMs: result.durationMs,
322
+ turnsUsed: result.turnsUsed,
323
+ tokensUsed: result.tokensUsed,
324
+ estimatedCostUsd: result.estimatedCostUsd,
325
+ });
326
+ runtimeOutcome = {
327
+ passed: result.verified && result.agentSuccess,
328
+ verdict: result.verdict,
329
+ durationMs: result.durationMs,
330
+ turnsUsed: result.turnsUsed,
331
+ };
332
+ return result;
333
+ }
334
+ finally {
335
+ await runtimeObservability?.finalize(runtimeOutcome).catch((err) => {
336
+ if (this.config.debug) {
337
+ console.log(`[TestRunner] Observability finalize failed for ${testCase.id}: ${err instanceof Error ? err.message : err}`);
338
+ }
339
+ });
340
+ if (testCase.teardown && page) {
341
+ await testCase.teardown(page).catch((err) => {
342
+ if (this.config.debug) {
343
+ console.log(`[TestRunner] Teardown failed for ${testCase.id}: ${err instanceof Error ? err.message : err}`);
344
+ }
345
+ });
346
+ }
347
+ }
348
+ }
349
+ /** Run a suite of test cases with dependency resolution and optional parallelism */
350
+ async runSuite(cases) {
351
+ // Pre-load pricing database (async fetch, cached 24h)
352
+ loadPricing().catch(() => { });
353
+ this.onProgress?.({ type: 'suite:start', totalTests: cases.length, concurrency: this.concurrency });
354
+ let result;
355
+ if (this.concurrency > 1 && this.driverFactory) {
356
+ result = await this.runParallel(cases);
357
+ }
358
+ else {
359
+ result = await this.runSequential(cases);
360
+ }
361
+ // Emit reports as artifacts
362
+ if (this.artifactSink) {
363
+ try {
364
+ await this.flushArtifactOps();
365
+ const jsonReport = generateReport(result, { format: 'json' });
366
+ const jsonUri = await this.artifactSink.put({
367
+ type: 'report-json',
368
+ testId: 'suite',
369
+ name: 'report.json',
370
+ data: Buffer.from(jsonReport, 'utf-8'),
371
+ contentType: 'application/json',
372
+ metadata: { model: result.model, timestamp: result.timestamp },
373
+ });
374
+ this.onProgress?.({ type: 'test:artifact', testId: 'suite', artifactType: 'report-json', uri: jsonUri });
375
+ const mdReport = generateReport(result, { format: 'markdown', includeTurns: true });
376
+ const mdUri = await this.artifactSink.put({
377
+ type: 'report-md',
378
+ testId: 'suite',
379
+ name: 'report.md',
380
+ data: Buffer.from(mdReport, 'utf-8'),
381
+ contentType: 'text/markdown',
382
+ });
383
+ this.onProgress?.({ type: 'test:artifact', testId: 'suite', artifactType: 'report-md', uri: mdUri });
384
+ }
385
+ catch {
386
+ // Report emission is best-effort
387
+ }
388
+ }
389
+ // Post-suite memory operations
390
+ if (this.projectStore) {
391
+ this.projectStore.saveRunSummary(result);
392
+ // Generate and save optimization hints
393
+ const analysis = this.analyzer.analyze(result);
394
+ const hints = this.analyzer.generateHints(analysis);
395
+ if (hints) {
396
+ this.projectStore.saveHints(hints);
397
+ }
398
+ // Extract knowledge from successful runs (async, best-effort)
399
+ await this.extractAndSaveKnowledge(result).catch((err) => {
400
+ if (this.config.debug) {
401
+ console.log(`[TestRunner] Knowledge extraction failed: ${err instanceof Error ? err.message : err}`);
402
+ }
403
+ });
404
+ }
405
+ // Finalize artifact sink (writes manifest, flushes)
406
+ let manifestUri;
407
+ if (this.artifactSink) {
408
+ try {
409
+ const manifest = this.artifactSink.getManifest();
410
+ manifestUri = await this.artifactSink.put({
411
+ type: 'report-json',
412
+ testId: 'suite',
413
+ name: 'manifest.json',
414
+ data: Buffer.from(JSON.stringify(manifest, null, 2), 'utf-8'),
415
+ contentType: 'application/json',
416
+ });
417
+ await this.artifactSink.close?.();
418
+ }
419
+ catch {
420
+ // Best-effort
421
+ }
422
+ }
423
+ const totalCostUsd = result.results.reduce((sum, r) => sum + (r.estimatedCostUsd || 0), 0);
424
+ this.onProgress?.({
425
+ type: 'suite:complete',
426
+ passed: result.summary.passed,
427
+ failed: result.summary.failed,
428
+ skipped: result.summary.skipped,
429
+ totalMs: result.summary.totalDurationMs,
430
+ totalCostUsd: totalCostUsd || undefined,
431
+ manifestUri,
432
+ });
433
+ return result;
434
+ }
435
+ /** Analyze a completed suite run and return structured findings */
436
+ analyzeSuite(suite) {
437
+ return this.analyzer.analyze(suite);
438
+ }
439
+ /** Generate optimization hints from analysis — pass to feedbackHints on the next run */
440
+ generateHints(analysis) {
441
+ return this.analyzer.generateHints(analysis);
442
+ }
443
+ // ── Sequential execution ──
444
+ async runSequential(cases) {
445
+ const sorted = [...cases].sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99));
446
+ const results = [];
447
+ for (const tc of sorted) {
448
+ // Check dependencies
449
+ const skipReason = this.checkDependencies(tc, results);
450
+ if (skipReason) {
451
+ const skipped = this.makeSkippedResult(tc, skipReason);
452
+ results.push(skipped);
453
+ continue;
454
+ }
455
+ const result = await this.runTest(tc);
456
+ results.push(result);
457
+ if (!result.verified && this.stopOnFailure)
458
+ break;
459
+ }
460
+ return this.buildSuiteResult(results);
461
+ }
462
+ // ── Parallel execution ──
463
+ async runParallel(cases) {
464
+ const sorted = [...cases].sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99));
465
+ const knownCaseIds = new Set(sorted.map((c) => c.id));
466
+ const results = [];
467
+ const completed = new Map();
468
+ const pending = new Set(sorted.map((c) => c.id));
469
+ let stopped = false;
470
+ let runningCount = 0;
471
+ const tryDequeue = () => {
472
+ for (const tc of sorted) {
473
+ if (!pending.has(tc.id))
474
+ continue;
475
+ const missingDeps = (tc.dependsOn ?? []).filter((dep) => !knownCaseIds.has(dep));
476
+ if (missingDeps.length > 0) {
477
+ pending.delete(tc.id);
478
+ const skipped = this.makeSkippedResult(tc, `Dependencies not found: ${missingDeps.join(', ')}`);
479
+ completed.set(tc.id, skipped);
480
+ results.push(skipped);
481
+ return tryDequeue();
482
+ }
483
+ // Check if dependencies are met
484
+ if (tc.dependsOn?.length) {
485
+ const unmet = tc.dependsOn.some((dep) => {
486
+ const depResult = completed.get(dep);
487
+ return !depResult || !depResult.verified;
488
+ });
489
+ // If a dependency failed, skip this case
490
+ const depFailed = tc.dependsOn.some((dep) => {
491
+ const depResult = completed.get(dep);
492
+ return depResult && !depResult.verified;
493
+ });
494
+ if (depFailed) {
495
+ const failedDeps = tc.dependsOn.filter(dep => {
496
+ const r = completed.get(dep);
497
+ return r && !r.verified;
498
+ });
499
+ pending.delete(tc.id);
500
+ const skipped = this.makeSkippedResult(tc, `Dependencies failed: ${failedDeps.join(', ')}`);
501
+ completed.set(tc.id, skipped);
502
+ results.push(skipped);
503
+ return tryDequeue(); // Try next
504
+ }
505
+ if (unmet)
506
+ continue; // Dependencies not yet resolved, try another
507
+ }
508
+ pending.delete(tc.id);
509
+ return tc;
510
+ }
511
+ return null;
512
+ };
513
+ const markUnresolvablePending = () => {
514
+ if (pending.size === 0)
515
+ return;
516
+ for (const tc of sorted) {
517
+ if (!pending.has(tc.id))
518
+ continue;
519
+ pending.delete(tc.id);
520
+ const unresolvedDeps = (tc.dependsOn ?? []).filter((dep) => {
521
+ const depResult = completed.get(dep);
522
+ if (!depResult)
523
+ return true;
524
+ return !depResult.verified;
525
+ });
526
+ const reason = unresolvedDeps.length > 0
527
+ ? `Unresolvable dependencies (cycle or unmet): ${unresolvedDeps.join(', ')}`
528
+ : 'Unresolvable dependency graph (no runnable tests remain)';
529
+ const skipped = this.makeSkippedResult(tc, reason);
530
+ completed.set(tc.id, skipped);
531
+ results.push(skipped);
532
+ }
533
+ };
534
+ const worker = async () => {
535
+ while (!stopped) {
536
+ const tc = tryDequeue();
537
+ if (!tc) {
538
+ if (pending.size === 0)
539
+ return;
540
+ if (runningCount === 0) {
541
+ markUnresolvablePending();
542
+ continue;
543
+ }
544
+ // Wait for dependencies to resolve
545
+ await new Promise((r) => setTimeout(r, 100));
546
+ continue;
547
+ }
548
+ runningCount += 1;
549
+ const driver = await this.driverFactory();
550
+ try {
551
+ let result;
552
+ if (this.workerTimeoutMs) {
553
+ const workerAbortController = new AbortController();
554
+ const runPromise = this.runTest(tc, driver, { signal: workerAbortController.signal });
555
+ let timeout;
556
+ const timeoutPromise = new Promise((resolve) => {
557
+ timeout = setTimeout(() => {
558
+ workerAbortController.abort(`Worker timed out after ${this.workerTimeoutMs}ms`);
559
+ resolve(this.makeSkippedResult(tc, `Worker timed out after ${this.workerTimeoutMs}ms`));
560
+ }, this.workerTimeoutMs);
561
+ });
562
+ try {
563
+ result = await Promise.race([runPromise, timeoutPromise]);
564
+ if (result.skipped) {
565
+ // Keep background promise from surfacing unhandled rejections.
566
+ runPromise.catch(() => { });
567
+ }
568
+ }
569
+ finally {
570
+ if (timeout)
571
+ clearTimeout(timeout);
572
+ }
573
+ }
574
+ else {
575
+ result = await this.runTest(tc, driver);
576
+ }
577
+ completed.set(tc.id, result);
578
+ results.push(result);
579
+ if (!result.verified && this.stopOnFailure) {
580
+ if (!stopped) {
581
+ stopped = true;
582
+ for (const remainingId of [...pending]) {
583
+ const remaining = sorted.find((c) => c.id === remainingId);
584
+ if (!remaining)
585
+ continue;
586
+ pending.delete(remaining.id);
587
+ const skipped = this.makeSkippedResult(remaining, `Stopped after failure in ${tc.id}`);
588
+ completed.set(remaining.id, skipped);
589
+ results.push(skipped);
590
+ }
591
+ }
592
+ }
593
+ }
594
+ finally {
595
+ runningCount = Math.max(0, runningCount - 1);
596
+ await driver.close?.();
597
+ }
598
+ }
599
+ };
600
+ // Spawn workers
601
+ const workers = Array.from({ length: Math.min(this.concurrency, cases.length) }, () => worker());
602
+ await Promise.all(workers);
603
+ // Preserve original sort order in results
604
+ const ordered = sorted.map((tc) => results.find((r) => r.testCase.id === tc.id) || this.makeSkippedResult(tc, 'Not reached'));
605
+ return this.buildSuiteResult(ordered);
606
+ }
607
+ // ── Verification ──
608
+ async verifyCriteria(criteria, page) {
609
+ const results = [];
610
+ for (const criterion of criteria) {
611
+ try {
612
+ results.push(await this.verifySingleCriterion(criterion, page));
613
+ }
614
+ catch (err) {
615
+ results.push({
616
+ criterion,
617
+ passed: false,
618
+ detail: `Error: ${err instanceof Error ? err.message : String(err)}`,
619
+ });
620
+ }
621
+ }
622
+ return results;
623
+ }
624
+ async verifySingleCriterion(criterion, page) {
625
+ switch (criterion.type) {
626
+ case 'url-contains': {
627
+ const url = page.url();
628
+ const passed = url.includes(criterion.value || '');
629
+ return {
630
+ criterion,
631
+ passed,
632
+ detail: passed ? undefined : `URL "${url}" does not contain "${criterion.value}"`,
633
+ };
634
+ }
635
+ case 'url-matches': {
636
+ const url = page.url();
637
+ const regex = new RegExp(criterion.value || '');
638
+ const passed = regex.test(url);
639
+ return {
640
+ criterion,
641
+ passed,
642
+ detail: passed ? undefined : `URL "${url}" does not match pattern "${criterion.value}"`,
643
+ };
644
+ }
645
+ case 'element-visible': {
646
+ if (!criterion.selector) {
647
+ return { criterion, passed: false, detail: 'No selector provided' };
648
+ }
649
+ const visible = await page
650
+ .locator(criterion.selector)
651
+ .first()
652
+ .isVisible({ timeout: 5000 })
653
+ .catch(() => false);
654
+ return {
655
+ criterion,
656
+ passed: visible,
657
+ detail: visible ? undefined : `Element "${criterion.selector}" is not visible`,
658
+ };
659
+ }
660
+ case 'element-text': {
661
+ if (!criterion.selector) {
662
+ return { criterion, passed: false, detail: 'No selector provided' };
663
+ }
664
+ const text = await page
665
+ .locator(criterion.selector)
666
+ .first()
667
+ .textContent({ timeout: 5000 })
668
+ .catch(() => '');
669
+ const passed = (text || '').includes(criterion.value || '');
670
+ return {
671
+ criterion,
672
+ passed,
673
+ detail: passed
674
+ ? undefined
675
+ : `Element "${criterion.selector}" text "${text}" does not contain "${criterion.value}"`,
676
+ };
677
+ }
678
+ case 'element-count': {
679
+ if (!criterion.selector) {
680
+ return { criterion, passed: false, detail: 'No selector provided' };
681
+ }
682
+ const count = await page.locator(criterion.selector).count();
683
+ const expected = parseInt(criterion.value || '0', 10);
684
+ const passed = count >= expected;
685
+ return {
686
+ criterion,
687
+ passed,
688
+ detail: passed
689
+ ? undefined
690
+ : `Found ${count} elements matching "${criterion.selector}", expected >= ${expected}`,
691
+ };
692
+ }
693
+ case 'custom': {
694
+ if (!criterion.check) {
695
+ return { criterion, passed: false, detail: 'No check function provided' };
696
+ }
697
+ const passed = await criterion.check(page);
698
+ return {
699
+ criterion,
700
+ passed,
701
+ detail: passed ? undefined : `Custom check failed: ${criterion.description || 'unknown'}`,
702
+ };
703
+ }
704
+ default:
705
+ return { criterion, passed: false, detail: `Unknown criterion type: ${criterion.type}` };
706
+ }
707
+ }
708
+ // ── Helpers ──
709
+ checkDependencies(tc, completed) {
710
+ if (!tc.dependsOn?.length)
711
+ return undefined;
712
+ const unmet = tc.dependsOn.filter((dep) => {
713
+ const depResult = completed.find((r) => r.testCase.id === dep);
714
+ return !depResult || !depResult.verified;
715
+ });
716
+ if (unmet.length > 0) {
717
+ return `Dependencies not met: ${unmet.join(', ')}`;
718
+ }
719
+ return undefined;
720
+ }
721
+ resolveTimeoutMs(testCase) {
722
+ if (testCase.timeoutMs !== undefined)
723
+ return testCase.timeoutMs;
724
+ return this.defaultTimeoutMs;
725
+ }
726
+ makeSkippedResult(tc, reason) {
727
+ const now = new Date();
728
+ return {
729
+ testCase: tc,
730
+ agentResult: { success: false, reason, turns: [], totalMs: 0 },
731
+ agentSuccess: false,
732
+ verified: false,
733
+ verdict: `Skipped: ${reason}`,
734
+ turnsUsed: 0,
735
+ tokensUsed: 0,
736
+ durationMs: 0,
737
+ phaseTimings: {},
738
+ startupDiagnostics: {
739
+ firstTurnSeen: false,
740
+ zeroTurnFailureClass: 'unknown',
741
+ startupReason: reason,
742
+ },
743
+ wasteMetrics: {
744
+ repeatedQueryCount: 0,
745
+ verificationRejectionCount: 0,
746
+ turnsAfterSufficientEvidence: 0,
747
+ errorTurns: 0,
748
+ },
749
+ startedAt: now,
750
+ endedAt: now,
751
+ skipped: true,
752
+ skipReason: reason,
753
+ runtime: this.buildRuntimeConfig(),
754
+ };
755
+ }
756
+ buildVerdict(agentResult, verified, criteriaResults) {
757
+ if (verified && agentResult.success) {
758
+ return agentResult.result || 'Goal achieved';
759
+ }
760
+ if (!agentResult.success) {
761
+ return agentResult.reason || 'Agent failed';
762
+ }
763
+ // Agent reported success but verification failed
764
+ const failedCriteria = criteriaResults
765
+ ?.filter((c) => !c.passed)
766
+ .map((c) => c.detail || c.criterion.description || c.criterion.type)
767
+ .join('; ');
768
+ return `Verification failed: ${failedCriteria || 'unknown criteria'}`;
769
+ }
770
+ /**
771
+ * Extract knowledge from successful test runs and merge into domain knowledge.
772
+ * Uses the Brain's extractKnowledge method to analyze trajectories.
773
+ */
774
+ async extractAndSaveKnowledge(suite) {
775
+ if (!this.projectStore)
776
+ return;
777
+ const successfulResults = suite.results.filter(r => r.verified && !r.skipped);
778
+ if (successfulResults.length === 0)
779
+ return;
780
+ const brain = new Brain(this.config);
781
+ for (const result of successfulResults) {
782
+ const startUrl = result.testCase.startUrl;
783
+ if (!startUrl)
784
+ continue;
785
+ // Format trajectory for extraction
786
+ const trajectoryText = result.agentResult.turns
787
+ .map((t, i) => {
788
+ const action = JSON.stringify(t.action);
789
+ const verified = t.verified ? ' [verified]' : '';
790
+ return `${i + 1}. URL: ${t.state.url} → ${action}${verified}`;
791
+ })
792
+ .join('\n');
793
+ const facts = await brain.extractKnowledge(trajectoryText, startUrl);
794
+ if (facts.length === 0)
795
+ continue;
796
+ // Merge into domain knowledge
797
+ const knowledge = new AppKnowledge(this.projectStore.getKnowledgePath(startUrl), startUrl);
798
+ knowledge.recordFacts(facts);
799
+ knowledge.save();
800
+ if (this.config.debug) {
801
+ console.log(`[TestRunner] Extracted ${facts.length} facts for ${startUrl}`);
802
+ }
803
+ }
804
+ }
805
+ buildSuiteResult(results) {
806
+ const nonSkipped = results.filter((r) => !r.skipped);
807
+ const durations = nonSkipped.map((r) => r.durationMs).sort((a, b) => a - b);
808
+ const passed = nonSkipped.filter((r) => r.verified && r.agentSuccess).length;
809
+ return {
810
+ model: this.config.model || 'unknown',
811
+ runtime: this.buildRuntimeConfig(),
812
+ timestamp: new Date().toISOString(),
813
+ results,
814
+ summary: {
815
+ total: results.length,
816
+ passed,
817
+ failed: nonSkipped.length - passed,
818
+ skipped: results.length - nonSkipped.length,
819
+ passRate: nonSkipped.length > 0 ? passed / nonSkipped.length : 0,
820
+ avgTurns: avg(nonSkipped.map((r) => r.turnsUsed)),
821
+ avgTokens: avg(nonSkipped.map((r) => r.tokensUsed)),
822
+ avgDurationMs: avg(durations),
823
+ p50DurationMs: percentile(durations, 0.5),
824
+ p95DurationMs: percentile(durations, 0.95),
825
+ totalDurationMs: durations.reduce((sum, d) => sum + d, 0),
826
+ },
827
+ };
828
+ }
829
+ buildRuntimeConfig() {
830
+ return {
831
+ provider: this.config.provider || 'openai',
832
+ model: this.config.model || 'unknown',
833
+ ...(this.config.sandboxBackendType ? { sandboxBackendType: this.config.sandboxBackendType } : {}),
834
+ ...(this.config.sandboxBackendProfile ? { sandboxBackendProfile: this.config.sandboxBackendProfile } : {}),
835
+ ...(this.config.sandboxBackendProvider ? { sandboxBackendProvider: this.config.sandboxBackendProvider } : {}),
836
+ };
837
+ }
838
+ trackArtifactOp(p) {
839
+ this.pendingArtifactOps.add(p);
840
+ p.finally(() => {
841
+ this.pendingArtifactOps.delete(p);
842
+ }).catch(() => { });
843
+ }
844
+ async flushArtifactOps() {
845
+ if (this.pendingArtifactOps.size === 0)
846
+ return;
847
+ await Promise.allSettled([...this.pendingArtifactOps]);
848
+ }
849
+ async startRuntimeObservability(testCase, page, driver) {
850
+ const config = this.config.observability;
851
+ if (config?.enabled === false || !page || typeof page.on !== 'function') {
852
+ return null;
853
+ }
854
+ const captureConsole = config?.captureConsole !== false;
855
+ const captureNetwork = config?.captureNetwork !== false;
856
+ const tracePolicy = config?.tracePolicy ?? 'on-failure';
857
+ const maxConsoleEntries = config?.maxConsoleEntries ?? 200;
858
+ const maxNetworkEntries = config?.maxNetworkEntries ?? 200;
859
+ const observabilityStartedAt = new Date().toISOString();
860
+ const consoleEntries = [];
861
+ const pageErrors = [];
862
+ const requestFailures = [];
863
+ const responseErrors = [];
864
+ const pushLimited = (target, entry, limit) => {
865
+ if (target.length >= limit)
866
+ return;
867
+ target.push(entry);
868
+ };
869
+ const onConsole = (msg) => {
870
+ const level = msg.type();
871
+ if (!['warning', 'error', 'assert'].includes(level))
872
+ return;
873
+ const location = msg.location();
874
+ pushLimited(consoleEntries, {
875
+ level,
876
+ text: msg.text(),
877
+ url: location.url || '',
878
+ line: String(location.lineNumber ?? ''),
879
+ column: String(location.columnNumber ?? ''),
880
+ }, maxConsoleEntries);
881
+ };
882
+ const onPageError = (err) => {
883
+ pushLimited(pageErrors, {
884
+ message: err.message,
885
+ stack: err.stack || '',
886
+ }, maxConsoleEntries);
887
+ };
888
+ const onRequestFailed = (request) => {
889
+ pushLimited(requestFailures, {
890
+ url: request.url(),
891
+ method: request.method(),
892
+ resourceType: request.resourceType(),
893
+ errorText: request.failure()?.errorText || '',
894
+ }, maxNetworkEntries);
895
+ };
896
+ const onResponse = (response) => {
897
+ const status = response.status();
898
+ if (status < 400)
899
+ return;
900
+ const request = response.request();
901
+ pushLimited(responseErrors, {
902
+ url: response.url(),
903
+ status: String(status),
904
+ statusText: response.statusText(),
905
+ method: request.method(),
906
+ resourceType: request.resourceType(),
907
+ }, maxNetworkEntries);
908
+ };
909
+ if (captureConsole) {
910
+ page.on('console', onConsole);
911
+ page.on('pageerror', onPageError);
912
+ }
913
+ if (captureNetwork) {
914
+ page.on('requestfailed', onRequestFailed);
915
+ page.on('response', onResponse);
916
+ }
917
+ const context = page.context?.();
918
+ let traceStarted = false;
919
+ if (tracePolicy !== 'off' && context?.tracing?.start) {
920
+ try {
921
+ await context.tracing.start({ screenshots: true, snapshots: true });
922
+ traceStarted = true;
923
+ }
924
+ catch {
925
+ traceStarted = false;
926
+ }
927
+ }
928
+ return {
929
+ finalize: async (outcome) => {
930
+ if (captureConsole) {
931
+ if (typeof page.off === 'function') {
932
+ page.off('console', onConsole);
933
+ page.off('pageerror', onPageError);
934
+ }
935
+ }
936
+ if (captureNetwork) {
937
+ if (typeof page.off === 'function') {
938
+ page.off('requestfailed', onRequestFailed);
939
+ page.off('response', onResponse);
940
+ }
941
+ }
942
+ let traceCaptured = false;
943
+ let traceArtifactUri = '';
944
+ let traceError = '';
945
+ if (traceStarted && context?.tracing?.stop) {
946
+ const shouldPersistTrace = tracePolicy === 'always' || (tracePolicy === 'on-failure' && outcome?.passed === false) || outcome === undefined;
947
+ try {
948
+ if (shouldPersistTrace) {
949
+ const tracePath = join(tmpdir(), `abd-trace-${Date.now()}-${sanitizeArtifactId(testCase.id)}.zip`);
950
+ await context.tracing.stop({ path: tracePath });
951
+ if (this.artifactSink && existsSync(tracePath)) {
952
+ const data = readFileSync(tracePath);
953
+ if (data.length > 0) {
954
+ traceCaptured = true;
955
+ traceArtifactUri = await this.artifactSink.put({
956
+ type: 'trace',
957
+ testId: testCase.id,
958
+ name: 'trace.zip',
959
+ data,
960
+ contentType: 'application/zip',
961
+ metadata: {
962
+ policy: tracePolicy,
963
+ passed: String(outcome?.passed ?? false),
964
+ },
965
+ });
966
+ this.onProgress?.({ type: 'test:artifact', testId: testCase.id, artifactType: 'trace', uri: traceArtifactUri });
967
+ }
968
+ unlinkSync(tracePath);
969
+ }
970
+ }
971
+ else {
972
+ await context.tracing.stop();
973
+ }
974
+ }
975
+ catch (err) {
976
+ traceError = err instanceof Error ? err.message : String(err);
977
+ }
978
+ }
979
+ if (!this.artifactSink)
980
+ return;
981
+ const driverDiagnostics = driver?.getDiagnostics?.();
982
+ const runtimeLog = {
983
+ startedAt: observabilityStartedAt,
984
+ finishedAt: new Date().toISOString(),
985
+ tracePolicy,
986
+ traceCaptured,
987
+ traceArtifactUri,
988
+ traceError,
989
+ outcome: outcome
990
+ ? {
991
+ passed: outcome.passed,
992
+ verdict: outcome.verdict,
993
+ durationMs: outcome.durationMs,
994
+ turnsUsed: outcome.turnsUsed,
995
+ }
996
+ : null,
997
+ console: consoleEntries,
998
+ pageErrors,
999
+ requestFailures,
1000
+ responseErrors,
1001
+ driverDiagnostics: driverDiagnostics ?? null,
1002
+ };
1003
+ const uri = await this.artifactSink.put({
1004
+ type: 'runtime-log',
1005
+ testId: testCase.id,
1006
+ name: 'runtime-log.json',
1007
+ data: Buffer.from(JSON.stringify(runtimeLog, null, 2), 'utf-8'),
1008
+ contentType: 'application/json',
1009
+ metadata: {
1010
+ consoleEntries: String(consoleEntries.length),
1011
+ pageErrors: String(pageErrors.length),
1012
+ requestFailures: String(requestFailures.length),
1013
+ responseErrors: String(responseErrors.length),
1014
+ traceCaptured: String(traceCaptured),
1015
+ },
1016
+ });
1017
+ this.onProgress?.({ type: 'test:artifact', testId: testCase.id, artifactType: 'runtime-log', uri });
1018
+ },
1019
+ };
1020
+ }
1021
+ }
1022
+ function attachStartupDiagnostics(agentResult, timeToFirstTurnMs) {
1023
+ if (agentResult.startupDiagnostics)
1024
+ return agentResult;
1025
+ if (typeof timeToFirstTurnMs === 'number') {
1026
+ return {
1027
+ ...agentResult,
1028
+ startupDiagnostics: {
1029
+ firstTurnSeen: true,
1030
+ timeToFirstTurnMs,
1031
+ },
1032
+ };
1033
+ }
1034
+ if ((agentResult.turns?.length ?? 0) > 0) {
1035
+ return {
1036
+ ...agentResult,
1037
+ startupDiagnostics: {
1038
+ firstTurnSeen: true,
1039
+ },
1040
+ };
1041
+ }
1042
+ return {
1043
+ ...agentResult,
1044
+ startupDiagnostics: {
1045
+ firstTurnSeen: false,
1046
+ zeroTurnFailureClass: classifyZeroTurnFailure(agentResult.reason),
1047
+ startupReason: agentResult.reason,
1048
+ },
1049
+ };
1050
+ }
1051
+ function classifyZeroTurnFailure(reasonRaw) {
1052
+ const reason = String(reasonRaw || '').toLowerCase();
1053
+ if (!reason)
1054
+ return 'unknown';
1055
+ if (/pre-first-turn timeout|startup timeout/.test(reason))
1056
+ return 'pre_first_turn_timeout';
1057
+ if (/incorrect api key|authorization failed|rate limit|llm|api key/.test(reason))
1058
+ return 'provider_or_credentials';
1059
+ return 'runner_startup_error';
1060
+ }
1061
+ function sanitizeArtifactId(value) {
1062
+ return String(value || 'test').replace(/[^a-zA-Z0-9_-]+/g, '-').slice(0, 64) || 'test';
1063
+ }
1064
+ function avg(nums) {
1065
+ if (nums.length === 0)
1066
+ return 0;
1067
+ return nums.reduce((sum, n) => sum + n, 0) / nums.length;
1068
+ }
1069
+ function percentile(sorted, p) {
1070
+ if (sorted.length === 0)
1071
+ return 0;
1072
+ const idx = Math.ceil(sorted.length * p) - 1;
1073
+ return sorted[Math.max(0, Math.min(idx, sorted.length - 1))];
1074
+ }
1075
+ //# sourceMappingURL=test-runner.js.map