e2e 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/.build.json +1 -1
- package/dist/agent/ai-sdk.js +5 -4
- package/dist/agent/error.js +3 -3
- package/dist/agent/invocation.js +3 -4
- package/dist/agent/model/tool-choice.d.ts +9 -0
- package/dist/agent/model/tool-choice.js +14 -0
- package/dist/agent/observation-feed.js +3 -4
- package/dist/agent/phases.d.ts +7 -1
- package/dist/agent/phases.js +12 -3
- package/dist/agent/prompts.js +3 -0
- package/dist/agent/replay.js +18 -15
- package/dist/agent/step-cache.d.ts +2 -1
- package/dist/agent/step-cache.js +9 -5
- package/dist/agent/tool-loop.js +92 -5
- package/dist/cache/trace.js +6 -2
- package/dist/cli/bin.js +2 -2
- package/dist/cli/index.js +35 -24
- package/dist/cli/init/mcp-config.d.ts +2 -1
- package/dist/cli/init/mcp-config.js +8 -3
- package/dist/cli/init/sibling-versions.json +1 -1
- package/dist/cli/mcp.d.ts +3 -2
- package/dist/cli/mcp.js +52 -16
- package/dist/cli/run-output.d.ts +17 -0
- package/dist/cli/run-output.js +45 -0
- package/dist/collect/collect.js +8 -4
- package/dist/collect/registry.js +27 -16
- package/dist/config/compiled-files.d.ts +36 -0
- package/dist/config/compiled-files.js +50 -0
- package/dist/config/diagnose.js +21 -19
- package/dist/config/esm-hooks.d.ts +39 -44
- package/dist/config/esm-hooks.js +238 -63
- package/dist/config/load.d.ts +1 -7
- package/dist/config/load.js +23 -66
- package/dist/config/register.d.ts +8 -0
- package/dist/config/register.js +9 -0
- package/dist/config/specifiers.d.ts +19 -0
- package/dist/config/specifiers.js +33 -0
- package/dist/config/tsconfig.d.ts +56 -0
- package/dist/config/tsconfig.js +115 -0
- package/dist/config/typescript.d.ts +19 -0
- package/dist/config/typescript.js +253 -0
- package/dist/engine/contract.d.ts +1 -0
- package/dist/engine/index.d.ts +2 -0
- package/dist/engine/session.js +2 -2
- package/dist/expect/async.js +2 -1
- package/dist/expect/poll.js +2 -2
- package/dist/internal/errors.d.ts +10 -3
- package/dist/internal/errors.js +23 -8
- package/dist/internal/node-version.d.ts +16 -7
- package/dist/internal/node-version.js +50 -15
- package/dist/internal/package-manager.js +1 -1
- package/dist/internal/realm-slot.d.ts +5 -4
- package/dist/internal/redact.d.ts +2 -0
- package/dist/internal/redact.js +4 -0
- package/dist/internal/time.d.ts +21 -5
- package/dist/internal/time.js +35 -3
- package/dist/locator/engine.js +47 -11
- package/dist/locator/screen.js +31 -5
- package/dist/mcp/output.d.ts +59 -0
- package/dist/mcp/output.js +129 -0
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.js +9 -12
- package/dist/mcp/session.d.ts +8 -1
- package/dist/mcp/session.js +55 -14
- package/dist/mcp/tools.js +7 -5
- package/dist/report/list.d.ts +11 -0
- package/dist/report/list.js +23 -6
- package/dist/run/artifacts.d.ts +4 -4
- package/dist/run/artifacts.js +3 -2
- package/dist/run/execute.d.ts +25 -5
- package/dist/run/execute.js +44 -20
- package/dist/run/failure-evidence.js +6 -2
- package/dist/run/in-process.js +51 -10
- package/dist/run/process-output.d.ts +73 -0
- package/dist/run/process-output.js +130 -0
- package/dist/run/provision.d.ts +2 -0
- package/dist/run/provision.js +1 -0
- package/dist/run/records.d.ts +4 -4
- package/dist/run/retry.d.ts +7 -0
- package/dist/run/retry.js +27 -11
- package/dist/run/runner.d.ts +8 -0
- package/dist/run/runner.js +32 -9
- package/dist/run/scheduler.js +255 -19
- package/dist/run/secrecy.d.ts +22 -19
- package/dist/run/secrecy.js +22 -18
- package/dist/run/serial.d.ts +73 -2
- package/dist/run/serial.js +239 -58
- package/dist/run/standalone.js +1 -1
- package/dist/run/trace-redaction.d.ts +5 -6
- package/dist/run/trace-redaction.js +5 -6
- package/dist/run/worker/entry.js +35 -14
- package/dist/run/worker/handle.js +10 -5
- package/dist/run/worker/protocol.d.ts +83 -3
- package/dist/run/worker/session.js +28 -0
- package/dist/telemetry/environment.d.ts +2 -1
- package/dist/telemetry/environment.js +12 -2
- package/dist/telemetry/project.d.ts +5 -6
- package/dist/telemetry/project.js +6 -11
- package/dist/telemetry/store.d.ts +5 -5
- package/dist/telemetry/store.js +5 -7
- package/dist/telemetry/telemetry.d.ts +2 -3
- package/dist/telemetry/telemetry.js +2 -4
- package/dist/types.d.ts +6 -3
- package/docs/agent-steps.mdx +1 -0
- package/docs/agents.mdx +1 -0
- package/docs/api-testing.mdx +2 -1
- package/docs/authentication.mdx +3 -2
- package/docs/browser.mdx +7 -6
- package/docs/bug-bash.mdx +2 -1
- package/docs/cache.mdx +2 -1
- package/docs/ci.mdx +3 -2
- package/docs/coding-agents.mdx +2 -1
- package/docs/core-concepts.mdx +208 -0
- package/docs/debugging.mdx +19 -9
- package/docs/decision-models.mdx +142 -0
- package/docs/example-projects.mdx +32 -0
- package/docs/executors.mdx +3 -0
- package/docs/explore.mdx +1 -0
- package/docs/github.mdx +2 -1
- package/docs/index.mdx +9 -5
- package/docs/integrations/eas.mdx +1 -0
- package/docs/integrations/index.mdx +1 -0
- package/docs/integrations/kernel.mdx +2 -1
- package/docs/migrate/cypress.mdx +26 -3
- package/docs/migrate/detox.mdx +24 -1
- package/docs/migrate/maestro.mdx +28 -4
- package/docs/migrate/playwright.mdx +27 -4
- package/docs/migrate/selenium.mdx +27 -3
- package/docs/mobile-ci.mdx +6 -5
- package/docs/mobile.mdx +79 -8
- package/docs/models.mdx +7 -2
- package/docs/quickstart.mdx +26 -15
- package/docs/reference/agent.mdx +13 -3
- package/docs/reference/app.mdx +1 -0
- package/docs/reference/cli.mdx +11 -5
- package/docs/reference/config.mdx +42 -6
- package/docs/reference/engine.mdx +22 -10
- package/docs/reference/environment.mdx +2 -1
- package/docs/reference/errors.mdx +11 -8
- package/docs/reference/expect.mdx +2 -1
- package/docs/reference/mcp.mdx +21 -8
- package/docs/reference/mobile.mdx +1 -0
- package/docs/reference/reporters.mdx +1 -0
- package/docs/reference/screen.mdx +19 -9
- package/docs/reference/test.mdx +21 -1
- package/docs/reference/web.mdx +8 -2
- package/docs/security.mdx +33 -27
- package/docs/subscriptions.mdx +1 -0
- package/docs/telemetry.mdx +8 -5
- package/docs/tools.mdx +1 -0
- package/docs/{starting-your-app.mdx → web.mdx} +157 -11
- package/docs/writing-an-engine.mdx +2 -1
- package/package.json +11 -7
- package/skills/e2e/SKILL.md +3 -1
- package/skills/e2e/references/agent.md +11 -0
- package/skills/e2e/references/debugging.md +1 -1
- package/skills/e2e/references/mcp.md +8 -3
- package/skills/e2e/references/running.md +2 -1
- package/skills/e2e/references/setup.md +9 -1
- package/skills/e2e/references/writing-tests.md +1 -1
- package/docs/assertions.mdx +0 -94
- package/docs/goals.mdx +0 -176
- package/docs/locators.mdx +0 -64
- package/docs/physical-devices.mdx +0 -76
- package/docs/viewports.mdx +0 -146
package/dist/mcp/server.js
CHANGED
|
@@ -10,11 +10,11 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
|
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
11
11
|
import { readGuide, skillTopics } from '../cli/skill.js';
|
|
12
12
|
import { ConfigurationError, errorMessage } from '../internal/errors.js';
|
|
13
|
+
import { processSecrets } from '../run/secrecy.js';
|
|
13
14
|
import { loadProjectConfig, locateProjectConfig } from './config.js';
|
|
14
15
|
import { SessionHost } from './session.js';
|
|
15
|
-
import { errorResult } from './tools.js';
|
|
16
16
|
const INSTRUCTIONS = `e2e is a local-first end-to-end test runner; this server drives an e2e project's app (its e2e.config.ts) live.
|
|
17
|
-
Call open_session (optionally with a target
|
|
17
|
+
Call open_session (optionally with a target, a config path, and headed: true when the user wants to watch) to get a session, its tool catalog, and the first observation. Then call {tool, args} runs any catalog tool: observe, the grammar its engine honors, type_secret, locate, screenshot, start_recording and stop_recording when the engine records video (a video of the app for a pull request), and the project's own tools; tools lists them, tools {tool} shows one tool's arguments. close_session when done.
|
|
18
18
|
Several sessions can be open at once, each with its own browser or device, so parallel agents (subagents) each open their own: pass the session id from open_session to every tools, call, and close_session.
|
|
19
19
|
Write deterministic tests (tests/*.e2e.ts) from what you saw and run them with the CLI: npx e2e run <file>. Resources e2e://guide and e2e://guide/{topic} hold the writing guide.`;
|
|
20
20
|
/** Serves until the client disconnects or `signal` aborts; resolves with the exit code. */
|
|
@@ -26,9 +26,11 @@ export async function serveMcp(options) {
|
|
|
26
26
|
if (!disconnected && server.isConnected())
|
|
27
27
|
void server.sendLoggingMessage({ level, logger: 'e2e', data: message }).catch(() => undefined);
|
|
28
28
|
};
|
|
29
|
+
const output = options.output;
|
|
29
30
|
const host = new SessionHost({
|
|
30
31
|
locateConfig: (configPath) => locateProjectConfig({ cwd: options.cwd, configPath: configPath ?? options.configPath }),
|
|
31
32
|
loadConfig: (configPath) => loadProjectConfig(configPath, options.env),
|
|
33
|
+
withholdOutput: output === undefined ? undefined : (load) => output.withholdDuring(load),
|
|
32
34
|
env: options.env,
|
|
33
35
|
headed: options.headed,
|
|
34
36
|
maxSessions: options.maxSessions,
|
|
@@ -44,14 +46,9 @@ export async function serveMcp(options) {
|
|
|
44
46
|
description: spec.description,
|
|
45
47
|
inputSchema: spec.inputSchema,
|
|
46
48
|
annotations: { readOnlyHint: spec.readOnly, openWorldHint: false },
|
|
47
|
-
},
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return await spec.call(args, { signal: ctx.mcpReq.signal });
|
|
51
|
-
}
|
|
52
|
-
catch (cause) {
|
|
53
|
-
return errorResult(cause);
|
|
54
|
-
}
|
|
49
|
+
}, (args, ctx) => {
|
|
50
|
+
const call = () => spec.call(args, { signal: ctx.mcpReq.signal });
|
|
51
|
+
return output === undefined ? call() : output.duringCall(call);
|
|
55
52
|
});
|
|
56
53
|
}
|
|
57
54
|
registerGuide(server);
|
|
@@ -84,9 +81,9 @@ export async function serveMcp(options) {
|
|
|
84
81
|
options.log(summary);
|
|
85
82
|
}
|
|
86
83
|
catch (cause) {
|
|
87
|
-
options.log(`session teardown failed: ${errorMessage(cause)}`);
|
|
84
|
+
options.log(`session teardown failed: ${processSecrets.redact(errorMessage(cause))}`);
|
|
88
85
|
}
|
|
89
|
-
await server.close().catch((cause) => options.log(`server close failed: ${errorMessage(cause)}`));
|
|
86
|
+
await server.close().catch((cause) => options.log(`server close failed: ${processSecrets.redact(errorMessage(cause))}`));
|
|
90
87
|
return 0;
|
|
91
88
|
}
|
|
92
89
|
/** The skill as resources, for clients that read context without a tool call. */
|
package/dist/mcp/session.d.ts
CHANGED
|
@@ -25,13 +25,18 @@ export interface OpenSessionOptions {
|
|
|
25
25
|
readonly target?: string | undefined;
|
|
26
26
|
/** A config file to load instead of the server's default, relative to the server's directory. */
|
|
27
27
|
readonly config?: string | undefined;
|
|
28
|
+
/** Whether this session shows its UI; otherwise the server's `headed`. */
|
|
29
|
+
readonly headed?: boolean | undefined;
|
|
28
30
|
}
|
|
29
31
|
export interface SessionHostOptions {
|
|
30
32
|
/** The absolute path of the config a session loads, without evaluating it; `configPath` overrides the server's default. */
|
|
31
33
|
readonly locateConfig: (configPath: string | undefined) => string;
|
|
32
34
|
/** Loads the config at an absolute path fresh for each session, so an edited config applies without a restart. */
|
|
33
35
|
readonly loadConfig: (configPath: string) => Promise<LoadedConfig>;
|
|
36
|
+
/** Holds what the process prints while `load` evaluates a config and registers its secrets; by default nothing is held. */
|
|
37
|
+
readonly withholdOutput?: (<T>(load: () => Promise<T>) => Promise<T>) | undefined;
|
|
34
38
|
readonly env: NodeJS.ProcessEnv;
|
|
39
|
+
/** Whether a session shows its UI when `open_session` does not say, from `--headed`. */
|
|
35
40
|
readonly headed: boolean;
|
|
36
41
|
/** The target every session opens on, from `--target`; a call may still name one. */
|
|
37
42
|
readonly defaultTarget?: string | undefined;
|
|
@@ -54,8 +59,10 @@ export declare class SessionHost {
|
|
|
54
59
|
constructor(options: SessionHostOptions);
|
|
55
60
|
get isOpen(): boolean;
|
|
56
61
|
private get maxSessions();
|
|
57
|
-
/** The server's tools: the same four whatever the project, the config, or the target. */
|
|
62
|
+
/** The server's tools: the same four whatever the project, the config, or the target, each redacting what it returns. */
|
|
58
63
|
toolSpecs(): readonly McpToolSpec[];
|
|
64
|
+
/** Logs a line for the operator and the client, redacted with every secret value the process knows. */
|
|
65
|
+
private log;
|
|
59
66
|
/**
|
|
60
67
|
* Opens a session and returns its opening text: the summary, the catalog,
|
|
61
68
|
* and the first screen. `signal` is the request's: a client that cancels
|
package/dist/mcp/session.js
CHANGED
|
@@ -21,7 +21,7 @@ import { LocatorEngine } from '../locator/engine.js';
|
|
|
21
21
|
import { allocateAppPorts } from '../run/app-ports.js';
|
|
22
22
|
import { SharedAppProcesses } from '../run/process-pool.js';
|
|
23
23
|
import { outputLayout } from '../run/output.js';
|
|
24
|
-
import { sessionSecrecy } from '../run/secrecy.js';
|
|
24
|
+
import { processSecrets, registerStaticSecrets, sessionSecrecy } from '../run/secrecy.js';
|
|
25
25
|
import { openStandaloneAttempt } from '../run/standalone.js';
|
|
26
26
|
import { createSessionCatalog, isGrammarVerb } from './catalog.js';
|
|
27
27
|
import { describeRecording, SessionRecorder } from './recording.js';
|
|
@@ -55,9 +55,13 @@ export class SessionHost {
|
|
|
55
55
|
get maxSessions() {
|
|
56
56
|
return this.options.maxSessions ?? SESSION_BOUNDS.default;
|
|
57
57
|
}
|
|
58
|
-
/** The server's tools: the same four whatever the project, the config, or the target. */
|
|
58
|
+
/** The server's tools: the same four whatever the project, the config, or the target, each redacting what it returns. */
|
|
59
59
|
toolSpecs() {
|
|
60
|
-
return [this.openSpec(), this.catalogSpec(), this.callSpec(), this.closeSpec()];
|
|
60
|
+
return [this.openSpec(), this.catalogSpec(), this.callSpec(), this.closeSpec()].map(redacting);
|
|
61
|
+
}
|
|
62
|
+
/** Logs a line for the operator and the client, redacted with every secret value the process knows. */
|
|
63
|
+
log(level, message) {
|
|
64
|
+
this.options.log(level, processSecrets.redact(message));
|
|
61
65
|
}
|
|
62
66
|
/**
|
|
63
67
|
* Opens a session and returns its opening text: the summary, the catalog,
|
|
@@ -65,8 +69,9 @@ export class SessionHost {
|
|
|
65
69
|
* it, or disconnects, aborts the open and what it started.
|
|
66
70
|
*/
|
|
67
71
|
open(options, signal) {
|
|
68
|
-
const
|
|
69
|
-
|
|
72
|
+
const headed = options.headed ?? this.options.headed;
|
|
73
|
+
const usage = new SessionUsage(this.sessions.liveCount, headed);
|
|
74
|
+
return this.sessions.admit((id) => this.openSession(id, options, headed, usage, signal)).catch((cause) => {
|
|
70
75
|
this.options.onSessionEnd?.(usage.openFailed(classifyError(cause).code));
|
|
71
76
|
throw cause;
|
|
72
77
|
});
|
|
@@ -99,7 +104,8 @@ export class SessionHost {
|
|
|
99
104
|
lines.push(`The session step ended with: ${errorMessage(value.outcome.error)}`);
|
|
100
105
|
for (const error of cleanupErrors)
|
|
101
106
|
lines.push(`Cleanup: ${error.code}: ${error.message}`);
|
|
102
|
-
|
|
107
|
+
// Redacted here, not only at the tool boundary: closeAll writes it to stderr.
|
|
108
|
+
return processSecrets.redact(lines.join('\n'));
|
|
103
109
|
}
|
|
104
110
|
/**
|
|
105
111
|
* Runs `settle` (ending the step, saving a recording), then closes the
|
|
@@ -149,7 +155,7 @@ export class SessionHost {
|
|
|
149
155
|
return redactResult(errorResult(cause), redact);
|
|
150
156
|
});
|
|
151
157
|
}
|
|
152
|
-
async openSession(id, options, usage, request) {
|
|
158
|
+
async openSession(id, options, headed, usage, request) {
|
|
153
159
|
// The catalog renders synchronously, so the optional SDK is loaded once
|
|
154
160
|
// here when installed. Without it the catalog reads the tools' Standard
|
|
155
161
|
// Schemas, and only a model-backed call needs the package.
|
|
@@ -159,7 +165,16 @@ export class SessionHost {
|
|
|
159
165
|
// knows that session's config.
|
|
160
166
|
const configPath = this.options.locateConfig(options.config);
|
|
161
167
|
this.sessions.claimConfig(id, configPath);
|
|
162
|
-
const
|
|
168
|
+
const withhold = this.options.withholdOutput ?? ((load) => load());
|
|
169
|
+
const loaded = await withhold(async () => {
|
|
170
|
+
const fresh = await this.options.loadConfig(configPath);
|
|
171
|
+
// Known to the process before anything can fail with one, so an open
|
|
172
|
+
// failure and what the config printed while it loaded are redacted
|
|
173
|
+
// like any other text; a session registers them only once its engine
|
|
174
|
+
// has launched.
|
|
175
|
+
registerStaticSecrets(fresh.allSecrets);
|
|
176
|
+
return fresh;
|
|
177
|
+
});
|
|
163
178
|
// A session is its own run: a URL declared with port 0 gets a port here.
|
|
164
179
|
const config = await allocateAppPorts(loaded);
|
|
165
180
|
const target = this.resolveTarget(config, options.target);
|
|
@@ -184,12 +199,12 @@ export class SessionHost {
|
|
|
184
199
|
// one attempt that closes as passed, so it traces under `on` only.
|
|
185
200
|
config,
|
|
186
201
|
target: { ...target, video: { mode: 'off', source: 'default' } },
|
|
187
|
-
headed
|
|
202
|
+
headed,
|
|
188
203
|
env: this.options.env,
|
|
189
204
|
signal: abort.signal,
|
|
190
205
|
timeoutMs: ttlMs + CLOSE_GRACE_MS,
|
|
191
206
|
processes: this.apps,
|
|
192
|
-
notice: (scope, message) => this.
|
|
207
|
+
notice: (scope, message) => this.log('info', `${scope}: ${message}`),
|
|
193
208
|
});
|
|
194
209
|
// The coding agent is the brain: the step is driven from here, and the
|
|
195
210
|
// configured model stays out of the way.
|
|
@@ -217,13 +232,14 @@ export class SessionHost {
|
|
|
217
232
|
actionTimeout: config.actionTimeout,
|
|
218
233
|
assertionTimeout: config.assertionTimeout,
|
|
219
234
|
}),
|
|
220
|
-
warn: (message) => this.
|
|
235
|
+
warn: (message) => this.log('warning', message),
|
|
221
236
|
onActionFailed: (cause) => usage.failed(classifyError(cause).code),
|
|
222
237
|
});
|
|
223
238
|
const live = {
|
|
224
239
|
id,
|
|
225
240
|
target,
|
|
226
241
|
configPath: loaded.configPath,
|
|
242
|
+
headed,
|
|
227
243
|
attempt,
|
|
228
244
|
step,
|
|
229
245
|
screen,
|
|
@@ -257,8 +273,8 @@ export class SessionHost {
|
|
|
257
273
|
endOnItsOwn(live, why, endedBy) {
|
|
258
274
|
if (!this.sessions.isLive(live.id))
|
|
259
275
|
return;
|
|
260
|
-
this.
|
|
261
|
-
this.close(why, live.id, endedBy).catch((cause) => this.
|
|
276
|
+
this.log('warning', `session ${live.id} ended: ${why}`);
|
|
277
|
+
this.close(why, live.id, endedBy).catch((cause) => this.log('error', `closing session ${live.id} failed: ${errorMessage(cause)}`));
|
|
262
278
|
}
|
|
263
279
|
/** A recorder writing to `<output>/videos/<session>/`, when the engine records video. */
|
|
264
280
|
recorder(id, config, attempt) {
|
|
@@ -315,7 +331,7 @@ export class SessionHost {
|
|
|
315
331
|
openingText(live, config, screen) {
|
|
316
332
|
const engine = live.target.engine;
|
|
317
333
|
const lines = [
|
|
318
|
-
`Session ${live.id} open on target "${live.target.name}" (platform ${live.target.platform}, engine ${engine === undefined ? 'none' : `${engine.name} ${engine.version}`}), ${
|
|
334
|
+
`Session ${live.id} open on target "${live.target.name}" (platform ${live.target.platform}, engine ${engine === undefined ? 'none' : `${engine.name} ${engine.version}`}), ${live.headed ? 'headed' : 'headless'}; config ${live.configPath}.`,
|
|
319
335
|
];
|
|
320
336
|
if (live.target.app.base !== undefined) {
|
|
321
337
|
lines.push(`App: ${live.target.app.base.href}.`);
|
|
@@ -385,6 +401,10 @@ export class SessionHost {
|
|
|
385
401
|
inputSchema: z.object({
|
|
386
402
|
target: z.string().min(1).optional().describe('Target name from the config; required when the config declares several'),
|
|
387
403
|
config: z.string().min(1).optional().describe("Path to an e2e config file, relative to the server's directory; default: the nearest e2e.config.ts"),
|
|
404
|
+
headed: z
|
|
405
|
+
.boolean()
|
|
406
|
+
.optional()
|
|
407
|
+
.describe('Show the browser or simulator, when the engine supports it; pass true when the user wants to watch. Default: headless, unless the server was started with --headed'),
|
|
388
408
|
}).strict(),
|
|
389
409
|
readOnly: false,
|
|
390
410
|
call: async (args, extra) => textResult(await this.open(args, extra.signal)),
|
|
@@ -427,3 +447,24 @@ export class SessionHost {
|
|
|
427
447
|
});
|
|
428
448
|
}
|
|
429
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* The tool with what it returns redacted with every secret value the process
|
|
452
|
+
* knows: the static values of each config a session loaded, and every value
|
|
453
|
+
* a session resolved or derived. A failure becomes a result the agent can
|
|
454
|
+
* react to, never a protocol error, and is redacted the same way.
|
|
455
|
+
*/
|
|
456
|
+
function redacting(spec) {
|
|
457
|
+
return {
|
|
458
|
+
...spec,
|
|
459
|
+
call: async (args, extra) => {
|
|
460
|
+
let result;
|
|
461
|
+
try {
|
|
462
|
+
result = await spec.call(args, extra);
|
|
463
|
+
}
|
|
464
|
+
catch (cause) {
|
|
465
|
+
result = errorResult(cause);
|
|
466
|
+
}
|
|
467
|
+
return redactResult(result, (text) => processSecrets.redact(text));
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|
package/dist/mcp/tools.js
CHANGED
|
@@ -192,13 +192,15 @@ async function resolveOutput(value) {
|
|
|
192
192
|
* else is JSON, so a structured output is never lost.
|
|
193
193
|
*/
|
|
194
194
|
export async function resultFromOutput(tool, output, input = undefined) {
|
|
195
|
-
if (typeof output === 'string')
|
|
196
|
-
return textResult(output);
|
|
197
195
|
if (tool.toModelOutput !== undefined) {
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
196
|
+
const rendered = await tool.toModelOutput({ toolCallId: 'mcp', input, output });
|
|
197
|
+
const content = contentFromModelOutput(rendered);
|
|
198
|
+
if (content !== undefined) {
|
|
199
|
+
return { content, ...(rendered.type === 'error-text' || rendered.type === 'error-json' ? { isError: true } : {}) };
|
|
200
|
+
}
|
|
201
201
|
}
|
|
202
|
+
if (typeof output === 'string')
|
|
203
|
+
return textResult(output);
|
|
202
204
|
return textResult(output === undefined ? 'Done.' : JSON.stringify(output, null, 2));
|
|
203
205
|
}
|
|
204
206
|
function contentFromModelOutput(output) {
|
package/dist/report/list.d.ts
CHANGED
|
@@ -120,6 +120,17 @@ export declare class ListReporter implements Reporter {
|
|
|
120
120
|
* the test's result, or the end of the run.
|
|
121
121
|
*/
|
|
122
122
|
private testOutput;
|
|
123
|
+
/**
|
|
124
|
+
* Text user code wrote in the runner's own process (the config's top
|
|
125
|
+
* level, a test file's while it is collected, a reporter), printed like a
|
|
126
|
+
* test's output under a `stdout | runner` heading. The runner hands it
|
|
127
|
+
* here already redacted, only while this reporter shows the run.
|
|
128
|
+
*/
|
|
129
|
+
processOutput(stream: 'stdout' | 'stderr', text: string): void;
|
|
130
|
+
/** Prints what `processOutput` left unfinished: the runner hands nothing more once the run's events are all dispatched. */
|
|
131
|
+
endProcessOutput(): void;
|
|
132
|
+
/** Prints the lines `text` ends under the heading of `stream` and `source`, holding the unfinished rest. */
|
|
133
|
+
private sourcedOutput;
|
|
123
134
|
/** Prints finished output lines under their heading, unless the heading is the one printed last. */
|
|
124
135
|
private printOutput;
|
|
125
136
|
/** Prints the unfinished output of `pair`, or of every source when undefined. */
|
package/dist/report/list.js
CHANGED
|
@@ -272,20 +272,37 @@ export class ListReporter {
|
|
|
272
272
|
* the test's result, or the end of the run.
|
|
273
273
|
*/
|
|
274
274
|
testOutput(event) {
|
|
275
|
-
const { pc } = this;
|
|
276
|
-
if (event.text === '')
|
|
277
|
-
return;
|
|
278
275
|
const pair = event.pair === undefined ? undefined : pairKey(event.pair.testId, event.pair.agent, event.target, event.pair.repeat);
|
|
279
276
|
const running = pair === undefined ? undefined : this.pairs.get(pair);
|
|
280
277
|
const source = running === undefined ? this.badge(event.target) : `${this.badge(running.group.target)} ${bounded(running.group.file)}${this.separator}${running.title}`;
|
|
281
|
-
|
|
278
|
+
this.sourcedOutput(event.stream, source, pair, event.text);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Text user code wrote in the runner's own process (the config's top
|
|
282
|
+
* level, a test file's while it is collected, a reporter), printed like a
|
|
283
|
+
* test's output under a `stdout | runner` heading. The runner hands it
|
|
284
|
+
* here already redacted, only while this reporter shows the run.
|
|
285
|
+
*/
|
|
286
|
+
processOutput(stream, text) {
|
|
287
|
+
this.sourcedOutput(stream, this.pc.dim('runner'), undefined, text);
|
|
288
|
+
}
|
|
289
|
+
/** Prints what `processOutput` left unfinished: the runner hands nothing more once the run's events are all dispatched. */
|
|
290
|
+
endProcessOutput() {
|
|
291
|
+
this.flushOutput();
|
|
292
|
+
}
|
|
293
|
+
/** Prints the lines `text` ends under the heading of `stream` and `source`, holding the unfinished rest. */
|
|
294
|
+
sourcedOutput(stream, source, pair, text) {
|
|
295
|
+
const { pc } = this;
|
|
296
|
+
if (text === '')
|
|
297
|
+
return;
|
|
298
|
+
const key = `${stream}\u0000${source}`;
|
|
282
299
|
let entry = this.pendingOutput.get(key);
|
|
283
300
|
if (entry === undefined) {
|
|
284
|
-
const label =
|
|
301
|
+
const label = stream === 'stderr' ? pc.yellow(stream) : pc.dim(stream);
|
|
285
302
|
entry = { heading: `${label} ${pc.dim('|')} ${source}`, pair, fragment: '' };
|
|
286
303
|
this.pendingOutput.set(key, entry);
|
|
287
304
|
}
|
|
288
|
-
const lines = (entry.fragment +
|
|
305
|
+
const lines = (entry.fragment + text).split('\n');
|
|
289
306
|
entry.fragment = lines.pop() ?? '';
|
|
290
307
|
if (entry.fragment === '')
|
|
291
308
|
this.pendingOutput.delete(key);
|
package/dist/run/artifacts.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { ArtifactStore } from '../types.ts';
|
|
3
3
|
import type { ArtifactSink } from './fixtures.ts';
|
|
4
4
|
import type { ArtifactRecord } from './records.ts';
|
|
5
|
-
import type
|
|
5
|
+
import { type SessionSecrecy } from './secrecy.ts';
|
|
6
6
|
export interface AttemptArtifacts {
|
|
7
7
|
/** Absolute attempt artifact directory, created eagerly. */
|
|
8
8
|
readonly dir: string;
|
|
@@ -38,9 +38,9 @@ export declare function createAttemptArtifacts(options: {
|
|
|
38
38
|
/**
|
|
39
39
|
* The secrecy of the session the attempt runs on, read when a download is
|
|
40
40
|
* registered; undefined (no session open yet) leaves every download as
|
|
41
|
-
* served.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* served. While its ledger holds a value (`redactsRecordings`), a
|
|
42
|
+
* text-like download is rewritten through it before it is hashed or
|
|
43
|
+
* stored.
|
|
44
44
|
*/
|
|
45
45
|
secrecy?: () => SessionSecrecy | undefined;
|
|
46
46
|
/**
|
package/dist/run/artifacts.js
CHANGED
|
@@ -5,6 +5,7 @@ import { mkdir, readdir, readFile, rm, rmdir, stat } from 'node:fs/promises';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { pipeline } from 'node:stream/promises';
|
|
7
7
|
import { writeFileAtomic } from '../internal/atomic-write.js';
|
|
8
|
+
import { redactsRecordings } from './secrecy.js';
|
|
8
9
|
/**
|
|
9
10
|
* How much of each kind the runner masked, unless the registration says. A
|
|
10
11
|
* screenshot masks secure fields at the source; a log passes through the
|
|
@@ -60,7 +61,7 @@ export function createAttemptArtifacts(options) {
|
|
|
60
61
|
const reportPath = path.posix.join(...options.segments, relativePath);
|
|
61
62
|
const secrecy = kind === 'download' && registration?.redaction === undefined ? options.secrecy?.() : undefined;
|
|
62
63
|
pending.push((async () => {
|
|
63
|
-
if (secrecy !== undefined && secrecy
|
|
64
|
+
if (secrecy !== undefined && redactsRecordings(secrecy) && isTextLike(record.mediaType)) {
|
|
64
65
|
record.redaction = await redactDownload(absolute, secrecy);
|
|
65
66
|
}
|
|
66
67
|
// Without a store the file is streamed for its size and digest only;
|
|
@@ -235,7 +236,7 @@ async function redactDownload(absolute, secrecy) {
|
|
|
235
236
|
try {
|
|
236
237
|
const bytes = await readFile(absolute);
|
|
237
238
|
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
238
|
-
const redacted = secrecy.ledger.
|
|
239
|
+
const redacted = secrecy.ledger.redactFragments(text);
|
|
239
240
|
if (redacted !== text)
|
|
240
241
|
await writeFileAtomic(absolute, redacted);
|
|
241
242
|
return 'complete';
|
package/dist/run/execute.d.ts
CHANGED
|
@@ -9,17 +9,33 @@ import type { AttemptRecordings } from '../internal/recording-modes.ts';
|
|
|
9
9
|
import type { ArtifactStore } from '../types.ts';
|
|
10
10
|
import { type ArtifactSink } from './fixtures.ts';
|
|
11
11
|
import { RealmManager, type FileRef, type Realm } from './realm.ts';
|
|
12
|
-
import type { AttemptRecord, ResultRecord, RunError, SerialGroupRecord } from './records.ts';
|
|
13
|
-
import { type SerialHost, type SharedSerialSession } from './serial.ts';
|
|
12
|
+
import type { AttemptRecord, ResultRecord, RunError, SerialGroupRecord, SerialMemberRecord } from './records.ts';
|
|
13
|
+
import { type SerialAttemptRun, type SerialAttemptStart, type SerialHost, type SharedSerialSession } from './serial.ts';
|
|
14
14
|
import { SessionStaging, SessionStore } from './sessions.ts';
|
|
15
15
|
import { type StepProgress } from './steps.ts';
|
|
16
16
|
export interface ExecutionEvents {
|
|
17
17
|
onResult?(result: ResultRecord): void;
|
|
18
18
|
onSerialGroup?(group: SerialGroupRecord): void;
|
|
19
|
+
/** An attempt of an ordinary or setup pair begins, its realm and `beforeAll` hooks included. */
|
|
20
|
+
onAttemptStart?(pair: TestTargetPair, attemptIndex: number): void;
|
|
21
|
+
/** One finished attempt of an ordinary or setup pair, ahead of the pair's result. */
|
|
22
|
+
onAttempt?(pair: TestTargetPair, attempt: AttemptRecord): void;
|
|
23
|
+
/** One member that finished in a serial group attempt, ahead of the group. */
|
|
24
|
+
onSerialMember?(groupId: string, attempt: SerialAttemptStart, member: SerialMemberRecord): void;
|
|
25
|
+
/** One finished serial group attempt, ahead of the group. */
|
|
26
|
+
onSerialAttempt?(groupId: string, run: SerialAttemptRun): void;
|
|
19
27
|
/** Fires before a runnable pair starts; a serial unit announces each member as it begins. */
|
|
20
28
|
onPairStart?(pair: TestTargetPair): void;
|
|
21
29
|
/** Live step progress of one running attempt, for reporters. */
|
|
22
30
|
onProgress?(pair: TestTargetPair, progress: StepProgress): void;
|
|
31
|
+
/** An attempt's test timeout started, as its `beforeEach` hooks and body begin. */
|
|
32
|
+
onAttemptDeadline?(pair: TestTargetPair, attempt: {
|
|
33
|
+
readonly index: number;
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly startedAt: string;
|
|
36
|
+
}): void;
|
|
37
|
+
/** The attempt `onAttemptDeadline` announced has ended, or one that never got that far has. */
|
|
38
|
+
onAttemptEnd?(): void;
|
|
23
39
|
/** One line of progress the engine's `init` reported, already naming the target and worker slot. */
|
|
24
40
|
onNotice?(message: string): void;
|
|
25
41
|
/**
|
|
@@ -132,6 +148,10 @@ export declare class TargetExecutor implements SerialHost {
|
|
|
132
148
|
pairStarted(pair: TestTargetPair): void;
|
|
133
149
|
/** Emits one finished serial group, ahead of its members' results (SerialHost). */
|
|
134
150
|
emitSerialGroup(group: SerialGroupRecord): void;
|
|
151
|
+
/** Streams one member finished in a serial group attempt (SerialHost). */
|
|
152
|
+
serialMemberFinished(groupId: string, attempt: SerialAttemptStart, member: SerialMemberRecord): void;
|
|
153
|
+
/** Streams one finished serial group attempt (SerialHost). */
|
|
154
|
+
serialAttemptFinished(groupId: string, run: SerialAttemptRun): void;
|
|
135
155
|
/** Builds one engine operation context. */
|
|
136
156
|
private op;
|
|
137
157
|
/** Run-level errors recorded so far, in order. */
|
|
@@ -196,9 +216,9 @@ export declare class TargetExecutor implements SerialHost {
|
|
|
196
216
|
* hands the app (basic-auth credentials). Registered for the session's and
|
|
197
217
|
* the process's redaction by `resolveSecretValue`, with every value the
|
|
198
218
|
* engine derives from it (the base64 credential an `Authorization` header
|
|
199
|
-
* carries) under the same name
|
|
200
|
-
*
|
|
201
|
-
*
|
|
219
|
+
* carries) under the same name, so its text, trace, and text downloads
|
|
220
|
+
* are redacted of them. Its pixels stay as they are, since nothing was
|
|
221
|
+
* typed.
|
|
202
222
|
*/
|
|
203
223
|
private resolveEngineSecret;
|
|
204
224
|
/**
|
package/dist/run/execute.js
CHANGED
|
@@ -26,7 +26,7 @@ import { isFailedStatus } from './records.js';
|
|
|
26
26
|
import { runWithRetries } from './retry.js';
|
|
27
27
|
import { runSerialUnit } from './serial.js';
|
|
28
28
|
import { interruptedSkip, pairKey, pairResult, repeatSegment, unstartedResult } from './units.js';
|
|
29
|
-
import { adoptSecrecy, carriedSecrecy, processSecrets, registerDerivedSecrets, redactForSession, resolveSecretValue, sessionSecrecy, staticSecretLedger } from './secrecy.js';
|
|
29
|
+
import { adoptSecrecy, carriedSecrecy, processSecrets, registerDerivedSecrets, redactForSession, redactsRecordings, resolveSecretValue, sessionSecrecy, staticSecretLedger } from './secrecy.js';
|
|
30
30
|
import { isSecret } from '../secrets.js';
|
|
31
31
|
import { SessionStaging, SessionStore, targetIdentity } from './sessions.js';
|
|
32
32
|
import { redactTraceArchives } from './trace-redaction.js';
|
|
@@ -97,6 +97,14 @@ export class TargetExecutor {
|
|
|
97
97
|
emitSerialGroup(group) {
|
|
98
98
|
this.options.events?.onSerialGroup?.(group);
|
|
99
99
|
}
|
|
100
|
+
/** Streams one member finished in a serial group attempt (SerialHost). */
|
|
101
|
+
serialMemberFinished(groupId, attempt, member) {
|
|
102
|
+
this.options.events?.onSerialMember?.(groupId, attempt, member);
|
|
103
|
+
}
|
|
104
|
+
/** Streams one finished serial group attempt (SerialHost). */
|
|
105
|
+
serialAttemptFinished(groupId, run) {
|
|
106
|
+
this.options.events?.onSerialAttempt?.(groupId, run);
|
|
107
|
+
}
|
|
100
108
|
/** Builds one engine operation context. */
|
|
101
109
|
op(attemptId, timeoutMs, signal) {
|
|
102
110
|
return { signal, timeoutMs, runId: this.options.runId, attemptId, origin: 'test' };
|
|
@@ -256,6 +264,7 @@ export class TargetExecutor {
|
|
|
256
264
|
let realm = incomingRealm;
|
|
257
265
|
let hookFailure;
|
|
258
266
|
const finalStatus = await runWithRetries(pair.options.retries + 1, this.interruptSignal, async (attemptIndex) => {
|
|
267
|
+
this.options.events?.onAttemptStart?.(pair, attemptIndex);
|
|
259
268
|
if (realm === null)
|
|
260
269
|
realm = await this.realms.create(file);
|
|
261
270
|
const registered = findRegistered(realm, pair.test);
|
|
@@ -270,6 +279,7 @@ export class TargetExecutor {
|
|
|
270
279
|
kind: 'ordinary',
|
|
271
280
|
});
|
|
272
281
|
attempts.push(attempt);
|
|
282
|
+
this.options.events?.onAttempt?.(pair, attempt);
|
|
273
283
|
if (isFailedStatus(attempt.status)) {
|
|
274
284
|
// A failed realm is never reused, but afterAll still runs for every
|
|
275
285
|
// scope whose beforeAll started in it. A body that skipped itself
|
|
@@ -316,6 +326,7 @@ export class TargetExecutor {
|
|
|
316
326
|
const attempts = [];
|
|
317
327
|
let hookFailure;
|
|
318
328
|
const finalStatus = await runWithRetries(pair.options.retries + 1, this.interruptSignal, async (attemptIndex) => {
|
|
329
|
+
this.options.events?.onAttemptStart?.(pair, attemptIndex);
|
|
319
330
|
const realm = attemptIndex === 0 && freshRegistration !== undefined
|
|
320
331
|
? this.realms.adopt(freshRegistration, file)
|
|
321
332
|
: await this.realms.create(file);
|
|
@@ -334,15 +345,17 @@ export class TargetExecutor {
|
|
|
334
345
|
kind: 'setup',
|
|
335
346
|
staging,
|
|
336
347
|
});
|
|
348
|
+
const missing = attempt.status === 'passed' ? staging.missing() : [];
|
|
349
|
+
if (missing.length > 0) {
|
|
350
|
+
attempt.status = 'failed';
|
|
351
|
+
attempt.error = serializeError(new E2EError('test', 'SESSION_CONTRACT', `setup must save each declared session exactly once; missing: [${missing.join(', ')}]`), { phase: 'body' });
|
|
352
|
+
}
|
|
337
353
|
attempts.push(attempt);
|
|
354
|
+
// Sent before teardown and the session saves, as in runOrdinaryPair:
|
|
355
|
+
// a crash in either then lands on this attempt instead of erasing it.
|
|
356
|
+
this.options.events?.onAttempt?.(pair, attempt);
|
|
338
357
|
await this.realms.leave(realm);
|
|
339
358
|
if (attempt.status === 'passed') {
|
|
340
|
-
const missing = staging.missing();
|
|
341
|
-
if (missing.length > 0) {
|
|
342
|
-
attempt.status = 'failed';
|
|
343
|
-
attempt.error = serializeError(new E2EError('test', 'SESSION_CONTRACT', `setup must save each declared session exactly once; missing: [${missing.join(', ')}]`), { phase: 'body' });
|
|
344
|
-
return attempt;
|
|
345
|
-
}
|
|
346
359
|
for (const [name, saved] of staging.entries()) {
|
|
347
360
|
await this.options.sessionStore.save(name, this.sessionIdentity, saved);
|
|
348
361
|
}
|
|
@@ -419,9 +432,9 @@ export class TargetExecutor {
|
|
|
419
432
|
* hands the app (basic-auth credentials). Registered for the session's and
|
|
420
433
|
* the process's redaction by `resolveSecretValue`, with every value the
|
|
421
434
|
* engine derives from it (the base64 credential an `Authorization` header
|
|
422
|
-
* carries) under the same name
|
|
423
|
-
*
|
|
424
|
-
*
|
|
435
|
+
* carries) under the same name, so its text, trace, and text downloads
|
|
436
|
+
* are redacted of them. Its pixels stay as they are, since nothing was
|
|
437
|
+
* typed.
|
|
425
438
|
*/
|
|
426
439
|
async resolveEngineSecret(session, secret, options) {
|
|
427
440
|
const engine = this.target.engine;
|
|
@@ -432,7 +445,6 @@ export class TargetExecutor {
|
|
|
432
445
|
const secrecy = sessionSecrecy(session, this.config.allSecrets);
|
|
433
446
|
const plaintext = await resolveSecretValue(secret, this.config.allSecrets, secrecy.ledger);
|
|
434
447
|
registerDerivedSecrets(secret.name, options?.derived?.(plaintext) ?? [], secrecy.ledger);
|
|
435
|
-
secrecy.exposure.raise('engine');
|
|
436
448
|
return plaintext;
|
|
437
449
|
}
|
|
438
450
|
/**
|
|
@@ -479,14 +491,14 @@ export class TargetExecutor {
|
|
|
479
491
|
}
|
|
480
492
|
// An engine records what happened, filled secrets included, so the
|
|
481
493
|
// trace is the runner's to redact before anything hashes or stores
|
|
482
|
-
// it.
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
494
|
+
// it. Any value the ledger holds may be in it, filled or not (a URL
|
|
495
|
+
// the test spelled it into, an engine option), so a trace is kept
|
|
496
|
+
// only once rewritten; only a session that knows no value skips it.
|
|
497
|
+
// Its screencast frames go only where pixels are withheld, after a
|
|
498
|
+
// fill.
|
|
487
499
|
const secrecy = sessionSecrecy(session, this.config.allSecrets);
|
|
488
500
|
let redaction = 'not-required';
|
|
489
|
-
if (secrecy
|
|
501
|
+
if (redactsRecordings(secrecy)) {
|
|
490
502
|
try {
|
|
491
503
|
await redactTraceArchives(artifactSink.dir, archives, secrecy.ledger, { keepFrames: !secrecy.exposure.withholdsPixels });
|
|
492
504
|
}
|
|
@@ -540,7 +552,14 @@ export class TargetExecutor {
|
|
|
540
552
|
target: this.target.name,
|
|
541
553
|
agent: pair.agent,
|
|
542
554
|
attempt: attemptIndex,
|
|
543
|
-
}, () =>
|
|
555
|
+
}, async () => {
|
|
556
|
+
try {
|
|
557
|
+
return await this.executeAttempt(pair, registered, realm, attemptIndex, context);
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
this.options.events?.onAttemptEnd?.();
|
|
561
|
+
}
|
|
562
|
+
});
|
|
544
563
|
}
|
|
545
564
|
async executeAttempt(pair, registered, realm, attemptIndex, context) {
|
|
546
565
|
const attemptId = uuidv7();
|
|
@@ -654,9 +673,13 @@ export class TargetExecutor {
|
|
|
654
673
|
// One more look at the app the moment the failure lands: what the screen
|
|
655
674
|
// held then is the evidence the message lacks. Taken before teardown, so
|
|
656
675
|
// an `afterEach` that navigates away or resets state cannot replace it.
|
|
676
|
+
// Once per attempt: a screen that yielded nothing within the budget is
|
|
677
|
+
// not asked again, so a hung app costs the budget once.
|
|
678
|
+
let evidenceTried = false;
|
|
657
679
|
const captureEvidence = async () => {
|
|
658
|
-
if (failure === undefined ||
|
|
680
|
+
if (failure === undefined || evidenceTried || openSession === null || this.interruptSignal.aborted)
|
|
659
681
|
return;
|
|
682
|
+
evidenceTried = true;
|
|
660
683
|
const evidence = await captureFailureEvidence({
|
|
661
684
|
session: openSession,
|
|
662
685
|
error: failure,
|
|
@@ -700,6 +723,7 @@ export class TargetExecutor {
|
|
|
700
723
|
(await this.launchSession({ session: pair.options.session, recordings }, attemptId, artifacts.dir, attemptAbort.signal));
|
|
701
724
|
openSession = session;
|
|
702
725
|
const testDeadline = new Deadline(pair.options.timeout);
|
|
726
|
+
this.options.events?.onAttemptDeadline?.(pair, { index: attemptIndex, id: attemptId, startedAt });
|
|
703
727
|
const budget = new AttemptBudget(attemptAbort.signal, testDeadline);
|
|
704
728
|
const saveSession = context.kind !== 'setup'
|
|
705
729
|
? undefined
|
|
@@ -936,7 +960,7 @@ export class TargetExecutor {
|
|
|
936
960
|
function classifyAttemptStatus(failure, timedOut, interrupted) {
|
|
937
961
|
if (interrupted && !timedOut)
|
|
938
962
|
return 'interrupted';
|
|
939
|
-
if (timedOut || failure
|
|
963
|
+
if (timedOut || failure.code === 'TEST_TIMEOUT') {
|
|
940
964
|
return 'timed-out';
|
|
941
965
|
}
|
|
942
966
|
return 'failed';
|
|
@@ -14,6 +14,7 @@ import path from 'node:path';
|
|
|
14
14
|
import { formatNode, prepareObservation } from '../agent/observation.js';
|
|
15
15
|
import { truncateUtf8 } from '../internal/errors.js';
|
|
16
16
|
import { isTypoOf } from '../internal/suggest.js';
|
|
17
|
+
import { withAbort } from '../internal/time.js';
|
|
17
18
|
/** The whole capture, observation and screenshot together, gets this long. */
|
|
18
19
|
const EVIDENCE_TIMEOUT_MS = 5_000;
|
|
19
20
|
/** Screen lines a locator failure lists as its nearest nodes, and the bytes each keeps; the wire schema caps both. */
|
|
@@ -37,9 +38,12 @@ export async function captureFailureEvidence(options) {
|
|
|
37
38
|
return finish(evidence);
|
|
38
39
|
const operation = options.operation(signal, EVIDENCE_TIMEOUT_MS);
|
|
39
40
|
const { redact, redactCut } = options.secrecy.ledger;
|
|
41
|
+
// The engine is handed the budget, and the wait is abandoned at its end
|
|
42
|
+
// either way: an engine stuck on a hung app may never honor it.
|
|
43
|
+
const bounded = (work) => withAbort(work, signal, () => new Error('failure evidence ran out of budget'));
|
|
40
44
|
let observation;
|
|
41
45
|
try {
|
|
42
|
-
const raw = await options.session.observe(operation);
|
|
46
|
+
const raw = await bounded(() => options.session.observe(operation));
|
|
43
47
|
observation = prepareObservation(raw, {
|
|
44
48
|
redact,
|
|
45
49
|
redactCut,
|
|
@@ -73,7 +77,7 @@ export async function captureFailureEvidence(options) {
|
|
|
73
77
|
// prove a tainted viewport redacted.
|
|
74
78
|
if (!options.secrecy.exposure.withholdsPixels && !signal.aborted) {
|
|
75
79
|
try {
|
|
76
|
-
const relative = await options.session.artifacts.screenshot('failure', operation);
|
|
80
|
+
const relative = await bounded(() => options.session.artifacts.screenshot('failure', operation));
|
|
77
81
|
evidence.screenshot = options.artifacts.register('screenshot', relative);
|
|
78
82
|
}
|
|
79
83
|
catch {
|