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.
- package/README.md +2 -0
- package/dist/.build.json +1 -1
- package/dist/agent/act-validation.js +1 -0
- package/dist/agent/act.js +7 -3
- package/dist/agent/action-dispatcher.js +30 -8
- package/dist/agent/actions.d.ts +2 -0
- package/dist/agent/actions.js +5 -2
- package/dist/agent/error.js +1 -0
- package/dist/agent/executor.d.ts +14 -6
- package/dist/agent/index.js +5 -1
- package/dist/agent/invocation.d.ts +3 -0
- package/dist/agent/model/sdk.d.ts +5 -0
- package/dist/agent/model/sdk.js +18 -1
- package/dist/agent/observation.js +1 -0
- package/dist/agent/primitives.js +14 -3
- package/dist/agent/replay.js +9 -3
- package/dist/agent/step-cache.d.ts +3 -2
- package/dist/agent/step-cache.js +7 -4
- package/dist/agent/tool-loop.js +4 -1
- package/dist/cache/context.js +1 -1
- package/dist/cache/recorder.d.ts +2 -1
- package/dist/cache/recorder.js +11 -2
- package/dist/cache/trace-fields.d.ts +10 -0
- package/dist/cache/trace-fields.js +17 -0
- package/dist/cache/trace.d.ts +9 -1
- package/dist/cache/trace.js +26 -7
- package/dist/cli/feedback.js +2 -1
- package/dist/cli/init/sibling-versions.json +1 -1
- package/dist/collect/select.js +12 -2
- package/dist/config/agent.d.ts +1 -1
- package/dist/config/diagnose.js +9 -3
- package/dist/config/esm-hooks.js +3 -2
- package/dist/config/resolve.js +3 -2
- package/dist/config/typescript.js +52 -2
- package/dist/engine/contract.d.ts +3 -2
- package/dist/engine/manifest.js +2 -2
- package/dist/internal/errors.js +5 -2
- package/dist/internal/source.js +2 -0
- package/dist/internal/unserved-model.d.ts +5 -0
- package/dist/internal/unserved-model.js +22 -0
- package/dist/locator/screen.js +1 -2
- package/dist/oauth/copilot.js +23 -5
- package/dist/oauth/device-code.js +35 -19
- package/dist/oauth/errors.d.ts +3 -0
- package/dist/oauth/errors.js +26 -11
- package/dist/oauth/providers/github-copilot.d.ts +4 -0
- package/dist/oauth/providers/github-copilot.js +64 -16
- package/dist/oauth/providers/openai.d.ts +2 -2
- package/dist/oauth/providers/openai.js +47 -19
- package/dist/oauth/providers/opencode-console.js +36 -15
- package/dist/oauth/providers/xai.js +18 -9
- package/dist/oauth/token-endpoint.d.ts +15 -6
- package/dist/oauth/token-endpoint.js +18 -6
- package/dist/report/code-frame.js +9 -3
- package/dist/report/failure-text.d.ts +10 -2
- package/dist/report/failure-text.js +24 -8
- package/dist/report/list-model.d.ts +13 -0
- package/dist/report/list-steps.d.ts +6 -1
- package/dist/report/list-steps.js +29 -0
- package/dist/report/list.d.ts +1 -0
- package/dist/report/list.js +16 -7
- package/dist/report/live-window.js +2 -1
- package/dist/report/markdown.js +1 -1
- package/dist/run/fixtures.js +2 -0
- package/dist/run/host-trace.d.ts +3 -2
- package/dist/run/host-trace.js +2 -1
- package/dist/run/last-run.d.ts +9 -0
- package/dist/run/last-run.js +28 -0
- package/dist/run/open-session.d.ts +3 -1
- package/dist/run/open-session.js +1 -1
- package/dist/run/runner.js +6 -2
- package/dist/run/sessions.js +3 -3
- package/dist/run/standalone-attempt.d.ts +2 -1
- package/dist/run/standalone-attempt.js +3 -1
- package/dist/run/steps.d.ts +3 -1
- package/dist/run/units.d.ts +8 -0
- package/dist/run/units.js +14 -0
- package/dist/run/worker-models.d.ts +2 -0
- package/dist/run/worker-models.js +7 -0
- package/dist/types.d.ts +2 -2
- package/docs/authentication.mdx +26 -23
- package/docs/browser.mdx +7 -4
- package/docs/bug-bash.mdx +2 -2
- package/docs/cache.mdx +6 -1
- package/docs/ci/bitrise.mdx +1 -1
- package/docs/ci/codemagic.mdx +2 -2
- package/docs/ci/eas-workflows.mdx +1 -1
- package/docs/coding-agents.mdx +7 -35
- package/docs/debugging.mdx +24 -36
- package/docs/executors.mdx +1 -1
- package/docs/github.mdx +2 -1
- package/docs/integrations/eas.mdx +1 -1
- package/docs/integrations/index.mdx +6 -0
- package/docs/integrations/lightpanda.mdx +192 -0
- package/docs/integrations/testmu.mdx +152 -0
- package/docs/mcp.mdx +123 -0
- package/docs/migrate/detox.mdx +1 -1
- package/docs/migrate/maestro.mdx +101 -105
- package/docs/migrate/playwright.mdx +65 -70
- package/docs/mobile.mdx +18 -3
- package/docs/models.mdx +4 -2
- package/docs/quickstart.mdx +7 -0
- package/docs/reference/agent.mdx +369 -277
- package/docs/reference/app.mdx +141 -32
- package/docs/reference/cli.mdx +674 -558
- package/docs/reference/config.mdx +1406 -752
- package/docs/reference/engine.mdx +1062 -256
- package/docs/reference/environment.mdx +156 -84
- package/docs/reference/errors.mdx +106 -72
- package/docs/reference/expect.mdx +752 -372
- package/docs/reference/mcp.mdx +366 -141
- package/docs/reference/mobile.mdx +844 -403
- package/docs/reference/reporters.mdx +312 -106
- package/docs/reference/runner.mdx +373 -174
- package/docs/reference/screen.mdx +931 -398
- package/docs/reference/security.mdx +106 -40
- package/docs/reference/test.mdx +626 -273
- package/docs/reference/web.mdx +956 -533
- package/docs/security.mdx +6 -5
- package/docs/snippets/model-provider.jsx +763 -0
- package/docs/snippets/setup-prompt-card.jsx +22 -0
- package/docs/snippets/show-more.jsx +22 -0
- package/docs/snippets/video-card.jsx +34 -0
- package/docs/snippets/video.jsx +25 -0
- package/docs/subscriptions.mdx +10 -3
- package/docs/telemetry.mdx +39 -39
- package/package.json +3 -2
- package/schema/report-v1.schema.json +6 -0
- package/skills/e2e/references/agent.md +8 -3
- package/skills/e2e/references/debugging.md +1 -1
- package/skills/e2e/references/mcp.md +1 -1
- package/skills/e2e/references/setup.md +12 -3
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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` |
|
|
257
|
-
| `locate` | `location` | `observe` | `screen`, `Locator`, `expect(locator)
|
|
258
|
-
| `tapModifiers: true` | - | `perform` | `modifiers` on `tap`, `doubleTap`, and `secondaryTap
|
|
259
|
-
| `performAt` + `pointerActions` | `pointer` | `observe` |
|
|
260
|
-
| `keyboard.{type,press,dismiss?}` | `keyboard` | `observe` |
|
|
261
|
-
| `fixtures.<name>` | `<name>` | - |
|
|
262
|
-
| `state` | `state` | - | `test.setup` sessions and the `session:` option |
|
|
263
|
-
| `artifacts` | `artifacts` | - |
|
|
264
|
-
| `validateApp` | - | - |
|
|
265
|
-
| `session` | - | - | `app
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
`
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
`
|
|
278
|
-
`
|
|
279
|
-
|
|
280
|
-
`
|
|
281
|
-
|
|
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
|
|
284
|
-
`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
|
-
|
|
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
|
-
|
|
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)`
|
|
344
|
-
| `finish(info)` |
|
|
345
|
-
| `init(info)` |
|
|
346
|
-
| `startAttempt(context)` |
|
|
347
|
-
| `settleAttempt(context)` |
|
|
348
|
-
| `endAttempt(context)` |
|
|
349
|
-
| `dispose(context)` |
|
|
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
|
-
|
|
841
|
+
### Observation
|
|
842
|
+
|
|
843
|
+
`observe` returns one fresh semantic snapshot of the surface under test.
|
|
593
844
|
|
|
594
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
932
|
+
```ts
|
|
933
|
+
return {
|
|
934
|
+
treeUnavailable: true,
|
|
935
|
+
root: { ref: rootRef },
|
|
936
|
+
viewport,
|
|
937
|
+
pixels,
|
|
938
|
+
};
|
|
939
|
+
```
|
|
666
940
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
679
|
-
capture failure. Cancellation, policy denial, and hard engine failures must
|
|
680
|
-
also propagate.
|
|
965
|
+
### Actions
|
|
681
966
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
### Pointer actions
|
|
1068
|
+
#### Pointer actions
|
|
753
1069
|
|
|
754
1070
|
Pointer actions use viewport coordinates when no semantic node is available.
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
861
|
-
declaration to `validateApp` at config load
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1475
|
+
The reference `locate`. Resolves one `LocatorExpression` over a semantic
|
|
1476
|
+
tree, in document order.
|
|
1026
1477
|
|
|
1027
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
-
|
|
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
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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`,
|
|
1309
|
-
`LocatorAction`, `LocatorActionKind`, `PointerAction`,
|
|
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
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
|
1351
|
-
*
|
|
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
|
|
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`.
|