@asrunjs/asrun 0.6.1 → 0.8.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 (74) hide show
  1. package/.claude/settings.json +5 -5
  2. package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -103
  3. package/.claude/skills/exploring-cases/references/handoff-card.js +128 -128
  4. package/.claude/skills/making-cases/references/gaps.md +36 -36
  5. package/.claude/skills/making-cases/references/splitting.md +28 -28
  6. package/AGENTS.md +6 -0
  7. package/README.md +33 -9
  8. package/bin/asrun.mjs +19 -2
  9. package/dist/lib/ffmpeg.js +21 -10
  10. package/dist/lib/migrations.d.ts +106 -0
  11. package/dist/lib/migrations.js +106 -0
  12. package/dist/lib/paths.d.ts +9 -0
  13. package/dist/lib/paths.js +12 -1
  14. package/dist/lib/registry.d.ts +31 -0
  15. package/dist/lib/registry.js +66 -0
  16. package/dist/lib/runtime-deps.d.ts +59 -0
  17. package/dist/lib/runtime-deps.js +161 -0
  18. package/dist/lib/tar.d.ts +5 -0
  19. package/dist/lib/tar.js +79 -0
  20. package/dist/lib/update-check.d.ts +52 -0
  21. package/dist/lib/update-check.js +223 -0
  22. package/dist/migrations/index.d.ts +23 -0
  23. package/dist/migrations/index.js +22 -0
  24. package/dist/migrations/manifest.json +1 -0
  25. package/dist/scripts/build.js +7 -0
  26. package/dist/scripts/cli.js +33 -7
  27. package/dist/scripts/doctor.js +51 -9
  28. package/dist/scripts/go.mjs +43 -11
  29. package/dist/scripts/heal-evidence.mjs +149 -143
  30. package/dist/scripts/init.d.ts +9 -1
  31. package/dist/scripts/init.js +127 -23
  32. package/dist/scripts/migrate.d.ts +1 -0
  33. package/dist/scripts/migrate.js +259 -0
  34. package/dist/scripts/reset.d.ts +1 -0
  35. package/dist/scripts/reset.js +233 -0
  36. package/dist/scripts/run.js +25 -24
  37. package/dist/scripts/upgrade.d.ts +1 -0
  38. package/dist/scripts/upgrade.js +256 -0
  39. package/dist/ui/server/app.js +18 -0
  40. package/dist/ui/server/index.js +3 -0
  41. package/dist/ui/server/update.d.ts +18 -0
  42. package/dist/ui/server/update.js +87 -0
  43. package/dist/ui/shared/types.d.ts +24 -0
  44. package/docs/agents/framework-dev.md +99 -1
  45. package/docs/commands.md +96 -9
  46. package/docs/install.md +92 -19
  47. package/docs/troubleshooting.md +43 -14
  48. package/package.json +35 -33
  49. package/templates/.claude/settings.json +5 -5
  50. package/templates/AGENTS.md +1 -0
  51. package/templates/CLAUDE.md +1 -1
  52. package/templates/gitignore +13 -13
  53. package/templates/package.json +33 -32
  54. package/templates/playwright.config.ts +3 -3
  55. package/templates/pnpm-workspace.yaml +8 -5
  56. package/templates/seed.spec.ts +9 -9
  57. package/templates/tsconfig.json +14 -14
  58. package/ui/dist/assets/{dist-vmf3fnkM.js → dist-BC_P1Gnf.js} +1 -1
  59. package/ui/dist/assets/{dist-O_RVi4tP.js → dist-BgPbx2GZ.js} +1 -1
  60. package/ui/dist/assets/{dist-jPS6HCJ5.js → dist-BpkTvixI.js} +1 -1
  61. package/ui/dist/assets/{dist-DEPJZ3Me.js → dist-CJiwxr1r.js} +1 -1
  62. package/ui/dist/assets/{dist-CPLhRzom.js → dist-CavmZIv7.js} +1 -1
  63. package/ui/dist/assets/{dist-DZ7RiqmA.js → dist-ChBHX4rh.js} +1 -1
  64. package/ui/dist/assets/{dist-BudNAjOv.js → dist-D6jmnDr-.js} +1 -1
  65. package/ui/dist/assets/{dist-C-eCSFja.js → dist-DoChb6QQ.js} +1 -1
  66. package/ui/dist/assets/{dist-LixzGniF.js → dist-Do_MhHKF.js} +1 -1
  67. package/ui/dist/assets/{dist-CO4heSzh.js → dist-N-o4QIaZ.js} +1 -1
  68. package/ui/dist/assets/index-Cy2l91qV.css +2 -0
  69. package/ui/dist/assets/{index-BIGt8Hfz.js → index-DGQ5logJ.js} +129 -129
  70. package/ui/dist/favicon.svg +11 -11
  71. package/ui/dist/index.html +2 -2
  72. package/ui/dist/logos/claude.svg +1 -1
  73. package/ui/dist/logos/opencode.svg +1 -1
  74. package/ui/dist/assets/index-CTmnF0Wp.css +0 -2
@@ -1,6 +1,11 @@
1
1
  // `pnpm asrun <command>`: a thin dispatcher so every subcommand keeps one entry point.
2
2
  // Anything that is not a subcommand is passed to the run command unchanged.
3
+ import { announceUpdate } from '../lib/update-check.js';
3
4
  const [command, ...rest] = process.argv.slice(2);
5
+ // The same courtesy the installed `asrun` command shows: one line on stderr when the registry has a newer release.
6
+ // `upgrade` reads the registry itself, and `help` has no work to do.
7
+ if (!['upgrade', 'help', '--help-init'].includes(command ?? '') && !process.argv.includes('--help'))
8
+ announceUpdate();
4
9
  async function main() {
5
10
  if (command === 'init') {
6
11
  const { main: init } = await import('./init.js');
@@ -12,17 +17,38 @@ async function main() {
12
17
  process.exitCode = await doctor(rest);
13
18
  return;
14
19
  }
20
+ if (command === 'reset') {
21
+ const { main: reset } = await import('./reset.js');
22
+ process.exitCode = await reset(rest);
23
+ return;
24
+ }
25
+ if (command === 'upgrade') {
26
+ const { main: upgrade } = await import('./upgrade.js');
27
+ process.exitCode = await upgrade(rest);
28
+ return;
29
+ }
30
+ if (command === 'migrate') {
31
+ const { main: migrate } = await import('./migrate.js');
32
+ process.exitCode = await migrate(rest);
33
+ return;
34
+ }
15
35
  if (command === 'help' || command === '--help-init') {
16
36
  console.log(USAGE);
17
37
  return;
18
38
  }
19
39
  await import('./run.js');
20
40
  }
21
- const USAGE = `Usage: pnpm asrun [init|doctor] [options]
22
-
23
- (no subcommand) Run the tests of one environment. See "pnpm asrun --help".
24
- init Create the files a workspace needs (never overwrites; --update refreshes generated files,
25
- --root <dir> targets another folder, --force <path> overwrites one file).
26
- doctor Check the workspace: profiles, tsconfig alias, generated files, skill overrides.`;
41
+ const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate] [options]
42
+
43
+ (no subcommand) Run the tests of one environment. See "pnpm asrun --help".
44
+ init Create the files a workspace needs (never overwrites; --update refreshes generated files,
45
+ --root <dir> targets another folder, --force overwrites every file init writes,
46
+ --force <path> overwrites one file).
47
+ doctor Check the workspace: profiles, tsconfig alias, generated files, skill overrides, migrations.
48
+ reset Remove node_modules and the files init writes, for a clean reinstall (keeps cases, specs,
49
+ profiles.yaml, package.json and edited scaffold files; see "pnpm asrun reset --help").
50
+ upgrade Install the newest asrun, refresh the workspace files and apply its migrations, after checking the
51
+ target release for breaking changes (see "pnpm asrun upgrade --help").
52
+ migrate Apply the workspace migrations of the installed release, or list them (see "pnpm asrun migrate
53
+ --help"). "upgrade" runs it; use it after a manual install.`;
27
54
  await main();
28
- export {};
@@ -3,7 +3,10 @@ import path from 'node:path';
3
3
  import { loadProfiles, listEnvs, PROFILES_FILE } from '../lib/profiles.js';
4
4
  import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR } from '../lib/paths.js';
5
5
  import { spawnTarget } from '../lib/command.js';
6
+ import { migrationState, pendingMigrations } from '../lib/migrations.js';
7
+ import { MIGRATIONS } from '../migrations/index.js';
6
8
  import { GENERATED, frameworkPath, frameworkVersion, PACKAGE, renderTemplate } from './init.js';
9
+ import { BUILD_APPROVALS, RUNTIME_EXTRAS, hasCapability, installedInWorkspace } from '../lib/runtime-deps.js';
7
10
  import { builtFile, COPIED_SCRIPTS } from '../lib/build-artifacts.js';
8
11
  /**
9
12
  * Checks that a workspace is complete and consistent with the framework it points at. It never writes anything.
@@ -87,9 +90,11 @@ export async function main(args) {
87
90
  errors.push(`${PACKAGE} is not installed in this workspace: run "pnpm add -D ${PACKAGE}", then "pnpm asrun init". ` +
88
91
  'Every script that calls "asrun" fails until then.');
89
92
  }
90
- // The manifest, written by init: it names where the workspace reaches the framework.
93
+ // The manifest, written by init: it names where the workspace reaches the framework, and since when the workspace
94
+ // has been migrated.
91
95
  const manifest = read(path.join(ROOT, '.asrun.json'));
92
96
  let recordedPath;
97
+ let marker = null;
93
98
  const installedVersion = frameworkVersion();
94
99
  if (manifest === null) {
95
100
  errors.push('No .asrun.json: this workspace was never initialized, or "init" stopped before it finished (for example the ' +
@@ -98,10 +103,12 @@ export async function main(args) {
98
103
  else {
99
104
  try {
100
105
  const parsed = JSON.parse(manifest);
106
+ marker = parsed;
101
107
  recordedPath = parsed.frameworkPath;
102
- facts.push(`version: asrun ${parsed.version ?? '?'} (${parsed.commit ?? '?'})`);
103
- if (parsed.version !== undefined && parsed.version !== installedVersion.version) {
104
- warnings.push(`asrun is installed at ${installedVersion.version}, but this workspace was set up with ${parsed.version}: run "pnpm asrun init --update".`);
108
+ facts.push(`version: asrun ${typeof parsed.version === 'string' ? parsed.version : '?'} (${parsed.commit ?? '?'})`);
109
+ if (typeof parsed.version === 'string' && parsed.version !== installedVersion.version) {
110
+ warnings.push(`asrun is installed at ${installedVersion.version}, but this workspace was set up with ${parsed.version}: ` +
111
+ 'run "pnpm asrun upgrade" (or reinstall the version this workspace needs).');
105
112
  }
106
113
  if (recordedPath !== undefined && !fs.existsSync(path.resolve(ROOT, recordedPath))) {
107
114
  errors.push(`.asrun.json points at "${recordedPath}", which does not exist. Run "pnpm add -D @asrunjs/asrun" and then "pnpm asrun init".`);
@@ -112,6 +119,27 @@ export async function main(args) {
112
119
  }
113
120
  }
114
121
  const framework = recordedPath ?? actualPath;
122
+ // Workspace migrations: `migrations.since` is the release the workspace matches, so newer ones are still to do. A
123
+ // workspace upgraded by hand (pnpm add + init --update) keeps the old baseline, which is what makes this check work.
124
+ if (marker !== null) {
125
+ try {
126
+ const state = migrationState(marker, installedVersion.version);
127
+ const outstanding = state.manual.length;
128
+ facts.push(`migrations: since ${state.since}${outstanding > 0 ? `, ${outstanding} manual step(s) outstanding` : ''}`);
129
+ const pending = pendingMigrations(MIGRATIONS, state.since, installedVersion.version);
130
+ if (pending.length > 0) {
131
+ warnings.push(`${pending.length} workspace migration(s) apply between ${state.since} and ${installedVersion.version} ` +
132
+ `(${pending.map((migration) => migration.id).join(', ')}): run "pnpm asrun migrate".`);
133
+ }
134
+ if (outstanding > 0) {
135
+ warnings.push(`Manual migration steps are still open (${state.manual.join(', ')}): run "pnpm asrun migrate --list" to see ` +
136
+ 'them, then "pnpm asrun migrate --ack <id>" once they are done.');
137
+ }
138
+ }
139
+ catch (error) {
140
+ errors.push(`The migration registry is invalid: ${error instanceof Error ? error.message : String(error)}`);
141
+ }
142
+ }
115
143
  // The framework repository compiles itself into `dist/`, and `templates/playwright.config.ts` makes every workspace
116
144
  // load the config from there. A build older than the sources therefore keeps runs on the previous code.
117
145
  const stale = staleDistFiles();
@@ -231,13 +259,27 @@ export async function main(args) {
231
259
  // The framework is outside the workspace (examples/demo): the framework repository manages itself.
232
260
  }
233
261
  else if (workspaceFile === null) {
234
- warnings.push('pnpm-workspace.yaml is missing: add the "allowBuilds" block from templates/pnpm-workspace.yaml, or pnpm skips the build scripts of esbuild and ffmpeg-static.');
262
+ warnings.push(`pnpm-workspace.yaml is missing: run "pnpm asrun init" (it writes the "allowBuilds" entries for ${BUILD_APPROVALS.join(', ')}), or pnpm skips those install scripts.`);
235
263
  }
236
- else if (!workspaceFile.includes('allowBuilds')) {
237
- warnings.push('pnpm-workspace.yaml has no "allowBuilds": pnpm skips the build scripts of esbuild and ffmpeg-static.');
264
+ else {
265
+ const missing = BUILD_APPROVALS.filter((pkg) => {
266
+ const entry = new RegExp(`^\\s+['"]?${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?\\s*:`, 'm');
267
+ return !entry.test(workspaceFile);
268
+ });
269
+ if (missing.length > 0) {
270
+ warnings.push(`pnpm-workspace.yaml has no "allowBuilds" entry for ${missing.join(', ')}: run "pnpm asrun init" (it adds ` +
271
+ 'the entry before installing), or pnpm skips that install script.');
272
+ }
273
+ if (/^packages\s*:/m.test(workspaceFile)) {
274
+ warnings.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
275
+ }
238
276
  }
239
- else if (/^packages\s*:/m.test(workspaceFile)) {
240
- warnings.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
277
+ // Optional packages the workspace installs on demand, when this machine does not provide the capability itself.
278
+ for (const extra of RUNTIME_EXTRAS) {
279
+ if (hasCapability(extra) || installedInWorkspace(ROOT, extra.pkg) || installedInWorkspace(FRAMEWORK_ROOT, extra.pkg))
280
+ continue;
281
+ warnings.push(`${extra.pkg} is not installed and this machine does not provide it: ${extra.provides} is skipped. Run ` +
282
+ '"pnpm asrun init" (or "pnpm go") to install it, or install the tool yourself and run doctor again.');
241
283
  }
242
284
  let scripts = null;
243
285
  try {
@@ -4,16 +4,25 @@
4
4
  // pnpm add -D @asrunjs/asrun (or github:wizcas/asrun#<tag>)
5
5
  // pnpm exec asrun init
6
6
  // pnpm go
7
+ //
8
+ // `--no-open` does not open a browser. `--no-ui` stops after step 3 (the workspace is prepared, no server starts).
9
+ // `--dry-run` only prints what the command resolved (workspace, framework, the asrun command) and exits.
7
10
  import {spawn, spawnSync} from 'node:child_process';
8
11
  import fs from 'node:fs';
9
12
  import path from 'node:path';
10
13
 
11
- const FRAMEWORK = path.resolve(import.meta.dirname, '..');
14
+ // The package root: `scripts/go.mjs` in a development checkout, `dist/scripts/go.mjs` in an installed package, so the
15
+ // same rule as lib/paths.ts. `bin/`, `examples/` and the framework's `.env` hang off this folder, never off `dist/`.
16
+ const FRAMEWORK = path.basename(path.dirname(import.meta.dirname)) === 'dist'
17
+ ? path.resolve(import.meta.dirname, '..', '..')
18
+ : path.resolve(import.meta.dirname, '..');
12
19
  const PACKAGE = '@asrunjs/asrun';
13
20
  const MARKERS = ['.asrun.json', 'profiles.yaml'];
14
21
  const port = Number(process.env.ASRUN_UI_PORT ?? 4173);
15
22
  const url = `http://127.0.0.1:${port}`;
16
23
  const openBrowser = !process.argv.includes('--no-open');
24
+ const noUi = process.argv.includes('--no-ui');
25
+ const dryRun = process.argv.includes('--dry-run');
17
26
  const shell = process.platform === 'win32';
18
27
 
19
28
  // `.env` next to the command, or the framework's own `.env`; a real environment variable always wins.
@@ -33,8 +42,9 @@ function loadRootEnv(file) {
33
42
  loadRootEnv(path.join(process.cwd(), '.env'));
34
43
  if (process.env.ASRUN_ROOT === undefined) loadRootEnv(path.join(FRAMEWORK, '.env'));
35
44
 
36
- // The workspace: the project that owns profiles.yaml, cases/ and tests/. Inside the framework repository
37
- // (which has none of those) the demo workspace is used, so the framework itself can be developed this way.
45
+ // The workspace: the project that owns profiles.yaml, cases/ and tests/. Inside the framework repository (which has
46
+ // none of those) the demo workspace is used, so the framework itself can be developed this way. An installed package
47
+ // that ships no `examples/` and runs outside a project has neither: it says so instead of preparing a missing folder.
38
48
  function findWorkspace(from) {
39
49
  for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
40
50
  if (MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) return dir;
@@ -42,11 +52,16 @@ function findWorkspace(from) {
42
52
  }
43
53
  }
44
54
  const demo = path.join(FRAMEWORK, 'examples', 'demo');
45
- const ROOT = (process.env.ASRUN_ROOT && path.resolve(process.env.ASRUN_ROOT)) || findWorkspace(process.cwd()) || demo;
46
- if (ROOT === FRAMEWORK && !fs.existsSync(demo)) {
55
+ const chosen = process.env.ASRUN_ROOT ? path.resolve(process.env.ASRUN_ROOT) : findWorkspace(process.cwd());
56
+ const ROOT = chosen ?? (fs.existsSync(demo) ? demo : null);
57
+ if (ROOT === null) {
47
58
  console.error('No workspace found: run this command in the project that uses asrun, or set ASRUN_ROOT.');
48
59
  process.exit(1);
49
60
  }
61
+ if (!fs.existsSync(ROOT)) {
62
+ console.error(`${ROOT} does not exist: check ASRUN_ROOT (or the .env that sets it).`);
63
+ process.exit(1);
64
+ }
50
65
 
51
66
  const step = (n, text) => console.log(`\n\x1b[1;36m[${n}/4] ${text}\x1b[0m`);
52
67
  const warn = (text) => console.log(`\x1b[33m! ${text}\x1b[0m`);
@@ -72,7 +87,10 @@ function packageBin(name, command = name) {
72
87
  }
73
88
 
74
89
  /** The installed command of this workspace, or the framework's own bin while developing it here. */
75
- const bin = packageBin(PACKAGE) ?? path.join(FRAMEWORK, 'bin', 'asrun.mjs');
90
+ const bin = packageBin(PACKAGE, 'asrun') ?? path.join(FRAMEWORK, 'bin', 'asrun.mjs');
91
+ if (!fs.existsSync(bin)) {
92
+ fail(`The asrun command is missing: ${bin}\nReinstall the package: "pnpm add -D @asrunjs/asrun".`);
93
+ }
76
94
  /** The workspace's copy of the package, if any. */
77
95
  const installed = fs.existsSync(path.join(ROOT, 'node_modules', PACKAGE, 'package.json'));
78
96
  const frameworkRelative = installed
@@ -82,13 +100,21 @@ const frameworkRelative = installed
82
100
  return relative === '' ? '.' : relative.startsWith('.') ? relative : `./${relative}`;
83
101
  })();
84
102
 
103
+ if (dryRun) {
104
+ console.log(`workspace: ${ROOT}`);
105
+ console.log(`framework: ${FRAMEWORK}${installed ? ' (installed)' : ' (development checkout)'}`);
106
+ console.log(`command: ${bin}`);
107
+ process.exit(0);
108
+ }
109
+
85
110
  /** pnpm only installs dependencies normally; its build scripts need an explicit approval in pnpm-workspace.yaml. */
86
111
  function ensurePnpmWorkspace() {
87
112
  if (frameworkRelative.startsWith('..')) return 'skipped';
88
113
  const file = path.join(ROOT, 'pnpm-workspace.yaml');
89
- const block = 'allowBuilds:\n esbuild: true\n ffmpeg-static: true\n';
114
+ // `init` (step 3) adds the entry of each optional package it installs on demand, e.g. ffmpeg-static.
115
+ const block = 'allowBuilds:\n esbuild: true\n';
90
116
  if (!fs.existsSync(file)) {
91
- fs.writeFileSync(file, `# asrun's dependencies (esbuild through tsx, ffmpeg-static) compile a binary on install.\n${block}`);
117
+ fs.writeFileSync(file, `# asrun's dependencies (esbuild through tsx) compile a binary on install.\n${block}`);
92
118
  return 'created';
93
119
  }
94
120
  const text = fs.readFileSync(file, 'utf8');
@@ -115,7 +141,8 @@ function open(target) {
115
141
  spawn(command, args, {stdio: 'ignore', detached: true}).on('error', () => {}).unref();
116
142
  }
117
143
 
118
- // ffmpeg joins the videos of a test's browser tabs into one. Never fatal: the optional ffmpeg-static package is the fallback.
144
+ // ffmpeg joins the videos of a test's browser tabs into one. Never fatal: `init` (step 3) installs the bundled
145
+ // ffmpeg-static when this machine has no ffmpeg.
119
146
  function ensureFfmpeg() {
120
147
  // ffmpeg does not know `--version`, only `-version`.
121
148
  if (spawnSync('ffmpeg', ['-version'], {stdio: 'ignore', shell}).status === 0) return console.log('ffmpeg: ok');
@@ -126,11 +153,11 @@ function ensureFfmpeg() {
126
153
  ? ['winget', ['install', '--id', 'Gyan.FFmpeg', '-e', '--accept-source-agreements', '--accept-package-agreements']]
127
154
  : null;
128
155
  if (!installer) {
129
- return warn('ffmpeg not found; the bundled ffmpeg-static is used instead. To install it yourself: brew install ffmpeg | winget install Gyan.FFmpeg | sudo apt install ffmpeg');
156
+ return warn('ffmpeg not found; the bundled ffmpeg-static is installed when the workspace is prepared, in step 3. To install ffmpeg yourself: brew install ffmpeg | winget install Gyan.FFmpeg | sudo apt install ffmpeg');
130
157
  }
131
158
  console.log(`ffmpeg not found, installing it: ${installer[0]} ${installer[1].join(' ')}`);
132
159
  if (run(...installer)) console.log('ffmpeg: installed');
133
- else warn('Could not install ffmpeg; the bundled ffmpeg-static is used instead.');
160
+ else warn('Could not install ffmpeg; the bundled ffmpeg-static is installed when the workspace is prepared, in step 3.');
134
161
  }
135
162
 
136
163
  step(1, 'Checking this machine');
@@ -164,6 +191,11 @@ if (!run(process.execPath, [bin, 'init', '--root', ROOT], {cwd: process.cwd()}))
164
191
  }
165
192
  run(process.execPath, [bin, 'doctor']);
166
193
 
194
+ if (noUi) {
195
+ console.log(`\n\x1b[1;32mWorkspace ready: ${ROOT}\x1b[0m (not starting the web UI)`);
196
+ process.exit(0);
197
+ }
198
+
167
199
  step(4, `Starting the web UI at ${url}`);
168
200
  if (await uiIsUp()) {
169
201
  console.log('The UI is already running.');
@@ -1,143 +1,149 @@
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(',')}`);
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 package root: `scripts/heal-evidence.mjs` in a development checkout, `dist/scripts/heal-evidence.mjs` installed.
8
+ // The same rule as lib/paths.ts: a compiled file sits one folder deeper.
9
+ const FRAMEWORK = path.basename(path.dirname(import.meta.dirname)) === 'dist'
10
+ ? path.resolve(import.meta.dirname, '..', '..')
11
+ : path.resolve(import.meta.dirname, '..');
12
+
13
+ // The workspace root: the project that uses the framework. Same rule as lib/paths.ts, in plain Node.
14
+ const findWorkspace = (from) => {
15
+ for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
16
+ if (fs.existsSync(path.join(dir, 'profiles.yaml')) || fs.existsSync(path.join(dir, '.asrun.json'))) return dir;
17
+ if (path.dirname(dir) === dir) return null;
18
+ }
19
+ };
20
+ // `.env` next to the command, or the framework's own `.env`; a real environment variable always wins.
21
+ const loadRootEnv = (file) => {
22
+ try {
23
+ for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
24
+ if (process.env.ASRUN_ROOT !== undefined) return;
25
+ const match = /^(?:export\s+)?ASRUN_ROOT\s*=\s*(.*)$/.exec(raw.trim());
26
+ if (match) {
27
+ process.env.ASRUN_ROOT = path.resolve(path.dirname(file), match[1].trim().replace(/^(['"])(.*)\1$/, '$2'));
28
+ }
29
+ }
30
+ } catch {
31
+ // no .env: nothing to do
32
+ }
33
+ };
34
+ loadRootEnv(path.join(process.cwd(), '.env'));
35
+ if (process.env.ASRUN_ROOT === undefined) loadRootEnv(path.join(FRAMEWORK, '.env'));
36
+ const ROOT = process.env.ASRUN_ROOT
37
+ ? path.resolve(process.env.ASRUN_ROOT)
38
+ : (findWorkspace(process.cwd()) ?? FRAMEWORK);
39
+ const REPORTS = path.join(ROOT, 'reports');
40
+ const RUN_ID = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(-[a-z0-9]+(-[a-z0-9]+)*)?$/;
41
+ const NOT_LOGGED_IN = /is not logged in to/;
42
+
43
+ const args = process.argv.slice(2);
44
+ const option = (name) => {
45
+ const index = args.indexOf(name);
46
+ return index >= 0 ? args[index + 1] : undefined;
47
+ };
48
+ const caseId = option('--case');
49
+ const env = option('--env');
50
+ const profileFilter = (option('--profile') ?? '').split(',').filter(Boolean);
51
+ const keep = Number(option('--max-reports') ?? 1);
52
+
53
+ if (args.includes('--help') || !caseId || !env) {
54
+ console.log('Usage: pnpm heal:evidence --case <case-id> --env <env> [--profile a,b] [--max-reports 1]');
55
+ process.exit(args.includes('--help') ? 0 : 1);
56
+ }
57
+
58
+ const readSummary = (id) => {
59
+ try {
60
+ return JSON.parse(fs.readFileSync(path.join(REPORTS, id, 'summary.json'), 'utf8'));
61
+ } catch {
62
+ return null;
63
+ }
64
+ };
65
+ const mtime = (file) => (fs.existsSync(file) ? fs.statSync(file).mtimeMs : 0);
66
+ const iso = (ms) => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');
67
+ const firstLines = (text, count) => text.split('\n').slice(0, count).join('\n');
68
+ const indent = (text, prefix) => text.split('\n').map((line) => prefix + line).join('\n');
69
+
70
+ const runIds = fs.existsSync(REPORTS)
71
+ ? fs.readdirSync(REPORTS).filter((name) => RUN_ID.test(name)).sort().reverse()
72
+ : [];
73
+
74
+ const matches = [];
75
+ for (const id of runIds) {
76
+ const summary = readSummary(id);
77
+ if (!summary) continue;
78
+ const runEnv = summary.env ?? /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)$/.exec(id)?.[1] ?? 'production';
79
+ if (runEnv !== env) continue;
80
+ const results = summary.results.filter(
81
+ (result) => result.caseId === caseId && (profileFilter.length === 0 || profileFilter.includes(result.profile))
82
+ );
83
+ if (results.length === 0) continue;
84
+ matches.push({id, summary, results});
85
+ if (matches.length >= keep) break;
86
+ }
87
+
88
+ console.log(`Heal evidence for case "${caseId}" in ${env}`);
89
+
90
+ if (matches.length === 0) {
91
+ console.log(`\nNO REPORT: no run in ${env} has results for this case${profileFilter.length ? ` and these profiles (${profileFilter})` : ''}.`);
92
+ console.log('EVIDENCE: none -> run the case once: pnpm asrun --env ' + env + ' --logged-in --grep ' + caseId);
93
+ process.exit(0);
94
+ }
95
+
96
+ const watched = [
97
+ ['spec', path.join(ROOT, 'tests', `${caseId}.spec.ts`)],
98
+ ['case', path.join(ROOT, 'cases', `${caseId}.md`)],
99
+ ['profiles.yaml', path.join(ROOT, 'profiles.yaml')],
100
+ ];
101
+
102
+ let newest = null;
103
+ for (const {id, summary, results} of matches) {
104
+ const endedAt = Date.parse(summary.startedAt) + summary.durationMs;
105
+ const changed = watched
106
+ .map(([label, file]) => ({label, at: mtime(file)}))
107
+ .filter((item) => item.at > endedAt);
108
+ newest ??= {changed};
109
+
110
+ console.log(`\n=== Report ${id} (${summary.startedAt}) ===`);
111
+ console.log(`report: reports/${id}/summary.json html: reports/${id}/html`);
112
+ if (changed.length > 0) {
113
+ console.log(`STALE: changed after this run: ${changed.map((item) => `${item.label} (${iso(item.at)})`).join(', ')}`);
114
+ } else {
115
+ console.log('FRESH: the spec, the case and profiles.yaml have not changed since this run.');
116
+ }
117
+ if (summary.errors?.length) {
118
+ console.log('Run-level errors (not tied to a test):');
119
+ for (const error of summary.errors) console.log(indent(error.headline, ' - '));
120
+ }
121
+
122
+ for (const result of results) {
123
+ const loginProblem = result.errors.some((error) => NOT_LOGGED_IN.test(error.headline));
124
+ console.log(`\n[${result.profile}] ${result.status.toUpperCase()} (${result.attempts} attempt${result.attempts === 1 ? '' : 's'}, ${result.durationMs} ms)`);
125
+ if (result.status === 'passed' || result.status === 'skipped') continue;
126
+ if (loginProblem) {
127
+ console.log(' Not a test failure: the profile was not logged in during this run. Not evidence about the spec.');
128
+ continue;
129
+ }
130
+ if (result.failedStep) console.log(` failed step: ${result.failedStep}`);
131
+ for (const error of result.errors) {
132
+ console.log(indent(error.headline, ' error: '));
133
+ if (error.context) console.log(indent(firstLines(error.context, 8), ' '));
134
+ if (error.callLog) console.log(indent(firstLines(error.callLog, 12), ' log: '));
135
+ if (error.snippet) console.log(indent(firstLines(error.snippet, 8), ' code: '));
136
+ }
137
+ for (const file of result.screenshots) console.log(` screenshot: reports/${id}/html/${file}`);
138
+ for (const file of result.videos) console.log(` video: reports/${id}/html/${file}`);
139
+ }
140
+ }
141
+
142
+ const usable = matches[0].results.filter(
143
+ (result) =>
144
+ (result.status === 'failed' || result.status === 'flaky') &&
145
+ !result.errors.some((error) => NOT_LOGGED_IN.test(error.headline))
146
+ );
147
+ const state = newest.changed.length > 0 ? 'stale' : usable.length === 0 ? 'no-failure' : 'fresh';
148
+ console.log(`\nEVIDENCE: ${state}`);
149
+ if (usable.length > 0) console.log(`failing profiles in the newest report: ${usable.map((result) => result.profile).join(',')}`);
@@ -1,9 +1,17 @@
1
+ import { MARKER, PACKAGE } from '../lib/paths.js';
1
2
  /** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
2
3
  export declare const GENERATED: {
3
4
  file: string;
4
5
  template: string;
5
6
  }[];
6
- export declare const PACKAGE = "@asrunjs/asrun";
7
+ /** Files `init` creates once; after that the project owns them and may edit them freely. */
8
+ export declare const ONCE: {
9
+ file: string;
10
+ template: string;
11
+ }[];
12
+ export { MARKER };
13
+ export { PACKAGE };
14
+ export declare const DOTENV_HEADER = "# asrun: the workspace these commands run against (relative to this file).";
7
15
  /** How the workspace reaches the framework: `./node_modules/@asrunjs/asrun`, or a relative folder in the framework repo. */
8
16
  export declare function frameworkPath(root: string): string;
9
17
  /** True when this workspace has the package installed (or is the framework's own checkout). */