@itmetsam/samurai-framework 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 (328) hide show
  1. package/README.md +129 -0
  2. package/bin/samurai.mjs +31 -0
  3. package/dist/api.js +3 -0
  4. package/dist/assert/assertion-error.d.ts +23 -0
  5. package/dist/assert/assertion-error.js +45 -0
  6. package/dist/assert/expect.d.ts +55 -0
  7. package/dist/assert/expect.js +188 -0
  8. package/dist/browser/browser-context.d.ts +91 -0
  9. package/dist/browser/browser-context.js +146 -0
  10. package/dist/browser/browser-finder.d.ts +2 -0
  11. package/dist/browser/browser-finder.js +95 -0
  12. package/dist/browser/browser.d.ts +95 -0
  13. package/dist/browser/browser.js +372 -0
  14. package/dist/browser/context-tree.d.ts +23 -0
  15. package/dist/browser/context-tree.js +60 -0
  16. package/dist/browser/dialog.d.ts +37 -0
  17. package/dist/browser/dialog.js +61 -0
  18. package/dist/browser/download.d.ts +71 -0
  19. package/dist/browser/download.js +137 -0
  20. package/dist/browser/downloads-dir.d.ts +8 -0
  21. package/dist/browser/downloads-dir.js +38 -0
  22. package/dist/browser/emulation.d.ts +61 -0
  23. package/dist/browser/emulation.js +144 -0
  24. package/dist/browser/file-chooser.d.ts +39 -0
  25. package/dist/browser/file-chooser.js +86 -0
  26. package/dist/browser/file-paths.d.ts +4 -0
  27. package/dist/browser/file-paths.js +18 -0
  28. package/dist/browser/index.d.ts +4 -0
  29. package/dist/browser/index.js +4 -0
  30. package/dist/browser/navigation-error.d.ts +13 -0
  31. package/dist/browser/navigation-error.js +19 -0
  32. package/dist/browser/page-logs.d.ts +86 -0
  33. package/dist/browser/page-logs.js +149 -0
  34. package/dist/browser/page-wait.d.ts +9 -0
  35. package/dist/browser/page-wait.js +17 -0
  36. package/dist/browser/page.d.ts +370 -0
  37. package/dist/browser/page.js +1038 -0
  38. package/dist/browser/pdf.d.ts +72 -0
  39. package/dist/browser/pdf.js +37 -0
  40. package/dist/browser/screenshot.d.ts +29 -0
  41. package/dist/browser/screenshot.js +42 -0
  42. package/dist/cli/args.d.ts +35 -0
  43. package/dist/cli/args.js +69 -0
  44. package/dist/cli/cli.cli-test.d.ts +1 -0
  45. package/dist/cli/cli.cli-test.js +170 -0
  46. package/dist/cli/console-reporter.d.ts +15 -0
  47. package/dist/cli/console-reporter.js +52 -0
  48. package/dist/cli/init.d.ts +7 -0
  49. package/dist/cli/init.js +61 -0
  50. package/dist/cli/main.d.ts +12 -0
  51. package/dist/cli/main.js +156 -0
  52. package/dist/config/config.d.ts +24 -0
  53. package/dist/config/config.js +42 -0
  54. package/dist/config/default.d.ts +2 -0
  55. package/dist/config/default.js +3 -0
  56. package/dist/config/mask.d.ts +11 -0
  57. package/dist/config/mask.js +85 -0
  58. package/dist/config/run-settings.d.ts +28 -0
  59. package/dist/config/run-settings.js +61 -0
  60. package/dist/config/secrets.d.ts +10 -0
  61. package/dist/config/secrets.js +67 -0
  62. package/dist/config/variables.d.ts +11 -0
  63. package/dist/config/variables.js +26 -0
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.js +15 -0
  66. package/dist/locator/action-timeout-error.d.ts +25 -0
  67. package/dist/locator/action-timeout-error.js +35 -0
  68. package/dist/locator/element-state.d.ts +44 -0
  69. package/dist/locator/element-state.js +109 -0
  70. package/dist/locator/label-locator.d.ts +13 -0
  71. package/dist/locator/label-locator.js +49 -0
  72. package/dist/locator/locator.d.ts +168 -0
  73. package/dist/locator/locator.js +591 -0
  74. package/dist/locator/selector-errors.d.ts +9 -0
  75. package/dist/locator/selector-errors.js +15 -0
  76. package/dist/locator/selector.d.ts +60 -0
  77. package/dist/locator/selector.js +113 -0
  78. package/dist/locator/text-locator.d.ts +13 -0
  79. package/dist/logger/index.d.ts +2 -0
  80. package/dist/logger/index.js +30 -0
  81. package/dist/network/data-collector.d.ts +26 -0
  82. package/dist/network/data-collector.js +77 -0
  83. package/dist/network/network-tracker.d.ts +94 -0
  84. package/dist/network/network-tracker.js +198 -0
  85. package/dist/network/network-wait.d.ts +16 -0
  86. package/dist/network/network-wait.js +24 -0
  87. package/dist/network/response.d.ts +22 -0
  88. package/dist/network/response.js +42 -0
  89. package/dist/network/router.d.ts +120 -0
  90. package/dist/network/router.js +343 -0
  91. package/dist/network/url-match.d.ts +8 -0
  92. package/dist/network/url-match.js +38 -0
  93. package/dist/recorder/apply.d.ts +3 -0
  94. package/dist/recorder/apply.js +10 -0
  95. package/dist/recorder/capture.d.ts +18 -0
  96. package/dist/recorder/capture.js +198 -0
  97. package/dist/recorder/index.d.ts +3 -0
  98. package/dist/recorder/index.js +3 -0
  99. package/dist/recorder/normalise.d.ts +68 -0
  100. package/dist/recorder/normalise.js +104 -0
  101. package/dist/recorder/ranking.d.ts +9 -0
  102. package/dist/recorder/ranking.js +21 -0
  103. package/dist/recorder/recorder.d.ts +52 -0
  104. package/dist/recorder/recorder.js +143 -0
  105. package/dist/recorder/session.d.ts +49 -0
  106. package/dist/recorder/session.js +113 -0
  107. package/dist/runner/module-type.d.ts +6 -0
  108. package/dist/runner/module-type.js +29 -0
  109. package/dist/runner/page-logs-report.d.ts +40 -0
  110. package/dist/runner/page-logs-report.js +44 -0
  111. package/dist/runner/prepare-run.d.ts +14 -0
  112. package/dist/runner/prepare-run.js +20 -0
  113. package/dist/runner/project.d.ts +17 -0
  114. package/dist/runner/project.js +33 -0
  115. package/dist/runner/registry.d.ts +7 -0
  116. package/dist/runner/registry.js +40 -0
  117. package/dist/runner/reporter.d.ts +43 -0
  118. package/dist/runner/reporter.js +83 -0
  119. package/dist/runner/run.d.ts +27 -0
  120. package/dist/runner/run.js +34 -0
  121. package/dist/runner/test-error.d.ts +3 -0
  122. package/dist/runner/test-error.js +18 -0
  123. package/dist/runner/test-runner.d.ts +44 -0
  124. package/dist/runner/test-runner.js +205 -0
  125. package/dist/script/call-function.d.ts +34 -0
  126. package/dist/script/call-function.js +71 -0
  127. package/dist/script/element-handle.d.ts +10 -0
  128. package/{src/script/element-handle.ts → dist/script/element-handle.js} +4 -4
  129. package/dist/script/helpers.d.ts +28 -0
  130. package/dist/script/helpers.js +50 -0
  131. package/dist/script/preload.d.ts +31 -0
  132. package/dist/script/preload.js +71 -0
  133. package/dist/script/serialize.d.ts +29 -0
  134. package/dist/script/serialize.js +207 -0
  135. package/dist/steps/build.d.ts +5 -0
  136. package/dist/steps/build.js +38 -0
  137. package/dist/steps/edit.d.ts +30 -0
  138. package/dist/steps/edit.js +80 -0
  139. package/dist/steps/emit.d.ts +6 -0
  140. package/dist/steps/emit.js +73 -0
  141. package/dist/steps/index.d.ts +5 -0
  142. package/dist/steps/index.js +4 -0
  143. package/dist/steps/model.d.ts +103 -0
  144. package/dist/steps/model.js +1 -0
  145. package/dist/steps/parse.d.ts +3 -0
  146. package/dist/steps/parse.js +272 -0
  147. package/dist/transport/bidi-connection.d.ts +46 -0
  148. package/dist/transport/bidi-connection.js +176 -0
  149. package/dist/transport/bidi-error.d.ts +10 -0
  150. package/dist/transport/bidi-error.js +10 -0
  151. package/dist/transport/subscriptions.d.ts +36 -0
  152. package/dist/transport/subscriptions.js +81 -0
  153. package/{src → dist}/types/test.d.ts +10 -4
  154. package/dist/wait/wait-until.d.ts +20 -0
  155. package/dist/wait/wait-until.js +63 -0
  156. package/package.json +44 -7
  157. package/.claude/settings.local.json +0 -7
  158. package/.env.example +0 -3
  159. package/.github/actions/setup/action.yml +0 -16
  160. package/.github/workflows/ci.yml +0 -71
  161. package/.trunk/configs/.markdownlint.yaml +0 -2
  162. package/.trunk/configs/.yamllint.yaml +0 -7
  163. package/.trunk/configs/svgo.config.mjs +0 -14
  164. package/.trunk/trunk.yaml +0 -34
  165. package/docs/superpowers/bidi-roadmap.md +0 -60
  166. package/docs/superpowers/plans/2026-09-29-auto-wait.md +0 -1827
  167. package/docs/superpowers/plans/2026-09-29-expect.md +0 -1350
  168. package/docs/superpowers/plans/2026-10-01-ci.md +0 -350
  169. package/docs/superpowers/plans/2026-10-01-test-api-alignment.md +0 -1821
  170. package/docs/superpowers/recorder-roadmap.md +0 -52
  171. package/docs/superpowers/specs/2026-09-29-auto-wait-design.md +0 -185
  172. package/docs/superpowers/specs/2026-09-29-bidi-foundation-design.md +0 -130
  173. package/docs/superpowers/specs/2026-09-29-contexts-and-storage-design.md +0 -88
  174. package/docs/superpowers/specs/2026-09-29-downloads-and-file-dialogs-design.md +0 -94
  175. package/docs/superpowers/specs/2026-09-29-emulation-design.md +0 -88
  176. package/docs/superpowers/specs/2026-09-29-expect-design.md +0 -130
  177. package/docs/superpowers/specs/2026-09-29-locate-nodes-design.md +0 -90
  178. package/docs/superpowers/specs/2026-09-29-logs-and-errors-design.md +0 -87
  179. package/docs/superpowers/specs/2026-09-29-navigation-waits-design.md +0 -83
  180. package/docs/superpowers/specs/2026-09-29-network-data-design.md +0 -78
  181. package/docs/superpowers/specs/2026-09-29-network-interception-design.md +0 -91
  182. package/docs/superpowers/specs/2026-09-29-network-tracking-design.md +0 -86
  183. package/docs/superpowers/specs/2026-09-29-page-api-design.md +0 -90
  184. package/docs/superpowers/specs/2026-09-29-preload-scripts-design.md +0 -82
  185. package/docs/superpowers/specs/2026-09-29-script-call-function-design.md +0 -107
  186. package/docs/superpowers/specs/2026-09-29-set-files-design.md +0 -62
  187. package/docs/superpowers/specs/2026-09-29-user-prompts-design.md +0 -75
  188. package/docs/superpowers/specs/2026-10-01-ci-design.md +0 -106
  189. package/docs/superpowers/specs/2026-10-01-test-api-alignment-design.md +0 -241
  190. package/public/logo-dark.svg +0 -236
  191. package/public/logo-light.svg +0 -262
  192. package/samurai.config.ts +0 -7
  193. package/src/assert/assertion-error.test.ts +0 -55
  194. package/src/assert/assertion-error.ts +0 -62
  195. package/src/assert/expect-locator.test.ts +0 -217
  196. package/src/assert/expect.test.ts +0 -73
  197. package/src/assert/expect.ts +0 -299
  198. package/src/browser/browser-context.browser-test.ts +0 -201
  199. package/src/browser/browser-context.test.ts +0 -407
  200. package/src/browser/browser-context.ts +0 -208
  201. package/src/browser/browser-finder.ts +0 -110
  202. package/src/browser/browser.test.ts +0 -88
  203. package/src/browser/browser.ts +0 -515
  204. package/src/browser/context-tree.test.ts +0 -86
  205. package/src/browser/context-tree.ts +0 -70
  206. package/src/browser/dialog.browser-test.ts +0 -144
  207. package/src/browser/dialog.test.ts +0 -322
  208. package/src/browser/dialog.ts +0 -83
  209. package/src/browser/download.test.ts +0 -161
  210. package/src/browser/download.ts +0 -172
  211. package/src/browser/downloads-dir.test.ts +0 -75
  212. package/src/browser/downloads-dir.ts +0 -39
  213. package/src/browser/emulation.browser-test.ts +0 -186
  214. package/src/browser/emulation.test.ts +0 -200
  215. package/src/browser/emulation.ts +0 -204
  216. package/src/browser/file-chooser.test.ts +0 -142
  217. package/src/browser/file-chooser.ts +0 -99
  218. package/src/browser/file-paths.ts +0 -18
  219. package/src/browser/goto.browser-test.ts +0 -44
  220. package/src/browser/navigation-error.ts +0 -26
  221. package/src/browser/navigation.browser-test.ts +0 -172
  222. package/src/browser/page-api.browser-test.ts +0 -228
  223. package/src/browser/page-logs.browser-test.ts +0 -119
  224. package/src/browser/page-logs.test.ts +0 -212
  225. package/src/browser/page-logs.ts +0 -208
  226. package/src/browser/page-wait.ts +0 -19
  227. package/src/browser/page.test.ts +0 -1567
  228. package/src/browser/page.ts +0 -1176
  229. package/src/browser/pdf.ts +0 -62
  230. package/src/browser/screenshot.ts +0 -65
  231. package/src/browser/transfers.browser-test.ts +0 -180
  232. package/src/config/config.ts +0 -33
  233. package/src/config/default.ts +0 -5
  234. package/src/config/fixtures.test.ts +0 -137
  235. package/src/config/mask.test.ts +0 -143
  236. package/src/config/mask.ts +0 -87
  237. package/src/config/run-settings.test.ts +0 -163
  238. package/src/config/run-settings.ts +0 -114
  239. package/src/config/secrets.ts +0 -70
  240. package/src/config/variables.ts +0 -31
  241. package/src/index.ts +0 -10
  242. package/src/locator/action-timeout-error.test.ts +0 -83
  243. package/src/locator/action-timeout-error.ts +0 -72
  244. package/src/locator/element-state.test.ts +0 -181
  245. package/src/locator/element-state.ts +0 -159
  246. package/src/locator/locate-nodes.browser-test.ts +0 -169
  247. package/src/locator/locator-actions.test.ts +0 -355
  248. package/src/locator/locator-chain.test.ts +0 -292
  249. package/src/locator/locator.browser-test.ts +0 -137
  250. package/src/locator/locator.test.ts +0 -179
  251. package/src/locator/locator.ts +0 -656
  252. package/src/locator/selector-errors.ts +0 -18
  253. package/src/locator/selector.test.ts +0 -124
  254. package/src/locator/selector.ts +0 -107
  255. package/src/locator/set-files.browser-test.ts +0 -141
  256. package/src/locator/set-files.test.ts +0 -191
  257. package/src/locator/text-locator.test.ts +0 -118
  258. package/src/logger/index.test.ts +0 -28
  259. package/src/logger/index.ts +0 -40
  260. package/src/network/data-collector.test.ts +0 -111
  261. package/src/network/data-collector.ts +0 -92
  262. package/src/network/network-data.browser-test.ts +0 -192
  263. package/src/network/network-tracker.browser-test.ts +0 -151
  264. package/src/network/network-tracker.test.ts +0 -307
  265. package/src/network/network-tracker.ts +0 -286
  266. package/src/network/network-wait.ts +0 -28
  267. package/src/network/response.test.ts +0 -93
  268. package/src/network/response.ts +0 -58
  269. package/src/network/router.browser-test.ts +0 -260
  270. package/src/network/router.test.ts +0 -528
  271. package/src/network/router.ts +0 -420
  272. package/src/network/url-match.test.ts +0 -69
  273. package/src/network/url-match.ts +0 -41
  274. package/src/runner/page-logs-report.test.ts +0 -144
  275. package/src/runner/page-logs-report.ts +0 -82
  276. package/src/runner/prepare-run.test.ts +0 -33
  277. package/src/runner/prepare-run.ts +0 -36
  278. package/src/runner/registry.test.ts +0 -59
  279. package/src/runner/registry.ts +0 -46
  280. package/src/runner/reporter.test.ts +0 -73
  281. package/src/runner/reporter.ts +0 -109
  282. package/src/runner/test-error.test.ts +0 -39
  283. package/src/runner/test-error.ts +0 -20
  284. package/src/runner/test-runner.ts +0 -184
  285. package/src/script/call-function.browser-test.ts +0 -112
  286. package/src/script/call-function.test.ts +0 -174
  287. package/src/script/call-function.ts +0 -113
  288. package/src/script/helpers.test.ts +0 -116
  289. package/src/script/helpers.ts +0 -68
  290. package/src/script/preload.browser-test.ts +0 -144
  291. package/src/script/preload.test.ts +0 -180
  292. package/src/script/preload.ts +0 -102
  293. package/src/script/serialize.test.ts +0 -182
  294. package/src/script/serialize.ts +0 -203
  295. package/src/testing/browser-fixture.ts +0 -18
  296. package/src/testing/fake-websocket.ts +0 -72
  297. package/src/testing/stub-connector.ts +0 -154
  298. package/src/tests/index.spec.ts +0 -30
  299. package/src/transport/bidi-connection.test.ts +0 -155
  300. package/src/transport/bidi-connection.ts +0 -256
  301. package/src/transport/bidi-error.ts +0 -23
  302. package/src/transport/foundation.browser-test.ts +0 -73
  303. package/src/transport/subscriptions.test.ts +0 -140
  304. package/src/transport/subscriptions.ts +0 -110
  305. package/src/types/bidi-commands.test-d.ts +0 -55
  306. package/src/wait/wait-until.test.ts +0 -117
  307. package/src/wait/wait-until.ts +0 -86
  308. package/tsconfig.json +0 -44
  309. /package/{src/api.ts → dist/api.d.ts} +0 -0
  310. /package/{src/locator/text-locator.ts → dist/locator/text-locator.js} +0 -0
  311. /package/{src → dist}/types/bidi-modules/browser.d.ts +0 -0
  312. /package/{src → dist}/types/bidi-modules/browsing-context.d.ts +0 -0
  313. /package/{src → dist}/types/bidi-modules/emulation.d.ts +0 -0
  314. /package/{src → dist}/types/bidi-modules/input.d.ts +0 -0
  315. /package/{src → dist}/types/bidi-modules/log.d.ts +0 -0
  316. /package/{src → dist}/types/bidi-modules/network.d.ts +0 -0
  317. /package/{src → dist}/types/bidi-modules/permissions.d.ts +0 -0
  318. /package/{src → dist}/types/bidi-modules/script.d.ts +0 -0
  319. /package/{src → dist}/types/bidi-modules/session.d.ts +0 -0
  320. /package/{src → dist}/types/bidi-modules/storage.d.ts +0 -0
  321. /package/{src → dist}/types/bidi-modules/web-extensions.d.ts +0 -0
  322. /package/{src → dist}/types/bidi-protocols/command.d.ts +0 -0
  323. /package/{src → dist}/types/bidi-protocols/error.d.ts +0 -0
  324. /package/{src → dist}/types/bidi-protocols/event.d.ts +0 -0
  325. /package/{src → dist}/types/bidi.d.ts +0 -0
  326. /package/{src → dist}/types/browser.d.ts +0 -0
  327. /package/{src → dist}/types/config.d.ts +0 -0
  328. /package/{src → dist}/types/env.d.ts +0 -0
@@ -1,1827 +0,0 @@
1
- # Auto-Waiting Actions Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Make `Locator.click()`, `fill()` and `focus()` wait until the element is ready for that action (with a clear error naming the unmet check), add `waitFor()`, `isEnabled()`, `isEditable()` and a `force` option, and share one retry mechanism with `expect`.
6
-
7
- **Architecture:** A generic `waitUntil(probe, isDone, { timeout })` loop (`src/wait/wait-until.ts`) replaces the polling loop inside `expect.ts`. For actions, each poll runs one browser script (`src/locator/element-state.ts`) that returns the element's full state (attached, visible, enabled, editable, box, hit target) as JSON; Node evaluates the per-action checks (stability = same box on two consecutive polls) and, once all pass, performs a real BiDi pointer click at the centre of the latest box. Timeouts throw `ActionTimeoutError` with a per-check ✓/✗/— summary.
8
-
9
- **Tech Stack:** TypeScript (`module: nodenext`, `strict`, `verbatimModuleSyntax`, `noUncheckedIndexedAccess`), tsx, Node 26 `node:test` + `node:assert/strict`, WebDriver BiDi (`script.evaluate`, `input.performActions`), Firefox.
10
-
11
- **Spec:** `docs/superpowers/specs/2026-09-29-auto-wait-design.md`
12
-
13
- ## Global Constraints
14
-
15
- - Execute on a new branch `feat/auto-wait` created from the current `feat/expect` HEAD (it builds on the `expect` feature and its fix wave).
16
- - No new dependencies. Relative imports use the `.js` extension; type-only imports use `import type`.
17
- - Timeout resolution everywhere: per-call `{ timeout }` → config `expect.timeout` → `5000` ms. Poll interval `100` ms.
18
- - Check names, in this order when present: `attached`, `visible`, `stable`, `enabled`, `editable`, `hit target`. Symbols: `✓` pass, `✗` fail, `—` pending (not evaluated because an earlier check failed).
19
- - Per action: `click` → attached, visible, stable, enabled, hit target (probe scrolls + hit-tests); `fill` → attached, visible, enabled, editable, hit target (probe scrolls + hit-tests); `focus` → attached (no scroll, no hit test); `force: true` → only `attached` (probe still scrolls for click/fill).
20
- - `click()` and `fill()` click with real BiDi pointer input at the centre of the **latest** probed box; `click()` / `focus()` resolve to `undefined`.
21
- - Error messages (exact):
22
- - `<action>(): could not read <selector> within <timeout>ms`
23
- - `<action>(): <selector> was not attached within <timeout>ms`
24
- - `<action>(): <selector> was not actionable within <timeout>ms\n <name> <symbol> <name> <symbol> …` with `hit target ✗ (covered by <description>)` when covered
25
- - `waitFor(): <selector> did not become <state> within <timeout>ms`
26
- - Unit tests (`npm test`, glob `src/**/*.test.ts`) never launch a browser and always pass an explicit `timeout` (so `samurai.config.ts` is never read). Browser tests live only in `*.browser-test.ts` files and run via `npm run test:browser`.
27
- - Never run `bun run dev` / `npm run dev` (it submits a real contact form).
28
- - `npx tsc --noEmit -p .` must show no errors other than the pre-existing `src/config/default.ts(3,14): error TS2739`. Ignore tsx's `DeprecationWarning: module.register()` (DEP0205); any other warning (e.g. unhandled rejection) is a defect.
29
- - Never stage `result/report.json` or `src/tests/index.spec.ts` (the user has uncommitted changes there); stage explicit paths only.
30
- - Every commit message ends with a blank line and `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
31
-
32
- ---
33
-
34
- ## File Structure
35
-
36
- | File | Status | Responsibility |
37
- |---|---|---|
38
- | `src/wait/wait-until.ts` | create | `waitUntil`, `resolveTimeout`, `WaitTimeoutError` — the one retry loop |
39
- | `src/wait/wait-until.test.ts` | create | unit tests for the loop |
40
- | `src/assert/expect.ts` | modify | `assertEventually` rewritten on `waitUntil`; own loop, `readBefore`, `DEADLINE`, `resolveTimeout` removed |
41
- | `src/locator/element-state.ts` | create | browser probe script, `parseElementState`, check evaluation, shared types |
42
- | `src/locator/element-state.test.ts` | create | unit tests |
43
- | `src/locator/action-timeout-error.ts` | create | `ActionTimeoutError` and its message format |
44
- | `src/locator/action-timeout-error.test.ts` | create | unit tests |
45
- | `src/locator/locator.ts` | modify | waiting `click`/`fill`/`focus`, `force`, `waitFor`, `isEnabled`, `isEditable`, `clickRect` param widened |
46
- | `src/locator/locator-actions.test.ts` | create | action/wait unit tests against the stub connector |
47
- | `src/locator/locator.test.ts` | modify | three existing tests adapted to the probing `click`/`fill` |
48
- | `src/testing/stub-connector.ts` | modify | also answers `browsingContext.navigate` |
49
- | `src/browser/page.ts` | modify | `navigateTo` keeps URLs that already have a scheme |
50
- | `src/browser/page.test.ts` | create | unit test for `navigateTo` |
51
- | `src/testing/browser-fixture.ts` | create | `setContent(page, html, setup)` for browser tests |
52
- | `src/locator/locator.browser-test.ts` | create | opt-in headless-Firefox tests |
53
- | `package.json` | modify | `test:browser` script |
54
-
55
- ---
56
-
57
- ### Task 1: `waitUntil` retry loop
58
-
59
- **Files:**
60
- - Create: `src/wait/wait-until.ts`
61
- - Test: `src/wait/wait-until.test.ts`
62
-
63
- **Interfaces:**
64
- - Consumes: `readConfig("expect")` from `src/config/config.ts` (`Promise<{ timeout?: number } | undefined>`).
65
- - Produces:
66
- - `export interface WaitOptions { timeout: number; interval?: number }`
67
- - `export class WaitTimeoutError<T> extends Error { readonly timeout: number; readonly last: T | undefined }` (`name === "WaitTimeoutError"`)
68
- - `export function resolveTimeout(perCall?: number): Promise<number>`
69
- - `export function waitUntil<T>(probe: () => Promise<T>, isDone: (current: T, previous: T | undefined) => boolean, options: WaitOptions): Promise<T>`
70
-
71
- - [ ] **Step 1: Write the failing test**
72
-
73
- Create `src/wait/wait-until.test.ts`:
74
-
75
- ```ts
76
- import { test } from "node:test";
77
- import assert from "node:assert/strict";
78
- import { resolveTimeout, waitUntil, WaitTimeoutError } from "./wait-until.js";
79
-
80
- /** Probe returning the given values in order; the last one repeats */
81
- function sequence<T>(...values: T[]) {
82
- let calls = 0;
83
- const probe = async () => values[Math.min(calls++, values.length - 1)]!;
84
- return { probe, calls: () => calls };
85
- }
86
-
87
- test("probes until isDone and resolves with that value", async () => {
88
- const { probe, calls } = sequence(1, 2, 3);
89
- assert.equal(await waitUntil(probe, (value) => value === 3, { timeout: 2000 }), 3);
90
- assert.equal(calls(), 3);
91
- });
92
-
93
- test("passes the previous value to isDone", async () => {
94
- const { probe } = sequence(1, 2, 2);
95
- const seen: Array<[number, number | undefined]> = [];
96
- await waitUntil(
97
- probe,
98
- (current, previous) => {
99
- seen.push([current, previous]);
100
- return current === previous;
101
- },
102
- { timeout: 2000 },
103
- );
104
- assert.deepEqual(seen, [
105
- [1, undefined],
106
- [2, 1],
107
- [2, 2],
108
- ]);
109
- });
110
-
111
- test("times out with the last value, ending at the deadline", async () => {
112
- const { probe, calls } = sequence("a", "b");
113
- const start = Date.now();
114
- await assert.rejects(waitUntil(probe, () => false, { timeout: 250 }), (err) => {
115
- assert.ok(err instanceof WaitTimeoutError);
116
- assert.equal(err.name, "WaitTimeoutError");
117
- assert.equal(err.timeout, 250);
118
- assert.equal(err.last, "b");
119
- return true;
120
- });
121
- const elapsed = Date.now() - start;
122
- // probes at ~0, ~100, ~200; the final sleep is clamped to the ~50ms left, and no probe starts after the deadline
123
- assert.ok(elapsed >= 250 && elapsed < 295, `elapsed ${elapsed}ms`);
124
- assert.equal(calls(), 3);
125
- });
126
-
127
- test("timeout 0 probes exactly once", async () => {
128
- const { probe, calls } = sequence(false);
129
- await assert.rejects(waitUntil(probe, (value) => value, { timeout: 0 }), WaitTimeoutError);
130
- assert.equal(calls(), 1);
131
- });
132
-
133
- test("timeout 0 resolves when the single probe is done", async () => {
134
- const { probe } = sequence(true);
135
- assert.equal(await waitUntil(probe, (value) => value, { timeout: 0 }), true);
136
- });
137
-
138
- test("probe errors are rethrown without retrying", async () => {
139
- let calls = 0;
140
- const probe = async () => {
141
- calls++;
142
- throw new Error("boom");
143
- };
144
- await assert.rejects(waitUntil(probe, () => true, { timeout: 2000 }), /boom/);
145
- assert.equal(calls, 1);
146
- });
147
-
148
- test("a probe that never settles stops at the deadline", async () => {
149
- const start = Date.now();
150
- await assert.rejects(
151
- waitUntil(() => new Promise<never>(() => {}), () => true, { timeout: 200 }),
152
- (err) => {
153
- assert.ok(err instanceof WaitTimeoutError);
154
- assert.equal(err.last, undefined);
155
- return true;
156
- },
157
- );
158
- assert.ok(Date.now() - start < 400);
159
- });
160
-
161
- test("a probe that hangs after earlier results reports the last completed value", async () => {
162
- let calls = 0;
163
- const probe = () => (calls++ === 0 ? Promise.resolve("first") : new Promise<string>(() => {}));
164
- await assert.rejects(waitUntil(probe, () => false, { timeout: 200 }), (err) => {
165
- assert.ok(err instanceof WaitTimeoutError);
166
- assert.equal(err.last, "first");
167
- return true;
168
- });
169
- });
170
-
171
- test("no timer outlives a resolved wait", async () => {
172
- const timers = () => process.getActiveResourcesInfo().filter((name) => name === "Timeout").length;
173
- const before = timers();
174
- await waitUntil(async () => true, (value) => value, { timeout: 10000 });
175
- assert.equal(timers(), before);
176
- });
177
-
178
- test("resolveTimeout prefers the per-call value", async () => {
179
- assert.equal(await resolveTimeout(1234), 1234);
180
- assert.equal(await resolveTimeout(0), 0);
181
- });
182
- ```
183
-
184
- - [ ] **Step 2: Run it to verify it fails**
185
-
186
- Run: `npm test`
187
- Expected: FAIL — `Cannot find module '.../src/wait/wait-until.js'`; the other 48 tests still pass.
188
-
189
- - [ ] **Step 3: Implement**
190
-
191
- Create `src/wait/wait-until.ts`:
192
-
193
- ```ts
194
- import { setTimeout as sleep } from "node:timers/promises";
195
- import { readConfig } from "../config/config.js";
196
-
197
- const DEFAULT_TIMEOUT = 5000;
198
- const DEFAULT_INTERVAL = 100;
199
-
200
- export interface WaitOptions {
201
- /** Total time (ms) to keep probing; 0 probes exactly once */
202
- timeout: number;
203
- /** Time (ms) between probes. @default 100 */
204
- interval?: number;
205
- }
206
-
207
- export class WaitTimeoutError<T> extends Error {
208
- public readonly timeout: number;
209
- /** Last completed probe result; undefined when no probe completed */
210
- public readonly last: T | undefined;
211
-
212
- constructor(timeout: number, last: T | undefined) {
213
- super(`Condition not met within ${timeout}ms`);
214
- this.name = "WaitTimeoutError";
215
- this.timeout = timeout;
216
- this.last = last;
217
- }
218
- }
219
-
220
- /** Per-call timeout, else config `expect.timeout`, else 5000 */
221
- export async function resolveTimeout(perCall?: number): Promise<number> {
222
- if (perCall !== undefined) return perCall;
223
- return (await readConfig("expect"))?.timeout ?? DEFAULT_TIMEOUT;
224
- }
225
-
226
- const DEADLINE = Symbol("deadline");
227
-
228
- /**
229
- * Races `probe()` against the time left. Resolves `{ value }` when the probe wins,
230
- * DEADLINE when the time runs out first; probe errors reject as-is.
231
- */
232
- function probeBefore<T>(
233
- probe: () => Promise<T>,
234
- remaining: number,
235
- ): Promise<{ value: T } | typeof DEADLINE> {
236
- const probing = probe();
237
- let timer: ReturnType<typeof setTimeout> | undefined;
238
- const expired = new Promise<typeof DEADLINE>((resolve) => {
239
- timer = setTimeout(() => resolve(DEADLINE), remaining);
240
- });
241
- // A probe that loses the race may still fail later; nobody is waiting for it any more
242
- probing.catch(() => {});
243
- return Promise.race([probing.then((value) => ({ value })), expired]).finally(() =>
244
- clearTimeout(timer),
245
- );
246
- }
247
-
248
- /**
249
- * Probes until `isDone(current, previous)` is true and resolves with that result.
250
- * Always probes at least once; with `timeout > 0` every probe races the time left.
251
- * Probe errors are rethrown immediately. Throws WaitTimeoutError at the deadline.
252
- */
253
- export async function waitUntil<T>(
254
- probe: () => Promise<T>,
255
- isDone: (current: T, previous: T | undefined) => boolean,
256
- { timeout, interval = DEFAULT_INTERVAL }: WaitOptions,
257
- ): Promise<T> {
258
- const deadline = Date.now() + timeout;
259
- let previous: T | undefined;
260
-
261
- for (let first = true; ; first = false) {
262
- const remaining = deadline - Date.now();
263
- // Only start a probe while time remains; a later one could only lose the race
264
- if (!first && remaining <= 0) throw new WaitTimeoutError(timeout, previous);
265
-
266
- // timeout 0 keeps the single probe un-raced so it always completes
267
- const result = timeout > 0 ? await probeBefore(probe, remaining) : { value: await probe() };
268
- if (result === DEADLINE) throw new WaitTimeoutError(timeout, previous);
269
- if (isDone(result.value, previous)) return result.value;
270
- previous = result.value;
271
-
272
- const left = deadline - Date.now();
273
- if (left <= 0) throw new WaitTimeoutError(timeout, previous);
274
- await sleep(Math.min(interval, left));
275
- }
276
- }
277
- ```
278
-
279
- - [ ] **Step 4: Run tests to verify they pass**
280
-
281
- Run: `npm test` (three times — the timing test must be stable)
282
- Expected: `ℹ pass 58`, `ℹ fail 0` each run.
283
-
284
- - [ ] **Step 5: Type-check**
285
-
286
- Run: `npx tsc --noEmit -p .`
287
- Expected: only the pre-existing `src/config/default.ts` error.
288
-
289
- - [ ] **Step 6: Commit**
290
-
291
- ```bash
292
- git add src/wait/wait-until.ts src/wait/wait-until.test.ts
293
- git commit -m "feat(wait): add shared waitUntil retry loop"
294
- ```
295
-
296
- ---
297
-
298
- ### Task 2: Run `expect` locator assertions on `waitUntil`
299
-
300
- **Files:**
301
- - Modify: `src/assert/expect.ts`
302
-
303
- **Interfaces:**
304
- - Consumes: `waitUntil`, `WaitTimeoutError`, `resolveTimeout` from `../wait/wait-until.js` (Task 1).
305
- - Produces: no API change. `expect.ts` exports stay exactly: `takePendingAssertions`, `LocatorAssertionOptions`, `ValueAssertions`, `LocatorAssertions`, `expect`.
306
-
307
- This is a behaviour-preserving refactor: the existing `src/assert/expect-locator.test.ts` and `src/assert/expect.test.ts` are the tests. Do not change any test file in this task.
308
-
309
- - [ ] **Step 1: Confirm the baseline is green**
310
-
311
- Run: `npm test`
312
- Expected: `ℹ pass 58`, `ℹ fail 0`.
313
-
314
- - [ ] **Step 2: Replace the imports and remove the old loop pieces**
315
-
316
- In `src/assert/expect.ts`:
317
-
318
- a) Replace the first five import lines
319
-
320
- ```ts
321
- import { isDeepStrictEqual } from "node:util";
322
- import { setTimeout as sleep } from "node:timers/promises";
323
- import { AssertionError } from "./assertion-error.js";
324
- import Locator from "../locator/locator.js";
325
- import { readConfig } from "../config/config.js";
326
- ```
327
-
328
- with
329
-
330
- ```ts
331
- import { isDeepStrictEqual } from "node:util";
332
- import { AssertionError } from "./assertion-error.js";
333
- import Locator from "../locator/locator.js";
334
- import { resolveTimeout, waitUntil, WaitTimeoutError } from "../wait/wait-until.js";
335
- ```
336
-
337
- b) Delete these declarations entirely (they now live in `wait-until.ts` or are no longer needed):
338
- - `const DEFAULT_EXPECT_TIMEOUT = 5000;`
339
- - `const POLL_INTERVAL = 100;`
340
- - the `async function resolveTimeout(options?: LocatorAssertionOptions) { … }` function
341
- - `const DEADLINE = Symbol("deadline");`
342
- - the `readBefore` function and its doc comment
343
-
344
- Keep `LocatorAssertionOptions` (its doc comment stays accurate).
345
-
346
- - [ ] **Step 3: Rewrite `assertEventually`**
347
-
348
- Replace the whole `private assertEventually<V>(…) { … }` method in `LocatorAssertions` with:
349
-
350
- ```ts
351
- /** Re-reads the element until `check` passes or the timeout expires */
352
- private assertEventually<V>(
353
- matcher: string,
354
- expected: unknown,
355
- read: () => Promise<V>,
356
- check: (actual: V) => boolean,
357
- options?: LocatorAssertionOptions,
358
- ): Promise<void> {
359
- const name = this.negated ? `not.${matcher}` : matcher;
360
-
361
- return track(
362
- name,
363
- (async () => {
364
- const timeout = await resolveTimeout(options?.timeout);
365
- try {
366
- await waitUntil(read, (actual) => check(actual) !== this.negated, { timeout });
367
- } catch (err) {
368
- if (!(err instanceof WaitTimeoutError)) throw err;
369
- // Reads never resolve to undefined, so an undefined `last` means no read completed
370
- if (err.last === undefined) {
371
- throw new Error(
372
- `expect(locator).${name}: could not read ${this.locator.selector} within ${timeout}ms`,
373
- );
374
- }
375
- throw new AssertionError({
376
- matcher: name,
377
- expected,
378
- actual: err.last,
379
- locator: this.locator.selector,
380
- });
381
- }
382
- })(),
383
- );
384
- }
385
- ```
386
-
387
- - [ ] **Step 4: Check nothing references the removed names**
388
-
389
- Run: `grep -n "readBefore\|DEADLINE\|POLL_INTERVAL\|DEFAULT_EXPECT_TIMEOUT\|sleep(\|readConfig" src/assert/expect.ts`
390
- Expected: no output.
391
-
392
- - [ ] **Step 5: Run tests and type-check**
393
-
394
- Run: `npm test` (three times)
395
- Expected: `ℹ pass 58`, `ℹ fail 0` each run — the same tests as Step 1, all unchanged.
396
- Run: `npx tsc --noEmit -p .`
397
- Expected: only the pre-existing `src/config/default.ts` error.
398
-
399
- - [ ] **Step 6: Commit**
400
-
401
- ```bash
402
- git add src/assert/expect.ts
403
- git commit -m "refactor(expect): poll locator assertions with waitUntil"
404
- ```
405
-
406
- ---
407
-
408
- ### Task 3: Element state probe and check evaluation
409
-
410
- **Files:**
411
- - Create: `src/locator/element-state.ts`
412
- - Test: `src/locator/element-state.test.ts`
413
-
414
- **Interfaces:**
415
- - Consumes: `RemoteValue` type from `src/types/bidi-modules/script.js`.
416
- - Produces:
417
- - `export interface ElementBox { x: number; y: number; width: number; height: number }`
418
- - `export interface ElementState { attached: boolean; visible: boolean; enabled: boolean; editable: boolean; box: ElementBox | null; hitTarget: string | null }`
419
- - `export interface ProbeOptions { scroll: boolean; hitTest: boolean }`
420
- - `export type WaitForState = "attached" | "detached" | "visible" | "hidden"`
421
- - `export type CheckName = "attached" | "visible" | "stable" | "enabled" | "editable" | "hit target"`
422
- - `export type CheckResult = "pass" | "fail" | "pending"`
423
- - `export type CheckResults = Partial<Record<CheckName, CheckResult>>`
424
- - `export function elementStateScript(elementExpression: string, options: ProbeOptions): string`
425
- - `export function parseElementState(value: RemoteValue): ElementState`
426
- - `export function evaluateChecks(required: readonly CheckName[], current: ElementState, previous: ElementState | undefined): CheckResults`
427
- - `export function allPass(results: CheckResults): boolean`
428
-
429
- - [ ] **Step 1: Write the failing test**
430
-
431
- Create `src/locator/element-state.test.ts`:
432
-
433
- ```ts
434
- import { test } from "node:test";
435
- import assert from "node:assert/strict";
436
- import {
437
- allPass,
438
- elementStateScript,
439
- evaluateChecks,
440
- parseElementState,
441
- type CheckName,
442
- type ElementState,
443
- } from "./element-state.js";
444
-
445
- const EL = `document.evaluate("//button", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue`;
446
- const READY: ElementState = {
447
- attached: true,
448
- visible: true,
449
- enabled: true,
450
- editable: true,
451
- box: { x: 10, y: 20, width: 100, height: 40 },
452
- hitTarget: "self",
453
- };
454
- const ALL: CheckName[] = ["attached", "visible", "stable", "enabled", "editable", "hit target"];
455
-
456
- test("the script compiles for every option combination", () => {
457
- for (const scroll of [false, true]) {
458
- for (const hitTest of [false, true]) {
459
- const script = elementStateScript(EL, { scroll, hitTest });
460
- assert.doesNotThrow(() => new Function(script), script);
461
- }
462
- }
463
- });
464
-
465
- test("the script embeds the element expression", () => {
466
- assert.ok(elementStateScript(EL, { scroll: false, hitTest: false }).includes(EL));
467
- });
468
-
469
- test("scrolling and hit testing are only included when requested", () => {
470
- const plain = elementStateScript(EL, { scroll: false, hitTest: false });
471
- assert.ok(!plain.includes("scrollIntoView"));
472
- assert.ok(!plain.includes("elementFromPoint"));
473
- const full = elementStateScript(EL, { scroll: true, hitTest: true });
474
- assert.ok(full.includes("scrollIntoView"));
475
- assert.ok(full.includes("elementFromPoint"));
476
- });
477
-
478
- test("parseElementState reads the JSON state", () => {
479
- assert.deepEqual(parseElementState({ type: "string", value: JSON.stringify(READY) }), READY);
480
- });
481
-
482
- test("parseElementState rejects a non-string result", () => {
483
- assert.throws(
484
- () => parseElementState({ type: "null" }),
485
- /Expected an element state string but received "null"/,
486
- );
487
- });
488
-
489
- test("parseElementState rejects malformed state", () => {
490
- assert.throws(
491
- () => parseElementState({ type: "string", value: "not json" }),
492
- /Malformed element state/,
493
- );
494
- assert.throws(
495
- () => parseElementState({ type: "string", value: JSON.stringify({ attached: true }) }),
496
- /Malformed element state/,
497
- );
498
- });
499
-
500
- test("evaluateChecks passes an actionable element whose box did not move", () => {
501
- const results = evaluateChecks(ALL, READY, READY);
502
- assert.deepEqual(results, {
503
- attached: "pass",
504
- visible: "pass",
505
- stable: "pass",
506
- enabled: "pass",
507
- editable: "pass",
508
- "hit target": "pass",
509
- });
510
- assert.equal(allPass(results), true);
511
- });
512
-
513
- test("the first failing check marks later checks pending", () => {
514
- const results = evaluateChecks(ALL, { ...READY, visible: false }, READY);
515
- assert.deepEqual(results, {
516
- attached: "pass",
517
- visible: "fail",
518
- stable: "pending",
519
- enabled: "pending",
520
- editable: "pending",
521
- "hit target": "pending",
522
- });
523
- assert.equal(allPass(results), false);
524
- });
525
-
526
- test("stable needs a previous probe with the same box", () => {
527
- const stable = (previous: ElementState | undefined) =>
528
- evaluateChecks(["stable"], READY, previous).stable;
529
- assert.equal(stable(undefined), "fail");
530
- assert.equal(stable({ ...READY, box: { ...READY.box!, x: 11 } }), "fail");
531
- assert.equal(stable({ ...READY, box: null }), "fail");
532
- assert.equal(stable(READY), "pass");
533
- });
534
-
535
- test("hit target passes only when the element itself is hit", () => {
536
- const hit = (hitTarget: string | null) =>
537
- evaluateChecks(["hit target"], { ...READY, hitTarget }, READY)["hit target"];
538
- assert.equal(hit("self"), "pass");
539
- assert.equal(hit("div#overlay"), "fail");
540
- assert.equal(hit(null), "fail");
541
- });
542
-
543
- test("only the required checks are evaluated, in the given order", () => {
544
- assert.deepEqual(Object.keys(evaluateChecks(["attached", "enabled"], READY, undefined)), [
545
- "attached",
546
- "enabled",
547
- ]);
548
- });
549
- ```
550
-
551
- - [ ] **Step 2: Run it to verify it fails**
552
-
553
- Run: `npm test`
554
- Expected: FAIL — `Cannot find module '.../src/locator/element-state.js'`; the other 58 tests pass.
555
-
556
- - [ ] **Step 3: Implement**
557
-
558
- Create `src/locator/element-state.ts`:
559
-
560
- ```ts
561
- import type { RemoteValue } from "../types/bidi-modules/script.js";
562
-
563
- export interface ElementBox {
564
- x: number;
565
- y: number;
566
- width: number;
567
- height: number;
568
- }
569
-
570
- export interface ElementState {
571
- attached: boolean;
572
- visible: boolean;
573
- enabled: boolean;
574
- editable: boolean;
575
- /** Viewport coordinates after any scroll; null when not attached */
576
- box: ElementBox | null;
577
- /**
578
- * "self" when a click at the element's centre reaches it, otherwise a description of
579
- * the covering element ("nothing" when no element is there); null when not checked
580
- */
581
- hitTarget: string | null;
582
- }
583
-
584
- export interface ProbeOptions {
585
- /** Scroll the element into view first when it is not fully inside the viewport */
586
- scroll: boolean;
587
- /** Check which element receives a click at the element's centre */
588
- hitTest: boolean;
589
- }
590
-
591
- export type WaitForState = "attached" | "detached" | "visible" | "hidden";
592
- export type CheckName = "attached" | "visible" | "stable" | "enabled" | "editable" | "hit target";
593
- export type CheckResult = "pass" | "fail" | "pending";
594
- /** Results keyed by check name, in the order the checks were evaluated */
595
- export type CheckResults = Partial<Record<CheckName, CheckResult>>;
596
-
597
- const TEXT_INPUT_TYPES = ["text", "search", "email", "url", "tel", "password", "number"];
598
-
599
- const SCROLL_INTO_VIEW = `
600
- const before = el.getBoundingClientRect();
601
- const inViewport = before.top >= 0 && before.left >= 0 &&
602
- before.bottom <= window.innerHeight && before.right <= window.innerWidth;
603
- if (!inViewport) el.scrollIntoView({ block: "center", inline: "center" });`;
604
-
605
- const HIT_TEST = `
606
- const top = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
607
- hitTarget = !top ? "nothing"
608
- : top === el || el.contains(top) ? "self"
609
- : top.tagName.toLowerCase() + (top.id ? "#" + top.id : "") +
610
- Array.from(top.classList, (name) => "." + name).join("");`;
611
-
612
- /** Script returning the element's state as a JSON string in one round trip */
613
- export function elementStateScript(
614
- elementExpression: string,
615
- { scroll, hitTest }: ProbeOptions,
616
- ): string {
617
- return `(() => {
618
- const el = ${elementExpression};
619
- if (!el) {
620
- return JSON.stringify({ attached: false, visible: false, enabled: false, editable: false, box: null, hitTarget: null });
621
- }${scroll ? SCROLL_INTO_VIEW : ""}
622
- const rect = el.getBoundingClientRect();
623
- const style = getComputedStyle(el);
624
- const visible = rect.width > 0 && rect.height > 0 &&
625
- style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0";
626
- const enabled = !el.matches(":disabled") && el.getAttribute("aria-disabled") !== "true";
627
- const tag = el.tagName.toLowerCase();
628
- const typeable =
629
- (tag === "input" && ${JSON.stringify(TEXT_INPUT_TYPES)}.includes((el.getAttribute("type") || "text").toLowerCase())) ||
630
- tag === "textarea" || el.isContentEditable;
631
- const editable = enabled && !el.readOnly && typeable;
632
- let hitTarget = null;${hitTest ? HIT_TEST : ""}
633
- return JSON.stringify({
634
- attached: true, visible, enabled, editable,
635
- box: { x: rect.left, y: rect.top, width: rect.width, height: rect.height },
636
- hitTarget,
637
- });
638
- })()`;
639
- }
640
-
641
- /** Parses the probe result; throws on anything that is not a complete state */
642
- export function parseElementState(value: RemoteValue): ElementState {
643
- if (value.type !== "string") {
644
- throw new Error(`Expected an element state string but received "${value.type}"`);
645
- }
646
- let state: Partial<ElementState>;
647
- try {
648
- state = JSON.parse(value.value) as Partial<ElementState>;
649
- } catch {
650
- throw new Error(`Malformed element state: ${value.value}`);
651
- }
652
- const flags = ["attached", "visible", "enabled", "editable"] as const;
653
- if (
654
- typeof state !== "object" ||
655
- state === null ||
656
- flags.some((flag) => typeof state[flag] !== "boolean") ||
657
- !("box" in state) ||
658
- !("hitTarget" in state)
659
- ) {
660
- throw new Error(`Malformed element state: ${value.value}`);
661
- }
662
- return state as ElementState;
663
- }
664
-
665
- function sameBox(a: ElementBox | null, b: ElementBox | null) {
666
- return (
667
- a !== null &&
668
- b !== null &&
669
- a.x === b.x &&
670
- a.y === b.y &&
671
- a.width === b.width &&
672
- a.height === b.height
673
- );
674
- }
675
-
676
- function checkPasses(name: CheckName, current: ElementState, previous: ElementState | undefined) {
677
- switch (name) {
678
- case "attached":
679
- return current.attached;
680
- case "visible":
681
- return current.visible;
682
- case "stable":
683
- return sameBox(current.box, previous?.box ?? null);
684
- case "enabled":
685
- return current.enabled;
686
- case "editable":
687
- return current.editable;
688
- case "hit target":
689
- return current.hitTarget === "self";
690
- }
691
- }
692
-
693
- /** Evaluates the required checks in order; after the first failure the rest are pending */
694
- export function evaluateChecks(
695
- required: readonly CheckName[],
696
- current: ElementState,
697
- previous: ElementState | undefined,
698
- ): CheckResults {
699
- const results: CheckResults = {};
700
- let failed = false;
701
- for (const name of required) {
702
- if (failed) {
703
- results[name] = "pending";
704
- continue;
705
- }
706
- const pass = checkPasses(name, current, previous);
707
- results[name] = pass ? "pass" : "fail";
708
- failed = !pass;
709
- }
710
- return results;
711
- }
712
-
713
- export function allPass(results: CheckResults): boolean {
714
- return Object.values(results).every((result) => result === "pass");
715
- }
716
- ```
717
-
718
- - [ ] **Step 4: Run tests to verify they pass**
719
-
720
- Run: `npm test`
721
- Expected: `ℹ pass 69`, `ℹ fail 0`.
722
-
723
- - [ ] **Step 5: Type-check**
724
-
725
- Run: `npx tsc --noEmit -p .`
726
- Expected: only the pre-existing `src/config/default.ts` error.
727
-
728
- - [ ] **Step 6: Commit**
729
-
730
- ```bash
731
- git add src/locator/element-state.ts src/locator/element-state.test.ts
732
- git commit -m "feat(locator): add element state probe and actionability checks"
733
- ```
734
-
735
- ---
736
-
737
- ### Task 4: `ActionTimeoutError`
738
-
739
- **Files:**
740
- - Create: `src/locator/action-timeout-error.ts`
741
- - Test: `src/locator/action-timeout-error.test.ts`
742
-
743
- **Interfaces:**
744
- - Consumes: `CheckName`, `CheckResult`, `CheckResults`, `WaitForState` types from `./element-state.js` (Task 3).
745
- - Produces:
746
- - `export type ActionTimeoutReason = "unreadable" | "not-attached" | "not-actionable" | "wrong-state"`
747
- - `export interface ActionTimeoutDetails { action: string; selector: string; timeout: number; reason: ActionTimeoutReason; checks?: CheckResults | undefined; coveredBy?: string | undefined; state?: WaitForState | undefined }`
748
- - `export class ActionTimeoutError extends Error` with readonly `action`, `selector`, `timeout`, `reason`, `checks`, `coveredBy`, `state`; `name === "ActionTimeoutError"`.
749
-
750
- - [ ] **Step 1: Write the failing test**
751
-
752
- Create `src/locator/action-timeout-error.test.ts`:
753
-
754
- ```ts
755
- import { test } from "node:test";
756
- import assert from "node:assert/strict";
757
- import { ActionTimeoutError } from "./action-timeout-error.js";
758
-
759
- const base = { action: "click", selector: '//button[@type="submit"]', timeout: 5000 } as const;
760
-
761
- test("unreadable: no probe completed", () => {
762
- const err = new ActionTimeoutError({ ...base, reason: "unreadable" });
763
- assert.equal(err.message, 'click(): could not read //button[@type="submit"] within 5000ms');
764
- });
765
-
766
- test("not attached", () => {
767
- const err = new ActionTimeoutError({
768
- ...base,
769
- reason: "not-attached",
770
- checks: { attached: "fail", visible: "pending" },
771
- });
772
- assert.equal(err.message, 'click(): //button[@type="submit"] was not attached within 5000ms');
773
- });
774
-
775
- test("wrong state for waitFor", () => {
776
- const err = new ActionTimeoutError({
777
- action: "waitFor",
778
- selector: '//div[@class="spinner"]',
779
- timeout: 300,
780
- reason: "wrong-state",
781
- state: "hidden",
782
- });
783
- assert.equal(err.message, 'waitFor(): //div[@class="spinner"] did not become hidden within 300ms');
784
- });
785
-
786
- test("not actionable lists every check in order", () => {
787
- const err = new ActionTimeoutError({
788
- ...base,
789
- reason: "not-actionable",
790
- checks: {
791
- attached: "pass",
792
- visible: "pass",
793
- stable: "pass",
794
- enabled: "fail",
795
- "hit target": "pending",
796
- },
797
- });
798
- assert.equal(
799
- err.message,
800
- 'click(): //button[@type="submit"] was not actionable within 5000ms\n' +
801
- " attached ✓ visible ✓ stable ✓ enabled ✗ hit target —",
802
- );
803
- });
804
-
805
- test("a failed hit target names the covering element", () => {
806
- const err = new ActionTimeoutError({
807
- ...base,
808
- reason: "not-actionable",
809
- checks: {
810
- attached: "pass",
811
- visible: "pass",
812
- stable: "pass",
813
- enabled: "pass",
814
- "hit target": "fail",
815
- },
816
- coveredBy: "div#cookie-banner.overlay",
817
- });
818
- assert.equal(
819
- err.message,
820
- 'click(): //button[@type="submit"] was not actionable within 5000ms\n' +
821
- " attached ✓ visible ✓ stable ✓ enabled ✓ hit target ✗ (covered by div#cookie-banner.overlay)",
822
- );
823
- });
824
-
825
- test("exposes its details", () => {
826
- const checks = { attached: "fail" } as const;
827
- const err = new ActionTimeoutError({ ...base, reason: "not-attached", checks });
828
- assert.ok(err instanceof Error);
829
- assert.equal(err.name, "ActionTimeoutError");
830
- assert.equal(err.action, "click");
831
- assert.equal(err.selector, '//button[@type="submit"]');
832
- assert.equal(err.timeout, 5000);
833
- assert.equal(err.reason, "not-attached");
834
- assert.deepEqual(err.checks, checks);
835
- assert.equal(err.coveredBy, undefined);
836
- assert.equal(err.state, undefined);
837
- });
838
- ```
839
-
840
- - [ ] **Step 2: Run it to verify it fails**
841
-
842
- Run: `npm test`
843
- Expected: FAIL — `Cannot find module '.../src/locator/action-timeout-error.js'`; the other 69 tests pass.
844
-
845
- - [ ] **Step 3: Implement**
846
-
847
- Create `src/locator/action-timeout-error.ts`:
848
-
849
- ```ts
850
- import type { CheckName, CheckResult, CheckResults, WaitForState } from "./element-state.js";
851
-
852
- export type ActionTimeoutReason = "unreadable" | "not-attached" | "not-actionable" | "wrong-state";
853
-
854
- export interface ActionTimeoutDetails {
855
- /** Locator method that timed out, e.g. "click" */
856
- action: string;
857
- selector: string;
858
- timeout: number;
859
- reason: ActionTimeoutReason;
860
- /** Check results from the last probe, in check order */
861
- checks?: CheckResults | undefined;
862
- /** Description of the element covering the target, when the hit-target check failed */
863
- coveredBy?: string | undefined;
864
- /** Target state, for waitFor */
865
- state?: WaitForState | undefined;
866
- }
867
-
868
- const SYMBOLS: Record<CheckResult, string> = { pass: "✓", fail: "✗", pending: "—" };
869
-
870
- function formatMessage({
871
- action,
872
- selector,
873
- timeout,
874
- reason,
875
- checks,
876
- coveredBy,
877
- state,
878
- }: ActionTimeoutDetails) {
879
- switch (reason) {
880
- case "unreadable":
881
- return `${action}(): could not read ${selector} within ${timeout}ms`;
882
- case "not-attached":
883
- return `${action}(): ${selector} was not attached within ${timeout}ms`;
884
- case "wrong-state":
885
- return `${action}(): ${selector} did not become ${state} within ${timeout}ms`;
886
- case "not-actionable": {
887
- const summary = (Object.entries(checks ?? {}) as Array<[CheckName, CheckResult]>)
888
- .map(([name, result]) => {
889
- const covered =
890
- name === "hit target" && result === "fail" && coveredBy
891
- ? ` (covered by ${coveredBy})`
892
- : "";
893
- return `${name} ${SYMBOLS[result]}${covered}`;
894
- })
895
- .join(" ");
896
- return `${action}(): ${selector} was not actionable within ${timeout}ms\n ${summary}`;
897
- }
898
- }
899
- }
900
-
901
- export class ActionTimeoutError extends Error {
902
- public readonly action: string;
903
- public readonly selector: string;
904
- public readonly timeout: number;
905
- public readonly reason: ActionTimeoutReason;
906
- public readonly checks: CheckResults | undefined;
907
- public readonly coveredBy: string | undefined;
908
- public readonly state: WaitForState | undefined;
909
-
910
- constructor(details: ActionTimeoutDetails) {
911
- super(formatMessage(details));
912
- this.name = "ActionTimeoutError";
913
- this.action = details.action;
914
- this.selector = details.selector;
915
- this.timeout = details.timeout;
916
- this.reason = details.reason;
917
- this.checks = details.checks;
918
- this.coveredBy = details.coveredBy;
919
- this.state = details.state;
920
- }
921
- }
922
- ```
923
-
924
- - [ ] **Step 4: Run tests to verify they pass**
925
-
926
- Run: `npm test`
927
- Expected: `ℹ pass 75`, `ℹ fail 0`.
928
-
929
- - [ ] **Step 5: Type-check**
930
-
931
- Run: `npx tsc --noEmit -p .`
932
- Expected: only the pre-existing `src/config/default.ts` error.
933
-
934
- - [ ] **Step 6: Commit**
935
-
936
- ```bash
937
- git add src/locator/action-timeout-error.ts src/locator/action-timeout-error.test.ts
938
- git commit -m "feat(locator): add ActionTimeoutError with per-check summary"
939
- ```
940
-
941
- ---
942
-
943
- ### Task 5: Waiting `click`, `fill`, `focus`, `force`, `isEnabled`, `isEditable`
944
-
945
- **Files:**
946
- - Modify: `src/locator/locator.ts`
947
- - Modify: `src/locator/locator.test.ts` (three existing tests adapted)
948
- - Test: `src/locator/locator-actions.test.ts` (new)
949
-
950
- **Interfaces:**
951
- - Consumes: Task 1 (`waitUntil`, `WaitTimeoutError`, `resolveTimeout`), Task 3 (`elementStateScript`, `parseElementState`, `evaluateChecks`, `allPass`, types), Task 4 (`ActionTimeoutError`); existing `stubConnector(...responses)` → `{ connector, expressions, sent }` where `StubResponse = RemoteValue | { exception: string } | { hang: true }` and `input.performActions` resolves `{}`.
952
- - Produces (on `Locator`):
953
- - `export interface ActionOptions { timeout?: number; force?: boolean }`
954
- - `export type { WaitForState } from "./element-state.js"` (re-export)
955
- - `click(options?: ActionOptions): Promise<void>`
956
- - `fill(value: string, options?: ActionOptions): Promise<void>`
957
- - `focus(options?: ActionOptions): Promise<void>`
958
- - `isEnabled(): Promise<boolean>`, `isEditable(): Promise<boolean>`
959
- - `clickRect(rect: Pick<ElementRectangle, "x" | "y" | "width" | "height">): Promise<void>` (param widened; behaviour unchanged)
960
- - private `probeState(options: ProbeOptions): Promise<ElementState>` (Task 6 uses it)
961
-
962
- - [ ] **Step 1: Write the failing tests**
963
-
964
- Create `src/locator/locator-actions.test.ts`:
965
-
966
- ```ts
967
- import { test } from "node:test";
968
- import assert from "node:assert/strict";
969
- import Locator from "./locator.js";
970
- import { ActionTimeoutError } from "./action-timeout-error.js";
971
- import type { ElementState } from "./element-state.js";
972
- import { stubConnector, type StubResponse } from "../testing/stub-connector.js";
973
-
974
- const BOX = { x: 10, y: 20, width: 100, height: 40 };
975
-
976
- /** Element state response; defaults describe an element ready for any action */
977
- function state(overrides: Partial<ElementState> = {}): StubResponse {
978
- return {
979
- type: "string",
980
- value: JSON.stringify({
981
- attached: true,
982
- visible: true,
983
- enabled: true,
984
- editable: true,
985
- box: BOX,
986
- hitTarget: "self",
987
- ...overrides,
988
- }),
989
- };
990
- }
991
-
992
- const detached = state({
993
- attached: false,
994
- visible: false,
995
- enabled: false,
996
- editable: false,
997
- box: null,
998
- hitTarget: null,
999
- });
1000
-
1001
- function locatorWith(xpath: string, ...responses: StubResponse[]) {
1002
- const stub = stubConnector(...responses);
1003
- return { locator: new Locator(xpath, stub.connector, "ctx"), ...stub };
1004
- }
1005
-
1006
- type Performed = {
1007
- actions: Array<{
1008
- type: string;
1009
- actions: Array<{ type: string; x?: number; y?: number; value?: string }>;
1010
- }>;
1011
- };
1012
- type Sent = Array<{ method: string; params: unknown }>;
1013
-
1014
- function performed(sent: Sent, sourceType: string) {
1015
- return sent
1016
- .filter(({ method }) => method === "input.performActions")
1017
- .map(({ params }) => (params as Performed).actions[0]!)
1018
- .filter((source) => source.type === sourceType);
1019
- }
1020
-
1021
- /** [x, y] of the pointerMove of every pointer click sent */
1022
- function pointerClicks(sent: Sent) {
1023
- return performed(sent, "pointer").map((source) => [source.actions[0]!.x, source.actions[0]!.y]);
1024
- }
1025
-
1026
- function keysTyped(sent: Sent) {
1027
- return performed(sent, "key")
1028
- .flatMap((source) => source.actions.map((action) => action.value))
1029
- .join("");
1030
- }
1031
-
1032
- test("click() retries through every check, then clicks the latest box centre once", async () => {
1033
- const { locator, sent, expressions } = locatorWith(
1034
- "button",
1035
- detached, // attached ✗
1036
- state({ visible: false }), // visible ✗
1037
- state({ box: { ...BOX, x: 0 } }), // stable ✗ (moved)
1038
- state(), // stable ✗ (moved back)
1039
- state({ enabled: false }), // enabled ✗
1040
- state({ hitTarget: "div#overlay" }), // hit target ✗
1041
- state(), // actionable
1042
- );
1043
- assert.equal(await locator.click({ timeout: 3000 }), undefined);
1044
- assert.equal(expressions.length, 7);
1045
- assert.ok(expressions[0]!.includes("scrollIntoView"));
1046
- assert.ok(expressions[0]!.includes("elementFromPoint"));
1047
- assert.deepEqual(pointerClicks(sent), [[60, 40]]);
1048
- });
1049
-
1050
- test("click() needs the same box on two probes, so a ready element is probed twice", async () => {
1051
- const { locator, expressions } = locatorWith("button", state());
1052
- await locator.click({ timeout: 3000 });
1053
- assert.equal(expressions.length, 2);
1054
- });
1055
-
1056
- test("click() timeout lists the checks of the last probe", async () => {
1057
- const { locator, sent } = locatorWith("button", state({ enabled: false }));
1058
- await assert.rejects(locator.click({ timeout: 250 }), (err) => {
1059
- assert.ok(err instanceof ActionTimeoutError);
1060
- assert.equal(err.reason, "not-actionable");
1061
- assert.equal(
1062
- err.message,
1063
- "click(): //button was not actionable within 250ms\n" +
1064
- " attached ✓ visible ✓ stable ✓ enabled ✗ hit target —",
1065
- );
1066
- return true;
1067
- });
1068
- assert.deepEqual(pointerClicks(sent), []);
1069
- });
1070
-
1071
- test("click() timeout names a covering element", async () => {
1072
- const { locator } = locatorWith("button", state({ hitTarget: "div#cookie-banner.overlay" }));
1073
- await assert.rejects(
1074
- locator.click({ timeout: 250 }),
1075
- new ActionTimeoutError({
1076
- action: "click",
1077
- selector: "//button",
1078
- timeout: 250,
1079
- reason: "not-actionable",
1080
- checks: {
1081
- attached: "pass",
1082
- visible: "pass",
1083
- stable: "pass",
1084
- enabled: "pass",
1085
- "hit target": "fail",
1086
- },
1087
- coveredBy: "div#cookie-banner.overlay",
1088
- }),
1089
- );
1090
- });
1091
-
1092
- test("click() on an element that never appears reports it was not attached", async () => {
1093
- const { locator } = locatorWith("button", detached);
1094
- await assert.rejects(
1095
- locator.click({ timeout: 250 }),
1096
- /^ActionTimeoutError: click\(\): \/\/button was not attached within 250ms$/,
1097
- );
1098
- });
1099
-
1100
- test("click() when no probe completes reports it could not read the element", async () => {
1101
- const { locator } = locatorWith("button", { hang: true });
1102
- await assert.rejects(locator.click({ timeout: 250 }), (err) => {
1103
- assert.ok(err instanceof ActionTimeoutError);
1104
- assert.equal(err.reason, "unreadable");
1105
- assert.equal(err.message, "click(): could not read //button within 250ms");
1106
- return true;
1107
- });
1108
- });
1109
-
1110
- test("force skips every check except attached", async () => {
1111
- const { locator, sent, expressions } = locatorWith(
1112
- "button",
1113
- state({ visible: false, enabled: false, hitTarget: "div#overlay" }),
1114
- );
1115
- await locator.click({ force: true, timeout: 1000 });
1116
- assert.equal(expressions.length, 1);
1117
- assert.ok(expressions[0]!.includes("scrollIntoView"));
1118
- assert.deepEqual(pointerClicks(sent), [[60, 40]]);
1119
- });
1120
-
1121
- test("force still waits for the element to be attached", async () => {
1122
- const { locator } = locatorWith("button", detached);
1123
- await assert.rejects(
1124
- locator.click({ force: true, timeout: 250 }),
1125
- /was not attached within 250ms/,
1126
- );
1127
- });
1128
-
1129
- test("fill() waits until the field is editable, then clicks, selects and types", async () => {
1130
- const { locator, sent, expressions } = locatorWith(
1131
- "input",
1132
- state({ editable: false }),
1133
- state(),
1134
- { type: "undefined" },
1135
- );
1136
- await locator.fill("ab", { timeout: 3000 });
1137
- assert.equal(expressions.length, 3);
1138
- assert.match(expressions[2]!, /\.select\(\)/);
1139
- assert.deepEqual(pointerClicks(sent), [[60, 40]]);
1140
- assert.equal(keysTyped(sent), "aabb");
1141
- });
1142
-
1143
- test("fill() timeout lists the fill checks", async () => {
1144
- const { locator } = locatorWith("input", state({ editable: false }));
1145
- await assert.rejects(
1146
- locator.fill("x", { timeout: 250 }),
1147
- (err) =>
1148
- err instanceof ActionTimeoutError &&
1149
- err.message ===
1150
- "fill(): //input was not actionable within 250ms\n" +
1151
- " attached ✓ visible ✓ enabled ✓ editable ✗ hit target —",
1152
- );
1153
- });
1154
-
1155
- test("focus() waits only for the element to be attached", async () => {
1156
- const { locator, expressions } = locatorWith(
1157
- "input",
1158
- detached,
1159
- state({ visible: false, enabled: false }),
1160
- { type: "undefined" },
1161
- );
1162
- assert.equal(await locator.focus({ timeout: 3000 }), undefined);
1163
- assert.equal(expressions.length, 3);
1164
- assert.ok(!expressions[0]!.includes("scrollIntoView"));
1165
- assert.match(expressions[2]!, /\.focus\(\)$/);
1166
- });
1167
-
1168
- test("isEnabled() and isEditable() read the state without waiting", async () => {
1169
- assert.equal(await locatorWith("button", state()).locator.isEnabled(), true);
1170
- assert.equal(await locatorWith("button", state({ enabled: false })).locator.isEnabled(), false);
1171
- assert.equal(await locatorWith("button", detached).locator.isEnabled(), false);
1172
- assert.equal(await locatorWith("input", state()).locator.isEditable(), true);
1173
- assert.equal(await locatorWith("input", state({ editable: false })).locator.isEditable(), false);
1174
- assert.equal(await locatorWith("input", detached).locator.isEditable(), false);
1175
- });
1176
- ```
1177
-
1178
- - [ ] **Step 2: Run it to verify it fails**
1179
-
1180
- Run: `npm test`
1181
- Expected: FAIL — `locator-actions.test.ts` fails (e.g. `click()` resolves a BiDi value / `isEnabled is not a function` / pointer clicks empty); ≥ 10 failures in that file; the other 75 tests pass.
1182
-
1183
- - [ ] **Step 3: Implement in `src/locator/locator.ts`**
1184
-
1185
- a) Add imports below the existing ones:
1186
-
1187
- ```ts
1188
- import { resolveTimeout, waitUntil, WaitTimeoutError } from "../wait/wait-until.js";
1189
- import { ActionTimeoutError } from "./action-timeout-error.js";
1190
- import {
1191
- allPass,
1192
- elementStateScript,
1193
- evaluateChecks,
1194
- parseElementState,
1195
- type CheckName,
1196
- type CheckResults,
1197
- type ElementState,
1198
- type ProbeOptions,
1199
- } from "./element-state.js";
1200
-
1201
- export type { WaitForState } from "./element-state.js";
1202
-
1203
- export interface ActionOptions {
1204
- /** Time (ms) to wait for the element to become actionable. Defaults to config `expect.timeout`, then 5000 */
1205
- timeout?: number;
1206
- /** Skip every actionability check except "attached" */
1207
- force?: boolean;
1208
- }
1209
-
1210
- const CLICK_CHECKS: readonly CheckName[] = ["attached", "visible", "stable", "enabled", "hit target"];
1211
- const FILL_CHECKS: readonly CheckName[] = ["attached", "visible", "enabled", "editable", "hit target"];
1212
- const ATTACHED_ONLY: readonly CheckName[] = ["attached"];
1213
- ```
1214
-
1215
- b) Replace
1216
-
1217
- ```ts
1218
- /** Simulate a 'Click' event on the element */
1219
- public async click() {
1220
- return await this.evaluate(this.buildExpression("click()"));
1221
- }
1222
- ```
1223
-
1224
- with
1225
-
1226
- ```ts
1227
- /** Reads the element's actionability state in one round trip */
1228
- private async probeState(options: ProbeOptions): Promise<ElementState> {
1229
- return parseElementState(
1230
- await this.evaluate(elementStateScript(this.elementExpression(), options)),
1231
- );
1232
- }
1233
-
1234
- /**
1235
- * Probes until every required check passes and returns that state.
1236
- * Throws ActionTimeoutError describing the last probe on timeout.
1237
- */
1238
- private async waitForActionable(
1239
- action: string,
1240
- checks: readonly CheckName[],
1241
- probe: ProbeOptions,
1242
- options: ActionOptions | undefined,
1243
- ): Promise<ElementState> {
1244
- const required = options?.force ? ATTACHED_ONLY : checks;
1245
- const timeout = await resolveTimeout(options?.timeout);
1246
- let lastChecks: CheckResults | undefined;
1247
- try {
1248
- return await waitUntil(
1249
- () => this.probeState(probe),
1250
- (current, previous) => {
1251
- lastChecks = evaluateChecks(required, current, previous);
1252
- return allPass(lastChecks);
1253
- },
1254
- { timeout },
1255
- );
1256
- } catch (err) {
1257
- if (!(err instanceof WaitTimeoutError)) throw err;
1258
- const last = err.last as ElementState | undefined;
1259
- throw new ActionTimeoutError({
1260
- action,
1261
- selector: this.selector,
1262
- timeout,
1263
- reason: !last ? "unreadable" : last.attached ? "not-actionable" : "not-attached",
1264
- checks: lastChecks,
1265
- coveredBy: last?.hitTarget && last.hitTarget !== "self" ? last.hitTarget : undefined,
1266
- });
1267
- }
1268
- }
1269
-
1270
- /** Waits until the element is attached, visible, stable, enabled and not covered, then clicks its centre with real pointer input */
1271
- public async click(options?: ActionOptions): Promise<void> {
1272
- const state = await this.waitForActionable(
1273
- "click",
1274
- CLICK_CHECKS,
1275
- { scroll: true, hitTest: true },
1276
- options,
1277
- );
1278
- // "attached" is always required, so the box is present
1279
- await this.clickRect(state.box!);
1280
- }
1281
- ```
1282
-
1283
- c) Change the `clickRect` signature line
1284
-
1285
- ```ts
1286
- public async clickRect(rect: ElementRectangle) {
1287
- ```
1288
-
1289
- to
1290
-
1291
- ```ts
1292
- public async clickRect(rect: Pick<ElementRectangle, "x" | "y" | "width" | "height">) {
1293
- ```
1294
-
1295
- d) Replace
1296
-
1297
- ```ts
1298
- /** Simulates focusing an element */
1299
- public async focus() {
1300
- return await this.evaluate(this.buildExpression("focus()"));
1301
- }
1302
- ```
1303
-
1304
- with
1305
-
1306
- ```ts
1307
- /** Waits until the element is attached, then focuses it */
1308
- public async focus(options?: ActionOptions): Promise<void> {
1309
- await this.waitForActionable("focus", ATTACHED_ONLY, { scroll: false, hitTest: false }, options);
1310
- await this.evaluate(this.buildExpression("focus()"));
1311
- }
1312
- ```
1313
-
1314
- e) Add after `count()`:
1315
-
1316
- ```ts
1317
- /** Whether the element exists and is not disabled; does not wait */
1318
- public async isEnabled(): Promise<boolean> {
1319
- const state = await this.probeState({ scroll: false, hitTest: false });
1320
- return state.attached && state.enabled;
1321
- }
1322
-
1323
- /** Whether the element exists and accepts typing (enabled, not readonly, a text field); does not wait */
1324
- public async isEditable(): Promise<boolean> {
1325
- const state = await this.probeState({ scroll: false, hitTest: false });
1326
- return state.attached && state.editable;
1327
- }
1328
- ```
1329
-
1330
- f) In `fill`, replace the signature, doc comment and first line
1331
-
1332
- ```ts
1333
- /** Replaces the value of an input or textarea by typing `value`; an empty string clears it */
1334
- public async fill(value: string) {
1335
- const rect = await this.getBoundingClientRect();
1336
- ```
1337
-
1338
- with
1339
-
1340
- ```ts
1341
- /** Waits until the field is editable and not covered, then replaces its value by typing `value`; an empty string clears it */
1342
- public async fill(value: string, options?: ActionOptions): Promise<void> {
1343
- const state = await this.waitForActionable(
1344
- "fill",
1345
- FILL_CHECKS,
1346
- { scroll: true, hitTest: true },
1347
- options,
1348
- );
1349
- ```
1350
-
1351
- and further down in `fill` replace `await this.clickRect(rect);` with `await this.clickRect(state.box!);`. Leave the rest of `fill` (wait, select, typing) unchanged.
1352
-
1353
- - [ ] **Step 4: Adapt three existing tests in `src/locator/locator.test.ts`**
1354
-
1355
- `click()` and `fill()` now probe the element state first, so the scripted stub responses must include a state.
1356
-
1357
- a) Directly after the `assertCompiles` function add:
1358
-
1359
- ```ts
1360
- /** Element state for an element that is ready for any action */
1361
- const ready: StubResponse = {
1362
- type: "string",
1363
- value: JSON.stringify({
1364
- attached: true,
1365
- visible: true,
1366
- enabled: true,
1367
- editable: true,
1368
- box: { x: 0, y: 0, width: 100, height: 20 },
1369
- hitTarget: "self",
1370
- }),
1371
- };
1372
- ```
1373
-
1374
- b) In `test("xpaths with both quote styles produce valid JavaScript", …)`, change the stub responses from
1375
-
1376
- ```ts
1377
- { type: "string", value: "Don't stop" },
1378
- { type: "number", value: 1 },
1379
- );
1380
- ```
1381
-
1382
- to
1383
-
1384
- ```ts
1385
- { type: "string", value: "Don't stop" },
1386
- { type: "number", value: 1 },
1387
- ready,
1388
- );
1389
- ```
1390
-
1391
- and add `{ timeout: 3000 }` to the call: `await locator.click({ timeout: 3000 });`. The assertions stay as they are (`expressions[2]` is now the first state probe, which embeds the xpath).
1392
-
1393
- c) Replace the whole `test("actions on an all() item target that item", …)` with:
1394
-
1395
- ```ts
1396
- test("actions on an all() item target that item", async () => {
1397
- const { connector, expressions, sent } = stubConnector({ type: "number", value: 2 }, ready);
1398
- const items = await new Locator("li", connector, "ctx").all();
1399
- await items[1]!.click({ timeout: 3000 });
1400
- assert.ok(expressions[1]!.includes(JSON.stringify("(//li)[2]")));
1401
- assert.match(expressions[1]!, /FIRST_ORDERED_NODE_TYPE/);
1402
- assertCompiles(expressions[1]!);
1403
- assert.equal(sent.filter(({ method }) => method === "input.performActions").length, 1);
1404
- });
1405
- ```
1406
-
1407
- d) Delete the line
1408
-
1409
- ```ts
1410
- const rect = { type: "string", value: JSON.stringify({ x: 0, y: 0, width: 100, height: 20 }) } as const;
1411
- ```
1412
-
1413
- and in the two `fill()` tests change `locatorWith("input", rect, { type: "undefined" })` to `locatorWith("input", ready, { type: "undefined" })`, and pass an explicit timeout so the config file is never read: `await locator.fill("abc", { timeout: 3000 });` and `await locator.fill("", { timeout: 3000 });`. Their expected traces stay unchanged (the first `evaluate:other` is now the state probe instead of `getBoundingClientRect`).
1414
-
1415
- - [ ] **Step 5: Run tests to verify they pass**
1416
-
1417
- Run: `npm test` (three times)
1418
- Expected: `ℹ pass 87`, `ℹ fail 0` each run.
1419
-
1420
- - [ ] **Step 6: Type-check**
1421
-
1422
- Run: `npx tsc --noEmit -p .`
1423
- Expected: only the pre-existing `src/config/default.ts` error.
1424
-
1425
- - [ ] **Step 7: Commit**
1426
-
1427
- ```bash
1428
- git add src/locator/locator.ts src/locator/locator.test.ts src/locator/locator-actions.test.ts
1429
- git commit -m "feat(locator): wait for actionability in click, fill and focus"
1430
- ```
1431
-
1432
- ---
1433
-
1434
- ### Task 6: `waitFor()`
1435
-
1436
- **Files:**
1437
- - Modify: `src/locator/locator.ts`
1438
- - Modify: `src/locator/locator-actions.test.ts`
1439
-
1440
- **Interfaces:**
1441
- - Consumes: Task 5's private `probeState`, `ActionTimeoutError`, `waitUntil`, `WaitTimeoutError`, `resolveTimeout`; `WaitForState` and `ElementState` from `./element-state.js`.
1442
- - Produces: `waitFor(options?: { state?: WaitForState; timeout?: number }): Promise<void>` (state default `"visible"`).
1443
-
1444
- - [ ] **Step 1: Write the failing tests**
1445
-
1446
- Append to `src/locator/locator-actions.test.ts`:
1447
-
1448
- ```ts
1449
- test("waitFor() waits for visible by default, without scrolling", async () => {
1450
- const { locator, expressions } = locatorWith("div", state({ visible: false }), state());
1451
- await locator.waitFor({ timeout: 3000 });
1452
- assert.equal(expressions.length, 2);
1453
- assert.ok(!expressions[0]!.includes("scrollIntoView"));
1454
- assert.ok(!expressions[0]!.includes("elementFromPoint"));
1455
- });
1456
-
1457
- test("waitFor({ state: \"hidden\" }) resolves once the element is gone or invisible", async () => {
1458
- const gone = locatorWith("div", state(), detached);
1459
- await gone.locator.waitFor({ state: "hidden", timeout: 3000 });
1460
- assert.equal(gone.expressions.length, 2);
1461
-
1462
- const invisible = locatorWith("div", state(), state({ visible: false }));
1463
- await invisible.locator.waitFor({ state: "hidden", timeout: 3000 });
1464
- assert.equal(invisible.expressions.length, 2);
1465
- });
1466
-
1467
- test("waitFor() for attached and detached", async () => {
1468
- const appears = locatorWith("div", detached, state({ visible: false }));
1469
- await appears.locator.waitFor({ state: "attached", timeout: 3000 });
1470
- assert.equal(appears.expressions.length, 2);
1471
-
1472
- const disappears = locatorWith("div", state(), detached);
1473
- await disappears.locator.waitFor({ state: "detached", timeout: 3000 });
1474
- assert.equal(disappears.expressions.length, 2);
1475
- });
1476
-
1477
- test("waitFor() timeout names the state it waited for", async () => {
1478
- const { locator } = locatorWith("div", state({ visible: false }));
1479
- await assert.rejects(locator.waitFor({ timeout: 250 }), (err) => {
1480
- assert.ok(err instanceof ActionTimeoutError);
1481
- assert.equal(err.reason, "wrong-state");
1482
- assert.equal(err.state, "visible");
1483
- assert.equal(err.message, "waitFor(): //div did not become visible within 250ms");
1484
- return true;
1485
- });
1486
- });
1487
-
1488
- test("waitFor() when no probe completes reports it could not read the element", async () => {
1489
- const { locator } = locatorWith("div", { hang: true });
1490
- await assert.rejects(
1491
- locator.waitFor({ state: "hidden", timeout: 250 }),
1492
- /^ActionTimeoutError: waitFor\(\): could not read \/\/div within 250ms$/,
1493
- );
1494
- });
1495
- ```
1496
-
1497
- - [ ] **Step 2: Run it to verify it fails**
1498
-
1499
- Run: `npm test`
1500
- Expected: FAIL — 5 failures, `locator.waitFor is not a function`; the other 87 tests pass.
1501
-
1502
- - [ ] **Step 3: Implement**
1503
-
1504
- In `src/locator/locator.ts`, extend the `./element-state.js` import list with `type WaitForState` (keep the existing re-export line), then add after `focus()`:
1505
-
1506
- ```ts
1507
- /** Waits until the element reaches `state` (default "visible"); does not scroll */
1508
- public async waitFor({
1509
- state = "visible",
1510
- timeout,
1511
- }: { state?: WaitForState; timeout?: number } = {}): Promise<void> {
1512
- const resolved = await resolveTimeout(timeout);
1513
- const reached: Record<WaitForState, (current: ElementState) => boolean> = {
1514
- attached: (current) => current.attached,
1515
- detached: (current) => !current.attached,
1516
- visible: (current) => current.attached && current.visible,
1517
- hidden: (current) => !current.attached || !current.visible,
1518
- };
1519
- try {
1520
- await waitUntil(() => this.probeState({ scroll: false, hitTest: false }), reached[state], {
1521
- timeout: resolved,
1522
- });
1523
- } catch (err) {
1524
- if (!(err instanceof WaitTimeoutError)) throw err;
1525
- throw new ActionTimeoutError({
1526
- action: "waitFor",
1527
- selector: this.selector,
1528
- timeout: resolved,
1529
- reason: err.last === undefined ? "unreadable" : "wrong-state",
1530
- state,
1531
- });
1532
- }
1533
- }
1534
- ```
1535
-
1536
- - [ ] **Step 4: Run tests to verify they pass**
1537
-
1538
- Run: `npm test` (three times)
1539
- Expected: `ℹ pass 92`, `ℹ fail 0` each run.
1540
-
1541
- - [ ] **Step 5: Type-check**
1542
-
1543
- Run: `npx tsc --noEmit -p .`
1544
- Expected: only the pre-existing `src/config/default.ts` error.
1545
-
1546
- - [ ] **Step 6: Commit**
1547
-
1548
- ```bash
1549
- git add src/locator/locator.ts src/locator/locator-actions.test.ts
1550
- git commit -m "feat(locator): add waitFor for attached, detached, visible and hidden"
1551
- ```
1552
-
1553
- ---
1554
-
1555
- ### Task 7: `navigateTo` scheme fix and headless-Firefox browser tests
1556
-
1557
- **Files:**
1558
- - Modify: `src/testing/stub-connector.ts`
1559
- - Modify: `src/browser/page.ts` (`navigateTo`)
1560
- - Create: `src/browser/page.test.ts`
1561
- - Create: `src/testing/browser-fixture.ts`
1562
- - Create: `src/locator/locator.browser-test.ts`
1563
- - Modify: `package.json` (`scripts`)
1564
-
1565
- **Interfaces:**
1566
- - Consumes: `Browser.launch(browserName, launchOptions?, signal?)` → `Promise<{ browser: Browser; page: Page }>`; `browser.close(): Promise<void>`; `Page` (private fields `biDiConnector`, `contextId`); Tasks 5-6 actions and `ActionTimeoutError`.
1567
- - Produces: `setContent(page: Page, html: string, setup?: string): Promise<void>`; `npm run test:browser`.
1568
-
1569
- The browser tests need a blank page; `navigateTo("about:blank")` is currently turned into the literal `https://url`, so that bug is fixed first (TDD, unit level).
1570
-
1571
- - [ ] **Step 1: Let the stub answer navigation**
1572
-
1573
- In `src/testing/stub-connector.ts`, change
1574
-
1575
- ```ts
1576
- if (method === "input.performActions") {
1577
- return {};
1578
- }
1579
- ```
1580
-
1581
- to
1582
-
1583
- ```ts
1584
- if (method === "input.performActions" || method === "browsingContext.navigate") {
1585
- return {};
1586
- }
1587
- ```
1588
-
1589
- and in the doc comment change `` `input.performActions` resolves empty. `` to `` `input.performActions` and `browsingContext.navigate` resolve empty. ``
1590
-
1591
- - [ ] **Step 2: Write the failing `navigateTo` test**
1592
-
1593
- Create `src/browser/page.test.ts`:
1594
-
1595
- ```ts
1596
- import { test } from "node:test";
1597
- import assert from "node:assert/strict";
1598
- import Page from "./page.js";
1599
- import { stubConnector } from "../testing/stub-connector.js";
1600
-
1601
- test("navigateTo keeps URLs that have a scheme and prefixes bare hosts", async () => {
1602
- const { connector, sent } = stubConnector({ type: "undefined" });
1603
- const page = new Page(connector, "ctx");
1604
- await page.navigateTo("about:blank");
1605
- await page.navigateTo("https://itmetsam.nl/contact");
1606
- await page.navigateTo("itmetsam.nl");
1607
- await page.navigateTo("localhost:3000/login");
1608
- await page.navigateTo("itmetsam.nl", undefined, "http");
1609
- assert.deepEqual(
1610
- sent.map(({ params }) => (params as { url: string }).url),
1611
- [
1612
- "about:blank",
1613
- "https://itmetsam.nl/contact",
1614
- "https://itmetsam.nl",
1615
- "https://localhost:3000/login",
1616
- "http://itmetsam.nl",
1617
- ],
1618
- );
1619
- });
1620
- ```
1621
-
1622
- - [ ] **Step 3: Run it to verify it fails**
1623
-
1624
- Run: `npm test`
1625
- Expected: FAIL — 1 failure; actual URLs contain `https://url`; the other 92 tests pass.
1626
-
1627
- - [ ] **Step 4: Fix `navigateTo`**
1628
-
1629
- In `src/browser/page.ts` replace
1630
-
1631
- ```ts
1632
- const parsedURL = url.startsWith("http") ? url : `${protocol}://url`;
1633
- ```
1634
-
1635
- with
1636
-
1637
- ```ts
1638
- // A scheme is letters followed by ":" not directly followed by a digit, so "localhost:3000" is a host
1639
- const hasScheme = /^[a-z][a-z\d+.-]*:(?!\d)/i.test(url);
1640
- const parsedURL = hasScheme ? url : `${protocol}://${url}`;
1641
- ```
1642
-
1643
- Run: `npm test`
1644
- Expected: `ℹ pass 93`, `ℹ fail 0`.
1645
-
1646
- - [ ] **Step 5: Add the fixture helper and script**
1647
-
1648
- Create `src/testing/browser-fixture.ts`:
1649
-
1650
- ```ts
1651
- import type Page from "../browser/page.js";
1652
- import type { BiDiConnector } from "../transport/bidi-connection.js";
1653
-
1654
- /**
1655
- * Test-only: replaces the page body with `html`, then runs `setup` (JavaScript source,
1656
- * wrapped in its own function scope) in the page. Reaches into Page's private fields.
1657
- */
1658
- export async function setContent(page: Page, html: string, setup = "") {
1659
- const internals = page as unknown as { biDiConnector: BiDiConnector; contextId: string };
1660
- const result = await internals.biDiConnector.send("script.evaluate", {
1661
- expression: `document.body.innerHTML = ${JSON.stringify(html)}; (() => { ${setup} })();`,
1662
- awaitPromise: false,
1663
- target: { context: internals.contextId },
1664
- });
1665
- if (result.type === "exception") {
1666
- throw new Error(`setContent failed: ${result.exceptionDetails.text}`);
1667
- }
1668
- }
1669
- ```
1670
-
1671
- In `package.json` `scripts`, add after `"test"`:
1672
-
1673
- ```json
1674
- "test:browser": "tsx --test \"src/**/*.browser-test.ts\""
1675
- ```
1676
-
1677
- (add the comma after the `"test"` line).
1678
-
1679
- - [ ] **Step 6: Write the browser tests**
1680
-
1681
- Create `src/locator/locator.browser-test.ts`:
1682
-
1683
- ```ts
1684
- import { after, before, test } from "node:test";
1685
- import assert from "node:assert/strict";
1686
- import { Browser } from "../browser/browser.js";
1687
- import type Page from "../browser/page.js";
1688
- import { setContent } from "../testing/browser-fixture.js";
1689
- import { ActionTimeoutError } from "./action-timeout-error.js";
1690
-
1691
- let browser: Browser;
1692
- let page: Page;
1693
-
1694
- before(async () => {
1695
- ({ browser, page } = await Browser.launch("firefox", { port: 9231, headless: true }));
1696
- await page.navigateTo("about:blank", "complete");
1697
- });
1698
-
1699
- after(async () => {
1700
- await browser?.close();
1701
- });
1702
-
1703
- /** Records on the button whether a click was a real (trusted) pointer click */
1704
- const RECORD_CLICKS = `
1705
- const b = document.getElementById("b");
1706
- b.addEventListener("click", (event) => { b.dataset.clicked = event.isTrusted ? "trusted" : "synthetic"; });`;
1707
-
1708
- const OPTIONS = { timeout: 15000 };
1709
-
1710
- test("click waits for a button that becomes enabled", OPTIONS, async () => {
1711
- await setContent(
1712
- page,
1713
- `<button id="b" disabled>Go</button>`,
1714
- `${RECORD_CLICKS} setTimeout(() => { b.disabled = false; }, 300);`,
1715
- );
1716
- const button = page.locator("button[@id='b']");
1717
- await button.click({ timeout: 3000 });
1718
- assert.equal(await button.getAttribute("data-clicked"), "trusted");
1719
- });
1720
-
1721
- test("click waits for a covering overlay to disappear", OPTIONS, async () => {
1722
- await setContent(
1723
- page,
1724
- `<button id="b">Go</button><div id="overlay" style="position:fixed;inset:0;background:rgba(0,0,0,0.5)"></div>`,
1725
- `${RECORD_CLICKS} setTimeout(() => document.getElementById("overlay").remove(), 300);`,
1726
- );
1727
- const button = page.locator("button[@id='b']");
1728
- await button.click({ timeout: 3000 });
1729
- assert.equal(await button.getAttribute("data-clicked"), "trusted");
1730
- });
1731
-
1732
- test("click waits for a moving button to stop", OPTIONS, async () => {
1733
- await setContent(
1734
- page,
1735
- `<button id="b" style="position:absolute;left:0;top:0;transition:left 300ms linear">Go</button>`,
1736
- `${RECORD_CLICKS} requestAnimationFrame(() => requestAnimationFrame(() => { b.style.left = "300px"; }));`,
1737
- );
1738
- const button = page.locator("button[@id='b']");
1739
- await button.click({ timeout: 3000 });
1740
- assert.equal(await button.getAttribute("data-clicked"), "trusted");
1741
- });
1742
-
1743
- test("click scrolls a button below the fold into view", OPTIONS, async () => {
1744
- await setContent(page, `<div style="height:3000px"></div><button id="b">Go</button>`, RECORD_CLICKS);
1745
- const button = page.locator("button[@id='b']");
1746
- await button.click({ timeout: 3000 });
1747
- assert.equal(await button.getAttribute("data-clicked"), "trusted");
1748
- });
1749
-
1750
- test("fill waits for a readonly field to become editable and replaces its value", OPTIONS, async () => {
1751
- await setContent(
1752
- page,
1753
- `<input id="i" readonly value="old">`,
1754
- `setTimeout(() => { document.getElementById("i").readOnly = false; }, 300);`,
1755
- );
1756
- const input = page.locator("input[@id='i']");
1757
- await input.fill("typed", { timeout: 3000 });
1758
- assert.equal(await input.inputValue(), "typed");
1759
- });
1760
-
1761
- test("force clicks an element that is not considered visible", OPTIONS, async () => {
1762
- await setContent(page, `<button id="b" style="opacity:0">Go</button>`, RECORD_CLICKS);
1763
- const button = page.locator("button[@id='b']");
1764
- await assert.rejects(button.click({ timeout: 300 }), /visible ✗/);
1765
- await button.click({ force: true, timeout: 3000 });
1766
- assert.equal(await button.getAttribute("data-clicked"), "trusted");
1767
- });
1768
-
1769
- test("waitFor hidden resolves when the element is removed; visible times out when it stays hidden", OPTIONS, async () => {
1770
- await setContent(
1771
- page,
1772
- `<div id="s">Loading</div>`,
1773
- `setTimeout(() => document.getElementById("s").remove(), 300);`,
1774
- );
1775
- await page.locator("div[@id='s']").waitFor({ state: "hidden", timeout: 3000 });
1776
-
1777
- await setContent(page, `<div id="s" style="display:none">Loading</div>`);
1778
- await assert.rejects(
1779
- page.locator("div[@id='s']").waitFor({ timeout: 300 }),
1780
- (err) =>
1781
- err instanceof ActionTimeoutError &&
1782
- err.message === "waitFor(): //div[@id='s'] did not become visible within 300ms",
1783
- );
1784
- });
1785
-
1786
- test("a disabled button times out with the check summary", OPTIONS, async () => {
1787
- await setContent(page, `<button id="b" disabled>Go</button>`);
1788
- await assert.rejects(
1789
- page.locator("button[@id='b']").click({ timeout: 500 }),
1790
- (err) =>
1791
- err instanceof ActionTimeoutError &&
1792
- err.message ===
1793
- "click(): //button[@id='b'] was not actionable within 500ms\n" +
1794
- " attached ✓ visible ✓ stable ✓ enabled ✗ hit target —",
1795
- );
1796
- });
1797
-
1798
- test("a covered button times out naming the covering element", OPTIONS, async () => {
1799
- await setContent(
1800
- page,
1801
- `<button id="b">Go</button><div id="cover" class="overlay" style="position:fixed;inset:0"></div>`,
1802
- );
1803
- await assert.rejects(
1804
- page.locator("button[@id='b']").click({ timeout: 500 }),
1805
- /hit target ✗ \(covered by div#cover\.overlay\)/,
1806
- );
1807
- });
1808
- ```
1809
-
1810
- - [ ] **Step 7: Run the browser tests**
1811
-
1812
- Run: `npm run test:browser`
1813
- (In a sandboxed agent shell, run this one command with the sandbox disabled: Firefox must read its profile data and the BiDi port must be reachable. It launches headless Firefox on port 9231 and loads only `about:blank` — no external site.)
1814
- Expected: `ℹ pass 9`, `ℹ fail 0`; the process exits on its own. Framework debug logging is printed; that is expected.
1815
- If a test fails, it is a real finding about the browser-side script: fix the implementation (element-state script or locator), not the fixture's expectations, and re-run both `npm test` and `npm run test:browser`.
1816
-
1817
- - [ ] **Step 8: Final checks**
1818
-
1819
- Run: `npm test` → `ℹ pass 93`, `ℹ fail 0` (browser tests are not picked up).
1820
- Run: `npx tsc --noEmit -p .` → only the pre-existing `src/config/default.ts` error.
1821
-
1822
- - [ ] **Step 9: Commit**
1823
-
1824
- ```bash
1825
- git add src/testing/stub-connector.ts src/browser/page.ts src/browser/page.test.ts src/testing/browser-fixture.ts src/locator/locator.browser-test.ts package.json
1826
- git commit -m "test(locator): headless Firefox tests for auto-waiting actions"
1827
- ```