@asrunjs/asrun 0.0.0-stage → 0.5.2

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 (305) hide show
  1. package/.claude/commands/explore-case.md +18 -0
  2. package/.claude/commands/gen-spec.md +16 -0
  3. package/.claude/commands/heal-spec.md +26 -0
  4. package/.claude/commands/make-case.md +11 -0
  5. package/.claude/settings.json +5 -0
  6. package/.claude/skills/exploring-cases/SKILL.md +146 -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 +116 -0
  10. package/.claude/skills/exploring-cases/references/readiness.md +146 -0
  11. package/.claude/skills/exploring-cases/references/user-handoff.md +157 -0
  12. package/.claude/skills/generating-specs/SKILL.md +219 -0
  13. package/.claude/skills/generating-specs/references/conventions.md +148 -0
  14. package/.claude/skills/making-cases/SKILL.md +72 -0
  15. package/.claude/skills/making-cases/references/gaps.md +36 -0
  16. package/.claude/skills/making-cases/references/normalizing.md +39 -0
  17. package/.claude/skills/making-cases/references/splitting.md +28 -0
  18. package/.claude/skills/making-cases/references/writes-and-email.md +37 -0
  19. package/AGENTS.md +159 -0
  20. package/CLAUDE.md +2 -0
  21. package/LICENSE +21 -0
  22. package/README.md +511 -2
  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/build-artifacts.d.ts +8 -0
  31. package/dist/lib/build-artifacts.js +10 -0
  32. package/dist/lib/case-doc.d.ts +311 -0
  33. package/dist/lib/case-doc.js +1117 -0
  34. package/dist/lib/case-id.d.ts +11 -0
  35. package/dist/lib/case-id.js +17 -0
  36. package/dist/lib/case-ops.d.ts +26 -0
  37. package/dist/lib/case-ops.js +110 -0
  38. package/dist/lib/cases.d.ts +16 -0
  39. package/dist/lib/cases.js +51 -0
  40. package/dist/lib/cli.d.ts +10 -0
  41. package/dist/lib/cli.js +18 -0
  42. package/dist/lib/command.d.ts +24 -0
  43. package/dist/lib/command.js +90 -0
  44. package/dist/lib/downloads.d.ts +42 -0
  45. package/dist/lib/downloads.js +84 -0
  46. package/dist/lib/email.d.ts +10 -0
  47. package/dist/lib/email.js +19 -0
  48. package/dist/lib/ffmpeg.d.ts +8 -0
  49. package/dist/lib/ffmpeg.js +50 -0
  50. package/dist/lib/fixtures.d.ts +40 -0
  51. package/dist/lib/fixtures.js +149 -0
  52. package/dist/lib/login-card.d.ts +22 -0
  53. package/dist/lib/login-card.js +183 -0
  54. package/dist/lib/node-cli.d.ts +15 -0
  55. package/dist/lib/node-cli.js +36 -0
  56. package/dist/lib/paths.d.ts +25 -0
  57. package/dist/lib/paths.js +88 -0
  58. package/dist/lib/pdf.d.ts +71 -0
  59. package/dist/lib/pdf.js +140 -0
  60. package/dist/lib/profiles.d.ts +62 -0
  61. package/dist/lib/profiles.js +174 -0
  62. package/dist/lib/ready.d.ts +36 -0
  63. package/dist/lib/ready.js +53 -0
  64. package/dist/lib/records.d.ts +33 -0
  65. package/dist/lib/records.js +61 -0
  66. package/dist/lib/run-filters.d.ts +19 -0
  67. package/dist/lib/run-filters.js +38 -0
  68. package/dist/lib/run-summary.d.ts +90 -0
  69. package/dist/lib/run-summary.js +35 -0
  70. package/dist/lib/session.d.ts +30 -0
  71. package/dist/lib/session.js +111 -0
  72. package/dist/lib/summary-reporter.d.ts +16 -0
  73. package/dist/lib/summary-reporter.js +146 -0
  74. package/dist/lib/targets.d.ts +19 -0
  75. package/dist/lib/targets.js +26 -0
  76. package/dist/lib/ui-build.d.ts +10 -0
  77. package/dist/lib/ui-build.js +30 -0
  78. package/dist/lib/values.d.ts +29 -0
  79. package/dist/lib/values.js +85 -0
  80. package/dist/lib/write-gate.d.ts +41 -0
  81. package/dist/lib/write-gate.js +129 -0
  82. package/dist/playwright.config.d.ts +2 -0
  83. package/dist/playwright.config.js +92 -0
  84. package/dist/scripts/build.d.ts +1 -0
  85. package/dist/scripts/build.js +21 -0
  86. package/dist/scripts/check-cases.d.ts +1 -0
  87. package/dist/scripts/check-cases.js +149 -0
  88. package/dist/scripts/cleanup.d.ts +1 -0
  89. package/dist/scripts/cleanup.js +25 -0
  90. package/dist/scripts/cli.d.ts +1 -0
  91. package/dist/scripts/cli.js +28 -0
  92. package/dist/scripts/doctor.d.ts +1 -0
  93. package/dist/scripts/doctor.js +296 -0
  94. package/dist/scripts/explore.d.ts +1 -0
  95. package/dist/scripts/explore.js +75 -0
  96. package/dist/scripts/go.mjs +188 -0
  97. package/dist/scripts/heal-evidence.mjs +143 -0
  98. package/dist/scripts/init.d.ts +22 -0
  99. package/dist/scripts/init.js +332 -0
  100. package/dist/scripts/lint-specs.d.ts +1 -0
  101. package/dist/scripts/lint-specs.js +141 -0
  102. package/dist/scripts/login.d.ts +1 -0
  103. package/dist/scripts/login.js +75 -0
  104. package/dist/scripts/new-case.d.ts +1 -0
  105. package/dist/scripts/new-case.js +19 -0
  106. package/dist/scripts/render-pdf.d.ts +1 -0
  107. package/dist/scripts/render-pdf.js +44 -0
  108. package/dist/scripts/report.d.ts +1 -0
  109. package/dist/scripts/report.js +14 -0
  110. package/dist/scripts/review-baselines.d.ts +1 -0
  111. package/dist/scripts/review-baselines.js +30 -0
  112. package/dist/scripts/run.d.ts +1 -0
  113. package/dist/scripts/run.js +423 -0
  114. package/dist/scripts/status.d.ts +1 -0
  115. package/dist/scripts/status.js +31 -0
  116. package/dist/scripts/ui-build.d.ts +1 -0
  117. package/dist/scripts/ui-build.js +7 -0
  118. package/dist/scripts/ui-dev.d.ts +1 -0
  119. package/dist/scripts/ui-dev.js +3 -0
  120. package/dist/scripts/ui.d.ts +1 -0
  121. package/dist/scripts/ui.js +13 -0
  122. package/dist/ui/dev.d.ts +1 -0
  123. package/dist/ui/dev.js +36 -0
  124. package/dist/ui/server/actions.d.ts +95 -0
  125. package/dist/ui/server/actions.js +365 -0
  126. package/dist/ui/server/ai-models.d.ts +3 -0
  127. package/dist/ui/server/ai-models.js +347 -0
  128. package/dist/ui/server/ai-stream.d.ts +66 -0
  129. package/dist/ui/server/ai-stream.js +408 -0
  130. package/dist/ui/server/app.d.ts +4 -0
  131. package/dist/ui/server/app.js +395 -0
  132. package/dist/ui/server/cases.d.ts +23 -0
  133. package/dist/ui/server/cases.js +170 -0
  134. package/dist/ui/server/files.d.ts +5 -0
  135. package/dist/ui/server/files.js +67 -0
  136. package/dist/ui/server/index.d.ts +1 -0
  137. package/dist/ui/server/index.js +33 -0
  138. package/dist/ui/server/jobs.d.ts +80 -0
  139. package/dist/ui/server/jobs.js +317 -0
  140. package/dist/ui/server/profiles.d.ts +35 -0
  141. package/dist/ui/server/profiles.js +371 -0
  142. package/dist/ui/server/watch-files.d.ts +4 -0
  143. package/dist/ui/server/watch-files.js +36 -0
  144. package/dist/ui/shared/ai-commands.d.ts +88 -0
  145. package/dist/ui/shared/ai-commands.js +177 -0
  146. package/dist/ui/shared/case-id.d.ts +1 -0
  147. package/dist/ui/shared/case-id.js +1 -0
  148. package/dist/ui/shared/jobs.d.ts +6 -0
  149. package/dist/ui/shared/jobs.js +7 -0
  150. package/dist/ui/shared/product.d.ts +3 -0
  151. package/dist/ui/shared/product.js +3 -0
  152. package/dist/ui/shared/run-filters.d.ts +1 -0
  153. package/dist/ui/shared/run-filters.js +1 -0
  154. package/dist/ui/shared/site-address.d.ts +6 -0
  155. package/dist/ui/shared/site-address.js +11 -0
  156. package/dist/ui/shared/types.d.ts +371 -0
  157. package/dist/ui/shared/types.js +1 -0
  158. package/docs/agents/framework-dev.md +79 -0
  159. package/docs/theme-guide.html +922 -0
  160. package/package.json +109 -3
  161. package/templates/.claude/commands/explore-case.md +22 -0
  162. package/templates/.claude/commands/gen-spec.md +21 -0
  163. package/templates/.claude/commands/heal-spec.md +31 -0
  164. package/templates/.claude/commands/make-case.md +13 -0
  165. package/templates/.claude/settings.json +5 -0
  166. package/templates/.mcp.json +8 -0
  167. package/templates/AGENTS.md +64 -0
  168. package/templates/CLAUDE.md +1 -0
  169. package/templates/cases/_template.md +57 -0
  170. package/templates/gitignore +13 -0
  171. package/templates/opencode.jsonc +16 -0
  172. package/templates/package.json +31 -0
  173. package/templates/playwright.config.ts +3 -0
  174. package/templates/pnpm-workspace.yaml +5 -0
  175. package/templates/profiles.yaml +42 -0
  176. package/templates/seed.spec.ts +9 -0
  177. package/templates/tsconfig.json +14 -0
  178. package/ui/dist/apple-touch-icon.png +0 -0
  179. package/ui/dist/assets/apl-gfMMiTNf.js +1 -0
  180. package/ui/dist/assets/asciiarmor-C_eisQCp.js +1 -0
  181. package/ui/dist/assets/asn1-DCl_8MCW.js +1 -0
  182. package/ui/dist/assets/asterisk-BEn0Cwpx.js +1 -0
  183. package/ui/dist/assets/brainfuck-D5EjA2JK.js +1 -0
  184. package/ui/dist/assets/clike-xvONpD0Z.js +1 -0
  185. package/ui/dist/assets/clojure-BflJGmDX.js +1 -0
  186. package/ui/dist/assets/cmake-84nbDH0I.js +1 -0
  187. package/ui/dist/assets/cobol-DHLQUbN7.js +1 -0
  188. package/ui/dist/assets/coffeescript-De_zI4J0.js +1 -0
  189. package/ui/dist/assets/commonlisp-DgAKBe0r.js +1 -0
  190. package/ui/dist/assets/crystal-Bl6YKCG_.js +1 -0
  191. package/ui/dist/assets/css-CnaYoQib.js +1 -0
  192. package/ui/dist/assets/cypher-p9eYesGn.js +1 -0
  193. package/ui/dist/assets/d-DWkQyNnU.js +1 -0
  194. package/ui/dist/assets/diff-ChtP43wD.js +1 -0
  195. package/ui/dist/assets/dist-BGbe0-Aa.js +1 -0
  196. package/ui/dist/assets/dist-Ba-HC662.js +2 -0
  197. package/ui/dist/assets/dist-BtMLMthJ.js +1 -0
  198. package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
  199. package/ui/dist/assets/dist-BzulKuec.js +1 -0
  200. package/ui/dist/assets/dist-C-Oyo1ZT.js +1 -0
  201. package/ui/dist/assets/dist-C8MdhqgG.js +1 -0
  202. package/ui/dist/assets/dist-CG1pHn5m.js +1 -0
  203. package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
  204. package/ui/dist/assets/dist-CmpteMYY.js +9 -0
  205. package/ui/dist/assets/dist-DDJNvivM.js +1 -0
  206. package/ui/dist/assets/dist-DF3F_I8N.js +1 -0
  207. package/ui/dist/assets/dist-DSy3dVf4.js +11 -0
  208. package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
  209. package/ui/dist/assets/dist-Dq6sW5Ur.js +1 -0
  210. package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
  211. package/ui/dist/assets/dist-jjxQutEm.js +1 -0
  212. package/ui/dist/assets/dockerfile-DJkHQgkl.js +1 -0
  213. package/ui/dist/assets/dtd-DTF72YNO.js +1 -0
  214. package/ui/dist/assets/dylan-S7ucR4pR.js +1 -0
  215. package/ui/dist/assets/ebnf-D4c0_ac3.js +1 -0
  216. package/ui/dist/assets/ecl-BVIeWmwY.js +1 -0
  217. package/ui/dist/assets/eiffel-BEjRio4Q.js +1 -0
  218. package/ui/dist/assets/elm-Cshzl8qu.js +1 -0
  219. package/ui/dist/assets/erlang-BV0c4Hdn.js +1 -0
  220. package/ui/dist/assets/factor-LKH5gN8L.js +1 -0
  221. package/ui/dist/assets/fcl-ClOhbFR7.js +1 -0
  222. package/ui/dist/assets/forth-D6RzAl64.js +1 -0
  223. package/ui/dist/assets/fortran-Bt6PBEDR.js +1 -0
  224. package/ui/dist/assets/gas-BHEdbvp9.js +1 -0
  225. package/ui/dist/assets/gherkin-oBAE_ms0.js +1 -0
  226. package/ui/dist/assets/groovy-CUrWKPia.js +1 -0
  227. package/ui/dist/assets/haskell-BRsoo5mP.js +1 -0
  228. package/ui/dist/assets/haxe-DjVOVvNP.js +1 -0
  229. package/ui/dist/assets/http-CLVgA2GD.js +1 -0
  230. package/ui/dist/assets/idl-BUZw3wgd.js +1 -0
  231. package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
  232. package/ui/dist/assets/index-P_Yo0ZZn.js +338 -0
  233. package/ui/dist/assets/javascript-lS2r2ifg.js +1 -0
  234. package/ui/dist/assets/julia-CQfgDRfP.js +1 -0
  235. package/ui/dist/assets/livescript-BXxG0Yva.js +1 -0
  236. package/ui/dist/assets/lua-DPCEkfD_.js +1 -0
  237. package/ui/dist/assets/mathematica-u8YMmbU0.js +1 -0
  238. package/ui/dist/assets/mbox-BEMVcqjs.js +1 -0
  239. package/ui/dist/assets/mirc-C9zpzAZU.js +1 -0
  240. package/ui/dist/assets/mllike-B45xgp2S.js +1 -0
  241. package/ui/dist/assets/modelica-CqHI_q-D.js +1 -0
  242. package/ui/dist/assets/mscgen-qgSLujhx.js +1 -0
  243. package/ui/dist/assets/mumps-BSzj6Xyz.js +1 -0
  244. package/ui/dist/assets/nginx-C-ORUuei.js +1 -0
  245. package/ui/dist/assets/nsis-viPvXFiq.js +1 -0
  246. package/ui/dist/assets/ntriples-CzMBulkp.js +1 -0
  247. package/ui/dist/assets/octave-D2Q8cUp1.js +1 -0
  248. package/ui/dist/assets/oz-BwTct_5T.js +1 -0
  249. package/ui/dist/assets/pascal-Dp_KdFgX.js +1 -0
  250. package/ui/dist/assets/perl-BkSkiI9m.js +1 -0
  251. package/ui/dist/assets/pig-AF4Hwhju.js +1 -0
  252. package/ui/dist/assets/powershell-D8WpQPaX.js +1 -0
  253. package/ui/dist/assets/properties-C357ku5U.js +1 -0
  254. package/ui/dist/assets/protobuf-D1ij_kNL.js +1 -0
  255. package/ui/dist/assets/pug-BqCHZ5xT.js +1 -0
  256. package/ui/dist/assets/puppet-BZyAS7I4.js +1 -0
  257. package/ui/dist/assets/python-B_1Jd8HM.js +1 -0
  258. package/ui/dist/assets/q-B9NhS7L_.js +1 -0
  259. package/ui/dist/assets/r-CxDmU1I4.js +1 -0
  260. package/ui/dist/assets/rpm-gpDK5sbt.js +1 -0
  261. package/ui/dist/assets/ruby-ChfNwlCY.js +1 -0
  262. package/ui/dist/assets/sas-C2OyD2Y6.js +1 -0
  263. package/ui/dist/assets/scheme-D9qYN0V5.js +1 -0
  264. package/ui/dist/assets/shell-DwuoZtxw.js +1 -0
  265. package/ui/dist/assets/sieve-tLWDfEkZ.js +1 -0
  266. package/ui/dist/assets/simple-mode-DXiLM1-G.js +1 -0
  267. package/ui/dist/assets/smalltalk-BLp5-jwC.js +1 -0
  268. package/ui/dist/assets/solr-B78wSQJQ.js +1 -0
  269. package/ui/dist/assets/sparql-Dz-mlCAC.js +1 -0
  270. package/ui/dist/assets/spreadsheet-CTIncYxj.js +1 -0
  271. package/ui/dist/assets/sql-DuQMFDvy.js +1 -0
  272. package/ui/dist/assets/stex-DNzd62Q_.js +1 -0
  273. package/ui/dist/assets/stylus-BOVgKnng.js +1 -0
  274. package/ui/dist/assets/swift-DKB6_1j6.js +1 -0
  275. package/ui/dist/assets/tcl-BGfruO1u.js +1 -0
  276. package/ui/dist/assets/textile-C4jVoHG1.js +1 -0
  277. package/ui/dist/assets/tiddlywiki-VlUiK86J.js +1 -0
  278. package/ui/dist/assets/tiki-CvUEFusT.js +1 -0
  279. package/ui/dist/assets/toml-BPTmHmyx.js +1 -0
  280. package/ui/dist/assets/troff-C2dAgh7P.js +1 -0
  281. package/ui/dist/assets/ttcn-PPSTk1ow.js +1 -0
  282. package/ui/dist/assets/ttcn-cfg-2b4Gb5sE.js +1 -0
  283. package/ui/dist/assets/turtle-Cx3rYX2g.js +1 -0
  284. package/ui/dist/assets/vb-BthdM_4N.js +1 -0
  285. package/ui/dist/assets/vbscript-D9f6rSsU.js +1 -0
  286. package/ui/dist/assets/velocity-CqftRsjg.js +1 -0
  287. package/ui/dist/assets/verilog-CjI9Y6RI.js +1 -0
  288. package/ui/dist/assets/vhdl-kS471soi.js +1 -0
  289. package/ui/dist/assets/webidl-D5jiJvOU.js +1 -0
  290. package/ui/dist/assets/xquery-BKJx4mf9.js +1 -0
  291. package/ui/dist/assets/yacas-CXXgtw0p.js +1 -0
  292. package/ui/dist/assets/z80-Co3PbHdE.js +1 -0
  293. package/ui/dist/favicon.ico +0 -0
  294. package/ui/dist/favicon.svg +11 -0
  295. package/ui/dist/help/ai-tools.png +0 -0
  296. package/ui/dist/help/case-explore.png +0 -0
  297. package/ui/dist/help/case-generate.png +0 -0
  298. package/ui/dist/help/cases-memo.png +0 -0
  299. package/ui/dist/help/profile-form.png +0 -0
  300. package/ui/dist/help/profiles-login.png +0 -0
  301. package/ui/dist/help/reports.png +0 -0
  302. package/ui/dist/help/run-tests.png +0 -0
  303. package/ui/dist/index.html +23 -0
  304. package/ui/dist/logos/claude.svg +1 -0
  305. package/ui/dist/logos/opencode.svg +1 -0
@@ -0,0 +1,149 @@
1
+ import { expect, test as base } from '@playwright/test';
2
+ import { getProfile } from './profiles.js';
3
+ import { caseWrites, caseWritesIn, hasCaseFile, readCaseMeta } from './cases.js';
4
+ import { acquireAccountLock } from './account-lock.js';
5
+ import { ledgerFile, TEST_DATA_PREFIX, writeLedger } from './records.js';
6
+ import { caseIdFromFile } from './run-summary.js';
7
+ import { ALLOW_WRITES_ENV, isProtectedEnv } from './write-gate.js';
8
+ import { checkSession, isLoggedOutUrl, parseNotLoggedIn, sessionProblemMessage, signinCommand, } from './session.js';
9
+ export const test = base.extend({
10
+ profile: [
11
+ async ({}, use, workerInfo) => {
12
+ await use(getProfile(workerInfo.project.name));
13
+ },
14
+ { scope: 'worker' },
15
+ ],
16
+ mustLogin: [true, { scope: 'worker', option: true }],
17
+ // Login is not something we test: a profile without a valid session fails right away with an
18
+ // actionable message instead of a confusing login-page failure.
19
+ sessionGuard: [
20
+ async ({ browser, profile, mustLogin }, use) => {
21
+ if (mustLogin) {
22
+ // `pnpm asrun` already checked every profile before the run; reuse its result.
23
+ const status = parseNotLoggedIn().get(profile.id) ?? (await checkSession(browser, profile));
24
+ if (status !== 'ok') {
25
+ throw new Error(sessionProblemMessage(profile, status));
26
+ }
27
+ }
28
+ await use();
29
+ },
30
+ { scope: 'worker', auto: true },
31
+ ],
32
+ // Enforced here, not only in `pnpm asrun`, so that running `playwright test` directly cannot bypass it:
33
+ // a case may be limited to some environments (`envs`), a case that writes data only runs in the environments
34
+ // it lists under `writes` (elsewhere it is skipped), and writing in any environment except staging needs a
35
+ // human's confirmation.
36
+ caseGate: [
37
+ async ({ profile }, use, testInfo) => {
38
+ const caseId = caseIdFromFile(testInfo.file);
39
+ if (hasCaseFile(caseId)) {
40
+ const meta = readCaseMeta(caseId);
41
+ testInfo.skip(meta.envs !== null && !meta.envs.includes(profile.env), `This case is limited to: ${meta.envs?.join(', ')}`);
42
+ testInfo.skip(caseWrites(meta) && !caseWritesIn(meta, profile.env), `This case writes data and has no permission to write in ${profile.env.toUpperCase()} (it lists: ${meta.writes.join(', ')}).`);
43
+ if (caseWritesIn(meta, profile.env) && isProtectedEnv(profile.env) && process.env[ALLOW_WRITES_ENV] !== profile.env) {
44
+ throw new Error(`This case writes data and has no permission to run in ${profile.env.toUpperCase()}. ` +
45
+ `Start it with "pnpm asrun --env ${profile.env}" and confirm, or run it in staging.`);
46
+ }
47
+ }
48
+ await use();
49
+ },
50
+ { scope: 'test', auto: true },
51
+ ],
52
+ // Cases that write to an account run one at a time per profile, whatever the worker count.
53
+ accountLock: [
54
+ async ({ profile, caseGate: _gate }, use, testInfo) => {
55
+ const caseId = caseIdFromFile(testInfo.file);
56
+ if (!hasCaseFile(caseId) || !caseWrites(readCaseMeta(caseId))) {
57
+ await use();
58
+ return;
59
+ }
60
+ const release = await acquireAccountLock(profile.id, WRITE_CASE_TIMEOUT_MS);
61
+ try {
62
+ await use();
63
+ }
64
+ finally {
65
+ release();
66
+ }
67
+ },
68
+ { scope: 'test', auto: true },
69
+ ],
70
+ // Tracks test data and removes it again, newest first. See `Records.track`.
71
+ records: async ({ profile, page: _page }, use, testInfo) => {
72
+ const caseId = caseIdFromFile(testInfo.file);
73
+ const runId = process.env.ASRUN_RUN_ID ?? 'adhoc';
74
+ const file = ledgerFile(runId, testInfo.testId);
75
+ const entries = [];
76
+ const save = () => writeLedger(file, entries.map((item) => item.entry));
77
+ await use({
78
+ async track(data, remove) {
79
+ if (!data.name.startsWith(TEST_DATA_PREFIX)) {
80
+ throw new Error(`Test data must be named with the prefix "${TEST_DATA_PREFIX}" (got "${data.name}"). ` +
81
+ `Only data that is clearly a test's own may be created and removed.`);
82
+ }
83
+ entries.push({
84
+ entry: {
85
+ id: `${entries.length + 1}`,
86
+ runId,
87
+ env: profile.env,
88
+ profile: profile.id,
89
+ caseId,
90
+ kind: data.kind,
91
+ name: data.name,
92
+ where: data.where ?? null,
93
+ status: 'open',
94
+ error: null,
95
+ createdAt: new Date().toISOString(),
96
+ },
97
+ remove,
98
+ });
99
+ // Written at once: if the process dies, the ledger still says what was left behind.
100
+ save();
101
+ },
102
+ });
103
+ const failures = [];
104
+ for (const item of [...entries].reverse()) {
105
+ try {
106
+ await item.remove();
107
+ item.entry.status = 'cleaned';
108
+ }
109
+ catch (error) {
110
+ item.entry.status = 'leftover';
111
+ item.entry.error = error.message.split('\n')[0];
112
+ failures.push(`${item.entry.kind} "${item.entry.name}"${item.entry.where ? ` (look in ${item.entry.where})` : ''}`);
113
+ }
114
+ save();
115
+ }
116
+ if (failures.length > 0) {
117
+ testInfo.annotations.push({ type: 'leftover-data', description: failures.join('; ') });
118
+ throw new Error(`Test data was not removed and must be deleted by hand: ${failures.join('; ')}`);
119
+ }
120
+ },
121
+ // The session can also expire in the middle of a run; say so when a failed test ends on /login.
122
+ sessionExpiryHint: [
123
+ async ({ page, profile, mustLogin }, use, testInfo) => {
124
+ await use();
125
+ if (!mustLogin || testInfo.status === testInfo.expectedStatus) {
126
+ return;
127
+ }
128
+ if (page.isClosed() || !isLoggedOutUrl(page.url(), profile.sessionCheck)) {
129
+ return;
130
+ }
131
+ const description = `The session of profile "${profile.id}" in ${profile.env} expired during the run. Log in again: ${signinCommand(profile)}`;
132
+ testInfo.annotations.push({ type: 'session-expired', description });
133
+ console.error(`\n${description}\n`);
134
+ },
135
+ { scope: 'test', auto: true },
136
+ ],
137
+ });
138
+ /** Longest a write case may take, and how long it waits for another case that uses the same account. */
139
+ const WRITE_CASE_TIMEOUT_MS = 180_000;
140
+ /**
141
+ * Call at the top of the `test.describe` of every spec (`configureCase('<id>')`). Cases that write data are
142
+ * never retried (a retry would create the data a second time) and get more time than a read-only case.
143
+ */
144
+ export function configureCase(caseId) {
145
+ if (hasCaseFile(caseId) && caseWrites(readCaseMeta(caseId))) {
146
+ test.describe.configure({ retries: 0, timeout: WRITE_CASE_TIMEOUT_MS });
147
+ }
148
+ }
149
+ export { expect };
@@ -0,0 +1,22 @@
1
+ import type { BrowserContext, Page } from '@playwright/test';
2
+ /**
3
+ * The card the person sees in the sign-in window: it tells them what to do and saves the session when they press Done,
4
+ * so nobody has to switch back to the UI and press the button on the job.
5
+ *
6
+ * The card is painted by an init script, so it is there again after every navigation the login makes (and in a popup
7
+ * the site opens). It is the small variant of the attended handoff card
8
+ * (`<framework>/.claude/skills/exploring-cases/references/handoff-card.js`): no copy chips, no finish condition, and
9
+ * it lives in its own shadow root so the site's CSS cannot change it. Styles go through a constructed stylesheet,
10
+ * because a site with a strict CSP blocks an inline `<style>` element.
11
+ */
12
+ /** How the wait for the person ended. */
13
+ export type LoginCardOutcome = 'done' | 'cancel' | 'closed' | 'stopped';
14
+ /** Adds the card to every page of the context, now and on every navigation after this call. */
15
+ export declare function showLoginCard(context: BrowserContext, message: string): void;
16
+ /** What the card says once the session is saved, before the window closes. */
17
+ export declare function showLoginCardSaved(context: BrowserContext, text: string): Promise<void>;
18
+ /**
19
+ * Waits until the person presses Done or Cancel on the card, or closes the window. `signal` lets the caller stop the
20
+ * wait when something else finished it first (the Enter key on the command line, or the button in the UI).
21
+ */
22
+ export declare function waitForLoginCard(context: BrowserContext, page: Page, signal?: AbortSignal): Promise<LoginCardOutcome>;
@@ -0,0 +1,183 @@
1
+ const FLAG = '__asrunLoginCard';
2
+ const FINISH = '__asrunLoginCardFinish';
3
+ /** Adds the card to every page of the context, now and on every navigation after this call. */
4
+ export function showLoginCard(context, message) {
5
+ void context.addInitScript({ content: loginCardSource(message) }).catch(() => { });
6
+ }
7
+ /** What the card says once the session is saved, before the window closes. */
8
+ export async function showLoginCardSaved(context, text) {
9
+ const call = `window.${FINISH} && window.${FINISH}(${JSON.stringify(text)})`;
10
+ await Promise.all(context.pages().map((page) => page.evaluate(call).catch(() => null)));
11
+ }
12
+ /**
13
+ * Waits until the person presses Done or Cancel on the card, or closes the window. `signal` lets the caller stop the
14
+ * wait when something else finished it first (the Enter key on the command line, or the button in the UI).
15
+ */
16
+ export async function waitForLoginCard(context, page, signal) {
17
+ const read = `window.${FLAG} ?? null`;
18
+ for (;;) {
19
+ for (const open of context.pages()) {
20
+ const flag = await open.evaluate(read).catch(() => null);
21
+ if (flag === 'done' || flag === 'cancel')
22
+ return flag;
23
+ }
24
+ if (signal?.aborted)
25
+ return 'stopped';
26
+ if (page.isClosed() || context.pages().length === 0)
27
+ return 'closed';
28
+ await new Promise((resolve) => setTimeout(resolve, 400));
29
+ }
30
+ }
31
+ /** The whole card as one script, with the message baked in: an init script cannot close over anything. */
32
+ function loginCardSource(message) {
33
+ return `(() => {
34
+ const MESSAGE = ${JSON.stringify(message)};
35
+ const FLAG = '${FLAG}';
36
+ const CLICK_KEY = '__ASRUN_login_card_click';
37
+ const POS_KEY = '__ASRUN_login_card_pos';
38
+ const ID = '__ASRUN_login_card';
39
+ try {
40
+ const early = sessionStorage.getItem(CLICK_KEY);
41
+ if (early) window[FLAG] = early;
42
+ } catch (error) {}
43
+
44
+ const paint = () => {
45
+ // One card, in the page the person logs in on: never in an iframe of the site.
46
+ if (window.top !== window.self) return true;
47
+ if (!document.body) return false;
48
+ if (document.getElementById(ID)) return true;
49
+
50
+ const state = {minimized: false, left: null, top: null};
51
+ try { Object.assign(state, JSON.parse(sessionStorage.getItem(POS_KEY) || '{}')); } catch (error) {}
52
+ const save = () => { try { sessionStorage.setItem(POS_KEY, JSON.stringify(state)); } catch (error) {} };
53
+
54
+ const svg = (body) =>
55
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" ' +
56
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + '</svg>';
57
+ const ICON = {
58
+ move: svg('<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/>' +
59
+ '<polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/>'),
60
+ up: svg('<polyline points="18 15 12 9 6 15"/>'),
61
+ down: svg('<polyline points="6 9 12 15 18 9"/>'),
62
+ };
63
+ const css = [
64
+ '.card{width:300px;max-width:calc(100vw - 32px);background:#1e3340;color:#fff;font:13px/1.4 system-ui,sans-serif;' +
65
+ 'border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.35);overflow:hidden}',
66
+ '.card.min{width:auto}.card.ok{background:#15803d}',
67
+ '.card.ok .body,.card.ok .mini-done{display:none}',
68
+ '.bar{display:flex;align-items:center;gap:8px;padding:7px 8px 7px 12px;background:#b45309;cursor:grab;' +
69
+ 'user-select:none;touch-action:none}',
70
+ '.ok .bar{background:#166534;align-items:flex-start;padding-top:9px}',
71
+ '.bar b{flex:1;min-width:0;font-size:12px;letter-spacing:.04em;white-space:nowrap}',
72
+ '.ok .bar b{white-space:normal;line-height:1.35}',
73
+ '.grip{display:inline-flex;opacity:.8}',
74
+ 'button{all:unset;box-sizing:border-box;cursor:pointer;display:inline-flex;align-items:center;' +
75
+ 'justify-content:center;gap:4px;padding:3px 8px;border-radius:4px;background:rgba(255,255,255,.18);' +
76
+ 'color:#fff;font:600 12px system-ui,sans-serif}',
77
+ 'button:hover{background:rgba(255,255,255,.3)}',
78
+ '.body{padding:10px 12px 12px;display:flex;flex-direction:column;gap:8px}.min .body{display:none}',
79
+ '.mini-done{display:none}.min .mini-done{display:inline-flex}',
80
+ '.row{display:flex;gap:8px}.row .primary{background:#15803d}.row .primary:hover{background:#16a34a}',
81
+ '.row .primary{flex:1}',
82
+ ].join('');
83
+
84
+ const host = document.createElement('div');
85
+ host.id = ID;
86
+ host.style.cssText = 'all:initial;position:fixed;z-index:2147483647;left:16px;bottom:16px';
87
+ const root = host.attachShadow({mode: 'open'});
88
+ // A constructed stylesheet is not an inline style, so a site with a strict CSP cannot block it.
89
+ try {
90
+ const sheet = new CSSStyleSheet();
91
+ sheet.replaceSync(css);
92
+ root.adoptedStyleSheets = [sheet];
93
+ } catch (error) {
94
+ const style = document.createElement('style');
95
+ style.textContent = css;
96
+ root.appendChild(style);
97
+ }
98
+ const holder = document.createElement('div');
99
+ holder.innerHTML =
100
+ '<div class="card" id="card"><div class="bar" id="bar" title="Drag to move">' +
101
+ '<span class="grip">' + ICON.move + '</span><b id="title">YOUR TURN: log in</b>' +
102
+ '<button class="mini-done" data-a="done">Done</button>' +
103
+ '<button id="toggle" title="Minimize or expand" aria-label="Minimize or expand"></button></div>' +
104
+ '<div class="body"><div id="msg"></div><div class="row">' +
105
+ '<button class="primary" data-a="done">Done, save the session</button>' +
106
+ '<button data-a="cancel">Cancel</button></div></div></div>';
107
+
108
+ const card = holder.firstElementChild;
109
+ const bar = card.querySelector('#bar');
110
+ const toggle = card.querySelector('#toggle');
111
+ const message = card.querySelector('#msg');
112
+ message.textContent = MESSAGE;
113
+ root.appendChild(card);
114
+
115
+ const apply = () => {
116
+ card.classList.toggle('min', state.minimized);
117
+ toggle.innerHTML = state.minimized ? ICON.up : ICON.down;
118
+ };
119
+ const place = () => {
120
+ if (state.left === null) return;
121
+ const box = host.getBoundingClientRect();
122
+ const left = Math.max(0, Math.min(innerWidth - Math.min(box.width, 80), state.left));
123
+ const top = Math.max(0, Math.min(innerHeight - 36, state.top));
124
+ host.style.left = left + 'px';
125
+ host.style.top = top + 'px';
126
+ host.style.bottom = 'auto';
127
+ };
128
+
129
+ // The card must never take the keyboard focus away from the site's login form.
130
+ root.addEventListener('mousedown', (event) => {
131
+ if (event.target.closest('button')) event.preventDefault();
132
+ });
133
+ root.addEventListener('click', (event) => {
134
+ const button = event.target.closest('[data-a]');
135
+ if (!button) return;
136
+ const value = button.dataset.a;
137
+ window[FLAG] = value;
138
+ try { sessionStorage.setItem(CLICK_KEY, value); } catch (error) {}
139
+ });
140
+ toggle.addEventListener('click', () => {
141
+ state.minimized = !state.minimized;
142
+ save();
143
+ apply();
144
+ place();
145
+ });
146
+
147
+ let drag = null;
148
+ bar.addEventListener('pointerdown', (event) => {
149
+ if (event.target.closest('button')) return;
150
+ const box = host.getBoundingClientRect();
151
+ drag = {dx: event.clientX - box.left, dy: event.clientY - box.top};
152
+ bar.setPointerCapture(event.pointerId);
153
+ bar.style.cursor = 'grabbing';
154
+ });
155
+ bar.addEventListener('pointermove', (event) => {
156
+ if (!drag) return;
157
+ state.left = event.clientX - drag.dx;
158
+ state.top = event.clientY - drag.dy;
159
+ place();
160
+ });
161
+ bar.addEventListener('pointerup', () => {
162
+ drag = null;
163
+ bar.style.cursor = 'grab';
164
+ save();
165
+ });
166
+
167
+ window[${JSON.stringify(FINISH)}] = (text) => {
168
+ card.classList.remove('min');
169
+ card.classList.add('ok');
170
+ toggle.style.display = 'none';
171
+ bar.querySelector('b').textContent = text;
172
+ message.textContent = '';
173
+ };
174
+
175
+ apply();
176
+ document.documentElement.appendChild(host);
177
+ place();
178
+ return true;
179
+ };
180
+
181
+ if (!paint()) document.addEventListener('DOMContentLoaded', paint, {once: true});
182
+ })();`;
183
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Arguments that run one of the framework's own scripts (for example `login.ts`): the TypeScript source in the
3
+ * framework repository (tsx is installed there), the compiled file in a workspace, which only ships `dist/`.
4
+ *
5
+ * The source is tried first on purpose. A `dist/` left over from an earlier `pnpm build` must never win over a
6
+ * source file that was edited since: `pnpm asrun` runs `scripts/run.ts` through tsx, so the jobs the web UI starts
7
+ * have to run the same code, not the previous build.
8
+ */
9
+ export declare function scriptArgs(name: string): string[];
10
+ /**
11
+ * The JavaScript entry point of a package's command-line tool. The shims in `node_modules/.bin` are POSIX shell
12
+ * scripts on Windows and cannot be spawned there, so tools are always started with the current Node instead.
13
+ * The path comes from the package manifest, so a dependency upgrade cannot silently break it.
14
+ */
15
+ export declare function commandScript(packageName: string, command?: string): string;
@@ -0,0 +1,36 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { FRAMEWORK_ROOT, packageDir } from './paths.js';
4
+ /**
5
+ * Arguments that run one of the framework's own scripts (for example `login.ts`): the TypeScript source in the
6
+ * framework repository (tsx is installed there), the compiled file in a workspace, which only ships `dist/`.
7
+ *
8
+ * The source is tried first on purpose. A `dist/` left over from an earlier `pnpm build` must never win over a
9
+ * source file that was edited since: `pnpm asrun` runs `scripts/run.ts` through tsx, so the jobs the web UI starts
10
+ * have to run the same code, not the previous build.
11
+ */
12
+ export function scriptArgs(name) {
13
+ const source = path.join(FRAMEWORK_ROOT, 'scripts', name);
14
+ if (fs.existsSync(source)) {
15
+ try {
16
+ return [commandScript('tsx'), source];
17
+ }
18
+ catch {
19
+ // No tsx: an installed package ships `dist/` only, so the compiled file below is the one to run.
20
+ }
21
+ }
22
+ return [path.join(FRAMEWORK_ROOT, 'dist', 'scripts', name.replace(/\.ts$/, '.js'))];
23
+ }
24
+ /**
25
+ * The JavaScript entry point of a package's command-line tool. The shims in `node_modules/.bin` are POSIX shell
26
+ * scripts on Windows and cannot be spawned there, so tools are always started with the current Node instead.
27
+ * The path comes from the package manifest, so a dependency upgrade cannot silently break it.
28
+ */
29
+ export function commandScript(packageName, command = packageName) {
30
+ const dir = packageDir(packageName);
31
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
32
+ const script = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[command];
33
+ if (!script)
34
+ throw new Error(`${packageName} has no command-line entry point "${command}".`);
35
+ return path.join(dir, script);
36
+ }
@@ -0,0 +1,25 @@
1
+ export declare const FRAMEWORK_ROOT: string;
2
+ /**
3
+ * The workspace root: the project that uses the framework. Everything the framework reads or writes for a project
4
+ * (`profiles.yaml`, `cases/`, `tests/`, `.auth/`, `reports/`, `.tmp/`) is relative to it.
5
+ *
6
+ * `ASRUN_ROOT` chooses it explicitly. Otherwise it is the nearest folder at or above the current working
7
+ * directory that holds `profiles.yaml` or `.asrun.json`; without one the framework's own folder is used, which is
8
+ * how the framework runs against `examples/`.
9
+ */
10
+ export declare const ROOT: string;
11
+ /** True when `ROOT` is the framework folder itself, i.e. no workspace was found. */
12
+ export declare const isFrameworkRoot: () => boolean;
13
+ /** One saved login per profile: `.auth/<profile-id>.json`. */
14
+ export declare const authFile: (profileId: string) => string;
15
+ export declare const CASES_DIR: string;
16
+ export declare const TESTS_DIR: string;
17
+ export declare const REPORTS_DIR: string;
18
+ export declare const TMP_DIR: string;
19
+ export declare const TEMPLATES_DIR: string;
20
+ export declare const SKILLS_DIR: string;
21
+ /**
22
+ * The `node_modules/<package>` folder of the framework's own install, or the workspace's (a hoisted or linked
23
+ * install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use this.
24
+ */
25
+ export declare function packageDir(packageName: string): string;
@@ -0,0 +1,88 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /**
4
+ * Where the framework's own code and assets live: the package folder (`node_modules/@asrunjs/asrun`). The compiled files sit in
5
+ * `dist/lib`, so one level up from there is the package root again.
6
+ */
7
+ const MODULE_DIR = import.meta.dirname;
8
+ export const FRAMEWORK_ROOT = path.basename(path.dirname(MODULE_DIR)) === 'dist' ? path.resolve(MODULE_DIR, '..', '..') : path.resolve(MODULE_DIR, '..');
9
+ /**
10
+ * Reads a `.env` file into `process.env`. A variable that already exists (a real environment variable, or one from an
11
+ * earlier file) is never overridden. `ASRUN_ROOT` is resolved against the folder of the file, so `.` in
12
+ * `<workspace>/.env` means the workspace and `examples/demo` in the framework's `.env` means that folder.
13
+ */
14
+ function loadEnvFile(file) {
15
+ let text;
16
+ try {
17
+ text = fs.readFileSync(file, 'utf8');
18
+ }
19
+ catch {
20
+ return;
21
+ }
22
+ for (const raw of text.split(/\r?\n/)) {
23
+ const line = raw.trim();
24
+ if (line === '' || line.startsWith('#'))
25
+ continue;
26
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
27
+ if (!match)
28
+ continue;
29
+ const [, key, rawValue] = match;
30
+ if (process.env[key] !== undefined)
31
+ continue;
32
+ const value = rawValue.trim().replace(/^(['"])(.*)\1$/, '$2');
33
+ process.env[key] = key === 'ASRUN_ROOT' ? path.resolve(path.dirname(file), value) : value;
34
+ }
35
+ }
36
+ // The workspace can be chosen per machine without exporting anything: the `.env` next to the command, or the
37
+ // framework's own `.env` (that is how the framework itself develops against examples/).
38
+ loadEnvFile(path.join(process.cwd(), '.env'));
39
+ if (process.env.ASRUN_ROOT === undefined)
40
+ loadEnvFile(path.join(FRAMEWORK_ROOT, '.env'));
41
+ /** Files that mark the root of a workspace: the project that owns `profiles.yaml`, `cases/` and `tests/`. */
42
+ const WORKSPACE_MARKERS = ['.asrun.json', 'profiles.yaml'];
43
+ function findWorkspace(from) {
44
+ let dir = path.resolve(from);
45
+ for (;;) {
46
+ if (WORKSPACE_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker))))
47
+ return dir;
48
+ const parent = path.dirname(dir);
49
+ if (parent === dir)
50
+ return null;
51
+ dir = parent;
52
+ }
53
+ }
54
+ /**
55
+ * The workspace root: the project that uses the framework. Everything the framework reads or writes for a project
56
+ * (`profiles.yaml`, `cases/`, `tests/`, `.auth/`, `reports/`, `.tmp/`) is relative to it.
57
+ *
58
+ * `ASRUN_ROOT` chooses it explicitly. Otherwise it is the nearest folder at or above the current working
59
+ * directory that holds `profiles.yaml` or `.asrun.json`; without one the framework's own folder is used, which is
60
+ * how the framework runs against `examples/`.
61
+ */
62
+ export const ROOT = process.env.ASRUN_ROOT ? path.resolve(process.env.ASRUN_ROOT) : (findWorkspace(process.cwd()) ?? FRAMEWORK_ROOT);
63
+ // Children (Playwright, the web UI jobs) inherit the resolved absolute path: a relative ASRUN_ROOT would point
64
+ // somewhere else once the working directory changes.
65
+ process.env.ASRUN_ROOT = ROOT;
66
+ /** True when `ROOT` is the framework folder itself, i.e. no workspace was found. */
67
+ export const isFrameworkRoot = () => path.resolve(ROOT) === path.resolve(FRAMEWORK_ROOT);
68
+ /** One saved login per profile: `.auth/<profile-id>.json`. */
69
+ export const authFile = (profileId) => path.join(ROOT, '.auth', `${profileId}.json`);
70
+ export const CASES_DIR = path.join(ROOT, 'cases');
71
+ export const TESTS_DIR = path.join(ROOT, 'tests');
72
+ export const REPORTS_DIR = path.join(ROOT, 'reports');
73
+ export const TMP_DIR = path.join(ROOT, '.tmp');
74
+ export const TEMPLATES_DIR = path.join(FRAMEWORK_ROOT, 'templates');
75
+ export const SKILLS_DIR = path.join(FRAMEWORK_ROOT, '.claude', 'skills');
76
+ /**
77
+ * The `node_modules/<package>` folder of the framework's own install, or the workspace's (a hoisted or linked
78
+ * install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use this.
79
+ */
80
+ export function packageDir(packageName) {
81
+ // The package's own install, the pnpm virtual store that holds its dependencies, or a hoisted install in the workspace.
82
+ for (const base of [FRAMEWORK_ROOT, path.dirname(path.dirname(FRAMEWORK_ROOT)), ROOT]) {
83
+ const dir = path.join(base, 'node_modules', packageName);
84
+ if (fs.existsSync(dir))
85
+ return dir;
86
+ }
87
+ throw new Error(`Package "${packageName}" is not installed. Run "pnpm install" in the workspace root.`);
88
+ }
@@ -0,0 +1,71 @@
1
+ type PdfJs = typeof import('pdfjs-dist/legacy/build/pdf.mjs');
2
+ type PdfTask = ReturnType<PdfJs['getDocument']>;
3
+ type PdfDoc = Awaited<PdfTask['promise']>;
4
+ /** Which pages a check covers: all of them, the first, the last, or the listed 1-based page numbers. */
5
+ export type PageSelection = 'all' | 'first' | 'last' | number[];
6
+ /** A rectangle of the page in fractions (0 to 1) of its width and height, from the top-left corner. */
7
+ export interface Region {
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ }
13
+ export type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
14
+ /** A corner of the page. Defaults: 30% of the width and 12% of the height, enough for a logo and nothing else. */
15
+ export declare function corner(name: Corner, size?: {
16
+ width?: number;
17
+ height?: number;
18
+ }): Region;
19
+ export declare function selectPages(selection: PageSelection, pageCount: number): number[];
20
+ export interface RenderedRegion {
21
+ png: Buffer;
22
+ width: number;
23
+ height: number;
24
+ /** Share of pixels that are not (almost) white: 0 is a blank region. */
25
+ inkRatio: number;
26
+ }
27
+ export declare class PdfFile {
28
+ private readonly doc;
29
+ private readonly task;
30
+ readonly pageCount: number;
31
+ /** The text of each page, in reading order. */
32
+ readonly texts: string[];
33
+ constructor(doc: PdfDoc, task: PdfTask, pageCount: number,
34
+ /** The text of each page, in reading order. */
35
+ texts: string[]);
36
+ /** Renders one page (or a region of it) to a PNG. Scale 2 is about 150 dpi. */
37
+ render(pageNumber: number, region?: Region, scale?: number): Promise<RenderedRegion>;
38
+ close(): Promise<void>;
39
+ }
40
+ export declare function openPdf(bytes: Buffer): Promise<PdfFile>;
41
+ export interface PdfRegionCheck {
42
+ /** Short name of the check, used in messages and in the baseline file name (`<name>-p<page>.png`). Lower case words and dashes. */
43
+ name: string;
44
+ pages: PageSelection;
45
+ region: Region;
46
+ /** The region must show something (not blank). Default true. */
47
+ notBlank?: boolean;
48
+ /** Compare the region with the baseline image of the profile (2% tolerance). Default true. */
49
+ baseline?: boolean;
50
+ }
51
+ export interface PdfChecks {
52
+ regions?: PdfRegionCheck[];
53
+ /** Texts that must appear somewhere in the PDF (case-insensitive). */
54
+ textContains?: string[];
55
+ /** Texts that must not appear on any page (for example the names that belong to another profile). */
56
+ textNotContains?: string[];
57
+ /** At least this many pages. */
58
+ minPages?: number;
59
+ scale?: number;
60
+ }
61
+ /**
62
+ * The one way a spec checks the content of a downloaded PDF. For every region check it renders each selected page,
63
+ * fails when the region is blank, and compares the region with the profile's baseline image (the first run with
64
+ * `--update-snapshots` creates it; a person must look at it). Then it checks the extracted text.
65
+ * Every failure names the page and the check. Pair every call with `// target: T<n>` in the spec.
66
+ *
67
+ * const pdf = await openPdf(file.bytes);
68
+ * await expectPdf(pdf, {regions: [{name: 'logo', pages: 'first', region: corner('top-left')}], textNotContains: otherProfileNames});
69
+ */
70
+ export declare function expectPdf(pdf: PdfFile, checks: PdfChecks): Promise<void>;
71
+ export {};