@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
@@ -0,0 +1,113 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { Browser } from "../browser/browser.js";
4
+ import { projectDir } from "../config/config.js";
5
+ import { withProject } from "../runner/project.js";
6
+ import { parseSpec } from "../steps/parse.js";
7
+ import { applyRecorderEvent } from "./apply.js";
8
+ const NEW_SPEC_HEADER = `import { expect, test } from "@itmetsam/samurai-framework";\n\n`;
9
+ function addTest(source, title) {
10
+ const header = source.trim() === ""
11
+ ? NEW_SPEC_HEADER
12
+ : `${source.replace(/\s*$/, "")}\n\n`;
13
+ return `${header}test(${JSON.stringify(title)}, async ({ page }) => {\n});\n`;
14
+ }
15
+ /** The index of the test `selector` names (see `RecordSpecOptions.test`) */
16
+ export function pickTest(tests, selector) {
17
+ if (tests.length === 0)
18
+ throw new Error("The file has no tests; give a title with --new to add one");
19
+ if (selector === undefined) {
20
+ if (tests.length === 1)
21
+ return 0;
22
+ throw new Error(`The file has ${tests.length} tests; choose one with --test:\n${tests.map((t) => ` ${t.name}`).join("\n")}`);
23
+ }
24
+ if (typeof selector === "number") {
25
+ if (!Number.isInteger(selector) ||
26
+ selector < 0 ||
27
+ selector >= tests.length) {
28
+ throw new Error(`There is no test ${selector}; the file has ${tests.length}`);
29
+ }
30
+ return selector;
31
+ }
32
+ const exact = tests.findIndex((t) => t.name === selector);
33
+ if (exact !== -1)
34
+ return exact;
35
+ const partial = tests.flatMap((t, index) => t.name.includes(selector) ? [index] : []);
36
+ if (partial.length === 1)
37
+ return partial[0];
38
+ throw new Error(partial.length === 0
39
+ ? `No test matches "${selector}". Tests:\n${tests.map((t) => ` ${t.name}`).join("\n")}`
40
+ : `"${selector}" matches ${partial.length} tests; use the full name`);
41
+ }
42
+ /**
43
+ * Opens a browser window and records what the person does into a spec file, step by step, until `signal`
44
+ * aborts or the window is closed. Every step is written to the file as it happens, as a minimal edit.
45
+ * @example
46
+ * await recordSpec({ file: "tests/login.spec.ts", create: "signs in", environment: "staging" });
47
+ */
48
+ export async function recordSpec(options) {
49
+ return withProject(options, async ({ settings }) => {
50
+ const file = path.resolve(projectDir(), options.file);
51
+ let source = existsSync(file) ? readFileSync(file, "utf8") : undefined;
52
+ if (source === undefined && options.create === undefined) {
53
+ throw new Error(`${options.file} does not exist; give a title with --new to create it`);
54
+ }
55
+ source ??= "";
56
+ if (options.create !== undefined &&
57
+ !parseSpec(source).some((t) => t.name === options.create)) {
58
+ source = addTest(source, options.create);
59
+ mkdirSync(path.dirname(file), { recursive: true });
60
+ writeFileSync(file, source);
61
+ }
62
+ const tests = parseSpec(source);
63
+ const testIndex = pickTest(tests, options.create ?? options.test);
64
+ const test = tests[testIndex];
65
+ const at = options.at ?? test.steps.length;
66
+ if (!Number.isInteger(at) || at < 0 || at > test.steps.length) {
67
+ throw new RangeError(`--at must be between 0 and ${test.steps.length} (the test has ${test.steps.length} steps)`);
68
+ }
69
+ const { browser, page } = await Browser.launch("firefox", { port: options.port ?? 9223, headless: false }, options.signal);
70
+ const events = [];
71
+ try {
72
+ const start = options.url ?? settings.baseURL;
73
+ if (options.url !== undefined)
74
+ await page.goto(options.url);
75
+ else if (settings.baseURL !== undefined)
76
+ await page.navigateTo(settings.baseURL);
77
+ const recorder = await page.record({
78
+ at,
79
+ initialGoto: at === 0 && start !== undefined,
80
+ ...(settings.baseURL !== undefined && { baseURL: settings.baseURL }),
81
+ onEvent: (event) => {
82
+ source = applyRecorderEvent(source, testIndex, event);
83
+ writeFileSync(file, source);
84
+ events.push(event);
85
+ options.onEvent?.(event, source);
86
+ },
87
+ });
88
+ await options.onStarted?.({ page, browser });
89
+ let windowClosed = false;
90
+ void browser.exited.then(() => (windowClosed = true));
91
+ await new Promise((resolve) => {
92
+ if (options.signal?.aborted)
93
+ return resolve();
94
+ options.signal?.addEventListener("abort", () => resolve(), {
95
+ once: true,
96
+ });
97
+ void browser.exited.then(resolve);
98
+ });
99
+ // With the window gone there is nothing to ask: what was reported before is in the file
100
+ if (!windowClosed)
101
+ await recorder.stop().catch(() => undefined);
102
+ }
103
+ finally {
104
+ await browser.close().catch(() => undefined);
105
+ }
106
+ const secrets = [
107
+ ...new Set(events.flatMap(({ step }) => step.kind === "fill" && step.value.kind === "secret"
108
+ ? [step.value.name]
109
+ : [])),
110
+ ];
111
+ return { file, test: test.name, events, secrets };
112
+ });
113
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Specs must load as ES modules. A spec in a project whose package.json lacks `"type": "module"` is loaded as
3
+ * CommonJS, which gives it second copies of the framework's modules: `expect(locator)` then no longer
4
+ * recognises a locator, and its tests land in a registry the runner never reads. Fail early instead.
5
+ */
6
+ export declare function assertModuleProject(specFile: string): void;
@@ -0,0 +1,29 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Specs must load as ES modules. A spec in a project whose package.json lacks `"type": "module"` is loaded as
5
+ * CommonJS, which gives it second copies of the framework's modules: `expect(locator)` then no longer
6
+ * recognises a locator, and its tests land in a registry the runner never reads. Fail early instead.
7
+ */
8
+ export function assertModuleProject(specFile) {
9
+ if (specFile.endsWith(".mts"))
10
+ return;
11
+ for (let dir = path.dirname(specFile);; dir = path.dirname(dir)) {
12
+ const manifest = path.join(dir, "package.json");
13
+ if (existsSync(manifest)) {
14
+ let type;
15
+ try {
16
+ type = JSON.parse(readFileSync(manifest, "utf8")).type;
17
+ }
18
+ catch {
19
+ throw new Error(`Cannot read ${manifest}`);
20
+ }
21
+ if (type === "module")
22
+ return;
23
+ throw new Error(`${manifest} must set "type": "module": specs are ES modules (needed to run ${path.basename(specFile)})`);
24
+ }
25
+ if (path.dirname(dir) === dir)
26
+ break;
27
+ }
28
+ throw new Error(`No package.json with "type": "module" found above ${specFile}: specs are ES modules`);
29
+ }
@@ -0,0 +1,40 @@
1
+ import type { LogRecord, PageError } from "../browser/page-logs.js";
2
+ import type { SamuraiTestConfig } from "../types/config.js";
3
+ import type { TestError } from "../types/test.js";
4
+ /** Config `logs` */
5
+ export type LogsConfig = NonNullable<SamuraiTestConfig["logs"]>;
6
+ /** The page threw exceptions nobody caught, and config `logs.failOnPageError` says that fails the test */
7
+ export declare class PageErrorsError extends Error {
8
+ readonly errors: readonly PageError[];
9
+ constructor(errors: readonly PageError[]);
10
+ }
11
+ /** A route handler threw: the test's mocks did not do what the test meant */
12
+ export declare class RouteErrorsError extends Error {
13
+ readonly errors: readonly Error[];
14
+ constructor(errors: readonly Error[]);
15
+ }
16
+ export interface PageLogsInput {
17
+ config: LogsConfig | undefined;
18
+ entries: readonly LogRecord[];
19
+ errors: readonly PageError[];
20
+ /** How many entries the page's buffer dropped */
21
+ dropped: number;
22
+ /** The test body already failed (or timed out) */
23
+ testFailed: boolean;
24
+ /** The test called `page.allowPageErrors()` */
25
+ allowPageErrors: boolean;
26
+ /** Errors thrown by `page.route` handlers; they fail a passing test */
27
+ routeErrors?: readonly Error[];
28
+ }
29
+ export interface PageLogsVerdict {
30
+ /** Set when a test that passed has to fail because of page errors */
31
+ error?: TestError;
32
+ /** The entries to put in the report, per `logs.capture` */
33
+ logs?: LogRecord[];
34
+ logsDropped?: number;
35
+ }
36
+ /**
37
+ * Decides, once a test body is done, whether page errors fail it and which log entries go in the report:
38
+ * `capture` "failures" (default) attaches them to failed tests, "all" always, "off" never.
39
+ */
40
+ export declare function judgePageLogs(input: PageLogsInput): PageLogsVerdict;
@@ -0,0 +1,44 @@
1
+ import { toTestError } from "./test-error.js";
2
+ /** The page threw exceptions nobody caught, and config `logs.failOnPageError` says that fails the test */
3
+ export class PageErrorsError extends Error {
4
+ constructor(errors) {
5
+ super(`${errors.length} uncaught page error(s): ${errors.map((error) => error.message).join("\n")}`);
6
+ this.name = "PageErrorsError";
7
+ this.errors = errors;
8
+ }
9
+ }
10
+ /** A route handler threw: the test's mocks did not do what the test meant */
11
+ export class RouteErrorsError extends Error {
12
+ constructor(errors) {
13
+ super(`${errors.length} route handler error(s): ${errors.map((error) => error.message).join("\n")}`);
14
+ this.name = "RouteErrorsError";
15
+ this.errors = errors;
16
+ }
17
+ }
18
+ function isIgnored(error, ignore) {
19
+ return ignore.some((pattern) => typeof pattern === "string" ? error.message.includes(pattern) : pattern.test(error.message));
20
+ }
21
+ /**
22
+ * Decides, once a test body is done, whether page errors fail it and which log entries go in the report:
23
+ * `capture` "failures" (default) attaches them to failed tests, "all" always, "off" never.
24
+ */
25
+ export function judgePageLogs(input) {
26
+ const { config, entries, errors, dropped, testFailed, allowPageErrors, routeErrors = [] } = input;
27
+ const capture = config?.capture ?? "failures";
28
+ let error;
29
+ if (routeErrors.length > 0 && !testFailed) {
30
+ error = toTestError(new RouteErrorsError(routeErrors));
31
+ }
32
+ else if (config?.failOnPageError && !testFailed && !allowPageErrors) {
33
+ const counted = errors.filter((pageError) => !isIgnored(pageError, config.ignoreErrors ?? []));
34
+ if (counted.length > 0)
35
+ error = toTestError(new PageErrorsError(counted));
36
+ }
37
+ const failed = testFailed || error !== undefined;
38
+ const attach = capture === "all" || (capture === "failures" && failed);
39
+ return {
40
+ ...(error && { error }),
41
+ ...(attach && { logs: [...entries] }),
42
+ ...(attach && dropped > 0 && { logsDropped: dropped }),
43
+ };
44
+ }
@@ -0,0 +1,14 @@
1
+ import { type RunOverrides, type RunSettings } from "../config/run-settings.js";
2
+ import type { SamuraiTestConfig } from "../types/config.js";
3
+ export interface PreparedRun {
4
+ settings: RunSettings;
5
+ secrets: ReadonlyMap<string, string>;
6
+ }
7
+ /**
8
+ * Starts a run: picks the environment, loads its secrets and registers every value for masking
9
+ * (all of them, not only the ones a test reads), then makes the settings active.
10
+ */
11
+ export declare function prepareRun(config: SamuraiTestConfig, overrides: RunOverrides, options?: {
12
+ cwd?: string;
13
+ env?: NodeJS.ProcessEnv;
14
+ }): PreparedRun;
@@ -0,0 +1,20 @@
1
+ import { isShortSecret, registerSecret } from "../config/mask.js";
2
+ import { resolveRunSettings, setRunSettings, } from "../config/run-settings.js";
3
+ import { loadSecrets } from "../config/secrets.js";
4
+ import logger from "../logger/index.js";
5
+ /**
6
+ * Starts a run: picks the environment, loads its secrets and registers every value for masking
7
+ * (all of them, not only the ones a test reads), then makes the settings active.
8
+ */
9
+ export function prepareRun(config, overrides, options = {}) {
10
+ const settings = resolveRunSettings(config, overrides);
11
+ const secrets = loadSecrets(settings.environment, options);
12
+ for (const [name, value] of secrets) {
13
+ registerSecret(name, value);
14
+ if (isShortSecret(value)) {
15
+ logger.warn(`Secret "${name}" is short; masking it may hide unrelated text in logs`);
16
+ }
17
+ }
18
+ setRunSettings(settings);
19
+ return { settings, secrets };
20
+ }
@@ -0,0 +1,17 @@
1
+ import { type RunOverrides } from "../config/run-settings.js";
2
+ import type { SamuraiTestConfig } from "../types/config.js";
3
+ import { type PreparedRun } from "./prepare-run.js";
4
+ /** Which project an operation works on, and in which environment */
5
+ export interface ProjectOptions extends RunOverrides {
6
+ /** The project folder: `samurai.config.ts`, `.env.<environment>`, specs and `result/`. @default the working directory */
7
+ projectDir?: string;
8
+ /** Use this config instead of reading `samurai.config.ts` */
9
+ config?: SamuraiTestConfig;
10
+ /** Where the framework keeps browser profiles. @default `SAMURAI_DATA_DIR`, else `browsers/` in the project folder */
11
+ dataDir?: string;
12
+ }
13
+ /**
14
+ * Runs `work` with the project's config, folders, environment and secrets in place, and cleans up afterwards.
15
+ * One at a time per process: these are process-wide, so a second call throws.
16
+ */
17
+ export declare function withProject<T>(options: ProjectOptions, work: (run: PreparedRun) => Promise<T>): Promise<T>;
@@ -0,0 +1,33 @@
1
+ import { loadConfig, projectDir, setActiveProject } from "../config/config.js";
2
+ import { resetSecrets } from "../config/mask.js";
3
+ import { setRunSettings } from "../config/run-settings.js";
4
+ import { prepareRun } from "./prepare-run.js";
5
+ let active = false;
6
+ /**
7
+ * Runs `work` with the project's config, folders, environment and secrets in place, and cleans up afterwards.
8
+ * One at a time per process: these are process-wide, so a second call throws.
9
+ */
10
+ export async function withProject(options, work) {
11
+ if (active)
12
+ throw new Error("A test run is already active in this process");
13
+ active = true;
14
+ const { projectDir: dir, config, dataDir, environment, timeout, expectTimeout, } = options;
15
+ const previousDir = projectDir();
16
+ try {
17
+ setActiveProject({ dir: dir ?? previousDir, config, dataDir });
18
+ const loaded = config ?? (await loadConfig());
19
+ setActiveProject({ dir: dir ?? previousDir, config: loaded, dataDir });
20
+ resetSecrets();
21
+ const run = prepareRun(loaded, {
22
+ ...(environment !== undefined && { environment }),
23
+ ...(timeout !== undefined && { timeout }),
24
+ ...(expectTimeout !== undefined && { expectTimeout }),
25
+ });
26
+ return await work(run);
27
+ }
28
+ finally {
29
+ setRunSettings(undefined);
30
+ setActiveProject(undefined);
31
+ active = false;
32
+ }
33
+ }
@@ -0,0 +1,7 @@
1
+ import type { RegisteredTestCase, TestCaseBase } from "../types/test.js";
2
+ /** Groups the tests registered inside `fn` under `title`. `fn` runs immediately and must be synchronous. */
3
+ export declare function describe(title: string, fn: () => void): void;
4
+ export declare function test(title: string, fn: TestCaseBase["function"]): void;
5
+ export declare function registeredTests(): readonly RegisteredTestCase[];
6
+ /** Forgets every registered test (tests only) */
7
+ export declare function clearRegistry(): void;
@@ -0,0 +1,40 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import logger from "../logger/index.js";
3
+ const registered = [];
4
+ /** Titles of the describe blocks being registered, outermost first */
5
+ const titleStack = [];
6
+ /** Groups the tests registered inside `fn` under `title`. `fn` runs immediately and must be synchronous. */
7
+ export function describe(title, fn) {
8
+ titleStack.push(title);
9
+ try {
10
+ const result = fn();
11
+ if (result instanceof Promise) {
12
+ result.catch(() => { });
13
+ throw new Error("describe() callback must be synchronous; it only registers tests");
14
+ }
15
+ }
16
+ finally {
17
+ titleStack.pop();
18
+ }
19
+ }
20
+ export function test(title, fn) {
21
+ const titlePath = [...titleStack, title];
22
+ const name = titlePath.join(" > ");
23
+ registered.push({ name, titlePath, function: fn, file: callerFile() });
24
+ logger.debug("Registered test %s", name);
25
+ }
26
+ export function registeredTests() {
27
+ return registered;
28
+ }
29
+ /** Forgets every registered test (tests only) */
30
+ export function clearRegistry() {
31
+ registered.length = 0;
32
+ titleStack.length = 0;
33
+ }
34
+ /** File of the code that called test(): stack lines are Error, callerFile, test, caller */
35
+ function callerFile() {
36
+ const caller = new Error().stack?.split("\n")[3] ?? "";
37
+ const match = caller.match(/\((.+?):\d+:\d+\)$/) ?? caller.match(/at (.+?):\d+:\d+$/);
38
+ const location = match?.[1] ?? "";
39
+ return location.startsWith("file://") ? fileURLToPath(location) : location;
40
+ }
@@ -0,0 +1,43 @@
1
+ import type { RegisteredTestCase, TestError, TestLogEntry, TestResult, TestSummary } from "../types/test.js";
2
+ /** What a run reports while it happens */
3
+ export type RunEvent = {
4
+ type: "run-start";
5
+ environment: string;
6
+ total: number;
7
+ } | {
8
+ type: "test-start";
9
+ name: string;
10
+ file: string;
11
+ } | {
12
+ type: "test-end";
13
+ name: string;
14
+ file: string;
15
+ result: TestResult;
16
+ } | {
17
+ type: "run-end";
18
+ summary: TestSummary;
19
+ };
20
+ export interface ReporterOptions {
21
+ /** The environment the run uses, recorded in the report */
22
+ environment: string;
23
+ /** Where the report is written. @default <cwd>/result/report.json */
24
+ output?: string | false;
25
+ /** Called with every event, in order. A throwing listener is ignored */
26
+ onEvent?: (event: RunEvent) => void;
27
+ }
28
+ export default class TestReporter {
29
+ private testResults;
30
+ private summary;
31
+ private environment;
32
+ private output;
33
+ private listener;
34
+ constructor(options: ReporterOptions);
35
+ private emit;
36
+ onStart(total?: number): void;
37
+ onEnd(): Promise<TestSummary>;
38
+ onTestStart(test: RegisteredTestCase): void;
39
+ onTestEnd(test: RegisteredTestCase, error?: TestError, logs?: {
40
+ logs?: TestLogEntry[];
41
+ logsDropped?: number;
42
+ }): void;
43
+ }
@@ -0,0 +1,83 @@
1
+ import { mkdir, writeFile } from "fs/promises";
2
+ import path from "path";
3
+ import { projectDir } from "../config/config.js";
4
+ import { maskDeep } from "../config/mask.js";
5
+ /** Results are keyed by file and full title, so equal titles in different files or describe blocks stay apart */
6
+ function keyOf(test) {
7
+ return `${test.file}::${test.name}`;
8
+ }
9
+ export default class TestReporter {
10
+ constructor(options) {
11
+ this.testResults = new Map();
12
+ this.environment = options.environment;
13
+ this.output =
14
+ options.output ?? path.join(projectDir(), "result/report.json");
15
+ this.listener = options.onEvent;
16
+ }
17
+ emit(event) {
18
+ try {
19
+ this.listener?.(event);
20
+ }
21
+ catch {
22
+ // A listener must not break the run
23
+ }
24
+ }
25
+ onStart(total = 0) {
26
+ const t = performance.mark(`group-start`);
27
+ this.summary = {
28
+ duration: 0,
29
+ status: "failed",
30
+ startTime: t.startTime,
31
+ environment: this.environment,
32
+ tests: [],
33
+ };
34
+ this.emit({ type: "run-start", environment: this.environment, total });
35
+ }
36
+ async onEnd() {
37
+ performance.mark(`group-finish`);
38
+ const duration = performance.measure("test-duration", `group-start`, `group-finish`).duration;
39
+ this.summary = {
40
+ ...this.summary,
41
+ duration,
42
+ environment: this.environment,
43
+ tests: Array.from(this.testResults.values()),
44
+ status: Array.from(this.testResults.values()).every((r) => r.status === "success")
45
+ ? "success"
46
+ : "failed",
47
+ };
48
+ if (this.output !== false) {
49
+ await mkdir(path.dirname(this.output), { recursive: true });
50
+ await writeFile(this.output, JSON.stringify(this.summary, null, 2), {
51
+ encoding: "utf8",
52
+ });
53
+ }
54
+ this.emit({ type: "run-end", summary: this.summary });
55
+ return this.summary;
56
+ }
57
+ onTestStart(test) {
58
+ const key = keyOf(test);
59
+ const t = performance.mark(`${key}-start`);
60
+ this.testResults.set(key, {
61
+ name: test.name,
62
+ file: test.file,
63
+ status: "started",
64
+ startTime: t.startTime,
65
+ });
66
+ this.emit({ type: "test-start", name: test.name, file: test.file });
67
+ }
68
+ onTestEnd(test, error, logs) {
69
+ const key = keyOf(test);
70
+ performance.mark(`${key}-finish`);
71
+ const duration = performance.measure("test-duration", `${key}-start`, `${key}-finish`).duration;
72
+ const result = maskDeep({
73
+ ...this.testResults.get(key),
74
+ duration,
75
+ status: error ? "failed" : "success",
76
+ ...(error ? error : undefined),
77
+ ...(logs?.logs && { logs: logs.logs }),
78
+ ...(logs?.logsDropped && { logsDropped: logs.logsDropped }),
79
+ });
80
+ this.testResults.set(key, result);
81
+ this.emit({ type: "test-end", name: test.name, file: test.file, result });
82
+ }
83
+ }
@@ -0,0 +1,27 @@
1
+ import type { TestSummary } from "../types/test.js";
2
+ import { type ProjectOptions } from "./project.js";
3
+ import { type RunnerOptions } from "./test-runner.js";
4
+ export interface RunTestsOptions extends RunnerOptions, ProjectOptions {
5
+ }
6
+ /**
7
+ * Runs the project's tests, one after the other, and resolves with the summary (also written to the report).
8
+ * Spec files are TypeScript: the process must be able to import them (run under `tsx`, or with Node's own
9
+ * type stripping). One run at a time per process: config, secrets and the test registry are shared state;
10
+ * use a child process per run for more.
11
+ * @example
12
+ * const summary = await runTests({ projectDir: "/work/shop", environment: "staging", headless: true,
13
+ * onEvent: (event) => console.log(event.type) });
14
+ */
15
+ export declare function runTests(options?: RunTestsOptions): Promise<TestSummary>;
16
+ /** A test found in a spec file */
17
+ export interface FoundTest {
18
+ /** Full name: `describe` titles and the test title joined with " > " */
19
+ name: string;
20
+ file: string;
21
+ }
22
+ /** Imports the project's specs without running anything and lists the tests the options select */
23
+ export declare function listTests(options?: RunTestsOptions): Promise<FoundTest[]>;
24
+ export type { RunEvent } from "./reporter.js";
25
+ export type { TestError, TestResult, TestSummary } from "../types/test.js";
26
+ export type { RunnerOptions } from "./test-runner.js";
27
+ export type { ProjectOptions } from "./project.js";
@@ -0,0 +1,34 @@
1
+ import { withProject } from "./project.js";
2
+ import TestRunner, {} from "./test-runner.js";
3
+ /**
4
+ * Runs the project's tests, one after the other, and resolves with the summary (also written to the report).
5
+ * Spec files are TypeScript: the process must be able to import them (run under `tsx`, or with Node's own
6
+ * type stripping). One run at a time per process: config, secrets and the test registry are shared state;
7
+ * use a child process per run for more.
8
+ * @example
9
+ * const summary = await runTests({ projectDir: "/work/shop", environment: "staging", headless: true,
10
+ * onEvent: (event) => console.log(event.type) });
11
+ */
12
+ export async function runTests(options = {}) {
13
+ const { projectDir, config, dataDir, environment, timeout, expectTimeout, ...runner } = options;
14
+ return withProject({
15
+ projectDir,
16
+ config,
17
+ dataDir,
18
+ environment,
19
+ timeout,
20
+ expectTimeout,
21
+ }, async (run) => (await TestRunner.init(run, undefined, runner)).start());
22
+ }
23
+ /** Imports the project's specs without running anything and lists the tests the options select */
24
+ export async function listTests(options = {}) {
25
+ const { projectDir, config, dataDir, environment, timeout, expectTimeout, ...runner } = options;
26
+ return withProject({
27
+ projectDir,
28
+ config,
29
+ dataDir,
30
+ environment,
31
+ timeout,
32
+ expectTimeout,
33
+ }, async (run) => (await (await TestRunner.init(run, undefined, runner)).register()).map(({ name, file }) => ({ name, file })));
34
+ }
@@ -0,0 +1,3 @@
1
+ import type { TestError } from "../types/test.js";
2
+ /** Maps anything a test threw to the error stored in the report */
3
+ export declare function toTestError(err: unknown): TestError;
@@ -0,0 +1,18 @@
1
+ import { AssertionError, toReportValue } from "../assert/assertion-error.js";
2
+ /** Maps anything a test threw to the error stored in the report */
3
+ export function toTestError(err) {
4
+ if (err instanceof AssertionError) {
5
+ return {
6
+ message: err.message,
7
+ stack: err.stack,
8
+ type: "assertion",
9
+ expected: toReportValue(err.expected),
10
+ actual: toReportValue(err.actual),
11
+ };
12
+ }
13
+ return {
14
+ message: err instanceof Error ? err.message : String(err),
15
+ stack: err instanceof Error ? err.stack : undefined,
16
+ type: "error",
17
+ };
18
+ }
@@ -0,0 +1,44 @@
1
+ import type { RegisteredTestCase } from "../types/test.js";
2
+ import type { PreparedRun } from "./prepare-run.js";
3
+ import { type RunEvent } from "./reporter.js";
4
+ import type { SamuraiGroup } from "../types/config.js";
5
+ /** What decides which tests of the found files run, and how the browser starts */
6
+ export interface RunnerOptions {
7
+ /** Spec files to run, absolute or relative to the project folder. Default: every `*.spec.ts` under `srcDir` */
8
+ files?: string[];
9
+ /** Only tests whose full name matches (a string must be contained in it) */
10
+ grep?: string | RegExp;
11
+ /** Only tests with exactly one of these full names (`describe` titles joined with " > ") */
12
+ testNames?: string[];
13
+ /** Run the browser without a window. @default false */
14
+ headless?: boolean;
15
+ /** Remote debugging port of the browser. @default 9223 */
16
+ port?: number;
17
+ /** Where the report is written; `false` writes none. @default `<project>/result/report.json` */
18
+ reportPath?: string | false;
19
+ /** Receives start and end of the run and of every test, as they happen */
20
+ onEvent?: (event: RunEvent) => void;
21
+ /** Aborting stops the running test (its browser is closed), skips the rest and reports the run as failed */
22
+ signal?: AbortSignal;
23
+ }
24
+ /** Whether a test with this full name is selected by the options */
25
+ export declare function isSelected(name: string, { grep, testNames }: Pick<RunnerOptions, "grep" | "testNames">): boolean;
26
+ export default class TestRunner {
27
+ private testFiles;
28
+ private run;
29
+ private group?;
30
+ private options;
31
+ constructor(testFiles: string[], run: PreparedRun, group?: SamuraiGroup, options?: RunnerOptions);
32
+ static init(run: PreparedRun, group?: SamuraiGroup, options?: RunnerOptions): Promise<TestRunner>;
33
+ /** Runs the selected tests and returns the summary that was also written to the report */
34
+ /**
35
+ * Imports the spec files and returns the tests the options select. A fresh registry and a fresh import of each
36
+ * file, so a second run in this process registers its tests again (and sees edited specs, for files loaded as
37
+ * ES modules; CommonJS files stay cached until the process ends)
38
+ */
39
+ register(): Promise<RegisteredTestCase[]>;
40
+ /** Runs the selected tests and returns the summary that was also written to the report */
41
+ start(): Promise<import("../types/test.js").TestSummary>;
42
+ private executeTestCase;
43
+ }
44
+ export { describe, test } from "./registry.js";