@asrunjs/asrun 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/.claude/commands/explore-case.md +15 -0
  2. package/.claude/commands/gen-spec.md +13 -0
  3. package/.claude/commands/heal-spec.md +23 -0
  4. package/.claude/commands/make-case.md +8 -0
  5. package/.claude/settings.json +5 -0
  6. package/.claude/skills/exploring-cases/SKILL.md +132 -0
  7. package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -0
  8. package/.claude/skills/exploring-cases/references/handoff-card.js +128 -0
  9. package/.claude/skills/exploring-cases/references/open-questions.md +112 -0
  10. package/.claude/skills/exploring-cases/references/readiness.md +137 -0
  11. package/.claude/skills/exploring-cases/references/user-handoff.md +147 -0
  12. package/.claude/skills/generating-specs/SKILL.md +207 -0
  13. package/.claude/skills/generating-specs/references/conventions.md +148 -0
  14. package/.claude/skills/making-cases/SKILL.md +64 -0
  15. package/.claude/skills/making-cases/references/gaps.md +36 -0
  16. package/.claude/skills/making-cases/references/normalizing.md +38 -0
  17. package/.claude/skills/making-cases/references/splitting.md +28 -0
  18. package/.claude/skills/making-cases/references/writes-and-email.md +36 -0
  19. package/AGENTS.md +154 -0
  20. package/CLAUDE.md +1 -0
  21. package/LICENSE +21 -0
  22. package/README.md +410 -3
  23. package/bin/asrun.mjs +49 -0
  24. package/dist/lib/account-lock.d.ts +6 -0
  25. package/dist/lib/account-lock.js +52 -0
  26. package/dist/lib/assertions.d.ts +14 -0
  27. package/dist/lib/assertions.js +39 -0
  28. package/dist/lib/baselines.d.ts +43 -0
  29. package/dist/lib/baselines.js +108 -0
  30. package/dist/lib/case-id.d.ts +11 -0
  31. package/dist/lib/case-id.js +17 -0
  32. package/dist/lib/case-ops.d.ts +26 -0
  33. package/dist/lib/case-ops.js +110 -0
  34. package/dist/lib/cases.d.ts +29 -0
  35. package/dist/lib/cases.js +124 -0
  36. package/dist/lib/cli.d.ts +10 -0
  37. package/dist/lib/cli.js +18 -0
  38. package/dist/lib/downloads.d.ts +42 -0
  39. package/dist/lib/downloads.js +84 -0
  40. package/dist/lib/email.d.ts +10 -0
  41. package/dist/lib/email.js +19 -0
  42. package/dist/lib/ffmpeg.d.ts +8 -0
  43. package/dist/lib/ffmpeg.js +50 -0
  44. package/dist/lib/fixtures.d.ts +40 -0
  45. package/dist/lib/fixtures.js +149 -0
  46. package/dist/lib/node-cli.d.ts +11 -0
  47. package/dist/lib/node-cli.js +26 -0
  48. package/dist/lib/paths.d.ts +25 -0
  49. package/dist/lib/paths.js +88 -0
  50. package/dist/lib/pdf.d.ts +71 -0
  51. package/dist/lib/pdf.js +140 -0
  52. package/dist/lib/profiles.d.ts +38 -0
  53. package/dist/lib/profiles.js +121 -0
  54. package/dist/lib/ready.d.ts +36 -0
  55. package/dist/lib/ready.js +53 -0
  56. package/dist/lib/records.d.ts +33 -0
  57. package/dist/lib/records.js +61 -0
  58. package/dist/lib/run-filters.d.ts +19 -0
  59. package/dist/lib/run-filters.js +30 -0
  60. package/dist/lib/run-summary.d.ts +90 -0
  61. package/dist/lib/run-summary.js +35 -0
  62. package/dist/lib/session.d.ts +18 -0
  63. package/dist/lib/session.js +45 -0
  64. package/dist/lib/summary-reporter.d.ts +16 -0
  65. package/dist/lib/summary-reporter.js +146 -0
  66. package/dist/lib/targets.d.ts +27 -0
  67. package/dist/lib/targets.js +51 -0
  68. package/dist/lib/ui-build.d.ts +10 -0
  69. package/dist/lib/ui-build.js +30 -0
  70. package/dist/lib/values.d.ts +29 -0
  71. package/dist/lib/values.js +85 -0
  72. package/dist/lib/write-gate.d.ts +41 -0
  73. package/dist/lib/write-gate.js +129 -0
  74. package/dist/playwright.config.d.ts +2 -0
  75. package/dist/playwright.config.js +92 -0
  76. package/dist/scripts/build.d.ts +1 -0
  77. package/dist/scripts/build.js +20 -0
  78. package/dist/scripts/check-cases.d.ts +1 -0
  79. package/dist/scripts/check-cases.js +81 -0
  80. package/dist/scripts/cleanup.d.ts +1 -0
  81. package/dist/scripts/cleanup.js +25 -0
  82. package/dist/scripts/cli.d.ts +1 -0
  83. package/dist/scripts/cli.js +28 -0
  84. package/dist/scripts/doctor.d.ts +1 -0
  85. package/dist/scripts/doctor.js +185 -0
  86. package/dist/scripts/explore.d.ts +1 -0
  87. package/dist/scripts/explore.js +54 -0
  88. package/dist/scripts/go.mjs +188 -0
  89. package/dist/scripts/heal-evidence.mjs +143 -0
  90. package/dist/scripts/init.d.ts +16 -0
  91. package/dist/scripts/init.js +284 -0
  92. package/dist/scripts/lint-specs.d.ts +1 -0
  93. package/dist/scripts/lint-specs.js +138 -0
  94. package/dist/scripts/login.d.ts +1 -0
  95. package/dist/scripts/login.js +47 -0
  96. package/dist/scripts/new-case.d.ts +1 -0
  97. package/dist/scripts/new-case.js +19 -0
  98. package/dist/scripts/render-pdf.d.ts +1 -0
  99. package/dist/scripts/render-pdf.js +44 -0
  100. package/dist/scripts/report.d.ts +1 -0
  101. package/dist/scripts/report.js +14 -0
  102. package/dist/scripts/review-baselines.d.ts +1 -0
  103. package/dist/scripts/review-baselines.js +30 -0
  104. package/dist/scripts/run.d.ts +1 -0
  105. package/dist/scripts/run.js +423 -0
  106. package/dist/scripts/status.d.ts +1 -0
  107. package/dist/scripts/status.js +31 -0
  108. package/dist/scripts/ui-build.d.ts +1 -0
  109. package/dist/scripts/ui-build.js +7 -0
  110. package/dist/scripts/ui-dev.d.ts +1 -0
  111. package/dist/scripts/ui-dev.js +3 -0
  112. package/dist/scripts/ui.d.ts +1 -0
  113. package/dist/scripts/ui.js +13 -0
  114. package/dist/ui/dev.d.ts +1 -0
  115. package/dist/ui/dev.js +36 -0
  116. package/dist/ui/server/actions.d.ts +91 -0
  117. package/dist/ui/server/actions.js +352 -0
  118. package/dist/ui/server/ai-stream.d.ts +41 -0
  119. package/dist/ui/server/ai-stream.js +202 -0
  120. package/dist/ui/server/app.d.ts +4 -0
  121. package/dist/ui/server/app.js +333 -0
  122. package/dist/ui/server/cases.d.ts +23 -0
  123. package/dist/ui/server/cases.js +170 -0
  124. package/dist/ui/server/files.d.ts +5 -0
  125. package/dist/ui/server/files.js +67 -0
  126. package/dist/ui/server/index.d.ts +1 -0
  127. package/dist/ui/server/index.js +33 -0
  128. package/dist/ui/server/jobs.d.ts +74 -0
  129. package/dist/ui/server/jobs.js +263 -0
  130. package/dist/ui/server/profiles.d.ts +16 -0
  131. package/dist/ui/server/profiles.js +195 -0
  132. package/dist/ui/server/watch-files.d.ts +4 -0
  133. package/dist/ui/server/watch-files.js +36 -0
  134. package/dist/ui/shared/ai-commands.d.ts +78 -0
  135. package/dist/ui/shared/ai-commands.js +154 -0
  136. package/dist/ui/shared/case-id.d.ts +1 -0
  137. package/dist/ui/shared/case-id.js +1 -0
  138. package/dist/ui/shared/jobs.d.ts +6 -0
  139. package/dist/ui/shared/jobs.js +7 -0
  140. package/dist/ui/shared/product.d.ts +3 -0
  141. package/dist/ui/shared/product.js +3 -0
  142. package/dist/ui/shared/run-filters.d.ts +1 -0
  143. package/dist/ui/shared/run-filters.js +1 -0
  144. package/dist/ui/shared/site-address.d.ts +6 -0
  145. package/dist/ui/shared/site-address.js +11 -0
  146. package/dist/ui/shared/types.d.ts +257 -0
  147. package/dist/ui/shared/types.js +1 -0
  148. package/docs/theme-guide.html +692 -0
  149. package/package.json +98 -3
  150. package/templates/.claude/commands/explore-case.md +21 -0
  151. package/templates/.claude/commands/gen-spec.md +20 -0
  152. package/templates/.claude/commands/heal-spec.md +30 -0
  153. package/templates/.claude/commands/make-case.md +12 -0
  154. package/templates/.claude/settings.json +5 -0
  155. package/templates/.mcp.json +8 -0
  156. package/templates/AGENTS.md +45 -0
  157. package/templates/CLAUDE.md +1 -0
  158. package/templates/cases/_template.md +63 -0
  159. package/templates/gitignore +13 -0
  160. package/templates/opencode.jsonc +11 -0
  161. package/templates/package.json +31 -0
  162. package/templates/playwright.config.ts +3 -0
  163. package/templates/pnpm-workspace.yaml +5 -0
  164. package/templates/profiles.yaml +29 -0
  165. package/templates/seed.spec.ts +9 -0
  166. package/templates/tsconfig.json +14 -0
  167. package/ui/dist/apple-touch-icon.png +0 -0
  168. package/ui/dist/assets/apl-gfMMiTNf.js +1 -0
  169. package/ui/dist/assets/asciiarmor-C_eisQCp.js +1 -0
  170. package/ui/dist/assets/asn1-DCl_8MCW.js +1 -0
  171. package/ui/dist/assets/asterisk-BEn0Cwpx.js +1 -0
  172. package/ui/dist/assets/brainfuck-D5EjA2JK.js +1 -0
  173. package/ui/dist/assets/clike-xvONpD0Z.js +1 -0
  174. package/ui/dist/assets/clojure-BflJGmDX.js +1 -0
  175. package/ui/dist/assets/cmake-84nbDH0I.js +1 -0
  176. package/ui/dist/assets/cobol-DHLQUbN7.js +1 -0
  177. package/ui/dist/assets/coffeescript-De_zI4J0.js +1 -0
  178. package/ui/dist/assets/commonlisp-DgAKBe0r.js +1 -0
  179. package/ui/dist/assets/crystal-Bl6YKCG_.js +1 -0
  180. package/ui/dist/assets/css-CnaYoQib.js +1 -0
  181. package/ui/dist/assets/cypher-p9eYesGn.js +1 -0
  182. package/ui/dist/assets/d-DWkQyNnU.js +1 -0
  183. package/ui/dist/assets/diff-ChtP43wD.js +1 -0
  184. package/ui/dist/assets/dist-BHaIOZdW.js +1 -0
  185. package/ui/dist/assets/dist-BSJMM_jp.js +1 -0
  186. package/ui/dist/assets/dist-BiE4suo9.js +1 -0
  187. package/ui/dist/assets/dist-BqAROxzI.js +1 -0
  188. package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
  189. package/ui/dist/assets/dist-BwYRbe5s.js +9 -0
  190. package/ui/dist/assets/dist-BzulKuec.js +1 -0
  191. package/ui/dist/assets/dist-CBV7_vDN.js +2 -0
  192. package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
  193. package/ui/dist/assets/dist-DDJNvivM.js +1 -0
  194. package/ui/dist/assets/dist-DF3F_I8N.js +1 -0
  195. package/ui/dist/assets/dist-DHCY9KpV.js +1 -0
  196. package/ui/dist/assets/dist-DX8knLVr.js +1 -0
  197. package/ui/dist/assets/dist-D_I45xgJ.js +11 -0
  198. package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
  199. package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
  200. package/ui/dist/assets/dist-gF2bFn-5.js +1 -0
  201. package/ui/dist/assets/dockerfile-DJkHQgkl.js +1 -0
  202. package/ui/dist/assets/dtd-DTF72YNO.js +1 -0
  203. package/ui/dist/assets/dylan-S7ucR4pR.js +1 -0
  204. package/ui/dist/assets/ebnf-D4c0_ac3.js +1 -0
  205. package/ui/dist/assets/ecl-BVIeWmwY.js +1 -0
  206. package/ui/dist/assets/eiffel-BEjRio4Q.js +1 -0
  207. package/ui/dist/assets/elm-Cshzl8qu.js +1 -0
  208. package/ui/dist/assets/erlang-BV0c4Hdn.js +1 -0
  209. package/ui/dist/assets/factor-LKH5gN8L.js +1 -0
  210. package/ui/dist/assets/fcl-ClOhbFR7.js +1 -0
  211. package/ui/dist/assets/forth-D6RzAl64.js +1 -0
  212. package/ui/dist/assets/fortran-Bt6PBEDR.js +1 -0
  213. package/ui/dist/assets/gas-BHEdbvp9.js +1 -0
  214. package/ui/dist/assets/gherkin-oBAE_ms0.js +1 -0
  215. package/ui/dist/assets/groovy-CUrWKPia.js +1 -0
  216. package/ui/dist/assets/haskell-BRsoo5mP.js +1 -0
  217. package/ui/dist/assets/haxe-DjVOVvNP.js +1 -0
  218. package/ui/dist/assets/http-CLVgA2GD.js +1 -0
  219. package/ui/dist/assets/idl-BUZw3wgd.js +1 -0
  220. package/ui/dist/assets/index-DmasQ6Rq.css +2 -0
  221. package/ui/dist/assets/index-I7uU93kk.js +294 -0
  222. package/ui/dist/assets/javascript-lS2r2ifg.js +1 -0
  223. package/ui/dist/assets/julia-CQfgDRfP.js +1 -0
  224. package/ui/dist/assets/livescript-BXxG0Yva.js +1 -0
  225. package/ui/dist/assets/lua-DPCEkfD_.js +1 -0
  226. package/ui/dist/assets/mathematica-u8YMmbU0.js +1 -0
  227. package/ui/dist/assets/mbox-BEMVcqjs.js +1 -0
  228. package/ui/dist/assets/mirc-C9zpzAZU.js +1 -0
  229. package/ui/dist/assets/mllike-B45xgp2S.js +1 -0
  230. package/ui/dist/assets/modelica-CqHI_q-D.js +1 -0
  231. package/ui/dist/assets/mscgen-qgSLujhx.js +1 -0
  232. package/ui/dist/assets/mumps-BSzj6Xyz.js +1 -0
  233. package/ui/dist/assets/nginx-C-ORUuei.js +1 -0
  234. package/ui/dist/assets/nsis-viPvXFiq.js +1 -0
  235. package/ui/dist/assets/ntriples-CzMBulkp.js +1 -0
  236. package/ui/dist/assets/octave-D2Q8cUp1.js +1 -0
  237. package/ui/dist/assets/oz-BwTct_5T.js +1 -0
  238. package/ui/dist/assets/pascal-Dp_KdFgX.js +1 -0
  239. package/ui/dist/assets/perl-BkSkiI9m.js +1 -0
  240. package/ui/dist/assets/pig-AF4Hwhju.js +1 -0
  241. package/ui/dist/assets/powershell-D8WpQPaX.js +1 -0
  242. package/ui/dist/assets/properties-C357ku5U.js +1 -0
  243. package/ui/dist/assets/protobuf-D1ij_kNL.js +1 -0
  244. package/ui/dist/assets/pug-BqCHZ5xT.js +1 -0
  245. package/ui/dist/assets/puppet-BZyAS7I4.js +1 -0
  246. package/ui/dist/assets/python-B_1Jd8HM.js +1 -0
  247. package/ui/dist/assets/q-B9NhS7L_.js +1 -0
  248. package/ui/dist/assets/r-CxDmU1I4.js +1 -0
  249. package/ui/dist/assets/rpm-gpDK5sbt.js +1 -0
  250. package/ui/dist/assets/ruby-ChfNwlCY.js +1 -0
  251. package/ui/dist/assets/sas-C2OyD2Y6.js +1 -0
  252. package/ui/dist/assets/scheme-D9qYN0V5.js +1 -0
  253. package/ui/dist/assets/shell-DwuoZtxw.js +1 -0
  254. package/ui/dist/assets/sieve-tLWDfEkZ.js +1 -0
  255. package/ui/dist/assets/simple-mode-DXiLM1-G.js +1 -0
  256. package/ui/dist/assets/smalltalk-BLp5-jwC.js +1 -0
  257. package/ui/dist/assets/solr-B78wSQJQ.js +1 -0
  258. package/ui/dist/assets/sparql-Dz-mlCAC.js +1 -0
  259. package/ui/dist/assets/spreadsheet-CTIncYxj.js +1 -0
  260. package/ui/dist/assets/sql-DuQMFDvy.js +1 -0
  261. package/ui/dist/assets/stex-DNzd62Q_.js +1 -0
  262. package/ui/dist/assets/stylus-BOVgKnng.js +1 -0
  263. package/ui/dist/assets/swift-DKB6_1j6.js +1 -0
  264. package/ui/dist/assets/tcl-BGfruO1u.js +1 -0
  265. package/ui/dist/assets/textile-C4jVoHG1.js +1 -0
  266. package/ui/dist/assets/tiddlywiki-VlUiK86J.js +1 -0
  267. package/ui/dist/assets/tiki-CvUEFusT.js +1 -0
  268. package/ui/dist/assets/toml-BPTmHmyx.js +1 -0
  269. package/ui/dist/assets/troff-C2dAgh7P.js +1 -0
  270. package/ui/dist/assets/ttcn-PPSTk1ow.js +1 -0
  271. package/ui/dist/assets/ttcn-cfg-2b4Gb5sE.js +1 -0
  272. package/ui/dist/assets/turtle-Cx3rYX2g.js +1 -0
  273. package/ui/dist/assets/vb-BthdM_4N.js +1 -0
  274. package/ui/dist/assets/vbscript-D9f6rSsU.js +1 -0
  275. package/ui/dist/assets/velocity-CqftRsjg.js +1 -0
  276. package/ui/dist/assets/verilog-CjI9Y6RI.js +1 -0
  277. package/ui/dist/assets/vhdl-kS471soi.js +1 -0
  278. package/ui/dist/assets/webidl-D5jiJvOU.js +1 -0
  279. package/ui/dist/assets/xquery-BKJx4mf9.js +1 -0
  280. package/ui/dist/assets/yacas-CXXgtw0p.js +1 -0
  281. package/ui/dist/assets/z80-Co3PbHdE.js +1 -0
  282. package/ui/dist/favicon.ico +0 -0
  283. package/ui/dist/favicon.svg +11 -0
  284. package/ui/dist/index.html +17 -0
  285. package/ui/dist/logos/claude.svg +1 -0
  286. package/ui/dist/logos/opencode.svg +1 -0
package/README.md CHANGED
@@ -1,3 +1,410 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # AsRun
2
+
3
+ Automated tests against production and staging for multiple profiles. Humans write test cases in plain language; an AI
4
+ agent turns them into Playwright specs; the specs run locally, once per profile, and produce videos, screenshots and
5
+ traces for review.
6
+
7
+ Profiles and environments: see `profiles.yaml`. A profile is one environment of the system under test (a URL, an
8
+ account label, tags and an environment name), and each one becomes one Playwright project.
9
+
10
+ ## Profiles and environments
11
+ Every entry in `profiles.yaml` is one environment: `id`, `name`, `env`, `baseURL`, optional `account` and `tags`, and
12
+ `email`. One profile = one Playwright project named after its id (for example `example-staging`).
13
+ - **There is no default environment for a run.** `pnpm asrun` and every AI command need `--env <env>`, which selects
14
+ the profiles whose `env` matches (the Run tests page needs one radio button picked). `pnpm signin <profile-id>` and
15
+ `pnpm explore <profile-id>` need no `--env`: the profile already names its environment.
16
+ - **Separate logins.** Sessions are saved per profile in `.auth/<profile-id>.json`; log in with
17
+ `pnpm signin <profile-id>` (a human, including 2FA).
18
+ - **Separate baselines and reports.** Screenshot baselines are stored per project (`...-example-staging-darwin.png`).
19
+ A run's folder, report title and summary carry the environment (`reports/2026-10-28T07-30-22-staging`); the web UI
20
+ shows PRODUCTION in red and STAGING in blue everywhere, and prints `ENV: PRODUCTION` / `ENV: STAGING` at the start of a CLI run.
21
+ - **Tags.** `tags: [us, core]` groups profiles; `pnpm asrun --env staging --tag us` runs only the matching ones.
22
+ - **Email.** `email: true` says a real mailbox is connected and tests may send email. It defaults to `false`, is set to
23
+ `false` for new profiles, and is rejected for `production`.
24
+ - **Expected values are not here.** They live in each case document (`## Values`); `profiles.yaml` holds connection
25
+ settings only, so adding a profile never touches a case and two profiles can never fight over one case's values.
26
+
27
+ ## Installing asrun in a project
28
+
29
+ `asrun` is a normal devDependency: it brings the Playwright fixtures and config, the case/spec tooling, the agent
30
+ prompts and the web UI. A project commits only its own test content.
31
+
32
+ ```bash
33
+ pnpm dlx @asrunjs/asrun init # scaffolding only: writes .env, configs, AGENTS.md, commands, the build-script approval
34
+ pnpm add -D @asrunjs/asrun # or: pnpm add -D github:wizcas/asrun#v0.1.0
35
+ pnpm exec asrun init --update # picks up the installed version
36
+ pnpm go # install + Chromium + doctor + web UI
37
+ ```
38
+
39
+ Running `init` before `add` matters on pnpm 12: it writes the `allowBuilds` entry that pnpm needs before it will run
40
+ the install scripts of `esbuild`/`ffmpeg-static` (without it, `pnpm add` exits with `ERR_PNPM_IGNORED_BUILDS`).
41
+ `pnpm dlx @asrunjs/asrun init --install` does both in one step (the spec is optional:
42
+ `--install github:wizcas/asrun#v0.1.0`).
43
+
44
+ `pnpm asrun init` is safe to run again: it never overwrites a file you own.
45
+
46
+ ```bash
47
+ pnpm asrun init --update # refreshes only the generated files (playwright.config.ts, seed.spec.ts, commands)
48
+ pnpm asrun doctor # checks profiles, the tsconfig alias, generated files, the installed version, skill overrides
49
+ ```
50
+
51
+ The project commits `profiles.yaml`, `cases/`, `tests/` (with their `*-snapshots/`), `AGENTS.md`, `.claude/`,
52
+ `.mcp.json`, `opencode.jsonc` and the generated files. It never commits `.auth/`, `reports/`, `.tmp/`, `.explore/`,
53
+ `.env` or `node_modules/`. See "Layout" below.
54
+
55
+ **Which workspace a command runs against** comes from `.env` (`ASRUN_ROOT`, relative to that file; gitignored and
56
+ written by `init`), then from the nearest folder with `profiles.yaml`/`.asrun.json`. A real environment variable wins
57
+ over `.env`, so a CI job can point a checkout somewhere else without editing files.
58
+
59
+ Update:
60
+
61
+ ```bash
62
+ pnpm add -D @asrunjs/asrun@latest # or: pnpm add -D github:wizcas/asrun#v0.2.0
63
+ pnpm exec asrun init --update
64
+ pnpm asrun doctor
65
+ ```
66
+
67
+ There is no folder to move: the framework lives in `node_modules/@asrunjs/asrun`, which the package manager owns. To change
68
+ the framework itself, `pnpm patch asrun` (a committed patch) or point the dependency at your fork's tag.
69
+
70
+ `templates/` in the package holds what `init` writes. `AGENTS.md` and the skills stay in the package as the single
71
+ source: a project narrows a rule in its own `AGENTS.md` (by naming its id, `F1`..`F10`; `[non-negotiable]` rules cannot
72
+ be narrowed) and overrides a skill with `.claude/skills/<skill>.md`, which `doctor` lists.
73
+
74
+ ## Quick start in a project
75
+
76
+ ```bash
77
+ node framework/scripts/go.mjs # the very first time (no dependencies yet)
78
+ pnpm go # every time after that
79
+ ```
80
+ That is all. It checks Node (22+), lists the framework in `pnpm-workspace.yaml`, installs the dependencies and the
81
+ browser (`pnpm install` + Chromium), writes `.env` and the workspace files, runs `doctor`, starts the web UI and opens
82
+ http://127.0.0.1:4173. Run it again any day: the install step then takes seconds, and if the UI is already running it
83
+ just opens it. The **?** icon in the top bar of the UI opens the step-by-step "How to start" page (install, log in,
84
+ make cases, explore, generate specs, run, reports). `pnpm go --no-open` does not open the browser.
85
+
86
+ The tab title shows where you are (`AsRun | Cases | documents/landing-page`, `AsRun | Job Log`). When it is your
87
+ turn (a login to finish, a step that an attended exploration hands to you in the browser window, or answers that an
88
+ AI run needs), the title gets a count in front (`(1) AsRun | ...`), the job shows a "Your turn" badge, and the bell
89
+ icon in the top bar, once switched on, shows a browser notification while the UI tab is in the background. Clicking the
90
+ notification opens that job.
91
+
92
+ You need Node 22+ and pnpm. The AI steps (make cases, explore, generate) also need Claude Code or OpenCode; `pnpm go`
93
+ warns if neither is found, everything else works without them.
94
+ `ffmpeg` is also needed: it joins the videos of one test (a new browser tab has its own recording) into a single video
95
+ for the report. `pnpm go` tries to install it (Homebrew on macOS, winget on Windows; on Linux run
96
+ `sudo apt install ffmpeg`). If it is missing, the optional `ffmpeg-static` package is used instead; if neither works,
97
+ the report shows the videos separately.
98
+ Then check `profiles.yaml`: the profile ids, names, environments and addresses.
99
+
100
+ ## Every day
101
+
102
+ | I want to... | Command |
103
+ |---|---|
104
+ | Check the workspace (profiles, tsconfig alias, generated files, skill overrides) | `pnpm asrun doctor` |
105
+ | Create or refresh the generated workspace files | `pnpm asrun init`, `pnpm asrun init --update` |
106
+ | See which profiles are logged in | `pnpm status` (all environments) or `pnpm status --env staging` |
107
+ | Log in a profile (human, with 2FA) | `pnpm signin <profile-id>` |
108
+ | Run everything in one environment | `pnpm asrun --env staging` |
109
+ | Run one profile | `pnpm asrun --env production --profile example-production` |
110
+ | Run all profiles except some (e.g. not logged in) | `pnpm asrun --env production --skip-profile example-production,example-staging` |
111
+ | Run only the profiles that carry a tag | `pnpm asrun --env staging --tag us` |
112
+ | Run only the profiles that are logged in | `pnpm asrun --env production --logged-in` |
113
+ | Read what the last report says about a case | `pnpm heal:evidence --case <case-id> --env staging` |
114
+ | Check the specs for guessed targets, sleeps and weak download checks | `pnpm spec:lint [case-id]` |
115
+ | Render a PDF to images and print its text (to look at it) | `pnpm pdf:render <file.pdf> --out .explore/targets/<case-id>/<name> [--pages all] [--corner top-left]` |
116
+ | List screenshot baselines next to what each must show | `pnpm baselines:review [case-id] [--new]` |
117
+ | Overwrite the baselines of some cases | `pnpm asrun --env staging --grep <case-id> --update-baselines <case-id>` |
118
+ | Run one case | `pnpm asrun --env production --grep hub-landing` |
119
+ | Run cases of one suite | `pnpm asrun --env production --suite smoke` (several: `--suite smoke,regression`; `--priority` is the old name) |
120
+ | Leave out the cases that write data | `pnpm asrun --env production --read-only` |
121
+ | List test data a case could not remove | `pnpm cleanup --env <env>` (`--forget` after deleting it by hand) |
122
+ | Watch a run step by step | `pnpm asrun --env staging --ui` |
123
+ | Show the current test step as text on the videos | `pnpm asrun --env production --step-text` (also `=1\|true\|yes\|on`; off by default) |
124
+ | Open the last report (videos, screenshots, traces) | `pnpm report` |
125
+ | Look around a page as a profile (files you download in that window are saved to `~/Downloads`; the path is printed in the terminal / job log) | `pnpm explore <profile-id> /some/path/` |
126
+
127
+ ### Local web UI
128
+ ```bash
129
+ pnpm go # install + build + start + open the browser (use this the first time)
130
+ pnpm ui # builds the web app and serves http://127.0.0.1:4173
131
+ pnpm ui:dev # development: Vite on :5173 with hot reload, API on :4173
132
+ ```
133
+ One page for the daily work (it only listens on `127.0.0.1`):
134
+
135
+ | Page | What it does |
136
+ |---|---|
137
+ | Profiles | Login state of every profile, one row each (same check as `pnpm status`). "Log in" starts `pnpm signin <profile-id>`; log in in the browser window that opens, then click "I've logged in, save the session". "Visit" (shown for logged-in profiles) opens a logged-in browser window at `/app/home` (`pnpm explore <profile-id> /app/home`). While that window is open the button turns into a red "Close browser", which kills the browser process. Files downloaded in that window are saved to `~/Downloads` (an existing file is never overwritten, a ` (1)` is added) and the saved path is printed in the job log. "Add a new profile" has a form (environment, URL, account label, tags) that appends an entry to `profiles.yaml`, plus a guide for what to do next (log in, update the cases, a copyable AI prompt). |
138
+ | Cases | List of `cases/**/*.md`, grouped by folder (collapsible), source editor with a rendered preview (both highlight what needs a human: `TODO` in any spelling such as `todo` / `TO-DO` / `to-do` (red for `🚫 TODO [blocking]`, gold otherwise), `🔍 to be confirmed by QA` / `👁️ not observed` / `TBC` / `TBD` in blue, and your `✍️ Answer:` lines in green; the emoji in front is part of the highlighted marker; hover a highlighted word for a short tooltip on what it means and how to resolve it, and click the small X after it to delete the marker, or the whole question block for a `TODO`, in one step, in the editor and in the preview (Undo brings it back; the file is only saved when you save). An `Answer:` gets its X once you typed something, and it clears what you typed), the editor and the preview scroll together, the preview block that holds the caret is shaded, and a click in the preview puts the caret on that line of the editor; `Ctrl/Cmd+S` to save, "New case" has an optional folder field and runs `pnpm case:new`. The **Spec** view is editable too (`Ctrl/Cmd+S` saves; the save is refused when the file changed on disk meanwhile): **Open in editor** opens the spec in the default application of your system for `.spec.ts` files, and **Copy path** copies its absolute path, to paste into a terminal editor such as nvim. Click the file name (or the pencil icon next to it) to rename the case or move it to another folder: the spec and the baseline images move with it and the spec is rewritten to match. **Delete** (type the id to confirm, or click the feather icon to fill it in) removes the case, its spec and its baseline images; both dialogs list exactly what they touch first, and are refused while an AI or test run is in progress. The editor toolbar has a command picker (explore / generate / heal, explore by default), an **Environment** picker (required for every command, no default), an **Attended** box (ticked by default, only for explore), a **Run AI** button (for **Heal spec** it opens a dialog first, see below) and icon buttons to copy the shell command and open the AI guide, for the one-shot AI command of the open case. The Claude / OpenCode switch (Claude by default, remembered in the browser) is in the top bar and applies to every AI command. **From memo** opens a text box for raw test notes: **Make cases** saves them to `.tmp/memos/` and runs `/make-case` one-shot (no questions: defaults for gaps, a `TODO` block for unknown values, no page exploration, existing cases are never overwritten); **Copy /make-case** copies the memo for the full interactive dialogue. |
139
+ | Run tests | Form for `pnpm asrun`: environment (required, no default), profiles, suite, read-only, case, step text. The **Cases to run** list shows what the filters select, with an **Update baselines** box per case (`--update-baselines`, unticked by default; asks before it overwrites and shows the new images next to their target cards when the run ends). Asks for confirmation when write cases would run in production. |
140
+ | Reports | One entry per run, each with a PRODUCTION / STAGING badge (runs from before environments existed are shown as PRODUCTION). **Results** lists every case x profile with the failure reason on one line (failed first); selecting one shows a red "Why it failed" block (failed step + error message) above the screenshot, video and a folded "Technical details" (call log, stack trace). **Full Playwright report** embeds the standard HTML report (videos and traces work). The latest run opens first. Runs made before summaries existed only have the full report. |
141
+ | Help (the **?** icon in the top bar) | "How to start": seven short steps with screenshots, from `pnpm go` to the reports. The screenshots are in `ui/web/public/help/` (retake them when a page changes a lot). |
142
+ | AI guide | Copyable `/make-case`, `/explore-case`, `/gen-spec`, `/heal-spec` commands for Claude Code and OpenCode. |
143
+
144
+ The "Jobs" bar at the bottom (opening it shrinks the page above instead of covering it) shows every command started from the UI with live stdout/stderr and its exit
145
+ code; a job can be cancelled there. Only these fixed actions can run (`signin <profile>`, `visit <profile>` and `run` with
146
+ checked options) the three AI commands for an existing case and `/make-case` for a memo (`claude -p` / `opencode run`); there is no free shell. Set `ASRUN_UI_PORT` to change the port.
147
+
148
+ ### Checking what a test actually did
149
+ Every run records a video and a trace per test. A new browser tab gets its own recording; the **Reports** page joins
150
+ them in order into one video per test (`reports/<run>/videos/`, needs ffmpeg, see Quick start). The full Playwright report
151
+ still lists the recordings separately. Add `--step-text`
152
+ to `pnpm asrun` to overlay the current step's title on the videos; without it the videos are plain.
153
+ In `pnpm report`, open the test: the steps are named after the steps in the case document. The trace shows each action with
154
+ the page before and after it, plus every network request. `pnpm asrun` also prints the file paths of the
155
+ videos and traces when it finishes; open a trace directly with `pnpm exec playwright show-trace <trace.zip>`.
156
+ Recordings contain real production pages, so do not share them.
157
+
158
+ ### Logging in
159
+ `pnpm signin <profile-id>` opens a normal browser. Log in yourself, including 2FA, wait for the logged-in
160
+ page, then press Enter in the terminal. The session is saved to `.auth/<profile-id>.json` and verified.
161
+ Nothing about 2FA is automated.
162
+
163
+ Login itself is not tested. Before every run, `pnpm asrun` checks the session of each profile of the chosen
164
+ environment. If a profile has no saved session, or the session expired (the check page redirects to `/login`), the
165
+ tests of that profile fail right away (no retries) and the run prints:
166
+ ```
167
+ Not logged in (tests for these profiles will FAIL):
168
+ example-production expired -> pnpm signin example-production
169
+ Or skip them for this run: pnpm asrun --env production --skip-profile example-production
170
+ ```
171
+ Either log in (`pnpm signin <profile-id>`) or leave the profile out with `--skip-profile`. Profiles that are logged
172
+ in are tested as usual. If a session expires in the middle of a run, the failed test is marked
173
+ "session expired" in the output.
174
+
175
+ Sessions are as sensitive as passwords: `.auth/` (one file per profile) is git-ignored, never share or commit it.
176
+
177
+ ## From a case document to a test (AI workflow)
178
+
179
+ 0. **(Optional) start from raw notes.** If you only have a memo or a list of things to check, run
180
+ `/make-case path/to/notes.md` (or paste the text after the command). The agent normalizes the text into English steps,
181
+ marks steps that write data (`[writes]`) or send email (`[email]`), keeps your original lines in a `## Source` table,
182
+ lists the gaps with a suggested default for each, and only then writes `cases/<id>.md`. It never writes specs and
183
+ never invents expected values (unknown ones become a default sentence with a `TODO` block under it). It never opens a
184
+ browser: what the pages really show is found in step 2. `pnpm case:check` validates the front matter of every case.
185
+ 1. **Write the case.** `pnpm case:new header-logo` creates `cases/header-logo.md` from a template; `pnpm case:new documents/header-logo` puts it in the
186
+ group folder `cases/documents/` (created on demand, one level only). The id of a case is its path without `.md`, its spec is
187
+ `tests/documents/header-logo.spec.ts`, and its expected values are in its own `## Values` block.
188
+ Fill in steps, what is expected for every profile, and the exact values the spec asserts under Values, one entry per
189
+ profile id (`default` applies to all). Plain language is fine for the checks. Write it in English; if it is in
190
+ another language, `/gen-spec` translates the case file to English first (and tells you).
191
+ 2. **(Optional) explore the case.** In OpenCode or Claude Code run `/explore-case header-logo production` (the case id is
192
+ required; the environment is optional: `production` or `staging`). This is the only step where a person and the
193
+ agent work together. The agent opens the real pages of the profiles that are logged in and only **reads** them: it
194
+ never creates, edits, publishes, shares or deletes anything, in staging or in production. It fixes wording in the
195
+ case file, writes what the pages show into it (marked `observed, 🔍 to be confirmed by QA`), and turns what it cannot
196
+ know into `🚫 TODO [blocking]` / `❓ TODO [non-blocking]` questions (every marker starts with its emoji). Every question is a block
197
+ under a sentence that is the agent's suggestion, written so that it can stand in the case as it is:
198
+ ```
199
+ - Clicking "Edit Draft" opens the document editor, in the same tab or in a new tab.
200
+ - ❓ TODO [non-blocking]: Same tab or new tab? (👁️ not observed)
201
+ - ✍️ Answer:
202
+ ```
203
+ It edits only the case file. **To accept the suggestion, delete the whole block** (the `TODO` line and the
204
+ `Answer:` line). **To give your own answer, write it after `Answer:`** (you may delete the rest of the block).
205
+ Then run the command again. It ends with `RESULT: ready` or `RESULT: needs-input`. In the web UI a run
206
+ that ends with `needs-input` shows an amber "Needs your input" badge and an answer box under its output: send your
207
+ answers (or "Use the suggested defaults", which accepts every suggestion) and the same agent conversation continues with the same permissions, folds
208
+ them into the case file and ends with a new `RESULT:`. A run can be continued once per turn, and only while the UI
209
+ server has not been restarted; after a restart, answer in the case file and run the command again.
210
+
211
+ **Attended exploration (`--attended`, or the "Attended" box on the Cases page, ticked by default).** Use it for a case that creates or
212
+ edits data, or needs a choice (an existing document, items). Stay at the computer: the browser window opens and the
213
+ agent waits while you do those steps. The window shows a small card in the bottom-left corner that says what to do (drag it by its title bar or minimize it if it is in the way), with **Done** (hand
214
+ control back) and **Cannot do this**; the agent also continues by itself when the finish condition it named shows up
215
+ on the page (for example a "Saved" message), and the card turns green when control is back. It waits up to 10
216
+ minutes per step. Delete the `[asrun]` items you created before you press Done on the last step. What you did is
217
+ saved in the case under `## Inputs` as a **general recipe**: positions instead of names ("select items #1 to #3"),
218
+ `[asrun]` names for created data, control labels but no selectors. Without `--attended` the agent never waits:
219
+ a step that needs a person becomes a `TODO [blocking]`.
220
+ 3. **Log in the profiles the case needs** (`pnpm status`, `pnpm signin ...`). Skip for `login: none` cases.
221
+ 4. **Generate.** In OpenCode or Claude Code run `/gen-spec header-logo staging` (the environment is required; there is
222
+ no default). The agent replays the case on the real pages with your saved sessions (it never writes data), writes `tests/header-logo.spec.ts`, fills the
223
+ case's `## Values` for the profiles it explored and checks the files (`pnpm typecheck`, `pnpm case:check`). It does not run the
224
+ spec and does not create screenshot baselines: running specs is a separate step (6). It does not stop to ask: what the
225
+ page answers is written into the case as observed (QA confirms it), and what is still unknown stays in the case as a
226
+ `TODO`. It ends with `RESULT: ready` or `RESULT: needs-input`. A case with a `[writes]` or `[input]` step that has no
227
+ recipe under `## Inputs` is not generated: it stops with `needs-input` and asks you to run step 2 with `--attended`.
228
+ If the spec already exists, the command leaves it alone and says so. Add `--force` (`/gen-spec header-logo staging
229
+ --force`) to regenerate it from the case and overwrite it (the case's `## Values` and baseline images are kept; the
230
+ report lists the ones that may be out of date). In the web UI, **Run** on a case that has a spec
231
+ asks first: "Overwrite the spec?". The server refuses to start `gen-spec` on an existing spec without that choice.
232
+ 5. **Review what the AI produced.** Check: the spec reads like your case, and the `## Values` block of the case has the
233
+ right values for each profile (you normally don't edit these by hand, but you can).
234
+ 6. **Run it:** `pnpm asrun --env <env> --grep <case-id>`. If the spec has visual checks, add `--update-snapshots`
235
+ the first time, then open the new images in `tests/<case>.spec.ts-snapshots/` and confirm each is the correct logo
236
+ for that profile. Baselines are the source of truth for pixel checks. Then run all profiles and `pnpm report`.
237
+
238
+ When the product changes and a test fails, run `/heal-spec <case-id> <env> [--profiles a,b] [--report <run-id>] [--hints <file>]`
239
+ (or **Heal spec** in the Cases toolbar, or **Heal this failure** on a failed result on the Reports page). The agent
240
+ decides whether the test needs updating or whether it found a real problem, and never edits an expectation just to
241
+ make a failure disappear. How it works:
242
+ - The environment is always required. Only the profiles that are logged in there are run (`pnpm asrun --logged-in`
243
+ leaves the others out instead of failing them).
244
+ - The web UI first opens a dialog: the profiles to heal (every logged-in profile ticked) and an optional text box for what
245
+ you want healed or any evidence and clues. The text is stored in `.tmp/hints/` (git-ignored) and passed as `--hints`.
246
+ Clues guide the diagnosis; they never lower the bar for a product finding.
247
+ - The agent reads the existing report first (`pnpm heal:evidence --case <id> --env <env>`: status, failed step, error,
248
+ call log and screenshots per profile, and whether the report is fresh or stale) and runs the case again only when
249
+ there is no report, it is stale, it does not explain the failure, or a clue has to be checked. After it edits the
250
+ spec it runs it once to confirm.
251
+
252
+ ### Targets, readiness and downloads (why generated specs can be trusted)
253
+ Three rules keep an AI-written spec from passing or failing for the wrong reason. They hold in exploring, generating and
254
+ healing (`AGENTS.md`, `<framework>/.claude/skills/exploring-cases/references/assertion-targets.md` and `readiness.md`).
255
+ - **Targets.** A check on a logo, image, icon, file, PDF, screenshot or repeated text points to a card under `## Targets`
256
+ in the case (`[T1]`): what it is (and what it is not), where it is, how it is identified (never `.first()` or a bare
257
+ `img`), when it is ready, and a highlighted reference screenshot under `.explore/targets/`. When the agent is in doubt
258
+ it does not pick: it writes a `🚫 TODO [blocking]` that asks for the exact image, text, place or UI, with the
259
+ candidates listed. Answer it by editing the case (or, in an attended run, by outlining the target in the browser).
260
+ - **Readiness.** After an action that loads something, the key requests must finish and the content must be visible before
261
+ anything is read. Specs use `actAndWait` (`@asrunjs/asrun/ready`); "missing" and "empty" checks need a positive ready signal.
262
+ Healing treats a "missing / empty / timeout" failure as a readiness problem first.
263
+ - **Downloads.** `verifyDownload` (`@asrunjs/asrun/downloads`) checks that the download starts, finishes, the file exists and the
264
+ file is valid for its kind; any failure or timeout fails the whole step with a message that names the stage.
265
+ - **PDFs are read like a person reads them.** `expectPdf` (`@asrunjs/asrun/pdf`) renders the pages to images (a logo region must not
266
+ be blank and must match the profile's baseline, 2% tolerance) and extracts the text (for example, no other profile's name).
267
+ Every page is checked unless the case limits the pages in strong words ("only", "exactly", "must"); "should",
268
+ "always", "usually" or no wording mean every page.
269
+ `pnpm pdf:render <file.pdf> --out .explore/targets/<case-id>/<name> --corner top-left` lets you (or the exploring agent)
270
+ look at the rendered pages.
271
+ - **Checks you can run yourself:** `pnpm spec:lint` (fails on screenshots without a target, `.first()` on images, sleeps,
272
+ `networkidle`, raw download events, a `%PDF` check on its own), `pnpm baselines:review` (opens nothing; lists every
273
+ baseline with the card that says what it must show, so you can look at each new image), and `pnpm case:check`
274
+ (fails on an incomplete card, warns on an image check with no card).
275
+
276
+ ### Updating baselines on purpose
277
+ If a profile really changed its logo, or a baseline is wrong: fix the spec first (the check must look at the element
278
+ its target card names; a wrong locator just makes a new wrong image), then
279
+ `pnpm asrun --env <env> --profile <id> --grep <case> --update-baselines <case-id>[,<case-id>]`, then look at the new
280
+ image before keeping it (`pnpm baselines:review <case-id>`).
281
+ - `--update-baselines` names the cases whose images are overwritten (`--update-snapshots=all`). They must be among the
282
+ cases the filters run. Playwright has one global switch, so they run as a second pass with their own report
283
+ (`<run-id>-baselines`); every other case is compared with its baselines as usual.
284
+ - Without `--update-baselines`, `--update-snapshots` still works as before, but it rewrites every image of the run.
285
+ - In the web UI, every case in **Cases to run** has an **Update baselines** box (nothing is ticked by default). A
286
+ confirm dialog follows, and when the run ends the job output shows each rewritten image next to the target card that
287
+ says what it must show.
288
+ - Not part of exploring, generating or healing: an agent never updates a baseline.
289
+
290
+ ### Suites
291
+ Each case lists its suites in the front matter: `suites: [smoke, regression]` (any lowercase words). A case runs in
292
+ every suite it lists. The generated spec tags itself `@<suite>` for each one, which:
293
+ - shows as badges next to the test in the terminal and in `pnpm report`, and can be typed into the report's
294
+ search box (`@smoke`) to filter there;
295
+ - drives `pnpm asrun --suite <name>`, which can be combined with `--profile` and `--grep`. A case that is in two
296
+ of the chosen suites still runs once.
297
+
298
+ An unknown suite is rejected with the list of suites the cases use. The old `priority: <level>` is read as
299
+ `suites: [<level>]`.
300
+
301
+ ### Other front matter
302
+ - `writes: [staging]` or `writes: [staging, production]` (omit it for a read-only case): the environments in which the
303
+ case may create or change data of the test accounts (and cleans it up). It then needs
304
+ `writes-note: <what it creates or changes>`. In an environment that is not listed, the case is **skipped with a
305
+ notice**, not failed. The old `writes: test-data` is refused with this hint.
306
+ - `envs: [production, staging]` (optional): the case runs only in these environments; in others it is skipped. Every
307
+ environment in `writes` must also be in `envs`.
308
+
309
+ Cases that write data get a red **WRITES** badge in the web UI and the report, and an `@writes` tag.
310
+
311
+ ## What a case may and may not do
312
+ Tests run against production and staging. By default a case is read-only: look, read, compare. No creating,
313
+ editing, deleting, submitting or accepting anything.
314
+
315
+ A case may write only in the environments listed in its `writes` front matter (elsewhere it is skipped with a notice),
316
+ and only data of the test account (named with the prefix `[asrun]`), which it must clean up.
317
+ **AI agents never write data while they explore or generate**, in either environment: a person does those steps, in
318
+ the browser, in an attended exploration (step 2 above).
319
+ - **Staging:** no confirmation needed.
320
+ - **Production (and any environment that is not staging)**, for a case that lists it under `writes`: a person confirms every run. `pnpm asrun --env production` lists
321
+ the write cases and asks you to type `production`. The web UI shows a confirm dialog. Unattended runs use
322
+ `--allow-writes=production`; AI agents never do. `--read-only` leaves the write cases out. The permission is also
323
+ enforced inside the test itself, so running `playwright test` directly cannot bypass it.
324
+ - **Cleanup:** data is named `[asrun] ...`, registered with the `records` fixture and removed after the test, also
325
+ after a failure. Whatever could not be removed is shown in red in the report and listed by
326
+ `pnpm cleanup --env <env>`; delete it by hand, then `pnpm cleanup --env <env> --forget`.
327
+ - **No retries, one at a time:** write cases are never retried, and run one at a time per profile.
328
+ - **Email:** never sent from production. In other environments only if the profile has `email: true` in `profiles.yaml`.
329
+
330
+ ## Author
331
+ Created by Zoro Chen.
332
+
333
+ ## Layout
334
+
335
+ **In a project that uses the framework** (everything here is committed except the ignored folders):
336
+
337
+ ```
338
+ profiles.yaml one entry per environment: URL, account label, tags, environment (no expected values)
339
+ cases/ human-written cases (+ _template.md); optional group folders: cases/<group>/<name>.md
340
+ tests/ generated specs (same folders as cases/), and *-snapshots/ baseline images per profile
341
+ tsconfig.json plain compiler options: specs import the framework by package name (`@asrunjs/asrun/fixtures`)
342
+ playwright.config.ts re-exports node_modules/@asrunjs/asrun/dist/playwright.config.js
343
+ AGENTS.md the project rules: they point at node_modules/@asrunjs/asrun/AGENTS.md and narrow it (CLAUDE.md imports AGENTS.md)
344
+ .claude/commands/ /make-case, /explore-case, /gen-spec, /heal-spec: thin shells that load the framework skill
345
+ .claude/skills/ optional project overrides of a skill: <skill>.md
346
+ .mcp.json, opencode.jsonc the playwright-test MCP server, for Claude Code and OpenCode
347
+ .env ASRUN_ROOT (which workspace the commands use) and local settings (git-ignored)
348
+ node_modules/@asrunjs/asrun/ the framework (installed dependency; never edited)
349
+ .auth/ saved logins, one per profile (git-ignored)
350
+ reports/ one folder per run; reports/latest points to the newest (git-ignored)
351
+ ```
352
+
353
+ **In the framework repository** (no workspace content at the root: no `profiles.yaml`, `cases/`, `tests/` or reports):
354
+
355
+ ```
356
+ lib/ fixtures, session guard, image assertions, case values (imported as asrun/<module>)
357
+ scripts/ the pnpm commands above, plus init (workspace files) and doctor (checks)
358
+ templates/ what `pnpm asrun init` writes into a project
359
+ ui/ local web UI: server/ (Hono API, job runner), web/ (React + Vite), shared/ (types)
360
+ docs/ static docs; theme-guide.html = the Inkstone theme (designed and created by Zoro Chen): colors, buttons, icons of the web UI (open in a browser, or via the "Inkstone theme" link in the UI's top bar)
361
+ examples/demo/ a local workspace for developing the framework (only README and .gitignore are committed)
362
+ .claude/ skills + slash commands for the AI workflow (shared by OpenCode and Claude Code)
363
+ .opencode/ symlink: commands -> ../.claude/commands (OpenCode only reads commands here)
364
+ AGENTS.md the framework baseline rules (OpenCode); CLAUDE.md imports it for Claude Code
365
+ opencode.jsonc MCP config for OpenCode; .mcp.json is the same server for Claude Code
366
+ ```
367
+
368
+ ## Developing the framework
369
+
370
+ `pnpm go`, `pnpm asrun`, `pnpm ui` work here too: without a workspace they use `examples/demo`. `init` writes the
371
+ framework's `.env` for that (`ASRUN_ROOT=examples/demo`), so the commands need no environment variable:
372
+
373
+ ```bash
374
+ pnpm asrun init --root examples/demo # creates the demo workspace and the framework-root .env
375
+ pnpm asrun --env staging --read-only
376
+ ```
377
+
378
+ The demo is a workspace of its own as well:
379
+
380
+ ```bash
381
+ cd examples/demo && ASRUN_ROOT=. pnpm asrun doctor
382
+ ```
383
+
384
+ `ASRUN_ROOT` still wins over `.env` (a real environment variable always does), so one checkout can be pointed at
385
+ another project for a single command:
386
+
387
+ ```bash
388
+ ASRUN_ROOT=../my-real-project pnpm asrun --env staging --read-only
389
+ ```
390
+
391
+ Two paths keep the two halves apart: `ROOT` (`lib/paths.ts`) is the workspace, `FRAMEWORK_ROOT` is this folder. The
392
+ package is compiled to `dist/` (`pnpm build`) and the web UI to `ui/dist` (`pnpm ui`); both are shipped by `prepack`.
393
+ Nothing writes into `FRAMEWORK_ROOT` at run time. After changing `lib/`, run `pnpm build` before exercising the demo's
394
+ specs (they import the package by name, which resolves to `dist/`). After changing `templates/`, bump `version` in
395
+ `package.json`; `pnpm asrun doctor` in a project then shows that it is behind.
396
+
397
+ ## How profiles become tests
398
+ Every entry in `profiles.yaml` becomes a Playwright project (name = profile id) with its own `baseURL` and saved login.
399
+ A spec is written once and runs once per profile; the `profile` fixture exposes that profile's `id`, `env`, `baseURL`,
400
+ `account`, `tags` and `email`. The expected values come from the case document, through
401
+ `requireValues(caseId, profile)` (its `## Values` block, `default` merged with the profile's own entry). Adding a
402
+ profile = add a YAML entry (by hand or with the form on the Profiles page) + `pnpm signin <id>`, then add the values
403
+ for it to the cases (see the guide on the Profiles page).
404
+
405
+ ## Troubleshooting
406
+ - `Profile "x" is not logged in`: `pnpm signin <profile-id>`, or run with `--skip-profile <profile-id>`.
407
+ - Screenshot mismatch: open the report; the diff view shows expected / actual / diff. If the change is
408
+ intended, update the baseline as above.
409
+ - Report empty or missing: run `pnpm asrun --env <env>` first.
410
+ - `Choose the environment to test`: there is no default; add `--env production` or `--env staging`.
package/bin/asrun.mjs ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // The `asrun` command: a thin dispatcher over the compiled framework in `dist/`.
3
+ import {spawn} from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {fileURLToPath} from 'node:url';
7
+
8
+ const FRAMEWORK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ const DIST = path.join(FRAMEWORK, 'dist');
10
+ const args = process.argv.slice(2);
11
+
12
+ // The script of each subcommand. No subcommand means "run the tests" (`scripts/run.js`).
13
+ const SCRIPTS = {
14
+ init: 'cli.js',
15
+ doctor: 'cli.js',
16
+ go: 'go.mjs',
17
+ ui: 'ui.js',
18
+ 'ui:dev': 'ui-dev.js',
19
+ signin: 'login.js',
20
+ status: 'status.js',
21
+ report: 'report.js',
22
+ explore: 'explore.js',
23
+ cleanup: 'cleanup.js',
24
+ 'case:new': 'new-case.js',
25
+ 'case:check': 'check-cases.js',
26
+ 'spec:lint': 'lint-specs.js',
27
+ 'baselines:review': 'review-baselines.js',
28
+ 'pdf:render': 'render-pdf.js',
29
+ 'heal:evidence': 'heal-evidence.mjs',
30
+ };
31
+
32
+ const subcommand = SCRIPTS[args[0]] ? args.shift() : undefined;
33
+ const scriptName = SCRIPTS[subcommand] ?? 'cli.js';
34
+ const script = path.join(DIST, 'scripts', scriptName);
35
+ if (!fs.existsSync(script)) {
36
+ console.error(
37
+ `The asrun installation is incomplete: ${script} is missing. Reinstall it: "pnpm add -D @asrunjs/asrun".`
38
+ );
39
+ process.exit(1);
40
+ }
41
+
42
+ // `init` and `doctor` are subcommands of cli.js; it reads them from its own arguments.
43
+ const cliArgs = scriptName === 'cli.js' && subcommand ? [subcommand, ...args] : args;
44
+ const child = spawn(process.execPath, [script, ...cliArgs], {cwd: process.cwd(), stdio: 'inherit'});
45
+ child.on('error', (error) => {
46
+ console.error(`Could not start asrun: ${error.message}`);
47
+ process.exit(1);
48
+ });
49
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
@@ -0,0 +1,6 @@
1
+ /**
2
+ * One test at a time per profile: two cases that write to the same account must not run
3
+ * side by side, because they would see each other's drafts. Returns the function that releases the lock.
4
+ * A lock left behind by a dead process is taken over.
5
+ */
6
+ export declare function acquireAccountLock(account: string, waitMs: number): Promise<() => void>;
@@ -0,0 +1,52 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ROOT } from './paths.js';
4
+ const LOCKS_DIR = path.join(ROOT, '.tmp', 'locks');
5
+ const POLL_MS = 500;
6
+ function isAlive(pid) {
7
+ try {
8
+ process.kill(pid, 0);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+ /**
17
+ * One test at a time per profile: two cases that write to the same account must not run
18
+ * side by side, because they would see each other's drafts. Returns the function that releases the lock.
19
+ * A lock left behind by a dead process is taken over.
20
+ */
21
+ export async function acquireAccountLock(account, waitMs) {
22
+ const dir = path.join(LOCKS_DIR, account.replace(/[^\w@-]/g, '_'));
23
+ fs.mkdirSync(LOCKS_DIR, { recursive: true });
24
+ const deadline = Date.now() + waitMs;
25
+ for (;;) {
26
+ try {
27
+ fs.mkdirSync(dir);
28
+ fs.writeFileSync(path.join(dir, 'pid'), String(process.pid));
29
+ return () => fs.rmSync(dir, { recursive: true, force: true });
30
+ }
31
+ catch (error) {
32
+ if (error.code !== 'EEXIST')
33
+ throw error;
34
+ }
35
+ let holder = 0;
36
+ try {
37
+ holder = Number(fs.readFileSync(path.join(dir, 'pid'), 'utf8').trim());
38
+ }
39
+ catch {
40
+ // The holder has just created the lock and not yet written its pid: wait for it.
41
+ holder = -1;
42
+ }
43
+ if (holder === 0 || (holder > 0 && !isAlive(holder))) {
44
+ fs.rmSync(dir, { recursive: true, force: true });
45
+ continue;
46
+ }
47
+ if (Date.now() > deadline) {
48
+ throw new Error(`Another test is still writing to the account ${account}; gave up waiting for it.`);
49
+ }
50
+ await sleep(POLL_MS);
51
+ }
52
+ }
@@ -0,0 +1,14 @@
1
+ import { type Locator } from '@playwright/test';
2
+ export interface LogoExpectation {
3
+ /** `svg`: inline <svg><title>; `img`: <img alt src>. */
4
+ kind: 'svg' | 'img';
5
+ title?: string;
6
+ alt?: string;
7
+ srcContains?: string;
8
+ sha256?: string;
9
+ }
10
+ export declare function expectImageLoaded(image: Locator): Promise<void>;
11
+ /** Compares the bytes the browser downloads for an <img> with a known hash. */
12
+ export declare function expectImageSha256(image: Locator, sha256: string): Promise<void>;
13
+ /** Asserts the product logo by DOM content. Pair with toHaveScreenshot for pixels. */
14
+ export declare function expectLogo(container: Locator, logo: LogoExpectation): Promise<void>;
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { expect } from '@playwright/test';
3
+ export async function expectImageLoaded(image) {
4
+ await expect(image, 'The image should be visible on the page').toBeVisible();
5
+ await expect
6
+ .poll(() => image.evaluate((el) => el.naturalWidth), {
7
+ message: 'The image is on the page but did not load (its width is 0, so it is probably broken)',
8
+ })
9
+ .toBeGreaterThan(0);
10
+ }
11
+ /** Compares the bytes the browser downloads for an <img> with a known hash. */
12
+ export async function expectImageSha256(image, sha256) {
13
+ const src = await image.getAttribute('src');
14
+ expect(src, 'image has a src').toBeTruthy();
15
+ const response = await image.page().request.get(new URL(src, image.page().url()).href);
16
+ expect(response.ok(), `The image file could not be downloaded (HTTP ${response.status()})`).toBe(true);
17
+ const actual = createHash('sha256').update(await response.body()).digest('hex');
18
+ expect(actual, 'The image file is not the expected one (its content hash differs)').toBe(sha256);
19
+ }
20
+ /** Asserts the product logo by DOM content. Pair with toHaveScreenshot for pixels. */
21
+ export async function expectLogo(container, logo) {
22
+ if (logo.kind === 'svg') {
23
+ await expect(container.locator('svg'), 'The product logo (SVG) should be visible').toBeVisible();
24
+ await expect(container.locator('svg title'), 'The product logo has the wrong name').toHaveText(logo.title ?? '');
25
+ return;
26
+ }
27
+ const image = container.locator('img');
28
+ await expectImageLoaded(image);
29
+ if (logo.alt) {
30
+ await expect(image, 'The product logo has the wrong alt text').toHaveAttribute('alt', logo.alt);
31
+ }
32
+ if (logo.srcContains) {
33
+ await expect(image, 'The product logo comes from an unexpected file').toHaveAttribute('src', new RegExp(escapeRegExp(logo.srcContains)));
34
+ }
35
+ if (logo.sha256) {
36
+ await expectImageSha256(image, logo.sha256);
37
+ }
38
+ }
39
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -0,0 +1,43 @@
1
+ /** One screenshot baseline of a spec, with the target card that says what it must show. */
2
+ export interface BaselineInfo {
3
+ caseId: string;
4
+ /** File name inside the `<spec>-snapshots` folder, e.g. `preview-logo-example-staging-darwin.png`. */
5
+ file: string;
6
+ /** Path relative to the project root. */
7
+ rel: string;
8
+ /** The profile and environment in the file name; null when the name does not follow `<name>-<profile>-<env>-<platform>.png`. */
9
+ profile: string | null;
10
+ env: string | null;
11
+ /** New or modified in git (not committed yet). */
12
+ changed: boolean;
13
+ size: number;
14
+ mtimeMs: number;
15
+ targetId: string | null;
16
+ /** The card of the target; null when the spec names none or the case has no such card. */
17
+ card: {
18
+ id: string;
19
+ name: string;
20
+ what: string | null;
21
+ where: string | null;
22
+ not: string | null;
23
+ pages: string | null;
24
+ region: string | null;
25
+ } | null;
26
+ /** Why nobody can tell what the image must show. */
27
+ problem: string | null;
28
+ }
29
+ export declare const snapshotsDir: (caseId: string) => string;
30
+ /** Project-relative paths below `tests/` that git reports as new or modified. */
31
+ export declare function gitChangedTests(): Set<string>;
32
+ /** The target a `toHaveScreenshot('<name>.png')` belongs to: the nearest `// target: T<n>` above it. */
33
+ export declare function targetOfScreenshot(specText: string, name: string): string | null;
34
+ /** The target of a PDF region check: its `name: '<name>'` is the baseline name without `-p<page>`; the target comment is above the call. */
35
+ export declare function targetOfPdfRegion(specText: string, name: string): string | null;
36
+ /** Splits `<name>-<profile-id>-<platform>.png` using the known profile ids (Playwright adds both). */
37
+ export declare function parseBaselineName(file: string): {
38
+ name: string;
39
+ profile: string | null;
40
+ env: string | null;
41
+ };
42
+ /** The baselines of one case, or of every case (`caseIds` omitted). */
43
+ export declare function listBaselines(caseIds?: string[], changedFiles?: Set<string>): BaselineInfo[];