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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Node.js
|
|
3
|
-
* managers only warn about `engines`, so an
|
|
4
|
-
* otherwise surface as an unrelated TypeError deep
|
|
2
|
+
* The Node.js releases e2e runs on, as a set of release ranges. The CLI checks the floor before it loads
|
|
3
|
+
* anything else: package managers only warn about `engines`, so an
|
|
4
|
+
* unsupported runtime would otherwise surface as an unrelated TypeError deep
|
|
5
|
+
* inside a run.
|
|
5
6
|
*/
|
|
6
|
-
|
|
7
|
-
export const MINIMUM_NODE_VERSION = '22.12.0';
|
|
7
|
+
import nodeModule from 'node:module';
|
|
8
8
|
function parse(version) {
|
|
9
9
|
const [major = 0, minor = 0, patch = 0] = version
|
|
10
10
|
.replace(/^v/, '')
|
|
@@ -12,16 +12,51 @@ function parse(version) {
|
|
|
12
12
|
.map((part) => Number.parseInt(part, 10) || 0);
|
|
13
13
|
return [major, minor, patch];
|
|
14
14
|
}
|
|
15
|
-
|
|
16
|
-
export function unsupportedNodeMessage(current, minimum = MINIMUM_NODE_VERSION) {
|
|
17
|
-
const have = parse(current);
|
|
18
|
-
const need = parse(minimum);
|
|
15
|
+
function atLeast(have, need) {
|
|
19
16
|
for (let i = 0; i < 3; i += 1) {
|
|
20
|
-
if (have[i]
|
|
21
|
-
return
|
|
22
|
-
if (have[i] < need[i]) {
|
|
23
|
-
return `e2e requires Node.js ${minimum} or newer; this is Node.js ${current.replace(/^v/, '')}. Upgrade Node.js, or switch versions with your version manager (nvm use 22, fnm use 22, volta pin node@22).`;
|
|
24
|
-
}
|
|
17
|
+
if (have[i] !== need[i])
|
|
18
|
+
return have[i] > need[i];
|
|
25
19
|
}
|
|
26
|
-
return
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
function within(version, ranges) {
|
|
23
|
+
const have = parse(version);
|
|
24
|
+
return ranges.some((range) => ('line' in range ? have[0] === range.line : true) && atLeast(have, range.since));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Where e2e's TypeScript loader works. It runs on `module.registerHooks`, and
|
|
28
|
+
* before these releases a CommonJS file an ES module imported cannot
|
|
29
|
+
* `require()` an ES module the hooks load, such as a project's TypeScript:
|
|
30
|
+
* Node.js throws reading its own cache (nodejs/node#59679, in 22.22.3 and
|
|
31
|
+
* 24.8.0). Node.js 23 has no such release.
|
|
32
|
+
*/
|
|
33
|
+
const SUPPORTED = [
|
|
34
|
+
{ line: 22, since: [22, 22, 3] },
|
|
35
|
+
{ since: [24, 8, 0] },
|
|
36
|
+
];
|
|
37
|
+
/** `engines.node` in package.json; a unit test keeps the two in step. */
|
|
38
|
+
export const SUPPORTED_NODE_RANGE = SUPPORTED.map((range) => `${'line' in range ? '^' : '>='}${range.since.join('.')}`).join(' || ');
|
|
39
|
+
/** Explains a Node.js outside `SUPPORTED_NODE_RANGE`, or undefined for one inside it. */
|
|
40
|
+
function unsupportedNodeMessage(current) {
|
|
41
|
+
if (within(current, SUPPORTED))
|
|
42
|
+
return undefined;
|
|
43
|
+
const required = SUPPORTED.map((range) => ('line' in range ? `${range.since.join('.')} or newer on Node.js ${range.line}` : `${range.since.join('.')} or newer`)).join(', or ');
|
|
44
|
+
return `e2e requires Node.js ${required}; this is Node.js ${current.replace(/^v/, '')}. Upgrade Node.js, or switch versions with your version manager (nvm install 24, fnm install 24, volta install node@24).`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Explains a runtime e2e cannot run on, or undefined for one it can: a
|
|
48
|
+
* Node.js outside `SUPPORTED_NODE_RANGE`, or a runtime that reports a
|
|
49
|
+
* supported Node.js version without Node.js's `module.registerHooks` (Bun),
|
|
50
|
+
* which e2e's TypeScript loader runs on, or Deno.
|
|
51
|
+
*/
|
|
52
|
+
export function unsupportedRuntimeMessage(versions = process.versions, hasRegisterHooks = typeof nodeModule.registerHooks === 'function') {
|
|
53
|
+
// Deno implements module.registerHooks, but not the require() of a native addon from inside a hook that oxc needs.
|
|
54
|
+
if (versions.deno !== undefined)
|
|
55
|
+
return `e2e runs on Node.js, not Deno ${versions.deno}: run the CLI with Node.js (npx e2e).`;
|
|
56
|
+
// Checked before the Node.js version a runtime reports, which upgrading Node.js does not change.
|
|
57
|
+
if (!hasRegisterHooks) {
|
|
58
|
+
const runtime = versions.bun !== undefined ? `Bun ${versions.bun}` : 'This runtime';
|
|
59
|
+
return `e2e runs on Node.js: ${runtime} reports Node.js ${versions.node} but has no module.registerHooks, which e2e's TypeScript loader needs. Run the CLI with Node.js: npx e2e, or bunx e2e (without --bun).`;
|
|
60
|
+
}
|
|
61
|
+
return unsupportedNodeMessage(versions.node);
|
|
27
62
|
}
|
|
@@ -44,7 +44,7 @@ export function runScriptCommand(manager, name) {
|
|
|
44
44
|
export function execCommand(manager, command) {
|
|
45
45
|
switch (manager) {
|
|
46
46
|
case 'npm':
|
|
47
|
-
return `npm exec ${command}`;
|
|
47
|
+
return `npm exec -- ${command}`;
|
|
48
48
|
case 'pnpm':
|
|
49
49
|
return `pnpm exec ${command}`;
|
|
50
50
|
case 'yarn':
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Typed accessor for a `Symbol.for` slot on a host object.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Typed accessor for a `Symbol.for` slot on a host object. A project can load
|
|
3
|
+
* a copy of e2e other than the runner's (a second install, a linked checkout,
|
|
4
|
+
* a fresh module registry under a test runner), so runner-owned instances are
|
|
5
|
+
* shared through registry symbols instead of module state. All unsafe reads
|
|
6
|
+
* live here; call sites stay fully typed.
|
|
6
7
|
*/
|
|
7
8
|
export interface RealmSlot<T> {
|
|
8
9
|
set(host: object, value: T): void;
|
|
@@ -37,6 +37,8 @@ export declare class SecretLedger {
|
|
|
37
37
|
register(name: string, value: string): void;
|
|
38
38
|
/** Bound so it can be handed out as a plain function. */
|
|
39
39
|
readonly redact: (text: string) => string;
|
|
40
|
+
/** Whether no value is registered, so there is nothing to redact. */
|
|
41
|
+
get isEmpty(): boolean;
|
|
40
42
|
/** Every registered name and value, in registration order. */
|
|
41
43
|
entries(): readonly (readonly [string, string])[];
|
|
42
44
|
/**
|
package/dist/internal/redact.js
CHANGED
|
@@ -527,6 +527,10 @@ export class SecretLedger {
|
|
|
527
527
|
}
|
|
528
528
|
/** Bound so it can be handed out as a plain function. */
|
|
529
529
|
redact = (text) => this.compile().redact(text);
|
|
530
|
+
/** Whether no value is registered, so there is nothing to redact. */
|
|
531
|
+
get isEmpty() {
|
|
532
|
+
return this.values.length === 0;
|
|
533
|
+
}
|
|
530
534
|
/** Every registered name and value, in registration order. */
|
|
531
535
|
entries() {
|
|
532
536
|
return this.values.map(([name, value]) => [name, value]);
|
package/dist/internal/time.d.ts
CHANGED
|
@@ -12,6 +12,15 @@ export declare class Deadline {
|
|
|
12
12
|
export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
13
13
|
/** Canonical polling cadence for locator and assertion loops. */
|
|
14
14
|
export declare const POLL_INTERVAL_MS = 100;
|
|
15
|
+
/**
|
|
16
|
+
* Whether `cause` is a read a wait's deadline cut off: an operation timeout
|
|
17
|
+
* of a read that started with less than one poll tick of the wait left, so
|
|
18
|
+
* it timed out because the wait did, not because the app stopped answering.
|
|
19
|
+
* A read that started with more and still timed out hung, and its timeout is
|
|
20
|
+
* the failure. Reads the engine error a locator failure wraps as well as a
|
|
21
|
+
* bare one.
|
|
22
|
+
*/
|
|
23
|
+
export declare function cutOffAtDeadline(cause: unknown, startedWithMs: number): boolean;
|
|
15
24
|
/** How long a negated assertion must hold before it passes. */
|
|
16
25
|
export declare const NEGATION_GRACE_MS = 1000;
|
|
17
26
|
export interface PollConditionOptions {
|
|
@@ -19,12 +28,14 @@ export interface PollConditionOptions {
|
|
|
19
28
|
readonly signal: AbortSignal;
|
|
20
29
|
readonly negated: boolean;
|
|
21
30
|
/**
|
|
22
|
-
* Evaluates the positive condition once
|
|
23
|
-
*
|
|
24
|
-
* the
|
|
31
|
+
* Evaluates the positive condition once, every read bounded by
|
|
32
|
+
* `deadline`: a read on a longer budget outlasts the poll. Returns
|
|
33
|
+
* undefined when the condition cannot be evaluated yet: the positive poll
|
|
34
|
+
* keeps waiting and the negation grace window resets.
|
|
25
35
|
*/
|
|
26
36
|
evaluate(): Promise<boolean | undefined>;
|
|
27
|
-
|
|
37
|
+
/** Builds the poll's failure; `cause` is the read the deadline cut off, when one did. */
|
|
38
|
+
onTimeout(cause?: unknown): Error | Promise<Error>;
|
|
28
39
|
}
|
|
29
40
|
/**
|
|
30
41
|
* Polls a condition until it holds (or, when negated, until its negation has
|
|
@@ -35,7 +46,12 @@ export interface PollConditionOptions {
|
|
|
35
46
|
* a slow read counts toward the window. A budget shorter than the window
|
|
36
47
|
* still has to be satisfiable: the negation then only needs to hold for the
|
|
37
48
|
* whole budget, and passes at the deadline on what it has seen, since a read
|
|
38
|
-
* past the deadline has no budget left.
|
|
49
|
+
* past the deadline has no budget left. A read the deadline cut off (see
|
|
50
|
+
* `cutOffAtDeadline`) after an earlier one completed saw nothing: the poll
|
|
51
|
+
* ends there. A negation then passes if it has held long enough by the
|
|
52
|
+
* time the read is cut off: the read left less than one poll tick unseen, no
|
|
53
|
+
* more than the gap between any two reads, so a short budget still passes at
|
|
54
|
+
* its deadline on what it saw.
|
|
39
55
|
*/
|
|
40
56
|
export declare function pollCondition(options: PollConditionOptions): Promise<void>;
|
|
41
57
|
/** Races a promise against a timeout; on timeout invokes onTimeout to build the error. */
|
package/dist/internal/time.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Deadline and cancellation helpers. */
|
|
2
|
-
import { E2EError } from './errors.js';
|
|
2
|
+
import { asEngineError, E2EError } from './errors.js';
|
|
3
3
|
export class Deadline {
|
|
4
4
|
endsAt;
|
|
5
5
|
constructor(timeoutMs, now = Date.now()) {
|
|
@@ -41,6 +41,20 @@ export function sleep(ms, signal) {
|
|
|
41
41
|
}
|
|
42
42
|
/** Canonical polling cadence for locator and assertion loops. */
|
|
43
43
|
export const POLL_INTERVAL_MS = 100;
|
|
44
|
+
/**
|
|
45
|
+
* Whether `cause` is a read a wait's deadline cut off: an operation timeout
|
|
46
|
+
* of a read that started with less than one poll tick of the wait left, so
|
|
47
|
+
* it timed out because the wait did, not because the app stopped answering.
|
|
48
|
+
* A read that started with more and still timed out hung, and its timeout is
|
|
49
|
+
* the failure. Reads the engine error a locator failure wraps as well as a
|
|
50
|
+
* bare one.
|
|
51
|
+
*/
|
|
52
|
+
export function cutOffAtDeadline(cause, startedWithMs) {
|
|
53
|
+
if (startedWithMs >= POLL_INTERVAL_MS)
|
|
54
|
+
return false;
|
|
55
|
+
const engineError = asEngineError(cause) ?? asEngineError(cause instanceof Error ? cause.cause : undefined);
|
|
56
|
+
return engineError?.code === 'OPERATION_TIMEOUT';
|
|
57
|
+
}
|
|
44
58
|
/** How long a negated assertion must hold before it passes. */
|
|
45
59
|
export const NEGATION_GRACE_MS = 1000;
|
|
46
60
|
/**
|
|
@@ -52,7 +66,12 @@ export const NEGATION_GRACE_MS = 1000;
|
|
|
52
66
|
* a slow read counts toward the window. A budget shorter than the window
|
|
53
67
|
* still has to be satisfiable: the negation then only needs to hold for the
|
|
54
68
|
* whole budget, and passes at the deadline on what it has seen, since a read
|
|
55
|
-
* past the deadline has no budget left.
|
|
69
|
+
* past the deadline has no budget left. A read the deadline cut off (see
|
|
70
|
+
* `cutOffAtDeadline`) after an earlier one completed saw nothing: the poll
|
|
71
|
+
* ends there. A negation then passes if it has held long enough by the
|
|
72
|
+
* time the read is cut off: the read left less than one poll tick unseen, no
|
|
73
|
+
* more than the gap between any two reads, so a short budget still passes at
|
|
74
|
+
* its deadline on what it saw.
|
|
56
75
|
*/
|
|
57
76
|
export async function pollCondition(options) {
|
|
58
77
|
const { deadline, negated } = options;
|
|
@@ -61,8 +80,21 @@ export async function pollCondition(options) {
|
|
|
61
80
|
let readAt = startedAt;
|
|
62
81
|
let holdingSince;
|
|
63
82
|
const holds = (now) => holdingSince !== undefined && now - holdingSince >= grace;
|
|
83
|
+
let sampled = false;
|
|
64
84
|
for (;;) {
|
|
65
|
-
const
|
|
85
|
+
const startedWithMs = deadline.remaining(readAt);
|
|
86
|
+
let value;
|
|
87
|
+
try {
|
|
88
|
+
value = await options.evaluate();
|
|
89
|
+
sampled = true;
|
|
90
|
+
}
|
|
91
|
+
catch (cause) {
|
|
92
|
+
if (!sampled || !cutOffAtDeadline(cause, startedWithMs))
|
|
93
|
+
throw cause;
|
|
94
|
+
if (negated && holds(Math.min(Date.now(), deadline.endsAt)))
|
|
95
|
+
return;
|
|
96
|
+
throw await options.onTimeout(cause);
|
|
97
|
+
}
|
|
66
98
|
if (!negated) {
|
|
67
99
|
if (value === true)
|
|
68
100
|
return;
|
package/dist/locator/engine.js
CHANGED
|
@@ -3,7 +3,7 @@ import {} from '../engine/surface.js';
|
|
|
3
3
|
import { asEngineError, ConfigurationError, E2EError, TestError, translateEngineError, } from '../internal/errors.js';
|
|
4
4
|
import { requireKey } from '../internal/keys.js';
|
|
5
5
|
import { describeExpression, expressionHints } from './expression.js';
|
|
6
|
-
import { Deadline, POLL_INTERVAL_MS, sleep } from '../internal/time.js';
|
|
6
|
+
import { cutOffAtDeadline, Deadline, POLL_INTERVAL_MS, sleep } from '../internal/time.js';
|
|
7
7
|
/** Canonical visibility predicate over a semantic node snapshot. */
|
|
8
8
|
export function isNodeVisible(node) {
|
|
9
9
|
return node !== null && node.states?.hidden !== true;
|
|
@@ -96,16 +96,22 @@ export class LocatorEngine {
|
|
|
96
96
|
* matches, and a read that promised not to wait must not wait for it.
|
|
97
97
|
*/
|
|
98
98
|
async resolveOnce(expression, deadline, missingFrame = 'wait') {
|
|
99
|
+
let retried;
|
|
99
100
|
for (;;) {
|
|
101
|
+
const startedWithMs = deadline.remaining();
|
|
100
102
|
try {
|
|
101
103
|
return await this.session.locate(expression, this.operationWithin(deadline));
|
|
102
104
|
}
|
|
103
105
|
catch (cause) {
|
|
106
|
+
// A retry the deadline cut off saw nothing new: the wait ends on the failure it was retrying.
|
|
107
|
+
if (retried !== undefined && cutOffAtDeadline(cause, startedWithMs))
|
|
108
|
+
throw translateLocatorError(retried, expression);
|
|
104
109
|
const engineError = asEngineError(cause);
|
|
105
110
|
if (engineError?.retryable === true) {
|
|
106
111
|
if (missingFrame === 'empty' && engineError.code === 'FRAME_NOT_FOUND')
|
|
107
112
|
return [];
|
|
108
113
|
if (!deadline.expired()) {
|
|
114
|
+
retried = cause;
|
|
109
115
|
await sleep(POLL_INTERVAL_MS, this.signal);
|
|
110
116
|
continue;
|
|
111
117
|
}
|
|
@@ -120,15 +126,31 @@ export class LocatorEngine {
|
|
|
120
126
|
*/
|
|
121
127
|
async resolveExactlyOne(expression, deadline) {
|
|
122
128
|
const startedMs = Date.now();
|
|
129
|
+
// The wait as it happened: a deadline capped by the test's remaining
|
|
130
|
+
// budget waited less than the action timeout, and the report says so.
|
|
131
|
+
const notFound = (cause) => new TestError('LOCATOR_NOT_FOUND', `locator matched no nodes within ${describeExpression(expression)}`, {
|
|
132
|
+
details: locatorDetails(expression, Date.now() - startedMs),
|
|
133
|
+
...(cause === undefined ? {} : { cause }),
|
|
134
|
+
});
|
|
135
|
+
let sampled = false;
|
|
123
136
|
for (;;) {
|
|
124
|
-
const
|
|
137
|
+
const startedWithMs = deadline.remaining();
|
|
138
|
+
let refs;
|
|
139
|
+
try {
|
|
140
|
+
refs = await this.resolveOnce(expression, deadline);
|
|
141
|
+
}
|
|
142
|
+
catch (cause) {
|
|
143
|
+
// A resolve the deadline cut off, after one that matched nothing, is the wait running out.
|
|
144
|
+
if (sampled && cutOffAtDeadline(cause, startedWithMs))
|
|
145
|
+
throw notFound(cause);
|
|
146
|
+
throw cause;
|
|
147
|
+
}
|
|
148
|
+
sampled = true;
|
|
149
|
+
const ref = assertSingle(refs, expression);
|
|
125
150
|
if (ref !== null)
|
|
126
151
|
return ref;
|
|
127
|
-
if (deadline.expired())
|
|
128
|
-
|
|
129
|
-
// budget waited less than the action timeout, and the report says so.
|
|
130
|
-
throw new TestError('LOCATOR_NOT_FOUND', `locator matched no nodes within ${describeExpression(expression)}`, { details: locatorDetails(expression, Date.now() - startedMs) });
|
|
131
|
-
}
|
|
152
|
+
if (deadline.expired())
|
|
153
|
+
throw notFound();
|
|
132
154
|
await sleep(POLL_INTERVAL_MS, this.signal);
|
|
133
155
|
}
|
|
134
156
|
}
|
|
@@ -275,16 +297,30 @@ export class LocatorEngine {
|
|
|
275
297
|
if (this.session.actions.has('scrollIntoView')) {
|
|
276
298
|
await this.performUntil(expression, { kind: 'scrollIntoView' }, deadline);
|
|
277
299
|
}
|
|
300
|
+
const notVisible = (cause) => new TestError('LOCATOR_NOT_FOUND', `locator did not become visible with a box to act within: ${describeExpression(expression)}`, {
|
|
301
|
+
details: locatorDetails(expression, Date.now() - startedMs),
|
|
302
|
+
...(cause === undefined ? {} : { cause }),
|
|
303
|
+
});
|
|
304
|
+
let sampled = false;
|
|
278
305
|
for (;;) {
|
|
279
|
-
const
|
|
306
|
+
const startedWithMs = deadline.remaining();
|
|
307
|
+
let node;
|
|
308
|
+
try {
|
|
309
|
+
({ node } = await this.tryRead(expression, deadline));
|
|
310
|
+
}
|
|
311
|
+
catch (cause) {
|
|
312
|
+
if (sampled && cutOffAtDeadline(cause, startedWithMs))
|
|
313
|
+
throw notVisible(cause);
|
|
314
|
+
throw cause;
|
|
315
|
+
}
|
|
316
|
+
sampled = true;
|
|
280
317
|
const rect = isNodeVisible(node) ? node.rect : undefined;
|
|
281
318
|
if (rect !== undefined) {
|
|
282
319
|
await this.dispatchAt({ x: rect.x + offset.x, y: rect.y + offset.y }, action, deadline);
|
|
283
320
|
return;
|
|
284
321
|
}
|
|
285
|
-
if (deadline.expired())
|
|
286
|
-
throw
|
|
287
|
-
}
|
|
322
|
+
if (deadline.expired())
|
|
323
|
+
throw notVisible();
|
|
288
324
|
await sleep(POLL_INTERVAL_MS, this.signal);
|
|
289
325
|
}
|
|
290
326
|
}
|
package/dist/locator/screen.js
CHANGED
|
@@ -12,7 +12,7 @@ import { obj } from '../internal/objects.js';
|
|
|
12
12
|
import { isTextMatch, normalizeText } from '../internal/text.js';
|
|
13
13
|
import { attributeOf, denySecureRead, isNodeVisible, locatorDetails } from './engine.js';
|
|
14
14
|
import { describeExpression, filterExpression, indexExpression, roleQuery, testIdQuery, textQuery, } from './expression.js';
|
|
15
|
-
import { POLL_INTERVAL_MS, pollCondition, sleep } from '../internal/time.js';
|
|
15
|
+
import { cutOffAtDeadline, POLL_INTERVAL_MS, pollCondition, sleep } from '../internal/time.js';
|
|
16
16
|
/**
|
|
17
17
|
* Internals hang off the locator object itself under a global symbol so that
|
|
18
18
|
* an expect() imported in an isolated test-module realm can still reach them.
|
|
@@ -127,10 +127,15 @@ class ScreenImpl {
|
|
|
127
127
|
}
|
|
128
128
|
async swipe(options) {
|
|
129
129
|
if (isSwipePath(options)) {
|
|
130
|
-
rejectUnknownOptions('swipe', options, ['from', 'to']);
|
|
130
|
+
rejectUnknownOptions('swipe', options, ['from', 'to', 'duration']);
|
|
131
131
|
const from = requirePoint(options.from, 'swipe from');
|
|
132
132
|
const to = requirePoint(options.to, 'swipe to');
|
|
133
|
-
|
|
133
|
+
const durationMs = validateSwipeDuration(options.duration);
|
|
134
|
+
await this.context.steps.run('screen', 'screen.swipe', `${describePoint(from)} → ${describePoint(to)}`, () => this.context.engine.performAt(from, obj({
|
|
135
|
+
kind: 'swipeTo',
|
|
136
|
+
target: to,
|
|
137
|
+
durationMs,
|
|
138
|
+
})));
|
|
134
139
|
return;
|
|
135
140
|
}
|
|
136
141
|
rejectUnknownOptions('swipe', options, ['direction', 'momentum']);
|
|
@@ -160,8 +165,19 @@ class ScreenImpl {
|
|
|
160
165
|
details: locatorDetails(internals.expression, Date.now() - startedMs),
|
|
161
166
|
...(cause === undefined ? {} : { cause }),
|
|
162
167
|
});
|
|
168
|
+
let sampled = false;
|
|
163
169
|
for (;;) {
|
|
164
|
-
const
|
|
170
|
+
const startedWithMs = deadline.remaining();
|
|
171
|
+
let node;
|
|
172
|
+
try {
|
|
173
|
+
({ node } = await engine.tryRead(internals.expression, deadline));
|
|
174
|
+
}
|
|
175
|
+
catch (cause) {
|
|
176
|
+
if (sampled && cutOffAtDeadline(cause, startedWithMs))
|
|
177
|
+
throw notVisible(cause);
|
|
178
|
+
throw cause;
|
|
179
|
+
}
|
|
180
|
+
sampled = true;
|
|
165
181
|
if (isNodeVisible(node))
|
|
166
182
|
return;
|
|
167
183
|
if (deadline.expired())
|
|
@@ -399,8 +415,9 @@ class LocatorImpl extends ScreenImpl {
|
|
|
399
415
|
const { node } = await engine.tryRead(this.expression, deadline, ABSENCE_STATES.has(state) ? 'empty' : 'wait');
|
|
400
416
|
return inWaitForState(node, state);
|
|
401
417
|
},
|
|
402
|
-
onTimeout: () => new TestError('LOCATOR_NOT_FOUND', `locator did not become ${state}: ${this.label}`, {
|
|
418
|
+
onTimeout: (cause) => new TestError('LOCATOR_NOT_FOUND', `locator did not become ${state}: ${this.label}`, {
|
|
403
419
|
details: locatorDetails(this.expression, Date.now() - startedMs),
|
|
420
|
+
...(cause === undefined ? {} : { cause }),
|
|
404
421
|
}),
|
|
405
422
|
});
|
|
406
423
|
}, { verifies: true });
|
|
@@ -485,6 +502,15 @@ function validateLongPress(durationMs) {
|
|
|
485
502
|
}
|
|
486
503
|
return durationMs;
|
|
487
504
|
}
|
|
505
|
+
/** Validates the shared swipe duration bound; none named leaves the gesture to the engine's default. */
|
|
506
|
+
function validateSwipeDuration(duration) {
|
|
507
|
+
if (duration === undefined)
|
|
508
|
+
return undefined;
|
|
509
|
+
if (!Number.isInteger(duration) || duration < 16 || duration > 10_000) {
|
|
510
|
+
throw new TestError('INVALID_ARGUMENT', `swipe duration must be an integer from 16 through 10000, got ${String(duration)}`);
|
|
511
|
+
}
|
|
512
|
+
return duration;
|
|
513
|
+
}
|
|
488
514
|
/**
|
|
489
515
|
* The `modifiers` of a click, validated: a list of distinct key modifiers,
|
|
490
516
|
* spread into the action only when it names one.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the `e2e mcp` process prints besides the protocol. User code runs in
|
|
3
|
+
* this process (the config's top-level code, project tools, engines), and
|
|
4
|
+
* what it writes to stdout or stderr reaches the operator's stderr redacted
|
|
5
|
+
* with every secret value the process knows, as a test's output leaves an
|
|
6
|
+
* `e2e run` worker. The server's own diagnostics are redacted where they are
|
|
7
|
+
* written and pass as they are.
|
|
8
|
+
*/
|
|
9
|
+
import { type SecretLedger } from '../internal/redact.ts';
|
|
10
|
+
export type OutputStream = 'stdout' | 'stderr';
|
|
11
|
+
/** Writes text to the operator's stderr; the callback fires once the stream took it. Answers false while the stream is backed up, like `Writable.write`. */
|
|
12
|
+
export type OutputSink = (text: string, callback?: () => void) => boolean;
|
|
13
|
+
export declare class McpOutput {
|
|
14
|
+
private readonly sink;
|
|
15
|
+
/** Each stream's bytes decoded on their own, so a character split across writes stays whole. */
|
|
16
|
+
private readonly decoders;
|
|
17
|
+
/** One redactor for both streams: they land on one stderr, where a value split across them reads whole. */
|
|
18
|
+
private readonly redactor;
|
|
19
|
+
/** Config loads in flight: until each ends, what anyone prints is held. */
|
|
20
|
+
private loading;
|
|
21
|
+
/** Whether a load in flight failed, so its secrets are unknown and what is held cannot be redacted. */
|
|
22
|
+
private loadFailed;
|
|
23
|
+
/** Tool calls in flight: a held tail is released only once none is, since any of them may be partway through a value. */
|
|
24
|
+
private calls;
|
|
25
|
+
private held;
|
|
26
|
+
/** Whether the last text passed on ended its line. */
|
|
27
|
+
private atLineStart;
|
|
28
|
+
constructor(sink: OutputSink, ledger: SecretLedger);
|
|
29
|
+
/**
|
|
30
|
+
* One write of user code to `stream`, redacted across writes: the tail a
|
|
31
|
+
* later write could complete into a value waits for it, or for the end of
|
|
32
|
+
* the tool calls in flight. Held while a config loads. Answers what the
|
|
33
|
+
* sink did.
|
|
34
|
+
*/
|
|
35
|
+
write(stream: OutputStream, chunk: string | Uint8Array, callback?: () => void): boolean;
|
|
36
|
+
/** One line of the server's own, already redacted where it was written; on a line of its own even when user code left one unfinished. */
|
|
37
|
+
log(line: string): void;
|
|
38
|
+
/** A line of the server's that carries user text (an error nobody caught): redacted and held like what user code writes. */
|
|
39
|
+
report(line: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Runs `load`, a config load that registers the config's secrets, holding
|
|
42
|
+
* what anyone prints until it ends: the config's top-level code may print
|
|
43
|
+
* a value before the process knows it is one. Released redacted once every
|
|
44
|
+
* load in flight succeeded; withheld when one failed, since the secrets of
|
|
45
|
+
* a config that did not load are unknown.
|
|
46
|
+
*/
|
|
47
|
+
withholdDuring<T>(load: () => Promise<T>): Promise<T>;
|
|
48
|
+
/**
|
|
49
|
+
* Runs one tool call. Once no call is in flight, the tail held for a later
|
|
50
|
+
* write is released, redacted: user code that printed without a newline is
|
|
51
|
+
* seen when its call ends, not when the server exits.
|
|
52
|
+
*/
|
|
53
|
+
duringCall<T>(call: () => Promise<T>): Promise<T>;
|
|
54
|
+
/** Releases what is still held, redacted; nothing more will follow. Output held for a load still in flight is withheld. */
|
|
55
|
+
end(): void;
|
|
56
|
+
private releaseHeld;
|
|
57
|
+
private withhold;
|
|
58
|
+
private emit;
|
|
59
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the `e2e mcp` process prints besides the protocol. User code runs in
|
|
3
|
+
* this process (the config's top-level code, project tools, engines), and
|
|
4
|
+
* what it writes to stdout or stderr reaches the operator's stderr redacted
|
|
5
|
+
* with every secret value the process knows, as a test's output leaves an
|
|
6
|
+
* `e2e run` worker. The server's own diagnostics are redacted where they are
|
|
7
|
+
* written and pass as they are.
|
|
8
|
+
*/
|
|
9
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
10
|
+
import { StreamRedactor } from '../internal/redact.js';
|
|
11
|
+
export class McpOutput {
|
|
12
|
+
sink;
|
|
13
|
+
/** Each stream's bytes decoded on their own, so a character split across writes stays whole. */
|
|
14
|
+
decoders = {
|
|
15
|
+
stdout: new StringDecoder('utf8'),
|
|
16
|
+
stderr: new StringDecoder('utf8'),
|
|
17
|
+
};
|
|
18
|
+
/** One redactor for both streams: they land on one stderr, where a value split across them reads whole. */
|
|
19
|
+
redactor;
|
|
20
|
+
/** Config loads in flight: until each ends, what anyone prints is held. */
|
|
21
|
+
loading = 0;
|
|
22
|
+
/** Whether a load in flight failed, so its secrets are unknown and what is held cannot be redacted. */
|
|
23
|
+
loadFailed = false;
|
|
24
|
+
/** Tool calls in flight: a held tail is released only once none is, since any of them may be partway through a value. */
|
|
25
|
+
calls = 0;
|
|
26
|
+
held = [];
|
|
27
|
+
/** Whether the last text passed on ended its line. */
|
|
28
|
+
atLineStart = true;
|
|
29
|
+
constructor(sink, ledger) {
|
|
30
|
+
this.sink = sink;
|
|
31
|
+
this.redactor = new StreamRedactor(ledger);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* One write of user code to `stream`, redacted across writes: the tail a
|
|
35
|
+
* later write could complete into a value waits for it, or for the end of
|
|
36
|
+
* the tool calls in flight. Held while a config loads. Answers what the
|
|
37
|
+
* sink did.
|
|
38
|
+
*/
|
|
39
|
+
write(stream, chunk, callback) {
|
|
40
|
+
const text = typeof chunk === 'string' ? chunk : this.decoders[stream].write(chunk);
|
|
41
|
+
if (this.loading > 0) {
|
|
42
|
+
this.held.push(text);
|
|
43
|
+
if (callback !== undefined)
|
|
44
|
+
process.nextTick(callback);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return this.emit(this.redactor.push(text), callback);
|
|
48
|
+
}
|
|
49
|
+
/** One line of the server's own, already redacted where it was written; on a line of its own even when user code left one unfinished. */
|
|
50
|
+
log(line) {
|
|
51
|
+
this.emit(`${this.atLineStart ? '' : '\n'}${line}\n`);
|
|
52
|
+
}
|
|
53
|
+
/** A line of the server's that carries user text (an error nobody caught): redacted and held like what user code writes. */
|
|
54
|
+
report(line) {
|
|
55
|
+
this.write('stderr', `${this.atLineStart ? '' : '\n'}${line}\n`);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Runs `load`, a config load that registers the config's secrets, holding
|
|
59
|
+
* what anyone prints until it ends: the config's top-level code may print
|
|
60
|
+
* a value before the process knows it is one. Released redacted once every
|
|
61
|
+
* load in flight succeeded; withheld when one failed, since the secrets of
|
|
62
|
+
* a config that did not load are unknown.
|
|
63
|
+
*/
|
|
64
|
+
async withholdDuring(load) {
|
|
65
|
+
this.loading += 1;
|
|
66
|
+
try {
|
|
67
|
+
return await load();
|
|
68
|
+
}
|
|
69
|
+
catch (cause) {
|
|
70
|
+
this.loadFailed = true;
|
|
71
|
+
throw cause;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
this.loading -= 1;
|
|
75
|
+
if (this.loading === 0)
|
|
76
|
+
this.releaseHeld();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Runs one tool call. Once no call is in flight, the tail held for a later
|
|
81
|
+
* write is released, redacted: user code that printed without a newline is
|
|
82
|
+
* seen when its call ends, not when the server exits.
|
|
83
|
+
*/
|
|
84
|
+
async duringCall(call) {
|
|
85
|
+
this.calls += 1;
|
|
86
|
+
try {
|
|
87
|
+
return await call();
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
this.calls -= 1;
|
|
91
|
+
if (this.calls === 0 && this.loading === 0)
|
|
92
|
+
this.emit(this.redactor.flush());
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Releases what is still held, redacted; nothing more will follow. Output held for a load still in flight is withheld. */
|
|
96
|
+
end() {
|
|
97
|
+
this.write('stdout', this.decoders.stdout.end());
|
|
98
|
+
this.write('stderr', this.decoders.stderr.end());
|
|
99
|
+
if (this.loading > 0)
|
|
100
|
+
this.withhold();
|
|
101
|
+
this.emit(this.redactor.flush());
|
|
102
|
+
}
|
|
103
|
+
releaseHeld() {
|
|
104
|
+
if (this.loadFailed) {
|
|
105
|
+
this.withhold();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const held = this.held;
|
|
109
|
+
this.held = [];
|
|
110
|
+
for (const text of held)
|
|
111
|
+
this.emit(this.redactor.push(text));
|
|
112
|
+
}
|
|
113
|
+
withhold() {
|
|
114
|
+
const bytes = this.held.reduce((total, text) => total + Buffer.byteLength(text), 0);
|
|
115
|
+
this.held = [];
|
|
116
|
+
this.loadFailed = false;
|
|
117
|
+
if (bytes > 0)
|
|
118
|
+
this.log(`e2e mcp: [warning] withheld ${bytes} bytes of output printed while a config failed to load: its secrets are unknown, so the output cannot be redacted`);
|
|
119
|
+
}
|
|
120
|
+
emit(text, callback) {
|
|
121
|
+
if (text === '') {
|
|
122
|
+
if (callback !== undefined)
|
|
123
|
+
process.nextTick(callback);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
this.atLineStart = text.endsWith('\n');
|
|
127
|
+
return this.sink(text, callback);
|
|
128
|
+
}
|
|
129
|
+
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* it the process's and a test hands it pipes.
|
|
8
8
|
*/
|
|
9
9
|
import type { Readable, Writable } from 'node:stream';
|
|
10
|
+
import type { McpOutput } from './output.ts';
|
|
10
11
|
import type { McpSessionSummary } from './usage.ts';
|
|
11
12
|
/** The client as it named itself in `initialize`. */
|
|
12
13
|
export interface McpClient {
|
|
@@ -19,6 +20,7 @@ export interface ServeOptions {
|
|
|
19
20
|
readonly configPath?: string | undefined;
|
|
20
21
|
/** The target every session opens on; otherwise a call names one, or the only one is used. */
|
|
21
22
|
readonly target?: string | undefined;
|
|
23
|
+
/** Whether a session shows its UI when `open_session` does not set `headed`. */
|
|
22
24
|
readonly headed: boolean;
|
|
23
25
|
/** How many sessions may be open at once, from `--max-sessions`. */
|
|
24
26
|
readonly maxSessions?: number | undefined;
|
|
@@ -29,6 +31,8 @@ export interface ServeOptions {
|
|
|
29
31
|
readonly stdout: Writable;
|
|
30
32
|
/** Diagnostics for the operator, normally stderr. */
|
|
31
33
|
readonly log: (line: string) => void;
|
|
34
|
+
/** What user code in this process prints: held while a config loads, and its unfinished lines released once no tool call is in flight. */
|
|
35
|
+
readonly output?: Pick<McpOutput, 'withholdDuring' | 'duringCall'> | undefined;
|
|
32
36
|
/** Ends the server from outside: a process signal. */
|
|
33
37
|
readonly signal?: AbortSignal | undefined;
|
|
34
38
|
/** Told once per `open_session`, when its session closes or its open fails, with the client that asked; undefined before `initialize`. */
|