e2e 0.16.0 → 0.17.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 (174) hide show
  1. package/dist/.build.json +1 -1
  2. package/dist/agent/act.js +7 -4
  3. package/dist/agent/action-dispatcher.js +4 -5
  4. package/dist/agent/actions.d.ts +32 -23
  5. package/dist/agent/actions.js +38 -24
  6. package/dist/agent/model/sdk.js +2 -0
  7. package/dist/agent/observation-feed.d.ts +5 -6
  8. package/dist/agent/observation-feed.js +5 -7
  9. package/dist/agent/observation.d.ts +40 -11
  10. package/dist/agent/observation.js +109 -59
  11. package/dist/agent/point-tap.d.ts +8 -8
  12. package/dist/agent/point-tap.js +1 -1
  13. package/dist/agent/replay.d.ts +19 -8
  14. package/dist/agent/replay.js +11 -14
  15. package/dist/agent/scroll-target.d.ts +2 -3
  16. package/dist/agent/step-cache.d.ts +41 -14
  17. package/dist/agent/step-cache.js +147 -62
  18. package/dist/agent/tool-loop.js +2 -0
  19. package/dist/cache/anchors.d.ts +72 -23
  20. package/dist/cache/anchors.js +233 -60
  21. package/dist/cache/context.d.ts +25 -14
  22. package/dist/cache/context.js +50 -8
  23. package/dist/cache/decide.d.ts +2 -2
  24. package/dist/cache/decide.js +4 -6
  25. package/dist/cache/identity.d.ts +18 -4
  26. package/dist/cache/identity.js +9 -4
  27. package/dist/cache/recorder.d.ts +15 -1
  28. package/dist/cache/recorder.js +36 -23
  29. package/dist/cache/rekeyed.d.ts +46 -0
  30. package/dist/cache/rekeyed.js +89 -0
  31. package/dist/cache/relocate.d.ts +14 -14
  32. package/dist/cache/relocate.js +33 -25
  33. package/dist/cache/route.d.ts +32 -33
  34. package/dist/cache/route.js +94 -50
  35. package/dist/cache/store.d.ts +6 -0
  36. package/dist/cache/store.js +21 -1
  37. package/dist/cache/trace.d.ts +53 -14
  38. package/dist/cache/trace.js +70 -28
  39. package/dist/cli/index.js +8 -8
  40. package/dist/cli/init/engines.d.ts +1 -3
  41. package/dist/cli/init/engines.js +2 -2
  42. package/dist/cli/init/gateways.d.ts +1 -1
  43. package/dist/cli/init/gateways.js +11 -1
  44. package/dist/cli/init/sibling-versions.json +1 -1
  45. package/dist/cli/init/versions.d.ts +0 -8
  46. package/dist/cli/init/versions.js +1 -10
  47. package/dist/config/resolve.d.ts +1 -0
  48. package/dist/config/resolve.js +33 -1
  49. package/dist/engine/index.d.ts +2 -2
  50. package/dist/expect/async.js +2 -1
  51. package/dist/expect/poll-scope.d.ts +52 -0
  52. package/dist/expect/poll-scope.js +113 -0
  53. package/dist/expect/poll.js +31 -4
  54. package/dist/expect/values.js +11 -4
  55. package/dist/internal/abandoned.d.ts +11 -0
  56. package/dist/internal/abandoned.js +23 -0
  57. package/dist/internal/brands.d.ts +2 -0
  58. package/dist/internal/brands.js +2 -0
  59. package/dist/internal/client-identity.d.ts +9 -0
  60. package/dist/internal/client-identity.js +14 -0
  61. package/dist/internal/redact.d.ts +7 -4
  62. package/dist/internal/redact.js +116 -23
  63. package/dist/internal/time.d.ts +6 -0
  64. package/dist/internal/time.js +25 -13
  65. package/dist/internal/urls.d.ts +9 -5
  66. package/dist/internal/urls.js +19 -10
  67. package/dist/locator/engine.d.ts +12 -2
  68. package/dist/locator/engine.js +8 -3
  69. package/dist/locator/screen.js +28 -4
  70. package/dist/mcp/catalog.d.ts +3 -2
  71. package/dist/mcp/catalog.js +6 -6
  72. package/dist/mcp/session.js +1 -1
  73. package/dist/oauth/chatgpt.js +3 -23
  74. package/dist/oauth/cli.js +1 -0
  75. package/dist/oauth/copilot.d.ts +5 -4
  76. package/dist/oauth/copilot.js +80 -7
  77. package/dist/oauth/grok.js +2 -1
  78. package/dist/oauth/models.js +2 -1
  79. package/dist/oauth/opencode-console.d.ts +10 -0
  80. package/dist/oauth/opencode-console.js +133 -0
  81. package/dist/oauth/providers/github-copilot.d.ts +12 -0
  82. package/dist/oauth/providers/github-copilot.js +77 -23
  83. package/dist/oauth/providers/opencode-console.d.ts +31 -0
  84. package/dist/oauth/providers/opencode-console.js +148 -0
  85. package/dist/oauth/providers.d.ts +2 -6
  86. package/dist/oauth/providers.js +3 -7
  87. package/dist/oauth/responses.d.ts +9 -0
  88. package/dist/oauth/responses.js +23 -0
  89. package/dist/report/build.d.ts +26 -0
  90. package/dist/report/build.js +16 -8
  91. package/dist/report/failure-text.d.ts +6 -1
  92. package/dist/report/failure-text.js +14 -4
  93. package/dist/report/format.d.ts +10 -5
  94. package/dist/report/format.js +12 -5
  95. package/dist/report/junit.js +11 -12
  96. package/dist/report/list.d.ts +1 -0
  97. package/dist/report/list.js +38 -18
  98. package/dist/report/markdown.d.ts +1 -1
  99. package/dist/report/markdown.js +37 -14
  100. package/dist/report/repeats.d.ts +6 -1
  101. package/dist/report/repeats.js +12 -3
  102. package/dist/run/artifacts.d.ts +18 -0
  103. package/dist/run/artifacts.js +68 -1
  104. package/dist/run/execute.d.ts +9 -0
  105. package/dist/run/execute.js +49 -24
  106. package/dist/run/extended-fixtures.js +6 -2
  107. package/dist/run/failure-evidence.js +4 -2
  108. package/dist/run/fixtures.js +6 -8
  109. package/dist/run/in-process.d.ts +2 -0
  110. package/dist/run/in-process.js +1 -0
  111. package/dist/run/last-run.d.ts +42 -8
  112. package/dist/run/last-run.js +121 -14
  113. package/dist/run/realm.d.ts +4 -1
  114. package/dist/run/realm.js +20 -6
  115. package/dist/run/records.d.ts +16 -0
  116. package/dist/run/records.js +26 -0
  117. package/dist/run/retry.d.ts +3 -1
  118. package/dist/run/retry.js +4 -2
  119. package/dist/run/runner.d.ts +1 -1
  120. package/dist/run/runner.js +69 -30
  121. package/dist/run/serial.d.ts +2 -0
  122. package/dist/run/serial.js +8 -5
  123. package/dist/run/steps.d.ts +0 -6
  124. package/dist/run/steps.js +3 -20
  125. package/dist/run/worker/entry.js +5 -4
  126. package/dist/run/worker/protocol.d.ts +2 -0
  127. package/dist/run/worker/session.d.ts +2 -0
  128. package/dist/run/worker/session.js +1 -0
  129. package/dist/telemetry/events.js +1 -0
  130. package/dist/types.d.ts +9 -3
  131. package/docs/agents.mdx +6 -3
  132. package/docs/assertions.mdx +94 -0
  133. package/docs/authentication.mdx +3 -1
  134. package/docs/browser.mdx +42 -6
  135. package/docs/cache.mdx +64 -18
  136. package/docs/ci.mdx +11 -7
  137. package/docs/debugging.mdx +6 -3
  138. package/docs/executors.mdx +4 -2
  139. package/docs/github.mdx +12 -4
  140. package/docs/{writing-tests.mdx → goals.mdx} +21 -101
  141. package/docs/index.mdx +4 -4
  142. package/docs/integrations/kernel.mdx +1 -1
  143. package/docs/locators.mdx +64 -0
  144. package/docs/migrate/cypress.mdx +2 -2
  145. package/docs/migrate/maestro.mdx +2 -2
  146. package/docs/migrate/playwright.mdx +19 -16
  147. package/docs/migrate/selenium.mdx +2 -2
  148. package/docs/mobile.mdx +21 -4
  149. package/docs/models.mdx +53 -7
  150. package/docs/physical-devices.mdx +76 -0
  151. package/docs/quickstart.mdx +4 -3
  152. package/docs/reference/app.mdx +1 -1
  153. package/docs/reference/cli.mdx +30 -13
  154. package/docs/reference/config.mdx +25 -7
  155. package/docs/reference/engine.mdx +3 -3
  156. package/docs/reference/environment.mdx +8 -1
  157. package/docs/reference/errors.mdx +5 -5
  158. package/docs/reference/expect.mdx +34 -12
  159. package/docs/reference/mobile.mdx +23 -13
  160. package/docs/reference/reporters.mdx +8 -2
  161. package/docs/reference/screen.mdx +30 -7
  162. package/docs/reference/test.mdx +11 -4
  163. package/docs/reference/web.mdx +125 -21
  164. package/docs/security.mdx +26 -16
  165. package/docs/subscriptions.mdx +91 -12
  166. package/docs/telemetry.mdx +1 -1
  167. package/package.json +16 -3
  168. package/schema/report-v1.schema.json +14 -1
  169. package/skills/e2e/SKILL.md +2 -2
  170. package/skills/e2e/references/agent.md +22 -11
  171. package/skills/e2e/references/debugging.md +4 -4
  172. package/skills/e2e/references/running.md +13 -8
  173. package/skills/e2e/references/setup.md +36 -15
  174. package/skills/e2e/references/writing-tests.md +34 -17
@@ -50,12 +50,22 @@ export const GATEWAYS = [
50
50
  id: 'copilot',
51
51
  label: 'GitHub Copilot subscription',
52
52
  hint: 'your Copilot plan: OpenAI, Anthropic, Google, and SpaceXAI models',
53
- dependencies: { '@ai-sdk/openai-compatible': '^3.0.0' },
53
+ dependencies: { '@ai-sdk/openai-compatible': '^3.0.0', '@ai-sdk/openai': '^4.0.0' },
54
54
  import: "import { copilot } from 'e2e/oauth/copilot';",
55
55
  comment: 'Your GitHub Copilot subscription serves the model; sign in once with `e2e login github-copilot`, `e2e models github-copilot` lists the ids.',
56
56
  login: 'github-copilot',
57
57
  model: () => "copilot('claude-sonnet-5')",
58
58
  },
59
+ {
60
+ id: 'opencode-console',
61
+ label: 'OpenCode Console',
62
+ hint: 'the models your OpenCode Console workspace serves',
63
+ dependencies: { '@ai-sdk/openai-compatible': '^3.0.0', '@ai-sdk/openai': '^4.0.0', '@ai-sdk/anthropic': '^4.0.0', '@ai-sdk/google': '^4.0.0' },
64
+ import: "import { opencodeConsole } from 'e2e/oauth/opencode-console';",
65
+ comment: 'Your OpenCode Console workspace serves the model; sign in once with `e2e login opencode-console`, `e2e models opencode-console` lists the ids.',
66
+ login: 'opencode-console',
67
+ model: () => "opencodeConsole('deepseek-v4.1-flash')",
68
+ },
59
69
  {
60
70
  id: 'grok',
61
71
  label: 'SuperGrok subscription',
@@ -1 +1 @@
1
- {"@e2e-dev/web":"0.11.2","@e2e-dev/mobile":"0.9.1","playwright":"1.63.0"}
1
+ {"@e2e-dev/web":"0.12.0","@e2e-dev/mobile":"0.9.2"}
@@ -3,7 +3,6 @@
3
3
  * build in `sibling-versions.json` next to this module from their manifests.
4
4
  * Absent when running from source. Both preset tables read from here.
5
5
  */
6
- export declare const SIBLING_VERSIONS: Readonly<Record<string, string>> | undefined;
7
6
  /**
8
7
  * The range init writes for the runner and for an engine. Engines version
9
8
  * independently of the runner and pin it through their own peer range, so init
@@ -20,10 +19,3 @@ export declare const SIBLING_VERSIONS: Readonly<Record<string, string>> | undefi
20
19
  export declare function dependencyRange(version: string | undefined): string;
21
20
  /** The range init writes for a sibling `@e2e-dev/*` package released alongside this runner. */
22
21
  export declare function siblingDependency(name: string): Readonly<Record<string, string>>;
23
- /**
24
- * The range init writes for `playwright`, which `@e2e-dev/web` peers on
25
- * rather than installs, so an app that already ships Playwright keeps one copy
26
- * and one browser cache. The build records the version the engine was built
27
- * and tested against; from source, where nothing is recorded, any 1.x will do.
28
- */
29
- export declare function playwrightRange(version: string | undefined): string;
@@ -4,7 +4,7 @@
4
4
  * Absent when running from source. Both preset tables read from here.
5
5
  */
6
6
  import { readJson } from '../../internal/package-version.js';
7
- export const SIBLING_VERSIONS = readJson(import.meta.url, './sibling-versions.json');
7
+ const SIBLING_VERSIONS = readJson(import.meta.url, './sibling-versions.json');
8
8
  /**
9
9
  * The range init writes for the runner and for an engine. Engines version
10
10
  * independently of the runner and pin it through their own peer range, so init
@@ -27,12 +27,3 @@ export function dependencyRange(version) {
27
27
  export function siblingDependency(name) {
28
28
  return { [name]: dependencyRange(SIBLING_VERSIONS?.[name]) };
29
29
  }
30
- /**
31
- * The range init writes for `playwright`, which `@e2e-dev/web` peers on
32
- * rather than installs, so an app that already ships Playwright keeps one copy
33
- * and one browser cache. The build records the version the engine was built
34
- * and tested against; from source, where nothing is recorded, any 1.x will do.
35
- */
36
- export function playwrightRange(version) {
37
- return version === undefined ? '^1' : `^${version}`;
38
- }
@@ -34,6 +34,7 @@ export interface ResolvedConfig {
34
34
  readonly assertionTimeout: number;
35
35
  readonly cleanupTimeout: number;
36
36
  readonly retries: number;
37
+ readonly failOnSkippedFailure: boolean;
37
38
  readonly workers: number;
38
39
  /** Host store every produced artifact is handed to; undefined keeps files local only. */
39
40
  readonly artifactStore: ArtifactStore | undefined;
@@ -25,6 +25,7 @@ const TOP_LEVEL_KEYS = new Set([
25
25
  'assertionTimeout',
26
26
  'cleanupTimeout',
27
27
  'retries',
28
+ 'failOnSkippedFailure',
28
29
  'workers',
29
30
  'artifacts',
30
31
  'output',
@@ -111,6 +112,10 @@ export function resolveConfig(raw, options) {
111
112
  const workers = boundedInt(cli.workers, '--workers', 1, 1024) ??
112
113
  boundedInt(raw.workers, 'workers', 1, 1024) ??
113
114
  (ci ? 1 : Math.max(1, Math.floor(os.availableParallelism() / 2)));
115
+ const failOnSkippedFailure = raw.failOnSkippedFailure === undefined ? false : raw.failOnSkippedFailure;
116
+ if (typeof failOnSkippedFailure !== 'boolean') {
117
+ throw new ConfigurationError('INVALID_CONFIG', 'failOnSkippedFailure must be a boolean');
118
+ }
114
119
  const artifactStore = resolveArtifactStore(raw);
115
120
  const { reporters, customReporters } = resolveReporters(raw, cli);
116
121
  const projectId = resolveProjectId(raw.projectId, options.projectRoot);
@@ -134,6 +139,7 @@ export function resolveConfig(raw, options) {
134
139
  assertionTimeout,
135
140
  cleanupTimeout,
136
141
  retries,
142
+ failOnSkippedFailure,
137
143
  workers,
138
144
  artifactStore,
139
145
  output,
@@ -533,6 +539,8 @@ function resolveSecrets(raw, env) {
533
539
  const credentials = new Map();
534
540
  const secrets = new Map();
535
541
  const allSecrets = new Map();
542
+ checkEnvNameCollisions('E2E_USER', 'credentials', Object.keys(raw.credentials ?? {}));
543
+ checkEnvNameCollisions('E2E_SECRET', 'secrets', Object.keys(raw.secrets ?? {}));
536
544
  for (const [name, credential] of Object.entries(raw.credentials ?? {})) {
537
545
  const prefix = envName('E2E_USER', name);
538
546
  const username = env[`${prefix}_USERNAME`] ?? credential.username;
@@ -561,6 +569,29 @@ function resolveSecrets(raw, env) {
561
569
  }
562
570
  return { credentials, secrets, allSecrets };
563
571
  }
572
+ /**
573
+ * Two entries of one namespace whose names map to the same override variable
574
+ * (`api-key` and `api_key` both read `E2E_SECRET_API_KEY`) would both take one
575
+ * rotated value, so the config load refuses them, whether or not the variable
576
+ * is set. The message names the entries and the variable, never a value.
577
+ */
578
+ function checkEnvNameCollisions(prefix, namespace, names) {
579
+ const byEnvName = new Map();
580
+ for (const name of names) {
581
+ const variable = envName(prefix, name);
582
+ byEnvName.set(variable, [...(byEnvName.get(variable) ?? []), name]);
583
+ }
584
+ for (const [variable, group] of byEnvName) {
585
+ if (group.length < 2)
586
+ continue;
587
+ const quoted = group.map((name) => `"${name}"`);
588
+ const entries = `${quoted.slice(0, -1).join(', ')} and ${quoted.at(-1)}`;
589
+ const variables = prefix === 'E2E_USER'
590
+ ? `the override variables ${variable}_USERNAME and ${variable}_PASSWORD`
591
+ : `the override variable ${variable}`;
592
+ throw new ConfigurationError('INVALID_CONFIG', `${namespace} ${entries} share ${variables}, so one value set there would replace all of them; rename all but one`);
593
+ }
594
+ }
564
595
  /**
565
596
  * Every secret an engine's options hold (a `secrets.get()` the config
566
597
  * evaluated before any run existed) names a configured secret, so a typo
@@ -643,9 +674,10 @@ function computeConfigDigest(raw, projectId, targets) {
643
674
  // reporter object changes nothing about what a run records, so it never
644
675
  // enters the digest either; the built-in ids digest as they always have,
645
676
  // so adding a reporter to a config leaves its cache valid.
677
+ // `failOnSkippedFailure` decides only the exit code, never what runs.
646
678
  // `targets` digest by declaration below and never enter the clone: an
647
679
  // engine holds `secrets.get()` handles, which refuse to serialize.
648
- const { artifacts: _artifacts, output: _output, trace: _trace, video: _video, targets: _targets, credentials: _credentials, secrets: _secrets, agents, cache, reporters, ...plain } = raw;
680
+ const { failOnSkippedFailure: _failOnSkippedFailure, artifacts: _artifacts, output: _output, trace: _trace, video: _video, targets: _targets, credentials: _credentials, secrets: _secrets, agents, cache, reporters, ...plain } = raw;
649
681
  const sanitized = {
650
682
  ...structuredCloneJsonSafe(plain),
651
683
  ...(reporters === undefined
@@ -147,8 +147,8 @@ export interface EngineFixtureContext {
147
147
  /**
148
148
  * Resolves a navigation target against the base URL. Throws
149
149
  * `APP_URL_REQUIRED` when the target declares no URL and `POLICY_DENIED`
150
- * for a `file:`, `data:`, or `javascript:` scheme, so a fixture never
151
- * re-implements the rule the harness owns.
150
+ * for any scheme but `http:` and `https:` (`about:blank` aside), so a
151
+ * fixture never re-implements the rule the harness owns.
152
152
  */
153
153
  resolveUrl(url: string): string;
154
154
  };
@@ -144,7 +144,8 @@ class AsyncExpectationImpl {
144
144
  const refs = await engine.resolveAll(this.internals.expression, deadline);
145
145
  return { count: refs.length, node: null, nodes: [] };
146
146
  }
147
- const { node, count } = await engine.tryRead(this.internals.expression, deadline);
147
+ const missingFrame = spec.evaluableWithoutNode === true ? 'empty' : 'wait';
148
+ const { node, count } = await engine.tryRead(this.internals.expression, deadline, missingFrame);
148
149
  return { count, node, nodes: [] };
149
150
  }
150
151
  /** A state matcher; its Playwright flag set to `false` expects the opposite state. */
@@ -0,0 +1,52 @@
1
+ /** Which phase of a test owns an `expect.poll`, so one left running fails that phase and nothing later. */
2
+ import { TestError } from '../internal/errors.ts';
3
+ /**
4
+ * The polls one phase started: a test body with its fixtures and
5
+ * `beforeEach` hooks, one teardown, or one suite hook. A poll is not a step
6
+ * and records nothing; what the scope adds is that a poll the phase did not
7
+ * await ends with the phase instead of failing whatever runs when it times out.
8
+ */
9
+ export declare class PollScope {
10
+ private readonly owner;
11
+ private readonly running;
12
+ private closed;
13
+ /** `owner` completes "... returned before", as in `the test body`. */
14
+ constructor(owner: string);
15
+ /**
16
+ * Runs one poll, owned by this scope until it settles. `work` receives the
17
+ * signal that cancels it when the scope closes first. A poll started after
18
+ * the scope closed, by work its phase left running (a body past its
19
+ * timeout), is cancelled before its first read: it belongs to no phase
20
+ * that could still fail, and must not fail the one running now.
21
+ */
22
+ track<T>(label: string, stack: string | undefined, work: (signal: AbortSignal) => Promise<T>): Promise<T>;
23
+ /**
24
+ * Ends the scope when its phase has returned: every poll still running is
25
+ * cancelled and its later rejection observed. Returns the error naming
26
+ * them, at the line of the first, or nothing when every poll had settled.
27
+ * A caller whose phase was cut short (a timeout, a throw) may drop the
28
+ * error: the phase may have been awaiting the poll. Closing twice returns
29
+ * nothing the second time.
30
+ */
31
+ close(): TestError | undefined;
32
+ private cancel;
33
+ }
34
+ /**
35
+ * Runs `run` with `scope` owning every poll it starts, however deep in its
36
+ * async work and however long that work outlives the phase: a poll belongs
37
+ * to the phase that started it, never to the one running when it is called.
38
+ */
39
+ export declare function runInPollScope<T>(scope: PollScope, run: () => T): T;
40
+ /** One lineage of async work whose polls go to the scope current where it started, until `handOver`. */
41
+ export interface PollLineage {
42
+ run<T>(run: () => T): T;
43
+ /** Sends the lineage's polls from now on to the scope current at this call. */
44
+ handOver(): void;
45
+ }
46
+ /**
47
+ * A lineage for work that spans two phases, a fixture: its setup runs in the
48
+ * body's phase, and the rest of its function after `use` is its teardown's.
49
+ */
50
+ export declare function pollLineage(): PollLineage;
51
+ /** The scope a poll started here belongs to, or undefined outside any phase (a standalone script). */
52
+ export declare function currentPollScope(): PollScope | undefined;
@@ -0,0 +1,113 @@
1
+ /** Which phase of a test owns an `expect.poll`, so one left running fails that phase and nothing later. */
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { markAbandonedRejection, relocateStack } from '../internal/abandoned.js';
4
+ import { pollScopesBrand } from '../internal/brands.js';
5
+ import { TestError, withHint } from '../internal/errors.js';
6
+ import { realmSlot } from '../internal/realm-slot.js';
7
+ /**
8
+ * The polls one phase started: a test body with its fixtures and
9
+ * `beforeEach` hooks, one teardown, or one suite hook. A poll is not a step
10
+ * and records nothing; what the scope adds is that a poll the phase did not
11
+ * await ends with the phase instead of failing whatever runs when it times out.
12
+ */
13
+ export class PollScope {
14
+ owner;
15
+ running = new Set();
16
+ closed = false;
17
+ /** `owner` completes "... returned before", as in `the test body`. */
18
+ constructor(owner) {
19
+ this.owner = owner;
20
+ }
21
+ /**
22
+ * Runs one poll, owned by this scope until it settles. `work` receives the
23
+ * signal that cancels it when the scope closes first. A poll started after
24
+ * the scope closed, by work its phase left running (a body past its
25
+ * timeout), is cancelled before its first read: it belongs to no phase
26
+ * that could still fail, and must not fail the one running now.
27
+ */
28
+ track(label, stack, work) {
29
+ const poll = { label, stack, controller: new AbortController(), promise: Promise.resolve(), abandoned: false };
30
+ if (this.closed)
31
+ this.cancel(poll);
32
+ const promise = (async () => {
33
+ try {
34
+ return await work(poll.controller.signal);
35
+ }
36
+ catch (cause) {
37
+ if (poll.abandoned)
38
+ markAbandonedRejection(cause);
39
+ throw cause;
40
+ }
41
+ finally {
42
+ this.running.delete(poll);
43
+ }
44
+ })();
45
+ poll.promise = promise;
46
+ if (poll.abandoned)
47
+ promise.catch(() => undefined);
48
+ else
49
+ this.running.add(poll);
50
+ return promise;
51
+ }
52
+ /**
53
+ * Ends the scope when its phase has returned: every poll still running is
54
+ * cancelled and its later rejection observed. Returns the error naming
55
+ * them, at the line of the first, or nothing when every poll had settled.
56
+ * A caller whose phase was cut short (a timeout, a throw) may drop the
57
+ * error: the phase may have been awaiting the poll. Closing twice returns
58
+ * nothing the second time.
59
+ */
60
+ close() {
61
+ this.closed = true;
62
+ const polls = [...this.running];
63
+ const [first] = polls;
64
+ if (first === undefined)
65
+ return undefined;
66
+ for (const poll of polls)
67
+ this.cancel(poll);
68
+ const more = polls.length > 1 ? ` and ${polls.length - 1} more` : '';
69
+ const error = new TestError('STEP_NOT_AWAITED', withHint(`${this.owner} returned before ${first.label}${more} finished`, 'put `await` in front of every expect.poll call'));
70
+ relocateStack(error, first.stack);
71
+ return error;
72
+ }
73
+ cancel(poll) {
74
+ this.running.delete(poll);
75
+ poll.abandoned = true;
76
+ poll.promise.catch(() => undefined);
77
+ poll.controller.abort();
78
+ }
79
+ }
80
+ const storage = new AsyncLocalStorage();
81
+ const scopes = { current: () => storage.getStore()?.scope };
82
+ const slot = realmSlot(pollScopesBrand);
83
+ /** Runs `run` in the lineage `owner` names, publishing the runner's context for a test module's `expect`. */
84
+ function enter(owner, run) {
85
+ if (slot.get(globalThis) !== scopes)
86
+ slot.set(globalThis, scopes);
87
+ return storage.run(owner, run);
88
+ }
89
+ /**
90
+ * Runs `run` with `scope` owning every poll it starts, however deep in its
91
+ * async work and however long that work outlives the phase: a poll belongs
92
+ * to the phase that started it, never to the one running when it is called.
93
+ */
94
+ export function runInPollScope(scope, run) {
95
+ return enter({ scope }, run);
96
+ }
97
+ /**
98
+ * A lineage for work that spans two phases, a fixture: its setup runs in the
99
+ * body's phase, and the rest of its function after `use` is its teardown's.
100
+ */
101
+ export function pollLineage() {
102
+ const owner = { scope: scopes.current() };
103
+ return {
104
+ run: (run) => enter(owner, run),
105
+ handOver: () => {
106
+ owner.scope = scopes.current();
107
+ },
108
+ };
109
+ }
110
+ /** The scope a poll started here belongs to, or undefined outside any phase (a standalone script). */
111
+ export function currentPollScope() {
112
+ return slot.get(globalThis)?.current();
113
+ }
@@ -2,9 +2,12 @@
2
2
  import { ConfigurationError, E2EError, TestError } from '../internal/errors.js';
3
3
  import { Deadline, POLL_INTERVAL_MS, sleep, withAbort, withTimeout } from '../internal/time.js';
4
4
  import { currentAttempt } from './attempt.js';
5
+ import { currentPollScope } from './poll-scope.js';
5
6
  import { createValueExpectation } from './values.js';
6
7
  /** Outside an attempt (a standalone script) there is no config to read `assertionTimeout` from. */
7
8
  const DEFAULT_TIMEOUT_MS = 5000;
9
+ /** Frames kept for the line a poll was called on; the user's line is a few frames up. */
10
+ const CALL_STACK_FRAMES = 20;
8
11
  /** `satisfies` keeps this list equal to the ValueExpectation matcher set. */
9
12
  const MATCHERS = {
10
13
  toBe: true,
@@ -46,7 +49,21 @@ function sample(read, deadline, signal) {
46
49
  const bounded = withTimeout((async () => read())(), deadline.remaining(), () => new ReadHung());
47
50
  return signal === undefined ? bounded : withAbort(bounded, signal, () => new ReadAborted());
48
51
  }
49
- async function pollMatcher(read, options, negated, name, args) {
52
+ /** The stack at a poll's call, for the error that names it when its phase did not await it. */
53
+ function callStack() {
54
+ const limit = Error.stackTraceLimit;
55
+ Error.stackTraceLimit = CALL_STACK_FRAMES;
56
+ try {
57
+ return new Error().stack;
58
+ }
59
+ finally {
60
+ Error.stackTraceLimit = limit;
61
+ }
62
+ }
63
+ function matcherLabel(negated, name) {
64
+ return `expect.poll(...).${negated ? 'not.' : ''}${name}(...)`;
65
+ }
66
+ async function pollMatcher(read, options, negated, name, args, scopeSignal) {
50
67
  const attempt = currentAttempt();
51
68
  const timeout = options.timeout ?? attempt?.assertionTimeout ?? DEFAULT_TIMEOUT_MS;
52
69
  const interval = options.interval ?? POLL_INTERVAL_MS;
@@ -57,9 +74,14 @@ async function pollMatcher(read, options, negated, name, args) {
57
74
  // the moment the attempt is cancelled, so a timed-out test never keeps
58
75
  // reading through teardown.
59
76
  const deadline = attempt === undefined ? own : Deadline.min(own, attempt.budget.deadline);
60
- const signal = attempt?.budget.signal;
77
+ // The phase that started the poll cancels it when it returns without
78
+ // awaiting it, so the poll can never fail whatever runs after.
79
+ const signals = [attempt?.budget.signal, scopeSignal].filter((signal) => signal !== undefined);
80
+ const signal = signals.length === 0 ? undefined : AbortSignal.any(signals);
61
81
  let last;
62
82
  while (!deadline.expired()) {
83
+ if (signal?.aborted === true)
84
+ throw cancelled();
63
85
  let value;
64
86
  try {
65
87
  value = await sample(read, deadline, signal);
@@ -89,7 +111,7 @@ async function pollMatcher(read, options, negated, name, args) {
89
111
  ? `timed out after ${timeout} ms`
90
112
  : `stopped at the attempt deadline after ${Date.now() - startedAt} ms`;
91
113
  throw new TestError('ASSERTION_FAILED', [
92
- `expect.poll(...).${negated ? 'not.' : ''}${name}(...) ${ending}`,
114
+ `${matcherLabel(negated, name)} ${ending}`,
93
115
  ...(options.message === undefined ? [] : [options.message]),
94
116
  `last: ${last ?? 'no read completed'}`,
95
117
  ].join('\n'));
@@ -107,7 +129,12 @@ function checkOptions(options) {
107
129
  function build(read, options, negated) {
108
130
  const matchers = Object.fromEntries(Object.keys(MATCHERS).map((name) => [
109
131
  name,
110
- (...args) => pollMatcher(read, options, negated, name, args),
132
+ (...args) => {
133
+ const scope = currentPollScope();
134
+ if (scope === undefined)
135
+ return pollMatcher(read, options, negated, name, args, undefined);
136
+ return scope.track(matcherLabel(negated, name), callStack(), (signal) => pollMatcher(read, options, negated, name, args, signal));
137
+ },
111
138
  ]));
112
139
  return {
113
140
  ...matchers,
@@ -207,14 +207,21 @@ class ValueExpectationImpl {
207
207
  this.check(holds(actual), () => `expected ${actual} to be ${phrase} ${expected}`, () => `expected ${actual} not to be ${phrase} ${expected}`);
208
208
  }
209
209
  }
210
- /** Walks `keys` into `value`; `present` says whether the whole path resolved. */
210
+ /**
211
+ * Walks `keys` into `value`; `present` says whether the whole path resolved.
212
+ * A primitive on the way is read through its wrapper object, as Jest does,
213
+ * so a string has its `length` and indices; `null` and `undefined` have no
214
+ * properties.
215
+ */
211
216
  function lookup(value, keys) {
212
217
  let current = value;
213
218
  for (const key of keys) {
214
- if ((typeof current !== 'object' && typeof current !== 'function') || current === null || !(key in current)) {
219
+ if (current === null || current === undefined)
215
220
  return { present: false, value: undefined };
216
- }
217
- current = current[key];
221
+ const holder = Object(current);
222
+ if (!(key in holder))
223
+ return { present: false, value: undefined };
224
+ current = holder[key];
218
225
  }
219
226
  return { present: true, value: current };
220
227
  }
@@ -0,0 +1,11 @@
1
+ /** Work a test left running when its phase returned: steps and polls nobody awaited. */
2
+ /** Records `cause` as the rejection of work already failed as not awaited. */
3
+ export declare function markAbandonedRejection(cause: unknown): void;
4
+ /**
5
+ * Whether an unhandled rejection is abandoned work's: already recorded as
6
+ * `STEP_NOT_AWAITED`, reaching the process only because the promise the
7
+ * test did not await rejected with it. Such a rejection is not a fault.
8
+ */
9
+ export declare function isAbandonedRejection(cause: unknown): boolean;
10
+ /** Points `error` at the frames of `stack`, keeping its own name and message as the first line. */
11
+ export declare function relocateStack(error: Error, stack: string | undefined): void;
@@ -0,0 +1,23 @@
1
+ /** Work a test left running when its phase returned: steps and polls nobody awaited. */
2
+ /** What abandoned work rejected with. The promise the test holds rejects with the same value, and nobody awaits it. */
3
+ const ABANDONED_REJECTIONS = new WeakSet();
4
+ /** Records `cause` as the rejection of work already failed as not awaited. */
5
+ export function markAbandonedRejection(cause) {
6
+ if (typeof cause === 'object' && cause !== null)
7
+ ABANDONED_REJECTIONS.add(cause);
8
+ }
9
+ /**
10
+ * Whether an unhandled rejection is abandoned work's: already recorded as
11
+ * `STEP_NOT_AWAITED`, reaching the process only because the promise the
12
+ * test did not await rejected with it. Such a rejection is not a fault.
13
+ */
14
+ export function isAbandonedRejection(cause) {
15
+ return typeof cause === 'object' && cause !== null && ABANDONED_REJECTIONS.has(cause);
16
+ }
17
+ /** Points `error` at the frames of `stack`, keeping its own name and message as the first line. */
18
+ export function relocateStack(error, stack) {
19
+ if (stack === undefined)
20
+ return;
21
+ const frames = stack.split('\n').slice(1).join('\n');
22
+ error.stack = `${error.name}: ${error.message}\n${frames}`;
23
+ }
@@ -9,3 +9,5 @@ export declare const locatorBrand: unique symbol;
9
9
  export declare const expectationBrand: unique symbol;
10
10
  /** Slot on `globalThis` naming the running attempt for `expect.poll`. */
11
11
  export declare const attemptBrand: unique symbol;
12
+ /** Slot on `globalThis` holding the runner's view of which phase owns an `expect.poll`. */
13
+ export declare const pollScopesBrand: unique symbol;
@@ -9,3 +9,5 @@ export const locatorBrand = Symbol.for('e2e.locator');
9
9
  export const expectationBrand = Symbol.for('e2e.expectation.v1');
10
10
  /** Slot on `globalThis` naming the running attempt for `expect.poll`. */
11
11
  export const attemptBrand = Symbol.for('e2e.attempt.v1');
12
+ /** Slot on `globalThis` holding the runner's view of which phase owns an `expect.poll`. */
13
+ export const pollScopesBrand = Symbol.for('e2e.pollScopes.v1');
@@ -0,0 +1,9 @@
1
+ /** How e2e names itself to the vendors it calls. */
2
+ /** `e2e/<version> (<platform>; <arch>)`: what every vendor request identifies as. Never another client's name. */
3
+ export declare const USER_AGENT: string;
4
+ /**
5
+ * Sent with every model call, whichever provider serves it. The AI SDK appends
6
+ * its own user agent after ours; `http-referer` and `x-title` are the app
7
+ * attribution the Vercel AI Gateway and OpenRouter read, and others ignore.
8
+ */
9
+ export declare const MODEL_REQUEST_HEADERS: Readonly<Record<string, string>>;
@@ -0,0 +1,14 @@
1
+ /** How e2e names itself to the vendors it calls. */
2
+ import { packageVersion } from './package-version.js';
3
+ /** `e2e/<version> (<platform>; <arch>)`: what every vendor request identifies as. Never another client's name. */
4
+ export const USER_AGENT = `e2e/${packageVersion(import.meta.url, '../../package.json', '0.0.0')} (${process.platform}; ${process.arch})`;
5
+ /**
6
+ * Sent with every model call, whichever provider serves it. The AI SDK appends
7
+ * its own user agent after ours; `http-referer` and `x-title` are the app
8
+ * attribution the Vercel AI Gateway and OpenRouter read, and others ignore.
9
+ */
10
+ export const MODEL_REQUEST_HEADERS = {
11
+ 'user-agent': USER_AGENT,
12
+ 'http-referer': 'https://tester.army/e2e',
13
+ 'x-title': 'e2e',
14
+ };
@@ -47,9 +47,12 @@ export declare class SecretLedger {
47
47
  * `FRAGMENT_LENGTH` characters, or half of a value shorter than twice that.
48
48
  * A cut is one position, so a boundary this short seldom matches plain text
49
49
  * by chance. Matched as the value is written, in any case (`caseKey`), as
50
- * a CSS `text-transform` shows it: the cut falls on text as the engine read
51
- * it, before any serializer spells it. A case mapping that changes length
52
- * (`ß` to `SS`) is not followed, nor is collapsed whitespace. The part is found before whole values are
50
+ * a CSS `text-transform` shows it, and with every whitespace run on both
51
+ * sides read as one space (`readingKey`) and the value's leading whitespace
52
+ * dropped where whole-value matching trims it (`valuePattern`), as an
53
+ * engine that collapses text before cutting it shows it: the cut falls on
54
+ * text as the engine read it, before any serializer spells it. A case
55
+ * mapping that changes length (`ß` to `SS`) is not followed. The part is found before whole values are
53
56
  * rewritten, so a value that starts with another registered value is not
54
57
  * half rewritten as the shorter one, and an occurrence running into the
55
58
  * part joins its marker. Bound like `redact`.
@@ -61,7 +64,7 @@ export declare class SecretLedger {
61
64
  * becomes its marker. For a recording that keeps what an engine read raw
62
65
  * (a Playwright trace holds the page's cut text and selections), where a
63
66
  * value cut or selected partway through survives whole-value matching.
64
- * Matched as the value is written, in any case, like `redactCut`. A
67
+ * Matched in any case and with whitespace runs collapsed, like `redactCut`. A
65
68
  * base64 or base64url run that decodes to text holding a value or a
66
69
  * fragment is rewritten whole (a basic-auth header the engine sent). Bound
67
70
  * like `redact`.