e2e 0.21.1 → 0.21.2

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 (133) hide show
  1. package/README.md +2 -0
  2. package/dist/.build.json +1 -1
  3. package/dist/agent/act-validation.js +1 -0
  4. package/dist/agent/act.js +7 -3
  5. package/dist/agent/action-dispatcher.js +30 -8
  6. package/dist/agent/actions.d.ts +2 -0
  7. package/dist/agent/actions.js +5 -2
  8. package/dist/agent/error.js +1 -0
  9. package/dist/agent/executor.d.ts +14 -6
  10. package/dist/agent/index.js +5 -1
  11. package/dist/agent/invocation.d.ts +3 -0
  12. package/dist/agent/model/sdk.d.ts +5 -0
  13. package/dist/agent/model/sdk.js +18 -1
  14. package/dist/agent/observation.js +1 -0
  15. package/dist/agent/primitives.js +14 -3
  16. package/dist/agent/replay.js +9 -3
  17. package/dist/agent/step-cache.d.ts +3 -2
  18. package/dist/agent/step-cache.js +7 -4
  19. package/dist/agent/tool-loop.js +4 -1
  20. package/dist/cache/context.js +1 -1
  21. package/dist/cache/recorder.d.ts +2 -1
  22. package/dist/cache/recorder.js +11 -2
  23. package/dist/cache/trace-fields.d.ts +10 -0
  24. package/dist/cache/trace-fields.js +17 -0
  25. package/dist/cache/trace.d.ts +9 -1
  26. package/dist/cache/trace.js +26 -7
  27. package/dist/cli/feedback.js +2 -1
  28. package/dist/cli/init/sibling-versions.json +1 -1
  29. package/dist/collect/select.js +12 -2
  30. package/dist/config/agent.d.ts +1 -1
  31. package/dist/config/diagnose.js +9 -3
  32. package/dist/config/esm-hooks.js +3 -2
  33. package/dist/config/resolve.js +3 -2
  34. package/dist/config/typescript.js +52 -2
  35. package/dist/engine/contract.d.ts +3 -2
  36. package/dist/engine/manifest.js +2 -2
  37. package/dist/internal/errors.js +5 -2
  38. package/dist/internal/source.js +2 -0
  39. package/dist/internal/unserved-model.d.ts +5 -0
  40. package/dist/internal/unserved-model.js +22 -0
  41. package/dist/locator/screen.js +1 -2
  42. package/dist/oauth/copilot.js +23 -5
  43. package/dist/oauth/device-code.js +35 -19
  44. package/dist/oauth/errors.d.ts +3 -0
  45. package/dist/oauth/errors.js +26 -11
  46. package/dist/oauth/providers/github-copilot.d.ts +4 -0
  47. package/dist/oauth/providers/github-copilot.js +64 -16
  48. package/dist/oauth/providers/openai.d.ts +2 -2
  49. package/dist/oauth/providers/openai.js +47 -19
  50. package/dist/oauth/providers/opencode-console.js +36 -15
  51. package/dist/oauth/providers/xai.js +18 -9
  52. package/dist/oauth/token-endpoint.d.ts +15 -6
  53. package/dist/oauth/token-endpoint.js +18 -6
  54. package/dist/report/code-frame.js +9 -3
  55. package/dist/report/failure-text.d.ts +10 -2
  56. package/dist/report/failure-text.js +24 -8
  57. package/dist/report/list-model.d.ts +13 -0
  58. package/dist/report/list-steps.d.ts +6 -1
  59. package/dist/report/list-steps.js +29 -0
  60. package/dist/report/list.d.ts +1 -0
  61. package/dist/report/list.js +16 -7
  62. package/dist/report/live-window.js +2 -1
  63. package/dist/report/markdown.js +1 -1
  64. package/dist/run/fixtures.js +2 -0
  65. package/dist/run/host-trace.d.ts +3 -2
  66. package/dist/run/host-trace.js +2 -1
  67. package/dist/run/last-run.d.ts +9 -0
  68. package/dist/run/last-run.js +28 -0
  69. package/dist/run/open-session.d.ts +3 -1
  70. package/dist/run/open-session.js +1 -1
  71. package/dist/run/runner.js +6 -2
  72. package/dist/run/sessions.js +3 -3
  73. package/dist/run/standalone-attempt.d.ts +2 -1
  74. package/dist/run/standalone-attempt.js +3 -1
  75. package/dist/run/steps.d.ts +3 -1
  76. package/dist/run/units.d.ts +8 -0
  77. package/dist/run/units.js +14 -0
  78. package/dist/run/worker-models.d.ts +2 -0
  79. package/dist/run/worker-models.js +7 -0
  80. package/dist/types.d.ts +2 -2
  81. package/docs/authentication.mdx +26 -23
  82. package/docs/browser.mdx +7 -4
  83. package/docs/bug-bash.mdx +2 -2
  84. package/docs/cache.mdx +6 -1
  85. package/docs/ci/bitrise.mdx +1 -1
  86. package/docs/ci/codemagic.mdx +2 -2
  87. package/docs/ci/eas-workflows.mdx +1 -1
  88. package/docs/coding-agents.mdx +7 -35
  89. package/docs/debugging.mdx +24 -36
  90. package/docs/executors.mdx +1 -1
  91. package/docs/github.mdx +2 -1
  92. package/docs/integrations/eas.mdx +1 -1
  93. package/docs/integrations/index.mdx +6 -0
  94. package/docs/integrations/lightpanda.mdx +192 -0
  95. package/docs/integrations/testmu.mdx +152 -0
  96. package/docs/mcp.mdx +123 -0
  97. package/docs/migrate/detox.mdx +1 -1
  98. package/docs/migrate/maestro.mdx +101 -105
  99. package/docs/migrate/playwright.mdx +65 -70
  100. package/docs/mobile.mdx +18 -3
  101. package/docs/models.mdx +4 -2
  102. package/docs/quickstart.mdx +7 -0
  103. package/docs/reference/agent.mdx +369 -277
  104. package/docs/reference/app.mdx +141 -32
  105. package/docs/reference/cli.mdx +674 -558
  106. package/docs/reference/config.mdx +1406 -752
  107. package/docs/reference/engine.mdx +1062 -256
  108. package/docs/reference/environment.mdx +156 -84
  109. package/docs/reference/errors.mdx +106 -72
  110. package/docs/reference/expect.mdx +752 -372
  111. package/docs/reference/mcp.mdx +366 -141
  112. package/docs/reference/mobile.mdx +844 -403
  113. package/docs/reference/reporters.mdx +312 -106
  114. package/docs/reference/runner.mdx +373 -174
  115. package/docs/reference/screen.mdx +931 -398
  116. package/docs/reference/security.mdx +106 -40
  117. package/docs/reference/test.mdx +626 -273
  118. package/docs/reference/web.mdx +956 -533
  119. package/docs/security.mdx +6 -5
  120. package/docs/snippets/model-provider.jsx +763 -0
  121. package/docs/snippets/setup-prompt-card.jsx +22 -0
  122. package/docs/snippets/show-more.jsx +22 -0
  123. package/docs/snippets/video-card.jsx +34 -0
  124. package/docs/snippets/video.jsx +25 -0
  125. package/docs/subscriptions.mdx +10 -3
  126. package/docs/telemetry.mdx +39 -39
  127. package/package.json +3 -2
  128. package/schema/report-v1.schema.json +6 -0
  129. package/skills/e2e/references/agent.md +8 -3
  130. package/skills/e2e/references/debugging.md +1 -1
  131. package/skills/e2e/references/mcp.md +1 -1
  132. package/skills/e2e/references/setup.md +12 -3
  133. package/skills/e2e/references/writing-tests.md +1 -1
@@ -6,55 +6,130 @@ icon: "gear"
6
6
  "twitter:image": "https://e2e.tester.army/docs/images/og/reference/engine.png?v=d82cf186db"
7
7
  ---
8
8
 
9
- Import engine types and helpers from `e2e/engine`. `defineEngine` validates
10
- and freezes your implementation, derives its capabilities, and returns a
11
- handle for `targets` in the config.
9
+ `e2e/engine` is the contract an engine implements to drive a user interface,
10
+ such as a browser or simulator. An engine implements shared types for nodes,
11
+ locators, actions, and errors. `defineEngine` validates and freezes the
12
+ implementation, derives its capabilities, and returns a handle for `targets`
13
+ in the config. Use `test.extend` fixtures for resources outside the UI, such
14
+ as APIs or databases.
15
+
16
+ This sketch assumes a `connect` adapter for the platform:
17
+
18
+ ```ts title="src/index.ts"
19
+ import { defineEngine, resolveExpression, type EngineHandle } from 'e2e/engine';
20
+ import { connect } from './client.ts';
21
+
22
+ export function kiosk(options: { host: string }): EngineHandle {
23
+ const client = connect(options.host);
24
+ return defineEngine({
25
+ name: 'kiosk',
26
+ version: '1.0.0',
27
+ spiVersion: 1,
28
+ platform: 'kiosk',
29
+ async observe() {
30
+ const screen = await client.snapshot();
31
+ return {
32
+ root: { ref: { id: 'root', revision: '' }, role: 'window', children: screen.nodes },
33
+ viewport: screen.viewport,
34
+ };
35
+ },
36
+ async locate(expression) {
37
+ const screen = await client.snapshot();
38
+ return resolveExpression(expression, screen.nodes);
39
+ },
40
+ actions: ['tap'],
41
+ async perform(ref) {
42
+ await client.tap(ref.id);
43
+ },
44
+ });
45
+ }
46
+ ```
47
+
48
+ ```ts title="e2e.config.ts"
49
+ import type { E2EConfig } from 'e2e';
50
+ import { kiosk } from './src/index.ts';
51
+
52
+ export default {
53
+ targets: [{ name: 'kiosk', engine: kiosk({ host: 'kiosk.local' }) }],
54
+ } satisfies E2EConfig;
55
+ ```
56
+
57
+ ## Reference
58
+
59
+ ### defineEngine
60
+
61
+ Validates an engine spec, freezes it, computes its
62
+ [capabilities](#capabilities), and returns a branded handle.
63
+ `isEngineHandle` tells such a handle from any other value.
12
64
 
13
65
  ```ts
14
- import { defineEngine, type EngineHandle } from 'e2e/engine';
66
+ import { defineEngine, isEngineHandle, type EngineHandle } from 'e2e/engine';
15
67
 
16
68
  export function defineEngine(spec: Engine): EngineHandle;
17
69
  export function isEngineHandle(value: unknown): value is EngineHandle;
18
- ```
19
70
 
20
- An engine drives a user interface, such as a browser or simulator. It
21
- implements shared types for nodes, locators, actions, and errors. Use
22
- `test.extend` fixtures for resources outside the UI, such as APIs or databases.
23
-
24
- Validation fails with `INVALID_CONFIG` for:
25
-
26
- - a spec that is not an object, or a missing or empty `name`;
27
- - a missing or empty `version`, or a `spiVersion` other than `1`;
28
- - an empty `platform`, or a `workers` that is not a positive safe integer;
29
- - a hook (`observe`, `locate`, `perform`, `performAt`, `prepare`, `finish`,
30
- `init`, `startAttempt`, `settleAttempt`, `endAttempt`, `dispose`,
31
- `validateApp`) that is not a function;
32
- - an unknown top-level key on an object literal (a class instance's own
33
- fields are not checked);
34
- - `perform`, `locate`, `performAt`, or `keyboard` without `observe`;
35
- - `perform` without `actions` or the reverse; an `actions` list that is
36
- empty, repeats a kind, or names one outside `LOCATOR_ACTION_KINDS`;
37
- - `performAt` without `pointerActions` or the reverse; a `pointerActions`
38
- list that is empty, repeats a kind, or names one outside
39
- `POINTER_ACTION_KINDS`;
40
- - a `tapModifiers` that is not a boolean, or `true` without `perform`;
41
- - a `keyboard`, `state`, `artifacts`, or `session` manifest with an unknown
42
- key or a non-function member. `keyboard` needs `type` and `press`; `state`
43
- needs both `capture` and `restore`; `artifacts` needs `screenshot`;
44
- `startVideo` and `stopVideo` come as a pair (the removed `startTrace` and
45
- `stopTrace` an engine published before their removal declares are
46
- accepted and never called);
47
- - a `secrets` that is not an array of `secrets.get()` handles;
48
- - an `app` member: the app under test is declared on the target, and an
49
- engine checks what it needs in `validateApp`;
50
- - a `fixtures` that is not an object, a fixture that is not a factory
51
- function, or a fixture name that is not a lower-camel identifier or shadows
52
- `agent`, `app`, `screen`, `platform`, or `session`.
71
+ /** A validated engine: branded, frozen, capabilities computed. */
72
+ export interface EngineHandle extends Engine {
73
+ readonly capabilities: ReadonlySet<EngineCapability>;
74
+ }
75
+ ```
53
76
 
54
77
  Every function on the handle is bound to the spec it came from, so a class
55
78
  instance is as valid a body as an object literal.
56
79
 
57
- ## Engine
80
+ `defineEngine` throws `INVALID_CONFIG` when the spec breaks one of these
81
+ rules:
82
+
83
+ | Member | Rejected when |
84
+ | --- | --- |
85
+ | the spec | It is not an object. |
86
+ | `name` | Missing or empty. |
87
+ | `version` | Missing or empty. |
88
+ | `spiVersion` | Anything but `1`. |
89
+ | `platform` | Empty. |
90
+ | `workers` | Not a positive safe integer. |
91
+ | `observe`, `locate`, `perform`, `performAt`, `prepare`, `finish`, `init`, `startAttempt`, `settleAttempt`, `endAttempt`, `dispose`, `validateApp` | Not a function. |
92
+ | any other top-level key | On an object literal, `app` included: declare the app on the target and check it in `validateApp`. A class instance's own fields are not checked. |
93
+ | `perform`, `locate`, `performAt`, `keyboard` | Declared without `observe`. |
94
+ | `perform`, `actions` | One without the other. An `actions` list that is empty, repeats a kind, or names one outside `LOCATOR_ACTION_KINDS`. |
95
+ | `performAt`, `pointerActions` | One without the other. A `pointerActions` list that is empty, repeats a kind, or names one outside `POINTER_ACTION_KINDS`. |
96
+ | `tapModifiers` | Not a boolean, or `true` without `perform`. |
97
+ | `keyboard`, `state`, `artifacts`, `session` | An unknown key or a non-function member. `keyboard` without `type` and `press`; `state` without both `capture` and `restore`; `artifacts` without `screenshot`; `startVideo` without `stopVideo`, or the reverse. |
98
+ | `secrets` | Not an array of `secrets.get()` handles. |
99
+ | `fixtures` | Not an object, a fixture that is not a factory function, or a fixture name that is not a lower-camel identifier or shadows `agent`, `app`, `screen`, `platform`, or `session`. |
100
+
101
+ <Note>
102
+ `artifacts.startTrace` and `artifacts.stopTrace` were removed from the
103
+ contract. An engine published before their removal that declares them still
104
+ passes validation; the runner never calls them.
105
+ </Note>
106
+
107
+ ### Engine
108
+
109
+ The body of one target. Every member but `name`, `version`, and
110
+ `spiVersion` is optional; the members an engine implements decide its
111
+ [capabilities](#capabilities).
112
+
113
+ | Member | Type |
114
+ | --- | --- |
115
+ | `name` | `string` |
116
+ | `version` | `string` |
117
+ | `spiVersion` | `1` |
118
+ | `platform` | `string` |
119
+ | `workers` | `number` |
120
+ | [`observe`](#observation) | method |
121
+ | [`perform`, `actions`](#actions) | method, kinds |
122
+ | [`locate`](#capabilities) | method |
123
+ | [`performAt`, `pointerActions`](#pointer-actions) | method, kinds |
124
+ | [`tapModifiers`](#capabilities) | `boolean` |
125
+ | [`keyboard`](#keyboard) | `EngineKeyboard` |
126
+ | [`fixtures`](#contributed-fixtures) | factories |
127
+ | [`state`](#state) | `EngineStateCapability` |
128
+ | [`artifacts`](#artifacts) | `EngineArtifacts` |
129
+ | [`validateApp`](#app) | method |
130
+ | [`secrets`](#startattempt) | `Secret[]` |
131
+ | [`session`](#session) | `EngineSession` |
132
+ | [`prepare`, `finish`, `init`, `startAttempt`, `settleAttempt`, `endAttempt`, `dispose`](#lifecycle) | hooks |
58
133
 
59
134
  ```ts
60
135
  /** The body of one target: typed, model-free, capability-graded. */
@@ -223,6 +298,8 @@ export interface Engine {
223
298
  }
224
299
  ```
225
300
 
301
+ #### `OperationContext`
302
+
226
303
  ```ts
227
304
  /** What every engine call carries: budget, ids, and who is acting. */
228
305
  export interface OperationContext {
@@ -250,38 +327,106 @@ export interface OperationContext {
250
327
 
251
328
  ### Capabilities
252
329
 
330
+ The members an engine implements set its capabilities. A `screen`,
331
+ `Locator`, or `app` call that needs a missing one fails with
332
+ `UNSUPPORTED_CAPABILITY`. [Test APIs](#test-apis) lists what needs which
333
+ member. Three gaps end differently:
334
+
335
+ - A test that `requires` a fixture the engine lacks is skipped
336
+ (`capability-unavailable`).
337
+ - A selected test that uses a `test.setup` session, on an engine without
338
+ `state`, is a collection error before the setup runs.
339
+ - A `video` mode on an engine without video recording prints a notice and
340
+ records no video.
341
+
253
342
  | Member | Capability | Requires | Unlocks |
254
343
  | --- | --- | --- | --- |
255
- | `observe` | `observation` | - | judgment tier, prompt snapshots, trace replay, the viewport swipe's root |
256
- | `perform` + `actions` | `actions` | `observe` | the agent's grammar verbs (`tap`, `double_tap`, `long_press`, `right_click`, `hover`, `type`, `press`, `select`, `check`, `type_secret`, `scroll`, `scroll_to`, `drag`, `upload`, and `tap_at` or `hover_at` when the point lands on a listed node) and the `screen`/`Locator` action set with platform actionability checks, each only for a kind listed in `actions` |
257
- | `locate` | `location` | `observe` | `screen`, `Locator`, `expect(locator)` |
258
- | `tapModifiers: true` | - | `perform` | `modifiers` on `tap`, `doubleTap`, and `secondaryTap` (`row.click({ modifiers: ['Shift'] })`), held for the click; without it such a call is `UNSUPPORTED_CAPABILITY` |
259
- | `performAt` + `pointerActions` | `pointer` | `observe` | the agent's `tap_at` and `hover_at` verbs when the point lands on nothing the tree lists (the `tap` and `hover` pointer kinds), and the test's `screen.tapAt` and `tap({ position })` (the `tap` kind) and `screen.swipe({ from, to })` (the `swipeTo` kind): one pointer action at a viewport point in the CSS pixels of `SemanticNode.rect`, with no node behind it |
260
- | `keyboard.{type,press,dismiss?}` | `keyboard` | `observe` | the agent's `type` and `press` without a target, the keyboard fallback of `type_at` and `press_at` on a point the tree lists nothing at, and `dismiss_keyboard` when `dismiss` exists: input to whatever holds focus, for a field drawn on a canvas or flattened out of a platform's tree; with the `focus` action, `locator.pressSequentially`, which the harness composes as `focus` then `keyboard.type` |
261
- | `fixtures.<name>` | `<name>` | - | a contributed fixture; `requires: ['<name>']` gates at selection |
262
- | `state` | `state` | - | `test.setup` sessions and the `session:` option |
263
- | `artifacts` | `artifacts` | - | `app.screenshot()`, assertion evidence, the failure screenshot, and every [`video` mode](/reference/config#recording) but `off`, from the config, a target, a test, or `--video` |
264
- | `validateApp` | - | - | a config-load check of the target's `app` |
265
- | `session` | - | - | `app.open()` and the agent's `navigate` verb (`open`), `app.back()` and the agent's `back` verb (`back`), `app.restart()` (`restart`), `app.clearState()` (`reset`) |
266
-
267
- The agent offers the model only what `actions` declares: `tap` needs the
268
- `tap` kind, `double_tap`, `long_press`, and `right_click` need `doubleTap`,
269
- `longPress`, and `secondaryTap`; `hover` needs `hover`; `type` and `type_secret` need `fill`;
270
- `press` needs `press`; `select` needs `selectOption`; `check` needs both
271
- `check` and `uncheck`, since it sets a state rather than flipping one;
272
- `scroll` and `scroll_to({ text })` need `swipe`; `scroll_to({ target })` needs `scrollIntoView`; `drag` needs
273
- `dragTo`; `upload` needs `setInputFiles`; `navigate` needs `session.open`;
274
- `back` needs `session.back`; `tap_at` and `hover_at` need the `tap` or
275
- `hover` kind in `actions` or in `pointerActions`. `clear` and `focus` unlock
276
- no verb. An undeclared verb is absent from the toolset, and a
277
- `screen`/`Locator` method for an undeclared kind fails with
278
- `UNSUPPORTED_CAPABILITY` before reaching the engine.
279
-
280
- `engine.capabilities` is the computed `ReadonlySet` of capability names, and
281
- is what selection, the report, and `UNSUPPORTED_CAPABILITY` messages read.
344
+ | `observe` | `observation` | - | The judgment tier, prompt snapshots, trace replay, the viewport swipe's root. |
345
+ | `perform` + `actions` | `actions` | `observe` | Node actions of the declared kinds, with platform actionability checks. |
346
+ | `locate` | `location` | `observe` | `screen`, `Locator`, `expect(locator)`. |
347
+ | `tapModifiers: true` | - | `perform` | `modifiers` on `tap`, `doubleTap`, and `secondaryTap`. |
348
+ | `performAt` + `pointerActions` | `pointer` | `observe` | One pointer action at a viewport point, with no node behind it. |
349
+ | `keyboard.{type,press,dismiss?}` | `keyboard` | `observe` | Input to whatever holds focus. |
350
+ | `fixtures.<name>` | `<name>` | - | A contributed fixture. |
351
+ | `state` | `state` | - | `test.setup` sessions and the `session:` option. |
352
+ | `artifacts` | `artifacts` | - | Screenshots and videos. |
353
+ | `validateApp` | - | - | A config-load check of the target's `app`. |
354
+ | `session` | - | - | The `app` steering methods and the agent's `navigate` and `back` verbs. |
355
+
356
+ #### Agent verbs
357
+
358
+ The agent offers the model only the verbs the engine's declarations unlock.
359
+ An undeclared verb is absent from the toolset.
360
+
361
+ | Verb | Needs |
362
+ | --- | --- |
363
+ | `tap` | the `tap` kind in `actions` |
364
+ | `double_tap` | `doubleTap` |
365
+ | `long_press` | `longPress` |
366
+ | `right_click` | `secondaryTap` |
367
+ | `hover` | `hover` |
368
+ | `type` | `fill`; without a target, `keyboard` |
369
+ | `type_secret` | `fill` |
370
+ | `press` | `press`; without a target, `keyboard` |
371
+ | `select` | `selectOption` |
372
+ | `check` | both `check` and `uncheck`, since it sets a state rather than flipping one |
373
+ | `scroll`, `scroll_to({ text })` | `swipe` |
374
+ | `scroll_to({ target })` | `scrollIntoView` |
375
+ | `drag` | `dragTo` |
376
+ | `upload` | `setInputFiles` |
377
+ | `navigate` | `session.open` |
378
+ | `back` | `session.back` |
379
+ | `tap_at`, `hover_at` | the `tap` or `hover` kind, in `actions` or in `pointerActions` |
380
+ | `type_at` | `fill` or `keyboard` |
381
+ | `press_at` | `press` or `keyboard` |
382
+ | `select_at` | `selectOption` |
383
+ | `dismiss_keyboard` | `keyboard.dismiss` |
384
+
385
+ - `clear` and `focus` unlock no verb.
386
+ - `tap_at`, `hover_at`, `type_at`, `press_at`, and `select_at` go through
387
+ `perform` when the point lands on a node the tree lists.
388
+ - When the point lands on nothing the tree lists, `tap_at` and `hover_at`
389
+ go through `performAt` (the `tap` and `hover` pointer kinds), and
390
+ `type_at` and `press_at` fall back to `keyboard`.
391
+
392
+ #### Test APIs
393
+
394
+ The test-facing APIs each engine member backs. A `screen` or `Locator`
395
+ method for an undeclared kind, or a call with `modifiers` and no
396
+ `tapModifiers`, fails with `UNSUPPORTED_CAPABILITY` before it reaches the
397
+ engine.
398
+
399
+ | API | Needs |
400
+ | --- | --- |
401
+ | `screen`, `Locator`, `expect(locator)` | `locate` |
402
+ | `screen` and `Locator` actions | `perform`, each only for a kind listed in `actions` |
403
+ | `screen.swipe()` | `perform` with `swipe` on the observation root |
404
+ | `modifiers` on a tap (`row.click({ modifiers: ['Shift'] })`) | `tapModifiers: true`; the keys are held for the click |
405
+ | `screen.tapAt`, `tap({ position })` | `performAt` with the `tap` pointer kind |
406
+ | `screen.swipe({ from, to })` | `performAt` with the `swipeTo` pointer kind |
407
+ | `locator.pressSequentially` | the `focus` action and `keyboard`; the harness composes `focus` then `keyboard.type` |
408
+ | `test.setup` sessions, the `session:` option | `state` |
409
+ | `app.screenshot()`, assertion evidence, the failure screenshot | `artifacts` |
410
+ | every [`video` mode](/reference/config#recording) but `off`, from the config, a target, a test, or `--video` | `artifacts` |
411
+ | `app.open()` | `session.open`; on a target with no app URL, `session.restart` |
412
+ | `app.back()` | `session.back` |
413
+ | `app.restart()` | `session.restart` |
414
+ | `app.clearState()` | `session.reset` |
415
+ | `requires: ['<name>']` | `fixtures.<name>` |
416
+
417
+ #### `engine.capabilities`
418
+
419
+ The computed `ReadonlySet` of capability names. Selection, the report, and
420
+ `UNSUPPORTED_CAPABILITY` messages read it.
421
+
422
+ ```ts
423
+ const engine = kiosk({ host: 'kiosk.local' });
424
+ engine.capabilities.has('location'); // true: the engine implements locate
425
+ ```
426
+
282
427
  Selection also grades a target by the kind of app it declares, not by its
283
- engine: `browser` for a target with `app.url`, `native-app` for one with
284
- `app.bundleId` or `app.appPath`, so a test can `requires: ['native-app']`.
428
+ engine. A target with `app.url` has `browser`, and one with `app.bundleId` or
429
+ `app.appPath` has `native-app`, so a test can `requires: ['native-app']`.
285
430
 
286
431
  ```ts
287
432
  /**
@@ -301,7 +446,11 @@ export type EngineCapability =
301
446
  | 'state'
302
447
  | 'artifacts'
303
448
  | (string & {});
449
+ ```
304
450
 
451
+ #### Keyboard
452
+
453
+ ```ts
305
454
  /**
306
455
  * capability: keyboard - requires observation. Input to whatever holds focus,
307
456
  * with no node behind it: the agent's `type` and `press` without a target, and
@@ -336,17 +485,47 @@ export interface EngineKeyboard {
336
485
  }
337
486
  ```
338
487
 
339
- ## Lifecycle
488
+ ### Lifecycle
489
+
490
+ The hooks the runner calls around workers and attempts. Every hook is
491
+ optional. The `Engine` declaration's JSDoc above states each hook's contract.
340
492
 
341
- | Hook | Budget |
493
+ `init` and `startAttempt` run within `launchTimeout`. `finish`,
494
+ `settleAttempt`, `endAttempt`, and `dispose` run within `cleanupTimeout`.
495
+ `prepare` has no timeout. Both budgets come from the
496
+ [config](/reference/config).
497
+
498
+ | Hook | Runs |
342
499
  | --- | --- |
343
- | `prepare(info)` | none; only `info.signal` (the run's interrupt) cuts it short. Failure ends the run before any test: a runner error class (a `ConfigurationError` for an option the engine cannot honour) keeps its code, an `EngineError` `CANCELLED` stays a cancellation, and anything else, any other `EngineError` or a plain `Error`, is infrastructure `ENGINE_FAILURE` |
344
- | `finish(info)` | `cleanupTimeout`, shared by every prepared target, never cut short by an interrupt; failure is a cleanup run error |
345
- | `init(info)` | `launchTimeout`; failure is infrastructure |
346
- | `startAttempt(context)` | `launchTimeout`; on failure or timeout `context.signal` aborts and `endAttempt` runs |
347
- | `settleAttempt(context)` | `cleanupTimeout`, after the body and again after teardown; a thrown error is the attempt's failure and keeps its code |
348
- | `endAttempt(context)` | `cleanupTimeout`; failure marks cleanup failed, the test keeps its status |
349
- | `dispose(context)` | `cleanupTimeout`; failure is a run error |
500
+ | [`prepare(info)`](#prepare) | Once per target, before any worker |
501
+ | [`finish(info)`](#finish) | After the target's last worker is gone |
502
+ | [`init(info)`](#init) | Once per worker, before its first step |
503
+ | [`startAttempt(context)`](#startattempt) | Before each attempt |
504
+ | [`settleAttempt(context)`](#settleattempt) | After the body and after teardown |
505
+ | [`endAttempt(context)`](#endattempt) | After each attempt |
506
+ | [`dispose(context)`](#dispose) | Worker shutdown |
507
+
508
+ #### `prepare`
509
+
510
+ Runs once per target in the runner process, after collection and before
511
+ workers, app commands, and the run's clock start.
512
+
513
+ ```ts
514
+ async prepare(info) {
515
+ if (info.slots === 0) return; // nothing runs on this target
516
+ info.log('booting devices');
517
+ const devices = await pool.boot(info.slots, { signal: info.signal });
518
+ return { workers: devices.length, env: { KIOSK_DEVICES: devices.join(',') } };
519
+ },
520
+ ```
521
+
522
+ A failure ends the run before any test:
523
+
524
+ | Thrown | Becomes |
525
+ | --- | --- |
526
+ | A runner error class, such as a `ConfigurationError` for an option the engine cannot honour | The same code. |
527
+ | An `EngineError` with code `CANCELLED` | A cancellation. |
528
+ | Any other `EngineError`, or a plain `Error` | Infrastructure `ENGINE_FAILURE`. |
350
529
 
351
530
  ```ts
352
531
  /**
@@ -402,6 +581,8 @@ export interface EnginePrepareInfo {
402
581
  * fail every test behind it.
403
582
  */
404
583
  readonly signal: AbortSignal;
584
+ /** Whether the run asked for a visible surface (`--headed`), as `EngineInitInfo` declares it. */
585
+ readonly headed: boolean;
405
586
  /**
406
587
  * Reports one line of progress. The runner streams it as a `notice` run
407
588
  * event, so it reaches the reporter and every host sink instead of being
@@ -409,7 +590,22 @@ export interface EnginePrepareInfo {
409
590
  */
410
591
  readonly log: (line: string) => void;
411
592
  }
593
+ ```
412
594
 
595
+ #### `finish`
596
+
597
+ Runs once per run and target in the runner process, after every worker of
598
+ the target is gone. Release what `prepare` acquired for the run.
599
+
600
+ ```ts
601
+ async finish(info) {
602
+ await pool.release({ signal: info.signal });
603
+ },
604
+ ```
605
+
606
+ The `finish` hooks of all prepared targets share one `cleanupTimeout`.
607
+
608
+ ```ts
413
609
  /** Handed to `finish`, once per run and target after the last worker is gone, with the cleanup budget of one hook. */
414
610
  export interface EngineFinishInfo extends EngineCleanupContext {
415
611
  /** The run id. */
@@ -421,7 +617,24 @@ export interface EngineFinishInfo extends EngineCleanupContext {
421
617
  /** Reports one line of progress, streamed as a `notice` run event like `prepare`'s. */
422
618
  readonly log: (line: string) => void;
423
619
  }
620
+ ```
621
+
622
+ #### `init`
623
+
624
+ Runs once per worker, before the first step. Boot devices here, not in a step
625
+ budget.
626
+
627
+ ```ts
628
+ async init(info) {
629
+ const device = info.env.KIOSK_DEVICES?.split(',')[info.workerSlot];
630
+ info.log(`slot ${info.workerSlot}: ${device}`);
631
+ },
632
+ ```
633
+
634
+ `init` never learns the app URL; URLs arrive through `session.open` only. A
635
+ failure is an infrastructure error.
424
636
 
637
+ ```ts
425
638
  /** Run identity and harness-resolved facts handed to `init`, once per worker before the first step. */
426
639
  export interface EngineInitInfo {
427
640
  /** The run id. */
@@ -469,31 +682,30 @@ export interface EngineInitInfo {
469
682
  */
470
683
  readonly log: (line: string) => void;
471
684
  }
685
+ ```
472
686
 
473
- /**
474
- * The app the harness resolved from the target's declaration, handed to
475
- * `prepare` and `init`: the site policy for the decisions only a surface can
476
- * make (which nested documents enter an observation, which requests carry
477
- * injected credentials), and what a device launches.
478
- */
479
- export interface EngineAppInfo {
480
- /**
481
- * The app URL's registrable domain, as read by `siteOf`. Use `sameSite`
482
- * to scope secret fills, injected headers, and observed child frames.
483
- * Absent when the app has no URL. The runner passes navigation URLs
484
- * separately through `session.open`.
485
- */
486
- readonly site?: string;
487
- /** The installed app to launch, as the target declared it. */
488
- readonly bundleId?: string;
489
- /** The build to install, as the target declared it; relative to `projectRoot`. */
490
- readonly appPath?: string;
491
- /** Arguments every fresh launch passes the app. */
492
- readonly launchArguments?: readonly string[];
493
- /** Permissions the app holds on every fresh launch, by name. */
494
- readonly permissions?: Readonly<Record<string, AppPermissionState>>;
495
- }
687
+ #### `startAttempt`
496
688
 
689
+ Runs before each attempt, to set up the per-test isolation that
690
+ [`endAttempt`](#endattempt) tears down.
691
+
692
+ ```ts
693
+ async startAttempt(context) {
694
+ const password = await context.resolveSecret(options.password);
695
+ await client.openContext({ password, signal: context.signal });
696
+ context.environment({ device: 'Kiosk 2', os: 'KioskOS 4.1' });
697
+ },
698
+ ```
699
+
700
+ On failure or timeout, `context.signal` aborts and `endAttempt` runs.
701
+
702
+ <Warning>
703
+ A value from `resolveSecret` is protected as text only. Reports, logs, and
704
+ observations redact it and the attempt's text downloads are rewritten, but
705
+ unlike a fill it withholds no screenshot or model pixels.
706
+ </Warning>
707
+
708
+ ```ts
497
709
  /**
498
710
  * Per-attempt isolation context. An engine that must give each test a fresh
499
711
  * surface (a fresh isolated context per test, a reset device) sets it up in
@@ -575,7 +787,44 @@ export interface ResolveSecretOptions {
575
787
  */
576
788
  readonly derived?: (plaintext: string) => readonly string[];
577
789
  }
790
+ ```
578
791
 
792
+ #### `settleAttempt`
793
+
794
+ Runs after the body has settled, and again after teardown, before the
795
+ verdict. It reports a failure on a path no step awaited, such as a request
796
+ interceptor or a native dialog handler.
797
+
798
+ ```ts
799
+ async settleAttempt() {
800
+ const running = [...dialogHandlers];
801
+ dialogHandlers.clear();
802
+ await Promise.all(running); // rethrows a handler's failure
803
+ },
804
+ ```
805
+
806
+ #### `endAttempt`
807
+
808
+ Runs after each attempt and tears down what `startAttempt` set up. A failure
809
+ marks cleanup failed; the test keeps its status.
810
+
811
+ ```ts
812
+ async endAttempt(context) {
813
+ await client.closeContext({ signal: context.signal });
814
+ },
815
+ ```
816
+
817
+ #### `dispose`
818
+
819
+ Shuts the worker down.
820
+
821
+ ```ts
822
+ async dispose(context) {
823
+ await client.shutdown({ signal: context.signal });
824
+ },
825
+ ```
826
+
827
+ ```ts
579
828
  /**
580
829
  * Budget of one cleanup hook (`settleAttempt`, `endAttempt`, `dispose`). `signal` aborts when
581
830
  * the budget is exhausted, so a hook that cannot finish in time stops instead
@@ -589,9 +838,25 @@ export interface EngineCleanupContext {
589
838
  }
590
839
  ```
591
840
 
592
- URLs arrive through `session.open` only; `init` never learns the app URL.
841
+ ### Observation
842
+
843
+ `observe` returns one fresh semantic snapshot of the surface under test.
593
844
 
594
- ## Observation
845
+ | Field | Type |
846
+ | --- | --- |
847
+ | `location` | `string` |
848
+ | `root` | `SemanticNode` |
849
+ | `viewport` | `ViewportSize` |
850
+ | [`truncated`](#truncated-trees) | `boolean` |
851
+ | `keyboardVisible` | `boolean` |
852
+ | [`treeUnavailable`](#pixel-fallback) | `true` |
853
+ | `pixels` | `ObservationPixels` |
854
+ | `maskedRegionCount` | `number` |
855
+
856
+ Return the surface as it is right now. The runner mints the revision and
857
+ stamps it onto every ref. When `pixels` are present, it checks that
858
+ `maskedRegionCount` covers every `secure` node. A valid empty tree may have a
859
+ childless root and include `pixels`.
595
860
 
596
861
  ```ts
597
862
  /**
@@ -653,39 +918,92 @@ export interface EngineObserveOptions {
653
918
  }
654
919
  ```
655
920
 
656
- Return the surface as it is right now. The runner mints the revision, stamps
657
- it onto every ref, redacts registered secret values, bounds the byte size, and
658
- checks that `maskedRegionCount` covers every `secure` node when `pixels` are
659
- present.
921
+ #### Truncated trees
922
+
923
+ Set `truncated` when a node limit or an inaccessible subtree leaves content
924
+ out. The `truncated` JSDoc above says how the runner treats it.
925
+
926
+ #### Pixel fallback
660
927
 
661
- Set `truncated` when a node limit or inaccessible subtree leaves content out.
662
- The runner warns the model that the tree is incomplete and never treats a
663
- truncated observation as unchanged. Omit it for a complete tree.
928
+ When semantic capture times out, an engine may return masked pixels in place
929
+ of the tree, but only when `pixelFallback` is true and it can prove
930
+ screenshot masking without that capture.
664
931
 
665
- A valid empty tree may have a childless root and include `pixels`.
932
+ ```ts
933
+ return {
934
+ treeUnavailable: true,
935
+ root: { ref: rootRef },
936
+ viewport,
937
+ pixels,
938
+ };
939
+ ```
666
940
 
667
- If semantic capture times out, an engine may recover only when `pixelFallback`
668
- is true and it can prove screenshot masking without that capture. Return
669
- `treeUnavailable: true`, fresh `pixels`, and a `root` containing only its
670
- stable `ref`. That reference supports viewport actions; it supplies no
671
- semantic node evidence. Retire earlier node references. The flag distinguishes
672
- missing semantic evidence from a valid empty tree; never infer absence from
673
- it. The runner validates this shape at the engine boundary and rejects a
674
- fallback without permitted pixels as `UNSUPPORTED_CAPABILITY`, or one with
675
- semantic root fields as `INVALID_STATE`. `EngineSnapshot` remains an
676
- extendable interface; these dependent requirements are checked at runtime.
941
+ - Return `treeUnavailable: true`, fresh `pixels`, and a `root` containing
942
+ only its stable `ref`. That ref supports viewport actions; it supplies no
943
+ semantic node evidence. Retire earlier node refs.
944
+ - The flag tells missing semantic evidence from a valid empty tree. Never
945
+ infer absence from it.
946
+ - Without permission, pixels, or independent masking proof, propagate the
947
+ capture failure. Cancellation, policy denial, and hard engine failures
948
+ propagate too.
949
+ - The runner permits fallback for `act` and for judgments with
950
+ `vision: true` or `'only'`, before any secret fill. It includes the
951
+ screenshot even if the initial observation requested only a tree.
952
+ - A step that loses semantic evidence cannot record or finish by replaying a
953
+ trace.
954
+ - Engines whose masks depend on their semantic capture cannot provide this
955
+ fallback.
956
+
957
+ `EngineSnapshot` is an extendable interface, so TypeScript can't enforce
958
+ these rules. The runner checks the shape at the engine boundary, at runtime:
959
+
960
+ | Throws | When |
961
+ | --- | --- |
962
+ | `UNSUPPORTED_CAPABILITY` | A fallback without permitted pixels. |
963
+ | `INVALID_STATE` | A fallback whose root carries semantic fields. |
677
964
 
678
- Without permission, pixels, or independent masking proof, propagate the
679
- capture failure. Cancellation, policy denial, and hard engine failures must
680
- also propagate.
965
+ ### Actions
681
966
 
682
- The runner permits fallback for `act` and for judgments with `vision: true`
683
- or `'only'`, before any secret fill. It includes the screenshot even if the
684
- initial observation requested only a tree. A step that loses semantic
685
- evidence cannot record or finish by replaying a trace. Engines whose masks
686
- depend on their semantic capture cannot provide this fallback.
967
+ `perform` carries out one `LocatorAction` on a node; `performAt` carries out
968
+ one `PointerAction` at a viewport point. An engine declares the kinds it
969
+ honors in `actions` and `pointerActions`.
687
970
 
688
- ## Actions
971
+ | Kind | Fields | `actions` | `pointerActions` |
972
+ | --- | --- | --- | --- |
973
+ | `tap` | `modifiers?` on a node | yes | yes |
974
+ | `doubleTap` | `modifiers?` on a node | yes | yes |
975
+ | `secondaryTap` | `modifiers?` on a node | yes | yes |
976
+ | `longPress` | `durationMs?` | yes | yes |
977
+ | `fill` | `value`, `sensitive` | yes | - |
978
+ | `clear` | - | yes | - |
979
+ | `press` | `key` | yes | - |
980
+ | `check` | - | yes | - |
981
+ | `uncheck` | - | yes | - |
982
+ | `focus` | - | yes | - |
983
+ | `hover` | - | yes | yes |
984
+ | `scrollIntoView` | - | yes | - |
985
+ | `selectOption` | `value` | yes | - |
986
+ | `setInputFiles` | `paths` | yes | - |
987
+ | `dragTo` | `target`: a `NodeRef` or a `ViewportPoint` | yes | yes |
988
+ | `swipe` | `direction`, `momentum?` | yes | yes |
989
+ | `swipeTo` | `target`, `durationMs?` | - | yes |
990
+
991
+ The grammar is closed. Anything beyond it is an agent-side `defineTool`.
992
+
993
+ #### Locator actions
994
+
995
+ ```ts
996
+ async perform(ref, action, context) {
997
+ switch (action.kind) {
998
+ case 'tap':
999
+ return client.tap(ref.id, { signal: context.signal });
1000
+ case 'swipe':
1001
+ return client.scroll(ref.id === rootId ? undefined : ref.id, action.direction);
1002
+ default:
1003
+ throw new EngineError('UNSUPPORTED_CAPABILITY', `kiosk cannot ${action.kind}`, { retryable: false });
1004
+ }
1005
+ },
1006
+ ```
689
1007
 
690
1008
  ```ts
691
1009
  /**
@@ -747,16 +1065,16 @@ export const LOCATOR_ACTION_KINDS = [
747
1065
  ] as const satisfies readonly LocatorActionKind[];
748
1066
  ```
749
1067
 
750
- The grammar is closed. Anything beyond it is an agent-side `defineTool`.
751
-
752
- ### Pointer actions
1068
+ #### Pointer actions
753
1069
 
754
1070
  Pointer actions use viewport coordinates when no semantic node is available.
755
- `dragTo` ends at another point; `swipe` starts a scroll gesture at the given
756
- point. `swipeTo` is the one kind with no node form: the finger's path from
757
- the point to `target`, a touch swipe on a device and a pointer drag on a
758
- document platform, distinct from `dragTo` where a platform holds before it
759
- drags. Declare supported kinds in `pointerActions`.
1071
+
1072
+ - `dragTo` ends at another point.
1073
+ - `swipe` starts a scroll gesture at the given point.
1074
+ - `swipeTo` is the one kind with no node form: the finger's path from the
1075
+ point to `target`, a touch swipe on a device and a pointer drag on a
1076
+ document platform. It differs from `dragTo`, where a platform holds before
1077
+ it drags.
760
1078
 
761
1079
  ```ts
762
1080
  export type PointerAction =
@@ -790,7 +1108,15 @@ export const POINTER_ACTION_KINDS = [
790
1108
  ] as const satisfies readonly PointerActionKind[];
791
1109
  ```
792
1110
 
793
- ### Keys
1111
+ #### Keys
1112
+
1113
+ The key grammar of `press`, shared by every engine, and its parser.
1114
+
1115
+ ```ts
1116
+ parseKey('Control+a'); // { modifiers: ['Control'], key: { kind: 'char', char: 'a' } }
1117
+ parseKey('Shift+Tab'); // { modifiers: ['Shift'], key: { kind: 'named', name: 'Tab' } }
1118
+ parseKey('Ctrl+a'); // undefined: Ctrl is not a modifier of the grammar
1119
+ ```
794
1120
 
795
1121
  ```ts
796
1122
  /**
@@ -853,14 +1179,42 @@ export interface ParsedKey {
853
1179
  export function parseKey(key: string): ParsedKey | undefined;
854
1180
  ```
855
1181
 
856
- ## App
1182
+ ### App
857
1183
 
858
1184
  The app under test is declared on the target (`targets: [{ engine, app }]`,
859
1185
  see [the app under test](/reference/config#the-app-under-test)); an engine
860
- only drives it. The harness checks the shape of every field, then hands the
861
- declaration to `validateApp` at config load, so an engine can require what
862
- its platform needs and refuse what it cannot drive. `@e2e-dev/web` requires
863
- `url`; `@e2e-dev/mobile` requires `bundleId` or `appPath` and refuses `url`.
1186
+ only drives it. The harness checks the shape of every field. Then it hands
1187
+ the declaration to [`validateApp`](#validateapp) at config load.
1188
+
1189
+ | Target field | Reaches the engine as |
1190
+ | --- | --- |
1191
+ | `url` | `validateApp` only. The harness opens it through `session.open`. |
1192
+ | the site of `url` | `info.app.site` in `prepare` and `init` |
1193
+ | `bundleId`, `appPath`, `launchArguments`, `permissions` | `validateApp`, and `info.app` in `prepare` and `init`, as the target declared them |
1194
+ | `command`, `readyUrl` | Never. The runner starts the app. |
1195
+
1196
+ `CommandConfig` is exported from `e2e`, not `e2e/engine`.
1197
+
1198
+ #### `validateApp`
1199
+
1200
+ Checks the declared app at config load, synchronously.
1201
+
1202
+ ```ts
1203
+ validateApp(app, { targetName }) {
1204
+ if (app.url === undefined) {
1205
+ throw new ConfigurationError('INVALID_CONFIG', `target "${targetName}" needs app.url`);
1206
+ }
1207
+ },
1208
+ ```
1209
+
1210
+ - It runs for every target the engine serves, with `{}` for a target that
1211
+ declares no `app`.
1212
+ - `@e2e-dev/web` requires `url`; `@e2e-dev/mobile` requires `bundleId` or
1213
+ `appPath` and refuses `url`.
1214
+
1215
+ | Throws | When |
1216
+ | --- | --- |
1217
+ | `INVALID_CONFIG` | A field the platform needs is missing, or the engine cannot drive one. Name `info.targetName` in the message. |
864
1218
 
865
1219
  ```ts
866
1220
  /**
@@ -883,24 +1237,60 @@ export interface EngineAppCheckInfo {
883
1237
  export type AppPermissionState = 'grant' | 'deny' | 'reset';
884
1238
  ```
885
1239
 
1240
+ #### `EngineAppInfo`
1241
+
1242
+ The resolved app, as `EnginePrepareInfo.app` and `EngineInitInfo.app`: the
1243
+ site of the URL and the device fields as the target declared them. Apply the
1244
+ site to a URL with [`sameSite`](#site-helpers).
1245
+
886
1246
  ```ts
887
- validateApp(app, { targetName }) {
888
- if (app.url === undefined) {
889
- throw new ConfigurationError('INVALID_CONFIG', `target "${targetName}" needs app.url`);
890
- }
891
- },
1247
+ if (sameSite(request.url, info.app.site ?? '')) request.headers.set('x-api-key', key);
1248
+ ```
1249
+
1250
+ ```ts
1251
+ /**
1252
+ * The app the harness resolved from the target's declaration, handed to
1253
+ * `prepare` and `init`: the site policy for the decisions only a surface can
1254
+ * make (which nested documents enter an observation, which requests carry
1255
+ * injected credentials), and what a device launches.
1256
+ */
1257
+ export interface EngineAppInfo {
1258
+ /**
1259
+ * The app URL's registrable domain, as read by `siteOf`. Use `sameSite`
1260
+ * to scope secret fills, injected headers, and observed child frames.
1261
+ * Absent when the app has no URL. The runner passes navigation URLs
1262
+ * separately through `session.open`.
1263
+ */
1264
+ readonly site?: string;
1265
+ /** The installed app to launch, as the target declared it. */
1266
+ readonly bundleId?: string;
1267
+ /** The build to install, as the target declared it; relative to `projectRoot`. */
1268
+ readonly appPath?: string;
1269
+ /** Arguments every fresh launch passes the app. */
1270
+ readonly launchArguments?: readonly string[];
1271
+ /** Permissions the app holds on every fresh launch, by name. */
1272
+ readonly permissions?: Readonly<Record<string, AppPermissionState>>;
1273
+ }
892
1274
  ```
893
1275
 
894
- `validateApp` runs for every target the engine serves, with `{}` for a
895
- target that declares no `app`. The resolved app comes back to the engine as
896
- `EnginePrepareInfo.app` and `EngineInitInfo.app`: the site of the URL, which
897
- `sameSite` from `e2e/engine` applies to a URL, and the device fields as the
898
- target declared them. The URL itself stays with the harness, which opens it
899
- through `session.open`. The target's `command` and `readyUrl` never reach
900
- the engine: the runner starts it. `CommandConfig` is exported from `e2e`, not
901
- `e2e/engine`.
1276
+ ### Session
1277
+
1278
+ Steering hooks behind the universal `app` fixture and the agent's `navigate`
1279
+ and `back` verbs.
902
1280
 
903
- ## Session
1281
+ | Hook | Behind |
1282
+ | --- | --- |
1283
+ | `open(url)` | `app.open()`, the agent's `navigate` verb, the reopen after `restart` and `reset` |
1284
+ | `back()` | `app.back()`, the agent's `back` verb |
1285
+ | `restart()` | `app.restart()` |
1286
+ | `reset()` | `app.clearState()` |
1287
+
1288
+ ```ts
1289
+ session: {
1290
+ async back(context) { await client.back({ signal: context.signal }); },
1291
+ async restart(context) { await client.relaunch(bundleId, { signal: context.signal }); },
1292
+ },
1293
+ ```
904
1294
 
905
1295
  ```ts
906
1296
  /**
@@ -931,12 +1321,27 @@ export interface EngineSession {
931
1321
  }
932
1322
  ```
933
1323
 
934
- `open` is behind `app.open()`, the agent's `navigate` verb, and the reopen
935
- after `restart` and `reset`; `back`, `restart`, and `reset` are behind
936
- `app.back()`, `app.restart()`, and `app.clearState()`. An absent hook makes
937
- its method fail with `UNSUPPORTED_CAPABILITY`.
1324
+ ### Errors
938
1325
 
939
- ## Errors
1326
+ An engine throws `EngineError` with one code of a closed set. The runner maps
1327
+ each code to the error a test sees.
1328
+
1329
+ | Code | Runner mapping | Retryable |
1330
+ | --- | --- | --- |
1331
+ | `NODE_STALE` | re-resolve, then `LOCATOR_NOT_FOUND` | yes |
1332
+ | `FRAME_NOT_FOUND` | re-resolve, then `LOCATOR_NOT_FOUND` | yes |
1333
+ | `FRAME_AMBIGUOUS` | `LOCATOR_AMBIGUOUS` | no |
1334
+ | `NOT_ACTIONABLE`, `ACTION_MAY_HAVE_COMMITTED`, `OPERATION_TIMEOUT` | `ACTION_FAILED` | no |
1335
+ | `CANCELLED` | infrastructure `CANCELLED` | no |
1336
+ | `UNSUPPORTED_CAPABILITY` | configuration `UNSUPPORTED_CAPABILITY` | no |
1337
+ | `INVALID_STATE` | `APP_NOT_OPEN` | no |
1338
+ | `ENGINE_FAILURE`, any non-`EngineError` | infrastructure `ENGINE_FAILURE` | no |
1339
+
1340
+ #### `EngineError`
1341
+
1342
+ ```ts
1343
+ throw new EngineError('NODE_STALE', `node ${ref.id} is gone`, { retryable: true });
1344
+ ```
940
1345
 
941
1346
  ```ts
942
1347
  export class EngineError extends Error {
@@ -975,28 +1380,33 @@ export const RETRYABLE_ENGINE_ERROR_CODES: ReadonlySet<EngineErrorCode> = new Se
975
1380
  ]);
976
1381
  ```
977
1382
 
978
- | Code | Runner mapping | Retryable |
979
- | --- | --- | --- |
980
- | `NODE_STALE` | re-resolve, then `LOCATOR_NOT_FOUND` | yes |
981
- | `FRAME_NOT_FOUND` | re-resolve, then `LOCATOR_NOT_FOUND` | yes |
982
- | `FRAME_AMBIGUOUS` | `LOCATOR_AMBIGUOUS` | no |
983
- | `NOT_ACTIONABLE`, `ACTION_MAY_HAVE_COMMITTED`, `OPERATION_TIMEOUT` | `ACTION_FAILED` | no |
984
- | `CANCELLED` | infrastructure `CANCELLED` | no |
985
- | `UNSUPPORTED_CAPABILITY` | configuration `UNSUPPORTED_CAPABILITY` | no |
986
- | `INVALID_STATE` | `APP_NOT_OPEN` | no |
987
- | `ENGINE_FAILURE`, any non-`EngineError` | infrastructure `ENGINE_FAILURE` | no |
1383
+ #### Timeouts at a deadline
988
1384
 
989
1385
  An `OPERATION_TIMEOUT` on a locate or read that started with under 100 ms of
990
1386
  a waiting call's deadline left, after one that completed, ends the wait
991
- instead: an action's or `waitFor`'s `LOCATOR_NOT_FOUND`, or an assertion's
992
- `ASSERTION_FAILED`, with the timeout as its cause. A negated assertion passes
993
- there if its negation had held long enough by the time the read was cut off, as
994
- `pollCondition` below describes.
1387
+ instead:
1388
+
1389
+ | Waiting call | Fails with |
1390
+ | --- | --- |
1391
+ | an action, `waitFor` | `LOCATOR_NOT_FOUND`, with the timeout as its cause |
1392
+ | an assertion | `ASSERTION_FAILED`, with the timeout as its cause |
1393
+
1394
+ A negated assertion passes there if its negation had held long enough by the
1395
+ time the read was cut off. [`pollCondition`](#pollcondition) polls by these
1396
+ rules.
1397
+
1398
+ #### Runner error classes
995
1399
 
996
1400
  An `EngineError` a contributed fixture method throws is mapped by the table
997
1401
  above, so a fixture over a session that ended reports `APP_NOT_OPEN` the way
998
1402
  an observation would. A method can also fail a step with the runner's own
999
- error classes, exported from the same entry:
1403
+ error classes, exported from the same entry.
1404
+
1405
+ ```ts
1406
+ throw new TestError('ASSERTION_FAILED', 'expected the receipt to print');
1407
+ ```
1408
+
1409
+ `E2EError` itself is not exported; construct one of the three.
1000
1410
 
1001
1411
  ```ts
1002
1412
  /** The runner's error base: `category` sets the exit code, `code` names the cause. */
@@ -1020,26 +1430,271 @@ export class InfrastructureError extends E2EError {
1020
1430
  }
1021
1431
  ```
1022
1432
 
1023
- `E2EError` itself is not exported; construct one of the three.
1433
+ ### Shared semantics
1434
+
1435
+ Helpers that keep behavior consistent across engines. Every engine
1436
+ reproduces these semantics; import them instead of carrying a copy.
1437
+
1438
+ | Export | Returns |
1439
+ | --- | --- |
1440
+ | [`matchesText`, `toTextPattern`, `describePattern`](#text-patterns) | `boolean`, `TextPattern`, `string` |
1441
+ | [`resolveExpression`](#resolveexpression) | `SemanticNode[]` |
1442
+ | [`urlMatches`](#urlmatches) | `boolean` |
1443
+ | [`siteOf`, `sameSite`](#site-helpers) | `string`, `boolean` |
1444
+ | [`pollCondition`, `Deadline`](#pollcondition) | `Promise<void>`, `Deadline` |
1445
+ | [`validateJsonValue`](#validatejsonvalue) | `void` |
1446
+ | [`rejectUnknownKeys`](#rejectunknownkeys) | `void` |
1447
+ | [`rejectUnknownOptions`](#rejectunknownoptions) | `void` |
1448
+ | [`obj`](#obj) | `WithoutUndefined<T>` |
1449
+ | [`raceAbort`, `withTimeout`, `withinCleanupBudget`](#timing-helpers) | `Promise<T>`, `Promise<T>`, `Promise<void>` |
1450
+ | [`isSecret`](#issecret) | `boolean` |
1451
+ | [`isProviderRecording`, `stopProviderRecording`](#provider-recordings) | `boolean`, `Promise<VideoSegment>` |
1452
+
1453
+ #### Text patterns
1454
+
1455
+ `TextPattern` matching for `locate` and text matchers.
1456
+
1457
+ ```ts
1458
+ export function toTextPattern(match: TextMatch, options?: { exact?: boolean }): TextPattern;
1459
+ export function matchesText(actual: string, pattern: TextPattern): boolean;
1460
+ export function describePattern(pattern: TextPattern): string;
1461
+ ```
1462
+
1463
+ ```ts
1464
+ const pattern = toTextPattern(/total/i);
1465
+ matchesText('Order total', pattern); // true
1466
+ describePattern(pattern); // '/total/i'
1467
+ ```
1468
+
1469
+ | Throws | When |
1470
+ | --- | --- |
1471
+ | `INVALID_ARGUMENT` | `toTextPattern` gets a `match` that is neither a string nor a `RegExp`. |
1472
+
1473
+ #### `resolveExpression`
1024
1474
 
1025
- ## Shared semantics
1475
+ The reference `locate`. Resolves one `LocatorExpression` over a semantic
1476
+ tree, in document order.
1026
1477
 
1027
- Use these helpers to keep behavior consistent across engines:
1478
+ ```ts
1479
+ export function resolveExpression(
1480
+ expression: LocatorExpression,
1481
+ nodes: readonly SemanticNode[],
1482
+ options?: ResolveExpressionOptions,
1483
+ ): SemanticNode[];
1484
+ ```
1485
+
1486
+ ```ts
1487
+ async locate(expression) {
1488
+ const screen = await client.snapshot();
1489
+ return resolveExpression(expression, screen.nodes);
1490
+ },
1491
+ ```
1492
+
1493
+ - `text` and `label` answer with the innermost match when a container echoes
1494
+ a descendant's text.
1495
+ - An engine whose tree is the whole truth (a device) calls it; a browser
1496
+ engine reproduces it.
1497
+ - A `selector` expression needs the `selector` option.
1498
+ - A `frame` expression is `FRAME_NOT_FOUND`.
1499
+
1500
+ #### `urlMatches`
1501
+
1502
+ `toHaveURL`-style comparison of a current URL against an expected one.
1503
+
1504
+ ```ts
1505
+ export function urlMatches(
1506
+ current: string,
1507
+ expected: string | RegExp,
1508
+ baseHref: string,
1509
+ ignoreCase?: boolean,
1510
+ ): boolean;
1511
+ ```
1028
1512
 
1029
- | Export | Use |
1513
+ ```ts
1514
+ urlMatches('https://shop.test/cart', '/cart', 'https://shop.test/'); // true
1515
+ ```
1516
+
1517
+ `ignoreCase` works as on the locator matchers.
1518
+
1519
+ | Throws | When |
1030
1520
  | --- | --- |
1031
- | `matchesText(actual, pattern)`, `toTextPattern(match, { exact? })`, `describePattern(pattern)` | `TextPattern` matching for `locate` and text matchers; `toTextPattern` throws `INVALID_ARGUMENT` for a `match` that is neither a string nor a `RegExp` |
1032
- | `resolveExpression(expression, nodes, { selector? })` | The reference `locate`. Resolves one `LocatorExpression` over a semantic tree in document order, with `text` and `label` answering the innermost match when a container echoes a descendant's text. An engine whose tree is the whole truth (a device) calls it; a browser engine reproduces it. A `selector` needs the hook, a `frame` is `FRAME_NOT_FOUND` |
1033
- | `urlMatches(current, expected, baseHref, ignoreCase?)` | `toHaveURL`-style comparison, `ignoreCase` as on the locator matchers; an `expected` that is neither a string nor a `RegExp` throws `INVALID_ARGUMENT`, as in `toTextPattern` |
1034
- | `pollCondition({ deadline, signal, negated, evaluate, onTimeout })`, `Deadline` | Assertion polling and the negation grace window. An `OPERATION_TIMEOUT` from an `evaluate` that started with under 100 ms of the deadline left, after one that completed, ends the poll: `onTimeout(cause)` builds the failure with it, and a negation passes if it had held long enough by the time that read was cut off. Bound each read `evaluate` makes by `deadline` (`context.operation(deadline.remaining())`): one on the action timeout can outlast the assertion's own |
1035
- | `validateJsonValue(value, label, rules?)` | Checks a value is plain JSON: a top-level `undefined` passes; a non-finite number, a cycle, an object that is not a plain object or array, or a function, symbol, bigint, or `undefined` inside is `INVALID_ARGUMENT` naming `label`. A `Secret` is `POLICY_DENIED` unless `rules.onSecret` throws its own; `rules.maxDepth` caps the nesting |
1036
- | `rejectUnknownKeys(label, value, keys, code?)` | Throws `INVALID_CONFIG` for the first own key of `value` outside `keys`, naming the nearest known key when one is a plausible typo (`web() has unknown key "viewprt"; did you mean "viewport"?`) and the known keys otherwise: the check the runner runs on its own config objects, for an engine factory's options. Pass `'INVALID_ARGUMENT'` as `code` for an object a test passes to a fixture method |
1037
- | `rejectUnknownOptions(api, options, known, code?)` | `INVALID_ARGUMENT` (or `code`, `INVALID_LOCATOR` for a locator option) for an option key a fixture method or matcher does not take, so a JavaScript caller's unsupported option fails instead of running on the default. `options` must be a plain object or `undefined`; a class instance or array is refused, and non-enumerable own keys count as keys |
1038
- | `obj`, `raceAbort`, `withTimeout`, `withinCleanupBudget` | See [Contributed fixtures](#contributed-fixtures) |
1039
- | `isProviderRecording(value)` | Whether what a provider's `record` resolved to is a recording: a parseable `startedAt` and a `stop`. Check it before keeping one, so a malformed recording fails the start, named, instead of the stop |
1040
- | `stopProviderRecording(recording, { artifactsDir, provider, leaseId, signal })` | Ends a hosted service's recording and checks it into a `VideoSegment`; see [Provider recordings](#provider-recordings) |
1521
+ | `INVALID_ARGUMENT` | `expected` is neither a string nor a `RegExp`, as in `toTextPattern`. |
1522
+
1523
+ #### Site helpers
1524
+
1525
+ `siteOf` reads a hostname's registrable domain, the form
1526
+ `EngineAppInfo.site` takes. `sameSite` tells whether a URL is on a site.
1041
1527
 
1042
- ## State
1528
+ ```ts
1529
+ export function siteOf(hostname: string): string;
1530
+ export function sameSite(url: string | URL, site: string): boolean;
1531
+ ```
1532
+
1533
+ #### `pollCondition`
1534
+
1535
+ Assertion polling and the negation grace window.
1536
+
1537
+ ```ts
1538
+ export function pollCondition(options: PollConditionOptions): Promise<void>;
1539
+ ```
1540
+
1541
+ ```ts
1542
+ const deadline = new Deadline(context.timeouts.assertion);
1543
+ await pollCondition({
1544
+ deadline,
1545
+ signal: context.signal,
1546
+ negated: false,
1547
+ evaluate: async () => (await read(context.operation(deadline.remaining()))) === 'Paid',
1548
+ onTimeout: (cause) => new TestError('ASSERTION_FAILED', 'status never became Paid', { cause }),
1549
+ });
1550
+ ```
1551
+
1552
+ - Bound each read `evaluate` makes by `deadline`
1553
+ (`context.operation(deadline.remaining())`). A read on the action timeout
1554
+ can outlast the assertion's own.
1555
+ - An `OPERATION_TIMEOUT` from an `evaluate` ends the poll under the rules in
1556
+ [Timeouts at a deadline](#timeouts-at-a-deadline). `onTimeout(cause)`
1557
+ builds the failure with it.
1558
+
1559
+ #### `validateJsonValue`
1560
+
1561
+ Checks that a value is plain JSON.
1562
+
1563
+ ```ts
1564
+ export function validateJsonValue(value: unknown, label: string, rules?: JsonValueRules): void;
1565
+ ```
1566
+
1567
+ ```ts
1568
+ validateJsonValue(result, 'kiosk.evaluate result', { maxDepth: 32 });
1569
+ ```
1570
+
1571
+ - A top-level `undefined` passes.
1572
+ - `rules.onSecret` replaces the `POLICY_DENIED` a `Secret` raises with an
1573
+ error of its own; `rules.maxDepth` caps the nesting.
1574
+
1575
+ | Throws | When |
1576
+ | --- | --- |
1577
+ | `INVALID_ARGUMENT` | A non-finite number, a cycle, an object that is not a plain object or array, or a function, symbol, bigint, or `undefined` inside. The message names `label`. |
1578
+ | `POLICY_DENIED` | A `Secret`, unless `rules.onSecret` throws its own. |
1579
+
1580
+ #### `rejectUnknownKeys`
1581
+
1582
+ Throws for the first own key of `value` outside `keys`: the check the runner
1583
+ runs on its own config objects, for an engine factory's options.
1584
+
1585
+ ```ts
1586
+ export function rejectUnknownKeys(
1587
+ label: string,
1588
+ value: object,
1589
+ keys: readonly string[],
1590
+ code?: 'INVALID_CONFIG' | 'INVALID_ARGUMENT', // default: 'INVALID_CONFIG'
1591
+ ): void;
1592
+ ```
1593
+
1594
+ ```ts
1595
+ rejectUnknownKeys('web()', options, ['browser', 'viewport']);
1596
+ // web() has unknown key "viewprt"; did you mean "viewport"?
1597
+ ```
1598
+
1599
+ - The message names the nearest known key when one is a plausible typo, and
1600
+ the known keys otherwise.
1601
+ - Pass `'INVALID_ARGUMENT'` as `code` for an object a test passes to a
1602
+ fixture method.
1603
+
1604
+ #### `rejectUnknownOptions`
1605
+
1606
+ Refuses an option key a fixture method or matcher does not take, so a
1607
+ JavaScript caller's unsupported option fails instead of running on the
1608
+ default.
1609
+
1610
+ ```ts
1611
+ export function rejectUnknownOptions(
1612
+ api: string,
1613
+ options: object | undefined,
1614
+ known: readonly string[],
1615
+ code?: 'INVALID_ARGUMENT' | 'INVALID_LOCATOR', // default: 'INVALID_ARGUMENT'
1616
+ ): void;
1617
+ ```
1618
+
1619
+ ```ts
1620
+ rejectUnknownOptions('kiosk.print', options, ['copies']);
1621
+ ```
1622
+
1623
+ - Pass `INVALID_LOCATOR` as `code` for a locator option.
1624
+ - `options` must be a plain object or `undefined`; a class instance or an
1625
+ array is refused.
1626
+ - Non-enumerable own keys count as keys.
1627
+
1628
+ #### `obj`
1629
+
1630
+ Drops `undefined`-valued keys, so a result built from optional inputs
1631
+ satisfies exact optional property types.
1632
+
1633
+ ```ts
1634
+ obj({ a: 1, b: undefined, c: 3 }); // { a: 1, c: 3 }
1635
+ ```
1636
+
1637
+ #### Timing helpers
1638
+
1639
+ ```ts
1640
+ export function raceAbort<T>(work: Promise<T> | (() => Promise<T>), signal: AbortSignal, label: string): Promise<T>;
1641
+ export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T>;
1642
+ export function withinCleanupBudget(
1643
+ promise: Promise<unknown>,
1644
+ budget: { readonly signal: AbortSignal; readonly timeoutMs: number },
1645
+ ): Promise<void>;
1646
+ ```
1647
+
1648
+ ```ts
1649
+ await raceAbort(() => client.tap(ref.id), context.signal, 'kiosk tap');
1650
+ ```
1651
+
1652
+ - `raceAbort` abandons the wait when `signal` aborts. Pass a thunk to check
1653
+ cancellation before dispatch.
1654
+ - `withTimeout` races a promise against a timeout and throws what
1655
+ `onTimeout` builds.
1656
+ - `withinCleanupBudget` never rejects. It resolves when the promise settles,
1657
+ failures included, when `signal` aborts, or when `timeoutMs` passes.
1658
+
1659
+ #### `isSecret`
1660
+
1661
+ Tells a `secrets.get()` handle from a string, for an option that takes
1662
+ either.
1663
+
1664
+ ```ts
1665
+ const password = isSecret(options.password)
1666
+ ? await context.resolveSecret(options.password)
1667
+ : options.password;
1668
+ ```
1669
+
1670
+ ### State
1671
+
1672
+ Platform-neutral state capture and restore, behind `test.setup` sessions and
1673
+ the `session:` option.
1674
+
1675
+ | Member | Does |
1676
+ | --- | --- |
1677
+ | `capture(context)` | Captures the surface's persisted state as an `EngineState`. |
1678
+ | `restore(state, context)` | Replaces the surface's whole persisted state with `state`. |
1679
+
1680
+ ```ts
1681
+ state: {
1682
+ async capture(context) {
1683
+ return { format: 'kiosk-storage', version: 1, data: await client.exportStorage(context) };
1684
+ },
1685
+ async restore(state, context) {
1686
+ await client.replaceStorage(state.data, context);
1687
+ },
1688
+ },
1689
+ ```
1690
+
1691
+ <Warning>
1692
+ Snapshots are credentials: cookies, tokens, and storage that authenticate
1693
+ the app under test. An engine must not persist, cache, or log a snapshot on
1694
+ its own, must not echo snapshot contents in an error message, and must treat
1695
+ `restore` as a replacement, never a merge, so a restored session cannot leak
1696
+ into the next attempt.
1697
+ </Warning>
1043
1698
 
1044
1699
  ```ts
1045
1700
  /**
@@ -1083,7 +1738,30 @@ export interface EngineStateCapability {
1083
1738
  }
1084
1739
  ```
1085
1740
 
1086
- ## Artifacts
1741
+ ### Artifacts
1742
+
1743
+ Evidence capture: screenshots under the attempt directory, and videos there
1744
+ or at a provider.
1745
+
1746
+ | Member | Returns |
1747
+ | --- | --- |
1748
+ | `screenshot(label, context)` | `Promise<string>` |
1749
+ | [`startVideo(context)`](#videos) | `Promise<void>` |
1750
+ | [`stopVideo(context)`](#videos) | `Promise<readonly VideoSegment[]>` |
1751
+
1752
+ `screenshot` is required and returns the path of the file it wrote.
1753
+
1754
+ ```ts
1755
+ artifacts: {
1756
+ async screenshot(_label, context) {
1757
+ // The label comes from test code, so it never becomes part of the path.
1758
+ shots += 1;
1759
+ const path = `screenshots/${String(shots).padStart(3, '0')}.png`;
1760
+ await client.capture(join(artifactsDir, path), context);
1761
+ return path;
1762
+ },
1763
+ },
1764
+ ```
1087
1765
 
1088
1766
  ```ts
1089
1767
  /**
@@ -1134,22 +1812,51 @@ export interface VideoLink {
1134
1812
  }
1135
1813
  ```
1136
1814
 
1815
+ #### Videos
1816
+
1137
1817
  The runner calls `startVideo` only for an attempt whose
1138
- [video mode](/reference/config#video) records, after `startAttempt` and any
1139
- session restore. It uses the launch budget and stops before `endAttempt`,
1140
- within the cleanup budget; a `startVideo` that fails fails the launch. Return
1141
- segments in recording order from `stopVideo`. The runner decides which recordings are kept and deletes a file an
1142
- attempt does not keep.
1818
+ [video mode](/reference/config#video) records.
1143
1819
 
1144
- ### Provider recordings
1820
+ - `startVideo` runs after `startAttempt` and any session restore, within the
1821
+ launch budget.
1822
+ - `stopVideo` runs before `endAttempt`, within the cleanup budget.
1823
+ - When the video mode is set explicitly, a `startVideo` that fails fails the
1824
+ launch and a `stopVideo` that fails fails cleanup. With the default mode, a
1825
+ failure of either drops the video quietly.
1826
+ - The runner decides which recordings are kept and deletes a file an attempt
1827
+ does not keep.
1828
+
1829
+ #### Provider recordings
1145
1830
 
1146
1831
  An engine that leases its browser or device from a hosted service can let the
1147
- service record the attempt in place of its own capture, the way
1832
+ service record the attempt in place of its own capture. That is how
1148
1833
  [`BrowserProvider.record`](/browser#recording-on-the-provider) and
1149
- [`DeviceProvider.record`](/mobile#hosted-devices) do. `e2e/engine` exports
1834
+ [`DeviceProvider.record`](/mobile#hosted-devices) work. `e2e/engine` exports
1150
1835
  the shared shape and `stopProviderRecording`, so every engine turns a
1151
1836
  provider's recording into a `VideoSegment` the same way.
1152
1837
 
1838
+ ```ts
1839
+ async stopVideo(context) {
1840
+ if (recording === undefined) return [];
1841
+ return [await stopProviderRecording(recording, { artifactsDir, provider: 'browser provider "kernel"', leaseId, signal: context.signal })];
1842
+ },
1843
+ ```
1844
+
1845
+ - `isProviderRecording(value)` tells whether what a provider's `record`
1846
+ resolved to is a recording: a parseable `startedAt` and a `stop`. Check it
1847
+ before keeping one, so a malformed recording fails the start, named,
1848
+ instead of the stop.
1849
+ - `stopProviderRecording` creates the attempt's `video` directory and calls
1850
+ `stop` with it. A file must be a plain name that exists in that directory;
1851
+ a link must be an `http(s)` URL with a media type.
1852
+ - It returns `{ path: 'video/<file>', startedAt }` or
1853
+ `{ url, mediaType, startedAt }` for `stopVideo` to hand back.
1854
+ - The target argument's type is exported as `ProviderRecordingTarget`.
1855
+
1856
+ | Throws | When |
1857
+ | --- | --- |
1858
+ | `ENGINE_FAILURE` | `stop` throws or returns anything else. The message names the provider and the lease. |
1859
+
1153
1860
  ```ts
1154
1861
  /** Handed to a provider's `record`, once per attempt that records video. */
1155
1862
  export interface ProviderRecordContext {
@@ -1187,6 +1894,8 @@ export type ProviderRecordingResult =
1187
1894
  | { readonly file: string }
1188
1895
  | { readonly url: string; readonly mediaType: string };
1189
1896
 
1897
+ export function isProviderRecording(value: unknown): value is ProviderRecording;
1898
+
1190
1899
  export function stopProviderRecording(
1191
1900
  recording: ProviderRecording,
1192
1901
  target: {
@@ -1201,21 +1910,66 @@ export function stopProviderRecording(
1201
1910
  ): Promise<VideoSegment>;
1202
1911
  ```
1203
1912
 
1204
- `stopProviderRecording` creates the attempt's `video` directory, calls
1205
- `stop` with it, and checks what came back: a file must be a plain name that
1206
- exists in that directory, a link an `http(s)` URL with a media type. It
1207
- returns `{ path: 'video/<file>', startedAt }` or `{ url, mediaType,
1208
- startedAt }` for `stopVideo` to hand back. A `stop` that throws or returns
1209
- anything else is `ENGINE_FAILURE`, naming the provider and the lease. The
1210
- target argument's type is exported as `ProviderRecordingTarget`.
1913
+ #### Redaction
1914
+
1915
+ - Videos are unmasked and carry `redaction: 'incomplete'`.
1916
+ - A `download` an engine attaches is the app's bytes. When the session knows
1917
+ a secret value, the runner rewrites a text one (`.txt`, `.csv`, `.json`,
1918
+ `.html`) through the ledger and labels it `complete`.
1919
+ - Every other download keeps its bytes and carries
1920
+ `redaction: 'incomplete'`.
1921
+
1922
+ ### Contributed fixtures
1923
+
1924
+ An engine contributes named surfaces to the test fixtures through
1925
+ `fixtures`. Each key becomes a fixture and a capability name.
1926
+
1927
+ ```ts title="src/index.ts"
1928
+ fixtures: {
1929
+ printer: (context) =>
1930
+ context.fixture(
1931
+ 'printer',
1932
+ {
1933
+ async print(text: string): Promise<void> {
1934
+ await client.print(text, context.operation());
1935
+ },
1936
+ },
1937
+ { print: { kind: 'resource', label: (text) => text } },
1938
+ ),
1939
+ },
1940
+ ```
1941
+
1942
+ ```ts title="tests/receipt.e2e.ts"
1943
+ import { test as base } from 'e2e';
1944
+
1945
+ const test = base.extend<{ printer: { print(text: string): Promise<void> } }>();
1211
1946
 
1212
- Videos are unmasked and carry `redaction: 'incomplete'`.
1213
- A `download` an engine attaches is the app's bytes: when the session knows
1214
- a secret value, the runner rewrites a text one (`.txt`,
1215
- `.csv`, `.json`, `.html`) through the ledger and labels it `complete`; every
1216
- other download keeps its bytes and carries `redaction: 'incomplete'`.
1947
+ test('prints a receipt', { requires: ['printer'] }, async ({ printer }) => {
1948
+ await printer.print('Order 42');
1949
+ });
1950
+ ```
1217
1951
 
1218
- ## Contributed fixtures
1952
+ The factory runs once per attempt with an `EngineFixtureContext`:
1953
+
1954
+ | Member | Use |
1955
+ | --- | --- |
1956
+ | `targetName` | The target the fixture serves. |
1957
+ | [`fixture(name, surface, operations)`](#declaring-operations) | Declares which methods are recorded steps. |
1958
+ | `app` | The resolved app, plus `resolveUrl(url)`. |
1959
+ | `timeouts` | The `test`, `action`, and `assertion` budgets. |
1960
+ | `signal` | The running phase's signal. Read it per call. |
1961
+ | `operation(timeoutMs?)` | A per-call `OperationContext`. |
1962
+ | `attachArtifact(kind, relativePath)` | Registers a file the current step produced. |
1963
+ | `attachViewport(viewport)` | Records the viewport the current step established. |
1964
+ | `locator(expression)` | Mints a public locator from a raw expression. |
1965
+ | `screen(scope)` | Mints a screen scope. |
1966
+ | [`expectable(target, factory)`](#expectations) | Makes `expect(fixture)` return matchers. |
1967
+
1968
+ | Throws | When |
1969
+ | --- | --- |
1970
+ | `INVALID_CONFIG` | The factory returns a plain surface, not one declared through `context.fixture`, the first time a test reaches for it. |
1971
+ | `APP_URL_REQUIRED` | `app.resolveUrl` on a target that declares no URL. |
1972
+ | `POLICY_DENIED` | `app.resolveUrl` with any scheme but `http:` and `https:` (`about:blank` aside). |
1219
1973
 
1220
1974
  ```ts
1221
1975
  /**
@@ -1276,53 +2030,75 @@ export interface EngineFixtureContext {
1276
2030
  }
1277
2031
  ```
1278
2032
 
2033
+ #### Declaring operations
2034
+
1279
2035
  `context.fixture(name, surface, operations)` declares which methods are
1280
- recorded steps. Each operation names a `kind: 'resource' | 'assertion'`, an
1281
- optional `label(...args)`, an optional `timeout` (milliseconds, a function of
1282
- the arguments, or `false` when the method owns its deadline), and optional
1283
- `verifies`. The default timeout is `actionTimeout`; assertions verify by
1284
- default. Nested maps describe namespaces. Recording decorates the object in
1285
- place, so create a fresh surface in each factory invocation.
1286
-
1287
- The runner opens the step before calling a declared method, so attachments
1288
- and failures from synchronous and asynchronous work land on the same step.
1289
- Work arriving after its step has closed cannot attach to a later one.
1290
-
1291
- A factory must return the surface it declared through `context.fixture`; a
1292
- plain surface is `INVALID_CONFIG` the first time a test reaches for it.
1293
- `expectable` attaches an expectation surface so `expect(fixture)` returns it;
1294
- declare matcher recording with `context.fixture('expect', matchers,
1295
- operations)`. Type the fixture with `test.extend<{ name: Type }>()`.
1296
-
1297
- The entrypoint also exports `obj(value)`, which drops `undefined`-valued keys
1298
- so a result built from optional inputs satisfies exact optional property
1299
- types, plus `raceAbort(work, signal, label)`, `withTimeout(promise,
1300
- timeoutMs, onTimeout)`, and `withinCleanupBudget(promise, { signal,
1301
- timeoutMs })`. Pass a thunk to `raceAbort` to check cancellation before
1302
- dispatch. `isSecret(value)` tells a `secrets.get()` handle from a string, for
1303
- an option that takes either.
1304
-
1305
- ## Vocabulary
2036
+ recorded steps. Each operation takes:
2037
+
2038
+ | Field | Values | Default |
2039
+ | --- | --- | --- |
2040
+ | `kind` | `'resource'` or `'assertion'` | required |
2041
+ | `label` | `(...args) => string` | none |
2042
+ | `timeout` | milliseconds, a function of the arguments, or `false` when the method owns its deadline | `actionTimeout` |
2043
+ | `verifies` | `boolean` | `true` for assertions |
2044
+
2045
+ - Nested maps describe namespaces.
2046
+ - Recording decorates the object in place, so create a fresh surface in each
2047
+ factory invocation.
2048
+ - The runner opens the step before calling a declared method, so attachments
2049
+ and failures from synchronous and asynchronous work land on the same step.
2050
+ Work arriving after its step has closed cannot attach to a later one.
2051
+ - Type the fixture with `test.extend<{ name: Type }>()`.
2052
+
2053
+ ```ts
2054
+ /** Explicit recording policy for one async fixture method. Arguments enter reports only through label. */
2055
+ export interface FixtureOperation<Args extends unknown[] = unknown[]> {
2056
+ readonly kind: 'resource' | 'assertion';
2057
+ readonly label?: (...args: Args) => string;
2058
+ /** Action timeout by default; false when the method owns its assertion/navigation deadline. */
2059
+ readonly timeout?: number | false | ((...args: Args) => number | undefined);
2060
+ /** Assertions verify by default; waits may opt in as well. */
2061
+ readonly verifies?: boolean;
2062
+ }
2063
+
2064
+ /** Only declared async methods are recorded. Sync accessors pass through unchanged. */
2065
+ export type FixtureOperations<T extends object> = {
2066
+ readonly [Key in keyof T]?: T[Key] extends (...args: infer Args) => Promise<unknown>
2067
+ ? FixtureOperation<Args>
2068
+ : T[Key] extends object ? FixtureOperations<T[Key]> : never;
2069
+ };
2070
+ ```
2071
+
2072
+ #### Expectations
2073
+
2074
+ `expectable` attaches an expectation surface so `expect(fixture)` returns it.
2075
+ Declare matcher recording with `context.fixture('expect', matchers,
2076
+ operations)`.
2077
+
2078
+ ```ts
2079
+ const printer = context.expectable(surface, () =>
2080
+ context.fixture('expect', matchers, { toHavePrinted: { kind: 'assertion' } }),
2081
+ );
2082
+ return context.fixture('printer', printer, { print: { kind: 'resource' } });
2083
+ ```
2084
+
2085
+ ## Types
2086
+
2087
+ ### Exported names
1306
2088
 
1307
2089
  `OperationContext`, `TextPattern`, `SemanticQuery`, `LocatorExpression`,
1308
- `NodeRef`, `SemanticNode`, `ViewportPoint`, `ViewportSize`, `ObservationPixels`,
1309
- `LocatorAction`, `LocatorActionKind`, `PointerAction`, `PointerActionKind`,
1310
- `ScrollDirection`, `Momentum`, `SelectOption`, `Key`,
2090
+ `NodeRef`, `SemanticNode`, `ViewportPoint`, `ViewportSize`,
2091
+ `ObservationPixels`, `LocatorAction`, `LocatorActionKind`, `PointerAction`,
2092
+ `PointerActionKind`, `ScrollDirection`, `Momentum`, `SelectOption`, `Key`,
1311
2093
  `KeyModifier`, `KeyName`, `ParsedKey`, `ENGINE_SPI_VERSION`,
1312
2094
  `LOCATOR_ACTION_KINDS`, `POINTER_ACTION_KINDS`, `KEY_MODIFIERS`,
1313
2095
  `KEY_NAMES`, `parseKey`, `ENGINE_ERROR_CODES`, `RETRYABLE_ENGINE_ERROR_CODES`,
1314
2096
  `Secret`, `isSecret`, `TextMatch`, `JsonValue`, `JsonValueRules`,
1315
2097
  `ResolveExpressionOptions`, `PollConditionOptions`, `WithoutUndefined`,
1316
- `FixtureOperation`, `OBSERVED_NAME_LIMIT` (256) and `OBSERVED_TEXT_LIMIT`
1317
- (512) are exported from `e2e/engine`. An engine that cuts an observed field cuts it at exactly its
1318
- limit: `name` at the name limit, `text` and any `value` or `selection` it
1319
- cuts at the text limit. So a field exactly at its limit may have been cut; a
1320
- shorter one, or a longer one the engine left whole, is complete. Keep the start and cut the end: the
1321
- runner redacts the leading part of a secret that a field exactly at its limit
1322
- ends with, which a whole-value match cannot see. Single-node reads are
1323
- unbounded and always carry the full value. The `selector` expression kind carries a
1324
- platform-native selector string; `frame` scopes a query into a nested
1325
- document.
2098
+ `FixtureOperation`, `OBSERVED_NAME_LIMIT`, and `OBSERVED_TEXT_LIMIT` are
2099
+ exported from `e2e/engine`.
2100
+
2101
+ ### `SemanticNode`
1326
2102
 
1327
2103
  ```ts
1328
2104
  /** One node of an observation tree. */
@@ -1347,8 +2123,9 @@ export interface SemanticNode {
1347
2123
  * The node's test id, when the platform gives it one: the value of the
1348
2124
  * project's test-id attribute on a document platform, an accessibility
1349
2125
  * identifier on iOS, a resource id on Android, an automation id on a
1350
- * desktop. The `testId` query resolves against exactly this field, so the
1351
- * harness never knows where a platform keeps it.
2126
+ * desktop. The `testId` query resolves against this field, so the harness
2127
+ * never knows where a platform keeps it. An engine may also match the id
2128
+ * without its platform's namespacing.
1352
2129
  */
1353
2130
  readonly testId?: string;
1354
2131
  /** What a field takes, deciding which secrets may fill it. */
@@ -1402,13 +2179,42 @@ export interface SemanticNode {
1402
2179
  /** Child nodes. */
1403
2180
  readonly children?: readonly SemanticNode[];
1404
2181
  }
2182
+ ```
2183
+
2184
+ ### Observed field limits
2185
+
2186
+ | Constant | Value | Cuts |
2187
+ | --- | --- | --- |
2188
+ | `OBSERVED_NAME_LIMIT` | 256 | `name` |
2189
+ | `OBSERVED_TEXT_LIMIT` | 512 | `text`, and any `value` or `selection` the engine cuts |
2190
+
2191
+ - An engine that cuts an observed field cuts it at exactly its limit. A field
2192
+ exactly at its limit may have been cut; a shorter one, or a longer one the
2193
+ engine left whole, is complete.
2194
+ - Keep the start and cut the end: the runner redacts the leading part of a
2195
+ secret that a field exactly at its limit ends with, which a whole-value
2196
+ match cannot see.
2197
+ - Single-node reads are unbounded and always carry the full value.
1405
2198
 
2199
+ ### Queries
2200
+
2201
+ The `selector` expression kind carries a platform-native selector string;
2202
+ `frame` scopes a query into a nested document.
2203
+
2204
+ ```ts
1406
2205
  /** The `screen` query kinds. */
1407
2206
  export type QueryKind = 'role' | 'label' | 'placeholder' | 'text' | 'displayValue' | 'testId';
1408
2207
  ```
1409
2208
 
1410
2209
  A `SemanticQuery` with `visible: true` must match only nodes whose
1411
- `states.hidden` is false, by the same predicate the engine reports on
2210
+ `states.hidden` is false. Use the same predicate the engine reports on
1412
2211
  `SemanticNode`, so `getByText('x', { visible: true })` and `toBeVisible()`
1413
2212
  never disagree. Apply it wherever the query sits in the expression, under a
1414
2213
  scope, filter, or index too.
2214
+
2215
+ ## Related
2216
+
2217
+ [Writing an engine](/writing-an-engine) walks through building one.
2218
+ `@e2e-dev/web` ([reference](/reference/web)) and `@e2e-dev/mobile`
2219
+ ([reference](/reference/mobile)) implement this contract with the same
2220
+ public `defineEngine`.