e2e 0.21.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build.json +1 -1
- package/dist/agent/executor.d.ts +7 -0
- package/dist/agent/observation-feed.js +1 -0
- package/dist/agent/observation.d.ts +2 -0
- package/dist/agent/observation.js +1 -0
- package/dist/cli/bin.js +2 -0
- package/dist/cli/broken-pipe.d.ts +10 -0
- package/dist/cli/broken-pipe.js +17 -0
- package/dist/cli/index.js +4 -3
- package/dist/cli/init/sibling-versions.json +1 -1
- package/dist/cli/run-output.d.ts +1 -1
- package/dist/cli/run-output.js +2 -20
- package/dist/engine/index.d.ts +7 -0
- package/dist/engine/session.js +1 -0
- package/dist/engine/surface.d.ts +2 -0
- package/dist/expect/attempt.d.ts +2 -0
- package/dist/mcp/catalog.js +3 -1
- package/dist/run/execute.js +3 -3
- package/dist/run/open-session.js +48 -32
- package/dist/run/process-output.d.ts +4 -0
- package/dist/run/process-output.js +59 -0
- package/dist/run/public-ownership.d.ts +4 -0
- package/dist/run/public-ownership.js +38 -0
- package/dist/run/public-run.d.ts +15 -0
- package/dist/run/public-run.js +58 -0
- package/dist/run/runner.d.ts +9 -0
- package/dist/run/runner.js +1 -1
- package/dist/run/standalone-attempt.js +17 -7
- package/dist/run/units.d.ts +5 -0
- package/dist/run/units.js +9 -0
- package/dist/runner.d.ts +3 -10
- package/dist/runner.js +2 -10
- package/dist/telemetry/telemetry.d.ts +2 -2
- package/dist/telemetry/telemetry.js +2 -2
- package/docs/browser.mdx +7 -2
- package/docs/decision-models.mdx +36 -11
- package/docs/executors.mdx +3 -0
- package/docs/migrate/playwright.mdx +2 -1
- package/docs/reference/engine.mdx +6 -0
- package/docs/reference/errors.mdx +2 -1
- package/docs/reference/runner.mdx +61 -4
- package/docs/reference/web.mdx +7 -3
- package/docs/telemetry.mdx +5 -3
- package/package.json +2 -2
- package/skills/e2e/references/bug-bash.md +4 -1
- package/skills/e2e/references/running.md +7 -0
- package/skills/e2e/references/writing-tests.md +4 -2
package/dist/.build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.21.
|
|
1
|
+
{"version":"0.21.1"}
|
package/dist/agent/executor.d.ts
CHANGED
|
@@ -117,6 +117,13 @@ export interface ExecutorObservation {
|
|
|
117
117
|
readonly text: string;
|
|
118
118
|
readonly truncated: boolean;
|
|
119
119
|
readonly viewport: ViewportSize;
|
|
120
|
+
/**
|
|
121
|
+
* Whether an on-screen keyboard is showing, as the engine measured it apart
|
|
122
|
+
* from the tree; `false` when it measured none. Absent only when the engine
|
|
123
|
+
* did not measure it; the tree's `keyboard` and `key` nodes, or a
|
|
124
|
+
* screenshot, are then the evidence.
|
|
125
|
+
*/
|
|
126
|
+
readonly keyboardVisible?: boolean;
|
|
120
127
|
/**
|
|
121
128
|
* The current location as path and query, redacted, when the engine
|
|
122
129
|
* reports one. Absent on engines without a location (a device screen).
|
|
@@ -132,6 +132,7 @@ export class ObservationFeed {
|
|
|
132
132
|
...metadata,
|
|
133
133
|
text: observation.text,
|
|
134
134
|
truncated: observation.truncated,
|
|
135
|
+
...(observation.keyboardVisible === undefined ? {} : { keyboardVisible: observation.keyboardVisible }),
|
|
135
136
|
...(options.tree === true ? { tree: projectTree(observation.tree, this.runtime.app.base?.origin) } : {}),
|
|
136
137
|
...pixels,
|
|
137
138
|
};
|
|
@@ -11,6 +11,8 @@ export type AgentObservation = AgentObservationMetadata & ({
|
|
|
11
11
|
readonly parents: ReadonlyMap<string, string>;
|
|
12
12
|
readonly tree: RedactedNode;
|
|
13
13
|
readonly truncated: boolean;
|
|
14
|
+
/** The engine's measured keyboard state; `false` when it measured none, absent only when it did not measure. */
|
|
15
|
+
readonly keyboardVisible?: boolean | undefined;
|
|
14
16
|
readonly pixels?: ExecutorPixels | undefined;
|
|
15
17
|
readonly pixelsWithheld?: PixelsWithheld | undefined;
|
|
16
18
|
} | {
|
|
@@ -94,6 +94,7 @@ export function prepareObservation(observation, options) {
|
|
|
94
94
|
parents,
|
|
95
95
|
tree,
|
|
96
96
|
truncated,
|
|
97
|
+
...(observation.keyboardVisible === undefined ? {} : { keyboardVisible: observation.keyboardVisible }),
|
|
97
98
|
...(pixels.cleared === undefined ? {} : { pixels: pixels.cleared }),
|
|
98
99
|
...(pixels.withheld === undefined ? {} : { pixelsWithheld: pixels.withheld }),
|
|
99
100
|
};
|
package/dist/cli/bin.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { unsupportedRuntimeMessage } from '../internal/node-version.js';
|
|
3
|
+
import { ignoreBrokenPipes } from './broken-pipe.js';
|
|
3
4
|
// Checked before the CLI module loads: that module and its dependencies are
|
|
4
5
|
// what an old runtime would fail on, with a stack trace instead of a sentence.
|
|
5
6
|
const unsupported = unsupportedRuntimeMessage();
|
|
@@ -7,5 +8,6 @@ if (unsupported !== undefined) {
|
|
|
7
8
|
process.stderr.write(`${unsupported}\n`);
|
|
8
9
|
process.exit(2);
|
|
9
10
|
}
|
|
11
|
+
ignoreBrokenPipes([process.stdout, process.stderr]);
|
|
10
12
|
const { main } = await import('./index.js');
|
|
11
13
|
await main(process.argv);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Writable } from 'node:stream';
|
|
2
|
+
/**
|
|
3
|
+
* A reader that stops early (`e2e --help | head -1`) closes the pipe, and the
|
|
4
|
+
* next write fails with EPIPE, an `error` event that kills the process with a
|
|
5
|
+
* stack trace when nothing listens. EPIPE is dropped instead: output nobody
|
|
6
|
+
* reads is discarded and the command finishes as it would have, teardown and
|
|
7
|
+
* exit code included. Any other error still throws, unless another listener
|
|
8
|
+
* (the MCP server's, on its protocol pipe) handles it.
|
|
9
|
+
*/
|
|
10
|
+
export declare function ignoreBrokenPipes(streams: readonly Writable[]): void;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reader that stops early (`e2e --help | head -1`) closes the pipe, and the
|
|
3
|
+
* next write fails with EPIPE, an `error` event that kills the process with a
|
|
4
|
+
* stack trace when nothing listens. EPIPE is dropped instead: output nobody
|
|
5
|
+
* reads is discarded and the command finishes as it would have, teardown and
|
|
6
|
+
* exit code included. Any other error still throws, unless another listener
|
|
7
|
+
* (the MCP server's, on its protocol pipe) handles it.
|
|
8
|
+
*/
|
|
9
|
+
export function ignoreBrokenPipes(streams) {
|
|
10
|
+
for (const stream of streams) {
|
|
11
|
+
stream.on('error', (error) => {
|
|
12
|
+
if (error.code === 'EPIPE' || stream.listenerCount('error') > 1)
|
|
13
|
+
return;
|
|
14
|
+
throw error;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { Argument, Command, CommanderError, InvalidArgumentError, Option } from
|
|
|
5
5
|
import picocolors from 'picocolors';
|
|
6
6
|
import { detectPackageManager, execCommand, runScriptCommand } from '../internal/package-manager.js';
|
|
7
7
|
import { packageVersion } from '../internal/package-version.js';
|
|
8
|
+
import { MAX_TIMER_MS } from '../internal/time.js';
|
|
8
9
|
import { classifyError, exitCodeForCategory } from '../internal/errors.js';
|
|
9
10
|
import { list } from '../runner.js';
|
|
10
11
|
import { run } from '../run/runner.js';
|
|
@@ -33,7 +34,7 @@ import { skillTopics } from './skill.js';
|
|
|
33
34
|
import { watchSession } from './watch.js';
|
|
34
35
|
import { telemetry as telemetryCommand, TELEMETRY_ACTIONS } from './telemetry.js';
|
|
35
36
|
import { wordmarkBanner } from './wordmark.js';
|
|
36
|
-
/** An integer of at least one, for `--max-failures` and `--
|
|
37
|
+
/** An integer of at least one, for `--max-failures` and `--repeat-each`. */
|
|
37
38
|
function parsePositiveInt(value) {
|
|
38
39
|
const parsed = Number(value);
|
|
39
40
|
if (!Number.isSafeInteger(parsed) || parsed < 1)
|
|
@@ -75,7 +76,7 @@ function parseShard(value) {
|
|
|
75
76
|
}
|
|
76
77
|
return { index, total };
|
|
77
78
|
}
|
|
78
|
-
/** An integer inside a closed range, for the explore budgets
|
|
79
|
+
/** An integer inside a closed range, for the explore budgets and `--max-duration`. */
|
|
79
80
|
function parseBoundedInt(bounds) {
|
|
80
81
|
return (value) => {
|
|
81
82
|
const parsed = Number(value);
|
|
@@ -462,7 +463,7 @@ function createProgram(version, telemetry) {
|
|
|
462
463
|
.option('--workers <n>', 'parallel workers (default: from the config)', parseNonNegativeInt)
|
|
463
464
|
.option('--retries <n>', 'retries per failing test (default: from the config)', parseNonNegativeInt)
|
|
464
465
|
.option('--max-failures <n>', 'stop the run once this many tests have failed; the rest are skipped', parsePositiveInt)
|
|
465
|
-
.option('--max-duration <ms>', 'stop the run once it has spent this many milliseconds running tests; the rest are skipped (default: maxDuration in the config)',
|
|
466
|
+
.option('--max-duration <ms>', 'stop the run once it has spent this many milliseconds running tests; the rest are skipped (default: maxDuration in the config)', parseBoundedInt({ min: 1, max: MAX_TIMER_MS }))
|
|
466
467
|
.option('--repeat-each <n>', 'run every selected test this many times, each run its own result (pair with --no-cache to exercise the model each time)', parsePositiveInt)
|
|
467
468
|
.option('--no-cache', 'run with the replay cache off, whatever the config says')
|
|
468
469
|
.option('--strict-cache', 'fail a step whose recording no longer replays (REPLAY_STALE) instead of handing it to the agent; never writes the cache')
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"@e2e-dev/web":"0.15.
|
|
1
|
+
{"@e2e-dev/web":"0.15.1","@e2e-dev/mobile":"0.12.2"}
|
package/dist/cli/run-output.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* through a `RunnerOutput`, redacted, until the process exits; only the list
|
|
6
6
|
* reporter writes to the terminal past it.
|
|
7
7
|
*/
|
|
8
|
-
import { RunnerOutput } from '../run/process-output.ts';
|
|
8
|
+
import { type RunnerOutput } from '../run/process-output.ts';
|
|
9
9
|
/**
|
|
10
10
|
* Takes over `process.stdout` and `process.stderr` for the rest of the
|
|
11
11
|
* process, once: a reporter the run stopped waiting for, or a timer the
|
package/dist/cli/run-output.js
CHANGED
|
@@ -5,14 +5,8 @@
|
|
|
5
5
|
* through a `RunnerOutput`, redacted, until the process exits; only the list
|
|
6
6
|
* reporter writes to the terminal past it.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { claimProcessOutput } from '../run/process-output.js';
|
|
9
9
|
import { processSecrets } from '../run/secrecy.js';
|
|
10
|
-
/**
|
|
11
|
-
* Encodings in which a string spells bytes rather than text: the write puts
|
|
12
|
-
* those bytes on the terminal, so they are what is redacted. A string in any
|
|
13
|
-
* other encoding is the text itself, redacted and written as UTF-8.
|
|
14
|
-
*/
|
|
15
|
-
const BYTE_ENCODINGS = new Set(['hex', 'base64', 'base64url']);
|
|
16
10
|
/** The process's claimed output, once claimed: the wrappers stay for the rest of the process. */
|
|
17
11
|
let claimed;
|
|
18
12
|
/**
|
|
@@ -26,19 +20,7 @@ let claimed;
|
|
|
26
20
|
export function claimRunnerOutput() {
|
|
27
21
|
if (claimed !== undefined)
|
|
28
22
|
return claimed;
|
|
29
|
-
const
|
|
30
|
-
const terminal = {
|
|
31
|
-
stdout: streams.stdout.write.bind(streams.stdout),
|
|
32
|
-
stderr: streams.stderr.write.bind(streams.stderr),
|
|
33
|
-
};
|
|
34
|
-
const output = new RunnerOutput(terminal, processSecrets);
|
|
35
|
-
for (const name of ['stdout', 'stderr']) {
|
|
36
|
-
streams[name].write = ((chunk, encoding, callback) => {
|
|
37
|
-
const done = typeof encoding === 'function' ? encoding : callback;
|
|
38
|
-
const bytes = typeof chunk === 'string' && typeof encoding === 'string' && BYTE_ENCODINGS.has(encoding);
|
|
39
|
-
return output.write(name, bytes ? Buffer.from(chunk, encoding) : chunk, done);
|
|
40
|
-
});
|
|
41
|
-
}
|
|
23
|
+
const output = claimProcessOutput(processSecrets);
|
|
42
24
|
process.once('exit', () => output.end());
|
|
43
25
|
claimed = output;
|
|
44
26
|
return output;
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -563,6 +563,13 @@ export interface EngineSnapshot {
|
|
|
563
563
|
* read whole.
|
|
564
564
|
*/
|
|
565
565
|
readonly truncated?: boolean;
|
|
566
|
+
/**
|
|
567
|
+
* Whether an on-screen keyboard is showing, as the engine measured it apart
|
|
568
|
+
* from `root`: a platform can raise a keyboard the tree lists only in part,
|
|
569
|
+
* or not at all. Set `false` when it measured none, and omit it only when
|
|
570
|
+
* the engine did not measure it; consumers then fall back to the tree.
|
|
571
|
+
*/
|
|
572
|
+
readonly keyboardVisible?: boolean;
|
|
566
573
|
/**
|
|
567
574
|
* Semantic capture timed out. Return only the stable root reference, with no other fields,
|
|
568
575
|
* and fresh pixels whose masking was proven independently of the failed
|
package/dist/engine/session.js
CHANGED
|
@@ -355,6 +355,7 @@ export function createEngineSession(options) {
|
|
|
355
355
|
kind: 'semantic',
|
|
356
356
|
tree: stampRevision(snapshot.root, minted),
|
|
357
357
|
truncated: snapshot.truncated === true,
|
|
358
|
+
...(snapshot.keyboardVisible === undefined ? {} : { keyboardVisible: snapshot.keyboardVisible }),
|
|
358
359
|
...(snapshot.pixels === undefined ? {} : { pixels: snapshot.pixels }),
|
|
359
360
|
};
|
|
360
361
|
showScreen(observation);
|
package/dist/engine/surface.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export type Observation = ObservationMetadata & ({
|
|
|
32
32
|
readonly kind: 'semantic';
|
|
33
33
|
readonly tree: SemanticNode;
|
|
34
34
|
readonly truncated: boolean;
|
|
35
|
+
/** The engine's measured keyboard state (`EngineSnapshot.keyboardVisible`); `false` when it measured none, absent only when it did not measure. */
|
|
36
|
+
readonly keyboardVisible?: boolean;
|
|
35
37
|
readonly pixels?: ObservationPixels;
|
|
36
38
|
} | {
|
|
37
39
|
readonly kind: 'pixels';
|
package/dist/expect/attempt.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { SoftFailures } from './soft.ts';
|
|
|
4
4
|
import type { PollScope } from './poll-scope.ts';
|
|
5
5
|
export interface PublishedAttempt {
|
|
6
6
|
readonly attemptId: string;
|
|
7
|
+
/** Host preparation may overlap a hosted attempt, but not a suite attempt. */
|
|
8
|
+
readonly owner: 'suite' | 'host';
|
|
7
9
|
/** Setup tests owe their sessions and may not skip themselves. */
|
|
8
10
|
readonly testKind: 'test' | 'setup';
|
|
9
11
|
/** The config default for a poll's `timeout`. */
|
package/dist/mcp/catalog.js
CHANGED
|
@@ -125,7 +125,9 @@ const CONTENT_NAMED_ROLES = new Set(['status', 'alert', 'listitem']);
|
|
|
125
125
|
/** The zero-match advice for a content-named role: match the text instead of retrying it as a name. */
|
|
126
126
|
function contentNameNotFound(role, name, exact) {
|
|
127
127
|
const quoted = JSON.stringify(name);
|
|
128
|
-
|
|
128
|
+
const article = /^[aeiou]/.test(role) ? 'an' : 'a';
|
|
129
|
+
const text = `screen.getByText(${quoted}${exact ? '' : ', { exact: false }'})`;
|
|
130
|
+
return `A role query matches the accessible name only, and observe shows ${article} ${role} by its text in its place. If ${quoted} is that text, use screen.getByRole(${JSON.stringify(role)}).filter({ hasText: ${quoted} }) or ${text}.${exact ? ' Otherwise loosen the match with exact: false.' : ''}`;
|
|
129
131
|
}
|
|
130
132
|
/** Renders a locate result: the count, the verdict a test would get, and the nodes, each through the attempt's redactor. */
|
|
131
133
|
export function describeLocate(query, count, nodes, redaction) {
|
package/dist/run/execute.js
CHANGED
|
@@ -28,7 +28,7 @@ import { isFailedStatus } from './records.js';
|
|
|
28
28
|
import { traceScreen } from './step-screens.js';
|
|
29
29
|
import { runWithRetries } from './retry.js';
|
|
30
30
|
import { runSerialUnit } from './serial.js';
|
|
31
|
-
import { interruptedSkip, pairKey, pairResult, unstartedResult } from './units.js';
|
|
31
|
+
import { interruptedInPhase, interruptedSkip, pairKey, pairResult, unstartedResult } from './units.js';
|
|
32
32
|
import { adoptSecrecy, carriedSecrecy, processSecrets, registerDerivedSecrets, redactForSession, resolveSecretValue, sessionSecrecy, staticSecretLedger } from './secrecy.js';
|
|
33
33
|
import { isSecret } from '../secrets.js';
|
|
34
34
|
import { SessionStaging, SessionStore, targetIdentity } from './sessions.js';
|
|
@@ -729,7 +729,7 @@ export class TargetExecutor {
|
|
|
729
729
|
// `expect.poll` and `expect.soft` take no fixture, so the attempt they
|
|
730
730
|
// run on is published here and cleared when `attemptEnd` fires in `finally`.
|
|
731
731
|
const soft = new SoftFailures();
|
|
732
|
-
publishAttempt({ attemptId, testKind: pair.test.kind, assertionTimeout: this.config.assertionTimeout, budget, soft }, attemptEnd.signal);
|
|
732
|
+
publishAttempt({ attemptId, owner: 'suite', testKind: pair.test.kind, assertionTimeout: this.config.assertionTimeout, budget, soft }, attemptEnd.signal);
|
|
733
733
|
const beforeEachHooks = this.realms.hooksFor(realm, registered, 'beforeEach');
|
|
734
734
|
const afterEachHooks = this.realms.hooksFor(realm, registered, 'afterEach');
|
|
735
735
|
// The test's own chain decides the fixture set; hooks get the same
|
|
@@ -802,7 +802,7 @@ export class TargetExecutor {
|
|
|
802
802
|
// down with the worker process; the attempt records the interrupt and
|
|
803
803
|
// moves to cleanup.
|
|
804
804
|
const work = this.options.isolated
|
|
805
|
-
? withAbort(runInPollScope(bodyPolls, mainWork), this.interruptSignal, () => new E2EError('interrupted', 'INTERRUPTED',
|
|
805
|
+
? withAbort(runInPollScope(bodyPolls, mainWork), this.interruptSignal, () => new E2EError('interrupted', 'INTERRUPTED', interruptedInPhase(this.interruptSignal, phase)))
|
|
806
806
|
: runInPollScope(bodyPolls, mainWork);
|
|
807
807
|
const body = Promise.race([
|
|
808
808
|
work,
|
package/dist/run/open-session.js
CHANGED
|
@@ -6,44 +6,60 @@ import { allocateTargetPorts } from './app-ports.js';
|
|
|
6
6
|
import { processSecrets, registerStaticSecrets } from './secrecy.js';
|
|
7
7
|
import { selectTarget } from './select-target.js';
|
|
8
8
|
import { openStandaloneHost } from './standalone.js';
|
|
9
|
+
import { reserveHostedRun } from './public-ownership.js';
|
|
9
10
|
/** Opens the worker-scoped owner of preparation, app processes, and engine initialization. */
|
|
10
11
|
export async function openHost(options = {}) {
|
|
11
12
|
return redactOpening(() => openHostWorker(options));
|
|
12
13
|
}
|
|
13
14
|
/** Loads the selected target and opens its worker behind the redacted public boundary. */
|
|
14
15
|
async function openHostWorker(options) {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
config, target,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
16
|
+
const release = reserveHostedRun();
|
|
17
|
+
try {
|
|
18
|
+
const cwd = options.cwd ?? process.cwd();
|
|
19
|
+
const env = options.env ?? process.env;
|
|
20
|
+
const loaded = await loadHostConfig(cwd, options.config, env);
|
|
21
|
+
registerStaticSecrets(loaded.allSecrets);
|
|
22
|
+
const { config, target } = await allocateTargetPorts(loaded, selectTarget(loaded, options.target, 'pass `target` to openHost'));
|
|
23
|
+
const host = await openStandaloneHost({
|
|
24
|
+
config, target, headed: options.headed ?? false, env,
|
|
25
|
+
signal: options.signal ?? new AbortController().signal,
|
|
26
|
+
...(options.onNotice === undefined ? {} : { notice: options.onNotice }),
|
|
27
|
+
});
|
|
28
|
+
const close = async () => {
|
|
29
|
+
try {
|
|
30
|
+
return await host.close();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
release();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
runId: host.runId, target: target.name, close,
|
|
38
|
+
[Symbol.asyncDispose]: () => dispose(close, 'host'),
|
|
39
|
+
openSession: (sessionOptions) => redactOpening(async () => {
|
|
40
|
+
if (typeof sessionOptions.name !== 'string')
|
|
41
|
+
throw new ConfigurationError('INVALID_CONFIG', 'a hosted session requires a string name');
|
|
42
|
+
const attempt = await host.openAttempt({
|
|
43
|
+
name: sessionOptions.name,
|
|
44
|
+
signal: sessionOptions.signal ?? new AbortController().signal,
|
|
45
|
+
timeoutMs: sessionOptions.timeout ?? config.timeout,
|
|
46
|
+
agent: sessionOptions.agent,
|
|
47
|
+
...(sessionOptions.onStep === undefined ? {} : { onProgress: sessionOptions.onStep }),
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
runId: attempt.runId, attemptId: attempt.attemptId, target: target.name,
|
|
51
|
+
fixtures: attempt.fixtures,
|
|
52
|
+
artifactsDir: attempt.artifactsDir, tracePath: attempt.tracePath,
|
|
53
|
+
steps: () => attempt.steps.all(), close: attempt.close,
|
|
54
|
+
[Symbol.asyncDispose]: () => dispose(attempt.close, 'session'),
|
|
55
|
+
};
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch (cause) {
|
|
60
|
+
release();
|
|
61
|
+
throw cause;
|
|
62
|
+
}
|
|
47
63
|
}
|
|
48
64
|
/** Opening errors go straight to the external runner, never through a report writer. */
|
|
49
65
|
async function redactOpening(open) {
|
|
@@ -11,6 +11,10 @@ import type { ListReporterOutput } from '../report/list.ts';
|
|
|
11
11
|
export type OutputStream = 'stdout' | 'stderr';
|
|
12
12
|
/** Writes text to one stream of the terminal; the callback fires once the stream took it. Answers false while the stream is backed up, like `Writable.write`. */
|
|
13
13
|
export type TerminalWrite = (text: string, callback?: () => void) => boolean;
|
|
14
|
+
/** The CLI owns every process write, including timers that print after a run ends. */
|
|
15
|
+
export declare function claimProcessOutput(ledger: SecretLedger): RunnerOutput;
|
|
16
|
+
/** Embedded runs own their async output; caller output resumes once cleanup ends. */
|
|
17
|
+
export declare function withProcessOutput<T>(ledger: SecretLedger, run: (output: RunnerOutput) => Promise<T>): Promise<T>;
|
|
14
18
|
/** Where runner output goes instead of the terminal while the list reporter shows the run. */
|
|
15
19
|
export interface OutputView {
|
|
16
20
|
/** Prints redacted text from `stream`. */
|
|
@@ -7,6 +7,65 @@
|
|
|
7
7
|
* what is printed meanwhile is held, and withheld if the load fails.
|
|
8
8
|
*/
|
|
9
9
|
import { StreamRedactor } from '../internal/redact.js';
|
|
10
|
+
import { AsyncLocalStorage, AsyncResource } from 'node:async_hooks';
|
|
11
|
+
import { realmSlot } from '../internal/realm-slot.js';
|
|
12
|
+
const BYTE_ENCODINGS = new Set(['hex', 'base64', 'base64url']);
|
|
13
|
+
const routingSlot = realmSlot('e2e.process-output.routing.v1');
|
|
14
|
+
/** One process dispatcher preserves redaction for asynchronous work that outlives a suite. */
|
|
15
|
+
function routing() {
|
|
16
|
+
const streams = { stdout: process.stdout, stderr: process.stderr };
|
|
17
|
+
let state = routingSlot.get(process);
|
|
18
|
+
if (state === undefined) {
|
|
19
|
+
state = {
|
|
20
|
+
scope: new AsyncLocalStorage(),
|
|
21
|
+
terminal: {
|
|
22
|
+
stdout: streams.stdout.write.bind(streams.stdout),
|
|
23
|
+
stderr: streams.stderr.write.bind(streams.stderr),
|
|
24
|
+
},
|
|
25
|
+
dispatchers: {},
|
|
26
|
+
process: undefined,
|
|
27
|
+
};
|
|
28
|
+
routingSlot.set(process, state);
|
|
29
|
+
}
|
|
30
|
+
for (const name of ['stdout', 'stderr']) {
|
|
31
|
+
if (streams[name].write === state.dispatchers[name])
|
|
32
|
+
continue;
|
|
33
|
+
const forward = streams[name].write.bind(streams[name]);
|
|
34
|
+
state.terminal[name] = (text, callback) => {
|
|
35
|
+
const done = callback === undefined ? undefined : AsyncResource.bind(callback);
|
|
36
|
+
return state.scope.run(null, () => forward(text, done));
|
|
37
|
+
};
|
|
38
|
+
const dispatch = ((chunk, encoding, callback) => {
|
|
39
|
+
const context = state.scope.getStore();
|
|
40
|
+
const output = context === null ? undefined : context ?? state.process;
|
|
41
|
+
if (output === undefined)
|
|
42
|
+
return forward(chunk, encoding, callback);
|
|
43
|
+
const done = typeof encoding === 'function' ? encoding : callback;
|
|
44
|
+
const bytes = typeof chunk === 'string' && typeof encoding === 'string' && BYTE_ENCODINGS.has(encoding);
|
|
45
|
+
return output.write(name, bytes ? Buffer.from(chunk, encoding) : chunk, done);
|
|
46
|
+
});
|
|
47
|
+
state.dispatchers[name] = dispatch;
|
|
48
|
+
streams[name].write = dispatch;
|
|
49
|
+
}
|
|
50
|
+
return state;
|
|
51
|
+
}
|
|
52
|
+
/** The CLI owns every process write, including timers that print after a run ends. */
|
|
53
|
+
export function claimProcessOutput(ledger) {
|
|
54
|
+
const state = routing();
|
|
55
|
+
state.process ??= new RunnerOutput(state.terminal, ledger);
|
|
56
|
+
return state.process;
|
|
57
|
+
}
|
|
58
|
+
/** Embedded runs own their async output; caller output resumes once cleanup ends. */
|
|
59
|
+
export async function withProcessOutput(ledger, run) {
|
|
60
|
+
const state = routing();
|
|
61
|
+
const output = new RunnerOutput(state.terminal, ledger);
|
|
62
|
+
try {
|
|
63
|
+
return await state.scope.run(output, () => run(output));
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
output.end();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
10
69
|
/** The warning that stands in for what a config printed before it failed to load. */
|
|
11
70
|
function withheldWarning(bytes) {
|
|
12
71
|
return `withheld ${bytes} bytes of output printed while the config failed to load: its secrets are unknown, so the output cannot be redacted`;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Reserves the process before a suite can change registries or claim output. */
|
|
2
|
+
export declare function reserveSuiteRun(): () => void;
|
|
3
|
+
/** Holds a host's process ownership from before config loading through close. */
|
|
4
|
+
export declare function reserveHostedRun(): () => void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { currentAttempt } from '../expect/attempt.js';
|
|
2
|
+
import { ConfigurationError } from '../internal/errors.js';
|
|
3
|
+
import { realmSlot } from '../internal/realm-slot.js';
|
|
4
|
+
const slot = realmSlot('e2e.public-runner.owners.v1');
|
|
5
|
+
/** Public runner copies in isolated realms share one set of process owners. */
|
|
6
|
+
function owners() {
|
|
7
|
+
let state = slot.get(globalThis);
|
|
8
|
+
if (state === undefined) {
|
|
9
|
+
state = { suite: undefined, hosts: new Set() };
|
|
10
|
+
slot.set(globalThis, state);
|
|
11
|
+
}
|
|
12
|
+
return state;
|
|
13
|
+
}
|
|
14
|
+
/** Reserves the process before a suite can change registries or claim output. */
|
|
15
|
+
export function reserveSuiteRun() {
|
|
16
|
+
const state = owners();
|
|
17
|
+
if (state.suite !== undefined)
|
|
18
|
+
throw new ConfigurationError('RUNNER_BUSY', 'a public run is already in progress; await it before starting another');
|
|
19
|
+
if (state.hosts.size > 0)
|
|
20
|
+
throw new ConfigurationError('RUNNER_BUSY', 'a hosted runner is still open; close it before starting a suite');
|
|
21
|
+
if (currentAttempt() !== undefined)
|
|
22
|
+
throw new ConfigurationError('RUNNER_BUSY', 'an attempt is still running; close it before starting a suite');
|
|
23
|
+
const owner = {};
|
|
24
|
+
state.suite = owner;
|
|
25
|
+
return () => { if (state.suite === owner)
|
|
26
|
+
state.suite = undefined; };
|
|
27
|
+
}
|
|
28
|
+
/** Holds a host's process ownership from before config loading through close. */
|
|
29
|
+
export function reserveHostedRun() {
|
|
30
|
+
const state = owners();
|
|
31
|
+
if (state.suite !== undefined)
|
|
32
|
+
throw new ConfigurationError('RUNNER_BUSY', 'a suite is running; await it before opening a host');
|
|
33
|
+
if (currentAttempt()?.owner === 'suite')
|
|
34
|
+
throw new ConfigurationError('RUNNER_BUSY', 'a suite attempt is still running; await it before opening a host');
|
|
35
|
+
const owner = {};
|
|
36
|
+
state.hosts.add(owner);
|
|
37
|
+
return () => { state.hosts.delete(owner); };
|
|
38
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Report } from '../types.ts';
|
|
2
|
+
import type { RunExitCode, RunStatus } from './events.ts';
|
|
3
|
+
import { type ListOptions, type RunOptions as InternalRunOptions } from './runner.ts';
|
|
4
|
+
/** Options for executing a suite through the public runner API. */
|
|
5
|
+
export type SuiteRunOptions = Pick<ListOptions, 'cwd' | 'config' | 'files'> & Pick<InternalRunOptions, 'interruptSignal' | 'onEvent'>;
|
|
6
|
+
/** The completed run's report and output paths, without live engine or configuration handles. */
|
|
7
|
+
export interface RunResult {
|
|
8
|
+
readonly exitCode: RunExitCode;
|
|
9
|
+
readonly status: RunStatus;
|
|
10
|
+
readonly report: Report;
|
|
11
|
+
readonly reportPath: string | undefined;
|
|
12
|
+
readonly aiTracePath: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
/** Executes a suite without exiting the caller; await its cleanup before starting another run. */
|
|
15
|
+
export declare function runSuite(options?: SuiteRunOptions): Promise<RunResult>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { AsyncResource } from 'node:async_hooks';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { packageVersion } from '../internal/package-version.js';
|
|
4
|
+
import { runsFromCheckout } from '../telemetry/checkout.js';
|
|
5
|
+
import { runCompletedEvent } from '../telemetry/events.js';
|
|
6
|
+
import { Telemetry } from '../telemetry/telemetry.js';
|
|
7
|
+
import { withProcessOutput } from './process-output.js';
|
|
8
|
+
import { reserveSuiteRun } from './public-ownership.js';
|
|
9
|
+
import { processSecrets } from './secrecy.js';
|
|
10
|
+
import { run as execute, toSelectionRunOptions } from './runner.js';
|
|
11
|
+
/** Executes a suite without exiting the caller; await its cleanup before starting another run. */
|
|
12
|
+
export async function runSuite(options = {}) {
|
|
13
|
+
const release = reserveSuiteRun();
|
|
14
|
+
try {
|
|
15
|
+
const telemetry = new Telemetry({
|
|
16
|
+
version: packageVersion(import.meta.url, '../../package.json', '0.0.0'),
|
|
17
|
+
checkout: runsFromCheckout(import.meta.url),
|
|
18
|
+
cwd: path.resolve(options.cwd ?? process.cwd()),
|
|
19
|
+
});
|
|
20
|
+
telemetry.notice();
|
|
21
|
+
const onEvent = options.onEvent === undefined ? undefined : AsyncResource.bind(options.onEvent);
|
|
22
|
+
const outcome = await withProcessOutput(processSecrets, async (output) => {
|
|
23
|
+
const interrupt = ownedInterrupt(options.interruptSignal);
|
|
24
|
+
try {
|
|
25
|
+
return await execute({
|
|
26
|
+
...toSelectionRunOptions({ cwd: options.cwd, config: options.config, files: options.files }),
|
|
27
|
+
quiet: true,
|
|
28
|
+
processOutput: output,
|
|
29
|
+
interruptSignal: interrupt.signal,
|
|
30
|
+
onEvent,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
interrupt.dispose();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
telemetry.record(runCompletedEvent(outcome.report, { command: 'runner.run', flags: [], config: outcome.config }));
|
|
38
|
+
// The caller's result never waits on the network; the send has its own deadline.
|
|
39
|
+
void telemetry.flush();
|
|
40
|
+
const { exitCode, status, report, reportPath, aiTracePath } = outcome;
|
|
41
|
+
return { exitCode, status, report, reportPath, aiTracePath };
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
release();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Caller cancellation enters the suite's async context before invoking its listeners. */
|
|
48
|
+
function ownedInterrupt(signal) {
|
|
49
|
+
if (signal === undefined)
|
|
50
|
+
return { signal: undefined, dispose: () => undefined };
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const abort = AsyncResource.bind(() => controller.abort(signal.reason));
|
|
53
|
+
if (signal.aborted)
|
|
54
|
+
abort();
|
|
55
|
+
else
|
|
56
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
57
|
+
return { signal: controller.signal, dispose: () => signal.removeEventListener('abort', abort) };
|
|
58
|
+
}
|
package/dist/run/runner.d.ts
CHANGED
|
@@ -250,3 +250,12 @@ export interface RunOutcome {
|
|
|
250
250
|
}
|
|
251
251
|
/** Executes one complete run and returns the outcome without exiting. */
|
|
252
252
|
export declare function run(options?: RunOptions): Promise<RunOutcome>;
|
|
253
|
+
/**
|
|
254
|
+
* What `loadRunConfig`, `selectionInputs`, and `validateRunOptions` read.
|
|
255
|
+
* `run` passes `RunOptions`; `list` translates the public bag into these
|
|
256
|
+
* (`config` string to `configPath`, object to `rawConfig`, `targets` to `targetIds`).
|
|
257
|
+
*/
|
|
258
|
+
type SelectionRunOptions = Pick<RunOptions, 'configPath' | 'rawConfig' | 'files' | 'tags' | 'tagMode' | 'excludeTags' | 'grep' | 'grepInvert' | 'lastFailed' | 'freshGraph' | 'shard' | 'targetIds' | 'passWithNoTests' | 'output' | 'maxFailures' | 'maxDuration' | 'repeatEach'>;
|
|
259
|
+
/** The public `list` bag as the options config loading and selection already take. */
|
|
260
|
+
export declare function toSelectionRunOptions(options: ListOptions): SelectionRunOptions;
|
|
261
|
+
export {};
|
package/dist/run/runner.js
CHANGED
|
@@ -731,7 +731,7 @@ function workerEnvFor(plans, env) {
|
|
|
731
731
|
};
|
|
732
732
|
}
|
|
733
733
|
/** The public `list` bag as the options config loading and selection already take. */
|
|
734
|
-
function toSelectionRunOptions(options) {
|
|
734
|
+
export function toSelectionRunOptions(options) {
|
|
735
735
|
const { config, targets, ...rest } = options;
|
|
736
736
|
return {
|
|
737
737
|
...rest,
|
|
@@ -36,7 +36,7 @@ export function reserveAttempt(worker, options) {
|
|
|
36
36
|
return { attemptId, options, end, signal, budget, soft, polls, release: () => undefined };
|
|
37
37
|
const { config } = worker;
|
|
38
38
|
const publication = new AbortController();
|
|
39
|
-
publishAttempt({ attemptId, testKind: 'test', assertionTimeout: config.assertionTimeout, budget, soft, polls }, publication.signal);
|
|
39
|
+
publishAttempt({ attemptId, owner: 'host', testKind: 'test', assertionTimeout: config.assertionTimeout, budget, soft, polls }, publication.signal);
|
|
40
40
|
// The live attempt's secrets resolve against its own config while other hosts hold theirs.
|
|
41
41
|
const releaseRegistry = holdSecretRegistry(config, { live: true });
|
|
42
42
|
return { attemptId, options, end, signal, budget, soft, polls, release: () => { publication.abort(); releaseRegistry(); } };
|
|
@@ -102,16 +102,23 @@ export async function launchAttempt(worker, reservation, onClosed) {
|
|
|
102
102
|
await closeOpened({ status: 'failed', cleanup: 'complete' });
|
|
103
103
|
throw cause;
|
|
104
104
|
}
|
|
105
|
+
const { deadline } = budget;
|
|
106
|
+
let timedOut = false;
|
|
107
|
+
/** Fails the attempt as timed out, once, and cuts whatever is still in flight. */
|
|
108
|
+
const expire = () => {
|
|
109
|
+
if (timedOut)
|
|
110
|
+
return;
|
|
111
|
+
timedOut = true;
|
|
112
|
+
const timeout = new TestTimeoutError(`session timed out after ${options.timeoutMs} ms`);
|
|
113
|
+
recordFailure(timeout, 'body');
|
|
114
|
+
end.abort(timeout);
|
|
115
|
+
};
|
|
105
116
|
// An engine that ignores its operation's timeout, or a model call, must not
|
|
106
117
|
// outlive the deadline: the attempt is cut the way a run's body is. One
|
|
107
118
|
// tick after it, so an operation that honors the deadline reports its own
|
|
108
119
|
// failure first; whatever is still in flight then fails as timed out.
|
|
109
|
-
const remaining =
|
|
110
|
-
const expiry = remaining > MAX_TIMER_MS ? undefined : setTimeout(
|
|
111
|
-
const timedOut = new TestTimeoutError(`session timed out after ${options.timeoutMs} ms`);
|
|
112
|
-
recordFailure(timedOut, 'body');
|
|
113
|
-
end.abort(timedOut);
|
|
114
|
-
}, remaining);
|
|
120
|
+
const remaining = deadline.remaining() + 1;
|
|
121
|
+
const expiry = remaining > MAX_TIMER_MS ? undefined : setTimeout(expire, remaining);
|
|
115
122
|
expiry?.unref();
|
|
116
123
|
const startedAt = timestamp();
|
|
117
124
|
const startedMs = Date.now();
|
|
@@ -120,6 +127,9 @@ export async function launchAttempt(worker, reservation, onClosed) {
|
|
|
120
127
|
const close = () => {
|
|
121
128
|
ending ??= (async () => {
|
|
122
129
|
clearTimeout(expiry);
|
|
130
|
+
// The deadline decides the outcome, not whether the timer ran before close.
|
|
131
|
+
if (deadline.expired())
|
|
132
|
+
expire();
|
|
123
133
|
const abandonedPoll = polls.close();
|
|
124
134
|
const abandonedStep = steps.abandonRunning();
|
|
125
135
|
end.abort();
|
package/dist/run/units.d.ts
CHANGED
|
@@ -66,6 +66,11 @@ export declare function timeLimitSkip(limit: TimeLimit): SkipInfo;
|
|
|
66
66
|
* so), else the plain interrupt.
|
|
67
67
|
*/
|
|
68
68
|
export declare function interruptedSkip(signal: AbortSignal, fallback?: SkipInfo): SkipInfo;
|
|
69
|
+
/**
|
|
70
|
+
* The message of an attempt an interrupt cut short in `phase`, naming the
|
|
71
|
+
* limit that stopped the run when the interrupt carried one.
|
|
72
|
+
*/
|
|
73
|
+
export declare function interruptedInPhase(signal: AbortSignal, phase: string): string;
|
|
69
74
|
/**
|
|
70
75
|
* Builds a result for one pair, narrowing the collected test to its identity
|
|
71
76
|
* so no test function or realm state can reach a record or the wire.
|
package/dist/run/units.js
CHANGED
|
@@ -106,6 +106,15 @@ export function interruptedSkip(signal, fallback = INTERRUPTED_BEFORE_START) {
|
|
|
106
106
|
const reason = signal.reason;
|
|
107
107
|
return isSkipInfo(reason) ? reason : fallback;
|
|
108
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* The message of an attempt an interrupt cut short in `phase`, naming the
|
|
111
|
+
* limit that stopped the run when the interrupt carried one.
|
|
112
|
+
*/
|
|
113
|
+
export function interruptedInPhase(signal, phase) {
|
|
114
|
+
const reason = signal.reason;
|
|
115
|
+
const message = `run interrupted in phase ${phase}`;
|
|
116
|
+
return isSkipInfo(reason) ? `${message}: ${reason.reason}` : message;
|
|
117
|
+
}
|
|
109
118
|
function isSkipInfo(value) {
|
|
110
119
|
return typeof value === 'object' && value !== null && typeof value.cause === 'string' && typeof value.reason === 'string';
|
|
111
120
|
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-process runner APIs. `list` collects and selects as `e2e list` does and
|
|
3
|
-
* stops before any app, engine, or worker starts. `openHost` opens a reusable
|
|
4
|
-
* worker whose per-test sessions share the runner's attempt lifecycle.
|
|
5
|
-
* Importing `e2e` does not load this module.
|
|
6
|
-
*
|
|
7
|
-
* `ConfigurationError` is also on `e2e/engine`. A caller of `list` or
|
|
8
|
-
* `openHost` matches `error.code` from here, without importing the engine
|
|
9
|
-
* contract.
|
|
10
|
-
*/
|
|
1
|
+
/** Runner APIs live on e2e/runner so importing e2e does not load them; ConfigurationError is shared with e2e/engine. */
|
|
11
2
|
export { ConfigurationError, isE2EError, type SerializedError } from './internal/errors.ts';
|
|
12
3
|
export { list, type ListOptions, type ListResult, type ListedPair } from './run/runner.ts';
|
|
4
|
+
export { runSuite as run, type SuiteRunOptions as RunOptions, type RunResult } from './run/public-run.ts';
|
|
5
|
+
export type { RunEventSink } from './run/events.ts';
|
|
13
6
|
export { openHost, type E2EHost, type E2ESession, type OpenHostOptions, type OpenSessionOptions } from './run/open-session.ts';
|
|
14
7
|
export type { StepProgress, StepRecord } from './run/steps.ts';
|
package/dist/runner.js
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-process runner APIs. `list` collects and selects as `e2e list` does and
|
|
3
|
-
* stops before any app, engine, or worker starts. `openHost` opens a reusable
|
|
4
|
-
* worker whose per-test sessions share the runner's attempt lifecycle.
|
|
5
|
-
* Importing `e2e` does not load this module.
|
|
6
|
-
*
|
|
7
|
-
* `ConfigurationError` is also on `e2e/engine`. A caller of `list` or
|
|
8
|
-
* `openHost` matches `error.code` from here, without importing the engine
|
|
9
|
-
* contract.
|
|
10
|
-
*/
|
|
1
|
+
/** Runner APIs live on e2e/runner so importing e2e does not load them; ConfigurationError is shared with e2e/engine. */
|
|
11
2
|
export { ConfigurationError, isE2EError } from './internal/errors.js';
|
|
12
3
|
export { list } from './run/runner.js';
|
|
4
|
+
export { runSuite as run } from './run/public-run.js';
|
|
13
5
|
export { openHost } from './run/open-session.js';
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* command never waits on telemetry for more than the flush budget and never
|
|
7
7
|
* fails because of it. A long-lived command (`e2e mcp`) sends what it has
|
|
8
8
|
* queued as it goes with `sendQueued`, so a client that kills it loses only
|
|
9
|
-
* what was still in progress.
|
|
10
|
-
*
|
|
9
|
+
* what was still in progress. Besides the CLI, only the public `run()` on
|
|
10
|
+
* `e2e/runner` constructs this class, one per call.
|
|
11
11
|
*
|
|
12
12
|
* Off means off at every step. `E2E_TELEMETRY_DISABLED`, `DO_NOT_TRACK`, a
|
|
13
13
|
* source checkout of the repository, an `e2e telemetry disable`, or a
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* command never waits on telemetry for more than the flush budget and never
|
|
7
7
|
* fails because of it. A long-lived command (`e2e mcp`) sends what it has
|
|
8
8
|
* queued as it goes with `sendQueued`, so a client that kills it loses only
|
|
9
|
-
* what was still in progress.
|
|
10
|
-
*
|
|
9
|
+
* what was still in progress. Besides the CLI, only the public `run()` on
|
|
10
|
+
* `e2e/runner` constructs this class, one per call.
|
|
11
11
|
*
|
|
12
12
|
* Off means off at every step. `E2E_TELEMETRY_DISABLED`, `DO_NOT_TRACK`, a
|
|
13
13
|
* source checkout of the repository, an `e2e telemetry disable`, or a
|
package/docs/browser.mdx
CHANGED
|
@@ -78,13 +78,18 @@ A function handler gets the dialog and must call `accept(text?)` or
|
|
|
78
78
|
## Download a file
|
|
79
79
|
|
|
80
80
|
```ts
|
|
81
|
+
import { readFile } from 'node:fs/promises';
|
|
82
|
+
|
|
81
83
|
const file = await browser.waitForDownload(() =>
|
|
82
84
|
screen.getByRole('link', 'Export CSV').tap(),
|
|
83
85
|
);
|
|
86
|
+
const csv = await readFile(file.absolutePath, 'utf8');
|
|
87
|
+
expect(csv).toContain('Invoice');
|
|
84
88
|
```
|
|
85
89
|
|
|
86
|
-
e2e saves the file as a `download` artifact.
|
|
87
|
-
|
|
90
|
+
e2e saves the file as a `download` artifact. Read it through
|
|
91
|
+
`file.absolutePath`, which works with hosted browsers too. `file.path` is
|
|
92
|
+
relative to the attempt's artifact directory, not to the project.
|
|
88
93
|
|
|
89
94
|
## Reach into an iframe
|
|
90
95
|
|
package/docs/decision-models.mdx
CHANGED
|
@@ -109,10 +109,15 @@ newest screen: `tap`, `type`, `typeSecret`, `submit`, `select`, `check`,
|
|
|
109
109
|
`upload`, `hover`, `secondary_tap` (a right-click), `double_tap`,
|
|
110
110
|
`long_press`, `drag`, `scroll_to`, `scroll_up`, `scroll_down`, and `back`.
|
|
111
111
|
Hover, right-click, drag, and scroll-to also reach labeled nodes that are
|
|
112
|
-
not controls, such as a card that opens a menu on hover
|
|
112
|
+
not controls, such as a card that opens a menu on hover, and a drag can drop
|
|
113
|
+
onto any of them. `tap` also reaches text fields, to open a date picker or a
|
|
114
|
+
suggestion list. While an on-screen
|
|
113
115
|
keyboard shows on an engine that can dismiss it, such as the mobile engine,
|
|
114
|
-
the executor also offers `dismiss_keyboard
|
|
116
|
+
the executor also offers `dismiss_keyboard`, even when the keyboard is
|
|
117
|
+
missing from the tree.
|
|
115
118
|
|
|
119
|
+
Every operation that acts on an element asks for its target, even when only
|
|
120
|
+
one element fits it.
|
|
116
121
|
Every target question has a `none` option, so a provider with no fitting
|
|
117
122
|
target answers `none` instead of refusing. `none` blocks the step.
|
|
118
123
|
|
|
@@ -130,7 +135,7 @@ secret, from its declared handle.
|
|
|
130
135
|
|
|
131
136
|
The text model also answers `waitFor` and `extract`. Instead of
|
|
132
137
|
`textModel`, you can set `model` on the agents entry. With neither, the
|
|
133
|
-
executor never offers `type`, `upload`, or `
|
|
138
|
+
executor never offers `type`, `upload`, `tap_at`, or `type_at`.
|
|
134
139
|
|
|
135
140
|
## Finishing a step
|
|
136
141
|
|
|
@@ -167,17 +172,37 @@ doesn't show. The operation and target questions read the tree only.
|
|
|
167
172
|
`agent.assert(..., { vision: true })` and `vision: 'only'` work too. After a
|
|
168
173
|
secret fill in the attempt, the executor gets no pixels.
|
|
169
174
|
|
|
170
|
-
The model must accept images and answer `score` questions
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
175
|
+
The model must accept images and answer `score` questions. TypeSafe Jev
|
|
176
|
+
does with `@ai-sdk/typesafe-ai` 3.0.19 or later and a TypeSafe API key in
|
|
177
|
+
`TYPESAFE_AI_API_KEY`:
|
|
178
|
+
|
|
179
|
+
```ts title="e2e.config.ts"
|
|
180
|
+
import { typeSafeAi } from '@ai-sdk/typesafe-ai';
|
|
181
|
+
import { openrouter } from '@openrouter/ai-sdk-provider';
|
|
182
|
+
|
|
183
|
+
const executor = decisionExecutor({
|
|
184
|
+
model: typeSafeAi.decisionModel('jev-latest'),
|
|
185
|
+
textModel: openrouter('inception/mercury-2.5'),
|
|
186
|
+
vision: true,
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`openai.decisionModel('gpt-6-luna')` takes images too. Vercel AI Gateway
|
|
191
|
+
rejects the screenshot for every decision model, so keep `vision` off
|
|
192
|
+
there.
|
|
174
193
|
|
|
175
194
|
With a text model, vision also offers `tap_at` for a control the tree
|
|
176
195
|
doesn't list. The text model names the control, such as "the 3 key on the
|
|
177
|
-
keypad", and the decision model finds it on the screenshot, then on
|
|
178
|
-
zoomed
|
|
196
|
+
keypad", and the decision model finds it on the screenshot, then on two
|
|
197
|
+
zoomed crops. A point tap costs four decide calls and one text call, or one
|
|
179
198
|
of each to repeat a tap on the same page.
|
|
180
199
|
|
|
200
|
+
When the engine can type into whatever has focus, vision also offers
|
|
201
|
+
`type_at` for a drawn field. The text model names the field and the value
|
|
202
|
+
from the goal, the decision model finds the field on the screenshot, and
|
|
203
|
+
the executor taps it and types the value at the caret, after any text
|
|
204
|
+
already there. It costs two decide calls and one text call.
|
|
205
|
+
|
|
181
206
|
## Provider options
|
|
182
207
|
|
|
183
208
|
The executor's `providerOptions` go with every decide call, completion
|
|
@@ -221,8 +246,8 @@ executor takes that choice with probability 1 and confidence 0, so a
|
|
|
221
246
|
|
|
222
247
|
- No `navigate`. In-app navigation is a `tap` on a link. URLs belong in
|
|
223
248
|
test code (`browser.goto`, `app.open`).
|
|
224
|
-
- No multi-select `listbox
|
|
225
|
-
|
|
249
|
+
- No multi-select `listbox` and no `scrollUntil`. `type_at` never clears a
|
|
250
|
+
drawn field first, so it suits empty fields.
|
|
226
251
|
- The text model that names a drawn control doesn't see pixels. It can't
|
|
227
252
|
follow an instruction that exists only as drawn text, such as a wizard
|
|
228
253
|
that paints "tap the circle".
|
package/docs/executors.mdx
CHANGED
|
@@ -144,6 +144,9 @@ without acting. Engines with a keyboard add `typeText`, `pressKey`, and
|
|
|
144
144
|
path outside the project root or a hidden one with `POLICY_DENIED`, and a
|
|
145
145
|
missing file with `INVALID_ARGUMENT`.
|
|
146
146
|
|
|
147
|
+
The observation's `keyboardVisible` says whether an on-screen keyboard is
|
|
148
|
+
showing, and is absent when the engine can't tell.
|
|
149
|
+
|
|
147
150
|
When semantic capture fails, the observation sets `treeUnavailable: true`,
|
|
148
151
|
has no `tree`, and includes a screenshot if policy allows. Earlier node ids
|
|
149
152
|
are invalid, and a missing tree doesn't prove a control is absent.
|
|
@@ -187,7 +187,8 @@ uses it declares `requires: ['browser']` and other targets skip it. Calls on
|
|
|
187
187
|
| `innerHTML()`, `dispatchEvent()`, `selectText()`, `blur()`, `highlight()` | not yet; `browser.evaluate` | missing |
|
|
188
188
|
| `ariaSnapshot()` | not yet | missing |
|
|
189
189
|
| `page.keyboard.press`, `keyboard.type` | `browser.keyboard.press`, `browser.keyboard.type`; plain text only, never a `Secret` | web only |
|
|
190
|
-
| `keyboard.down`, `keyboard.up`, `keyboard.
|
|
190
|
+
| `keyboard.down`, `keyboard.up` | `browser.keyboard.down`, `browser.keyboard.up`; keys and modifiers stay held until released | web only |
|
|
191
|
+
| `keyboard.insertText` | not yet | missing |
|
|
191
192
|
| `page.mouse.click(x, y)` | `screen.tapAt({ x, y })`, viewport coordinates | renamed |
|
|
192
193
|
| `mouse.move`, `down`, `up` as a drag | `screen.swipe({ from, to })` | renamed |
|
|
193
194
|
| `page.dragAndDrop(source, target, { sourcePosition, targetPosition })` | `source.dragTo(target)`, which takes no positions; for `sourcePosition` or `targetPosition`, `screen.swipe({ from, to })` with each `boundingBox()` origin plus its offset | renamed |
|
|
@@ -629,6 +629,12 @@ export interface EngineSnapshot {
|
|
|
629
629
|
* read whole.
|
|
630
630
|
*/
|
|
631
631
|
readonly truncated?: boolean;
|
|
632
|
+
/**
|
|
633
|
+
* Whether an on-screen keyboard is showing, as the engine measured it apart
|
|
634
|
+
* from `root`. Set `false` when it measured none, and omit it only when the
|
|
635
|
+
* engine did not measure it.
|
|
636
|
+
*/
|
|
637
|
+
readonly keyboardVisible?: boolean;
|
|
632
638
|
/** Semantic capture failed; only fresh, independently masked pixels are evidence. */
|
|
633
639
|
readonly treeUnavailable?: true;
|
|
634
640
|
/** Masked pixels, when requested and producible; omitted otherwise. */
|
|
@@ -104,6 +104,7 @@ These appear in terminal output and in the report as plain string codes.
|
|
|
104
104
|
| `UNSUPPORTED_ARTIFACT` | A [`video` mode](/reference/config#recording) set on a target, or on a test that will run there, records on some attempt, and the target's engine cannot record video. The message names the target and, for a test's mode, the test. A mode from the config root or `--video` never raises it: it applies to the targets that can record, with a notice naming the rest | configuration | 2 |
|
|
105
105
|
| `UNSUPPORTED_CAPABILITY` | A fixture, action, or tier the target's engine does not declare: a contributed fixture it does not contribute, `screen` without `locate`, state restore without `state`, screenshots without `artifacts`, `app.clearState()` on a persistent browser context (the message names the mode), or `agent.assert` `vision` with an executor that does not declare `vision: true` | configuration | 2 |
|
|
106
106
|
| `NO_TESTS` | Zero runnable ordinary test-target pairs. The message names the cause: no file matched the `tests` globs (with look-alike files), a positional matched nothing (naming the `!` entry that excludes a file it names), a file registered no tests, or filters excluded everything. Suppress with `--pass-with-no-tests`. | configuration | 2 |
|
|
107
|
+
| `RUNNER_BUSY` | A suite overlapped another suite, an opening or open host, or a live attempt; or a host opened during a suite. Await the suite and close existing hosts before retrying. | configuration | 2 |
|
|
107
108
|
| `NO_LAST_RUN` | `--last-failed` found no `report.json` where the run writes it, or a file there that is not a `report-1` document. Run once without the flag first. | configuration | 2 |
|
|
108
109
|
| `ONLY_IN_CI` | A focused test reached a CI run | configuration | 2 |
|
|
109
110
|
| `COLLECTION_ERROR` | Registration outside collection, an invalid title, a non-function body, an async `describe` body, nested serial groups, an option forbidden inside a serial group, a setup test inside `describe`, an invalid session name, a malformed test option such as `tags` that is not a list of distinct names, a test or `describe` option key the call does not take (the message names the nearest one), a test file that fails to import (in a run with no positionals, or a file the positionals selected), a session that a selected test consumes or `e2e explore --session` names but no collected setup test produces (the message lists the declared sessions), a consumed session on a target whose engine has no `state` capability to save it, or a positional path outside the project root | configuration | 2 |
|
|
@@ -132,7 +133,7 @@ These appear in terminal output and in the report as plain string codes.
|
|
|
132
133
|
| `SESSION_KEY_INVALID` | A worker received a session key that is not 32 bytes. Packaging or IPC fault. | infrastructure | 3 |
|
|
133
134
|
| `REPORT_WRITE_FAILED` | `report.json` or `ai-trace.json` could not be written. Recorded as a run error; the in-memory outcome is still complete. A `junit.xml` that could not be written is the `junit` reporter's stderr line instead | infrastructure | 3 |
|
|
134
135
|
| `EVALUATE_FAILED` | `browser.evaluate` threw inside the page (`@e2e-dev/web`) | test | 1 |
|
|
135
|
-
| `INTERRUPTED` | A user or CI signal aborted the run | interrupted | 130 |
|
|
136
|
+
| `INTERRUPTED` | A user or CI signal aborted the run. A test cut short by `--max-failures` or the time limit also ends with this code, its message naming the limit; that run exits 1, not 130 | interrupted | 130 |
|
|
136
137
|
|
|
137
138
|
Errors raised by argument validation in test code use `INVALID_ARGUMENT` and
|
|
138
139
|
`INVALID_LOCATOR`, both test failures with exit code 1. An unclassified thrown
|
|
@@ -1,12 +1,69 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "e2e/runner"
|
|
3
|
-
description: "
|
|
3
|
+
description: "Run a test suite, list its selection, or host e2e sessions in another test runner."
|
|
4
4
|
icon: "file-lines"
|
|
5
|
-
"og:image": "https://e2e.tester.army/docs/images/og/reference/runner.png?v=
|
|
6
|
-
"twitter:image": "https://e2e.tester.army/docs/images/og/reference/runner.png?v=
|
|
5
|
+
"og:image": "https://e2e.tester.army/docs/images/og/reference/runner.png?v=98c298d3ed"
|
|
6
|
+
"twitter:image": "https://e2e.tester.army/docs/images/og/reference/runner.png?v=98c298d3ed"
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
`e2e/runner` runs
|
|
9
|
+
`e2e/runner` runs a suite from code without invoking the CLI. `run` executes tests and returns their report; `list` returns a selection. `openHost` prepares a target for your test runner, and `host.openSession` opens a fresh attempt for each test. Importing `e2e` from a test file does not load the runner.
|
|
10
|
+
|
|
11
|
+
## run
|
|
12
|
+
|
|
13
|
+
`run(options?: RunOptions)` executes a complete suite and returns `Promise<RunResult>`. It finishes cleanup before resolving and never exits the caller's process.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import type { E2EConfig } from 'e2e';
|
|
17
|
+
import { run } from 'e2e/runner';
|
|
18
|
+
import { web } from '@e2e-dev/web';
|
|
19
|
+
|
|
20
|
+
const config: E2EConfig = {
|
|
21
|
+
targets: [{ name: 'web', engine: web(), app: { url: 'http://localhost:3000' } }],
|
|
22
|
+
};
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const result = await run({
|
|
25
|
+
config,
|
|
26
|
+
files: ['tests/signup.e2e.ts'],
|
|
27
|
+
interruptSignal: controller.signal,
|
|
28
|
+
onEvent(event) {
|
|
29
|
+
if (event.type === 'test-started') console.log(event.title);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
console.log(result.status, result.report.run.summary);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Execution options
|
|
37
|
+
|
|
38
|
+
`run` accepts these options. Put execution settings, such as retries and workers, in the config.
|
|
39
|
+
|
|
40
|
+
| Option | Meaning |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `cwd` | Project directory. Defaults to the current working directory. |
|
|
43
|
+
| `config` | Config file path or an `E2EConfig` value. Omit it to discover `e2e.config.ts`. |
|
|
44
|
+
| `files` | File selection: paths, globs, bare names, or `file:line`. Omit it to run the configured suite. |
|
|
45
|
+
| `interruptSignal` | Abort to interrupt tests and execute teardown. |
|
|
46
|
+
| `onEvent` | Receives typed progress events. Must not block; a throwing or rejecting callback is disabled without failing the run. |
|
|
47
|
+
|
|
48
|
+
Runtime config runs with one worker in the caller's process. A file config supports the configured worker processes. Terminal progress is disabled; use `onEvent` for progress.
|
|
49
|
+
|
|
50
|
+
Helper modules your test files import stay cached between `run` calls in the same process, so edits to them need a new process.
|
|
51
|
+
|
|
52
|
+
### Completed result
|
|
53
|
+
|
|
54
|
+
| Field | Meaning |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `exitCode` | The CLI-equivalent exit code: `0`, `1`, `2`, `3`, `4`, or `130`. |
|
|
57
|
+
| `status` | `passed`, `failed`, `blocked`, `error`, or `interrupted`. |
|
|
58
|
+
| `report` | The complete report. Read `report.run.results`, `report.run.errors`, and `report.run.summary` for outcomes. |
|
|
59
|
+
| `reportPath` | Absolute path to `report.json`, or `undefined` if it could not be written. |
|
|
60
|
+
| `aiTracePath` | AI trace path, or `undefined` when no AI trace was written. |
|
|
61
|
+
|
|
62
|
+
Test failures, config failures, and interruption resolve with their status and errors in the report. Overlapping suites reject with `RUNNER_BUSY`. Close every host before running a suite, and await the suite before opening a host.
|
|
63
|
+
|
|
64
|
+
Each run that returns a report sends one anonymous [`e2e_run_completed`](/telemetry) event, with the same opt-outs as the CLI. The first call can print the telemetry notice on stderr.
|
|
65
|
+
|
|
66
|
+
Suite output uses UTF-8 text redaction and does not preserve binary data. Your application's logs and progress callback output stay under your control.
|
|
10
67
|
|
|
11
68
|
## list
|
|
12
69
|
|
package/docs/reference/web.mdx
CHANGED
|
@@ -341,6 +341,10 @@ export interface Browser extends Expectable<BrowserExpectation> {
|
|
|
341
341
|
readonly keyboard: {
|
|
342
342
|
/** Sends one key. */
|
|
343
343
|
press(key: string): Promise<void>;
|
|
344
|
+
/** Holds a key or modifier until up, with repeat on another down. */
|
|
345
|
+
down(key: string): Promise<void>;
|
|
346
|
+
/** Releases a held key. */
|
|
347
|
+
up(key: string): Promise<void>;
|
|
344
348
|
/** Types plain text. */
|
|
345
349
|
type(text: string): Promise<void>;
|
|
346
350
|
};
|
|
@@ -632,9 +636,9 @@ page it opens. Each defaults to the browser's own.
|
|
|
632
636
|
every open tab and every tab opened after them, through `app.restart()` and
|
|
633
637
|
`app.clearState()`. It works before the first navigation, so the next
|
|
634
638
|
`app.open` loads with them. A feature it leaves out keeps its value. A tab
|
|
635
|
-
the app opens itself
|
|
636
|
-
|
|
637
|
-
feature or value outside `WebMedia` is `INVALID_CONFIG` in `web()` and
|
|
639
|
+
the app opens itself gets a test's change as it appears, on a persistent
|
|
640
|
+
context too, so a script it runs at load may still read the configured
|
|
641
|
+
values. A feature or value outside `WebMedia` is `INVALID_CONFIG` in `web()` and
|
|
638
642
|
`INVALID_ARGUMENT` in `emulateMedia`.
|
|
639
643
|
|
|
640
644
|
```ts
|
package/docs/telemetry.mdx
CHANGED
|
@@ -67,7 +67,9 @@ sends nothing.
|
|
|
67
67
|
Each command sends one `e2e_cli_session` event. `run` and `explore` also
|
|
68
68
|
send `e2e_run_completed`, `init` sends `e2e_init_completed`, and `mcp` sends
|
|
69
69
|
one `e2e_mcp_session` per MCP session. `--help` and `--version` send
|
|
70
|
-
nothing, also on a command.
|
|
70
|
+
nothing, also on a command. `run()` from `e2e/runner` sends one
|
|
71
|
+
`e2e_run_completed` per run that returns a report, and no session event. Its
|
|
72
|
+
first call can print the notice below on stderr.
|
|
71
73
|
|
|
72
74
|
The events go in one request when the command ends, with a two-second
|
|
73
75
|
deadline. `e2e mcp` sends each MCP session event in its own request. Events
|
|
@@ -112,11 +114,11 @@ hold only the properties below.
|
|
|
112
114
|
| `skill`, `mcp`, `install` | Whether the agent skill, the MCP registration, and the dependency install were chosen |
|
|
113
115
|
|
|
114
116
|
</Accordion>
|
|
115
|
-
<Accordion title="e2e_run_completed, once per e2e run or
|
|
117
|
+
<Accordion title="e2e_run_completed, once per e2e run, e2e explore, or completed run()">
|
|
116
118
|
|
|
117
119
|
| Property | Value |
|
|
118
120
|
| --- | --- |
|
|
119
|
-
| `command` | `run` or `
|
|
121
|
+
| `command` | `run`, `explore`, or `runner.run` for `run()` from `e2e/runner` |
|
|
120
122
|
| `status`, `exit_code`, `duration_ms` | The run's outcome |
|
|
121
123
|
| `flags` | The names of the flags given to the run command, never their values |
|
|
122
124
|
| `tests_discovered`, `tests_selected`, `tests_executed`, `tests_passed`, `tests_failed`, `tests_interrupted`, `tests_flaky`, `tests_skipped` | The summary counts |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "e2e",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "SDK, runner, and CLI for agentic end-to-end testing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"e2e",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"@ai-sdk/openai": "4.0.90",
|
|
133
133
|
"@ai-sdk/openai-compatible": "3.0.53",
|
|
134
134
|
"@ai-sdk/xai": "5.0.4",
|
|
135
|
-
"@e2e-dev/web": "0.15.
|
|
135
|
+
"@e2e-dev/web": "0.15.1",
|
|
136
136
|
"@modelcontextprotocol/client": "2.2.0",
|
|
137
137
|
"@openrouter/ai-sdk-provider": "3.1.0",
|
|
138
138
|
"@types/node": "26.6.2",
|
|
@@ -80,7 +80,10 @@ const persona = { model: gateway('openai/gpt-6-luna-fast'), maxSteps: 40, maxMod
|
|
|
80
80
|
export default {
|
|
81
81
|
...shared,
|
|
82
82
|
// The project's tests, so a repro can use its setup tests' sessions, plus the repro tests from step 6.
|
|
83
|
-
tests: [
|
|
83
|
+
tests: [
|
|
84
|
+
...[shared.tests ?? 'tests/**/*.e2e.ts'].flat().filter((glob) => !glob.startsWith('!tests/bugbash/')),
|
|
85
|
+
'tests/bugbash/**/*.e2e.ts',
|
|
86
|
+
],
|
|
84
87
|
// The app already runs: no command.
|
|
85
88
|
targets: [{ name: 'web', engine: web(), app: { url: 'http://127.0.0.1:3000' } }],
|
|
86
89
|
retries: 0,
|
|
@@ -79,6 +79,13 @@ target names.
|
|
|
79
79
|
npx e2e list tests/signup.e2e.ts --tag smoke --reporter json
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
+
`import { run } from 'e2e/runner'` executes the suite and returns its report
|
|
83
|
+
without exiting the caller. Pass `cwd`, `config`, and `files` to select the
|
|
84
|
+
project and tests; `onEvent` receives progress and `interruptSignal` cancels
|
|
85
|
+
with teardown. A runtime config uses one in-process worker. Await each run
|
|
86
|
+
before starting the next and close every host first; overlaps reject with
|
|
87
|
+
`RUNNER_BUSY`.
|
|
88
|
+
|
|
82
89
|
With a `package.json` script `"test:e2e": "e2e run"`, pnpm forwards `--`
|
|
83
90
|
literally: `pnpm test:e2e -- --headed` reaches e2e as `run -- --headed` and
|
|
84
91
|
exits 2. Write `pnpm test:e2e --headed` or `pnpm exec e2e run --headed`.
|
|
@@ -426,8 +426,10 @@ the app opens itself (`target="_blank"`, `window.open`) is not followed:
|
|
|
426
426
|
- `waitForDownload(() => trigger, { timeout? })`: returns
|
|
427
427
|
`{ path, absolutePath, suggestedFilename }`; `absolutePath` names the saved
|
|
428
428
|
runner-side file for reading its contents, including with a hosted browser.
|
|
429
|
-
- `keyboard.press(key)`, `keyboard.type(text)`, `
|
|
430
|
-
|
|
429
|
+
- `keyboard.press(key)`, `keyboard.type(text)`, `keyboard.down(key)`,
|
|
430
|
+
`keyboard.up(key)`, `mouse.*`: unfocused input. Only `down(key)` holds a
|
|
431
|
+
key or modifier until `up(key)`. `press(key)` presses and releases within
|
|
432
|
+
the call. Prefer `locator.press` and `locator.fill`.
|
|
431
433
|
|
|
432
434
|
A `route`, `unroute`, or `waitForResponse` pattern is a glob string or
|
|
433
435
|
`RegExp` matched against the full URL (`*` stays within one path segment,
|