@rehearsal-labs/retest 0.1.0 → 0.2.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 (315) hide show
  1. package/README.md +6 -0
  2. package/dist/agent/session.d.ts +21 -2
  3. package/dist/agent/session.d.ts.map +1 -1
  4. package/dist/agent/session.js +73 -11
  5. package/dist/api/app-page.d.ts +39 -3
  6. package/dist/api/app-page.d.ts.map +1 -1
  7. package/dist/api/app-page.js +108 -0
  8. package/dist/api/dialog.d.ts +38 -0
  9. package/dist/api/dialog.d.ts.map +1 -0
  10. package/dist/api/dialog.js +78 -0
  11. package/dist/api/drag.d.ts +32 -0
  12. package/dist/api/drag.d.ts.map +1 -0
  13. package/dist/api/drag.js +26 -0
  14. package/dist/api/files.d.ts +49 -0
  15. package/dist/api/files.d.ts.map +1 -0
  16. package/dist/api/files.js +168 -0
  17. package/dist/api/inbox.d.ts +97 -0
  18. package/dist/api/inbox.d.ts.map +1 -0
  19. package/dist/api/inbox.js +191 -0
  20. package/dist/api/locator-recipes.d.ts +1 -1
  21. package/dist/api/locator-recipes.d.ts.map +1 -1
  22. package/dist/api/locator-recipes.js +4 -3
  23. package/dist/api/page.d.ts +214 -4
  24. package/dist/api/page.d.ts.map +1 -1
  25. package/dist/api/route.d.ts +21 -0
  26. package/dist/api/route.d.ts.map +1 -0
  27. package/dist/api/route.js +99 -0
  28. package/dist/api/test-run.d.ts +127 -1
  29. package/dist/api/test-run.d.ts.map +1 -1
  30. package/dist/api/test-run.js +293 -0
  31. package/dist/assertions/expect.d.ts +91 -2
  32. package/dist/assertions/expect.d.ts.map +1 -1
  33. package/dist/assertions/expect.js +98 -0
  34. package/dist/assertions/look-until.d.ts +5 -0
  35. package/dist/assertions/look-until.d.ts.map +1 -1
  36. package/dist/assertions/look-until.js +1 -1
  37. package/dist/assertions/poll-layout.d.ts +24 -0
  38. package/dist/assertions/poll-layout.d.ts.map +1 -0
  39. package/dist/assertions/poll-layout.js +63 -0
  40. package/dist/assertions/screenshot.d.ts +36 -0
  41. package/dist/assertions/screenshot.d.ts.map +1 -0
  42. package/dist/assertions/screenshot.js +125 -0
  43. package/dist/browser/accessibility.d.ts.map +1 -1
  44. package/dist/browser/accessibility.js +22 -1
  45. package/dist/browser/actionability.d.ts +9 -6
  46. package/dist/browser/actionability.d.ts.map +1 -1
  47. package/dist/browser/actionability.js +17 -8
  48. package/dist/browser/browser.d.ts.map +1 -1
  49. package/dist/browser/browser.js +15 -4
  50. package/dist/browser/cdp/connection.d.ts +6 -0
  51. package/dist/browser/cdp/connection.d.ts.map +1 -1
  52. package/dist/browser/cdp/connection.js +8 -0
  53. package/dist/browser/checked-state.d.ts +2 -2
  54. package/dist/browser/checked-state.d.ts.map +1 -1
  55. package/dist/browser/chromium-process.d.ts +9 -2
  56. package/dist/browser/chromium-process.d.ts.map +1 -1
  57. package/dist/browser/chromium-process.js +17 -4
  58. package/dist/browser/contract.d.ts +119 -5
  59. package/dist/browser/contract.d.ts.map +1 -1
  60. package/dist/browser/dialogs.d.ts +157 -0
  61. package/dist/browser/dialogs.d.ts.map +1 -0
  62. package/dist/browser/dialogs.js +271 -0
  63. package/dist/browser/downloads.d.ts +71 -0
  64. package/dist/browser/downloads.d.ts.map +1 -0
  65. package/dist/browser/downloads.js +253 -0
  66. package/dist/browser/drag.d.ts +50 -0
  67. package/dist/browser/drag.d.ts.map +1 -0
  68. package/dist/browser/drag.js +584 -0
  69. package/dist/browser/electron-window-watch.d.ts +32 -0
  70. package/dist/browser/electron-window-watch.d.ts.map +1 -0
  71. package/dist/browser/electron-window-watch.js +93 -0
  72. package/dist/browser/electron.d.ts +13 -2
  73. package/dist/browser/electron.d.ts.map +1 -1
  74. package/dist/browser/electron.js +53 -8
  75. package/dist/browser/element-queries.d.ts +51 -15
  76. package/dist/browser/element-queries.d.ts.map +1 -1
  77. package/dist/browser/element-queries.js +92 -13
  78. package/dist/browser/emulation.d.ts.map +1 -1
  79. package/dist/browser/emulation.js +7 -5
  80. package/dist/browser/file-chooser.d.ts +75 -0
  81. package/dist/browser/file-chooser.d.ts.map +1 -0
  82. package/dist/browser/file-chooser.js +449 -0
  83. package/dist/browser/firefox/input.d.ts +5 -0
  84. package/dist/browser/firefox/input.d.ts.map +1 -1
  85. package/dist/browser/firefox/input.js +6 -3
  86. package/dist/browser/firefox/page.d.ts +11 -1
  87. package/dist/browser/firefox/page.d.ts.map +1 -1
  88. package/dist/browser/firefox/page.js +66 -7
  89. package/dist/browser/frames.d.ts +205 -0
  90. package/dist/browser/frames.d.ts.map +1 -0
  91. package/dist/browser/frames.js +657 -0
  92. package/dist/browser/input-guard.d.ts +18 -5
  93. package/dist/browser/input-guard.d.ts.map +1 -1
  94. package/dist/browser/input-guard.js +65 -6
  95. package/dist/browser/isolated-world.d.ts +8 -1
  96. package/dist/browser/isolated-world.d.ts.map +1 -1
  97. package/dist/browser/layout.d.ts +120 -0
  98. package/dist/browser/layout.d.ts.map +1 -0
  99. package/dist/browser/layout.js +213 -0
  100. package/dist/browser/locate.d.ts +10 -0
  101. package/dist/browser/locate.d.ts.map +1 -1
  102. package/dist/browser/locate.js +24 -6
  103. package/dist/browser/origin-document.d.ts +4 -0
  104. package/dist/browser/origin-document.d.ts.map +1 -1
  105. package/dist/browser/origin-document.js +9 -1
  106. package/dist/browser/page-scripts.d.ts +48 -0
  107. package/dist/browser/page-scripts.d.ts.map +1 -1
  108. package/dist/browser/page-scripts.js +515 -39
  109. package/dist/browser/page.d.ts +60 -1
  110. package/dist/browser/page.d.ts.map +1 -1
  111. package/dist/browser/page.js +275 -32
  112. package/dist/browser/popups.d.ts +166 -0
  113. package/dist/browser/popups.d.ts.map +1 -0
  114. package/dist/browser/popups.js +317 -0
  115. package/dist/browser/routes.d.ts +97 -0
  116. package/dist/browser/routes.d.ts.map +1 -0
  117. package/dist/browser/routes.js +226 -0
  118. package/dist/browser/screenshot.d.ts +44 -0
  119. package/dist/browser/screenshot.d.ts.map +1 -0
  120. package/dist/browser/screenshot.js +51 -0
  121. package/dist/browser/shadow-candidates.d.ts +20 -0
  122. package/dist/browser/shadow-candidates.d.ts.map +1 -0
  123. package/dist/browser/shadow-candidates.js +77 -0
  124. package/dist/browser/webkit/page.d.ts +11 -1
  125. package/dist/browser/webkit/page.d.ts.map +1 -1
  126. package/dist/browser/webkit/page.js +53 -2
  127. package/dist/browser/webkit/relay.d.ts.map +1 -1
  128. package/dist/browser/webkit/relay.js +5 -1
  129. package/dist/cli/commands/run.d.ts.map +1 -1
  130. package/dist/cli/commands/run.js +2 -0
  131. package/dist/cli/inspect/test-timeline.d.ts.map +1 -1
  132. package/dist/cli/inspect/test-timeline.js +38 -4
  133. package/dist/config/loaded.d.ts +12 -2
  134. package/dist/config/loaded.d.ts.map +1 -1
  135. package/dist/config/read-inboxes.d.ts +10 -0
  136. package/dist/config/read-inboxes.d.ts.map +1 -0
  137. package/dist/config/read-inboxes.js +78 -0
  138. package/dist/config/register.d.ts +5 -1
  139. package/dist/config/register.d.ts.map +1 -1
  140. package/dist/config/types.d.ts +36 -0
  141. package/dist/config/types.d.ts.map +1 -1
  142. package/dist/config/validate.d.ts.map +1 -1
  143. package/dist/config/validate.js +4 -1
  144. package/dist/diagnostics/artifact.d.ts.map +1 -1
  145. package/dist/diagnostics/artifact.js +10 -2
  146. package/dist/diagnostics/attempt.d.ts +5 -0
  147. package/dist/diagnostics/attempt.d.ts.map +1 -1
  148. package/dist/diagnostics/attempt.js +10 -0
  149. package/dist/diagnostics/chromium-collector.d.ts +9 -1
  150. package/dist/diagnostics/chromium-collector.d.ts.map +1 -1
  151. package/dist/diagnostics/chromium-collector.js +19 -4
  152. package/dist/diagnostics/observations.d.ts +8 -0
  153. package/dist/diagnostics/observations.d.ts.map +1 -1
  154. package/dist/diagnostics/session-capture.d.ts.map +1 -1
  155. package/dist/diagnostics/session-capture.js +8 -0
  156. package/dist/index.d.ts +9 -4
  157. package/dist/index.d.ts.map +1 -1
  158. package/dist/index.js +1 -0
  159. package/dist/media/policy.d.ts +2 -2
  160. package/dist/media/policy.d.ts.map +1 -1
  161. package/dist/media/policy.js +1 -1
  162. package/dist/playwright/not-yet.d.ts.map +1 -1
  163. package/dist/playwright/not-yet.js +5 -2
  164. package/dist/protocol/commands.d.ts +122 -13
  165. package/dist/protocol/commands.d.ts.map +1 -1
  166. package/dist/protocol/commands.js +85 -6
  167. package/dist/protocol/diagnostics.d.ts +10 -0
  168. package/dist/protocol/diagnostics.d.ts.map +1 -1
  169. package/dist/protocol/diagnostics.js +5 -0
  170. package/dist/protocol/dialogs.d.ts +104 -0
  171. package/dist/protocol/dialogs.d.ts.map +1 -0
  172. package/dist/protocol/dialogs.js +101 -0
  173. package/dist/protocol/drag.d.ts +45 -0
  174. package/dist/protocol/drag.d.ts.map +1 -0
  175. package/dist/protocol/drag.js +31 -0
  176. package/dist/protocol/events.d.ts +153 -1
  177. package/dist/protocol/events.d.ts.map +1 -1
  178. package/dist/protocol/events.js +97 -0
  179. package/dist/protocol/files.d.ts +83 -0
  180. package/dist/protocol/files.d.ts.map +1 -0
  181. package/dist/protocol/files.js +68 -0
  182. package/dist/protocol/inbox.d.ts +87 -0
  183. package/dist/protocol/inbox.d.ts.map +1 -0
  184. package/dist/protocol/inbox.js +51 -0
  185. package/dist/protocol/index.d.ts +4 -1
  186. package/dist/protocol/index.d.ts.map +1 -1
  187. package/dist/protocol/layout-checks.d.ts +83 -0
  188. package/dist/protocol/layout-checks.d.ts.map +1 -0
  189. package/dist/protocol/layout-checks.js +214 -0
  190. package/dist/protocol/layout.d.ts +135 -0
  191. package/dist/protocol/layout.d.ts.map +1 -0
  192. package/dist/protocol/layout.js +124 -0
  193. package/dist/protocol/locator-checks.d.ts +19 -2
  194. package/dist/protocol/locator-checks.d.ts.map +1 -1
  195. package/dist/protocol/locator-checks.js +23 -1
  196. package/dist/protocol/locator.d.ts +55 -7
  197. package/dist/protocol/locator.d.ts.map +1 -1
  198. package/dist/protocol/locator.js +99 -18
  199. package/dist/protocol/messages.d.ts +95 -0
  200. package/dist/protocol/messages.d.ts.map +1 -1
  201. package/dist/protocol/messages.js +63 -0
  202. package/dist/protocol/popups.d.ts +43 -0
  203. package/dist/protocol/popups.d.ts.map +1 -0
  204. package/dist/protocol/popups.js +27 -0
  205. package/dist/protocol/result.d.ts +5 -0
  206. package/dist/protocol/result.d.ts.map +1 -1
  207. package/dist/protocol/result.js +1 -0
  208. package/dist/protocol/routes.d.ts +136 -0
  209. package/dist/protocol/routes.d.ts.map +1 -0
  210. package/dist/protocol/routes.js +191 -0
  211. package/dist/protocol/screenshot.d.ts +219 -0
  212. package/dist/protocol/screenshot.d.ts.map +1 -0
  213. package/dist/protocol/screenshot.js +335 -0
  214. package/dist/reporters/actions.d.ts +6 -4
  215. package/dist/reporters/actions.d.ts.map +1 -1
  216. package/dist/reporters/actions.js +24 -6
  217. package/dist/reporters/agent.d.ts.map +1 -1
  218. package/dist/reporters/agent.js +4 -2
  219. package/dist/reporters/dialogs-popups.d.ts +22 -0
  220. package/dist/reporters/dialogs-popups.d.ts.map +1 -0
  221. package/dist/reporters/dialogs-popups.js +30 -0
  222. package/dist/reporters/failure-card.d.ts +10 -1
  223. package/dist/reporters/failure-card.d.ts.map +1 -1
  224. package/dist/reporters/failure-card.js +5 -1
  225. package/dist/reporters/format.d.ts +33 -0
  226. package/dist/reporters/format.d.ts.map +1 -1
  227. package/dist/reporters/format.js +42 -0
  228. package/dist/reporters/html/comparison-view.d.ts +12 -0
  229. package/dist/reporters/html/comparison-view.d.ts.map +1 -0
  230. package/dist/reporters/html/comparison-view.js +21 -0
  231. package/dist/reporters/html/diagnostics-view.js +8 -0
  232. package/dist/reporters/html/downloads-view.d.ts +10 -0
  233. package/dist/reporters/html/downloads-view.d.ts.map +1 -0
  234. package/dist/reporters/html/downloads-view.js +29 -0
  235. package/dist/reporters/html/style.d.ts.map +1 -1
  236. package/dist/reporters/html/style.js +3 -0
  237. package/dist/reporters/html/summary-view.d.ts.map +1 -1
  238. package/dist/reporters/html/summary-view.js +3 -2
  239. package/dist/reporters/html/test-view.d.ts.map +1 -1
  240. package/dist/reporters/html/test-view.js +6 -3
  241. package/dist/reporters/html/timeline-view.d.ts.map +1 -1
  242. package/dist/reporters/html/timeline-view.js +26 -2
  243. package/dist/reporters/human-card.js +10 -0
  244. package/dist/reporters/human.d.ts.map +1 -1
  245. package/dist/reporters/human.js +3 -1
  246. package/dist/reporters/routes.d.ts +9 -0
  247. package/dist/reporters/routes.d.ts.map +1 -0
  248. package/dist/reporters/routes.js +17 -0
  249. package/dist/runner/child-program.js +10 -0
  250. package/dist/runner/contract.d.ts +5 -0
  251. package/dist/runner/contract.d.ts.map +1 -1
  252. package/dist/runner/dialogs.d.ts +71 -0
  253. package/dist/runner/dialogs.d.ts.map +1 -0
  254. package/dist/runner/dialogs.js +189 -0
  255. package/dist/runner/files.d.ts +79 -0
  256. package/dist/runner/files.d.ts.map +1 -0
  257. package/dist/runner/files.js +312 -0
  258. package/dist/runner/inbox-links.d.ts +19 -0
  259. package/dist/runner/inbox-links.d.ts.map +1 -0
  260. package/dist/runner/inbox-links.js +63 -0
  261. package/dist/runner/inbox.d.ts +87 -0
  262. package/dist/runner/inbox.d.ts.map +1 -0
  263. package/dist/runner/inbox.js +340 -0
  264. package/dist/runner/index.d.ts +2 -1
  265. package/dist/runner/index.d.ts.map +1 -1
  266. package/dist/runner/native-pool.d.ts.map +1 -1
  267. package/dist/runner/native-pool.js +2 -0
  268. package/dist/runner/observations.d.ts +15 -2
  269. package/dist/runner/observations.d.ts.map +1 -1
  270. package/dist/runner/observations.js +109 -6
  271. package/dist/runner/popups.d.ts +82 -0
  272. package/dist/runner/popups.d.ts.map +1 -0
  273. package/dist/runner/popups.js +236 -0
  274. package/dist/runner/redactor.d.ts.map +1 -1
  275. package/dist/runner/redactor.js +10 -6
  276. package/dist/runner/routes.d.ts +23 -0
  277. package/dist/runner/routes.d.ts.map +1 -0
  278. package/dist/runner/routes.js +128 -0
  279. package/dist/runner/run-media.d.ts +6 -0
  280. package/dist/runner/run-media.d.ts.map +1 -1
  281. package/dist/runner/run-media.js +21 -0
  282. package/dist/runner/run-session.d.ts.map +1 -1
  283. package/dist/runner/run-session.js +93 -8
  284. package/dist/runner/running-test.d.ts +35 -0
  285. package/dist/runner/running-test.d.ts.map +1 -1
  286. package/dist/runner/running-test.js +333 -22
  287. package/dist/runner/screenshot-baselines.d.ts +159 -0
  288. package/dist/runner/screenshot-baselines.d.ts.map +1 -0
  289. package/dist/runner/screenshot-baselines.js +357 -0
  290. package/dist/runner/secrets.d.ts +13 -0
  291. package/dist/runner/secrets.d.ts.map +1 -1
  292. package/dist/runner/secrets.js +6 -4
  293. package/dist/runner/test-pages.d.ts +3 -1
  294. package/dist/runner/test-pages.d.ts.map +1 -1
  295. package/dist/runner/test-pages.js +4 -3
  296. package/dist/schemas/event-v1.schema.json +118873 -6311
  297. package/dist/schemas/result-v1.schema.json +4 -0
  298. package/dist/shared/image-diff.d.ts +36 -0
  299. package/dist/shared/image-diff.d.ts.map +1 -0
  300. package/dist/shared/image-diff.js +242 -0
  301. package/dist/shared/metadata-process.d.ts +4 -1
  302. package/dist/shared/metadata-process.d.ts.map +1 -1
  303. package/dist/shared/metadata-process.js +51 -9
  304. package/dist/shared/png.d.ts +35 -0
  305. package/dist/shared/png.d.ts.map +1 -0
  306. package/dist/shared/png.js +251 -0
  307. package/dist/shared/process-ownership.d.ts +15 -4
  308. package/dist/shared/process-ownership.d.ts.map +1 -1
  309. package/dist/shared/process-ownership.js +123 -34
  310. package/dist/store/artifacts.d.ts +23 -2
  311. package/dist/store/artifacts.d.ts.map +1 -1
  312. package/dist/store/artifacts.js +40 -1
  313. package/dist/store/rebuild-result.d.ts.map +1 -1
  314. package/dist/store/rebuild-result.js +6 -0
  315. package/package.json +1 -1
@@ -1,25 +1,37 @@
1
1
  import { unsupportedCheck } from "../native/assertions.js";
2
2
  import { describeElement } from "../native/locators.js";
3
- import { isPageCheck, locatorCheck } from "../protocol/locator-checks.js";
3
+ import { isLayoutCheck, isPageCheck, isScreenshotCheck, locatorCheck } from "../protocol/locator-checks.js";
4
4
  import { NativePageAdapter } from "./native-pool.js";
5
5
  import { refusedAnswer } from "../evaluation/attempt.js";
6
- import { describeCommand, isNavigationKind } from "../protocol/commands.js";
6
+ import { commandLocators, describeCommand, isNavigationKind } from "../protocol/commands.js";
7
7
  import { Deadline, elapsedMs, monotonicClock, smallestBudget } from "../protocol/deadline.js";
8
+ import { dragProblem } from "../protocol/drag.js";
8
9
  import { formatSessionId } from "../protocol/evidence.js";
9
10
  import { errorMessage, failure, withAlso, withLocation } from "../protocol/failures.js";
11
+ import { inputFilesProblem } from "../protocol/files.js";
12
+ import { withPickerAdvice } from "../browser/file-chooser.js";
13
+ import { noFramesInApp } from "../browser/frames.js";
10
14
  import { parseKey } from "../protocol/keys.js";
15
+ import { codePlaceholderName, linkName } from "../protocol/inbox.js";
11
16
  import { formatLine } from "../protocol/location.js";
12
17
  import { locatorProblem, locatorSteps } from "../protocol/locator.js";
13
18
  import { observedRecord } from "../protocol/observation-record.js";
14
19
  import { selectCommandProblem } from "../protocol/option-choices.js";
20
+ import { screenshotRequestProblem } from "../protocol/screenshot.js";
15
21
  import { scrollProblem } from "../protocol/scroll-delta.js";
16
22
  import { describeExit } from "../shared/process-exit.js";
17
23
  import { bounded } from "./bounded.js";
18
24
  import { cleanedLook, pageFields, ServedObservations } from "./observations.js";
19
25
  import { isOurs } from "./outcome.js";
20
26
  import { PageNavigations } from "./page-navigations.js";
27
+ import { TestDialogs, withDialogText } from "./dialogs.js";
28
+ import { TestPopups } from "./popups.js";
29
+ import { TestRoutes } from "./routes.js";
21
30
  /** How long a stopped test's process has to answer, and a page that lost its browser has to say so. */
22
31
  export const abortGraceMs = 1000;
32
+ // What a test file can do about a file picker nobody waited for, which the picker's failure ends with.
33
+ const testPickerAdvice = 'To give a picker files, wrap the action that opens it in page.fileChooser(), or call setInputFiles() on the file input.';
34
+ const playwrightPickerAdvice = "Retest's Playwright compatibility cannot give a page files yet.";
23
35
  // Commands that open a document, which the navigation budget times rather than the action budget.
24
36
  const navigationKinds = new Set(['goto', 'reload', 'goBack', 'goForward']);
25
37
  // The classes a poller gives a check whose time ran out on a look that fails it.
@@ -43,6 +55,19 @@ export class RunningTest {
43
55
  #inFlight = new Map();
44
56
  /** The AI checks running, by the id the test process gave each. */
45
57
  #evaluating = new Map();
58
+ /** The inbox reads running, by the id the test process gave each, each stopped by its controller. */
59
+ #inboxReads = new Map();
60
+ /** The test's JavaScript dialogs, and the pages its pages opened, which it was given as pages of its own, by app. */
61
+ #dialogs;
62
+ #popups;
63
+ /** The test's changes to its pages' routes, which answer requests with the test's own responses. */
64
+ #routes;
65
+ #popupPages = new Map();
66
+ /** The waits for a file picker or a download running, by the id the test process gave each, and those it let go of. */
67
+ #filesRequests = new Map();
68
+ #releasedRequests = new Set();
69
+ /** The pictures screenshot looks took, by the id the test process gave each look, until the look is served. */
70
+ #pictures = new Map();
46
71
  #finished = Promise.withResolvers();
47
72
  #deadline;
48
73
  #startedAt;
@@ -80,6 +105,34 @@ export class RunningTest {
80
105
  constructor(options) {
81
106
  this.#options = options;
82
107
  this.#observations = new ServedObservations(options.redactor);
108
+ const host = {
109
+ testId: options.testId,
110
+ attemptId: options.attemptId,
111
+ timeouts: options.timeouts,
112
+ page: (app) => options.pages.get(app) ?? this.#popupPages.get(app),
113
+ refusal: () => (this.#report === undefined ? this.#revocation : failure('usage', 'No test is running, so Retest sent nothing to the page.')),
114
+ remainingMs: () => this.#deadline?.remainingMs,
115
+ answerable: () => this.#report === undefined && this.#revocation === undefined,
116
+ commandsSent: () => this.#sent.size > 0,
117
+ stepId: () => this.#stepId,
118
+ send: (message) => options.process.send(message),
119
+ emit: (body) => options.emit(body),
120
+ observe: (problem) => {
121
+ if (this.#report === undefined && this.#revocation === undefined)
122
+ this.#observed.push(problem);
123
+ },
124
+ violation: (problem) => this.#violation(problem),
125
+ redact: (text) => options.redactor?.redact(text) ?? text,
126
+ };
127
+ this.#dialogs = new TestDialogs(host);
128
+ // A popup is one of the test's pages from the moment it opens: its navigations are written, its dialogs heard.
129
+ const adopt = (app, page, follow) => {
130
+ this.#popupPages.set(app, page);
131
+ this.#stopNavigation.push(follow((navigation) => this.#navigated(app, navigation)), this.#dialogs.watch(app, page));
132
+ this.#stopNavigation.push(this.#routes.watch(app, page));
133
+ };
134
+ this.#popups = new TestPopups({ ...host, adopt }, options.popups);
135
+ this.#routes = new TestRoutes(host);
83
136
  }
84
137
  /** Starts the body in the child and resolves when it is over, however it ends. A process that has ended gets no body. */
85
138
  run() {
@@ -89,6 +142,8 @@ export class RunningTest {
89
142
  return this.#finished.promise;
90
143
  }
91
144
  this.#stopNavigation = [...pages].map(([app, page]) => page.onNavigation((navigation) => this.#navigated(app, navigation)));
145
+ this.#stopNavigation.push(...[...pages].map(([app, page]) => this.#dialogs.watch(app, page)));
146
+ this.#stopNavigation.push(...[...pages].map(([app, page]) => this.#routes.watch(app, page)));
92
147
  process.listen((event) => this.#receive(event));
93
148
  process.send({ type: 'run', testId, attemptId, timeouts, apps: [...pages.keys()], ...(variant === undefined ? {} : { variant }) });
94
149
  this.#startedAt = monotonicClock();
@@ -112,6 +167,12 @@ export class RunningTest {
112
167
  this.#observed.push(reason);
113
168
  clearTimeout(this.#testTimer);
114
169
  this.#options.evaluations?.cancel(reason);
170
+ for (const read of this.#inboxReads.values())
171
+ read.abort(reason);
172
+ for (const request of this.#filesRequests.values())
173
+ request.abort(reason);
174
+ this.#dialogs.stop(reason);
175
+ this.#popups.stop(reason);
115
176
  for (const page of this.#options.pages.values())
116
177
  if (page instanceof NativePageAdapter)
117
178
  page.cancel(reason);
@@ -170,6 +231,8 @@ export class RunningTest {
170
231
  */
171
232
  async settle(timeoutMs) {
172
233
  const deadline = new Deadline(timeoutMs);
234
+ // A dialog the test left open holds its page and the action that opened it, so it is dismissed before anything waits.
235
+ await this.#dialogs.settle(deadline.remainingMs);
173
236
  await bounded(Promise.all([...this.#inFlight.values()].map((entry) => entry.done.promise)), timeoutMs);
174
237
  await this.#navigations.flush(deadline.remainingMs);
175
238
  this.#completeAnswered();
@@ -197,6 +260,7 @@ export class RunningTest {
197
260
  stop();
198
261
  this.#navigations.writeWaiting();
199
262
  this.#options.process.listen(undefined);
263
+ this.#options.files?.close();
200
264
  }
201
265
  #receive(event) {
202
266
  if (event.kind === 'exit')
@@ -211,6 +275,16 @@ export class RunningTest {
211
275
  return this.#childEvent(message.event);
212
276
  case 'evaluate':
213
277
  return this.#evaluate(message);
278
+ case 'inbox':
279
+ return this.#readInbox(message);
280
+ case 'dialog':
281
+ return this.#dialogs.receive(message);
282
+ case 'popup':
283
+ return this.#popups.receive(message);
284
+ case 'files':
285
+ return this.#filesRequest(message);
286
+ case 'route':
287
+ return this.#routes.receive(message);
214
288
  case 'test-finished':
215
289
  return this.#childFinished(message);
216
290
  default:
@@ -225,10 +299,10 @@ export class RunningTest {
225
299
  const refusal = this.#report === undefined ? this.#revocation : failure('usage', 'No test is running, so Retest sent nothing to the page.');
226
300
  if (refusal !== undefined)
227
301
  return this.#send(message.id, { ok: false, failure: withLocation(refusal, message.location) });
228
- const page = this.#options.pages.get(message.app);
302
+ const page = this.#options.pages.get(message.app) ?? this.#popupPages.get(message.app);
229
303
  if (page === undefined)
230
304
  return this.#violation(`sent a command for the app ${JSON.stringify(message.app)}, which this test does not use`);
231
- if (this.#inFlight.has(message.id) || this.#evaluating.has(message.id))
305
+ if (this.#inFlight.has(message.id) || this.#evaluating.has(message.id) || this.#inboxReads.has(message.id))
232
306
  return this.#violation(`sent command ${message.id} again while the first was still running`);
233
307
  const running = this.#busyWith(message.app, isLook(message.command));
234
308
  if (running !== undefined)
@@ -309,6 +383,10 @@ export class RunningTest {
309
383
  catch (error) {
310
384
  result = thrownResult(command, error);
311
385
  }
386
+ // A picker nobody waited for is answered by what this test file can call.
387
+ result = withPickerAdvice(result, this.#options.playwright === true ? playwrightPickerAdvice : testPickerAdvice);
388
+ // A dialog the page answered while the command ran is named in the failure, its text hidden as page text is.
389
+ result = this.#popups.settled(app, withDialogText(result, (text) => this.#options.redactor?.redact(text) ?? text));
312
390
  entry.pageAnswer = result.ok ? result : { ok: false, failure: withLocation(result.failure, location) };
313
391
  // A timeout spent the command's budget already. Optional titles cannot delay the page's answer further.
314
392
  if (!result.ok && result.failure.class === 'timeout')
@@ -350,7 +428,7 @@ export class RunningTest {
350
428
  // its own unredacted text.
351
429
  async #run({ message, page, stop, commandToken }, timeoutMs) {
352
430
  const { command } = message;
353
- const problem = commandProblem(command) ?? secretLocatorProblem(command, this.#options.redactor) ?? (page instanceof NativePageAdapter ? undefined : namedRowProblem(command));
431
+ const problem = this.#popups.refusal(message.app, command) ?? commandProblem(command) ?? secretLocatorProblem(command, this.#options.redactor) ?? (page instanceof NativePageAdapter ? noFramesInApp(commandLocators(command)) : namedRowProblem(command));
354
432
  if (problem !== undefined) {
355
433
  // A look the parent refuses is refused every time, so no check on it can pass: the refusal is the parent's own
356
434
  // failure, as an action's is through its event.
@@ -358,27 +436,63 @@ export class RunningTest {
358
436
  this.#observed.push(withLocation(problem, message.location));
359
437
  return { ok: false, failure: problem };
360
438
  }
439
+ if (command.kind === 'close')
440
+ return this.#popups.close(message.app, message.location, () => page.execute(command, timeoutMs, stop.signal, commandToken));
441
+ if (command.kind === 'observeScreenshot')
442
+ return this.#takePicture(message, command, timeoutMs, stop.signal);
361
443
  if (command.kind === 'swipe' || command.kind === 'nativeKeyboard' || command.kind === 'nativeAlert')
362
444
  return page instanceof NativePageAdapter ? page.execute(command, timeoutMs, stop.signal) : { ok: false, failure: failure('unsupported', `${command.kind} needs a native app session.`) };
445
+ if (command.kind === 'goto') {
446
+ // A link from an inbox opens only once its origin is one it may open on; the page is given the address alone.
447
+ const opened = typeof command.url === 'string' ? { ok: true, url: command.url } : this.#openLink(command.url);
448
+ return opened.ok ? page.execute({ kind: 'goto', url: opened.url }, timeoutMs, stop.signal, commandToken) : { ok: false, failure: opened.failure };
449
+ }
450
+ if (command.kind === 'setInputFiles' || command.kind === 'setChooserFiles')
451
+ return this.#giveFiles(command, page, { timeoutMs, signal: stop.signal, commandToken });
363
452
  if (command.kind !== 'fill')
364
453
  return page.execute(command, timeoutMs, stop.signal, commandToken);
365
454
  const { locator, value } = command;
366
455
  if (typeof value === 'string')
367
456
  return page.execute({ kind: 'fill', locator, value }, timeoutMs, stop.signal, commandToken);
368
457
  const deadline = new Deadline(timeoutMs);
369
- const { fillSecret } = this.#options;
370
458
  // A native app has no address: the bundle id read from its installed app is where the secret would go. A page can be
371
459
  // read again, so a fill that finds it on no address yet can wait for its first.
372
460
  const destination = page instanceof NativePageAdapter ? { pageUrl: undefined, bundleId: page.bundleId } : { pageUrl: page.url, currentUrl: () => page.url };
373
461
  const context = { app: message.app, ...destination, timeoutMs, signal: stop.signal };
374
- const resolved = fillSecret === undefined ? noSecrets(value.secret) : await fillSecret({ kind: 'fill', locator, value }, context);
462
+ const resolved = await this.#resolveFill(locator, value, context);
375
463
  if (!resolved.ok)
376
464
  return { ok: false, failure: resolved.failure };
377
- const ended = this.#options.secretEntry?.(message.app, value.secret);
465
+ const ended = this.#options.secretEntry?.(message.app, 'secret' in value ? value.secret : codePlaceholderName);
378
466
  const result = await page.execute(resolved.command, deadline.commandTimeoutMs, stop.signal, commandToken);
379
467
  ended?.(result.ok ? 'sent' : result.failure.details?.['inputSent'] === false ? 'not_sent' : 'unknown');
380
468
  return result;
381
469
  }
470
+ // A screenshot look's picture stays in this process: the page takes it, it is kept until the look is served, and the
471
+ // test process is told only its size and how it stands against its baseline. A look the attempt's pictures refuse is
472
+ // refused every time, so no check on it can pass: the refusal is the parent's own failure.
473
+ async #takePicture(message, command, timeoutMs, signal) {
474
+ const { screenshots } = this.#options;
475
+ const taken = screenshots === undefined ? { ok: false, refused: true, failure: failure('unsupported', 'This run takes no pictures for screenshot checks.') } : await screenshots.take(message.app, command, timeoutMs, signal);
476
+ if (!taken.ok) {
477
+ if (taken.refused === true && this.#revocation === undefined)
478
+ this.#observed.push(withLocation(taken.failure, message.location));
479
+ return { ok: false, failure: taken.failure };
480
+ }
481
+ if (taken.picture !== undefined)
482
+ this.#pictures.set(message.id, taken.picture);
483
+ return { ok: true, kind: 'observeScreenshot', observation: taken.look, ...(taken.page === undefined ? {} : { page: taken.page }) };
484
+ }
485
+ // A secret is read by the run's secrets, and a code by the run's inboxes; either says why it may not be typed.
486
+ #resolveFill(locator, value, context) {
487
+ const { fillSecret, inbox } = this.#options;
488
+ if ('secret' in value)
489
+ return fillSecret === undefined ? Promise.resolve(noSecrets(value.secret)) : fillSecret({ kind: 'fill', locator, value }, context);
490
+ return inbox === undefined ? Promise.resolve({ ok: false, failure: noInboxes(value.inbox) }) : inbox.fillCode({ kind: 'fill', locator, value }, context);
491
+ }
492
+ #openLink(reference) {
493
+ const { inbox } = this.#options;
494
+ return inbox === undefined ? { ok: false, failure: noInboxes(reference.inbox) } : inbox.openLink(reference);
495
+ }
382
496
  // An action is reported once: by the page's answer, or as unknown when the page gave none in time.
383
497
  #reportAction(entry, result, page) {
384
498
  const { command, callTimeoutMs } = entry.message;
@@ -438,6 +552,10 @@ export class RunningTest {
438
552
  const { command, app, stepId } = entry.message;
439
553
  if (result.ok && result.kind === 'observePage' && command.kind === 'observePage')
440
554
  return this.#servePage(app, result);
555
+ if (result.ok && result.kind === 'observeLayout' && command.kind === 'observeLayout')
556
+ return this.#serveLayout(entry, command, result);
557
+ if (result.ok && result.kind === 'observeScreenshot' && command.kind === 'observeScreenshot')
558
+ return this.#servePicture(entry, command, result);
441
559
  if (!result.ok || result.kind !== 'observe' || command.kind !== 'observe')
442
560
  return result;
443
561
  const { locator } = command;
@@ -464,6 +582,32 @@ export class RunningTest {
464
582
  const observationId = this.#observations.serve({ app, sessionId, page: baseUrl === undefined ? observation : { ...observation, baseUrl } });
465
583
  return { ...result, observation, observationId, sessionId };
466
584
  }
585
+ // A layout look is kept for the assertion that names it, which records what it measured, with the elements and the
586
+ // words the look was asked to measure, so the parent can tell a look made for one check from a look made for another.
587
+ // Like a look at the page, it has no event of its own, since an observation event lists a locator's matches.
588
+ #serveLayout(entry, command, result) {
589
+ const { app } = entry.message;
590
+ const sessionId = formatSessionId(this.#options.attemptId, app);
591
+ const page = commandPage(entry, result, this.#documents.get(app));
592
+ const { locator, other, words } = command;
593
+ const request = { ...(locator === undefined ? {} : { locator }), ...(other === undefined ? {} : { other }), ...(words === undefined ? {} : { words }) };
594
+ const observationId = this.#observations.serve({ app, sessionId, request, layout: result.observation, ...page });
595
+ return { ...result, observationId, sessionId };
596
+ }
597
+ // A screenshot look is kept for the assertion that names it, as a layout look is, with the element, the name and the
598
+ // threshold it was taken for; its picture stays with the attempt's pictures. It has no event of its own.
599
+ #servePicture(entry, command, result) {
600
+ const { app, id } = entry.message;
601
+ const sessionId = formatSessionId(this.#options.attemptId, app);
602
+ const page = commandPage(entry, result, this.#documents.get(app));
603
+ const { locator, name, threshold } = command;
604
+ const request = { ...(locator === undefined ? {} : { locator }), name, ...(threshold === undefined ? {} : { threshold }) };
605
+ const observationId = this.#observations.serve({ app, sessionId, picture: request, screenshot: result.observation, ...page });
606
+ const picture = this.#pictures.get(id);
607
+ this.#pictures.delete(id);
608
+ this.#options.screenshots?.keep({ observationId, app, command, look: result.observation, picture });
609
+ return { ...result, observationId, sessionId };
610
+ }
467
611
  // The page's own answer when it gave one. An action it never answered may have taken effect.
468
612
  #lossFailure(reason) {
469
613
  const unanswered = [...this.#inFlight.values()].find((entry) => !entry.message.command.kind.startsWith('observe'));
@@ -488,7 +632,7 @@ export class RunningTest {
488
632
  return this.#options.emit(event, 'child');
489
633
  // An assertion that names no app looks at the test's first one, as milestone 1's single page. One that names an app
490
634
  // the test does not have names a session that never existed.
491
- if (event.session !== undefined && !this.#options.pages.has(event.session)) {
635
+ if (event.session !== undefined && !this.#options.pages.has(event.session) && !this.#popupPages.has(event.session)) {
492
636
  return this.#violation(`sent ${event.type} for the app ${JSON.stringify(event.session)}, which this test does not use`);
493
637
  }
494
638
  const [firstApp] = this.#options.pages.keys();
@@ -499,7 +643,7 @@ export class RunningTest {
499
643
  // A native look is judged on the tree the parent read, which can also show the reference stale or the check one the
500
644
  // platform cannot make; the parent's failure then stands in for any verdict the process sent, a pass included.
501
645
  let nativeFailure;
502
- if (native !== undefined && event.check !== undefined && !isPageCheck(event.check) && event.locator !== undefined) {
646
+ if (native !== undefined && event.check !== undefined && !isPageCheck(event.check) && !isLayoutCheck(event.check) && !isScreenshotCheck(event.check) && event.locator !== undefined) {
503
647
  const rule = locatorCheck(event.check);
504
648
  const unsupported = native.page.native.checkReference(native.look.reference) ?? unsupportedCheck(event.check, native.look.matches, event.locator, native.look.tree.platform);
505
649
  if (unsupported !== undefined || !rule.passes(native.look.observation)) {
@@ -509,10 +653,12 @@ export class RunningTest {
509
653
  assertion = event.type === 'assertion.failed' ? event : { ...event, type: 'assertion.failed', failure: nativeFailure };
510
654
  }
511
655
  }
512
- const judged = this.#observations.judge(assertion, { app, ...looked });
656
+ // What a picture showed and the files a comparison left are the parent's to write, never the process's to claim.
657
+ const { screenshot: _claimedScreenshot, attachments: _claimedAttachments, ...sent } = assertion;
658
+ const judged = this.#observations.judge(sent, { app, ...looked });
513
659
  if (!judged.ok)
514
660
  return this.#violation(judged.problem);
515
- const { event: recorded, origin, seen } = this.#parentVerdict(judged.event, { app, check: event.check, nativeFailure });
661
+ const { event: recorded, origin, seen } = this.#settlePicture(this.#parentVerdict(judged.event, { app, check: event.check, nativeFailure }), event.check);
516
662
  // A class the parent did not see, and that would make the test ours rather than the app's, stays the process's claim.
517
663
  if (recorded.type === 'assertion.failed' && (seen || !isOurs(recorded.failure)))
518
664
  this.#observed.push(recorded.failure);
@@ -542,6 +688,26 @@ export class RunningTest {
542
688
  return { event, origin: 'child', seen: true };
543
689
  return { event: { ...event, failure: withAlso(withLocation(own, event.location), [event.failure]) }, origin: 'parent', seen: true };
544
690
  }
691
+ // A judged screenshot assertion leaves its pictures in the run folder when it failed, or writes its baseline when it
692
+ // passed in a run that updates them. A baseline the run could not write fails the assertion with the parent's own
693
+ // failure, which leads whatever the process said.
694
+ #settlePicture(verdict, check) {
695
+ const { event } = verdict;
696
+ const { screenshots } = this.#options;
697
+ if (screenshots === undefined || check === undefined || !isScreenshotCheck(check) || event.observationId === undefined)
698
+ return verdict;
699
+ const step = event.stepId === undefined ? {} : { stepId: event.stepId };
700
+ const place = event.location === undefined ? {} : { location: event.location };
701
+ const settled = screenshots.settle({ passed: event.type === 'assertion.passed', check, observationId: event.observationId, ...step, ...place });
702
+ const attached = settled.attachments === undefined ? event : { ...event, attachments: settled.attachments };
703
+ if (settled.failure === undefined)
704
+ return { ...verdict, event: attached };
705
+ const failed = withLocation(settled.failure, event.location);
706
+ if (attached.type === 'assertion.failed')
707
+ return { event: { ...attached, failure: withAlso(failed, [attached.failure]) }, origin: 'parent', seen: true };
708
+ const { judgedBy: _judgedBy, type: _type, ...fields } = attached;
709
+ return { event: { ...fields, type: 'assertion.failed', failure: failed }, origin: 'parent', seen: true };
710
+ }
545
711
  // A check that failed because the browser was lost while it looked is the parent's own record, since the parent saw
546
712
  // the loss: the loss leads, at the check's place, and anything else the process says of it follows. Its matcher, its
547
713
  // expected text and the look it rests on are what the parent judged, as for a check whose look answered.
@@ -594,6 +760,123 @@ export class RunningTest {
594
760
  })
595
761
  .finally(() => this.#evaluating.delete(message.id));
596
762
  }
763
+ // The parent reads the mailbox itself; the test file's process only names the inbox and the subject, and its own time
764
+ // bounds the read, cut to what the test has left. A read touches no page, so no app is held. Every read the parent
765
+ // answers is written down, the one stopped with its test too. A read that failed is the parent's own failure, which
766
+ // fails the test whatever the process claims, unless the test was stopped already.
767
+ #readInbox(message) {
768
+ if (!this.#isOwn(message))
769
+ return this.#violation('sent an inbox read for an inactive test attempt');
770
+ if (this.#inboxReads.has(message.id) || this.#inFlight.has(message.id) || this.#evaluating.has(message.id))
771
+ return this.#violation(`sent inbox read ${message.id} again while the first was still running`);
772
+ if (this.#deadline?.reached === true)
773
+ this.#runOutOfTime();
774
+ const { inbox, process } = this.#options;
775
+ const refusal = this.#report === undefined ? this.#revocation : failure('usage', 'No test is running, so Retest read no inbox.');
776
+ if (refusal !== undefined)
777
+ return process.send({ type: 'inbox-result', id: message.id, answer: { ok: false, failure: withLocation(refusal, message.location) } });
778
+ if (inbox === undefined) {
779
+ const answer = { ok: false, failure: withLocation(noInboxes(message.request.inbox), message.location) };
780
+ this.#observed.push(answer.failure);
781
+ this.#writeInboxRead(message, { answer, messages: 0, looks: 0, waitedMs: 0 });
782
+ return process.send({ type: 'inbox-result', id: message.id, answer });
783
+ }
784
+ const stop = new AbortController();
785
+ this.#inboxReads.set(message.id, stop);
786
+ const timeoutMs = this.#deadline === undefined ? message.timeoutMs : smallestBudget(message.timeoutMs, this.#deadline.remainingMs);
787
+ const broken = (error) => {
788
+ const problem = failure('setup_failed', `Retest could not read the inbox ${JSON.stringify(message.request.inbox)}: ${this.#options.redactor?.redact(errorMessage(error)) ?? errorMessage(error)}`);
789
+ return { answer: { ok: false, failure: problem }, messages: 0, looks: 0, waitedMs: 0 };
790
+ };
791
+ void inbox.read(message.request, { timeoutMs, signal: stop.signal }).then(undefined, broken).then((read) => {
792
+ this.#inboxReads.delete(message.id);
793
+ const answer = read.answer.ok ? read.answer : { ok: false, failure: withLocation(read.answer.failure, message.location) };
794
+ if (!answer.ok && this.#revocation === undefined && this.#report === undefined)
795
+ this.#observed.push(answer.failure);
796
+ this.#writeInboxRead(message, { ...read, answer });
797
+ if (this.#report === undefined && this.#revocation === undefined)
798
+ process.send({ type: 'inbox-result', id: message.id, answer });
799
+ });
800
+ }
801
+ #writeInboxRead({ request, stepId, location }, { answer, messages, looks, waitedMs }) {
802
+ const { testId, attemptId } = this.#options;
803
+ const { subject } = request;
804
+ const wanted = subject === undefined ? {} : { wanted: typeof subject === 'string' ? subject : `/${subject.pattern}/${subject.flags}` };
805
+ const found = answer.ok
806
+ ? { subject: answer.message.subject, receivedAt: answer.message.receivedAt, codes: answer.message.codes, links: answer.message.links.length }
807
+ : { failure: answer.failure };
808
+ const step = stepId === undefined ? {} : { stepId };
809
+ const place = location === undefined ? {} : { location };
810
+ this.#options.emit({ type: 'inbox.read', testId, attemptId, ...step, inbox: request.inbox, messages, looks, waitedMs, ...wanted, ...found, ...place });
811
+ }
812
+ // A file input takes paths on this machine: the parent checks each path the test gave, and writes each file given by
813
+ // content to the run's folder, before the page sees the command. A native app takes no files, and nothing is written.
814
+ #giveFiles(command, page, sending) {
815
+ const method = command.kind === 'setInputFiles' ? 'setInputFiles' : 'setFiles';
816
+ const refused = (problem) => Promise.resolve({ ok: false, failure: problem });
817
+ const problem = inputFilesProblem(command.files, method);
818
+ if (problem !== undefined)
819
+ return refused(problem);
820
+ if (page instanceof NativePageAdapter)
821
+ return refused(failure('unsupported', `${method}() gives files to a web page's file input, and a native app has none, so Retest gave none.`));
822
+ const { files } = this.#options;
823
+ if (files === undefined)
824
+ return refused(failure('unsupported', `${method}() needs a run that keeps files, and this one keeps none, so Retest gave none.`));
825
+ const staged = files.stage(command.files, method);
826
+ if (!staged.ok)
827
+ return refused(staged.failure);
828
+ const resolved = { ...command, paths: staged.paths, mimeTypes: staged.mimeTypes };
829
+ return page.execute(resolved, sending.timeoutMs, sending.signal, sending.commandToken);
830
+ }
831
+ // The parent arms the page and waits itself; the test file's process only names the app or the download, and its own
832
+ // time bounds the wait, cut to what the test has left. A wait holds no app: the action it waits on runs as an ordinary
833
+ // command. One that failed is the parent's own failure, which fails the test whatever the process claims, unless the
834
+ // test was stopped already or the process let go of it because its action failed.
835
+ #filesRequest(message) {
836
+ if (!this.#isOwn(message))
837
+ return this.#violation('sent a request about files for an inactive test attempt');
838
+ const { request, id, location } = message;
839
+ if (request.kind === 'release') {
840
+ // Only a wait still running is let go of; one already answered has nothing left to stop.
841
+ const waiting = this.#filesRequests.get(request.request);
842
+ if (waiting === undefined)
843
+ return;
844
+ this.#releasedRequests.add(request.request);
845
+ waiting.abort(failure('interrupted', 'The action the test waited on failed, so Retest stopped waiting.'));
846
+ return;
847
+ }
848
+ if (this.#filesRequests.has(id) || this.#inFlight.has(id) || this.#evaluating.has(id) || this.#inboxReads.has(id))
849
+ return this.#violation(`sent request ${id} again while the first was still running`);
850
+ if (request.kind !== 'saved' && !this.#options.pages.has(request.app))
851
+ return this.#violation(`sent a request for the app ${JSON.stringify(request.app)}, which this test does not use`);
852
+ if (this.#deadline?.reached === true)
853
+ this.#runOutOfTime();
854
+ const { files, process } = this.#options;
855
+ const answer = (sent) => process.send({ type: 'files-result', id, answer: sent.ok ? sent : { ok: false, failure: this.#options.redactor?.redactFailure(sent.failure) ?? sent.failure } });
856
+ const refusal = this.#report === undefined ? this.#revocation : failure('usage', 'No test is running, so Retest waited for nothing.');
857
+ if (refusal !== undefined)
858
+ return answer({ ok: false, failure: withLocation(refusal, location) });
859
+ if (files === undefined) {
860
+ const unkept = withLocation(failure('unsupported', 'This run keeps no files, so Retest waited for nothing.'), location);
861
+ this.#observed.push(unkept);
862
+ return answer({ ok: false, failure: unkept });
863
+ }
864
+ const stop = new AbortController();
865
+ this.#filesRequests.set(id, stop);
866
+ const timeoutMs = this.#deadline === undefined ? message.timeoutMs : smallestBudget(message.timeoutMs, this.#deadline.remainingMs);
867
+ const budget = { timeoutMs, signal: stop.signal, stepId: message.stepId, location };
868
+ const waiting = request.kind === 'chooser' ? files.chooser(request.app, budget) : request.kind === 'download' ? files.download(request.app, budget) : files.saved(request.download, budget);
869
+ const broken = (error) => ({ ok: false, failure: failure('session_lost', `The browser call failed: ${errorMessage(error)}`) });
870
+ void waiting.then(undefined, broken).then((settled) => {
871
+ this.#filesRequests.delete(id);
872
+ const placed = settled.ok ? settled : { ok: false, failure: withLocation(settled.failure, location) };
873
+ const ignored = this.#releasedRequests.delete(id);
874
+ if (!placed.ok && !ignored && this.#revocation === undefined && this.#report === undefined)
875
+ this.#observed.push(placed.failure);
876
+ if (this.#report === undefined && this.#revocation === undefined && !ignored)
877
+ answer(placed);
878
+ });
879
+ }
597
880
  #childFinished(message) {
598
881
  if (!this.#isOwn(message))
599
882
  return this.#violation('finished an inactive test attempt');
@@ -680,6 +963,13 @@ export class RunningTest {
680
963
  this.#stepId = undefined;
681
964
  clearTimeout(this.#testTimer);
682
965
  this.#options.evaluations?.cancel(failure('evaluation_error', 'The test ended before the answer came.'));
966
+ for (const read of this.#inboxReads.values())
967
+ read.abort(failure('interrupted', 'The test ended before the inbox answered.'));
968
+ for (const request of this.#filesRequests.values())
969
+ request.abort(failure('interrupted', 'The test ended before the page opened a file picker or a download.'));
970
+ this.#dialogs.stop(failure('interrupted', 'The test ended before the page opened a dialog.'));
971
+ this.#popups.stop(failure('interrupted', 'The test ended before the page opened another page.'));
972
+ this.#releasedRequests.clear();
683
973
  if (this.#timedOut)
684
974
  this.#options.process.send({ type: 'close' });
685
975
  else
@@ -715,7 +1005,7 @@ function openedDocument(result) {
715
1005
  }
716
1006
  // What the test process's own checks refuse, which the parent refuses again before the page sees the command.
717
1007
  function commandProblem(command) {
718
- const located = 'locator' in command && command.locator !== undefined ? locatorProblem(command.locator) : undefined;
1008
+ const located = commandLocators(command).map(locatorProblem).find((problem) => problem !== undefined);
719
1009
  if (located !== undefined)
720
1010
  return located;
721
1011
  switch (command.kind) {
@@ -727,6 +1017,13 @@ function commandProblem(command) {
727
1017
  return selectCommandProblem(command);
728
1018
  case 'scroll':
729
1019
  return scrollProblem(command);
1020
+ case 'dragTo':
1021
+ return dragProblem(command);
1022
+ case 'observeScreenshot': {
1023
+ // A name is a file name and a threshold a distance, so the parent refuses either before it reads any file.
1024
+ const problem = screenshotRequestProblem(command);
1025
+ return problem === undefined ? undefined : failure('usage', problem);
1026
+ }
730
1027
  default:
731
1028
  return undefined;
732
1029
  }
@@ -738,9 +1035,9 @@ function commandProblem(command) {
738
1035
  * Part of a value is not refused, since refusing it would itself say which texts are part of a secret.
739
1036
  */
740
1037
  function secretLocatorProblem(command, redactor) {
741
- if (redactor === undefined || !('locator' in command) || command.locator === undefined)
1038
+ if (redactor === undefined)
742
1039
  return undefined;
743
- const held = matchedTexts(command.locator).find((matched) => redactor.holdsValue(matched.text, matched.exact));
1040
+ const held = commandLocators(command).flatMap(matchedTexts).find((matched) => redactor.holdsValue(matched.text, matched.exact));
744
1041
  if (held === undefined)
745
1042
  return undefined;
746
1043
  const message = `Retest did not send this command to the page: its locator's ${held.named} holds the value of a secret. Find the element by its test id, or by text that holds no secret.`;
@@ -753,9 +1050,7 @@ function secretLocatorProblem(command, redactor) {
753
1050
  * every action and every check before the page is asked. A native app's tree names its rows, so it is not refused there.
754
1051
  */
755
1052
  function namedRowProblem(command) {
756
- if (!('locator' in command) || command.locator === undefined)
757
- return undefined;
758
- const named = locatorSteps(command.locator).some((step) => step.by === 'role' && step.role === 'row' && step.name !== undefined);
1053
+ const named = commandLocators(command).some((locator) => locatorSteps(locator).some((step) => step.by === 'role' && step.role === 'row' && step.name !== undefined));
759
1054
  if (!named)
760
1055
  return undefined;
761
1056
  const message = "getByRole('row', { name }) finds no row on a web page: the browser's accessibility tree gives a table row no name from its cells, so the lookup would match nothing and a check that the row is hidden would pass while it is on screen. Find the row by its test id, or find the cell by its own text, such as getByText('Grace Hopper') or getByRole('cell', { name: 'Grace Hopper' }).";
@@ -794,6 +1089,8 @@ function actionDetails(command, result, touch) {
794
1089
  switch (command.kind) {
795
1090
  case 'fill':
796
1091
  return typedValue(command.value);
1092
+ case 'goto':
1093
+ return typeof command.url === 'string' ? {} : { secret: linkName(command.url.default === true ? undefined : command.url.link), inbox: command.url.inbox };
797
1094
  case 'press':
798
1095
  return { key: command.key };
799
1096
  case 'select':
@@ -809,6 +1106,15 @@ function actionDetails(command, result, touch) {
809
1106
  return { operation: command.operation };
810
1107
  case 'nativeAlert':
811
1108
  return { operation: command.operation, ...(command.button === undefined ? {} : { button: command.button }) };
1109
+ case 'setInputFiles':
1110
+ case 'setChooserFiles':
1111
+ if (!result.ok || !('files' in result) || result.files === undefined)
1112
+ return {};
1113
+ return result.kept === undefined ? { files: result.files } : { files: result.files, kept: result.kept };
1114
+ case 'dragTo': {
1115
+ const through = result.ok && result.kind === 'dragTo' ? { through: result.through } : {};
1116
+ return { drag: { target: command.target, ...(command.steps === undefined ? {} : { steps: command.steps }), ...through } };
1117
+ }
812
1118
  default:
813
1119
  return {};
814
1120
  }
@@ -818,16 +1124,21 @@ function changedOf(result) {
818
1124
  }
819
1125
  // The page's answer says what it did; a failed click on a touch screen was a tap too.
820
1126
  function recordedKind(sent, result, touch) {
821
- if (result.ok && result.kind !== 'observe' && result.kind !== 'observePage')
1127
+ if (result.ok && result.kind !== 'observe' && result.kind !== 'observePage' && result.kind !== 'observeLayout' && result.kind !== 'observeScreenshot')
822
1128
  return result.kind;
823
1129
  return sent === 'click' && touch ? 'tap' : sent;
824
1130
  }
825
1131
  function noSecrets(name) {
826
1132
  return { ok: false, failure: failure('usage', `secret(${JSON.stringify(name)}) needs a config that declares it. This run has no secrets.`) };
827
1133
  }
828
- // A fill records how much it typed, or which secret, and never the text itself.
1134
+ // A fill records how much it typed, or which secret, or that it typed an inbox's code, and never the text itself.
829
1135
  function typedValue(value) {
830
- return typeof value === 'string' ? { valueLength: value.length } : { secret: value.secret };
1136
+ if (typeof value === 'string')
1137
+ return { valueLength: value.length };
1138
+ return 'secret' in value ? { secret: value.secret } : { secret: codePlaceholderName, inbox: value.inbox };
1139
+ }
1140
+ function noInboxes(name) {
1141
+ return failure('usage', `inbox(${JSON.stringify(name)}) needs a config that declares it. This run has no inboxes.`);
831
1142
  }
832
1143
  function thrownResult(command, error) {
833
1144
  const message = `The browser call for ${describeCommand(command)} failed: ${errorMessage(error)}`;
@@ -850,7 +1161,7 @@ function concurrentFailure(asked, running, location, outcome) {
850
1161
  }
851
1162
  // A look reads its app's page and changes nothing there; every other command is an action.
852
1163
  function isLook(command) {
853
- return command.kind === 'observe' || command.kind === 'observePage';
1164
+ return command.kind === 'observe' || command.kind === 'observePage' || command.kind === 'observeLayout' || command.kind === 'observeScreenshot';
854
1165
  }
855
1166
  function lineOrNull(location) {
856
1167
  return location === undefined ? null : formatLine(location);