e2e 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/README.md +10 -0
  2. package/dist/.build.json +1 -1
  3. package/dist/agent/act.js +7 -4
  4. package/dist/agent/action-dispatcher.js +4 -5
  5. package/dist/agent/actions.d.ts +32 -23
  6. package/dist/agent/actions.js +38 -24
  7. package/dist/agent/ai-sdk.js +5 -4
  8. package/dist/agent/error.js +3 -3
  9. package/dist/agent/invocation.js +3 -4
  10. package/dist/agent/model/sdk.js +2 -0
  11. package/dist/agent/model/tool-choice.d.ts +9 -0
  12. package/dist/agent/model/tool-choice.js +14 -0
  13. package/dist/agent/observation-feed.d.ts +5 -6
  14. package/dist/agent/observation-feed.js +8 -11
  15. package/dist/agent/observation.d.ts +40 -11
  16. package/dist/agent/observation.js +109 -59
  17. package/dist/agent/phases.d.ts +7 -1
  18. package/dist/agent/phases.js +12 -3
  19. package/dist/agent/point-tap.d.ts +8 -8
  20. package/dist/agent/point-tap.js +1 -1
  21. package/dist/agent/prompts.js +3 -0
  22. package/dist/agent/replay.d.ts +19 -8
  23. package/dist/agent/replay.js +29 -29
  24. package/dist/agent/scroll-target.d.ts +2 -3
  25. package/dist/agent/step-cache.d.ts +43 -15
  26. package/dist/agent/step-cache.js +156 -67
  27. package/dist/agent/tool-loop.js +94 -5
  28. package/dist/cache/anchors.d.ts +72 -23
  29. package/dist/cache/anchors.js +233 -60
  30. package/dist/cache/context.d.ts +25 -14
  31. package/dist/cache/context.js +50 -8
  32. package/dist/cache/decide.d.ts +2 -2
  33. package/dist/cache/decide.js +4 -6
  34. package/dist/cache/identity.d.ts +18 -4
  35. package/dist/cache/identity.js +9 -4
  36. package/dist/cache/recorder.d.ts +15 -1
  37. package/dist/cache/recorder.js +36 -23
  38. package/dist/cache/rekeyed.d.ts +46 -0
  39. package/dist/cache/rekeyed.js +89 -0
  40. package/dist/cache/relocate.d.ts +14 -14
  41. package/dist/cache/relocate.js +33 -25
  42. package/dist/cache/route.d.ts +32 -33
  43. package/dist/cache/route.js +94 -50
  44. package/dist/cache/store.d.ts +6 -0
  45. package/dist/cache/store.js +21 -1
  46. package/dist/cache/trace.d.ts +53 -14
  47. package/dist/cache/trace.js +76 -30
  48. package/dist/cli/bin.js +2 -2
  49. package/dist/cli/index.js +43 -32
  50. package/dist/cli/init/engines.d.ts +1 -3
  51. package/dist/cli/init/engines.js +2 -2
  52. package/dist/cli/init/gateways.d.ts +1 -1
  53. package/dist/cli/init/gateways.js +11 -1
  54. package/dist/cli/init/mcp-config.d.ts +2 -1
  55. package/dist/cli/init/mcp-config.js +8 -3
  56. package/dist/cli/init/sibling-versions.json +1 -1
  57. package/dist/cli/init/versions.d.ts +0 -8
  58. package/dist/cli/init/versions.js +1 -10
  59. package/dist/cli/mcp.d.ts +3 -2
  60. package/dist/cli/mcp.js +52 -16
  61. package/dist/cli/run-output.d.ts +17 -0
  62. package/dist/cli/run-output.js +45 -0
  63. package/dist/collect/collect.js +8 -4
  64. package/dist/collect/registry.js +27 -16
  65. package/dist/config/compiled-files.d.ts +36 -0
  66. package/dist/config/compiled-files.js +50 -0
  67. package/dist/config/diagnose.js +21 -19
  68. package/dist/config/esm-hooks.d.ts +39 -44
  69. package/dist/config/esm-hooks.js +238 -63
  70. package/dist/config/load.d.ts +1 -7
  71. package/dist/config/load.js +23 -66
  72. package/dist/config/register.d.ts +8 -0
  73. package/dist/config/register.js +9 -0
  74. package/dist/config/resolve.d.ts +1 -0
  75. package/dist/config/resolve.js +33 -1
  76. package/dist/config/specifiers.d.ts +19 -0
  77. package/dist/config/specifiers.js +33 -0
  78. package/dist/config/tsconfig.d.ts +56 -0
  79. package/dist/config/tsconfig.js +115 -0
  80. package/dist/config/typescript.d.ts +19 -0
  81. package/dist/config/typescript.js +253 -0
  82. package/dist/engine/contract.d.ts +1 -0
  83. package/dist/engine/index.d.ts +4 -2
  84. package/dist/engine/session.js +2 -2
  85. package/dist/expect/async.js +4 -2
  86. package/dist/expect/poll-scope.d.ts +52 -0
  87. package/dist/expect/poll-scope.js +113 -0
  88. package/dist/expect/poll.js +33 -6
  89. package/dist/expect/values.js +11 -4
  90. package/dist/internal/abandoned.d.ts +11 -0
  91. package/dist/internal/abandoned.js +23 -0
  92. package/dist/internal/brands.d.ts +2 -0
  93. package/dist/internal/brands.js +2 -0
  94. package/dist/internal/client-identity.d.ts +9 -0
  95. package/dist/internal/client-identity.js +14 -0
  96. package/dist/internal/errors.d.ts +10 -3
  97. package/dist/internal/errors.js +23 -8
  98. package/dist/internal/node-version.d.ts +16 -7
  99. package/dist/internal/node-version.js +50 -15
  100. package/dist/internal/package-manager.js +1 -1
  101. package/dist/internal/realm-slot.d.ts +5 -4
  102. package/dist/internal/redact.d.ts +9 -4
  103. package/dist/internal/redact.js +120 -23
  104. package/dist/internal/time.d.ts +26 -4
  105. package/dist/internal/time.js +58 -14
  106. package/dist/internal/urls.d.ts +9 -5
  107. package/dist/internal/urls.js +19 -10
  108. package/dist/locator/engine.d.ts +12 -2
  109. package/dist/locator/engine.js +55 -14
  110. package/dist/locator/screen.js +59 -9
  111. package/dist/mcp/catalog.d.ts +3 -2
  112. package/dist/mcp/catalog.js +6 -6
  113. package/dist/mcp/output.d.ts +59 -0
  114. package/dist/mcp/output.js +129 -0
  115. package/dist/mcp/server.d.ts +4 -0
  116. package/dist/mcp/server.js +9 -12
  117. package/dist/mcp/session.d.ts +8 -1
  118. package/dist/mcp/session.js +56 -15
  119. package/dist/mcp/tools.js +7 -5
  120. package/dist/oauth/chatgpt.js +3 -23
  121. package/dist/oauth/cli.js +1 -0
  122. package/dist/oauth/copilot.d.ts +5 -4
  123. package/dist/oauth/copilot.js +80 -7
  124. package/dist/oauth/grok.js +2 -1
  125. package/dist/oauth/models.js +2 -1
  126. package/dist/oauth/opencode-console.d.ts +10 -0
  127. package/dist/oauth/opencode-console.js +133 -0
  128. package/dist/oauth/providers/github-copilot.d.ts +12 -0
  129. package/dist/oauth/providers/github-copilot.js +77 -23
  130. package/dist/oauth/providers/opencode-console.d.ts +31 -0
  131. package/dist/oauth/providers/opencode-console.js +148 -0
  132. package/dist/oauth/providers.d.ts +2 -6
  133. package/dist/oauth/providers.js +3 -7
  134. package/dist/oauth/responses.d.ts +9 -0
  135. package/dist/oauth/responses.js +23 -0
  136. package/dist/report/build.d.ts +26 -0
  137. package/dist/report/build.js +16 -8
  138. package/dist/report/failure-text.d.ts +6 -1
  139. package/dist/report/failure-text.js +14 -4
  140. package/dist/report/format.d.ts +10 -5
  141. package/dist/report/format.js +12 -5
  142. package/dist/report/junit.js +11 -12
  143. package/dist/report/list.d.ts +12 -0
  144. package/dist/report/list.js +61 -24
  145. package/dist/report/markdown.d.ts +1 -1
  146. package/dist/report/markdown.js +37 -14
  147. package/dist/report/repeats.d.ts +6 -1
  148. package/dist/report/repeats.js +12 -3
  149. package/dist/run/artifacts.d.ts +22 -4
  150. package/dist/run/artifacts.js +71 -3
  151. package/dist/run/execute.d.ts +34 -5
  152. package/dist/run/execute.js +93 -44
  153. package/dist/run/extended-fixtures.js +6 -2
  154. package/dist/run/failure-evidence.js +10 -4
  155. package/dist/run/fixtures.js +6 -8
  156. package/dist/run/in-process.d.ts +2 -0
  157. package/dist/run/in-process.js +52 -10
  158. package/dist/run/last-run.d.ts +42 -8
  159. package/dist/run/last-run.js +121 -14
  160. package/dist/run/process-output.d.ts +73 -0
  161. package/dist/run/process-output.js +130 -0
  162. package/dist/run/provision.d.ts +2 -0
  163. package/dist/run/provision.js +1 -0
  164. package/dist/run/realm.d.ts +4 -1
  165. package/dist/run/realm.js +20 -6
  166. package/dist/run/records.d.ts +20 -4
  167. package/dist/run/records.js +26 -0
  168. package/dist/run/retry.d.ts +10 -1
  169. package/dist/run/retry.js +30 -12
  170. package/dist/run/runner.d.ts +9 -1
  171. package/dist/run/runner.js +101 -39
  172. package/dist/run/scheduler.js +255 -19
  173. package/dist/run/secrecy.d.ts +22 -19
  174. package/dist/run/secrecy.js +22 -18
  175. package/dist/run/serial.d.ts +75 -2
  176. package/dist/run/serial.js +243 -59
  177. package/dist/run/standalone.js +1 -1
  178. package/dist/run/steps.d.ts +0 -6
  179. package/dist/run/steps.js +3 -20
  180. package/dist/run/trace-redaction.d.ts +5 -6
  181. package/dist/run/trace-redaction.js +5 -6
  182. package/dist/run/worker/entry.js +40 -18
  183. package/dist/run/worker/handle.js +10 -5
  184. package/dist/run/worker/protocol.d.ts +85 -3
  185. package/dist/run/worker/session.d.ts +2 -0
  186. package/dist/run/worker/session.js +29 -0
  187. package/dist/telemetry/environment.d.ts +2 -1
  188. package/dist/telemetry/environment.js +12 -2
  189. package/dist/telemetry/events.js +1 -0
  190. package/dist/telemetry/project.d.ts +5 -6
  191. package/dist/telemetry/project.js +6 -11
  192. package/dist/telemetry/store.d.ts +5 -5
  193. package/dist/telemetry/store.js +5 -7
  194. package/dist/telemetry/telemetry.d.ts +2 -3
  195. package/dist/telemetry/telemetry.js +2 -4
  196. package/dist/types.d.ts +15 -6
  197. package/docs/agent-steps.mdx +1 -0
  198. package/docs/agents.mdx +7 -3
  199. package/docs/api-testing.mdx +2 -1
  200. package/docs/authentication.mdx +6 -3
  201. package/docs/browser.mdx +49 -12
  202. package/docs/bug-bash.mdx +2 -1
  203. package/docs/cache.mdx +66 -19
  204. package/docs/ci.mdx +14 -9
  205. package/docs/coding-agents.mdx +2 -1
  206. package/docs/core-concepts.mdx +208 -0
  207. package/docs/debugging.mdx +25 -12
  208. package/docs/decision-models.mdx +142 -0
  209. package/docs/example-projects.mdx +32 -0
  210. package/docs/executors.mdx +7 -2
  211. package/docs/explore.mdx +1 -0
  212. package/docs/github.mdx +14 -5
  213. package/docs/index.mdx +9 -5
  214. package/docs/integrations/eas.mdx +1 -0
  215. package/docs/integrations/index.mdx +1 -0
  216. package/docs/integrations/kernel.mdx +3 -2
  217. package/docs/migrate/cypress.mdx +26 -3
  218. package/docs/migrate/detox.mdx +24 -1
  219. package/docs/migrate/maestro.mdx +28 -4
  220. package/docs/migrate/playwright.mdx +44 -18
  221. package/docs/migrate/selenium.mdx +27 -3
  222. package/docs/mobile-ci.mdx +6 -5
  223. package/docs/mobile.mdx +96 -8
  224. package/docs/models.mdx +60 -9
  225. package/docs/quickstart.mdx +28 -16
  226. package/docs/reference/agent.mdx +13 -3
  227. package/docs/reference/app.mdx +2 -1
  228. package/docs/reference/cli.mdx +41 -18
  229. package/docs/reference/config.mdx +67 -13
  230. package/docs/reference/engine.mdx +25 -13
  231. package/docs/reference/environment.mdx +10 -2
  232. package/docs/reference/errors.mdx +13 -10
  233. package/docs/reference/expect.mdx +36 -13
  234. package/docs/reference/mcp.mdx +21 -8
  235. package/docs/reference/mobile.mdx +24 -13
  236. package/docs/reference/reporters.mdx +9 -2
  237. package/docs/reference/screen.mdx +49 -16
  238. package/docs/reference/test.mdx +32 -5
  239. package/docs/reference/web.mdx +133 -23
  240. package/docs/security.mdx +58 -42
  241. package/docs/subscriptions.mdx +92 -12
  242. package/docs/telemetry.mdx +9 -6
  243. package/docs/tools.mdx +1 -0
  244. package/docs/{starting-your-app.mdx → web.mdx} +157 -11
  245. package/docs/writing-an-engine.mdx +2 -1
  246. package/package.json +25 -8
  247. package/schema/report-v1.schema.json +14 -1
  248. package/skills/e2e/SKILL.md +5 -3
  249. package/skills/e2e/references/agent.md +33 -11
  250. package/skills/e2e/references/debugging.md +4 -4
  251. package/skills/e2e/references/mcp.md +8 -3
  252. package/skills/e2e/references/running.md +15 -9
  253. package/skills/e2e/references/setup.md +45 -16
  254. package/skills/e2e/references/writing-tests.md +35 -18
  255. package/docs/viewports.mdx +0 -146
  256. package/docs/writing-tests.mdx +0 -256
@@ -21,7 +21,7 @@ import { containerKey, describeTarget, parentsOf } from '../agent/actions.js';
21
21
  * change, because an entry recorded under different rules could relocate to a
22
22
  * different node.
23
23
  */
24
- export const REPLAY_POLICY_VERSION = 'conservative/5';
24
+ export const REPLAY_POLICY_VERSION = 'conservative/6';
25
25
  /**
26
26
  * The share of the viewport a scrolled node must have covered when it was
27
27
  * addressed (`ScrollAction.spans`) to scroll as the viewport does once it
@@ -33,18 +33,13 @@ export const MAIN_LIST_SHARE = 0.5;
33
33
  const IDENTITY_FIELDS = ['role', 'name', 'testId', 'placeholder', 'inputPurpose'];
34
34
  /** Text is identity too when neither a test id nor a name was recorded. */
35
35
  const IDENTITY_FIELDS_WITH_TEXT = [...IDENTITY_FIELDS, 'text'];
36
- /**
37
- * Whether a descriptor can identify a node at all. Role or selector alone
38
- * cannot: a wrong match acts on the wrong control, so such a descriptor is
39
- * not relocatable and, as an anchor, would prove nothing.
40
- */
41
36
  /**
42
37
  * A descriptor with nothing to identify the control by: no test id, name,
43
- * text, or placeholder, only a role. A form built without labels is made of
44
- * these. Such a descriptor relocates by its place among the unnamed
45
- * controls of its kind, so it is relocatable only once a position was
46
- * recorded for it, and it is matched strictly (`fieldsIdentical`): an
47
- * unnamed textbox must never stand in for a named one.
38
+ * text, or placeholder, only a role. An icon button and a form built
39
+ * without labels are made of these. Such a descriptor relocates by its place
40
+ * among the unnamed controls of its kind, and it is matched strictly
41
+ * (`fieldsIdentical`): an unnamed textbox must never stand in for a named
42
+ * one.
48
43
  */
49
44
  function isAnonymous(descriptor) {
50
45
  return (descriptor.testId === undefined &&
@@ -52,8 +47,21 @@ function isAnonymous(descriptor) {
52
47
  descriptor.text === undefined &&
53
48
  descriptor.placeholder === undefined);
54
49
  }
50
+ /**
51
+ * Whether a descriptor can identify a node at all. Role or selector alone
52
+ * cannot: a wrong match acts on the wrong control, so such a descriptor is
53
+ * not relocatable and, as an anchor, would prove nothing. An anonymous one
54
+ * is relocatable by its recorded place only when that place means something
55
+ * on the next screen: it was the one unnamed control of its role, or it sits
56
+ * in a named container (`within`) that tells it from its twins. A place
57
+ * counted among twins with no container is an order, and rows that reorder
58
+ * would hand the action to the wrong one, so such a target hands off.
59
+ */
55
60
  export function isRelocatableDescriptor(descriptor) {
56
- return isAnonymous(descriptor) ? descriptor.role !== undefined && descriptor.position !== undefined : true;
61
+ if (!isAnonymous(descriptor))
62
+ return true;
63
+ const { position } = descriptor;
64
+ return descriptor.role !== undefined && position !== undefined && (position.of === 1 || descriptor.within !== undefined);
57
65
  }
58
66
  /** The semantic tier of a descriptor: every identity field but the test id. */
59
67
  function withoutTestId(descriptor) {
@@ -102,17 +110,17 @@ function fieldsIdentical(recorded, candidate, fields) {
102
110
  */
103
111
  const projections = new WeakMap();
104
112
  /** Projects every node of an observation the way the recorder described its targets. */
105
- export function describeNodes(nodes, options) {
113
+ function describeNodes(nodes) {
106
114
  const cached = projections.get(nodes);
107
- if (cached !== undefined && cached.redact === options.redact)
108
- return cached.described;
115
+ if (cached !== undefined)
116
+ return cached;
109
117
  const described = [];
110
118
  for (const [id, node] of nodes) {
111
- const descriptor = describeTarget(node, options.redact);
119
+ const descriptor = describeTarget(node);
112
120
  if (descriptor !== undefined)
113
121
  described.push({ id, descriptor });
114
122
  }
115
- projections.set(nodes, { redact: options.redact, described });
123
+ projections.set(nodes, described);
116
124
  return described;
117
125
  }
118
126
  /**
@@ -124,8 +132,8 @@ export function describeNodes(nodes, options) {
124
132
  * same twins and noted which one it acted on, so the same count of twins
125
133
  * resolves to the same one; any other count diverges as before.
126
134
  */
127
- export function relocateDescriptor(descriptor, nodes, options) {
128
- const matches = matchingIds(descriptor, nodes, options);
135
+ export function relocateDescriptor(descriptor, nodes) {
136
+ const matches = matchingIds(descriptor, nodes);
129
137
  if (matches.length === 0)
130
138
  return { kind: 'failed', failure: 'target-not-found' };
131
139
  const { position } = descriptor;
@@ -146,8 +154,8 @@ export function relocateDescriptor(descriptor, nodes, options) {
146
154
  * matches. The recorder uses the same projection to notice, before it writes a
147
155
  * target, that the description alone would not tell the target from its twins.
148
156
  */
149
- function matchingIds(descriptor, nodes, options) {
150
- const candidates = describeNodes(nodes, options);
157
+ function matchingIds(descriptor, nodes) {
158
+ const candidates = describeNodes(nodes);
151
159
  // A recorded container key must hold: the same "Delete" in another row is
152
160
  // a different control. Checked against the tree the candidates came from,
153
161
  // never guessed.
@@ -155,7 +163,7 @@ function matchingIds(descriptor, nodes, options) {
155
163
  ? candidates
156
164
  : (() => {
157
165
  const parents = parentsOf(nodes);
158
- return candidates.filter((candidate) => containerKey(candidate.id, nodes, parents, options.redact) === descriptor.within);
166
+ return candidates.filter((candidate) => containerKey(candidate.id, nodes, parents) === descriptor.within);
159
167
  })();
160
168
  for (const tier of descriptorTiers(descriptor)) {
161
169
  const matches = tierMatches(tier, keyed);
@@ -183,8 +191,8 @@ const MAX_POSITIONED_TWINS = 1000;
183
191
  * alone. Recorded with the target so a replay can tell one "Set up" button per
184
192
  * card apart without an app change; a distinct label makes it unnecessary.
185
193
  */
186
- export function describePosition(node, within, nodes, options) {
187
- const described = describeTarget(node, options.redact);
194
+ export function describePosition(node, within, nodes) {
195
+ const described = describeTarget(node);
188
196
  if (described === undefined || (described.role === undefined && isAnonymous(described)))
189
197
  return undefined;
190
198
  // An anonymous target has no identity to match alone; its position is what
@@ -192,7 +200,7 @@ export function describePosition(node, within, nodes, options) {
192
200
  // it is the only one, and described with a placeholder position to do so.
193
201
  const anonymous = isAnonymous(described);
194
202
  const probe = { ...described, ...(within === undefined ? {} : { within }), ...(anonymous ? { position: { index: 0, of: 1 } } : {}) };
195
- const ids = matchingIds(probe, nodes, options);
203
+ const ids = matchingIds(probe, nodes);
196
204
  if (ids.length < (anonymous ? 1 : 2) || ids.length > MAX_POSITIONED_TWINS)
197
205
  return undefined;
198
206
  const index = ids.indexOf(node.ref.id);
@@ -4,43 +4,42 @@
4
4
  * The replay precondition asks "is the app on the screen this recording
5
5
  * began on", and the postcondition "did we land where the recording landed".
6
6
  * A URL answers neither directly: it carries the id the app minted for this
7
- * run's record, the slug of the record the test happened to open, a search
8
- * term in the query, a tracking parameter, a fragment. All of that changes
9
- * between two visits to the same screen, and none of it is the screen.
7
+ * run's record, a cache buster, a session token, a fragment. All of that
8
+ * changes between two visits to the same screen, and none of it is the
9
+ * screen.
10
10
  *
11
- * So a location is reduced to its route: the pathname's segments, with every
12
- * segment that looks minted per record (a uuid, a hex or digit run, a long
13
- * token mixing letters and digits, text with whitespace) replaced by `:id`;
14
- * the query and the fragment dropped, except that an app routing in the
15
- * fragment (`#/companies/1`) routes by that path. A value the call marked
16
- * with `unique()` needs no rule here: the recording spells it as a
11
+ * So a location is reduced to its route: the origin it is on, the pathname's
12
+ * segments, and the query's parameters, with every segment or parameter
13
+ * value that looks minted per record (a uuid, a hex or digit run, a long
14
+ * token mixing letters and digits, a prefixed record id, text with
15
+ * whitespace) replaced by `:id`.
16
+ * Every other query value is part of the screen: `?mode=unsafe` is not
17
+ * `?mode=safe`, and `?tab=notes` shows other controls than no tab. The
18
+ * fragment is dropped, except that an app routing in the fragment
19
+ * (`#/companies/1`) routes by that path and its query. A value the call
20
+ * marked with `unique()` needs no rule here: the recording spells it as a
17
21
  * placeholder in every spelling a URL gives it, its slug included
18
22
  * (`cache/template.ts`), and replay fills in this run's value on both sides.
19
23
  *
20
- * Two routes that still differ in exactly one segment are `undecided`: a slug
21
- * of a record the runner never saw the value of reads as two literals, and
22
- * only the screen can say whether they are the same page. At the end of a
23
- * step the recorded anchors can, because they are the step's own effect. At
24
- * the start nothing recorded is specific to the screen (the controls a start
25
- * screen shows are mostly the app's header and sidebar, shared by every
26
- * screen), so an undecided start is a miss. A device engine reports a screen
27
- * title in place of a URL, which is already a route and compares as itself.
24
+ * The origin is relative to the app (`appLocation`): a location on the app's
25
+ * own origin is kept as its path, so a recording made against one deployment
26
+ * of the app replays on another that shares its `app.identity`, while a
27
+ * screen on any other origin keeps its origin and is another route.
28
+ *
29
+ * Two routes are the same screen only when they read alike. A literal that
30
+ * differs, a slug the runner never saw the value of, is another screen at
31
+ * both ends of a step: nothing recorded can tell a record's slug from a
32
+ * route word, and a screen that looks alike on another route (a settings
33
+ * page and a profile page sharing one layout) is exactly where a wrong link
34
+ * lands. A device engine reports a screen title in place of a URL, which is
35
+ * already a route and compares as itself.
28
36
  */
29
- export type Route = {
30
- readonly kind: 'url';
31
- readonly segments: readonly string[];
32
- } | {
33
- readonly kind: 'opaque';
34
- readonly location: string;
35
- };
36
- export type RouteVerdict = 'same' | 'different' | 'undecided';
37
- /** The route of a location. */
38
- export declare function routeOf(location: string): Route;
39
- /** A route as one line, for tests and diagnostics: `/companies/:id`, or the opaque location. */
40
- export declare function routeKey(route: Route): string;
41
37
  /**
42
- * Whether two routes are the same screen: `same` when they read alike,
43
- * `undecided` when routes of one depth differ in exactly one segment, which
44
- * the screen must settle, and `different` otherwise.
38
+ * A location as the cache compares it: a URL on the app's own origin as its
39
+ * path, query, and fragment, so a recording follows the app to another
40
+ * deployment of it; anything else (another origin, a device screen title)
41
+ * as it is.
45
42
  */
46
- export declare function compareRoutes(recorded: Route, live: Route): RouteVerdict;
43
+ export declare function appLocation(location: string, appOrigin: string | undefined): string;
44
+ /** Whether two locations name the same screen: one origin, and segments and query that read alike. */
45
+ export declare function sameRoute(recorded: string, live: string): boolean;
@@ -4,43 +4,66 @@
4
4
  * The replay precondition asks "is the app on the screen this recording
5
5
  * began on", and the postcondition "did we land where the recording landed".
6
6
  * A URL answers neither directly: it carries the id the app minted for this
7
- * run's record, the slug of the record the test happened to open, a search
8
- * term in the query, a tracking parameter, a fragment. All of that changes
9
- * between two visits to the same screen, and none of it is the screen.
7
+ * run's record, a cache buster, a session token, a fragment. All of that
8
+ * changes between two visits to the same screen, and none of it is the
9
+ * screen.
10
10
  *
11
- * So a location is reduced to its route: the pathname's segments, with every
12
- * segment that looks minted per record (a uuid, a hex or digit run, a long
13
- * token mixing letters and digits, text with whitespace) replaced by `:id`;
14
- * the query and the fragment dropped, except that an app routing in the
15
- * fragment (`#/companies/1`) routes by that path. A value the call marked
16
- * with `unique()` needs no rule here: the recording spells it as a
11
+ * So a location is reduced to its route: the origin it is on, the pathname's
12
+ * segments, and the query's parameters, with every segment or parameter
13
+ * value that looks minted per record (a uuid, a hex or digit run, a long
14
+ * token mixing letters and digits, a prefixed record id, text with
15
+ * whitespace) replaced by `:id`.
16
+ * Every other query value is part of the screen: `?mode=unsafe` is not
17
+ * `?mode=safe`, and `?tab=notes` shows other controls than no tab. The
18
+ * fragment is dropped, except that an app routing in the fragment
19
+ * (`#/companies/1`) routes by that path and its query. A value the call
20
+ * marked with `unique()` needs no rule here: the recording spells it as a
17
21
  * placeholder in every spelling a URL gives it, its slug included
18
22
  * (`cache/template.ts`), and replay fills in this run's value on both sides.
19
23
  *
20
- * Two routes that still differ in exactly one segment are `undecided`: a slug
21
- * of a record the runner never saw the value of reads as two literals, and
22
- * only the screen can say whether they are the same page. At the end of a
23
- * step the recorded anchors can, because they are the step's own effect. At
24
- * the start nothing recorded is specific to the screen (the controls a start
25
- * screen shows are mostly the app's header and sidebar, shared by every
26
- * screen), so an undecided start is a miss. A device engine reports a screen
27
- * title in place of a URL, which is already a route and compares as itself.
24
+ * The origin is relative to the app (`appLocation`): a location on the app's
25
+ * own origin is kept as its path, so a recording made against one deployment
26
+ * of the app replays on another that shares its `app.identity`, while a
27
+ * screen on any other origin keeps its origin and is another route.
28
+ *
29
+ * Two routes are the same screen only when they read alike. A literal that
30
+ * differs, a slug the runner never saw the value of, is another screen at
31
+ * both ends of a step: nothing recorded can tell a record's slug from a
32
+ * route word, and a screen that looks alike on another route (a settings
33
+ * page and a profile page sharing one layout) is exactly where a wrong link
34
+ * lands. A device engine reports a screen title in place of a URL, which is
35
+ * already a route and compares as itself.
28
36
  */
29
37
  const PARAM = ':id';
30
38
  /**
31
39
  * A segment shaped like a value the app mints per record: a uuid, a hex or
32
40
  * digit run, a long token carrying letters and at least two digits (a route
33
- * word such as `companies-v2` has one), or text with whitespace, which no
34
- * router puts in a path. A capitalized or dashed word is none of these; two
35
- * of them differing is left to the screen.
41
+ * word such as `companies-v2` has one), a prefixed record id (letters, a
42
+ * dash, then a digit tail of two or more: `PROJ-016`, `INV-2041`; a single
43
+ * digit as in `page-2` is a route word), or text with whitespace, which no
44
+ * router puts in a path. Any other capitalized or dashed word is none of
45
+ * these.
36
46
  */
37
47
  const MINTED_SEGMENT = [
38
48
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
39
49
  /^[0-9a-f]{8,}$/i,
40
50
  /^\d+$/,
41
51
  /^(?=(?:[^\d]*\d){2})(?=.*[A-Za-z])[A-Za-z0-9_-]{12,}$/,
52
+ /^[A-Za-z]+-\d{2,}$/,
42
53
  /\s/,
43
54
  ];
55
+ /**
56
+ * A query value is minted on the same rules, with the wider alphabet a
57
+ * value carries that a path segment cannot: a signed token or a timestamp
58
+ * (`eyJhbGciOi.x1.y2`, `2026-10-01T10:00:00Z`) has dots, colons, and base64,
59
+ * and a date (`?from=2026-10-01`, a default an app fills in with today) has
60
+ * no letter at all.
61
+ */
62
+ const MINTED_VALUE = [
63
+ ...MINTED_SEGMENT,
64
+ /^(?=(?:[^\d]*\d){2})(?=.*[A-Za-z])[\w.~+/=:-]{12,}$/,
65
+ /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/,
66
+ ];
44
67
  function decodeSegment(raw) {
45
68
  try {
46
69
  return decodeURIComponent(raw);
@@ -50,15 +73,30 @@ function decodeSegment(raw) {
50
73
  }
51
74
  }
52
75
  /**
53
- * The pathname a location routes by, or undefined when the location is not
54
- * a web address (a device engine's screen title, a browser's own page).
76
+ * A location as the cache compares it: a URL on the app's own origin as its
77
+ * path, query, and fragment, so a recording follows the app to another
78
+ * deployment of it; anything else (another origin, a device screen title)
79
+ * as it is.
80
+ */
81
+ export function appLocation(location, appOrigin) {
82
+ if (appOrigin === undefined || !URL.canParse(location))
83
+ return location;
84
+ const url = new URL(location);
85
+ return url.origin === appOrigin ? `${url.pathname}${url.search}${url.hash}` : location;
86
+ }
87
+ /**
88
+ * The parts of a location its route is made of, or undefined when the
89
+ * location is not a web address (a device engine's screen title, a
90
+ * browser's own page).
55
91
  */
56
- function pathnameOf(location) {
92
+ function locationParts(location) {
93
+ let origin;
57
94
  let rest = location;
58
95
  if (URL.canParse(location)) {
59
96
  const url = new URL(location);
60
97
  if (url.protocol !== 'http:' && url.protocol !== 'https:')
61
98
  return undefined;
99
+ origin = url.origin;
62
100
  rest = `${url.pathname}${url.search}${url.hash}`;
63
101
  }
64
102
  else if (!location.startsWith('/')) {
@@ -66,42 +104,48 @@ function pathnameOf(location) {
66
104
  }
67
105
  const hashAt = rest.indexOf('#');
68
106
  const fragment = hashAt === -1 ? '' : rest.slice(hashAt + 1);
69
- const pathname = fragment.startsWith('/') ? fragment : hashAt === -1 ? rest : rest.slice(0, hashAt);
70
- return pathname.split('?')[0] ?? '';
107
+ const [documentPath, documentSearch] = splitOnce(hashAt === -1 ? rest : rest.slice(0, hashAt), '?');
108
+ if (!fragment.startsWith('/'))
109
+ return { ...(origin === undefined ? {} : { origin }), pathname: documentPath, search: documentSearch };
110
+ const [fragmentPath, fragmentSearch] = splitOnce(fragment, '?');
111
+ return {
112
+ ...(origin === undefined ? {} : { origin }),
113
+ pathname: fragmentPath,
114
+ search: [documentSearch, fragmentSearch].filter((part) => part !== '').join('&'),
115
+ };
116
+ }
117
+ function splitOnce(text, separator) {
118
+ const at = text.indexOf(separator);
119
+ return at === -1 ? [text, ''] : [text.slice(0, at), text.slice(at + 1)];
120
+ }
121
+ /** A query's parameters as route terms, `key=value` with a minted key or value as `:id`, sorted. */
122
+ function queryTerms(search) {
123
+ const terms = [];
124
+ for (const [key, value] of new URLSearchParams(search)) {
125
+ const minted = (text) => text !== '' && MINTED_VALUE.some((pattern) => pattern.test(text));
126
+ terms.push(`${minted(key) ? PARAM : key}=${minted(value) ? PARAM : value}`);
127
+ }
128
+ return terms.toSorted();
71
129
  }
72
130
  /** The route of a location. */
73
- export function routeOf(location) {
74
- const pathname = pathnameOf(location);
75
- if (pathname === undefined)
131
+ function routeOf(location) {
132
+ const parts = locationParts(location);
133
+ if (parts === undefined)
76
134
  return { kind: 'opaque', location };
77
- const segments = pathname
135
+ const segments = parts.pathname
78
136
  .split('/')
79
137
  .filter((raw) => raw !== '')
80
138
  .map((raw) => {
81
139
  const text = decodeSegment(raw);
82
140
  return MINTED_SEGMENT.some((pattern) => pattern.test(text)) ? PARAM : text;
83
141
  });
84
- return { kind: 'url', segments };
142
+ return { kind: 'url', ...(parts.origin === undefined ? {} : { origin: parts.origin }), segments, query: queryTerms(parts.search) };
85
143
  }
86
- /** A route as one line, for tests and diagnostics: `/companies/:id`, or the opaque location. */
87
- export function routeKey(route) {
88
- return route.kind === 'url' ? `/${route.segments.join('/')}` : route.location;
144
+ /** Whether two locations name the same screen: one origin, and segments and query that read alike. */
145
+ export function sameRoute(recorded, live) {
146
+ return routeIdentity(routeOf(recorded)) === routeIdentity(routeOf(live));
89
147
  }
90
- /**
91
- * Whether two routes are the same screen: `same` when they read alike,
92
- * `undecided` when routes of one depth differ in exactly one segment, which
93
- * the screen must settle, and `different` otherwise.
94
- */
95
- export function compareRoutes(recorded, live) {
96
- if (recorded.kind === 'opaque' || live.kind === 'opaque') {
97
- return recorded.kind === 'opaque' && live.kind === 'opaque' && recorded.location === live.location ? 'same' : 'different';
98
- }
99
- if (recorded.segments.length !== live.segments.length)
100
- return 'different';
101
- let differing = 0;
102
- for (const [index, segment] of recorded.segments.entries()) {
103
- if (segment !== live.segments[index])
104
- differing += 1;
105
- }
106
- return differing === 0 ? 'same' : differing === 1 ? 'undecided' : 'different';
148
+ /** A route's parts as one comparable string, a slash inside a segment kept inside it. */
149
+ function routeIdentity(route) {
150
+ return JSON.stringify(route.kind === 'opaque' ? [route.location] : [route.origin ?? null, route.segments, route.query]);
107
151
  }
@@ -59,6 +59,12 @@ export declare class FileCacheStore implements CacheStore {
59
59
  bytes: number;
60
60
  } | undefined>;
61
61
  delete(keyHash: string): Promise<void>;
62
+ /**
63
+ * The key hashes of the digest-named files in the directory, readable or
64
+ * not; none when the directory does not exist. Not part of `CacheStore`:
65
+ * only `cache.strict` lists a store, and only this one.
66
+ */
67
+ keyHashes(): Promise<string[]>;
62
68
  /**
63
69
  * Resolves one entry path. The key hash is the only caller-supplied path
64
70
  * component and must be a bare digest, so traversal is impossible by
@@ -13,7 +13,7 @@
13
13
  * The interface exists so a remote store — the cloud's shared cache — can
14
14
  * replace the filesystem without the harness noticing.
15
15
  */
16
- import { mkdir, readFile, stat, unlink } from 'node:fs/promises';
16
+ import { mkdir, readdir, readFile, stat, unlink } from 'node:fs/promises';
17
17
  import { join } from 'node:path';
18
18
  import { writeFileAtomic } from '../internal/atomic-write.js';
19
19
  import { errorMessage } from '../internal/errors.js';
@@ -83,6 +83,26 @@ export class FileCacheStore {
83
83
  return;
84
84
  await unlink(path).catch(() => undefined);
85
85
  }
86
+ /**
87
+ * The key hashes of the digest-named files in the directory, readable or
88
+ * not; none when the directory does not exist. Not part of `CacheStore`:
89
+ * only `cache.strict` lists a store, and only this one.
90
+ */
91
+ async keyHashes() {
92
+ let names;
93
+ try {
94
+ names = await readdir(this.directory);
95
+ }
96
+ catch (cause) {
97
+ if (cause.code === 'ENOENT')
98
+ return [];
99
+ throw cause;
100
+ }
101
+ return names.flatMap((name) => {
102
+ const keyHash = name.endsWith('.json') ? name.slice(0, -'.json'.length) : undefined;
103
+ return keyHash !== undefined && KEY_HASH.test(keyHash) ? [keyHash] : [];
104
+ });
105
+ }
86
106
  /**
87
107
  * Resolves one entry path. The key hash is the only caller-supplied path
88
108
  * component and must be a bare digest, so traversal is impossible by
@@ -32,26 +32,36 @@ export declare const MAX_TRACE_INPUT_CHARS = 4096;
32
32
  /** Caps prose at `maxChars`, marking the cut with an ellipsis. */
33
33
  export declare function bound(text: string, maxChars: number): string;
34
34
  /**
35
- * Cap on recorded end anchors. Anchors are the step's own delta — what
36
- * appeared on screen between the first observation and the passing one — so
37
- * a same-screen mutation rarely has more than a handful; a step that changes
38
- * the whole screen keeps the first few in document order.
35
+ * Cap on recorded anchors, per side of the delta. Anchors are the step's own
36
+ * delta, what appeared on screen between the first observation and the
37
+ * passing one and what vanished, so a same-screen mutation rarely has more
38
+ * than a handful; a step that changes the whole screen keeps the first few,
39
+ * announcements first (`cache/anchors.ts`).
39
40
  */
40
41
  export declare const MAX_TRACE_ANCHORS = 8;
41
42
  /** Longest a replay waits for the recorded end state to return. */
42
43
  export declare const MAX_TRACE_END_WAIT_MS = 120000;
43
44
  /**
44
45
  * What a trace was recorded for, in the terms a person uses: the test, the
45
- * target, and the digest of the instruction. None of it is replay input —
46
- * every one of these fields is already in the key, so a mismatch is a miss
47
- * before an entry is ever read — and none of it is trusted as such. It exists
48
- * so a file named after a digest can say which test it belongs to.
46
+ * target, the digest of the instruction, and, on entries recorded since they
47
+ * were added, the rest of what names the step within its test. None of it is
48
+ * replay input — every one of these fields is already in the key, so a
49
+ * mismatch is a miss before an entry is ever read — and none of it is trusted
50
+ * as such. It exists so a file named after a digest can say which step it
51
+ * belongs to, and so `cache.strict` can tell a step whose key changed under
52
+ * its recording from a step that was never recorded.
49
53
  */
50
54
  export interface TraceProvenance {
51
55
  readonly testId: string;
52
56
  readonly targetId: string;
53
57
  /** SHA-256 of the normalized instruction (`cache/identity.ts`). */
54
58
  readonly instructionDigest: string;
59
+ /** SHA-256/JCS of the step's params as the key digests them, a placeholder for each `unique()` value. */
60
+ readonly paramsDigest?: string;
61
+ /** Zero-based occurrence of the step among its repeats in the attempt. */
62
+ readonly callIndex?: number;
63
+ /** Name of the configured agent the step ran with. */
64
+ readonly agent?: string;
55
65
  }
56
66
  /**
57
67
  * How a recorded action addressed its node, independent of observation ids.
@@ -82,7 +92,23 @@ export interface TraceTargetDescriptor {
82
92
  * twin that appeared or vanished still diverges instead of guessing.
83
93
  */
84
94
  readonly position?: TracePosition;
95
+ /**
96
+ * An anchor's states that were on, sorted; anchors only. A switch turned
97
+ * on is the same control before and after, so its state is what tells
98
+ * the step's effect from a tap that did nothing.
99
+ */
100
+ readonly states?: readonly TraceAnchorState[];
101
+ /**
102
+ * An anchor's current input value, redacted and bounded like its text;
103
+ * anchors only, and never a secure field's, which is never observed. A
104
+ * field typed into or an option selected is the same control before and
105
+ * after, so its value is the step's effect.
106
+ */
107
+ readonly value?: string;
85
108
  }
109
+ /** The node states an anchor records: the ones a step sets on a control it leaves on screen. */
110
+ export declare const TRACE_ANCHOR_STATES: readonly ['checked', 'expanded', 'pressed', 'selected'];
111
+ export type TraceAnchorState = (typeof TRACE_ANCHOR_STATES)[number];
86
112
  interface ActionBase {
87
113
  /** One-line prose summary — the only view a mid-step hand-off notice shows. */
88
114
  readonly summary: string;
@@ -245,7 +271,7 @@ export interface ToolGapAction extends ActionBase {
245
271
  export type RecordedAction = NodeAction | TypeAction | TypeSecretAction | PressAction | SelectAction | CheckAction | UploadAction | DragAction | ScrollAction | ScrollUntilAction | NavigateAction | BackAction | PointAction | TypeTextAction | PressKeyAction | DismissKeyboardAction | ToolGapAction;
246
272
  export interface ActionTrace {
247
273
  readonly actions: readonly RecordedAction[];
248
- /** Executor that produced the trace — provenance, never part of the key (OQ10). */
274
+ /** Executor that produced the trace: provenance, never part of the key, which names the agent instead. */
249
275
  readonly executor: {
250
276
  readonly name: string;
251
277
  readonly version?: string;
@@ -254,12 +280,17 @@ export interface ActionTrace {
254
280
  readonly recordedFor?: TraceProvenance;
255
281
  /** The recorded run's verdict summary. */
256
282
  readonly summary: string;
257
- /** Location path when the step began; a precondition unless the trace opens with navigate. */
283
+ /**
284
+ * Location when the step began, as the cache compares it (`appLocation`):
285
+ * a path on the app's own origin, else the whole location. A precondition
286
+ * unless the trace opens with navigate.
287
+ */
258
288
  readonly startPath?: string;
259
289
  /**
260
- * Location path when the step passed — the trace's deterministic postcondition.
261
- * A full replay self-finalizes only while the live pathname still matches;
262
- * a recorded flow whose destination changed hands off instead of passing.
290
+ * Location when the step passed, spelled like `startPath`: the trace's
291
+ * deterministic postcondition. A full replay self-finalizes only while
292
+ * the live route still matches; a recorded flow whose destination changed
293
+ * hands off instead of passing.
263
294
  */
264
295
  readonly endPath?: string;
265
296
  /**
@@ -271,6 +302,14 @@ export interface ActionTrace {
271
302
  * instead of passing on mechanics alone.
272
303
  */
273
304
  readonly endAnchors?: readonly TraceTargetDescriptor[];
305
+ /**
306
+ * Descriptors of nodes that were on screen when the step began and were
307
+ * gone when it passed: a delete, a dismissed dialog, a cleared draft. A
308
+ * full replay self-finalizes only while every one of them is gone again,
309
+ * so a removal that did not happen hands off instead of passing with no
310
+ * anchor to check.
311
+ */
312
+ readonly goneAnchors?: readonly TraceTargetDescriptor[];
274
313
  /**
275
314
  * How long the recorded run took from its first action to its passing
276
315
  * verdict, plus a margin. A replay waits up to this long for the anchors to
@@ -295,7 +334,7 @@ export declare function buildTraceEntry(payload: ActionTrace): TraceEntry;
295
334
  */
296
335
  export declare function readTraceEntry(document: unknown): TraceEntry | undefined;
297
336
  /** Every field of a target descriptor; two descriptors equal on all of them name the same target. */
298
- export declare const DESCRIPTOR_FIELDS: readonly ['role', 'name', 'text', 'testId', 'placeholder', 'selector', 'inputPurpose', 'within'];
337
+ export declare const DESCRIPTOR_FIELDS: readonly ['role', 'name', 'text', 'testId', 'placeholder', 'selector', 'inputPurpose', 'within', 'value'];
299
338
  /** A string rewrite; `undefined` means the text cannot be rewritten and the whole trace is unusable. */
300
339
  export type TraceTextMap = (text: string) => string | undefined;
301
340
  /**