@asrunjs/asrun 0.0.0-stage → 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 (286) hide show
  1. package/.claude/commands/explore-case.md +15 -0
  2. package/.claude/commands/gen-spec.md +13 -0
  3. package/.claude/commands/heal-spec.md +23 -0
  4. package/.claude/commands/make-case.md +8 -0
  5. package/.claude/settings.json +5 -0
  6. package/.claude/skills/exploring-cases/SKILL.md +132 -0
  7. package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -0
  8. package/.claude/skills/exploring-cases/references/handoff-card.js +128 -0
  9. package/.claude/skills/exploring-cases/references/open-questions.md +112 -0
  10. package/.claude/skills/exploring-cases/references/readiness.md +137 -0
  11. package/.claude/skills/exploring-cases/references/user-handoff.md +147 -0
  12. package/.claude/skills/generating-specs/SKILL.md +207 -0
  13. package/.claude/skills/generating-specs/references/conventions.md +148 -0
  14. package/.claude/skills/making-cases/SKILL.md +64 -0
  15. package/.claude/skills/making-cases/references/gaps.md +36 -0
  16. package/.claude/skills/making-cases/references/normalizing.md +38 -0
  17. package/.claude/skills/making-cases/references/splitting.md +28 -0
  18. package/.claude/skills/making-cases/references/writes-and-email.md +36 -0
  19. package/AGENTS.md +154 -0
  20. package/CLAUDE.md +1 -0
  21. package/LICENSE +21 -0
  22. package/README.md +410 -3
  23. package/bin/asrun.mjs +49 -0
  24. package/dist/lib/account-lock.d.ts +6 -0
  25. package/dist/lib/account-lock.js +52 -0
  26. package/dist/lib/assertions.d.ts +14 -0
  27. package/dist/lib/assertions.js +39 -0
  28. package/dist/lib/baselines.d.ts +43 -0
  29. package/dist/lib/baselines.js +108 -0
  30. package/dist/lib/case-id.d.ts +11 -0
  31. package/dist/lib/case-id.js +17 -0
  32. package/dist/lib/case-ops.d.ts +26 -0
  33. package/dist/lib/case-ops.js +110 -0
  34. package/dist/lib/cases.d.ts +29 -0
  35. package/dist/lib/cases.js +124 -0
  36. package/dist/lib/cli.d.ts +10 -0
  37. package/dist/lib/cli.js +18 -0
  38. package/dist/lib/downloads.d.ts +42 -0
  39. package/dist/lib/downloads.js +84 -0
  40. package/dist/lib/email.d.ts +10 -0
  41. package/dist/lib/email.js +19 -0
  42. package/dist/lib/ffmpeg.d.ts +8 -0
  43. package/dist/lib/ffmpeg.js +50 -0
  44. package/dist/lib/fixtures.d.ts +40 -0
  45. package/dist/lib/fixtures.js +149 -0
  46. package/dist/lib/node-cli.d.ts +11 -0
  47. package/dist/lib/node-cli.js +26 -0
  48. package/dist/lib/paths.d.ts +25 -0
  49. package/dist/lib/paths.js +88 -0
  50. package/dist/lib/pdf.d.ts +71 -0
  51. package/dist/lib/pdf.js +140 -0
  52. package/dist/lib/profiles.d.ts +38 -0
  53. package/dist/lib/profiles.js +121 -0
  54. package/dist/lib/ready.d.ts +36 -0
  55. package/dist/lib/ready.js +53 -0
  56. package/dist/lib/records.d.ts +33 -0
  57. package/dist/lib/records.js +61 -0
  58. package/dist/lib/run-filters.d.ts +19 -0
  59. package/dist/lib/run-filters.js +30 -0
  60. package/dist/lib/run-summary.d.ts +90 -0
  61. package/dist/lib/run-summary.js +35 -0
  62. package/dist/lib/session.d.ts +18 -0
  63. package/dist/lib/session.js +45 -0
  64. package/dist/lib/summary-reporter.d.ts +16 -0
  65. package/dist/lib/summary-reporter.js +146 -0
  66. package/dist/lib/targets.d.ts +27 -0
  67. package/dist/lib/targets.js +51 -0
  68. package/dist/lib/ui-build.d.ts +10 -0
  69. package/dist/lib/ui-build.js +30 -0
  70. package/dist/lib/values.d.ts +29 -0
  71. package/dist/lib/values.js +85 -0
  72. package/dist/lib/write-gate.d.ts +41 -0
  73. package/dist/lib/write-gate.js +129 -0
  74. package/dist/playwright.config.d.ts +2 -0
  75. package/dist/playwright.config.js +92 -0
  76. package/dist/scripts/build.d.ts +1 -0
  77. package/dist/scripts/build.js +20 -0
  78. package/dist/scripts/check-cases.d.ts +1 -0
  79. package/dist/scripts/check-cases.js +81 -0
  80. package/dist/scripts/cleanup.d.ts +1 -0
  81. package/dist/scripts/cleanup.js +25 -0
  82. package/dist/scripts/cli.d.ts +1 -0
  83. package/dist/scripts/cli.js +28 -0
  84. package/dist/scripts/doctor.d.ts +1 -0
  85. package/dist/scripts/doctor.js +185 -0
  86. package/dist/scripts/explore.d.ts +1 -0
  87. package/dist/scripts/explore.js +54 -0
  88. package/dist/scripts/go.mjs +188 -0
  89. package/dist/scripts/heal-evidence.mjs +143 -0
  90. package/dist/scripts/init.d.ts +16 -0
  91. package/dist/scripts/init.js +284 -0
  92. package/dist/scripts/lint-specs.d.ts +1 -0
  93. package/dist/scripts/lint-specs.js +138 -0
  94. package/dist/scripts/login.d.ts +1 -0
  95. package/dist/scripts/login.js +47 -0
  96. package/dist/scripts/new-case.d.ts +1 -0
  97. package/dist/scripts/new-case.js +19 -0
  98. package/dist/scripts/render-pdf.d.ts +1 -0
  99. package/dist/scripts/render-pdf.js +44 -0
  100. package/dist/scripts/report.d.ts +1 -0
  101. package/dist/scripts/report.js +14 -0
  102. package/dist/scripts/review-baselines.d.ts +1 -0
  103. package/dist/scripts/review-baselines.js +30 -0
  104. package/dist/scripts/run.d.ts +1 -0
  105. package/dist/scripts/run.js +423 -0
  106. package/dist/scripts/status.d.ts +1 -0
  107. package/dist/scripts/status.js +31 -0
  108. package/dist/scripts/ui-build.d.ts +1 -0
  109. package/dist/scripts/ui-build.js +7 -0
  110. package/dist/scripts/ui-dev.d.ts +1 -0
  111. package/dist/scripts/ui-dev.js +3 -0
  112. package/dist/scripts/ui.d.ts +1 -0
  113. package/dist/scripts/ui.js +13 -0
  114. package/dist/ui/dev.d.ts +1 -0
  115. package/dist/ui/dev.js +36 -0
  116. package/dist/ui/server/actions.d.ts +91 -0
  117. package/dist/ui/server/actions.js +352 -0
  118. package/dist/ui/server/ai-stream.d.ts +41 -0
  119. package/dist/ui/server/ai-stream.js +202 -0
  120. package/dist/ui/server/app.d.ts +4 -0
  121. package/dist/ui/server/app.js +333 -0
  122. package/dist/ui/server/cases.d.ts +23 -0
  123. package/dist/ui/server/cases.js +170 -0
  124. package/dist/ui/server/files.d.ts +5 -0
  125. package/dist/ui/server/files.js +67 -0
  126. package/dist/ui/server/index.d.ts +1 -0
  127. package/dist/ui/server/index.js +33 -0
  128. package/dist/ui/server/jobs.d.ts +74 -0
  129. package/dist/ui/server/jobs.js +263 -0
  130. package/dist/ui/server/profiles.d.ts +16 -0
  131. package/dist/ui/server/profiles.js +195 -0
  132. package/dist/ui/server/watch-files.d.ts +4 -0
  133. package/dist/ui/server/watch-files.js +36 -0
  134. package/dist/ui/shared/ai-commands.d.ts +78 -0
  135. package/dist/ui/shared/ai-commands.js +154 -0
  136. package/dist/ui/shared/case-id.d.ts +1 -0
  137. package/dist/ui/shared/case-id.js +1 -0
  138. package/dist/ui/shared/jobs.d.ts +6 -0
  139. package/dist/ui/shared/jobs.js +7 -0
  140. package/dist/ui/shared/product.d.ts +3 -0
  141. package/dist/ui/shared/product.js +3 -0
  142. package/dist/ui/shared/run-filters.d.ts +1 -0
  143. package/dist/ui/shared/run-filters.js +1 -0
  144. package/dist/ui/shared/site-address.d.ts +6 -0
  145. package/dist/ui/shared/site-address.js +11 -0
  146. package/dist/ui/shared/types.d.ts +257 -0
  147. package/dist/ui/shared/types.js +1 -0
  148. package/docs/theme-guide.html +692 -0
  149. package/package.json +98 -3
  150. package/templates/.claude/commands/explore-case.md +21 -0
  151. package/templates/.claude/commands/gen-spec.md +20 -0
  152. package/templates/.claude/commands/heal-spec.md +30 -0
  153. package/templates/.claude/commands/make-case.md +12 -0
  154. package/templates/.claude/settings.json +5 -0
  155. package/templates/.mcp.json +8 -0
  156. package/templates/AGENTS.md +45 -0
  157. package/templates/CLAUDE.md +1 -0
  158. package/templates/cases/_template.md +63 -0
  159. package/templates/gitignore +13 -0
  160. package/templates/opencode.jsonc +11 -0
  161. package/templates/package.json +31 -0
  162. package/templates/playwright.config.ts +3 -0
  163. package/templates/pnpm-workspace.yaml +5 -0
  164. package/templates/profiles.yaml +29 -0
  165. package/templates/seed.spec.ts +9 -0
  166. package/templates/tsconfig.json +14 -0
  167. package/ui/dist/apple-touch-icon.png +0 -0
  168. package/ui/dist/assets/apl-gfMMiTNf.js +1 -0
  169. package/ui/dist/assets/asciiarmor-C_eisQCp.js +1 -0
  170. package/ui/dist/assets/asn1-DCl_8MCW.js +1 -0
  171. package/ui/dist/assets/asterisk-BEn0Cwpx.js +1 -0
  172. package/ui/dist/assets/brainfuck-D5EjA2JK.js +1 -0
  173. package/ui/dist/assets/clike-xvONpD0Z.js +1 -0
  174. package/ui/dist/assets/clojure-BflJGmDX.js +1 -0
  175. package/ui/dist/assets/cmake-84nbDH0I.js +1 -0
  176. package/ui/dist/assets/cobol-DHLQUbN7.js +1 -0
  177. package/ui/dist/assets/coffeescript-De_zI4J0.js +1 -0
  178. package/ui/dist/assets/commonlisp-DgAKBe0r.js +1 -0
  179. package/ui/dist/assets/crystal-Bl6YKCG_.js +1 -0
  180. package/ui/dist/assets/css-CnaYoQib.js +1 -0
  181. package/ui/dist/assets/cypher-p9eYesGn.js +1 -0
  182. package/ui/dist/assets/d-DWkQyNnU.js +1 -0
  183. package/ui/dist/assets/diff-ChtP43wD.js +1 -0
  184. package/ui/dist/assets/dist-BHaIOZdW.js +1 -0
  185. package/ui/dist/assets/dist-BSJMM_jp.js +1 -0
  186. package/ui/dist/assets/dist-BiE4suo9.js +1 -0
  187. package/ui/dist/assets/dist-BqAROxzI.js +1 -0
  188. package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
  189. package/ui/dist/assets/dist-BwYRbe5s.js +9 -0
  190. package/ui/dist/assets/dist-BzulKuec.js +1 -0
  191. package/ui/dist/assets/dist-CBV7_vDN.js +2 -0
  192. package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
  193. package/ui/dist/assets/dist-DDJNvivM.js +1 -0
  194. package/ui/dist/assets/dist-DF3F_I8N.js +1 -0
  195. package/ui/dist/assets/dist-DHCY9KpV.js +1 -0
  196. package/ui/dist/assets/dist-DX8knLVr.js +1 -0
  197. package/ui/dist/assets/dist-D_I45xgJ.js +11 -0
  198. package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
  199. package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
  200. package/ui/dist/assets/dist-gF2bFn-5.js +1 -0
  201. package/ui/dist/assets/dockerfile-DJkHQgkl.js +1 -0
  202. package/ui/dist/assets/dtd-DTF72YNO.js +1 -0
  203. package/ui/dist/assets/dylan-S7ucR4pR.js +1 -0
  204. package/ui/dist/assets/ebnf-D4c0_ac3.js +1 -0
  205. package/ui/dist/assets/ecl-BVIeWmwY.js +1 -0
  206. package/ui/dist/assets/eiffel-BEjRio4Q.js +1 -0
  207. package/ui/dist/assets/elm-Cshzl8qu.js +1 -0
  208. package/ui/dist/assets/erlang-BV0c4Hdn.js +1 -0
  209. package/ui/dist/assets/factor-LKH5gN8L.js +1 -0
  210. package/ui/dist/assets/fcl-ClOhbFR7.js +1 -0
  211. package/ui/dist/assets/forth-D6RzAl64.js +1 -0
  212. package/ui/dist/assets/fortran-Bt6PBEDR.js +1 -0
  213. package/ui/dist/assets/gas-BHEdbvp9.js +1 -0
  214. package/ui/dist/assets/gherkin-oBAE_ms0.js +1 -0
  215. package/ui/dist/assets/groovy-CUrWKPia.js +1 -0
  216. package/ui/dist/assets/haskell-BRsoo5mP.js +1 -0
  217. package/ui/dist/assets/haxe-DjVOVvNP.js +1 -0
  218. package/ui/dist/assets/http-CLVgA2GD.js +1 -0
  219. package/ui/dist/assets/idl-BUZw3wgd.js +1 -0
  220. package/ui/dist/assets/index-DmasQ6Rq.css +2 -0
  221. package/ui/dist/assets/index-I7uU93kk.js +294 -0
  222. package/ui/dist/assets/javascript-lS2r2ifg.js +1 -0
  223. package/ui/dist/assets/julia-CQfgDRfP.js +1 -0
  224. package/ui/dist/assets/livescript-BXxG0Yva.js +1 -0
  225. package/ui/dist/assets/lua-DPCEkfD_.js +1 -0
  226. package/ui/dist/assets/mathematica-u8YMmbU0.js +1 -0
  227. package/ui/dist/assets/mbox-BEMVcqjs.js +1 -0
  228. package/ui/dist/assets/mirc-C9zpzAZU.js +1 -0
  229. package/ui/dist/assets/mllike-B45xgp2S.js +1 -0
  230. package/ui/dist/assets/modelica-CqHI_q-D.js +1 -0
  231. package/ui/dist/assets/mscgen-qgSLujhx.js +1 -0
  232. package/ui/dist/assets/mumps-BSzj6Xyz.js +1 -0
  233. package/ui/dist/assets/nginx-C-ORUuei.js +1 -0
  234. package/ui/dist/assets/nsis-viPvXFiq.js +1 -0
  235. package/ui/dist/assets/ntriples-CzMBulkp.js +1 -0
  236. package/ui/dist/assets/octave-D2Q8cUp1.js +1 -0
  237. package/ui/dist/assets/oz-BwTct_5T.js +1 -0
  238. package/ui/dist/assets/pascal-Dp_KdFgX.js +1 -0
  239. package/ui/dist/assets/perl-BkSkiI9m.js +1 -0
  240. package/ui/dist/assets/pig-AF4Hwhju.js +1 -0
  241. package/ui/dist/assets/powershell-D8WpQPaX.js +1 -0
  242. package/ui/dist/assets/properties-C357ku5U.js +1 -0
  243. package/ui/dist/assets/protobuf-D1ij_kNL.js +1 -0
  244. package/ui/dist/assets/pug-BqCHZ5xT.js +1 -0
  245. package/ui/dist/assets/puppet-BZyAS7I4.js +1 -0
  246. package/ui/dist/assets/python-B_1Jd8HM.js +1 -0
  247. package/ui/dist/assets/q-B9NhS7L_.js +1 -0
  248. package/ui/dist/assets/r-CxDmU1I4.js +1 -0
  249. package/ui/dist/assets/rpm-gpDK5sbt.js +1 -0
  250. package/ui/dist/assets/ruby-ChfNwlCY.js +1 -0
  251. package/ui/dist/assets/sas-C2OyD2Y6.js +1 -0
  252. package/ui/dist/assets/scheme-D9qYN0V5.js +1 -0
  253. package/ui/dist/assets/shell-DwuoZtxw.js +1 -0
  254. package/ui/dist/assets/sieve-tLWDfEkZ.js +1 -0
  255. package/ui/dist/assets/simple-mode-DXiLM1-G.js +1 -0
  256. package/ui/dist/assets/smalltalk-BLp5-jwC.js +1 -0
  257. package/ui/dist/assets/solr-B78wSQJQ.js +1 -0
  258. package/ui/dist/assets/sparql-Dz-mlCAC.js +1 -0
  259. package/ui/dist/assets/spreadsheet-CTIncYxj.js +1 -0
  260. package/ui/dist/assets/sql-DuQMFDvy.js +1 -0
  261. package/ui/dist/assets/stex-DNzd62Q_.js +1 -0
  262. package/ui/dist/assets/stylus-BOVgKnng.js +1 -0
  263. package/ui/dist/assets/swift-DKB6_1j6.js +1 -0
  264. package/ui/dist/assets/tcl-BGfruO1u.js +1 -0
  265. package/ui/dist/assets/textile-C4jVoHG1.js +1 -0
  266. package/ui/dist/assets/tiddlywiki-VlUiK86J.js +1 -0
  267. package/ui/dist/assets/tiki-CvUEFusT.js +1 -0
  268. package/ui/dist/assets/toml-BPTmHmyx.js +1 -0
  269. package/ui/dist/assets/troff-C2dAgh7P.js +1 -0
  270. package/ui/dist/assets/ttcn-PPSTk1ow.js +1 -0
  271. package/ui/dist/assets/ttcn-cfg-2b4Gb5sE.js +1 -0
  272. package/ui/dist/assets/turtle-Cx3rYX2g.js +1 -0
  273. package/ui/dist/assets/vb-BthdM_4N.js +1 -0
  274. package/ui/dist/assets/vbscript-D9f6rSsU.js +1 -0
  275. package/ui/dist/assets/velocity-CqftRsjg.js +1 -0
  276. package/ui/dist/assets/verilog-CjI9Y6RI.js +1 -0
  277. package/ui/dist/assets/vhdl-kS471soi.js +1 -0
  278. package/ui/dist/assets/webidl-D5jiJvOU.js +1 -0
  279. package/ui/dist/assets/xquery-BKJx4mf9.js +1 -0
  280. package/ui/dist/assets/yacas-CXXgtw0p.js +1 -0
  281. package/ui/dist/assets/z80-Co3PbHdE.js +1 -0
  282. package/ui/dist/favicon.ico +0 -0
  283. package/ui/dist/favicon.svg +11 -0
  284. package/ui/dist/index.html +17 -0
  285. package/ui/dist/logos/claude.svg +1 -0
  286. package/ui/dist/logos/opencode.svg +1 -0
@@ -0,0 +1,108 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { loadProfiles } from './profiles.js';
5
+ import { listCaseFileIds } from './cases.js';
6
+ import { ROOT, TESTS_DIR } from './paths.js';
7
+ import { parseTargets, readCaseText } from './targets.js';
8
+ export const snapshotsDir = (caseId) => path.join(TESTS_DIR, `${caseId}.spec.ts-snapshots`);
9
+ /** Project-relative paths below `tests/` that git reports as new or modified. */
10
+ export function gitChangedTests() {
11
+ try {
12
+ // `git status` prints paths relative to the repository root, which may be above this folder.
13
+ const top = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: ROOT, encoding: 'utf8' }).trim();
14
+ const out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '--', 'tests'], { cwd: ROOT, encoding: 'utf8' });
15
+ return new Set(out
16
+ .split('\n')
17
+ .filter(Boolean)
18
+ .map((line) => path.relative(ROOT, path.join(top, line.slice(3).trim())).split(path.sep).join('/')));
19
+ }
20
+ catch {
21
+ return new Set();
22
+ }
23
+ }
24
+ /** The target a `toHaveScreenshot('<name>.png')` belongs to: the nearest `// target: T<n>` above it. */
25
+ export function targetOfScreenshot(specText, name) {
26
+ const lines = specText.split('\n');
27
+ const at = lines.findIndex((line) => line.includes(`toHaveScreenshot('${name}.png'`) || line.includes(`toHaveScreenshot("${name}.png"`));
28
+ if (at < 0)
29
+ return null;
30
+ return /\/\/\s*target:\s*(T\d+)/.exec(lines.slice(Math.max(0, at - 4), at + 1).join('\n'))?.[1] ?? null;
31
+ }
32
+ /** The target of a PDF region check: its `name: '<name>'` is the baseline name without `-p<page>`; the target comment is above the call. */
33
+ export function targetOfPdfRegion(specText, name) {
34
+ const lines = specText.split('\n');
35
+ const at = lines.findIndex((line) => line.includes(`name: '${name}'`) || line.includes(`name: "${name}"`));
36
+ if (at < 0)
37
+ return null;
38
+ return /\/\/\s*target:\s*(T\d+)/.exec(lines.slice(Math.max(0, at - 15), at + 1).join('\n'))?.[1] ?? null;
39
+ }
40
+ /** Splits `<name>-<profile-id>-<platform>.png` using the known profile ids (Playwright adds both). */
41
+ export function parseBaselineName(file) {
42
+ const base = file.replace(/\.png$/i, '');
43
+ const beforePlatform = /^(.*)-[a-z0-9]+$/i.exec(base)?.[1];
44
+ if (!beforePlatform)
45
+ return { name: base, profile: null, env: null };
46
+ const profiles = loadProfiles();
47
+ const profile = profiles
48
+ .map((item) => item.id)
49
+ .sort((a, b) => b.length - a.length)
50
+ .find((id) => beforePlatform.endsWith(`-${id}`));
51
+ if (!profile)
52
+ return { name: base, profile: null, env: null };
53
+ return {
54
+ name: beforePlatform.slice(0, -profile.length - 1),
55
+ profile,
56
+ env: profiles.find((item) => item.id === profile)?.env ?? null,
57
+ };
58
+ }
59
+ /** The baselines of one case, or of every case (`caseIds` omitted). */
60
+ export function listBaselines(caseIds, changedFiles = gitChangedTests()) {
61
+ const found = [];
62
+ for (const caseId of listCaseFileIds()) {
63
+ if (caseIds && !caseIds.includes(caseId))
64
+ continue;
65
+ const dir = snapshotsDir(caseId);
66
+ if (!fs.existsSync(dir))
67
+ continue;
68
+ const specFile = path.join(TESTS_DIR, `${caseId}.spec.ts`);
69
+ const specText = fs.existsSync(specFile) ? fs.readFileSync(specFile, 'utf8') : '';
70
+ const cards = parseTargets(readCaseText(caseId));
71
+ for (const file of fs.readdirSync(dir).filter((name) => name.toLowerCase().endsWith('.png')).sort()) {
72
+ const full = path.join(dir, file);
73
+ const rel = path.relative(ROOT, full).split(path.sep).join('/');
74
+ const { name, profile, env } = parseBaselineName(file);
75
+ const targetId = targetOfScreenshot(specText, name) ?? targetOfPdfRegion(specText, name.replace(/-p\d+$/, ''));
76
+ const card = cards.find((item) => item.id === targetId);
77
+ const stat = fs.statSync(full);
78
+ found.push({
79
+ caseId,
80
+ file,
81
+ rel,
82
+ profile,
83
+ env,
84
+ changed: changedFiles.has(rel),
85
+ size: stat.size,
86
+ mtimeMs: stat.mtimeMs,
87
+ targetId,
88
+ card: card
89
+ ? {
90
+ id: card.id,
91
+ name: card.name,
92
+ what: card.fields['what'] ?? null,
93
+ where: card.fields['where'] ?? null,
94
+ not: card.fields['not'] ?? null,
95
+ pages: card.fields['pages'] ?? null,
96
+ region: card.fields['region'] ?? null,
97
+ }
98
+ : null,
99
+ problem: !targetId
100
+ ? 'The spec has no "// target: T<n>" for this screenshot, so nothing says what it must show.'
101
+ : !card
102
+ ? `${targetId} is not a card in "## Targets" of ${caseId}.`
103
+ : null,
104
+ });
105
+ }
106
+ }
107
+ return found;
108
+ }
@@ -0,0 +1,11 @@
1
+ export declare const NAME_PATTERN: RegExp;
2
+ export declare const CASE_ID_PATTERN: RegExp;
3
+ export declare const isValidCaseName: (name: string) => boolean;
4
+ export declare const isValidCaseId: (id: string) => boolean;
5
+ export declare function splitCaseId(id: string): {
6
+ group: string | null;
7
+ name: string;
8
+ };
9
+ export declare const joinCaseId: (group: string | null | undefined, name: string) => string;
10
+ /** Folders between `tests/` and the spec; the import path to `lib/` is `../` times (depth + 1). */
11
+ export declare const caseDepth: (id: string) => number;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A case id is the path of its document below `cases/`, without `.md`: `hub-landing` or `documents/landing-page`.
3
+ * One optional folder level (the group). The spec of a case lives at the same path below `tests/`.
4
+ * No Node imports here: the web UI uses this file too.
5
+ */
6
+ const WORD = '[a-z0-9]+(?:-[a-z0-9]+)*';
7
+ export const NAME_PATTERN = new RegExp(`^${WORD}$`);
8
+ export const CASE_ID_PATTERN = new RegExp(`^(?:${WORD}/)?${WORD}$`);
9
+ export const isValidCaseName = (name) => NAME_PATTERN.test(name);
10
+ export const isValidCaseId = (id) => CASE_ID_PATTERN.test(id);
11
+ export function splitCaseId(id) {
12
+ const slash = id.indexOf('/');
13
+ return slash === -1 ? { group: null, name: id } : { group: id.slice(0, slash), name: id.slice(slash + 1) };
14
+ }
15
+ export const joinCaseId = (group, name) => (group ? `${group}/${name}` : name);
16
+ /** Folders between `tests/` and the spec; the import path to `lib/` is `../` times (depth + 1). */
17
+ export const caseDepth = (id) => (id.match(/\//g) ?? []).length;
@@ -0,0 +1,26 @@
1
+ export declare class CaseError extends Error {
2
+ readonly status: 400 | 404 | 409;
3
+ constructor(message: string, status: 400 | 404 | 409);
4
+ }
5
+ export declare const caseFilePath: (id: string) => string;
6
+ export declare const specFilePath: (id: string) => string;
7
+ /** Playwright keeps the baseline images of a spec next to it, in `<spec file>-snapshots/`. */
8
+ export declare const snapshotDirPath: (id: string) => string;
9
+ export declare function assertValidNewId(id: string): void;
10
+ /** Everything that belongs to one case besides its document. */
11
+ export interface CaseImpact {
12
+ id: string;
13
+ caseFile: string;
14
+ specFile: string | null;
15
+ snapshotDir: string | null;
16
+ snapshotCount: number;
17
+ }
18
+ export declare function getCaseImpact(id: string): CaseImpact;
19
+ /**
20
+ * What a spec says about its case: the document path, the id passed to `caseTags` / `configureCase` /
21
+ * `requireValues`, and the relative path to `lib/` (it depends on the folder depth).
22
+ */
23
+ export declare function rewriteSpec(text: string, from: string, to: string): string;
24
+ export declare function renameCase(from: string, to: string): CaseImpact;
25
+ /** Deletes the document, the spec and the baseline images of one case. */
26
+ export declare function deleteCase(id: string): CaseImpact;
@@ -0,0 +1,110 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { caseDepth, isValidCaseId } from './case-id.js';
4
+ import { listCaseFileIds } from './cases.js';
5
+ import { CASES_DIR, ROOT, TESTS_DIR } from './paths.js';
6
+ /** `memo` is the route of the "From memo" page of the web UI. */
7
+ const RESERVED_IDS = ['memo'];
8
+ export class CaseError extends Error {
9
+ status;
10
+ constructor(message, status) {
11
+ super(message);
12
+ this.status = status;
13
+ }
14
+ }
15
+ export const caseFilePath = (id) => path.join(CASES_DIR, `${id}.md`);
16
+ export const specFilePath = (id) => path.join(TESTS_DIR, `${id}.spec.ts`);
17
+ /** Playwright keeps the baseline images of a spec next to it, in `<spec file>-snapshots/`. */
18
+ export const snapshotDirPath = (id) => `${specFilePath(id)}-snapshots`;
19
+ const relative = (file) => path.relative(ROOT, file).split(path.sep).join('/');
20
+ export function assertValidNewId(id) {
21
+ if (!isValidCaseId(id)) {
22
+ throw new CaseError(`Invalid case id "${id}". Use lowercase words with dashes, optionally in one folder: "header-logo" or "documents/header-logo".`, 400);
23
+ }
24
+ if (RESERVED_IDS.includes(id))
25
+ throw new CaseError(`The id "${id}" is reserved.`, 400);
26
+ }
27
+ export function getCaseImpact(id) {
28
+ const spec = specFilePath(id);
29
+ const snapshots = snapshotDirPath(id);
30
+ const snapshotCount = fs.existsSync(snapshots) ? fs.readdirSync(snapshots, { recursive: true }).length : 0;
31
+ return {
32
+ id,
33
+ caseFile: relative(caseFilePath(id)),
34
+ specFile: fs.existsSync(spec) ? relative(spec) : null,
35
+ snapshotDir: fs.existsSync(snapshots) ? relative(snapshots) : null,
36
+ snapshotCount,
37
+ };
38
+ }
39
+ const writeAtomic = (file, text) => {
40
+ fs.mkdirSync(path.dirname(file), { recursive: true });
41
+ const temp = `${file}.${process.pid}.tmp`;
42
+ fs.writeFileSync(temp, text);
43
+ fs.renameSync(temp, file);
44
+ };
45
+ /** Removes a group folder once its last file is gone. Never touches `cases/` or `tests/` themselves. */
46
+ function pruneEmptyDir(dir, root) {
47
+ if (path.resolve(dir) === path.resolve(root) || !path.resolve(dir).startsWith(path.resolve(root)))
48
+ return;
49
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0)
50
+ fs.rmdirSync(dir);
51
+ }
52
+ function setFrontMatterId(text, id) {
53
+ const match = /^---\r?\n[\s\S]*?\r?\n---/.exec(text);
54
+ if (!match)
55
+ return text;
56
+ return match[0].replace(/^id:[ \t]*.*$/m, `id: ${id}`) + text.slice(match[0].length);
57
+ }
58
+ /**
59
+ * What a spec says about its case: the document path, the id passed to `caseTags` / `configureCase` /
60
+ * `requireValues`, and the relative path to `lib/` (it depends on the folder depth).
61
+ */
62
+ export function rewriteSpec(text, from, to) {
63
+ const quoted = (value) => new RegExp(`(['"\`])${value.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}\\1`, 'g');
64
+ const result = text.replaceAll(`cases/${from}.md`, `cases/${to}.md`).replace(quoted(from), `$1${to}$1`);
65
+ const up = '../'.repeat(caseDepth(to) + 1);
66
+ return result.replace(/(\bfrom\s+['"]|\bimport\(\s*['"])\.\.\/(?:\.\.\/)*lib\//g, `$1${up}lib/`);
67
+ }
68
+ export function renameCase(from, to) {
69
+ assertValidNewId(to);
70
+ const ids = listCaseFileIds();
71
+ if (!isValidCaseId(from) || !ids.includes(from))
72
+ throw new CaseError(`Case "${from}" does not exist.`, 404);
73
+ if (from === to)
74
+ throw new CaseError('The new id is the same as the old one.', 400);
75
+ if (ids.includes(to))
76
+ throw new CaseError(`Case "${to}" already exists.`, 409);
77
+ for (const leftover of [specFilePath(to), snapshotDirPath(to)]) {
78
+ if (fs.existsSync(leftover)) {
79
+ throw new CaseError(`${relative(leftover)} already exists (a spec of a deleted case?). Remove it first.`, 409);
80
+ }
81
+ }
82
+ // Everything is checked; now move the files. New files first, the old ones are removed at the end.
83
+ const impact = getCaseImpact(from);
84
+ writeAtomic(caseFilePath(to), setFrontMatterId(fs.readFileSync(caseFilePath(from), 'utf8'), to));
85
+ if (impact.specFile) {
86
+ writeAtomic(specFilePath(to), rewriteSpec(fs.readFileSync(specFilePath(from), 'utf8'), from, to));
87
+ }
88
+ if (impact.snapshotDir) {
89
+ fs.mkdirSync(path.dirname(snapshotDirPath(to)), { recursive: true });
90
+ fs.renameSync(snapshotDirPath(from), snapshotDirPath(to));
91
+ }
92
+ fs.rmSync(caseFilePath(from));
93
+ if (impact.specFile)
94
+ fs.rmSync(specFilePath(from));
95
+ pruneEmptyDir(path.dirname(caseFilePath(from)), CASES_DIR);
96
+ pruneEmptyDir(path.dirname(specFilePath(from)), TESTS_DIR);
97
+ return getCaseImpact(to);
98
+ }
99
+ /** Deletes the document, the spec and the baseline images of one case. */
100
+ export function deleteCase(id) {
101
+ if (!isValidCaseId(id) || !fs.existsSync(caseFilePath(id)))
102
+ throw new CaseError(`Case "${id}" does not exist.`, 404);
103
+ const impact = getCaseImpact(id);
104
+ fs.rmSync(caseFilePath(id));
105
+ fs.rmSync(specFilePath(id), { force: true });
106
+ fs.rmSync(snapshotDirPath(id), { recursive: true, force: true });
107
+ pruneEmptyDir(path.dirname(caseFilePath(id)), CASES_DIR);
108
+ pruneEmptyDir(path.dirname(specFilePath(id)), TESTS_DIR);
109
+ return impact;
110
+ }
@@ -0,0 +1,29 @@
1
+ /** The front matter of a case document that the tools care about. The case document is the single source of truth. */
2
+ export interface CaseMeta {
3
+ id: string;
4
+ /** Suites the case belongs to, e.g. `['smoke', 'regression']`. A case runs in every suite it lists. */
5
+ suites: string[];
6
+ /**
7
+ * Environments in which the case may create or change data of the test accounts (and cleans it up), e.g.
8
+ * `['staging']`. Empty = the case never writes. In any other environment the case is skipped, not failed.
9
+ */
10
+ writes: string[];
11
+ /** What the case writes; required when `writes` is not empty. Shown to the person who confirms a production run. */
12
+ writesNote: string | null;
13
+ /** Environments the case may run in; `null` = all. */
14
+ envs: string[] | null;
15
+ }
16
+ /** Does the case write data at all (in some environment)? */
17
+ export declare const caseWrites: (meta: CaseMeta) => boolean;
18
+ /** May the case write data in this environment? A case that writes elsewhere only is skipped here. */
19
+ export declare const caseWritesIn: (meta: CaseMeta, env: string) => boolean;
20
+ /** Parses and validates the front matter. Throws an Error that starts with `label` and says what to fix. */
21
+ export declare function parseCaseMeta(id: string, text: string, label?: string): CaseMeta;
22
+ export declare const hasCaseFile: (caseId: string) => boolean;
23
+ export declare function readCaseMeta(caseId: string): CaseMeta;
24
+ /** Every case document except the template: `cases/<name>.md` and `cases/<group>/<name>.md` (one folder level). */
25
+ export declare function listCaseFileIds(): string[];
26
+ /** Playwright tags of a case: one `@<suite>` per suite, plus `@writes` for cases that write data. */
27
+ export declare function caseTags(caseId: string): string[];
28
+ /** Every distinct suite used by the cases. */
29
+ export declare const listSuites: () => string[];
@@ -0,0 +1,124 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { listEnvs } from './profiles.js';
5
+ import { NAME_PATTERN } from './case-id.js';
6
+ import { CASES_DIR } from './paths.js';
7
+ const frontMatterOf = (text) => {
8
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
9
+ if (!match)
10
+ return null;
11
+ const value = parse(match[1]);
12
+ return value && typeof value === 'object' ? value : null;
13
+ };
14
+ function nameList(value, key, label) {
15
+ if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string' || !NAME_PATTERN.test(item))) {
16
+ throw new Error(`${label}: "${key}" must be a list of lowercase words (letters, digits, dashes), e.g. "${key}: [smoke, regression]"`);
17
+ }
18
+ const duplicate = value.find((item, i) => value.indexOf(item) !== i);
19
+ if (duplicate)
20
+ throw new Error(`${label}: "${key}" lists "${duplicate}" twice`);
21
+ return value;
22
+ }
23
+ /**
24
+ * `writes` is a list of environment names. Omitted, `none` or `[]` mean the case never writes. The old values
25
+ * `test-data` (and `staging` / `production` as a bare word) are refused with the fix, never guessed.
26
+ */
27
+ function parseWrites(value, label) {
28
+ if (value === undefined || value === null || value === 'none')
29
+ return [];
30
+ if (value === 'test-data') {
31
+ throw new Error(`${label}: "writes: test-data" is the old form. List the environments instead, e.g. "writes: [staging]"`);
32
+ }
33
+ if (Array.isArray(value) && value.length === 0)
34
+ return [];
35
+ if (!Array.isArray(value)) {
36
+ throw new Error(`${label}: "writes" must be a list of environments, e.g. "writes: [staging]" (or omit it for read-only)`);
37
+ }
38
+ const writes = nameList(value, 'writes', label);
39
+ const known = listEnvs();
40
+ const unknown = writes.filter((env) => !known.includes(env));
41
+ if (unknown.length > 0) {
42
+ throw new Error(`${label}: unknown environment(s) in "writes": ${unknown.join(', ')}. Known: ${known.join(', ')}`);
43
+ }
44
+ return writes;
45
+ }
46
+ /** Does the case write data at all (in some environment)? */
47
+ export const caseWrites = (meta) => meta.writes.length > 0;
48
+ /** May the case write data in this environment? A case that writes elsewhere only is skipped here. */
49
+ export const caseWritesIn = (meta, env) => meta.writes.includes(env);
50
+ /** Parses and validates the front matter. Throws an Error that starts with `label` and says what to fix. */
51
+ export function parseCaseMeta(id, text, label = `cases/${id}.md`) {
52
+ const front = frontMatterOf(text);
53
+ if (!front)
54
+ throw new Error(`${label}: the file must start with a front matter block (--- ... ---)`);
55
+ if (front.suites !== undefined && front.priority !== undefined) {
56
+ throw new Error(`${label}: use "suites: [...]" only; "priority" is the old name of the same setting`);
57
+ }
58
+ // `priority: smoke` is the old form of `suites: [smoke]`.
59
+ const suites = front.suites !== undefined
60
+ ? nameList(front.suites, 'suites', label)
61
+ : front.priority !== undefined
62
+ ? nameList([front.priority], 'priority', label)
63
+ : (() => {
64
+ throw new Error(`${label}: front matter needs "suites: [<suite>, ...]", e.g. "suites: [smoke, regression]"`);
65
+ })();
66
+ const note = front['writes-note'];
67
+ const writes = parseWrites(front.writes, label);
68
+ if (writes.length > 0 && (typeof note !== 'string' || note.trim() === '')) {
69
+ throw new Error(`${label}: "writes: [${writes.join(', ')}]" needs a "writes-note" that says what the case creates or changes`);
70
+ }
71
+ let envs = null;
72
+ if (front.envs !== undefined) {
73
+ envs = nameList(front.envs, 'envs', label);
74
+ const known = listEnvs();
75
+ const unknown = envs.filter((env) => !known.includes(env));
76
+ if (unknown.length > 0) {
77
+ throw new Error(`${label}: unknown environment(s) in "envs": ${unknown.join(', ')}. Known: ${known.join(', ')}`);
78
+ }
79
+ }
80
+ const unreachable = writes.filter((env) => envs !== null && !envs.includes(env));
81
+ if (unreachable.length > 0) {
82
+ throw new Error(`${label}: "writes" lists ${unreachable.join(', ')}, but "envs" does not run the case there`);
83
+ }
84
+ return {
85
+ id,
86
+ suites,
87
+ writes,
88
+ writesNote: typeof note === 'string' && note.trim() !== '' ? note.trim() : null,
89
+ envs,
90
+ };
91
+ }
92
+ const caseFile = (caseId) => path.join(CASES_DIR, `${caseId}.md`);
93
+ export const hasCaseFile = (caseId) => fs.existsSync(caseFile(caseId));
94
+ export function readCaseMeta(caseId) {
95
+ return parseCaseMeta(caseId, fs.readFileSync(caseFile(caseId), 'utf8'));
96
+ }
97
+ /** Every case document except the template: `cases/<name>.md` and `cases/<group>/<name>.md` (one folder level). */
98
+ export function listCaseFileIds() {
99
+ const ids = [];
100
+ if (!fs.existsSync(CASES_DIR))
101
+ return ids;
102
+ const isCaseFile = (name) => name.endsWith('.md') && !name.startsWith('_');
103
+ for (const entry of fs.readdirSync(CASES_DIR, { withFileTypes: true })) {
104
+ if (entry.name.startsWith('_') || entry.name.startsWith('.'))
105
+ continue;
106
+ if (entry.isFile() && isCaseFile(entry.name)) {
107
+ ids.push(entry.name.slice(0, -3));
108
+ }
109
+ else if (entry.isDirectory()) {
110
+ for (const name of fs.readdirSync(path.join(CASES_DIR, entry.name))) {
111
+ if (isCaseFile(name))
112
+ ids.push(`${entry.name}/${name.slice(0, -3)}`);
113
+ }
114
+ }
115
+ }
116
+ return ids.sort();
117
+ }
118
+ /** Playwright tags of a case: one `@<suite>` per suite, plus `@writes` for cases that write data. */
119
+ export function caseTags(caseId) {
120
+ const meta = readCaseMeta(caseId);
121
+ return [...meta.suites.map((suite) => `@${suite}`), ...(caseWrites(meta) ? ['@writes'] : [])];
122
+ }
123
+ /** Every distinct suite used by the cases. */
124
+ export const listSuites = () => [...new Set(listCaseFileIds().flatMap((id) => readCaseMeta(id).suites))].sort();
@@ -0,0 +1,10 @@
1
+ /** Arguments shared by the single-profile commands: `<profile-id> [path]`. */
2
+ export interface CliTarget {
3
+ profileId?: string;
4
+ /** Positional arguments after the profile id. */
5
+ rest: string[];
6
+ help: boolean;
7
+ /** Set when the caller passed `--env`; those commands do not need it (the profile has its env). */
8
+ envGiven: boolean;
9
+ }
10
+ export declare function parseCliTarget(args: string[]): CliTarget;
@@ -0,0 +1,18 @@
1
+ export function parseCliTarget(args) {
2
+ const positional = [];
3
+ let help = false;
4
+ let envGiven = false;
5
+ for (let i = 0; i < args.length; i++) {
6
+ const arg = args[i];
7
+ if (arg === '--help')
8
+ help = true;
9
+ else if (arg === '--env' || arg.startsWith('--env=')) {
10
+ envGiven = true;
11
+ if (arg === '--env')
12
+ i++;
13
+ }
14
+ else
15
+ positional.push(arg);
16
+ }
17
+ return { profileId: positional[0], rest: positional.slice(1), help, envGiven };
18
+ }
@@ -0,0 +1,42 @@
1
+ import type { Page } from '@playwright/test';
2
+ import { type PdfFile } from './pdf.js';
3
+ export type DownloadKind = 'pdf' | 'png' | 'jpeg' | 'zip' | 'csv' | 'any';
4
+ export interface DownloadOptions {
5
+ /** What the file must be; `any` only checks that it is not empty. */
6
+ kind?: DownloadKind;
7
+ /** The suggested file name must match. */
8
+ fileName?: RegExp;
9
+ /** Smallest plausible size in bytes. Default: 1 (a PDF: 500). */
10
+ minBytes?: number;
11
+ /** How long the download may take to start after the trigger, in ms. Default 30 s. */
12
+ startTimeout?: number;
13
+ /** How long it may take to finish once started, in ms. Default 120 s. */
14
+ finishTimeout?: number;
15
+ /** Extra check on the file; throw an Error with a plain message when it is not valid. */
16
+ validate?: (file: {
17
+ path: string;
18
+ bytes: Buffer;
19
+ fileName: string;
20
+ }) => void | Promise<void>;
21
+ }
22
+ export interface DownloadResult {
23
+ path: string;
24
+ fileName: string;
25
+ size: number;
26
+ bytes: Buffer;
27
+ /** `kind: 'pdf'` only: the opened PDF (its pages can be rendered and its text read), see `expectPdf` in `lib/pdf`. */
28
+ pdf?: PdfFile;
29
+ }
30
+ /**
31
+ * The one way a spec checks a download. Every stage has its own plain error, and any failure or timeout fails the
32
+ * whole verification:
33
+ * 1. the download starts after `trigger` (the browser reports it),
34
+ * 2. the download finishes,
35
+ * 3. the file exists and has content,
36
+ * 4. the file is valid for its kind (and for `validate`, when given). A PDF must open and have pages; its content is
37
+ * then checked by reading it like a person, with `expectPdf(file.pdf!, {...})` from `lib/pdf`: every page is
38
+ * rendered to an image, the logo areas are compared with the profile's baseline, the extracted text is checked.
39
+ *
40
+ * const file = await verifyDownload(page, () => option.click(), {kind: 'pdf'});
41
+ */
42
+ export declare function verifyDownload(page: Page, trigger: () => Promise<unknown>, options?: DownloadOptions): Promise<DownloadResult>;
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { openPdf } from './pdf.js';
4
+ const fail = (message) => {
5
+ throw new Error(message);
6
+ };
7
+ const startsWith = (bytes, signature) => signature.every((byte, index) => bytes[index] === byte);
8
+ /** Content checks per kind: magic bytes first, then the structure that proves the file was written completely. */
9
+ function checkKind(kind, bytes, fileName) {
10
+ if (kind === 'pdf') {
11
+ if (bytes.subarray(0, 5).toString('latin1') !== '%PDF-')
12
+ fail(`The downloaded file "${fileName}" is not a PDF (it does not start with %PDF-)`);
13
+ if (!bytes.subarray(Math.max(0, bytes.length - 2048)).toString('latin1').includes('%%EOF')) {
14
+ fail(`The downloaded PDF "${fileName}" is cut off (it has no end marker)`);
15
+ }
16
+ }
17
+ else if (kind === 'png') {
18
+ if (!startsWith(bytes, [0x89, 0x50, 0x4e, 0x47]))
19
+ fail(`The downloaded file "${fileName}" is not a PNG image`);
20
+ }
21
+ else if (kind === 'jpeg') {
22
+ if (!startsWith(bytes, [0xff, 0xd8, 0xff]))
23
+ fail(`The downloaded file "${fileName}" is not a JPEG image`);
24
+ }
25
+ else if (kind === 'zip') {
26
+ if (!startsWith(bytes, [0x50, 0x4b]))
27
+ fail(`The downloaded file "${fileName}" is not a ZIP-based file`);
28
+ }
29
+ else if (kind === 'csv') {
30
+ const head = bytes.subarray(0, 4096).toString('utf8');
31
+ if (head.includes('\u0000') || head.trim() === '')
32
+ fail(`The downloaded file "${fileName}" is not a text (CSV) file`);
33
+ }
34
+ }
35
+ /**
36
+ * The one way a spec checks a download. Every stage has its own plain error, and any failure or timeout fails the
37
+ * whole verification:
38
+ * 1. the download starts after `trigger` (the browser reports it),
39
+ * 2. the download finishes,
40
+ * 3. the file exists and has content,
41
+ * 4. the file is valid for its kind (and for `validate`, when given). A PDF must open and have pages; its content is
42
+ * then checked by reading it like a person, with `expectPdf(file.pdf!, {...})` from `lib/pdf`: every page is
43
+ * rendered to an image, the logo areas are compared with the profile's baseline, the extracted text is checked.
44
+ *
45
+ * const file = await verifyDownload(page, () => option.click(), {kind: 'pdf'});
46
+ */
47
+ export async function verifyDownload(page, trigger, options = {}) {
48
+ const { kind = 'any', fileName: namePattern, startTimeout = 30_000, finishTimeout = 120_000, validate } = options;
49
+ const minBytes = options.minBytes ?? (kind === 'pdf' ? 500 : 1);
50
+ // 1. Start. The waiter exists before the trigger, so a fast download is not missed.
51
+ const started = page.waitForEvent('download', { timeout: startTimeout }).then((download) => ({ download, error: null }), () => ({ download: null, error: new Error(`The download did not start within ${startTimeout / 1000}s after clicking`) }));
52
+ await trigger();
53
+ const { download, error } = await started;
54
+ if (!download)
55
+ throw error;
56
+ const fileName = download.suggestedFilename();
57
+ if (namePattern && !namePattern.test(fileName)) {
58
+ fail(`The download has an unexpected file name "${fileName}" (expected to match ${namePattern})`);
59
+ }
60
+ // 2. Finish. `path()` resolves when the file is complete; `failure()` says why it was not.
61
+ let timer;
62
+ const filePath = await Promise.race([
63
+ download.path(),
64
+ new Promise((_, reject) => {
65
+ timer = setTimeout(() => reject(new Error(`The download "${fileName}" did not finish within ${finishTimeout / 1000}s`)), finishTimeout);
66
+ }),
67
+ ]).finally(() => clearTimeout(timer));
68
+ const failure = await download.failure();
69
+ if (failure)
70
+ fail(`The download "${fileName}" failed: ${failure}`);
71
+ if (!filePath)
72
+ fail(`The download "${fileName}" produced no file`);
73
+ // 3. The file exists and has content.
74
+ if (!fs.existsSync(filePath))
75
+ fail(`The downloaded file "${fileName}" does not exist on disk`);
76
+ const bytes = fs.readFileSync(filePath);
77
+ if (bytes.length < minBytes)
78
+ fail(`The downloaded file "${fileName}" is too small (${bytes.length} bytes, expected at least ${minBytes})`);
79
+ // 4. Valid for its kind.
80
+ checkKind(kind, bytes, fileName);
81
+ const pdf = kind === 'pdf' ? await openPdf(bytes) : undefined;
82
+ await validate?.({ path: filePath, bytes, fileName: path.basename(fileName) });
83
+ return { path: filePath, fileName, size: bytes.length, bytes, pdf };
84
+ }
@@ -0,0 +1,10 @@
1
+ import type { Profile } from './profiles.js';
2
+ /** Tests never send email in production. In other environments only when a real mailbox is connected (`email: true`). */
3
+ export declare const emailAllowed: (profile: Profile) => boolean;
4
+ /**
5
+ * Call at the start of a step that sends email. Returns false, and records the skip in the report, when
6
+ * email must not be sent here; the step then does nothing.
7
+ *
8
+ * if (!emailStepAllowed(profile, '5. Send the document to the recipient')) return;
9
+ */
10
+ export declare function emailStepAllowed(profile: Profile, stepTitle: string): boolean;
@@ -0,0 +1,19 @@
1
+ import { test } from '@playwright/test';
2
+ /** Tests never send email in production. In other environments only when a real mailbox is connected (`email: true`). */
3
+ export const emailAllowed = (profile) => profile.env !== 'production' && profile.email;
4
+ /**
5
+ * Call at the start of a step that sends email. Returns false, and records the skip in the report, when
6
+ * email must not be sent here; the step then does nothing.
7
+ *
8
+ * if (!emailStepAllowed(profile, '5. Send the document to the recipient')) return;
9
+ */
10
+ export function emailStepAllowed(profile, stepTitle) {
11
+ if (emailAllowed(profile))
12
+ return true;
13
+ const reason = profile.env === 'production'
14
+ ? 'email is never sent in production'
15
+ : `email is off for ${profile.id} (${profile.env}) (no mailbox connected: email: false in profiles.yaml)`;
16
+ test.info().annotations.push({ type: 'email-step-skipped', description: `${stepTitle}: ${reason}` });
17
+ console.log(` skipped email step "${stepTitle}": ${reason}`);
18
+ return false;
19
+ }
@@ -0,0 +1,8 @@
1
+ /** The system `ffmpeg` if there is one, else the binary of the optional `ffmpeg-static` package, else null. */
2
+ export declare function findFfmpeg(): string | null;
3
+ /**
4
+ * Joins `inputs` (in order) into one webm at `output`. Re-encodes on purpose: Playwright's recordings have loose
5
+ * timestamps, and copying the streams gives files with a wrong duration that cannot be seeked.
6
+ * Returns an error message, or null on success.
7
+ */
8
+ export declare function concatVideos(inputs: string[], output: string): string | null;