@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,1567 +0,0 @@
1
- import { describe, test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import Page from "./page.js";
4
- import { stubConnector } from "../testing/stub-connector.js";
5
- import { resolveRunSettings, setRunSettings } from "../config/run-settings.js";
6
- import { autoReply, FakeWebSocket, tick } from "../testing/fake-websocket.js";
7
- import { BiDiConnector } from "../transport/bidi-connection.js";
8
- import { LoadStateTimeoutError, NavigationError } from "./navigation-error.js";
9
- import { ResponseTimeoutError } from "../network/network-wait.js";
10
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
11
- import { tmpdir } from "node:os";
12
- import path from "node:path";
13
- import { UnsupportedOperationError } from "../locator/selector-errors.js";
14
- import { ActionTimeoutError } from "../locator/action-timeout-error.js";
15
-
16
- test("navigateTo keeps URLs that have a scheme and prefixes bare hosts", async () => {
17
- const { connector, sent } = stubConnector({ type: "undefined" });
18
- const page = new Page(connector, "ctx");
19
- await page.navigateTo("about:blank");
20
- await page.navigateTo("https://itmetsam.nl/contact");
21
- await page.navigateTo("itmetsam.nl");
22
- await page.navigateTo("localhost:3000/login");
23
- await page.navigateTo("itmetsam.nl", undefined, "http");
24
- assert.deepEqual(
25
- sent.map(({ params }) => (params as { url: string }).url),
26
- [
27
- "about:blank",
28
- "https://itmetsam.nl/contact",
29
- "https://itmetsam.nl",
30
- "https://localhost:3000/login",
31
- "http://itmetsam.nl",
32
- ],
33
- );
34
- });
35
-
36
- describe("Page foundation", () => {
37
- function realPage(handlers: Record<string, (params: any) => object | Error> = {}) {
38
- const ws = new FakeWebSocket();
39
- const connector = new BiDiConnector(ws as unknown as WebSocket);
40
- const stop = autoReply(ws, handlers);
41
- return { ws, connector, page: new Page(connector, "ctx-1"), stop };
42
- }
43
-
44
- test("exposes its context id", () => {
45
- const { page, stop } = realPage();
46
- assert.equal(page.contextId, "ctx-1");
47
- stop();
48
- });
49
-
50
- test("shares one lazily created ContextTree", async () => {
51
- const { ws, page, stop } = realPage({
52
- "browsingContext.getTree": () => ({ contexts: [{ context: "ctx-1", parent: null, children: [] }] }),
53
- });
54
- assert.equal(ws.sent.length, 0);
55
- const [a, b] = await Promise.all([page.tree(), page.tree()]);
56
- assert.equal(a, b);
57
- assert.equal(ws.sent.filter((m) => m.method === "browsingContext.getTree").length, 1);
58
- assert.equal(a.isWithin("ctx-1", "ctx-1"), true);
59
- await a.dispose();
60
- stop();
61
- });
62
-
63
- });
64
-
65
- describe("Page init scripts", () => {
66
- const ok = { type: "success", realm: "r", result: { type: "undefined" } };
67
- function initPage() {
68
- const ws = new FakeWebSocket();
69
- const connector = new BiDiConnector(ws as unknown as WebSocket);
70
- let scripts = 0;
71
- const stop = autoReply(ws, {
72
- "script.addPreloadScript": () => ({ script: `preload-${++scripts}` }),
73
- "browsingContext.getTree": () => ({ contexts: [{ context: "ctx-1", parent: null, children: [] }] }),
74
- "script.callFunction": () => ok,
75
- "browsingContext.locateNodes": () => ({ nodes: [{ type: "node", sharedId: "n1" }] }),
76
- });
77
- const page = new Page(connector, "ctx-1");
78
- const sent = (method: string) => ws.sent.filter((m) => m.method === method);
79
- return { ws, page, stop, sent };
80
- }
81
-
82
- /** Runs a wrapped init script against a fake global and returns what it recorded */
83
- function run(functionDeclaration: string) {
84
- const recorded: unknown[] = [];
85
- const record = (value: unknown) => recorded.push(value);
86
- (globalThis as any).record = record;
87
- try {
88
- new Function("record", `(${functionDeclaration})()`)(record);
89
- } finally {
90
- delete (globalThis as any).record;
91
- }
92
- return recorded;
93
- }
94
-
95
- test("a function is registered for the page's context, in the page realm", async () => {
96
- const { page, sent, stop } = initPage();
97
- const handle = await page.addInitScript(() => {
98
- (globalThis as any).__flag = true;
99
- });
100
- const params = sent("script.addPreloadScript")[0]!.params as any;
101
- assert.deepEqual(params.contexts, ["ctx-1"]);
102
- assert.equal(params.sandbox, undefined);
103
- assert.match(params.functionDeclaration, /__flag/);
104
- assert.equal(handle.id, "preload-1");
105
- stop();
106
- });
107
-
108
- test("it also runs in the document that is already loaded", async () => {
109
- const { page, sent, stop } = initPage();
110
- await page.addInitScript(() => {});
111
- const call = sent("script.callFunction")[0]!.params as any;
112
- assert.deepEqual(call.target, { context: "ctx-1" });
113
- stop();
114
- });
115
-
116
- test("the argument is embedded as JSON and passed as the function's first argument", async () => {
117
- const { page, sent, stop } = initPage();
118
- await page.addInitScript((arg: { a: number; s: string }) => (globalThis as any).record(arg), {
119
- a: 1,
120
- s: `q"uote
`,
121
- });
122
- const declaration = (sent("script.addPreloadScript")[0]!.params as any).functionDeclaration;
123
- assert.deepEqual(run(declaration), [{ a: 1, s: `q"uote
` }]);
124
- stop();
125
- });
126
-
127
- test("a function without an argument is called with undefined", async () => {
128
- const { page, sent, stop } = initPage();
129
- await page.addInitScript((arg?: unknown) => (globalThis as any).record(arg));
130
- const declaration = (sent("script.addPreloadScript")[0]!.params as any).functionDeclaration;
131
- assert.deepEqual(run(declaration), [undefined]);
132
- stop();
133
- });
134
-
135
- test("a string is script source and runs as statements", async () => {
136
- const { page, sent, stop } = initPage();
137
- await page.addInitScript(`record("a"); record("b");`);
138
- const declaration = (sent("script.addPreloadScript")[0]!.params as any).functionDeclaration;
139
- assert.deepEqual(run(declaration), ["a", "b"]);
140
- stop();
141
- });
142
-
143
- test("a string cannot take an argument, and the argument must be JSON", async () => {
144
- const { page, sent, stop } = initPage();
145
- await assert.rejects(page.addInitScript("1", { a: 1 }), TypeError);
146
- await assert.rejects(page.addInitScript(() => {}, 10n), TypeError);
147
- const loop: Record<string, unknown> = {};
148
- loop.self = loop;
149
- await assert.rejects(page.addInitScript(() => {}, loop), TypeError);
150
- assert.equal(sent("script.addPreloadScript").length, 0);
151
- stop();
152
- });
153
-
154
- test("a function that needs the transpiler's __name helper is rejected", async () => {
155
- const { page, stop } = initPage();
156
- await assert.rejects(
157
- page.addInitScript(() => {
158
- function inner() {}
159
- inner();
160
- }),
161
- { name: "ScriptSerializationError" },
162
- );
163
- stop();
164
- });
165
-
166
- test("a throwing init script does not fail the registration in the loaded document", async () => {
167
- const ws = new FakeWebSocket();
168
- const connector = new BiDiConnector(ws as unknown as WebSocket);
169
- const stop = autoReply(ws, {
170
- "script.addPreloadScript": () => ({ script: "p" }),
171
- "browsingContext.getTree": () => ({ contexts: [{ context: "ctx-1", parent: null, children: [] }] }),
172
- "script.callFunction": () => ({
173
- type: "exception",
174
- realm: "r",
175
- exceptionDetails: { text: "boom", stackTrace: { callFrames: [] } },
176
- }),
177
- });
178
- const page = new Page(connector, "ctx-1");
179
- await assert.doesNotReject(page.addInitScript(() => {}));
180
- stop();
181
- });
182
-
183
- test("dispose() on the handle removes it, and page.dispose() removes the rest", async () => {
184
- const { page, sent, stop } = initPage();
185
- const first = await page.addInitScript(() => {});
186
- await page.addInitScript(() => {});
187
- await first.dispose();
188
- assert.equal(sent("script.removePreloadScript").length, 1);
189
- await page.dispose();
190
- const removed = sent("script.removePreloadScript").map((m) => (m.params as any).script);
191
- assert.deepEqual(removed.sort(), ["preload-1", "preload-2"]);
192
- await page.dispose(); // nothing left to remove
193
- assert.equal(sent("script.removePreloadScript").length, 2);
194
- stop();
195
- });
196
-
197
- test("locators created by the page share its helper realm", async () => {
198
- const { page, sent, stop } = initPage();
199
- const a = page.locator("//a");
200
- const b = page.locator("//b");
201
- await Promise.all([a.isEnabled().catch(() => {}), b.isEnabled().catch(() => {})]);
202
- // one registration of the framework helpers however many locators probe
203
- const helperRegistrations = sent("script.addPreloadScript").filter(
204
- (m) => (m.params as any).sandbox === "samurai",
205
- );
206
- assert.equal(helperRegistrations.length, 1);
207
- stop();
208
- });
209
- });
210
-
211
- describe("Page network tracking", () => {
212
- const TREE = [{ context: "ctx-1", parent: null, children: [] }];
213
- function trackedPage(settings?: ConstructorParameters<typeof Page>[2]) {
214
- const ws = new FakeWebSocket();
215
- const connector = new BiDiConnector(ws as unknown as WebSocket);
216
- const stop = autoReply(ws, { "browsingContext.getTree": () => ({ contexts: TREE }) });
217
- const page = new Page(connector, "ctx-1", settings);
218
- const subscribes = () => ws.sent.filter((m) => m.method === "session.subscribe").length;
219
- return { ws, page, stop, subscribes };
220
- }
221
- const requestParams = (id: string, context = "ctx-1") => ({
222
- context,
223
- isBlocked: false,
224
- navigation: null,
225
- redirectCount: 0,
226
- timestamp: 0,
227
- request: {
228
- request: id,
229
- url: `https://example.test/${id}`,
230
- method: "GET",
231
- headers: [],
232
- cookies: [],
233
- headersSize: 0,
234
- bodySize: null,
235
- destination: "",
236
- initiatorType: "fetch",
237
- timings: {},
238
- },
239
- });
240
- const completed = (id: string) => ({
241
- ...requestParams(id),
242
- response: { url: "", protocol: "", status: 200, statusText: "OK", fromCache: false, headers: [], mimeType: "", bytesReceived: 0, headersSize: 0, bodySize: 0, content: { size: 0 } },
243
- });
244
-
245
- test("waitForNetworkIdle resolves on a quiet page after idleTime", async () => {
246
- const { page, stop } = trackedPage();
247
- const started = Date.now();
248
- await page.waitForNetworkIdle({ idleTime: 40, timeout: 2000 });
249
- assert.ok(Date.now() - started >= 35);
250
- stop();
251
- });
252
-
253
- test("waitForNetworkIdle waits for requests in flight", async () => {
254
- const { ws, page, stop } = trackedPage();
255
- await page.startNetworkTracking();
256
- ws.emitEvent("network.beforeRequestSent", requestParams("1"));
257
- let idle = false;
258
- const waiting = page.waitForNetworkIdle({ idleTime: 20, timeout: 2000 }).then(() => (idle = true));
259
- await tick(60);
260
- assert.equal(idle, false);
261
- ws.emitEvent("network.responseCompleted", completed("1"));
262
- await waiting;
263
- stop();
264
- });
265
-
266
- test("waitForNetworkIdle names the requests still in flight on timeout", async () => {
267
- const { ws, page, stop } = trackedPage();
268
- await page.startNetworkTracking();
269
- ws.emitEvent("network.beforeRequestSent", requestParams("slow"));
270
- await assert.rejects(
271
- page.waitForNetworkIdle({ idleTime: 10, timeout: 50 }),
272
- /waitForNetworkIdle\(\): still 1 request\(s\) in flight after 50ms: https:\/\/example\.test\/slow/,
273
- );
274
- stop();
275
- });
276
-
277
- test("the idle time defaults to the config value", async () => {
278
- const { page, stop } = trackedPage({ network: { idleTime: 30 } });
279
- const started = Date.now();
280
- await page.waitForNetworkIdle();
281
- const took = Date.now() - started;
282
- assert.ok(took >= 25 && took < 400, `took ${took}ms`);
283
- stop();
284
- });
285
-
286
- test("one tracker serves every call; the network events are subscribed once", async () => {
287
- const { page, stop, subscribes } = trackedPage();
288
- await Promise.all([page.startNetworkTracking(), page.startNetworkTracking()]);
289
- await page.waitForNetworkIdle({ idleTime: 5, timeout: 1000 });
290
- // contextCreated + contextDestroyed for the tree, three for the network
291
- assert.equal(subscribes(), 5);
292
- stop();
293
- });
294
-
295
- test("request, response and requestfailed events reach page.on, and page.off stops them", async () => {
296
- const { ws, page, stop } = trackedPage();
297
- const seen: string[] = [];
298
- const onRequest = (r: { url: string }) => seen.push(`request ${r.url}`);
299
- page.on("request", onRequest);
300
- page.on("requestfailed", (r) => seen.push(`failed ${r.errorText}`));
301
- await page.startNetworkTracking();
302
- ws.emitEvent("network.beforeRequestSent", requestParams("1"));
303
- ws.emitEvent("network.fetchError", { ...requestParams("1"), errorText: "boom" });
304
- page.off("request", onRequest);
305
- ws.emitEvent("network.beforeRequestSent", requestParams("2"));
306
- assert.deepEqual(seen, ["request https://example.test/1", "failed boom"]);
307
- stop();
308
- });
309
-
310
- test("page.once fires a single time", async () => {
311
- const { ws, page, stop } = trackedPage();
312
- let count = 0;
313
- page.once("request", () => count++);
314
- await page.startNetworkTracking();
315
- ws.emitEvent("network.beforeRequestSent", requestParams("1"));
316
- ws.emitEvent("network.beforeRequestSent", requestParams("2"));
317
- assert.equal(count, 1);
318
- stop();
319
- });
320
-
321
- test("with tracking disabled, waitForNetworkIdle says so and nothing is subscribed", async () => {
322
- const { page, stop, subscribes } = trackedPage({ network: { track: false } });
323
- await page.startNetworkTracking();
324
- await assert.rejects(page.waitForNetworkIdle(), /network tracking is disabled/);
325
- assert.equal(subscribes(), 0);
326
- stop();
327
- });
328
-
329
- test("dispose stops tracking and releases the subscriptions", async () => {
330
- const { ws, page, stop } = trackedPage();
331
- await page.startNetworkTracking();
332
- await page.dispose();
333
- await tick(10);
334
- assert.ok(ws.sent.filter((m) => m.method === "session.unsubscribe").length >= 3);
335
- stop();
336
- });
337
- });
338
-
339
- describe("Page navigation", () => {
340
- const success = (value: string) => ({ type: "success", realm: "r", result: { type: "string", value } });
341
- const TREE = [{ context: "ctx-1", parent: null, children: [] }];
342
-
343
- function navPage(
344
- handlers: Record<string, (params: any) => object | Error> = {},
345
- settings?: ConstructorParameters<typeof Page>[2],
346
- ) {
347
- const ws = new FakeWebSocket();
348
- const connector = new BiDiConnector(ws as unknown as WebSocket);
349
- const stop = autoReply(ws, {
350
- "browsingContext.navigate": (params) => ({ navigation: "nav-1", url: params.url }),
351
- "browsingContext.getTree": () => ({ contexts: TREE }),
352
- "script.callFunction": () => success("complete"),
353
- ...handlers,
354
- });
355
- const page = new Page(connector, "ctx-1", settings);
356
- const navigates = () => ws.sent.filter((m) => m.method === "browsingContext.navigate").map((m) => m.params as any);
357
- return { ws, page, stop, navigates };
358
- }
359
- const navInfo = (context: string, navigation = "nav-1") => ({ context, navigation, url: "https://example.test/", timestamp: 0 });
360
-
361
- describe("navigateTo", () => {
362
- test("waits for complete by default, and returns the navigation and url", async () => {
363
- const { page, navigates, stop } = navPage();
364
- const result = await page.navigateTo("example.test");
365
- assert.deepEqual(result, { navigation: "nav-1", url: "https://example.test" });
366
- assert.equal(navigates()[0].wait, "complete");
367
- stop();
368
- });
369
-
370
- test("the wait comes from the options, or from config", async () => {
371
- const { page, navigates, stop } = navPage({}, { navigation: { waitUntil: "interactive" } });
372
- await page.navigateTo("example.test");
373
- await page.navigateTo("example.test", { wait: "none" });
374
- assert.deepEqual(navigates().map((n) => n.wait), ["interactive", "none"]);
375
- stop();
376
- });
377
-
378
- test("still accepts the positional wait and protocol", async () => {
379
- const { page, navigates, stop } = navPage();
380
- await page.navigateTo("example.test", "interactive", "http");
381
- await page.navigateTo("example.test", undefined, "http");
382
- await page.navigateTo("example.test", { protocol: "http" });
383
- assert.deepEqual(
384
- navigates().map((n) => [n.wait, n.url]),
385
- [
386
- ["interactive", "http://example.test"],
387
- ["complete", "http://example.test"],
388
- ["complete", "http://example.test"],
389
- ],
390
- );
391
- stop();
392
- });
393
-
394
- test("a same-document navigation has no navigation id", async () => {
395
- const { page, stop } = navPage({ "browsingContext.navigate": (p) => ({ navigation: null, url: p.url }) });
396
- assert.equal((await page.navigateTo("about:blank#x")).navigation, null);
397
- stop();
398
- });
399
-
400
- test("a browser error becomes a NavigationError naming the url and the reason", async () => {
401
- const ws = new FakeWebSocket();
402
- const connector = new BiDiConnector(ws as unknown as WebSocket);
403
- const timer = setInterval(() => {
404
- for (const m of ws.sent.splice(0)) ws.replyError(m.id, "unknown error", "NS_ERROR_CONNECTION_REFUSED");
405
- }, 1).unref();
406
- const page = new Page(connector, "ctx-1");
407
- await assert.rejects(page.navigateTo("http://127.0.0.1:1/"), (err: unknown) => {
408
- assert.ok(err instanceof NavigationError);
409
- assert.equal(err.url, "http://127.0.0.1:1/");
410
- assert.equal(err.code, "unknown error");
411
- assert.match(err.reason, /NS_ERROR_CONNECTION_REFUSED/);
412
- assert.match(err.message, /^navigateTo\(\): http:\/\/127\.0\.0\.1:1\/ failed: .*NS_ERROR_CONNECTION_REFUSED/);
413
- return true;
414
- });
415
- clearInterval(timer);
416
- });
417
-
418
- test("times out after the navigation timeout with a NavigationError", async () => {
419
- const ws = new FakeWebSocket();
420
- const connector = new BiDiConnector(ws as unknown as WebSocket);
421
- const page = new Page(connector, "ctx-1", { navigation: { timeout: 40 } });
422
- await assert.rejects(page.navigateTo("https://slow.test/"), (err: unknown) => {
423
- assert.ok(err instanceof NavigationError);
424
- assert.equal(err.reason, "timeout after 40ms");
425
- assert.equal(err.code, "timeout");
426
- return true;
427
- });
428
- });
429
-
430
- test("the timeout can be set per call", async () => {
431
- const ws = new FakeWebSocket();
432
- const connector = new BiDiConnector(ws as unknown as WebSocket);
433
- const page = new Page(connector, "ctx-1");
434
- await assert.rejects(page.navigateTo("https://slow.test/", { timeout: 30 }), /timeout after 30ms/);
435
- });
436
- });
437
-
438
- describe("waitForLoadState", () => {
439
- test("resolves at once when the document has already loaded", async () => {
440
- const { page, ws, stop } = navPage();
441
- await page.waitForLoadState("load", { timeout: 1000 });
442
- assert.equal(ws.sent.filter((m) => m.method === "script.callFunction").length, 1);
443
- stop();
444
- });
445
-
446
- test("domcontentloaded is reached at interactive as well", async () => {
447
- const { page, stop } = navPage({ "script.callFunction": () => success("interactive") });
448
- await page.waitForLoadState("domcontentloaded", { timeout: 1000 });
449
- stop();
450
- });
451
-
452
- test("load is not reached at interactive: it waits for this page's load event", async () => {
453
- const { page, ws, stop } = navPage({ "script.callFunction": () => success("interactive") });
454
- let loaded = false;
455
- const waiting = page.waitForLoadState("load", { timeout: 2000 }).then(() => (loaded = true));
456
- await tick(30);
457
- ws.emitEvent("browsingContext.load", navInfo("elsewhere"));
458
- await tick(10);
459
- assert.equal(loaded, false, "another page's load event does not count");
460
- ws.emitEvent("browsingContext.load", navInfo("ctx-1"));
461
- await waiting;
462
- stop();
463
- });
464
-
465
- test("an event that arrives while the state is being read is not lost", async () => {
466
- let ws!: FakeWebSocket;
467
- const built = navPage({
468
- "script.callFunction": () => {
469
- ws.emitEvent("browsingContext.load", navInfo("ctx-1"));
470
- return success("loading");
471
- },
472
- });
473
- ws = built.ws;
474
- await built.page.waitForLoadState("load", { timeout: 1000 });
475
- built.stop();
476
- });
477
-
478
- test("domcontentloaded waits for its own event", async () => {
479
- const { page, ws, stop } = navPage({ "script.callFunction": () => success("loading") });
480
- const waiting = page.waitForLoadState("domcontentloaded", { timeout: 2000 });
481
- await tick(30);
482
- ws.emitEvent("browsingContext.domContentLoaded", navInfo("ctx-1"));
483
- await waiting;
484
- stop();
485
- });
486
-
487
- test("a failed navigation of this page rejects with a NavigationError", async () => {
488
- const { page, ws, stop } = navPage({ "script.callFunction": () => success("loading") });
489
- const waiting = page.waitForLoadState("load", { timeout: 2000 });
490
- const rejection = assert.rejects(waiting, NavigationError);
491
- await tick(30);
492
- ws.emitEvent("browsingContext.navigationFailed", navInfo("ctx-1"));
493
- await rejection;
494
- stop();
495
- });
496
-
497
- test("after navigateTo, only failures of that navigation count", async () => {
498
- const { page, ws, stop } = navPage({ "script.callFunction": () => success("loading") });
499
- await page.navigateTo("example.test", { wait: "none" }); // navigation id nav-1
500
- const waiting = page.waitForLoadState("load", { timeout: 300 });
501
- const outcome = waiting.then(
502
- () => "loaded",
503
- (err: Error) => err.name,
504
- );
505
- await tick(30);
506
- ws.emitEvent("browsingContext.navigationFailed", navInfo("ctx-1", "nav-0-old"));
507
- ws.emitEvent("browsingContext.navigationAborted", navInfo("other-context", "nav-1"));
508
- assert.equal(await outcome, "LoadStateTimeoutError", "unrelated failures are ignored, the wait times out");
509
- stop();
510
- });
511
-
512
- test("after navigateTo(wait none), failure of that navigation rejects the wait", async () => {
513
- const { page, ws, stop } = navPage({ "script.callFunction": () => success("loading") });
514
- await page.navigateTo("example.test", { wait: "none" });
515
- const rejection = assert.rejects(page.waitForLoadState("load", { timeout: 2000 }), NavigationError);
516
- await tick(30);
517
- ws.emitEvent("browsingContext.navigationAborted", navInfo("ctx-1", "nav-1"));
518
- await rejection;
519
- stop();
520
- });
521
-
522
- test("after navigateTo(wait none) the old document does not count: it waits for that navigation's load", async () => {
523
- const { page, ws, stop } = navPage(); // readyState answers "complete": the document that is still showing
524
- await page.navigateTo("example.test", { wait: "none" });
525
- let loaded = false;
526
- const waiting = page.waitForLoadState("load", { timeout: 2000 }).then(() => (loaded = true));
527
- await tick(40);
528
- assert.equal(loaded, false);
529
- ws.emitEvent("browsingContext.load", navInfo("ctx-1", "some-other-navigation"));
530
- await tick(10);
531
- ws.emitEvent("browsingContext.load", navInfo("ctx-1", "nav-1"));
532
- await waiting;
533
- stop();
534
- });
535
-
536
- test("a navigation that already loaded before the wait started is not waited for again", async () => {
537
- let ws!: FakeWebSocket;
538
- const built = navPage({
539
- "browsingContext.navigate": (params) => {
540
- ws.emitEvent("browsingContext.domContentLoaded", navInfo("ctx-1"));
541
- ws.emitEvent("browsingContext.load", navInfo("ctx-1"));
542
- return { navigation: "nav-1", url: params.url };
543
- },
544
- "script.callFunction": () => success("complete"),
545
- });
546
- ws = built.ws;
547
- await built.page.navigateTo("example.test", { wait: "none" });
548
- await built.page.waitForLoadState("load", { timeout: 500 });
549
- await built.page.waitForLoadState("domcontentloaded", { timeout: 500 });
550
- built.stop();
551
- });
552
-
553
- test("after navigateTo(wait interactive) domcontentloaded is there, load is not", async () => {
554
- const { page, ws, stop } = navPage();
555
- await page.navigateTo("example.test", { wait: "interactive" });
556
- await page.waitForLoadState("domcontentloaded", { timeout: 500 });
557
- let loaded = false;
558
- const waiting = page.waitForLoadState("load", { timeout: 2000 }).then(() => (loaded = true));
559
- await tick(40);
560
- assert.equal(loaded, false);
561
- ws.emitEvent("browsingContext.load", navInfo("ctx-1", "nav-1"));
562
- await waiting;
563
- stop();
564
- });
565
-
566
- test("after navigateTo(wait complete) the document state is read as usual", async () => {
567
- const { page, stop } = navPage();
568
- await page.navigateTo("example.test");
569
- await page.waitForLoadState("load", { timeout: 500 });
570
- stop();
571
- });
572
-
573
- test("a browser without the navigationAborted event still works, and is not asked again", async () => {
574
- const unsupported = Object.assign(new Error("browsingContext.navigationAborted is not a valid event name"), {
575
- code: "invalid argument",
576
- });
577
- const { page, ws, stop } = navPage({
578
- "session.subscribe": (params) => (params.events.includes("browsingContext.navigationAborted") ? unsupported : {}),
579
- });
580
- await page.waitForLoadState("load", { timeout: 1000 });
581
- await page.waitForLoadState("load", { timeout: 1000 });
582
- const asked = ws.sent
583
- .filter((m) => m.method === "session.subscribe")
584
- .filter((m) => (m.params as any).events.includes("browsingContext.navigationAborted"));
585
- assert.equal(asked.length, 1, "asked once, then remembered");
586
- stop();
587
- });
588
-
589
- test("without the aborted event a failed navigation still rejects the wait", async () => {
590
- const unsupported = Object.assign(new Error("not a valid event name"), { code: "invalid argument" });
591
- const { page, ws, stop } = navPage({
592
- "script.callFunction": () => success("loading"),
593
- "session.subscribe": (params) => (params.events.includes("browsingContext.navigationAborted") ? unsupported : {}),
594
- });
595
- const rejection = assert.rejects(page.waitForLoadState("load", { timeout: 2000 }), NavigationError);
596
- await tick(40);
597
- ws.emitEvent("browsingContext.navigationFailed", navInfo("ctx-1"));
598
- await rejection;
599
- stop();
600
- });
601
-
602
- test("times out with a LoadStateTimeoutError", async () => {
603
- const { page, stop } = navPage({ "script.callFunction": () => success("loading") });
604
- await assert.rejects(page.waitForLoadState("load", { timeout: 60 }), (err: unknown) => {
605
- assert.ok(err instanceof LoadStateTimeoutError);
606
- assert.match(err.message, /waitForLoadState\("load"\) did not finish within 60ms/);
607
- return true;
608
- });
609
- stop();
610
- });
611
-
612
- test("the default timeout comes from config navigation.timeout", async () => {
613
- const { page, stop } = navPage({ "script.callFunction": () => success("loading") }, { navigation: { timeout: 50 } });
614
- await assert.rejects(page.waitForLoadState(), /did not finish within 50ms/);
615
- stop();
616
- });
617
-
618
- test("networkidle waits for the load and then for the network to go quiet", async () => {
619
- const { page, ws, stop } = navPage({}, { network: { idleTime: 30 } });
620
- await page.startNetworkTracking();
621
- ws.emitEvent("network.beforeRequestSent", {
622
- context: "ctx-1", isBlocked: false, navigation: null, redirectCount: 0, timestamp: 0,
623
- request: { request: "r1", url: "https://example.test/r1", method: "GET", headers: [], cookies: [], headersSize: 0, bodySize: null, destination: "", initiatorType: "fetch", timings: {} },
624
- });
625
- let idle = false;
626
- const waiting = page.waitForLoadState("networkidle", { timeout: 2000 }).then(() => (idle = true));
627
- await tick(80);
628
- assert.equal(idle, false, "a request is in flight");
629
- ws.emitEvent("network.fetchError", {
630
- context: "ctx-1", isBlocked: false, navigation: null, redirectCount: 0, timestamp: 0, errorText: "x",
631
- request: { request: "r1", url: "https://example.test/r1", method: "GET", headers: [], cookies: [], headersSize: 0, bodySize: null, destination: "", initiatorType: "fetch", timings: {} },
632
- });
633
- await waiting;
634
- stop();
635
- });
636
-
637
- test("releases its subscriptions when it is done", async () => {
638
- const { page, ws, stop } = navPage();
639
- await page.waitForLoadState("load", { timeout: 1000 });
640
- await tick(20);
641
- const subscribed = ws.sent.filter((m) => m.method === "session.subscribe").length;
642
- const unsubscribed = ws.sent.filter((m) => m.method === "session.unsubscribe").length;
643
- assert.equal(unsubscribed, subscribed);
644
- stop();
645
- });
646
- });
647
- });
648
-
649
- describe("Page logs", () => {
650
- const TREE = [{ context: "ctx-1", parent: null, children: [] }];
651
- function logPage() {
652
- const ws = new FakeWebSocket();
653
- const connector = new BiDiConnector(ws as unknown as WebSocket);
654
- const stop = autoReply(ws, { "browsingContext.getTree": () => ({ contexts: TREE }) });
655
- const page = new Page(connector, "ctx-1");
656
- const entry = (extra: object) =>
657
- ws.emitEvent("log.entryAdded", {
658
- level: "info",
659
- text: "hello",
660
- timestamp: 5,
661
- source: { realm: "r", context: "ctx-1", userContext: "default" },
662
- ...extra,
663
- });
664
- return { ws, page, stop, entry };
665
- }
666
-
667
- test("console messages and page errors reach page.on, and page.off stops them", async () => {
668
- const { page, stop, entry } = logPage();
669
- const seen: string[] = [];
670
- const onConsole = (message: { type(): string; text(): string }) => seen.push(`${message.type()}: ${message.text()}`);
671
- page.on("console", onConsole);
672
- page.on("pageerror", (error) => seen.push(`error: ${error.message}`));
673
- await page.startLogging();
674
- entry({ type: "console", method: "log", args: [] });
675
- entry({ type: "javascript", level: "error", text: "TypeError: x" });
676
- page.off("console", onConsole);
677
- entry({ type: "console", method: "log", text: "after off", args: [] });
678
- assert.deepEqual(seen, ["log: hello", "error: TypeError: x"]);
679
- stop();
680
- });
681
-
682
- test("getLogs, pageErrors and clearLogs read what was collected", async () => {
683
- const { page, stop, entry } = logPage();
684
- assert.deepEqual(page.getLogs(), [], "nothing before logging started");
685
- assert.deepEqual(page.pageErrors(), []);
686
- await page.startLogging();
687
- entry({ type: "console", method: "warn", level: "warn", args: [] });
688
- entry({ type: "javascript", level: "error", text: "Error: boom" });
689
- assert.deepEqual(
690
- page.getLogs().map((log) => [log.type, log.level, log.text]),
691
- [["console", "warn", "hello"], ["javascript", "error", "Error: boom"]],
692
- );
693
- assert.deepEqual(page.pageErrors().map((error) => error.message), ["Error: boom"]);
694
- page.clearLogs();
695
- assert.deepEqual(page.getLogs(), []);
696
- assert.deepEqual(page.pageErrors(), []);
697
- stop();
698
- });
699
-
700
- test("page.on starts collecting by itself, and startLogging is idempotent", async () => {
701
- const { ws, page, stop } = logPage();
702
- page.on("console", () => {});
703
- await Promise.all([page.startLogging(), page.startLogging()]);
704
- assert.equal(ws.sent.filter((m) => m.method === "session.subscribe" && (m.params as any).events[0] === "log.entryAdded").length, 1);
705
- stop();
706
- });
707
-
708
- test("syncLogs makes sure everything logged so far has arrived", async () => {
709
- const { ws, page, stop } = logPage();
710
- await page.startLogging();
711
- await page.syncLogs();
712
- assert.ok(ws.sent.some((m) => m.method === "session.status"));
713
- stop();
714
- });
715
-
716
- test("page errors can be allowed for a test", async () => {
717
- const { page, stop } = logPage();
718
- assert.equal(page.pageErrorsAllowed, false);
719
- page.allowPageErrors();
720
- assert.equal(page.pageErrorsAllowed, true);
721
- stop();
722
- });
723
-
724
- test("dispose stops collecting", async () => {
725
- const { ws, page, stop, entry } = logPage();
726
- await page.startLogging();
727
- await page.dispose();
728
- await tick(10);
729
- entry({ type: "console", method: "log", args: [] });
730
- assert.deepEqual(page.getLogs(), []);
731
- assert.ok(ws.sent.some((m) => m.method === "session.unsubscribe"));
732
- stop();
733
- });
734
- });
735
-
736
- describe("Page routing", () => {
737
- const TREE = [{ context: "ctx-1", parent: null, children: [] }];
738
- function routedPage(settings?: ConstructorParameters<typeof Page>[2]) {
739
- const ws = new FakeWebSocket();
740
- const connector = new BiDiConnector(ws as unknown as WebSocket);
741
- let intercepts = 0;
742
- const stop = autoReply(ws, {
743
- "browsingContext.getTree": () => ({ contexts: TREE }),
744
- "network.addIntercept": () => ({ intercept: `i${++intercepts}` }),
745
- });
746
- const page = new Page(connector, "ctx-1", settings);
747
- const commands = (method: string) => ws.sent.filter((m) => m.method === method).map((m) => m.params as any);
748
- const blocked = (id: string, url: string) =>
749
- ws.emitEvent("network.beforeRequestSent", {
750
- context: "ctx-1", isBlocked: true, intercepts: ["i1"], navigation: null, redirectCount: 0, timestamp: 0,
751
- request: { request: id, url, method: "GET", headers: [], cookies: [], headersSize: 0, bodySize: null, destination: "", initiatorType: "fetch", timings: {} },
752
- });
753
- return { ws, page, stop, commands, blocked };
754
- }
755
-
756
- test("route intercepts this page's requests and answers them", async () => {
757
- const { page, stop, commands, blocked } = routedPage();
758
- await page.route("**/api/users", (route) => route.fulfill({ json: [{ id: 1 }] }));
759
- assert.deepEqual(commands("network.addIntercept"), [{ phases: ["beforeRequestSent"], contexts: ["ctx-1"] }]);
760
- blocked("r1", "https://example.test/api/users");
761
- await tick(30);
762
- assert.equal(commands("network.provideResponse")[0].statusCode, 200);
763
- stop();
764
- });
765
-
766
- test("unroute and unrouteAll take routes away; before any route they do nothing", async () => {
767
- const { ws, page, stop, commands } = routedPage();
768
- await page.unroute("**/nothing");
769
- await page.unrouteAll();
770
- assert.equal(ws.sent.length, 0, "no router was created for that");
771
- await page.route("**/a", () => {});
772
- await page.unroute("**/a");
773
- assert.equal(commands("network.removeIntercept").length, 1);
774
- await page.route("**/b", () => {});
775
- await page.unrouteAll();
776
- assert.equal(commands("network.removeIntercept").length, 2);
777
- stop();
778
- });
779
-
780
- test("errors thrown by route handlers are available from routeErrors", async () => {
781
- const { page, stop, blocked } = routedPage();
782
- assert.deepEqual(page.routeErrors(), []);
783
- await page.route("**", () => {
784
- throw new Error("handler bug");
785
- });
786
- blocked("r1", "https://example.test/x");
787
- await tick(30);
788
- assert.equal(page.routeErrors().length, 1);
789
- assert.match(page.routeErrors()[0]!.message, /handler bug/);
790
- stop();
791
- });
792
-
793
- test("the route timeout comes from config network.routeTimeout", async () => {
794
- const { page, stop, commands, blocked } = routedPage({ network: { routeTimeout: 40 } });
795
- await page.route("**", async () => {
796
- await tick(300);
797
- });
798
- blocked("r1", "https://example.test/x");
799
- await tick(120);
800
- assert.deepEqual(commands("network.continueRequest"), [{ request: "r1" }]);
801
- stop();
802
- });
803
-
804
- test("dispose removes the intercept and lets held requests continue", async () => {
805
- const { page, stop, commands, blocked } = routedPage();
806
- await page.route("**", async () => {
807
- await tick(500);
808
- });
809
- blocked("r1", "https://example.test/x");
810
- await tick(20);
811
- await page.dispose();
812
- assert.deepEqual(commands("network.continueRequest"), [{ request: "r1" }]);
813
- assert.equal(commands("network.removeIntercept").length, 1);
814
- stop();
815
- });
816
- });
817
-
818
- describe("Page network data", () => {
819
- const TREE = [{ context: "ctx-1", parent: null, children: [] }];
820
- function dataPage(settings?: ConstructorParameters<typeof Page>[2], body = '{"ok":true}') {
821
- const ws = new FakeWebSocket();
822
- const connector = new BiDiConnector(ws as unknown as WebSocket);
823
- const stop = autoReply(ws, {
824
- "browsingContext.getTree": () => ({ contexts: TREE }),
825
- "network.addDataCollector": () => ({ collector: "c1" }),
826
- "network.getData": () => ({ bytes: { type: "string", value: body } }),
827
- });
828
- const page = new Page(connector, "ctx-1", settings);
829
- const commands = (method: string) => ws.sent.filter((m) => m.method === method).map((m) => m.params as any);
830
- const base = (id: string, url: string, context = "ctx-1") => ({
831
- context, isBlocked: false, navigation: null, redirectCount: 0, timestamp: 0,
832
- request: { request: id, url, method: "GET", headers: [], cookies: [], headersSize: 0, bodySize: null, destination: "", initiatorType: "fetch", timings: {} },
833
- });
834
- /** A request that starts and completes */
835
- const respond = (id: string, url: string, status = 200, context = "ctx-1") => {
836
- ws.emitEvent("network.beforeRequestSent", base(id, url, context));
837
- ws.emitEvent("network.responseCompleted", {
838
- ...base(id, url, context),
839
- response: { url, protocol: "http/1.1", status, statusText: "OK", fromCache: false, headers: [], mimeType: "", bytesReceived: 0, headersSize: 0, bodySize: 0, content: { size: 0 } },
840
- });
841
- };
842
- const request = (id: string, url: string) => ws.emitEvent("network.beforeRequestSent", base(id, url));
843
- return { ws, page, stop, commands, respond, request };
844
- }
845
-
846
- test("setCacheDisabled sends bypass for this page, default to turn it back on", async () => {
847
- const { page, stop, commands } = dataPage();
848
- await page.setCacheDisabled(true);
849
- await page.setCacheDisabled(false);
850
- assert.deepEqual(commands("network.setCacheBehavior"), [
851
- { cacheBehavior: "bypass", contexts: ["ctx-1"] },
852
- { cacheBehavior: "default", contexts: ["ctx-1"] },
853
- ]);
854
- stop();
855
- });
856
-
857
- test("waitForResponse resolves with a Response whose body can be read", async () => {
858
- const { page, stop, respond, commands } = dataPage();
859
- const waiting = page.waitForResponse("**/api/users", { timeout: 1000 });
860
- await tick(30); // tracking and body collection are set up first
861
- respond("r1", "https://example.test/api/other");
862
- respond("r2", "https://example.test/api/users", 201);
863
- const response = await waiting;
864
- assert.equal(response.status, 201);
865
- assert.equal(response.url, "https://example.test/api/users");
866
- assert.deepEqual(await response.json(), { ok: true });
867
- assert.deepEqual(commands("network.getData"), [{ dataType: "response", request: "r2", collector: "c1" }]);
868
- stop();
869
- });
870
-
871
- test("with a trigger, the wait is registered before the trigger runs", async () => {
872
- const { page, stop, respond } = dataPage();
873
- await page.startNetworkTracking();
874
- const response = await page.waitForResponse(/users$/, {
875
- timeout: 1000,
876
- trigger: async () => respond("r1", "https://example.test/users"),
877
- });
878
- assert.equal(response.request.id, "r1");
879
- stop();
880
- });
881
-
882
- test("a function decides what matches", async () => {
883
- const { page, stop, respond } = dataPage();
884
- await page.startNetworkTracking();
885
- const response = await page.waitForResponse((r) => r.status === 404, {
886
- timeout: 1000,
887
- trigger: async () => {
888
- respond("r1", "https://example.test/a", 200);
889
- respond("r2", "https://example.test/b", 404);
890
- },
891
- });
892
- assert.equal(response.url, "https://example.test/b");
893
- stop();
894
- });
895
-
896
- test("a trigger that throws rejects with its error and leaves nothing listening", async () => {
897
- const { page, stop, respond } = dataPage();
898
- await page.startNetworkTracking();
899
- await assert.rejects(
900
- page.waitForResponse("**", {
901
- timeout: 1000,
902
- trigger: async () => {
903
- throw new Error("click failed");
904
- },
905
- }),
906
- /click failed/,
907
- );
908
- let count = 0;
909
- page.on("response", () => count++);
910
- respond("r1", "https://example.test/x");
911
- assert.equal(count, 1, "only our own listener");
912
- stop();
913
- });
914
-
915
- test("waitForResponse times out naming what it waited for", async () => {
916
- const { page, stop } = dataPage();
917
- await assert.rejects(page.waitForResponse("**/api/users", { timeout: 40 }), (err: unknown) => {
918
- assert.ok(err instanceof ResponseTimeoutError);
919
- assert.equal(err.message, 'waitForResponse(): no response matching "**/api/users" within 40ms');
920
- return true;
921
- });
922
- await assert.rejects(page.waitForResponse(/users/, { timeout: 20 }), /no response matching \/users\/ within 20ms/);
923
- await assert.rejects(page.waitForResponse(() => false, { timeout: 20 }), /no response matching the given function within 20ms/);
924
- stop();
925
- });
926
-
927
- test("waitForRequest resolves with the request", async () => {
928
- const { page, stop, request } = dataPage();
929
- await page.startNetworkTracking();
930
- const found = await page.waitForRequest("**/api/**", {
931
- timeout: 1000,
932
- trigger: async () => {
933
- request("r1", "https://example.test/static.css");
934
- request("r2", "https://example.test/api/users");
935
- },
936
- });
937
- assert.equal(found.url, "https://example.test/api/users");
938
- await assert.rejects(page.waitForRequest("**/never", { timeout: 20 }), /waitForRequest\(\): no request matching "\*\*\/never" within 20ms/);
939
- stop();
940
- });
941
-
942
- test("page.on(response) hands out Responses with bodies, one instance for every listener", async () => {
943
- const { page, stop, respond, commands } = dataPage();
944
- const seen: unknown[] = [];
945
- page.on("response", (response) => seen.push(response));
946
- page.on("response", (response) => seen.push(response));
947
- await tick(40);
948
- respond("r1", "https://example.test/x");
949
- assert.equal(seen[0], seen[1]);
950
- const [response] = seen as [Awaited<ReturnType<Page["waitForResponse"]>>];
951
- await response.text();
952
- await (seen[1] as typeof response).text();
953
- assert.equal(commands("network.getData").length, 1, "read from the browser once");
954
- assert.equal(commands("network.disownData").length, 1, "and released");
955
- stop();
956
- });
957
-
958
- test("one collector serves the page, created once", async () => {
959
- const { page, stop, commands } = dataPage();
960
- await Promise.all([page.startBodyCollection(), page.startBodyCollection()]);
961
- await page.waitForResponse("**", { timeout: 20 }).catch(() => {});
962
- assert.equal(commands("network.addDataCollector").length, 1);
963
- assert.deepEqual(commands("network.addDataCollector")[0], {
964
- dataTypes: ["response"],
965
- maxEncodedDataSize: 10 * 1024 * 1024,
966
- contexts: ["ctx-1"],
967
- });
968
- stop();
969
- });
970
-
971
- test("config collectBodies starts collecting with the page, maxBodySize sets the limit", async () => {
972
- const { page, stop, commands } = dataPage({ network: { collectBodies: true, maxBodySize: 4096 } });
973
- await page.startNetworkTracking();
974
- assert.equal(commands("network.addDataCollector")[0].maxEncodedDataSize, 4096);
975
- stop();
976
- });
977
-
978
- test("bodies are not collected unless something asks for responses", async () => {
979
- const { page, stop, commands } = dataPage();
980
- await page.startNetworkTracking();
981
- assert.equal(commands("network.addDataCollector").length, 0);
982
- stop();
983
- });
984
-
985
- test("dispose removes the collector", async () => {
986
- const { page, stop, commands } = dataPage();
987
- await page.startBodyCollection();
988
- await page.dispose();
989
- assert.deepEqual(commands("network.removeDataCollector"), [{ collector: "c1" }]);
990
- stop();
991
- });
992
- });
993
-
994
- describe("Page API", () => {
995
- const TREE = [{ context: "ctx-1", parent: null, children: [] }];
996
- const str = (value: string) => ({ type: "success", realm: "r", result: { type: "string", value } });
997
- const PNG = Buffer.from("PNG-bytes");
998
- const PDF = Buffer.from("%PDF-1.7 fake");
999
-
1000
- function apiPage(handlers: Record<string, (params: any) => object | Error> = {}, settings?: ConstructorParameters<typeof Page>[2]) {
1001
- const ws = new FakeWebSocket();
1002
- const connector = new BiDiConnector(ws as unknown as WebSocket);
1003
- const stop = autoReply(ws, {
1004
- "browsingContext.getTree": () => ({ contexts: TREE }),
1005
- "browsingContext.captureScreenshot": () => ({ data: PNG.toString("base64") }),
1006
- "browsingContext.print": () => ({ data: PDF.toString("base64") }),
1007
- "browsingContext.reload": () => ({ navigation: "nav-2", url: "https://example.test/" }),
1008
- ...handlers,
1009
- });
1010
- const page = new Page(connector, "ctx-1", settings);
1011
- const commands = (method: string) => ws.sent.filter((m) => m.method === method).map((m) => m.params as any);
1012
- return { ws, page, stop, commands };
1013
- }
1014
- const bidiError = (code: string, message = "nope") => Object.assign(new Error(message), { code });
1015
- const tmp = () => mkdtempSync(path.join(tmpdir(), "samurai-page-api-"));
1016
-
1017
- describe("viewport", () => {
1018
- test("setViewport sends the size and the pixel ratio for this page", async () => {
1019
- const { page, stop, commands } = apiPage();
1020
- await page.setViewport({ width: 400, height: 300 });
1021
- await page.setViewport({ width: 800, height: 600, devicePixelRatio: 2 });
1022
- assert.deepEqual(commands("browsingContext.setViewport"), [
1023
- { context: "ctx-1", viewport: { width: 400, height: 300 } },
1024
- { context: "ctx-1", viewport: { width: 800, height: 600 }, devicePixelRatio: 2 },
1025
- ]);
1026
- stop();
1027
- });
1028
-
1029
- test("null resets both", async () => {
1030
- const { page, stop, commands } = apiPage();
1031
- await page.setViewport(null);
1032
- assert.deepEqual(commands("browsingContext.setViewport"), [
1033
- { context: "ctx-1", viewport: null, devicePixelRatio: null },
1034
- ]);
1035
- stop();
1036
- });
1037
-
1038
- test("rejects sizes that are not positive integers before sending", async () => {
1039
- const { page, stop, commands } = apiPage();
1040
- for (const bad of [{ width: 0, height: 10 }, { width: 10, height: -1 }, { width: 1.5, height: 10 }, { width: NaN, height: 10 }]) {
1041
- await assert.rejects(page.setViewport(bad), RangeError);
1042
- }
1043
- await assert.rejects(page.setViewport({ width: 10, height: 10, devicePixelRatio: 0 }), RangeError);
1044
- assert.equal(commands("browsingContext.setViewport").length, 0);
1045
- stop();
1046
- });
1047
-
1048
- test("applyDefaultViewport uses config use.viewport, 1280x720 without one", async () => {
1049
- const { page, stop, commands } = apiPage({}, { use: { viewport: { width: 640, height: 480 } } });
1050
- await page.applyDefaultViewport();
1051
- const other = apiPage();
1052
- await other.page.applyDefaultViewport();
1053
- assert.deepEqual(commands("browsingContext.setViewport"), [{ context: "ctx-1", viewport: { width: 640, height: 480 } }]);
1054
- assert.deepEqual(other.commands("browsingContext.setViewport"), [{ context: "ctx-1", viewport: { width: 1280, height: 720 } }]);
1055
- stop();
1056
- other.stop();
1057
- });
1058
-
1059
- test("viewport null in config keeps the browser's own size", async () => {
1060
- const { page, stop, commands } = apiPage({}, { use: { viewport: null } });
1061
- await page.applyDefaultViewport();
1062
- assert.equal(commands("browsingContext.setViewport").length, 0);
1063
- stop();
1064
- });
1065
- });
1066
-
1067
- describe("reload and history", () => {
1068
- test("reload waits for complete by default and returns the navigation", async () => {
1069
- const { page, stop, commands } = apiPage();
1070
- const result = await page.reload();
1071
- assert.deepEqual(result, { navigation: "nav-2", url: "https://example.test/" });
1072
- assert.deepEqual(commands("browsingContext.reload"), [{ context: "ctx-1", wait: "complete" }]);
1073
- stop();
1074
- });
1075
-
1076
- test("reload takes the wait from options or config", async () => {
1077
- const { page, stop, commands } = apiPage({}, { navigation: { waitUntil: "interactive" } });
1078
- await page.reload();
1079
- await page.reload({ wait: "none" });
1080
- assert.deepEqual(commands("browsingContext.reload").map((c) => c.wait), ["interactive", "none"]);
1081
- stop();
1082
- });
1083
-
1084
- test("reload passes ignoreCache, and says when the browser cannot do it", async () => {
1085
- const { page, stop, commands } = apiPage();
1086
- await page.reload({ ignoreCache: true });
1087
- assert.equal(commands("browsingContext.reload")[0].ignoreCache, true);
1088
- stop();
1089
- const refused = apiPage({ "browsingContext.reload": () => bidiError("unsupported operation", 'Argument "ignoreCache" is not supported yet.') });
1090
- await assert.rejects(refused.page.reload({ ignoreCache: true }), (err: unknown) => {
1091
- assert.ok(err instanceof UnsupportedOperationError);
1092
- assert.match(err.message, /reload\(\{ ignoreCache \}\) is not supported by the browser/);
1093
- return true;
1094
- });
1095
- refused.stop();
1096
- });
1097
-
1098
- test("reload failures are NavigationErrors that name the page", async () => {
1099
- const { page, stop } = apiPage({
1100
- "browsingContext.reload": () => bidiError("unknown error", "NS_ERROR_NET_TIMEOUT"),
1101
- "script.callFunction": () => str("https://example.test/here"),
1102
- });
1103
- await assert.rejects(page.reload(), (err: unknown) => {
1104
- assert.ok(err instanceof NavigationError);
1105
- assert.match(err.message, /^reload\(\): https:\/\/example\.test\/here failed: .*NS_ERROR_NET_TIMEOUT/);
1106
- return true;
1107
- });
1108
- stop();
1109
- });
1110
-
1111
- test("goBack and goForward move one entry and say true", async () => {
1112
- const { page, stop, commands } = apiPage({ "browsingContext.traverseHistory": () => ({}) });
1113
- assert.equal(await page.goBack(), true);
1114
- assert.equal(await page.goForward(), true);
1115
- assert.deepEqual(commands("browsingContext.traverseHistory"), [
1116
- { context: "ctx-1", delta: -1 },
1117
- { context: "ctx-1", delta: 1 },
1118
- ]);
1119
- stop();
1120
- });
1121
-
1122
- test("with no entry to go to they say false; other errors are thrown", async () => {
1123
- const none = apiPage({ "browsingContext.traverseHistory": () => bidiError("no such history entry") });
1124
- assert.equal(await none.page.goBack(), false);
1125
- assert.equal(await none.page.goForward(), false);
1126
- none.stop();
1127
- const broken = apiPage({ "browsingContext.traverseHistory": () => bidiError("no such frame") });
1128
- await assert.rejects(broken.page.goBack(), { code: "no such frame" });
1129
- broken.stop();
1130
- });
1131
- });
1132
-
1133
- describe("url and title", () => {
1134
- test("read location.href and document.title", async () => {
1135
- const { page, stop, ws } = apiPage({ "script.callFunction": (params) => str(params.functionDeclaration.includes("title") ? "My title" : "https://example.test/x") });
1136
- assert.equal(await page.url(), "https://example.test/x");
1137
- assert.equal(await page.title(), "My title");
1138
- assert.equal(ws.sent.filter((m) => m.method === "script.callFunction").length, 2);
1139
- stop();
1140
- });
1141
- });
1142
-
1143
- describe("screenshot", () => {
1144
- test("returns the image as a Buffer; nothing but the context is sent by default", async () => {
1145
- const { page, stop, commands } = apiPage();
1146
- const image = await page.screenshot();
1147
- assert.deepEqual([...image], [...PNG]);
1148
- assert.deepEqual(commands("browsingContext.captureScreenshot"), [{ context: "ctx-1" }]);
1149
- stop();
1150
- });
1151
-
1152
- test("fullPage captures the document, clip a box", async () => {
1153
- const { page, stop, commands } = apiPage();
1154
- await page.screenshot({ fullPage: true });
1155
- await page.screenshot({ clip: { x: 1, y: 2, width: 30, height: 40 } });
1156
- await page.screenshot({ fullPage: true, clip: { x: 0, y: 100, width: 50, height: 60 } });
1157
- assert.deepEqual(commands("browsingContext.captureScreenshot"), [
1158
- { context: "ctx-1", origin: "document" },
1159
- { context: "ctx-1", clip: { type: "box", x: 1, y: 2, width: 30, height: 40 } },
1160
- { context: "ctx-1", origin: "document", clip: { type: "box", x: 0, y: 100, width: 50, height: 60 } },
1161
- ]);
1162
- stop();
1163
- });
1164
-
1165
- test("types are sent as MIME types, jpeg quality as a fraction", async () => {
1166
- const { page, stop, commands } = apiPage();
1167
- await page.screenshot({ type: "png" });
1168
- await page.screenshot({ type: "jpeg" });
1169
- await page.screenshot({ type: "jpeg", quality: 80 });
1170
- assert.deepEqual(commands("browsingContext.captureScreenshot").map((c) => c.format), [
1171
- { type: "image/png" },
1172
- { type: "image/jpeg" },
1173
- { type: "image/jpeg", quality: 0.8 },
1174
- ]);
1175
- stop();
1176
- });
1177
-
1178
- test("rejects a quality outside 0–100, or without jpeg, before sending", async () => {
1179
- const { page, stop, commands } = apiPage();
1180
- await assert.rejects(page.screenshot({ type: "jpeg", quality: 101 }), RangeError);
1181
- await assert.rejects(page.screenshot({ type: "jpeg", quality: -1 }), RangeError);
1182
- await assert.rejects(page.screenshot({ quality: 50 }), /quality only applies to jpeg/);
1183
- await assert.rejects(page.screenshot({ type: "png", quality: 50 }), /quality only applies to jpeg/);
1184
- assert.equal(commands("browsingContext.captureScreenshot").length, 0);
1185
- stop();
1186
- });
1187
-
1188
- test("path writes the file, making the folders, and still returns the image", async () => {
1189
- const { page, stop } = apiPage();
1190
- const dir = tmp();
1191
- try {
1192
- const file = path.join(dir, "deep", "er", "shot.png");
1193
- const image = await page.screenshot({ path: file });
1194
- assert.deepEqual([...readFileSync(file)], [...PNG]);
1195
- assert.deepEqual([...image], [...PNG]);
1196
- } finally {
1197
- rmSync(dir, { recursive: true, force: true });
1198
- }
1199
- stop();
1200
- });
1201
-
1202
- test("a browser refusal is a BiDiError", async () => {
1203
- const { page, stop } = apiPage({ "browsingContext.captureScreenshot": () => bidiError("unable to capture screen") });
1204
- await assert.rejects(page.screenshot(), { name: "BiDiError", code: "unable to capture screen" });
1205
- stop();
1206
- });
1207
- });
1208
-
1209
- describe("Locator.screenshot", () => {
1210
- const state = (extra: object = {}) => ({
1211
- type: "success", realm: "r",
1212
- result: { type: "object", value: Object.entries({ attached: true, visible: true, enabled: true, editable: false, box: null, hitTarget: null, ...extra }).map(([k, v]) => [k, v === null ? { type: "null" } : { type: typeof v === "boolean" ? "boolean" : "string", value: v }]) },
1213
- });
1214
- const located = { "browsingContext.locateNodes": () => ({ nodes: [{ type: "node", sharedId: "node-9" }] }) };
1215
-
1216
- test("captures just the element, after waiting until it is attached and visible", async () => {
1217
- const { page, stop, commands } = apiPage({ ...located, "script.callFunction": () => state() });
1218
- const image = await page.locator("div[@id='card']").screenshot({ timeout: 1000 });
1219
- assert.deepEqual([...image], [...PNG]);
1220
- assert.deepEqual(commands("browsingContext.captureScreenshot"), [
1221
- { context: "ctx-1", clip: { type: "element", element: { sharedId: "node-9" } } },
1222
- ]);
1223
- stop();
1224
- });
1225
-
1226
- test("scrolls the element into view first", async () => {
1227
- const { page, stop, ws } = apiPage({ ...located, "script.callFunction": () => state() });
1228
- await page.locator("div").screenshot({ timeout: 1000 });
1229
- const probe = ws.sent.find((m) => m.method === "script.callFunction" && (m.params as any).arguments)!.params as any;
1230
- assert.deepEqual(probe.arguments[1].value.find(([k]: [string]) => k === "scroll")[1], { type: "boolean", value: true });
1231
- stop();
1232
- });
1233
-
1234
- test("times out naming the checks when the element is not visible", async () => {
1235
- const { page, stop, commands } = apiPage({ ...located, "script.callFunction": () => state({ visible: false }) });
1236
- await assert.rejects(page.locator("//div").screenshot({ timeout: 150 }), (err: unknown) => {
1237
- assert.ok(err instanceof ActionTimeoutError);
1238
- assert.equal(err.action, "screenshot");
1239
- assert.match(err.message, /^screenshot\(\): \/\/div was not actionable within 150ms\n attached ✓ visible ✗/);
1240
- return true;
1241
- });
1242
- assert.equal(commands("browsingContext.captureScreenshot").length, 0);
1243
- stop();
1244
- });
1245
-
1246
- test("takes a type, a quality and a path like the page does", async () => {
1247
- const { page, stop, commands } = apiPage({ ...located, "script.callFunction": () => state() });
1248
- const dir = tmp();
1249
- try {
1250
- const file = path.join(dir, "el.jpg");
1251
- await page.locator("div").screenshot({ type: "jpeg", quality: 50, path: file, timeout: 1000 });
1252
- assert.deepEqual(commands("browsingContext.captureScreenshot")[0].format, { type: "image/jpeg", quality: 0.5 });
1253
- assert.deepEqual([...readFileSync(file)], [...PNG]);
1254
- } finally {
1255
- rmSync(dir, { recursive: true, force: true });
1256
- }
1257
- stop();
1258
- });
1259
- });
1260
-
1261
- describe("pdf", () => {
1262
- test("returns the PDF as a Buffer; defaults send only the context", async () => {
1263
- const { page, stop, commands } = apiPage();
1264
- const pdf = await page.pdf();
1265
- assert.deepEqual([...pdf], [...PDF]);
1266
- assert.deepEqual(commands("browsingContext.print"), [{ context: "ctx-1" }]);
1267
- stop();
1268
- });
1269
-
1270
- test("maps the options: landscape, scale, ranges, margins, background and paper size", async () => {
1271
- const { page, stop, commands } = apiPage();
1272
- await page.pdf({
1273
- landscape: true,
1274
- scale: 0.5,
1275
- pageRanges: ["1-2", 4],
1276
- margin: { top: 1, bottom: 1.5, left: 2, right: 2.5 },
1277
- printBackground: true,
1278
- format: "A4",
1279
- });
1280
- await page.pdf({ format: "Letter" });
1281
- assert.deepEqual(commands("browsingContext.print"), [
1282
- {
1283
- context: "ctx-1",
1284
- orientation: "landscape",
1285
- scale: 0.5,
1286
- pageRanges: ["1-2", 4],
1287
- margin: { top: 1, bottom: 1.5, left: 2, right: 2.5 },
1288
- background: true,
1289
- page: { width: 21, height: 29.7 },
1290
- },
1291
- { context: "ctx-1", page: { width: 21.59, height: 27.94 } },
1292
- ]);
1293
- stop();
1294
- });
1295
-
1296
- test("validates the scale and the paper format before sending", async () => {
1297
- const { page, stop, commands } = apiPage();
1298
- await assert.rejects(page.pdf({ scale: 0.05 }), RangeError);
1299
- await assert.rejects(page.pdf({ scale: 2.5 }), RangeError);
1300
- await assert.rejects(page.pdf({ format: "Tabloid-ish" as never }), /unknown paper format/);
1301
- assert.equal(commands("browsingContext.print").length, 0);
1302
- stop();
1303
- });
1304
-
1305
- test("path writes the file", async () => {
1306
- const { page, stop } = apiPage();
1307
- const dir = tmp();
1308
- try {
1309
- const file = path.join(dir, "out", "page.pdf");
1310
- await page.pdf({ path: file });
1311
- assert.deepEqual([...readFileSync(file)], [...PDF]);
1312
- } finally {
1313
- rmSync(dir, { recursive: true, force: true });
1314
- }
1315
- stop();
1316
- });
1317
- });
1318
-
1319
- describe("close", () => {
1320
- test("closes the tab, releases everything the page registered, and refuses further use", async () => {
1321
- const { page, stop, commands } = apiPage({
1322
- "browsingContext.close": () => ({}),
1323
- "network.addIntercept": () => ({ intercept: "i1" }),
1324
- });
1325
- await page.route("**", () => {});
1326
- await page.close();
1327
- assert.deepEqual(commands("browsingContext.close"), [{ context: "ctx-1" }]);
1328
- assert.equal(commands("network.removeIntercept").length, 1, "disposed");
1329
- for (const use of [
1330
- () => page.navigateTo("https://example.test"),
1331
- () => page.reload(),
1332
- () => page.goBack(),
1333
- () => page.goForward(),
1334
- () => page.screenshot(),
1335
- () => page.pdf(),
1336
- () => page.url(),
1337
- () => page.title(),
1338
- () => page.setViewport({ width: 1, height: 1 }),
1339
- () => page.waitForLoadState(),
1340
- () => page.addInitScript("1"),
1341
- () => page.route("**", () => {}),
1342
- ]) {
1343
- await assert.rejects(use(), new Error("page closed"));
1344
- }
1345
- assert.throws(() => page.locator("//a"), /page closed/);
1346
- assert.equal(page.closed, true);
1347
- stop();
1348
- });
1349
-
1350
- test("runBeforeUnload asks the page to run its beforeunload handlers", async () => {
1351
- const { page, stop, commands } = apiPage({ "browsingContext.close": () => ({}) });
1352
- await page.close({ runBeforeUnload: true });
1353
- assert.deepEqual(commands("browsingContext.close"), [{ context: "ctx-1", promptUnload: true }]);
1354
- stop();
1355
- });
1356
-
1357
- test("closing twice is fine and sends nothing more", async () => {
1358
- const { page, stop, commands } = apiPage({ "browsingContext.close": () => ({}) });
1359
- await page.close();
1360
- await page.close();
1361
- assert.equal(commands("browsingContext.close").length, 1);
1362
- stop();
1363
- });
1364
-
1365
- test("a page that is already gone is closed all the same", async () => {
1366
- const { page, stop } = apiPage({ "browsingContext.close": () => bidiError("no such frame") });
1367
- await assert.doesNotReject(page.close());
1368
- assert.equal(page.closed, true);
1369
- stop();
1370
- });
1371
-
1372
- test("other close errors are thrown, and the page stays usable", async () => {
1373
- const { page, stop } = apiPage({ "browsingContext.close": () => bidiError("unable to close browser") });
1374
- await assert.rejects(page.close(), { code: "unable to close browser" });
1375
- assert.equal(page.closed, false);
1376
- stop();
1377
- });
1378
- });
1379
- });
1380
-
1381
- describe("Page downloads and file choosers", () => {
1382
- const TREE = [{ context: "ctx-1", parent: null, children: [] }];
1383
- function transferPage(settings?: ConstructorParameters<typeof Page>[2]) {
1384
- const ws = new FakeWebSocket();
1385
- const connector = new BiDiConnector(ws as unknown as WebSocket);
1386
- const stop = autoReply(ws, {
1387
- "browsingContext.getTree": () => ({ contexts: TREE }),
1388
- "input.setFiles": () => ({}),
1389
- });
1390
- const page = new Page(connector, "ctx-1", settings);
1391
- const begin = (navigation: string, filename = "report.csv") =>
1392
- ws.emitEvent("browsingContext.downloadWillBegin", { context: "ctx-1", navigation, timestamp: 1, url: `https://example.test/${filename}`, suggestedFilename: filename });
1393
- const end = (navigation: string, filepath = "/tmp/dl/report.csv") =>
1394
- ws.emitEvent("browsingContext.downloadEnd", { context: "ctx-1", navigation, timestamp: 2, url: "u", status: "complete", filepath });
1395
- const chooser = (multiple = false) =>
1396
- ws.emitEvent("input.fileDialogOpened", { context: "ctx-1", multiple, element: { type: "node", sharedId: "input-1" } });
1397
- return { ws, page, stop, begin, end, chooser };
1398
- }
1399
-
1400
- test("waitForDownload resolves when the download starts, and path() when it ends", async () => {
1401
- const { page, stop, begin, end } = transferPage();
1402
- await page.startDownloadTracking();
1403
- const download = await page.waitForDownload({
1404
- timeout: 1000,
1405
- trigger: async () => begin("n1", "report.csv"),
1406
- });
1407
- assert.equal(download.suggestedFilename(), "report.csv");
1408
- assert.equal(download.url(), "https://example.test/report.csv");
1409
- const pending = download.path();
1410
- end("n1", "/tmp/dl/report.csv");
1411
- assert.equal(await pending, "/tmp/dl/report.csv");
1412
- stop();
1413
- });
1414
-
1415
- test("waitForDownload sets up tracking itself, and takes the first download after it", async () => {
1416
- const { page, stop, begin } = transferPage();
1417
- const waiting = page.waitForDownload({ timeout: 1000 });
1418
- await tick(40);
1419
- begin("n1", "first.csv");
1420
- begin("n2", "second.csv");
1421
- assert.equal((await waiting).suggestedFilename(), "first.csv");
1422
- stop();
1423
- });
1424
-
1425
- test("a trigger that throws rejects the wait with its error and leaves nothing listening", async () => {
1426
- const { page, stop, begin } = transferPage();
1427
- await page.startDownloadTracking();
1428
- await assert.rejects(
1429
- page.waitForDownload({
1430
- timeout: 1000,
1431
- trigger: async () => {
1432
- throw new Error("click failed");
1433
- },
1434
- }),
1435
- /click failed/,
1436
- );
1437
- const seen: string[] = [];
1438
- page.on("download", (d) => seen.push(d.suggestedFilename()));
1439
- begin("n1", "x.csv");
1440
- assert.deepEqual(seen, ["x.csv"], "only our own listener");
1441
- stop();
1442
- });
1443
-
1444
- test("waitForDownload times out with a message of its own", async () => {
1445
- const { page, stop } = transferPage();
1446
- await assert.rejects(page.waitForDownload({ timeout: 40 }), /waitForDownload\(\): no download started within 40ms/);
1447
- stop();
1448
- });
1449
-
1450
- test("the time path() may take defaults to the navigation timeout in config", async () => {
1451
- const { page, stop, begin } = transferPage({ navigation: { timeout: 40 } });
1452
- await page.startDownloadTracking();
1453
- const download = await page.waitForDownload({ trigger: async () => begin("n1") });
1454
- await assert.rejects(download.path(), /did not finish within 40ms/);
1455
- stop();
1456
- });
1457
-
1458
- test("page.on(download) starts tracking by itself; page.off stops it", async () => {
1459
- const { page, stop, begin } = transferPage();
1460
- const seen: string[] = [];
1461
- const listener = (d: { suggestedFilename(): string }) => seen.push(d.suggestedFilename());
1462
- page.on("download", listener);
1463
- await tick(40);
1464
- begin("n1", "a.csv");
1465
- page.off("download", listener);
1466
- begin("n2", "b.csv");
1467
- assert.deepEqual(seen, ["a.csv"]);
1468
- stop();
1469
- });
1470
-
1471
- test("waitForFileChooser resolves with a chooser that can pick files", async () => {
1472
- const { ws, page, stop, chooser } = transferPage();
1473
- await page.startFileChooserTracking();
1474
- const picked = await page.waitForFileChooser({ timeout: 1000, trigger: async () => chooser(true) });
1475
- assert.equal(picked.isMultiple(), true);
1476
- await picked.setFiles([]);
1477
- const sent = ws.sent.filter((m) => m.method === "input.setFiles").map((m) => m.params as any);
1478
- assert.deepEqual(sent, [{ context: "ctx-1", element: { sharedId: "input-1" }, files: [] }]);
1479
- stop();
1480
- });
1481
-
1482
- test("waitForFileChooser sets up tracking itself and times out with a message of its own", async () => {
1483
- const { page, stop } = transferPage();
1484
- await assert.rejects(page.waitForFileChooser({ timeout: 40 }), /waitForFileChooser\(\): no file chooser opened within 40ms/);
1485
- stop();
1486
- });
1487
-
1488
- test("page.on(filechooser) starts tracking by itself", async () => {
1489
- const { page, stop, chooser } = transferPage();
1490
- let count = 0;
1491
- page.on("filechooser", () => count++);
1492
- await tick(40);
1493
- chooser();
1494
- assert.equal(count, 1);
1495
- stop();
1496
- });
1497
-
1498
- test("tracking is set up once, however it is asked for", async () => {
1499
- const { ws, page, stop } = transferPage();
1500
- await Promise.all([page.startDownloadTracking(), page.startDownloadTracking(), page.startFileChooserTracking(), page.startFileChooserTracking()]);
1501
- const events = ws.sent.filter((m) => m.method === "session.subscribe").map((m) => (m.params as any).events[0]);
1502
- assert.equal(events.filter((e: string) => e === "browsingContext.downloadWillBegin").length, 1);
1503
- assert.equal(events.filter((e: string) => e === "input.fileDialogOpened").length, 1);
1504
- stop();
1505
- });
1506
-
1507
- test("dispose stops both and fails downloads still going on", async () => {
1508
- const { page, stop, begin, chooser } = transferPage();
1509
- await page.startFileChooserTracking();
1510
- const download = await page.waitForDownload({ timeout: 1000, trigger: async () => begin("n1") });
1511
- const pending = download.path();
1512
- const rejection = assert.rejects(pending, /page closed/);
1513
- await page.dispose();
1514
- await rejection;
1515
- let count = 0;
1516
- page.on("filechooser", () => count++);
1517
- chooser();
1518
- assert.equal(count, 0, "the old tracker is gone");
1519
- stop();
1520
- });
1521
- });
1522
-
1523
- describe("Page.goto", () => {
1524
- const urls = (sent: Array<{ params: unknown }>) => sent.map(({ params }) => (params as { url: string }).url);
1525
-
1526
- test("resolves relative URLs against the page's base URL", async () => {
1527
- const { connector, sent } = stubConnector({ type: "undefined" });
1528
- const page = new Page(connector, "ctx");
1529
- page.setBaseURL("https://staging.harbor.shop/shop/");
1530
- await page.goto("/products");
1531
- await page.goto("./cart");
1532
- await page.goto("../account");
1533
- assert.deepEqual(urls(sent), [
1534
- "https://staging.harbor.shop/products",
1535
- "https://staging.harbor.shop/shop/cart",
1536
- "https://staging.harbor.shop/account",
1537
- ]);
1538
- });
1539
-
1540
- test("keeps absolute URLs and bare hosts like navigateTo", async () => {
1541
- const { connector, sent } = stubConnector({ type: "undefined" });
1542
- const page = new Page(connector, "ctx");
1543
- page.setBaseURL("https://staging.harbor.shop");
1544
- await page.goto("https://other.test/x");
1545
- await page.goto("itmetsam.nl");
1546
- assert.deepEqual(urls(sent), ["https://other.test/x", "https://itmetsam.nl"]);
1547
- });
1548
-
1549
- test("uses the active run's base URL when the page has none", async () => {
1550
- setRunSettings(resolveRunSettings({ baseURL: "https://harbor.shop" }));
1551
- try {
1552
- const { connector, sent } = stubConnector({ type: "undefined" });
1553
- await new Page(connector, "ctx").goto("/login");
1554
- assert.deepEqual(urls(sent), ["https://harbor.shop/login"]);
1555
- } finally {
1556
- setRunSettings(undefined);
1557
- }
1558
- });
1559
-
1560
- test("a relative URL without a base URL throws", async () => {
1561
- const { connector, sent } = stubConnector({ type: "undefined" });
1562
- await assert.rejects(new Page(connector, "ctx").goto("/products"), {
1563
- message: 'page.goto("/products") needs a baseURL; set one in samurai.config.ts or the environment',
1564
- });
1565
- assert.equal(sent.length, 0);
1566
- });
1567
- });