@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.
- package/LICENSE +196 -0
- package/README.md +149 -0
- package/dist/artifacts/composite-sink.d.ts +15 -0
- package/dist/artifacts/composite-sink.d.ts.map +1 -0
- package/dist/artifacts/composite-sink.js +27 -0
- package/dist/artifacts/composite-sink.js.map +1 -0
- package/dist/artifacts/filesystem-sink.d.ts +26 -0
- package/dist/artifacts/filesystem-sink.d.ts.map +1 -0
- package/dist/artifacts/filesystem-sink.js +81 -0
- package/dist/artifacts/filesystem-sink.js.map +1 -0
- package/dist/artifacts/types.d.ts +97 -0
- package/dist/artifacts/types.d.ts.map +1 -0
- package/dist/artifacts/types.js +9 -0
- package/dist/artifacts/types.js.map +1 -0
- package/dist/artifacts/webhook-sink.d.ts +71 -0
- package/dist/artifacts/webhook-sink.d.ts.map +1 -0
- package/dist/artifacts/webhook-sink.js +117 -0
- package/dist/artifacts/webhook-sink.js.map +1 -0
- package/dist/brain/index.d.ts +149 -0
- package/dist/brain/index.d.ts.map +1 -0
- package/dist/brain/index.js +1030 -0
- package/dist/brain/index.js.map +1 -0
- package/dist/browser-launch.d.ts +27 -0
- package/dist/browser-launch.d.ts.map +1 -0
- package/dist/browser-launch.js +92 -0
- package/dist/browser-launch.js.map +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1035 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +143 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +205 -0
- package/dist/config.js.map +1 -0
- package/dist/context-budget.d.ts +14 -0
- package/dist/context-budget.d.ts.map +1 -0
- package/dist/context-budget.js +39 -0
- package/dist/context-budget.js.map +1 -0
- package/dist/design-audit.d.ts +48 -0
- package/dist/design-audit.d.ts.map +1 -0
- package/dist/design-audit.js +172 -0
- package/dist/design-audit.js.map +1 -0
- package/dist/domain-policy.d.ts +4 -0
- package/dist/domain-policy.d.ts.map +1 -0
- package/dist/domain-policy.js +69 -0
- package/dist/domain-policy.js.map +1 -0
- package/dist/drivers/block-patterns.d.ts +11 -0
- package/dist/drivers/block-patterns.d.ts.map +1 -0
- package/dist/drivers/block-patterns.js +123 -0
- package/dist/drivers/block-patterns.js.map +1 -0
- package/dist/drivers/cdp-page-state.d.ts +22 -0
- package/dist/drivers/cdp-page-state.d.ts.map +1 -0
- package/dist/drivers/cdp-page-state.js +38 -0
- package/dist/drivers/cdp-page-state.js.map +1 -0
- package/dist/drivers/cdp-snapshot.d.ts +32 -0
- package/dist/drivers/cdp-snapshot.d.ts.map +1 -0
- package/dist/drivers/cdp-snapshot.js +120 -0
- package/dist/drivers/cdp-snapshot.js.map +1 -0
- package/dist/drivers/playwright.d.ts +84 -0
- package/dist/drivers/playwright.d.ts.map +1 -0
- package/dist/drivers/playwright.js +385 -0
- package/dist/drivers/playwright.js.map +1 -0
- package/dist/drivers/snapshot.d.ts +134 -0
- package/dist/drivers/snapshot.d.ts.map +1 -0
- package/dist/drivers/snapshot.js +354 -0
- package/dist/drivers/snapshot.js.map +1 -0
- package/dist/drivers/types.d.ts +37 -0
- package/dist/drivers/types.d.ts.map +1 -0
- package/dist/drivers/types.js +5 -0
- package/dist/drivers/types.js.map +1 -0
- package/dist/env-loader.d.ts +2 -0
- package/dist/env-loader.d.ts.map +1 -0
- package/dist/env-loader.js +35 -0
- package/dist/env-loader.js.map +1 -0
- package/dist/formatters/slack.d.ts +37 -0
- package/dist/formatters/slack.d.ts.map +1 -0
- package/dist/formatters/slack.js +97 -0
- package/dist/formatters/slack.js.map +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +53 -0
- package/dist/index.js.map +1 -0
- package/dist/memory/analyzer.d.ts +67 -0
- package/dist/memory/analyzer.d.ts.map +1 -0
- package/dist/memory/analyzer.js +265 -0
- package/dist/memory/analyzer.js.map +1 -0
- package/dist/memory/knowledge.d.ts +55 -0
- package/dist/memory/knowledge.d.ts.map +1 -0
- package/dist/memory/knowledge.js +115 -0
- package/dist/memory/knowledge.js.map +1 -0
- package/dist/memory/project-store.d.ts +43 -0
- package/dist/memory/project-store.d.ts.map +1 -0
- package/dist/memory/project-store.js +119 -0
- package/dist/memory/project-store.js.map +1 -0
- package/dist/memory/selectors.d.ts +46 -0
- package/dist/memory/selectors.d.ts.map +1 -0
- package/dist/memory/selectors.js +89 -0
- package/dist/memory/selectors.js.map +1 -0
- package/dist/memory/store.d.ts +39 -0
- package/dist/memory/store.d.ts.map +1 -0
- package/dist/memory/store.js +193 -0
- package/dist/memory/store.js.map +1 -0
- package/dist/model-pricing.d.ts +41 -0
- package/dist/model-pricing.d.ts.map +1 -0
- package/dist/model-pricing.js +197 -0
- package/dist/model-pricing.js.map +1 -0
- package/dist/multi-actor.d.ts +108 -0
- package/dist/multi-actor.d.ts.map +1 -0
- package/dist/multi-actor.js +192 -0
- package/dist/multi-actor.js.map +1 -0
- package/dist/override-pipeline.d.ts +57 -0
- package/dist/override-pipeline.d.ts.map +1 -0
- package/dist/override-pipeline.js +26 -0
- package/dist/override-pipeline.js.map +1 -0
- package/dist/personas.d.ts +19 -0
- package/dist/personas.d.ts.map +1 -0
- package/dist/personas.js +86 -0
- package/dist/personas.js.map +1 -0
- package/dist/preview.d.ts +32 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +138 -0
- package/dist/preview.js.map +1 -0
- package/dist/provider-defaults.d.ts +6 -0
- package/dist/provider-defaults.d.ts.map +1 -0
- package/dist/provider-defaults.js +40 -0
- package/dist/provider-defaults.js.map +1 -0
- package/dist/providers/sandbox-backend.d.ts +22 -0
- package/dist/providers/sandbox-backend.d.ts.map +1 -0
- package/dist/providers/sandbox-backend.js +259 -0
- package/dist/providers/sandbox-backend.js.map +1 -0
- package/dist/recovery.d.ts +91 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +500 -0
- package/dist/recovery.js.map +1 -0
- package/dist/reporters/junit.d.ts +8 -0
- package/dist/reporters/junit.d.ts.map +1 -0
- package/dist/reporters/junit.js +99 -0
- package/dist/reporters/junit.js.map +1 -0
- package/dist/run-metrics.d.ts +4 -0
- package/dist/run-metrics.d.ts.map +1 -0
- package/dist/run-metrics.js +48 -0
- package/dist/run-metrics.js.map +1 -0
- package/dist/run-state.d.ts +22 -0
- package/dist/run-state.d.ts.map +1 -0
- package/dist/run-state.js +33 -0
- package/dist/run-state.js.map +1 -0
- package/dist/runner/goal-verification.d.ts +13 -0
- package/dist/runner/goal-verification.d.ts.map +1 -0
- package/dist/runner/goal-verification.js +152 -0
- package/dist/runner/goal-verification.js.map +1 -0
- package/dist/runner/index.d.ts +12 -0
- package/dist/runner/index.d.ts.map +1 -0
- package/dist/runner/index.js +18 -0
- package/dist/runner/index.js.map +1 -0
- package/dist/runner/overrides.d.ts +75 -0
- package/dist/runner/overrides.d.ts.map +1 -0
- package/dist/runner/overrides.js +385 -0
- package/dist/runner/overrides.js.map +1 -0
- package/dist/runner/page-analysis.d.ts +23 -0
- package/dist/runner/page-analysis.d.ts.map +1 -0
- package/dist/runner/page-analysis.js +91 -0
- package/dist/runner/page-analysis.js.map +1 -0
- package/dist/runner/runner.d.ts +66 -0
- package/dist/runner/runner.d.ts.map +1 -0
- package/dist/runner/runner.js +1314 -0
- package/dist/runner/runner.js.map +1 -0
- package/dist/runner/scout.d.ts +29 -0
- package/dist/runner/scout.d.ts.map +1 -0
- package/dist/runner/scout.js +81 -0
- package/dist/runner/scout.js.map +1 -0
- package/dist/runner/search-guidance.d.ts +31 -0
- package/dist/runner/search-guidance.d.ts.map +1 -0
- package/dist/runner/search-guidance.js +210 -0
- package/dist/runner/search-guidance.js.map +1 -0
- package/dist/runner/utils.d.ts +28 -0
- package/dist/runner/utils.d.ts.map +1 -0
- package/dist/runner/utils.js +119 -0
- package/dist/runner/utils.js.map +1 -0
- package/dist/runner.d.ts +8 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +21 -0
- package/dist/runner.js.map +1 -0
- package/dist/supervisor/critic.d.ts +29 -0
- package/dist/supervisor/critic.d.ts.map +1 -0
- package/dist/supervisor/critic.js +320 -0
- package/dist/supervisor/critic.js.map +1 -0
- package/dist/supervisor/policy.d.ts +11 -0
- package/dist/supervisor/policy.d.ts.map +1 -0
- package/dist/supervisor/policy.js +118 -0
- package/dist/supervisor/policy.js.map +1 -0
- package/dist/test-report.d.ts +15 -0
- package/dist/test-report.d.ts.map +1 -0
- package/dist/test-report.js +241 -0
- package/dist/test-report.js.map +1 -0
- package/dist/test-runner.d.ts +99 -0
- package/dist/test-runner.d.ts.map +1 -0
- package/dist/test-runner.js +1075 -0
- package/dist/test-runner.js.map +1 -0
- package/dist/types.d.ts +493 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/wallet/automation.d.ts +52 -0
- package/dist/wallet/automation.d.ts.map +1 -0
- package/dist/wallet/automation.js +507 -0
- package/dist/wallet/automation.js.map +1 -0
- package/package.json +101 -0
- 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
|