@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,420 +0,0 @@
1
- import { STATUS_CODES } from "node:http";
2
- import type { ContextTree } from "../browser/context-tree.js";
3
- import logger from "../logger/index.js";
4
- import type { BiDiConnector } from "../transport/bidi-connection.js";
5
- import { BiDiError } from "../transport/bidi-error.js";
6
- import type { Subscription } from "../transport/subscriptions.js";
7
- import type {
8
- BeforeRequestSentParameters,
9
- BytesValue,
10
- Header,
11
- UrlPattern as BiDiUrlPattern,
12
- } from "../types/bidi-modules/network.js";
13
- import { toRequest, type NetworkRequest } from "./network-tracker.js";
14
- import { isExactString, matchUrl, samePattern, type UrlPattern } from "./url-match.js";
15
-
16
- /** Time (ms) a handler may take to answer a request before the router lets it continue */
17
- const DEFAULT_ROUTE_TIMEOUT = 30000;
18
- /** How many handler errors the router keeps */
19
- const MAX_ERRORS = 100;
20
-
21
- export type RouteHandler = (route: Route) => void | Promise<void>;
22
-
23
- export interface FulfillOptions {
24
- /** @default 200 */
25
- status?: number;
26
- headers?: Record<string, string>;
27
- /** Sets `content-type` unless `headers` has one; `json` implies "application/json" */
28
- contentType?: string;
29
- body?: string | Buffer;
30
- /** Sent as JSON text; cannot be combined with `body` */
31
- json?: unknown;
32
- }
33
-
34
- export interface ContinueOptions {
35
- /**
36
- * Sends the request to another URL. Firefox applies this to navigations only: the page's `fetch`, XHR and
37
- * image requests fail when their URL is changed, so mock those with `fulfill` instead
38
- */
39
- url?: string;
40
- method?: string;
41
- /** Replaces all request headers */
42
- headers?: Record<string, string>;
43
- postData?: string | Buffer;
44
- }
45
-
46
- interface RouteEntry {
47
- pattern: UrlPattern;
48
- handler: RouteHandler;
49
- }
50
-
51
- /** Answers `network.*` commands for one blocked request */
52
- interface RouteDeps {
53
- send: BiDiConnector["send"];
54
- /** Passes the request to the next matching route, or lets it continue */
55
- next: () => Promise<void>;
56
- /** Called once the route is answered, whichever way */
57
- settled: (route: Route) => void;
58
- }
59
-
60
- function toHeaders(headers: Record<string, string>): Header[] {
61
- return Object.entries(headers).map(([name, value]) => ({ name, value: { type: "string", value } }));
62
- }
63
-
64
- function toBytes(data: string | Buffer): BytesValue {
65
- return typeof data === "string"
66
- ? { type: "string", value: data }
67
- : { type: "base64", value: data.toString("base64") };
68
- }
69
-
70
- const hasHeader = (headers: Record<string, string>, name: string) =>
71
- Object.keys(headers).some((key) => key.toLowerCase() === name);
72
-
73
- /**
74
- * A request the browser holds until it is answered. Answer it exactly once with `fulfill`, `continue`,
75
- * `abort` or `fallback`; a second call throws.
76
- */
77
- export class Route {
78
- private answered = false;
79
- /** @internal */
80
- public timer: ReturnType<typeof setTimeout> | undefined;
81
-
82
- constructor(
83
- private readonly info: NetworkRequest,
84
- /** @internal */
85
- public readonly entry: RouteEntry,
86
- private readonly deps: RouteDeps,
87
- ) {}
88
-
89
- public request(): NetworkRequest {
90
- return this.info;
91
- }
92
-
93
- /** @internal */
94
- public get isAnswered(): boolean {
95
- return this.answered;
96
- }
97
-
98
- /** Answers the request without sending it: status 200 and no body unless told otherwise */
99
- public async fulfill(options: FulfillOptions = {}): Promise<void> {
100
- if (options.json !== undefined && options.body !== undefined) {
101
- throw new TypeError("route.fulfill(): use either json or body, not both");
102
- }
103
- const text = options.json !== undefined ? JSON.stringify(options.json) : undefined;
104
- const body = options.body ?? text;
105
- const headers: Record<string, string> = { ...options.headers };
106
- const contentType = options.contentType ?? (text !== undefined ? "application/json" : undefined);
107
- if (contentType && !hasHeader(headers, "content-type")) headers["content-type"] = contentType;
108
- if (!hasHeader(headers, "content-length")) {
109
- headers["content-length"] = String(body === undefined ? 0 : Buffer.byteLength(body));
110
- }
111
- const status = options.status ?? 200;
112
- await this.answer("network.provideResponse", {
113
- request: this.info.id,
114
- statusCode: status,
115
- reasonPhrase: STATUS_CODES[status] ?? "",
116
- headers: toHeaders(headers),
117
- ...(body !== undefined && { body: toBytes(body) }),
118
- });
119
- }
120
-
121
- /**
122
- * Lets the request go on, optionally changed. `headers` replaces all request headers. A new `postData` brings its
123
- * own `content-length` (the request's old one would cut the new body off or leave the server waiting).
124
- */
125
- public async continue(options: ContinueOptions = {}): Promise<void> {
126
- let headers = options.headers;
127
- if (options.postData !== undefined) {
128
- const withoutLength = Object.fromEntries(
129
- Object.entries(headers ?? this.info.headers).filter(([name]) => name.toLowerCase() !== "content-length"),
130
- );
131
- headers = { ...withoutLength, "content-length": String(Buffer.byteLength(options.postData)) };
132
- }
133
- await this.answer("network.continueRequest", {
134
- request: this.info.id,
135
- ...(options.url !== undefined && { url: options.url }),
136
- ...(options.method !== undefined && { method: options.method }),
137
- ...(headers !== undefined && { headers: toHeaders(headers) }),
138
- ...(options.postData !== undefined && { body: toBytes(options.postData) }),
139
- });
140
- }
141
-
142
- /** Makes the request fail (the browser reports a network error to the page) */
143
- public async abort(): Promise<void> {
144
- await this.answer("network.failRequest", { request: this.info.id });
145
- }
146
-
147
- /** Hands the request to the next route that matches it, or lets it continue when there is none */
148
- public async fallback(): Promise<void> {
149
- this.claim();
150
- await this.deps.next();
151
- }
152
-
153
- /** @internal Lets the request continue unchanged; failures are logged, not thrown */
154
- public async release(): Promise<void> {
155
- if (this.answered) return;
156
- try {
157
- await this.continue();
158
- } catch (err) {
159
- logger.debug("Could not let %s continue", this.info.url, { err });
160
- }
161
- }
162
-
163
- /** @internal Fails the request; failures are logged, not thrown */
164
- public async failQuietly(): Promise<void> {
165
- if (this.answered) return;
166
- try {
167
- await this.abort();
168
- } catch (err) {
169
- logger.debug("Could not fail %s", this.info.url, { err });
170
- }
171
- }
172
-
173
- private claim() {
174
- if (this.answered) throw new Error("route already handled");
175
- this.answered = true;
176
- clearTimeout(this.timer);
177
- this.deps.settled(this);
178
- }
179
-
180
- private async answer<M extends "network.provideResponse" | "network.continueRequest" | "network.failRequest">(
181
- method: M,
182
- params: Parameters<BiDiConnector["send"]>[1],
183
- ) {
184
- this.claim();
185
- await this.deps.send(method, params as never);
186
- }
187
- }
188
-
189
- /** Whether a browser accepts `urlPatterns` in `network.addIntercept`; asked once per connection */
190
- const patternsUnsupported = new WeakSet<BiDiConnector>();
191
-
192
- /**
193
- * Lets a page mock, block and change requests. One `network.addIntercept` serves all routes; matching
194
- * happens here. Every request the browser holds is answered exactly once, so nothing hangs.
195
- */
196
- export class Router {
197
- private routes: RouteEntry[] = [];
198
- private pending = new Set<Route>();
199
- private failures: Error[] = [];
200
- private queue: Promise<unknown> = Promise.resolve();
201
- private interceptIds = new Set<string>();
202
- private currentKey: string | undefined;
203
- private currentId: string | undefined;
204
- private subscription: Subscription | undefined;
205
- private disposed = false;
206
- private routeTimeout: number;
207
-
208
- constructor(
209
- private connector: BiDiConnector,
210
- private tree: ContextTree,
211
- private contextId: string,
212
- options: { routeTimeout?: number } = {},
213
- ) {
214
- this.routeTimeout = options.routeTimeout ?? DEFAULT_ROUTE_TIMEOUT;
215
- }
216
-
217
- /** Errors thrown by route handlers, oldest first */
218
- public get errors(): readonly Error[] {
219
- return [...this.failures];
220
- }
221
-
222
- /** Sends matching requests to `handler`. Later routes are asked first */
223
- public async route(pattern: UrlPattern, handler: RouteHandler): Promise<void> {
224
- if (this.disposed) throw new Error("router disposed");
225
- const entry: RouteEntry = { pattern, handler };
226
- this.routes.push(entry);
227
- try {
228
- await this.enqueue(() => this.sync());
229
- } catch (err) {
230
- this.routes = this.routes.filter((route) => route !== entry);
231
- throw err;
232
- }
233
- }
234
-
235
- /** Removes the routes for `pattern` (only the one with `handler`, when given) */
236
- public async unroute(pattern: UrlPattern, handler?: RouteHandler): Promise<void> {
237
- const removed = this.routes.filter(
238
- (route) => samePattern(route.pattern, pattern) && (handler === undefined || route.handler === handler),
239
- );
240
- await this.remove(removed);
241
- }
242
-
243
- public async unrouteAll(): Promise<void> {
244
- await this.remove([...this.routes]);
245
- }
246
-
247
- /** Lets held requests continue, removes the intercept and stops listening. Idempotent */
248
- public async dispose(): Promise<void> {
249
- if (this.disposed) return;
250
- this.disposed = true;
251
- await this.remove([...this.routes]);
252
- }
253
-
254
- private async remove(removed: RouteEntry[]) {
255
- if (removed.length === 0) return;
256
- this.routes = this.routes.filter((route) => !removed.includes(route));
257
- // Requests a removed route still holds would wait for nobody
258
- await Promise.all([...this.pending].filter((route) => removed.includes(route.entry)).map((route) => route.release()));
259
- await this.enqueue(() => this.sync());
260
- }
261
-
262
- private enqueue<T>(work: () => Promise<T>): Promise<T> {
263
- const run = this.queue.then(work);
264
- this.queue = run.catch(() => undefined);
265
- return run;
266
- }
267
-
268
- /** Makes the browser-side intercept match the routes: none, one for everything, or one for exact URLs */
269
- private async sync(): Promise<void> {
270
- if (this.routes.length === 0) return this.stop();
271
-
272
- await this.listen();
273
- const patterns = this.urlPatterns();
274
- const key = patterns ? JSON.stringify(patterns) : "all";
275
- if (key === this.currentKey) return;
276
-
277
- const id = await this.addIntercept(patterns);
278
- const previous = this.currentId;
279
- this.currentId = id;
280
- this.currentKey = patterns && !patternsUnsupported.has(this.connector) ? key : "all";
281
- this.interceptIds.add(id);
282
- // The new intercept is in place before the old one goes: nothing slips through in between
283
- if (previous !== undefined) await this.removeIntercept(previous);
284
- }
285
-
286
- /** The exact URLs to block, or undefined to block everything and match here */
287
- private urlPatterns(): BiDiUrlPattern[] | undefined {
288
- if (patternsUnsupported.has(this.connector)) return undefined;
289
- const patterns: BiDiUrlPattern[] = [];
290
- for (const { pattern } of this.routes) {
291
- // The browser reads a URL string in normal form; anything else could mean something else to it
292
- if (!isExactString(pattern) || !isNormalUrl(pattern)) return undefined;
293
- if (!patterns.some((existing) => existing.type === "string" && existing.pattern === pattern)) {
294
- patterns.push({ type: "string", pattern });
295
- }
296
- }
297
- return patterns;
298
- }
299
-
300
- private async addIntercept(patterns: BiDiUrlPattern[] | undefined): Promise<string> {
301
- const params = {
302
- phases: ["beforeRequestSent" as const],
303
- contexts: [this.contextId],
304
- };
305
- try {
306
- const { intercept } = await this.connector.send("network.addIntercept", {
307
- ...params,
308
- ...(patterns && { urlPatterns: patterns }),
309
- });
310
- return intercept;
311
- } catch (err) {
312
- if (patterns && err instanceof BiDiError && err.code === "invalid argument") {
313
- patternsUnsupported.add(this.connector);
314
- return this.addIntercept(undefined);
315
- }
316
- throw err;
317
- }
318
- }
319
-
320
- private async removeIntercept(id: string): Promise<void> {
321
- try {
322
- await this.connector.send("network.removeIntercept", { intercept: id });
323
- } catch (err) {
324
- if (!(err instanceof BiDiError && err.code === "no such intercept")) throw err;
325
- } finally {
326
- this.interceptIds.delete(id);
327
- }
328
- }
329
-
330
- private async listen(): Promise<void> {
331
- if (this.subscription) return;
332
- this.connector.onEvent("network.beforeRequestSent", this.onBeforeRequestSent);
333
- try {
334
- this.subscription = await this.connector.subscribe(["network.beforeRequestSent"]);
335
- } catch (err) {
336
- this.connector.offEvent("network.beforeRequestSent", this.onBeforeRequestSent);
337
- throw err;
338
- }
339
- }
340
-
341
- private async stop(): Promise<void> {
342
- const id = this.currentId;
343
- this.currentId = undefined;
344
- this.currentKey = undefined;
345
- if (id !== undefined) await this.removeIntercept(id);
346
- if (this.subscription) {
347
- this.connector.offEvent("network.beforeRequestSent", this.onBeforeRequestSent);
348
- const subscription = this.subscription;
349
- this.subscription = undefined;
350
- await subscription.unsubscribe();
351
- }
352
- }
353
-
354
- private onBeforeRequestSent = (params: BeforeRequestSentParameters) => {
355
- if (!params.isBlocked || params.context === null) return;
356
- if (!this.tree.isWithin(params.context, this.contextId)) return;
357
- // Held by an intercept of ours, not someone else's
358
- if (!(params.intercepts ?? []).some((id) => this.interceptIds.has(id))) return;
359
-
360
- const info = toRequest(params.request, params.navigation, params.isBlocked, null);
361
- const matching = this.routes.filter((entry) => matchUrl(entry.pattern, info.url)).reverse();
362
- this.dispatch(info, matching, 0).catch((err) =>
363
- logger.error("Handling the request to %s failed", info.url, { err }),
364
- );
365
- };
366
-
367
- /** Runs the `index`-th matching route; past the last one the request continues unchanged */
368
- private async dispatch(info: NetworkRequest, matching: RouteEntry[], index: number): Promise<void> {
369
- const entry = matching[index];
370
- const deps: RouteDeps = {
371
- send: (method, params) => this.connector.send(method, params),
372
- next: () => this.dispatch(info, matching, index + 1),
373
- settled: (settled) => {
374
- this.pending.delete(settled);
375
- },
376
- };
377
- if (!entry) {
378
- // Nobody (left) to ask: an unmatched request, or every route fell back
379
- await new Route(info, { pattern: "", handler: () => {} }, deps).release();
380
- return;
381
- }
382
-
383
- const route = new Route(info, entry, deps);
384
- this.pending.add(route);
385
- route.timer = setTimeout(() => {
386
- if (route.isAnswered) return;
387
- logger.error("A route for %s did not answer within %dms; letting the request continue", info.url, this.routeTimeout);
388
- void route.release();
389
- }, this.routeTimeout);
390
- route.timer.unref?.();
391
-
392
- try {
393
- await entry.handler(route);
394
- } catch (err) {
395
- this.record(info, err);
396
- await route.failQuietly();
397
- return;
398
- }
399
- if (!route.isAnswered) {
400
- logger.warn("The route for %s did not answer the request; letting it continue", info.url);
401
- await route.release();
402
- }
403
- }
404
-
405
- private record(info: NetworkRequest, err: unknown) {
406
- const message = err instanceof Error ? err.message : String(err);
407
- const error = Object.assign(new Error(`route handler for ${info.url} threw: ${message}`), { cause: err });
408
- this.failures.push(error);
409
- if (this.failures.length > MAX_ERRORS) this.failures.shift();
410
- logger.error("A route handler threw for %s", info.url, { err });
411
- }
412
- }
413
-
414
- function isNormalUrl(value: string): boolean {
415
- try {
416
- return new URL(value).href === value;
417
- } catch {
418
- return false;
419
- }
420
- }
@@ -1,69 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { isExactString, matchUrl, samePattern } from "./url-match.js";
4
-
5
- const url = "https://example.test/api/users?page=2";
6
-
7
- describe("matchUrl", () => {
8
- it("a string without * must equal the URL", () => {
9
- assert.equal(matchUrl(url, url), true);
10
- assert.equal(matchUrl("https://example.test/api/users", url), false);
11
- assert.equal(matchUrl(url, "https://example.test/api/users?page=3"), false);
12
- });
13
-
14
- it("** matches anything, also across slashes", () => {
15
- assert.equal(matchUrl("**/api/users*", url), true);
16
- assert.equal(matchUrl("https://example.test/**", url), true);
17
- assert.equal(matchUrl("**", url), true);
18
- assert.equal(matchUrl("**/nope", url), false);
19
- });
20
-
21
- it("* matches anything but a slash", () => {
22
- assert.equal(matchUrl("https://example.test/api/*", "https://example.test/api/users"), true);
23
- assert.equal(matchUrl("https://example.test/api/*", "https://example.test/api/users/1"), false);
24
- assert.equal(matchUrl("https://*.test/api/users", "https://example.test/api/users"), true);
25
- assert.equal(matchUrl("https://example.test/api/*?page=2", url.replace("users", "x")), true);
26
- });
27
-
28
- it("the rest of a glob is literal, regex characters included", () => {
29
- assert.equal(matchUrl("https://example.test/a.b/*", "https://example.test/a.b/c"), true);
30
- assert.equal(matchUrl("https://example.test/a.b/*", "https://example.test/aXb/c"), false);
31
- assert.equal(matchUrl("**/(x)+[y]?*", "https://e.test/(x)+[y]?z"), true);
32
- });
33
-
34
- it("a glob matches the whole URL, not a part", () => {
35
- assert.equal(matchUrl("*/users", url), false);
36
- assert.equal(matchUrl("**/users", url), false, "the query string is part of the URL");
37
- });
38
-
39
- it("a RegExp is tested against the URL", () => {
40
- assert.equal(matchUrl(/\/api\/users/, url), true);
41
- assert.equal(matchUrl(/^https:\/\/other/, url), false);
42
- assert.equal(matchUrl(/\.(png|jpg)$/, "https://e.test/a.png"), true);
43
- });
44
-
45
- it("a global RegExp gives the same answer every time", () => {
46
- const pattern = /users/g;
47
- assert.equal(matchUrl(pattern, url), true);
48
- assert.equal(matchUrl(pattern, url), true);
49
- assert.equal(matchUrl(pattern, url), true);
50
- });
51
- });
52
-
53
- describe("isExactString", () => {
54
- it("is true for a string without *", () => {
55
- assert.equal(isExactString("https://e.test/x"), true);
56
- assert.equal(isExactString("https://e.test/*"), false);
57
- assert.equal(isExactString(/x/), false);
58
- });
59
- });
60
-
61
- describe("samePattern", () => {
62
- it("compares strings by value and RegExps by source and flags", () => {
63
- assert.equal(samePattern("a", "a"), true);
64
- assert.equal(samePattern("a", "b"), false);
65
- assert.equal(samePattern(/a/i, /a/i), true);
66
- assert.equal(samePattern(/a/i, /a/), false);
67
- assert.equal(samePattern(/a/, "a"), false);
68
- });
69
- });
@@ -1,41 +0,0 @@
1
- /** A URL to match: an exact string, a glob (`*` = anything but `/`, `**` = anything) or a RegExp */
2
- export type UrlPattern = string | RegExp;
3
-
4
- /** True for a string without `*`: it must equal the URL */
5
- export function isExactString(pattern: UrlPattern): pattern is string {
6
- return typeof pattern === "string" && !pattern.includes("*");
7
- }
8
-
9
- function globToRegExp(glob: string): RegExp {
10
- let source = "";
11
- for (let i = 0; i < glob.length; i++) {
12
- const char = glob[i]!;
13
- if (char === "*") {
14
- if (glob[i + 1] === "*") {
15
- source += ".*";
16
- i++;
17
- } else {
18
- source += "[^/]*";
19
- }
20
- } else {
21
- source += char.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
22
- }
23
- }
24
- return new RegExp(`^${source}$`);
25
- }
26
-
27
- /** Whether `url` matches `pattern` */
28
- export function matchUrl(pattern: UrlPattern, url: string): boolean {
29
- if (typeof pattern === "string") {
30
- return isExactString(pattern) ? pattern === url : globToRegExp(pattern).test(url);
31
- }
32
- // A global or sticky RegExp remembers where it stopped; every match must start fresh
33
- pattern.lastIndex = 0;
34
- return pattern.test(url);
35
- }
36
-
37
- /** Same pattern, so `unroute` finds the route a `route` call made */
38
- export function samePattern(a: UrlPattern, b: UrlPattern): boolean {
39
- if (typeof a === "string" || typeof b === "string") return a === b;
40
- return a.source === b.source && a.flags === b.flags;
41
- }
@@ -1,144 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { PageError, type LogRecord } from "../browser/page-logs.js";
4
- import { judgePageLogs, PageErrorsError, RouteErrorsError } from "./page-logs-report.js";
5
-
6
- const entries: LogRecord[] = [
7
- { level: "info", type: "console", method: "log", text: "hello", timestamp: 1 },
8
- { level: "error", type: "javascript", text: "TypeError: x", timestamp: 2 },
9
- ];
10
- const errors = [new PageError("TypeError: x", 2)];
11
- const base = { entries, errors, dropped: 0, testFailed: false, allowPageErrors: false };
12
-
13
- describe("judgePageLogs capture", () => {
14
- it("attaches the logs of a failed test by default", () => {
15
- const verdict = judgePageLogs({ ...base, config: undefined, testFailed: true });
16
- assert.deepEqual(verdict.logs, entries);
17
- assert.equal(verdict.error, undefined);
18
- });
19
-
20
- it("attaches nothing for a passing test by default", () => {
21
- assert.equal(judgePageLogs({ ...base, config: {} }).logs, undefined);
22
- });
23
-
24
- it("capture all attaches always, off never", () => {
25
- assert.deepEqual(judgePageLogs({ ...base, config: { capture: "all" } }).logs, entries);
26
- assert.equal(judgePageLogs({ ...base, config: { capture: "off" }, testFailed: true }).logs, undefined);
27
- });
28
-
29
- it("reports how many entries were dropped, only when some were", () => {
30
- assert.equal(judgePageLogs({ ...base, config: { capture: "all" }, dropped: 7 }).logsDropped, 7);
31
- assert.equal(judgePageLogs({ ...base, config: { capture: "all" } }).logsDropped, undefined);
32
- });
33
-
34
- it("attaches an empty list for a failed test that logged nothing, so the report says so", () => {
35
- assert.deepEqual(judgePageLogs({ ...base, config: {}, testFailed: true, entries: [], errors: [] }).logs, []);
36
- });
37
- });
38
-
39
- describe("judgePageLogs failOnPageError", () => {
40
- it("does not fail a test on page errors unless asked", () => {
41
- assert.equal(judgePageLogs({ ...base, config: {} }).error, undefined);
42
- assert.equal(judgePageLogs({ ...base, config: { failOnPageError: false } }).error, undefined);
43
- });
44
-
45
- it("fails a passing test that had a page error, and attaches the logs", () => {
46
- const verdict = judgePageLogs({ ...base, config: { failOnPageError: true } });
47
- assert.deepEqual(verdict.error, {
48
- message: "1 uncaught page error(s): TypeError: x",
49
- stack: verdict.error!.stack,
50
- type: "error",
51
- });
52
- assert.deepEqual(verdict.logs, entries, "a failed test gets its logs under the default capture");
53
- });
54
-
55
- it("names every error, the first in the headline", () => {
56
- const verdict = judgePageLogs({
57
- ...base,
58
- config: { failOnPageError: true },
59
- errors: [new PageError("Error: a", 1), new PageError("Error: b", 2), new PageError("Error: c", 3)],
60
- });
61
- assert.equal(verdict.error!.message, "3 uncaught page error(s): Error: a\nError: b\nError: c");
62
- });
63
-
64
- it("ignores errors matching a string (substring) or a RegExp", () => {
65
- const config = { failOnPageError: true, ignoreErrors: ["ResizeObserver", /^Script error/] };
66
- const verdict = judgePageLogs({
67
- ...base,
68
- config,
69
- errors: [new PageError("ResizeObserver loop limit exceeded", 1), new PageError("Script error.", 2)],
70
- });
71
- assert.equal(verdict.error, undefined);
72
- const partly = judgePageLogs({
73
- ...base,
74
- config,
75
- errors: [new PageError("ResizeObserver loop limit exceeded", 1), new PageError("Error: real", 2)],
76
- });
77
- assert.equal(partly.error!.message, "1 uncaught page error(s): Error: real");
78
- });
79
-
80
- it("allowPageErrors switches it off for that test", () => {
81
- const verdict = judgePageLogs({ ...base, config: { failOnPageError: true }, allowPageErrors: true });
82
- assert.equal(verdict.error, undefined);
83
- });
84
-
85
- it("a test that already failed keeps its own error, the logs are attached", () => {
86
- const verdict = judgePageLogs({ ...base, config: { failOnPageError: true }, testFailed: true });
87
- assert.equal(verdict.error, undefined);
88
- assert.deepEqual(verdict.logs, entries);
89
- });
90
-
91
- it("does not fail without page errors", () => {
92
- const verdict = judgePageLogs({ ...base, config: { failOnPageError: true }, errors: [] });
93
- assert.equal(verdict.error, undefined);
94
- assert.equal(verdict.logs, undefined);
95
- });
96
-
97
- it("with capture off a page-error failure still fails, without logs", () => {
98
- const verdict = judgePageLogs({ ...base, config: { failOnPageError: true, capture: "off" } });
99
- assert.ok(verdict.error);
100
- assert.equal(verdict.logs, undefined);
101
- });
102
- });
103
-
104
- describe("PageErrorsError", () => {
105
- it("lists the errors", () => {
106
- const error = new PageErrorsError([new PageError("Error: a", 1)]);
107
- assert.equal(error.name, "PageErrorsError");
108
- assert.equal(error.errors.length, 1);
109
- assert.equal(error.message, "1 uncaught page error(s): Error: a");
110
- });
111
- });
112
-
113
- describe("judgePageLogs route handler errors", () => {
114
- const routeErrors = [new Error("route handler for https://e.test/a threw: bug"), new Error("second")];
115
-
116
- it("fails a passing test whose route handler threw, whatever the logs config", () => {
117
- const verdict = judgePageLogs({ ...base, config: undefined, routeErrors, errors: [] });
118
- assert.equal(
119
- verdict.error!.message,
120
- "2 route handler error(s): route handler for https://e.test/a threw: bug\nsecond",
121
- );
122
- assert.deepEqual(verdict.logs, entries, "a failed test gets its logs");
123
- });
124
-
125
- it("does not replace the error of a test that already failed", () => {
126
- const verdict = judgePageLogs({ ...base, config: {}, routeErrors, testFailed: true });
127
- assert.equal(verdict.error, undefined);
128
- });
129
-
130
- it("takes precedence over page errors", () => {
131
- const verdict = judgePageLogs({ ...base, config: { failOnPageError: true }, routeErrors });
132
- assert.match(verdict.error!.message, /route handler error/);
133
- });
134
-
135
- it("no route errors, no failure", () => {
136
- assert.equal(judgePageLogs({ ...base, config: {}, routeErrors: [] }).error, undefined);
137
- });
138
-
139
- it("RouteErrorsError lists the errors", () => {
140
- const error = new RouteErrorsError(routeErrors);
141
- assert.equal(error.name, "RouteErrorsError");
142
- assert.equal(error.errors.length, 2);
143
- });
144
- });