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.
Files changed (166) hide show
  1. package/README.md +10 -0
  2. package/dist/.build.json +1 -1
  3. package/dist/agent/ai-sdk.js +5 -4
  4. package/dist/agent/error.js +3 -3
  5. package/dist/agent/invocation.js +3 -4
  6. package/dist/agent/model/tool-choice.d.ts +9 -0
  7. package/dist/agent/model/tool-choice.js +14 -0
  8. package/dist/agent/observation-feed.js +3 -4
  9. package/dist/agent/phases.d.ts +7 -1
  10. package/dist/agent/phases.js +12 -3
  11. package/dist/agent/prompts.js +3 -0
  12. package/dist/agent/replay.js +18 -15
  13. package/dist/agent/step-cache.d.ts +2 -1
  14. package/dist/agent/step-cache.js +9 -5
  15. package/dist/agent/tool-loop.js +92 -5
  16. package/dist/cache/trace.js +6 -2
  17. package/dist/cli/bin.js +2 -2
  18. package/dist/cli/index.js +35 -24
  19. package/dist/cli/init/mcp-config.d.ts +2 -1
  20. package/dist/cli/init/mcp-config.js +8 -3
  21. package/dist/cli/init/sibling-versions.json +1 -1
  22. package/dist/cli/mcp.d.ts +3 -2
  23. package/dist/cli/mcp.js +52 -16
  24. package/dist/cli/run-output.d.ts +17 -0
  25. package/dist/cli/run-output.js +45 -0
  26. package/dist/collect/collect.js +8 -4
  27. package/dist/collect/registry.js +27 -16
  28. package/dist/config/compiled-files.d.ts +36 -0
  29. package/dist/config/compiled-files.js +50 -0
  30. package/dist/config/diagnose.js +21 -19
  31. package/dist/config/esm-hooks.d.ts +39 -44
  32. package/dist/config/esm-hooks.js +238 -63
  33. package/dist/config/load.d.ts +1 -7
  34. package/dist/config/load.js +23 -66
  35. package/dist/config/register.d.ts +8 -0
  36. package/dist/config/register.js +9 -0
  37. package/dist/config/specifiers.d.ts +19 -0
  38. package/dist/config/specifiers.js +33 -0
  39. package/dist/config/tsconfig.d.ts +56 -0
  40. package/dist/config/tsconfig.js +115 -0
  41. package/dist/config/typescript.d.ts +19 -0
  42. package/dist/config/typescript.js +253 -0
  43. package/dist/engine/contract.d.ts +1 -0
  44. package/dist/engine/index.d.ts +2 -0
  45. package/dist/engine/session.js +2 -2
  46. package/dist/expect/async.js +2 -1
  47. package/dist/expect/poll.js +2 -2
  48. package/dist/internal/errors.d.ts +10 -3
  49. package/dist/internal/errors.js +23 -8
  50. package/dist/internal/node-version.d.ts +16 -7
  51. package/dist/internal/node-version.js +50 -15
  52. package/dist/internal/package-manager.js +1 -1
  53. package/dist/internal/realm-slot.d.ts +5 -4
  54. package/dist/internal/redact.d.ts +2 -0
  55. package/dist/internal/redact.js +4 -0
  56. package/dist/internal/time.d.ts +21 -5
  57. package/dist/internal/time.js +35 -3
  58. package/dist/locator/engine.js +47 -11
  59. package/dist/locator/screen.js +31 -5
  60. package/dist/mcp/output.d.ts +59 -0
  61. package/dist/mcp/output.js +129 -0
  62. package/dist/mcp/server.d.ts +4 -0
  63. package/dist/mcp/server.js +9 -12
  64. package/dist/mcp/session.d.ts +8 -1
  65. package/dist/mcp/session.js +55 -14
  66. package/dist/mcp/tools.js +7 -5
  67. package/dist/report/list.d.ts +11 -0
  68. package/dist/report/list.js +23 -6
  69. package/dist/run/artifacts.d.ts +4 -4
  70. package/dist/run/artifacts.js +3 -2
  71. package/dist/run/execute.d.ts +25 -5
  72. package/dist/run/execute.js +44 -20
  73. package/dist/run/failure-evidence.js +6 -2
  74. package/dist/run/in-process.js +51 -10
  75. package/dist/run/process-output.d.ts +73 -0
  76. package/dist/run/process-output.js +130 -0
  77. package/dist/run/provision.d.ts +2 -0
  78. package/dist/run/provision.js +1 -0
  79. package/dist/run/records.d.ts +4 -4
  80. package/dist/run/retry.d.ts +7 -0
  81. package/dist/run/retry.js +27 -11
  82. package/dist/run/runner.d.ts +8 -0
  83. package/dist/run/runner.js +32 -9
  84. package/dist/run/scheduler.js +255 -19
  85. package/dist/run/secrecy.d.ts +22 -19
  86. package/dist/run/secrecy.js +22 -18
  87. package/dist/run/serial.d.ts +73 -2
  88. package/dist/run/serial.js +239 -58
  89. package/dist/run/standalone.js +1 -1
  90. package/dist/run/trace-redaction.d.ts +5 -6
  91. package/dist/run/trace-redaction.js +5 -6
  92. package/dist/run/worker/entry.js +35 -14
  93. package/dist/run/worker/handle.js +10 -5
  94. package/dist/run/worker/protocol.d.ts +83 -3
  95. package/dist/run/worker/session.js +28 -0
  96. package/dist/telemetry/environment.d.ts +2 -1
  97. package/dist/telemetry/environment.js +12 -2
  98. package/dist/telemetry/project.d.ts +5 -6
  99. package/dist/telemetry/project.js +6 -11
  100. package/dist/telemetry/store.d.ts +5 -5
  101. package/dist/telemetry/store.js +5 -7
  102. package/dist/telemetry/telemetry.d.ts +2 -3
  103. package/dist/telemetry/telemetry.js +2 -4
  104. package/dist/types.d.ts +6 -3
  105. package/docs/agent-steps.mdx +1 -0
  106. package/docs/agents.mdx +1 -0
  107. package/docs/api-testing.mdx +2 -1
  108. package/docs/authentication.mdx +3 -2
  109. package/docs/browser.mdx +7 -6
  110. package/docs/bug-bash.mdx +2 -1
  111. package/docs/cache.mdx +2 -1
  112. package/docs/ci.mdx +3 -2
  113. package/docs/coding-agents.mdx +2 -1
  114. package/docs/core-concepts.mdx +208 -0
  115. package/docs/debugging.mdx +19 -9
  116. package/docs/decision-models.mdx +142 -0
  117. package/docs/example-projects.mdx +32 -0
  118. package/docs/executors.mdx +3 -0
  119. package/docs/explore.mdx +1 -0
  120. package/docs/github.mdx +2 -1
  121. package/docs/index.mdx +9 -5
  122. package/docs/integrations/eas.mdx +1 -0
  123. package/docs/integrations/index.mdx +1 -0
  124. package/docs/integrations/kernel.mdx +2 -1
  125. package/docs/migrate/cypress.mdx +26 -3
  126. package/docs/migrate/detox.mdx +24 -1
  127. package/docs/migrate/maestro.mdx +28 -4
  128. package/docs/migrate/playwright.mdx +27 -4
  129. package/docs/migrate/selenium.mdx +27 -3
  130. package/docs/mobile-ci.mdx +6 -5
  131. package/docs/mobile.mdx +79 -8
  132. package/docs/models.mdx +7 -2
  133. package/docs/quickstart.mdx +26 -15
  134. package/docs/reference/agent.mdx +13 -3
  135. package/docs/reference/app.mdx +1 -0
  136. package/docs/reference/cli.mdx +11 -5
  137. package/docs/reference/config.mdx +42 -6
  138. package/docs/reference/engine.mdx +22 -10
  139. package/docs/reference/environment.mdx +2 -1
  140. package/docs/reference/errors.mdx +11 -8
  141. package/docs/reference/expect.mdx +2 -1
  142. package/docs/reference/mcp.mdx +21 -8
  143. package/docs/reference/mobile.mdx +1 -0
  144. package/docs/reference/reporters.mdx +1 -0
  145. package/docs/reference/screen.mdx +19 -9
  146. package/docs/reference/test.mdx +21 -1
  147. package/docs/reference/web.mdx +8 -2
  148. package/docs/security.mdx +33 -27
  149. package/docs/subscriptions.mdx +1 -0
  150. package/docs/telemetry.mdx +8 -5
  151. package/docs/tools.mdx +1 -0
  152. package/docs/{starting-your-app.mdx → web.mdx} +157 -11
  153. package/docs/writing-an-engine.mdx +2 -1
  154. package/package.json +11 -7
  155. package/skills/e2e/SKILL.md +3 -1
  156. package/skills/e2e/references/agent.md +11 -0
  157. package/skills/e2e/references/debugging.md +1 -1
  158. package/skills/e2e/references/mcp.md +8 -3
  159. package/skills/e2e/references/running.md +2 -1
  160. package/skills/e2e/references/setup.md +9 -1
  161. package/skills/e2e/references/writing-tests.md +1 -1
  162. package/docs/assertions.mdx +0 -94
  163. package/docs/goals.mdx +0 -176
  164. package/docs/locators.mdx +0 -64
  165. package/docs/physical-devices.mdx +0 -76
  166. package/docs/viewports.mdx +0 -146
@@ -1,10 +1,10 @@
1
1
  /**
2
- * The Node.js floor, checked before the CLI loads anything else. Package
3
- * managers only warn about `engines`, so an unsupported runtime would
4
- * otherwise surface as an unrelated TypeError deep inside a run.
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
- /** Mirrors `engines.node` in package.json; a unit test keeps the two in step. */
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
- /** Explains an unsupported runtime, or undefined when `current` meets the floor. */
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] > need[i])
21
- return undefined;
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 undefined;
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. Test modules load
3
- * in isolated module realms (tsx), so runner-owned instances are shared
4
- * through registry symbols instead of module state. All unsafe reads live
5
- * here; call sites stay fully typed.
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
  /**
@@ -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]);
@@ -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. Returns undefined when the
23
- * condition cannot be evaluated yet: the positive poll keeps waiting and
24
- * the negation grace window resets.
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
- onTimeout(): Error | Promise<Error>;
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. */
@@ -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 value = await options.evaluate();
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;
@@ -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 ref = assertSingle(await this.resolveOnce(expression, deadline), expression);
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
- // The wait as it happened: a deadline capped by the test's remaining
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 { node } = await this.tryRead(expression, deadline);
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 new TestError('LOCATOR_NOT_FOUND', `locator did not become visible with a box to act within: ${describeExpression(expression)}`, { details: locatorDetails(expression, Date.now() - startedMs) });
287
- }
322
+ if (deadline.expired())
323
+ throw notVisible();
288
324
  await sleep(POLL_INTERVAL_MS, this.signal);
289
325
  }
290
326
  }
@@ -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
- await this.context.steps.run('screen', 'screen.swipe', `${describePoint(from)} → ${describePoint(to)}`, () => this.context.engine.performAt(from, { kind: 'swipeTo', target: to }));
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 { node } = await engine.tryRead(internals.expression, deadline);
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
+ }
@@ -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`. */