@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,143 @@
1
+ // Reads the existing run reports (reports/<runId>/summary.json) for one case and prints what they already say,
2
+ // so that healing starts from the evidence instead of from a new run. It never opens a browser and never writes.
3
+ // Plain node (no tsx): it must work inside an agent's sandbox.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ // The workspace root: the project that uses the framework. Same rule as lib/paths.ts, in plain Node.
8
+ const findWorkspace = (from) => {
9
+ for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
10
+ if (fs.existsSync(path.join(dir, 'profiles.yaml')) || fs.existsSync(path.join(dir, '.asrun.json'))) return dir;
11
+ if (path.dirname(dir) === dir) return null;
12
+ }
13
+ };
14
+ // `.env` next to the command, or the framework's own `.env`; a real environment variable always wins.
15
+ const loadRootEnv = (file) => {
16
+ try {
17
+ for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
18
+ if (process.env.ASRUN_ROOT !== undefined) return;
19
+ const match = /^(?:export\s+)?ASRUN_ROOT\s*=\s*(.*)$/.exec(raw.trim());
20
+ if (match) {
21
+ process.env.ASRUN_ROOT = path.resolve(path.dirname(file), match[1].trim().replace(/^(['"])(.*)\1$/, '$2'));
22
+ }
23
+ }
24
+ } catch {
25
+ // no .env: nothing to do
26
+ }
27
+ };
28
+ loadRootEnv(path.join(process.cwd(), '.env'));
29
+ if (process.env.ASRUN_ROOT === undefined) loadRootEnv(path.join(path.resolve(import.meta.dirname, '..'), '.env'));
30
+ const ROOT = process.env.ASRUN_ROOT
31
+ ? path.resolve(process.env.ASRUN_ROOT)
32
+ : (findWorkspace(process.cwd()) ?? path.resolve(import.meta.dirname, '..'));
33
+ const REPORTS = path.join(ROOT, 'reports');
34
+ const RUN_ID = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(-[a-z0-9]+(-[a-z0-9]+)*)?$/;
35
+ const NOT_LOGGED_IN = /is not logged in to/;
36
+
37
+ const args = process.argv.slice(2);
38
+ const option = (name) => {
39
+ const index = args.indexOf(name);
40
+ return index >= 0 ? args[index + 1] : undefined;
41
+ };
42
+ const caseId = option('--case');
43
+ const env = option('--env');
44
+ const profileFilter = (option('--profile') ?? '').split(',').filter(Boolean);
45
+ const keep = Number(option('--max-reports') ?? 1);
46
+
47
+ if (args.includes('--help') || !caseId || !env) {
48
+ console.log('Usage: pnpm heal:evidence --case <case-id> --env <env> [--profile a,b] [--max-reports 1]');
49
+ process.exit(args.includes('--help') ? 0 : 1);
50
+ }
51
+
52
+ const readSummary = (id) => {
53
+ try {
54
+ return JSON.parse(fs.readFileSync(path.join(REPORTS, id, 'summary.json'), 'utf8'));
55
+ } catch {
56
+ return null;
57
+ }
58
+ };
59
+ const mtime = (file) => (fs.existsSync(file) ? fs.statSync(file).mtimeMs : 0);
60
+ const iso = (ms) => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');
61
+ const firstLines = (text, count) => text.split('\n').slice(0, count).join('\n');
62
+ const indent = (text, prefix) => text.split('\n').map((line) => prefix + line).join('\n');
63
+
64
+ const runIds = fs.existsSync(REPORTS)
65
+ ? fs.readdirSync(REPORTS).filter((name) => RUN_ID.test(name)).sort().reverse()
66
+ : [];
67
+
68
+ const matches = [];
69
+ for (const id of runIds) {
70
+ const summary = readSummary(id);
71
+ if (!summary) continue;
72
+ const runEnv = summary.env ?? /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)$/.exec(id)?.[1] ?? 'production';
73
+ if (runEnv !== env) continue;
74
+ const results = summary.results.filter(
75
+ (result) => result.caseId === caseId && (profileFilter.length === 0 || profileFilter.includes(result.profile))
76
+ );
77
+ if (results.length === 0) continue;
78
+ matches.push({id, summary, results});
79
+ if (matches.length >= keep) break;
80
+ }
81
+
82
+ console.log(`Heal evidence for case "${caseId}" in ${env}`);
83
+
84
+ if (matches.length === 0) {
85
+ console.log(`\nNO REPORT: no run in ${env} has results for this case${profileFilter.length ? ` and these profiles (${profileFilter})` : ''}.`);
86
+ console.log('EVIDENCE: none -> run the case once: pnpm asrun --env ' + env + ' --logged-in --grep ' + caseId);
87
+ process.exit(0);
88
+ }
89
+
90
+ const watched = [
91
+ ['spec', path.join(ROOT, 'tests', `${caseId}.spec.ts`)],
92
+ ['case', path.join(ROOT, 'cases', `${caseId}.md`)],
93
+ ['profiles.yaml', path.join(ROOT, 'profiles.yaml')],
94
+ ];
95
+
96
+ let newest = null;
97
+ for (const {id, summary, results} of matches) {
98
+ const endedAt = Date.parse(summary.startedAt) + summary.durationMs;
99
+ const changed = watched
100
+ .map(([label, file]) => ({label, at: mtime(file)}))
101
+ .filter((item) => item.at > endedAt);
102
+ newest ??= {changed};
103
+
104
+ console.log(`\n=== Report ${id} (${summary.startedAt}) ===`);
105
+ console.log(`report: reports/${id}/summary.json html: reports/${id}/html`);
106
+ if (changed.length > 0) {
107
+ console.log(`STALE: changed after this run: ${changed.map((item) => `${item.label} (${iso(item.at)})`).join(', ')}`);
108
+ } else {
109
+ console.log('FRESH: the spec, the case and profiles.yaml have not changed since this run.');
110
+ }
111
+ if (summary.errors?.length) {
112
+ console.log('Run-level errors (not tied to a test):');
113
+ for (const error of summary.errors) console.log(indent(error.headline, ' - '));
114
+ }
115
+
116
+ for (const result of results) {
117
+ const loginProblem = result.errors.some((error) => NOT_LOGGED_IN.test(error.headline));
118
+ console.log(`\n[${result.profile}] ${result.status.toUpperCase()} (${result.attempts} attempt${result.attempts === 1 ? '' : 's'}, ${result.durationMs} ms)`);
119
+ if (result.status === 'passed' || result.status === 'skipped') continue;
120
+ if (loginProblem) {
121
+ console.log(' Not a test failure: the profile was not logged in during this run. Not evidence about the spec.');
122
+ continue;
123
+ }
124
+ if (result.failedStep) console.log(` failed step: ${result.failedStep}`);
125
+ for (const error of result.errors) {
126
+ console.log(indent(error.headline, ' error: '));
127
+ if (error.context) console.log(indent(firstLines(error.context, 8), ' '));
128
+ if (error.callLog) console.log(indent(firstLines(error.callLog, 12), ' log: '));
129
+ if (error.snippet) console.log(indent(firstLines(error.snippet, 8), ' code: '));
130
+ }
131
+ for (const file of result.screenshots) console.log(` screenshot: reports/${id}/html/${file}`);
132
+ for (const file of result.videos) console.log(` video: reports/${id}/html/${file}`);
133
+ }
134
+ }
135
+
136
+ const usable = matches[0].results.filter(
137
+ (result) =>
138
+ (result.status === 'failed' || result.status === 'flaky') &&
139
+ !result.errors.some((error) => NOT_LOGGED_IN.test(error.headline))
140
+ );
141
+ const state = newest.changed.length > 0 ? 'stale' : usable.length === 0 ? 'no-failure' : 'fresh';
142
+ console.log(`\nEVIDENCE: ${state}`);
143
+ if (usable.length > 0) console.log(`failing profiles in the newest report: ${usable.map((result) => result.profile).join(',')}`);
@@ -0,0 +1,22 @@
1
+ /** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
2
+ export declare const GENERATED: {
3
+ file: string;
4
+ template: string;
5
+ }[];
6
+ /** How the workspace reaches the framework: `./node_modules/@asrunjs/asrun`, or a relative folder in the framework repo. */
7
+ export declare function frameworkPath(root: string): string;
8
+ /** True when this workspace has the package installed (or is the framework's own checkout). */
9
+ export declare function isInstalled(root: string): boolean;
10
+ /** Fills the `{{framework}}` and `{{playwright}}` placeholders of a template. */
11
+ export declare function renderTemplate(name: string, root: string): string;
12
+ /**
13
+ * The Playwright CLI the MCP server runs. The workspace's own install is the normal place; a workspace that lives
14
+ * inside the framework repository (examples/demo) has no `node_modules` of its own, so it points at the framework's.
15
+ * Kept relative: the file is committed and must not name a machine.
16
+ */
17
+ export declare function playwrightBin(root: string): string;
18
+ export declare function frameworkVersion(): {
19
+ version: string;
20
+ commit: string;
21
+ };
22
+ export declare function main(args: string[]): Promise<number>;
@@ -0,0 +1,332 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { FRAMEWORK_ROOT, TEMPLATES_DIR } from '../lib/paths.js';
5
+ /**
6
+ * Creates the files a workspace needs. It is safe to run many times: it never overwrites a file the project owns, and
7
+ * `--update` refreshes only the files generated from the framework templates.
8
+ */
9
+ const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force <path>] [--no-env] [--install [spec]]
10
+
11
+ --root <dir> the workspace to initialize (default: the current folder)
12
+ --update refresh the generated files (playwright.config.ts, tests/seed.spec.ts, .claude/commands/*,
13
+ .opencode/commands/*)
14
+ --force <path> overwrite one file this tool writes, e.g. --force playwright.config.ts (may be repeated)
15
+ --reset-rules allow --force on AGENTS.md, CLAUDE.md or .claude/skills/** (project-owned rules)
16
+ --no-env do not write .env (ASRUN_ROOT)
17
+ --install [spec] also add the dependency (default "asrun"; e.g. --install github:wizcas/asrun#v0.1.0)`;
18
+ /** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
19
+ export const GENERATED = [
20
+ { file: 'playwright.config.ts', template: 'playwright.config.ts' },
21
+ { file: 'tests/seed.spec.ts', template: 'seed.spec.ts' },
22
+ { file: '.claude/commands/make-case.md', template: '.claude/commands/make-case.md' },
23
+ { file: '.claude/commands/explore-case.md', template: '.claude/commands/explore-case.md' },
24
+ { file: '.claude/commands/gen-spec.md', template: '.claude/commands/gen-spec.md' },
25
+ { file: '.claude/commands/heal-spec.md', template: '.claude/commands/heal-spec.md' },
26
+ // OpenCode does not read `.claude/commands` (only `.claude/skills`), so the same commands live here too.
27
+ { file: '.opencode/commands/make-case.md', template: '.claude/commands/make-case.md' },
28
+ { file: '.opencode/commands/explore-case.md', template: '.claude/commands/explore-case.md' },
29
+ { file: '.opencode/commands/gen-spec.md', template: '.claude/commands/gen-spec.md' },
30
+ { file: '.opencode/commands/heal-spec.md', template: '.claude/commands/heal-spec.md' },
31
+ ];
32
+ /** Files `init` creates once; after that the project owns them and may edit them freely. */
33
+ const ONCE = [
34
+ { file: 'AGENTS.md', template: 'AGENTS.md' },
35
+ { file: 'CLAUDE.md', template: 'CLAUDE.md' },
36
+ { file: '.mcp.json', template: '.mcp.json' },
37
+ { file: 'opencode.jsonc', template: 'opencode.jsonc' },
38
+ { file: '.claude/settings.json', template: '.claude/settings.json' },
39
+ { file: 'tsconfig.json', template: 'tsconfig.json' },
40
+ { file: 'pnpm-workspace.yaml', template: 'pnpm-workspace.yaml' },
41
+ { file: 'profiles.yaml', template: 'profiles.yaml' },
42
+ { file: 'cases/_template.md', template: 'cases/_template.md' },
43
+ { file: 'package.json', template: 'package.json' },
44
+ ];
45
+ // npm never packs a `.gitignore`, so the template is named `gitignore`; init writes it as `.gitignore`.
46
+ const GITIGNORE_BLOCK = fs.readFileSync(path.join(TEMPLATES_DIR, 'gitignore'), 'utf8').trimEnd();
47
+ const MARKER = '.asrun.json';
48
+ const DOTENV_HEADER = '# asrun: the workspace these commands run against (relative to this file).';
49
+ const PACKAGE = '@asrunjs/asrun';
50
+ function readTemplate(name) {
51
+ return fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8');
52
+ }
53
+ /** True in the framework's own repository (not an installed package). */
54
+ function isRepoCheckout() {
55
+ return !FRAMEWORK_ROOT.split(path.sep).includes('node_modules') && fs.existsSync(path.join(FRAMEWORK_ROOT, 'examples', 'demo'));
56
+ }
57
+ /** How the workspace reaches the framework: `./node_modules/@asrunjs/asrun`, or a relative folder in the framework repo. */
58
+ export function frameworkPath(root) {
59
+ if (!isRepoCheckout()) {
60
+ // The standard install location, or a hoisted one in a monorepo.
61
+ for (let dir = path.resolve(root);; dir = path.dirname(dir)) {
62
+ const candidate = path.join(dir, 'node_modules', PACKAGE);
63
+ if (fs.existsSync(path.join(candidate, 'package.json'))) {
64
+ const relative = path.relative(root, candidate).split(path.sep).join('/');
65
+ return relative.startsWith('.') ? relative : `./${relative}`;
66
+ }
67
+ if (path.dirname(dir) === dir)
68
+ break;
69
+ }
70
+ // Not installed yet (a scaffolding run): name the place the package will live.
71
+ return `./node_modules/${PACKAGE}`;
72
+ }
73
+ const relative = path.relative(root, FRAMEWORK_ROOT).split(path.sep).join('/');
74
+ if (relative === '')
75
+ return '.';
76
+ return relative.startsWith('.') ? relative : `./${relative}`;
77
+ }
78
+ /** True when this workspace has the package installed (or is the framework's own checkout). */
79
+ export function isInstalled(root) {
80
+ return fs.existsSync(path.join(path.resolve(root, frameworkPath(root)), 'package.json'));
81
+ }
82
+ /** Fills the `{{framework}}` and `{{playwright}}` placeholders of a template. */
83
+ export function renderTemplate(name, root) {
84
+ return readTemplate(name)
85
+ .replaceAll('{{framework}}', frameworkPath(root))
86
+ .replaceAll('{{playwright}}', playwrightBin(root));
87
+ }
88
+ /**
89
+ * The Playwright CLI the MCP server runs. The workspace's own install is the normal place; a workspace that lives
90
+ * inside the framework repository (examples/demo) has no `node_modules` of its own, so it points at the framework's.
91
+ * Kept relative: the file is committed and must not name a machine.
92
+ */
93
+ export function playwrightBin(root) {
94
+ if (fs.existsSync(path.join(root, 'node_modules', '.bin', 'playwright')))
95
+ return './node_modules/.bin/playwright';
96
+ const framework = frameworkPath(root);
97
+ return framework.startsWith('..') ? `${framework}/node_modules/.bin/playwright` : './node_modules/.bin/playwright';
98
+ }
99
+ export function frameworkVersion() {
100
+ let version = '0.0.0';
101
+ try {
102
+ version = JSON.parse(fs.readFileSync(path.join(FRAMEWORK_ROOT, 'package.json'), 'utf8')).version ?? version;
103
+ }
104
+ catch {
105
+ // keep the default
106
+ }
107
+ let commit = 'unknown';
108
+ try {
109
+ commit = spawnSync('git', ['-C', FRAMEWORK_ROOT, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).stdout.trim() || 'unknown';
110
+ }
111
+ catch {
112
+ // keep the default
113
+ }
114
+ return { version, commit };
115
+ }
116
+ /** Merges the template's npm scripts and devDependencies into an existing package.json (never overwrites). */
117
+ function mergePackageJson(root) {
118
+ const file = path.join(root, 'package.json');
119
+ if (!fs.existsSync(file))
120
+ return [];
121
+ const current = JSON.parse(fs.readFileSync(file, 'utf8'));
122
+ const template = JSON.parse(readTemplate('package.json'));
123
+ const added = [];
124
+ current.scripts ??= {};
125
+ for (const [name, command] of Object.entries(template.scripts)) {
126
+ if (current.scripts[name] === undefined) {
127
+ current.scripts[name] = command;
128
+ added.push(`scripts.${name}`);
129
+ }
130
+ }
131
+ current.devDependencies ??= {};
132
+ for (const [name, range] of Object.entries(template.devDependencies)) {
133
+ if (current.devDependencies[name] === undefined) {
134
+ current.devDependencies[name] = range;
135
+ added.push(`devDependencies.${name}`);
136
+ }
137
+ }
138
+ if (added.length > 0)
139
+ fs.writeFileSync(file, JSON.stringify(current, null, 2) + '\n');
140
+ return added;
141
+ }
142
+ /** Adds the ignore block, or refreshes the one that is already there. */
143
+ function ensureGitignore(root, update) {
144
+ const file = path.join(root, '.gitignore');
145
+ if (!fs.existsSync(file)) {
146
+ fs.writeFileSync(file, GITIGNORE_BLOCK + '\n');
147
+ return 'created';
148
+ }
149
+ const text = fs.readFileSync(file, 'utf8');
150
+ const existing = /# >>> asrun[\s\S]*?# <<< asrun/;
151
+ if (!existing.test(text)) {
152
+ fs.writeFileSync(file, text.replace(/\n*$/, '\n\n') + GITIGNORE_BLOCK + '\n');
153
+ return 'appended';
154
+ }
155
+ if (!update)
156
+ return 'kept';
157
+ fs.writeFileSync(file, text.replace(existing, GITIGNORE_BLOCK));
158
+ return 'updated';
159
+ }
160
+ /** Points ASRUN_ROOT at the workspace, in the `.env` of the folder the commands run in. Keeps every other line. */
161
+ function ensureDotEnv(dir, workspaceRoot) {
162
+ const file = path.join(dir, '.env');
163
+ const relative = path.relative(dir, workspaceRoot).split(path.sep).join('/') || '.';
164
+ const line = `ASRUN_ROOT=${relative}`;
165
+ if (!fs.existsSync(file)) {
166
+ fs.writeFileSync(file, `${DOTENV_HEADER}\n${line}\n`);
167
+ return 'created';
168
+ }
169
+ const text = fs.readFileSync(file, 'utf8');
170
+ const cleaned = text.replace(/^#\s*asrun: the workspace.*$/m, DOTENV_HEADER);
171
+ const pattern = /^ASRUN_ROOT\s*=/m;
172
+ if (pattern.test(cleaned)) {
173
+ const next = cleaned.replace(/^ASRUN_ROOT\s*=.*$/m, line);
174
+ if (next === text)
175
+ return 'kept';
176
+ fs.writeFileSync(file, next);
177
+ return 'updated';
178
+ }
179
+ fs.writeFileSync(file, cleaned.replace(/\n*$/, '\n') + `\n${DOTENV_HEADER}\n${line}\n`);
180
+ return 'updated';
181
+ }
182
+ function readManifest(root) {
183
+ for (const name of [MARKER]) {
184
+ try {
185
+ return JSON.parse(fs.readFileSync(path.join(root, name), 'utf8'));
186
+ }
187
+ catch {
188
+ // try the next name
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+ export async function main(args) {
194
+ if (args.includes('--help')) {
195
+ console.log(USAGE);
196
+ return 0;
197
+ }
198
+ const value = (name) => {
199
+ const index = args.indexOf(name);
200
+ return index >= 0 ? args[index + 1] : undefined;
201
+ };
202
+ const root = path.resolve(value('--root') ?? process.cwd());
203
+ const update = args.includes('--update');
204
+ const installAt = args.indexOf('--install');
205
+ const install = installAt >= 0;
206
+ const installSpec = install && args[installAt + 1] && !args[installAt + 1].startsWith('-') ? args[installAt + 1] : PACKAGE;
207
+ const forced = args
208
+ .flatMap((arg, index) => (arg === '--force' && args[index + 1] ? [args[index + 1]] : []))
209
+ .map((file) => file.split('\\').join('/'));
210
+ const resetRules = args.includes('--reset-rules');
211
+ const skipEnv = args.includes('--no-env');
212
+ if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
213
+ console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun init" inside the project that uses ' +
214
+ 'asrun, or pass --root <dir> (the framework develops against examples/demo).');
215
+ return 1;
216
+ }
217
+ const firstRun = !fs.existsSync(path.join(root, MARKER));
218
+ const written = [];
219
+ const kept = [];
220
+ const notes = [];
221
+ const framework = frameworkPath(root);
222
+ // `--force` repairs a file this tool writes. A project's own rules are never overwritten by a framework update:
223
+ // `AGENTS.md`, `CLAUDE.md` and `.claude/skills/**` need `--reset-rules` on top.
224
+ const owned = [...GENERATED, ...ONCE].map((entry) => entry.file);
225
+ const projectRules = (file) => file === 'AGENTS.md' || file === 'CLAUDE.md' || file === '.claude/skills' || file.startsWith('.claude/skills/');
226
+ for (const file of forced) {
227
+ if (!owned.includes(file)) {
228
+ console.error(`--force ${file}: init only writes the files it owns (${owned.join(', ')}).`);
229
+ return 1;
230
+ }
231
+ if (projectRules(file) && !resetRules) {
232
+ console.error(`--force ${file}: this file belongs to the project. Add --reset-rules to replace it with the template.`);
233
+ return 1;
234
+ }
235
+ }
236
+ const place = (entry, overwrite) => {
237
+ const target = path.join(root, entry.file);
238
+ if (fs.existsSync(target) && !overwrite) {
239
+ kept.push(entry.file);
240
+ return;
241
+ }
242
+ fs.mkdirSync(path.dirname(target), { recursive: true });
243
+ fs.writeFileSync(target, renderTemplate(entry.template, root));
244
+ written.push(entry.file);
245
+ };
246
+ for (const entry of GENERATED)
247
+ place(entry, update || forced.includes(entry.file));
248
+ for (const entry of ONCE) {
249
+ // The demo workspace lives inside the framework repository: it has no package manager settings of its own.
250
+ if (framework.startsWith('..') && (entry.file === 'pnpm-workspace.yaml' || entry.file === 'package.json'))
251
+ continue;
252
+ place(entry, forced.includes(entry.file));
253
+ }
254
+ const gitignore = ensureGitignore(root, update);
255
+ const merged = mergePackageJson(root);
256
+ const dotenv = skipEnv ? 'skipped' : ensureDotEnv(process.cwd(), root);
257
+ if (fs.existsSync(path.join(root, 'tsconfig.json')) && !written.includes('tsconfig.json')) {
258
+ const tsconfig = fs.readFileSync(path.join(root, 'tsconfig.json'), 'utf8');
259
+ if (tsconfig.includes('@asrun/')) {
260
+ notes.push('Specs import the framework by package name now ("@asrunjs/asrun/fixtures"); the old "@..." path alias in tsconfig.json can go.');
261
+ }
262
+ }
263
+ if (fs.existsSync(path.join(root, 'pnpm-workspace.yaml')) && !written.includes('pnpm-workspace.yaml')) {
264
+ const workspace = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8');
265
+ if (!workspace.includes('allowBuilds')) {
266
+ notes.push('Add "allowBuilds: {esbuild: true, ffmpeg-static: true}" to pnpm-workspace.yaml, or pnpm skips their build scripts.');
267
+ }
268
+ if (/^packages\s*:/m.test(workspace)) {
269
+ notes.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
270
+ }
271
+ }
272
+ if (fs.existsSync(path.join(root, '.mcp.json')) && !fs.readFileSync(path.join(root, '.mcp.json'), 'utf8').includes('playwright-test')) {
273
+ notes.push('Add the "playwright-test" MCP server to .mcp.json (see templates/.mcp.json).');
274
+ }
275
+ if (fs.existsSync(path.join(root, 'opencode.jsonc')) && !fs.readFileSync(path.join(root, 'opencode.jsonc'), 'utf8').includes('playwright-test')) {
276
+ notes.push('Add the "playwright-test" MCP server to opencode.jsonc (see templates/opencode.jsonc).');
277
+ }
278
+ if (fs.existsSync(path.join(root, 'opencode.jsonc'))) {
279
+ const text = fs.readFileSync(path.join(root, 'opencode.jsonc'), 'utf8');
280
+ // A commented-out "model" (the template has one) does not set anything: only a real key counts.
281
+ const withoutComments = text.replace(/^\s*\/\/.*$/gm, '');
282
+ if (!/["']?model["']?\s*:/.test(withoutComments)) {
283
+ notes.push('opencode.jsonc sets no "model": a one-shot "opencode run" then uses OpenCode\'s built-in free model, which has a ' +
284
+ 'daily usage limit. Pick a model in the AsRun web UI (top bar), or set "model" there.');
285
+ }
286
+ }
287
+ if (fs.existsSync(path.join(root, '.claude', 'settings.json'))) {
288
+ const settings = fs.readFileSync(path.join(root, '.claude', 'settings.json'), 'utf8');
289
+ if (!settings.includes('pnpm asrun')) {
290
+ notes.push('Add "pnpm asrun *" to sandbox.excludedCommands in .claude/settings.json (see templates/.claude/settings.json).');
291
+ }
292
+ }
293
+ // Install last, so pnpm-workspace.yaml (the build-script approval) already exists. Its absence is the reason
294
+ // "pnpm add -D @asrunjs/asrun" fails on pnpm 12 with ERR_PNPM_IGNORED_BUILDS.
295
+ const installed = isInstalled(root);
296
+ if (install && !installed) {
297
+ if (spawnSync('pnpm', ['add', '-D', installSpec], { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' }).status !== 0) {
298
+ return 1;
299
+ }
300
+ }
301
+ const version = frameworkVersion();
302
+ fs.writeFileSync(path.join(root, MARKER), JSON.stringify({
303
+ framework: PACKAGE,
304
+ frameworkPath: framework,
305
+ version: version.version,
306
+ commit: version.commit,
307
+ generated: GENERATED.map((entry) => entry.file),
308
+ updatedAt: new Date().toISOString(),
309
+ }, null, 2) + '\n');
310
+ const label = firstRun ? 'Initialized' : update ? 'Refreshed' : 'Checked';
311
+ console.log(`${label} workspace: ${root}`);
312
+ console.log(` framework: ${PACKAGE} ${version.version} at ${framework}`);
313
+ if (written.length > 0)
314
+ console.log(` written: ${written.join(', ')}`);
315
+ if (kept.length > 0)
316
+ console.log(` kept: ${kept.join(', ')}`);
317
+ if (merged.length > 0)
318
+ console.log(` merged: package.json -> ${merged.join(', ')}`);
319
+ if (gitignore !== 'kept')
320
+ console.log(` .gitignore: ${gitignore}`);
321
+ if (dotenv !== 'skipped')
322
+ console.log(` .env: ${dotenv} (ASRUN_ROOT in ${process.cwd()})`);
323
+ for (const note of notes)
324
+ console.log(`\n ! ${note}`);
325
+ if (!installed && !install) {
326
+ console.log(`\n ! ${PACKAGE} is not installed yet. Next: pnpm add -D ${installSpec}`);
327
+ }
328
+ if (firstRun) {
329
+ console.log('\nNext: edit profiles.yaml, run "pnpm install", then "pnpm asrun doctor" and "pnpm signin <profile-id>".');
330
+ }
331
+ return 0;
332
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,141 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { listCaseFileIds } from '../lib/cases.js';
4
+ import { TESTS_DIR } from '../lib/paths.js';
5
+ import { parseTargets, readCaseText, TARGET_REF } from '../lib/targets.js';
6
+ const specFiles = (dir) => fs.existsSync(dir)
7
+ ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
8
+ const full = path.join(dir, entry.name);
9
+ if (entry.isDirectory())
10
+ return entry.name.endsWith('-snapshots') ? [] : specFiles(full);
11
+ return entry.name.endsWith('.spec.ts') && entry.name !== 'seed.spec.ts' ? [full] : [];
12
+ })
13
+ : [];
14
+ const IMAGE_WORDS = /\b(logo|image|img|icon|avatar|screenshot|snapshot)\b/i;
15
+ const TARGET_COMMENT = /\/\/\s*target:\s*(T\d+)/;
16
+ const near = (lines, index, back = 4) => lines.slice(Math.max(0, index - back), index + 1).join('\n');
17
+ const withoutStringsAndComments = (line) => line
18
+ .replace(/\/\/.*$/, '')
19
+ .replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/g, '""');
20
+ /**
21
+ * Marks every line that is inside the callback of `expect(async () => {...}).toPass(...)`. The callback is retried until it
22
+ * passes, so a single `isVisible()` look in it is not a one-look decision. `toPass` comes after the closing brace, so the
23
+ * block is found by counting braces from the `expect(async () => {` line.
24
+ */
25
+ function linesInsideToPass(lines) {
26
+ const inside = new Array(lines.length).fill(false);
27
+ const clean = lines.map(withoutStringsAndComments);
28
+ for (let start = 0; start < lines.length; start++) {
29
+ if (!/\bexpect\(\s*async\s*\(\s*\)\s*=>\s*\{/.test(clean[start]))
30
+ continue;
31
+ let depth = 0;
32
+ let opened = false;
33
+ for (let end = start; end < lines.length; end++) {
34
+ const row = clean[end];
35
+ for (let col = end === start ? row.search(/\bexpect\(\s*async/) : 0; col < row.length; col++) {
36
+ if (row[col] === '{') {
37
+ depth++;
38
+ opened = true;
39
+ }
40
+ else if (row[col] === '}') {
41
+ depth--;
42
+ if (opened && depth === 0) {
43
+ const after = row.slice(col) + (clean[end + 1] ?? '');
44
+ if (/^\}\s*\)\s*\.toPass\s*\(/.test(after))
45
+ for (let line = start; line <= end; line++)
46
+ inside[line] = true;
47
+ col = row.length;
48
+ end = lines.length;
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ return inside;
55
+ }
56
+ function lint(file) {
57
+ const findings = [];
58
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
59
+ const insideToPass = linesInsideToPass(lines);
60
+ const rel = path.relative(path.join(TESTS_DIR, '..'), file);
61
+ const add = (level, index, rule, message) => findings.push({ level, file: rel, line: index + 1, rule, message });
62
+ const caseId = /^\/\/ case: cases\/(.+)\.md/m.exec(lines.join('\n'))?.[1];
63
+ const cards = caseId && listCaseFileIds().includes(caseId) ? parseTargets(readCaseText(caseId)) : [];
64
+ const cardIds = new Set(cards.map((card) => card.id));
65
+ lines.forEach((line, i) => {
66
+ const code = line.replace(/\/\/.*$/, '');
67
+ const target = TARGET_COMMENT.exec(near(lines, i));
68
+ if (/\bwaitForTimeout\s*\(/.test(code))
69
+ add('fail', i, 'no-sleep', 'waitForTimeout waits for time, not for the page. Wait for the request (actAndWait) or for a visible signal.');
70
+ if (/networkidle/.test(code))
71
+ add('fail', i, 'no-networkidle', 'networkidle is not a readiness signal. Wait for the key request or for the content (actAndWait).');
72
+ if (/waitForEvent\(\s*['"]download['"]/.test(code) || /\.on\(\s*['"]download['"]/.test(code)) {
73
+ add('fail', i, 'download', 'Verify downloads with verifyDownload() from lib/downloads: start, finish, file exists, file valid.');
74
+ }
75
+ if (/kind:\s*['"]pdf['"]/.test(code) && !/expectPdf\s*\(/.test(lines.join('\n'))) {
76
+ add('warn', i, 'pdf-content', 'A PDF download is opened but its content is never checked. Render the pages and check the text with expectPdf() from lib/pdf.');
77
+ }
78
+ if (/expectPdf\s*\(/.test(code) && !target) {
79
+ add('fail', i, 'pdf-target', 'expectPdf needs "// target: T<n>" (a card in the case\'s "## Targets": which pages, which region, what must be there).');
80
+ }
81
+ if (/pages:\s*(['"](?:first|last)['"]|\[)/.test(code) && !/\/\/\s*pages:\s*\S/.test(near(lines, i, 3) + ' ' + line)) {
82
+ add('fail', i, 'pdf-pages', 'Checking only some pages of a PDF needs "// pages: <the case\'s words>" with the strong requirement (only / exactly / must). Otherwise check every page (pages: \'all\').');
83
+ }
84
+ if (/%PDF/.test(code) && !/lib\/downloads/.test(code))
85
+ add('fail', i, 'download', 'A %PDF magic-number check alone is not a download verification. Use verifyDownload(page, trigger, {kind: "pdf"}).');
86
+ if (/toHaveScreenshot\s*\(/.test(code)) {
87
+ if (!target)
88
+ add('fail', i, 'screenshot-target', 'A screenshot check needs "// target: T<n>" (a card in the case\'s "## Targets": what is shown, where it is, how it is found).');
89
+ }
90
+ const imageCheck = /toHaveScreenshot|expectImageLoaded|expectLogo|expectImageSha256|naturalWidth/.test(code);
91
+ const genericImage = /locator\(\s*['"`](?:img|svg|img,\s*svg|svg,\s*img|img\[alt\][^'"`]*)['"`]\s*\)/.test(code);
92
+ const pickedByPosition = /\.(first|last)\(\)|\.nth\(/.test(code);
93
+ if ((imageCheck || IMAGE_WORDS.test(code)) && genericImage && !target) {
94
+ add('fail', i, 'generic-image-locator', 'A bare img/svg locator can match any image on the page (a content image, an avatar). Locate the exact element by its role, name or test id and mark it "// target: T<n>".');
95
+ }
96
+ if (pickedByPosition && IMAGE_WORDS.test(code) && /\b(img|svg|logo|image)\b/i.test(code) && !target) {
97
+ add('fail', i, 'position-pick', '.first()/.last()/.nth() on an image or logo picks by position, not by identity. Name the exact element and mark it "// target: T<n>".');
98
+ }
99
+ if (/\.(isVisible|isHidden)\(\s*\{/.test(code)) {
100
+ add('fail', i, 'isvisible-timeout', 'isVisible()/isHidden() do not wait and ignore their timeout. Use expect(...).toBeVisible({timeout}), or for an optional confirmation clickOptionalUntilHidden() from lib/ready.');
101
+ }
102
+ else if (/\.isVisible\(\s*\)/.test(code) && /\bif\s*\(/.test(code)) {
103
+ const insideRetry = insideToPass[i];
104
+ if (/\.click\(/.test(code) && !insideRetry) {
105
+ add('fail', i, 'isvisible-click', 'if (await x.isVisible()) await x.click() decides from one look: a dialog that shows up a moment later is skipped. Use clickOptionalUntilHidden() from lib/ready, or put it inside expect(...).toPass().');
106
+ }
107
+ else if (!insideRetry && !/ready:/.test(near(lines, i, 6))) {
108
+ add('fail', i, 'isvisible-now', 'if (await x.isVisible()) checks only this instant, before the content may have loaded. Wait for a ready signal and mark it "// ready: <signal>", or move the check into expect(...).toPass().');
109
+ }
110
+ }
111
+ if (/\.catch\(\s*\(\)\s*=>\s*(false|null|undefined)\s*\)/.test(code) && /\b(isVisible|waitFor|toBeVisible)\b/.test(code)) {
112
+ add('warn', i, 'swallowed-wait', '.catch(() => false) turns a failed wait into "not there". For something that may not appear, use clickOptionalUntilHidden() or wait for a signal that proves the page finished.');
113
+ }
114
+ if (/Not verified|not verified on the page|unverified/i.test(line)) {
115
+ add('warn', i, 'unverified', 'This check was written without seeing it on the page. Say so in the report; confirm it by running /explore-case before trusting it.');
116
+ }
117
+ const absence = /toHaveCount\(\s*0\s*\)|\.not\.toBeVisible|\.not\.toBeAttached|toBeEmpty\(\)|toBeHidden/.test(code);
118
+ if (absence && /missing|empty|no |not (shown|found|listed)|without/i.test(line) && !/ready:/.test(near(lines, i, 6))) {
119
+ add('warn', i, 'absence-without-ready', 'An absence check passes while the page is still loading. Add a positive ready signal before it and mark it "// ready: <signal>".');
120
+ }
121
+ for (const match of line.matchAll(/\/\/\s*target:\s*(T\d+)/g)) {
122
+ if (!cardIds.has(match[1]))
123
+ add('fail', i, 'unknown-target', `${match[1]} is not a card in "## Targets" of ${caseId ?? 'the case'}.`);
124
+ else if (cards.find((card) => card.id === match[1])?.blocking)
125
+ add('fail', i, 'blocking-target', `${match[1]} still has an open [blocking] question in the case; do not build a check on it (use test.fixme).`);
126
+ }
127
+ });
128
+ TARGET_REF.lastIndex = 0;
129
+ return findings;
130
+ }
131
+ // A case id is written with `/` (`conduit/header-navigation`), while a path on Windows has `\`: without the
132
+ // normalization this filter matched nothing and reported "0 spec(s)" for a spec that was right there.
133
+ const casePath = (file) => file.replace(/\\/g, '/');
134
+ const wanted = process.argv.slice(2).filter((arg) => !arg.startsWith('-'));
135
+ const files = specFiles(TESTS_DIR).filter((file) => wanted.length === 0 || wanted.some((id) => casePath(file).endsWith(`${casePath(id)}.spec.ts`)));
136
+ const findings = files.flatMap(lint);
137
+ for (const f of findings)
138
+ console.log(`${f.level.toUpperCase().padEnd(4)} ${f.file}:${f.line} [${f.rule}] ${f.message}`);
139
+ const fails = findings.filter((f) => f.level === 'fail').length;
140
+ console.log(`\nspec lint: ${files.length} spec(s), ${fails} failure(s), ${findings.length - fails} warning(s)`);
141
+ process.exit(fails > 0 ? 1 : 0);
@@ -0,0 +1 @@
1
+ export {};