@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.
- package/.claude/settings.json +5 -5
- package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -103
- package/.claude/skills/exploring-cases/references/handoff-card.js +128 -128
- package/.claude/skills/making-cases/references/gaps.md +36 -36
- package/.claude/skills/making-cases/references/splitting.md +28 -28
- package/AGENTS.md +6 -0
- package/README.md +33 -9
- package/bin/asrun.mjs +19 -2
- package/dist/lib/ffmpeg.js +21 -10
- package/dist/lib/migrations.d.ts +106 -0
- package/dist/lib/migrations.js +106 -0
- package/dist/lib/paths.d.ts +9 -0
- package/dist/lib/paths.js +12 -1
- package/dist/lib/registry.d.ts +31 -0
- package/dist/lib/registry.js +66 -0
- package/dist/lib/runtime-deps.d.ts +59 -0
- package/dist/lib/runtime-deps.js +161 -0
- package/dist/lib/tar.d.ts +5 -0
- package/dist/lib/tar.js +79 -0
- package/dist/lib/update-check.d.ts +52 -0
- package/dist/lib/update-check.js +223 -0
- package/dist/migrations/index.d.ts +23 -0
- package/dist/migrations/index.js +22 -0
- package/dist/migrations/manifest.json +1 -0
- package/dist/scripts/build.js +7 -0
- package/dist/scripts/cli.js +33 -7
- package/dist/scripts/doctor.js +51 -9
- package/dist/scripts/go.mjs +43 -11
- package/dist/scripts/heal-evidence.mjs +149 -143
- package/dist/scripts/init.d.ts +9 -1
- package/dist/scripts/init.js +127 -23
- package/dist/scripts/migrate.d.ts +1 -0
- package/dist/scripts/migrate.js +259 -0
- package/dist/scripts/reset.d.ts +1 -0
- package/dist/scripts/reset.js +233 -0
- package/dist/scripts/run.js +25 -24
- package/dist/scripts/upgrade.d.ts +1 -0
- package/dist/scripts/upgrade.js +256 -0
- package/dist/ui/server/app.js +18 -0
- package/dist/ui/server/index.js +3 -0
- package/dist/ui/server/update.d.ts +18 -0
- package/dist/ui/server/update.js +87 -0
- package/dist/ui/shared/types.d.ts +24 -0
- package/docs/agents/framework-dev.md +99 -1
- package/docs/commands.md +96 -9
- package/docs/install.md +92 -19
- package/docs/troubleshooting.md +43 -14
- package/package.json +35 -33
- package/templates/.claude/settings.json +5 -5
- package/templates/AGENTS.md +1 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/gitignore +13 -13
- package/templates/package.json +33 -32
- package/templates/playwright.config.ts +3 -3
- package/templates/pnpm-workspace.yaml +8 -5
- package/templates/seed.spec.ts +9 -9
- package/templates/tsconfig.json +14 -14
- package/ui/dist/assets/{dist-vmf3fnkM.js → dist-BC_P1Gnf.js} +1 -1
- package/ui/dist/assets/{dist-O_RVi4tP.js → dist-BgPbx2GZ.js} +1 -1
- package/ui/dist/assets/{dist-jPS6HCJ5.js → dist-BpkTvixI.js} +1 -1
- package/ui/dist/assets/{dist-DEPJZ3Me.js → dist-CJiwxr1r.js} +1 -1
- package/ui/dist/assets/{dist-CPLhRzom.js → dist-CavmZIv7.js} +1 -1
- package/ui/dist/assets/{dist-DZ7RiqmA.js → dist-ChBHX4rh.js} +1 -1
- package/ui/dist/assets/{dist-BudNAjOv.js → dist-D6jmnDr-.js} +1 -1
- package/ui/dist/assets/{dist-C-eCSFja.js → dist-DoChb6QQ.js} +1 -1
- package/ui/dist/assets/{dist-LixzGniF.js → dist-Do_MhHKF.js} +1 -1
- package/ui/dist/assets/{dist-CO4heSzh.js → dist-N-o4QIaZ.js} +1 -1
- package/ui/dist/assets/index-Cy2l91qV.css +2 -0
- package/ui/dist/assets/{index-BIGt8Hfz.js → index-DGQ5logJ.js} +129 -129
- package/ui/dist/favicon.svg +11 -11
- package/ui/dist/index.html +2 -2
- package/ui/dist/logos/claude.svg +1 -1
- package/ui/dist/logos/opencode.svg +1 -1
- package/ui/dist/assets/index-CTmnF0Wp.css +0 -2
package/dist/scripts/cli.js
CHANGED
|
@@ -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
|
|
26
|
-
|
|
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 {};
|
package/dist/scripts/doctor.js
CHANGED
|
@@ -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
|
|
103
|
-
if (parsed.version
|
|
104
|
-
warnings.push(`asrun is installed at ${installedVersion.version}, but this workspace was set up with ${parsed.version}:
|
|
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(
|
|
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
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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 {
|
package/dist/scripts/go.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
);
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
console.log(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
for (const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1
|
+
// Reads the existing run reports (reports/<runId>/summary.json) for one case and prints what they already say,
|
|
2
|
+
// so that healing starts from the evidence instead of from a new run. It never opens a browser and never writes.
|
|
3
|
+
// Plain node (no tsx): it must work inside an agent's sandbox.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
// The 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(',')}`);
|
package/dist/scripts/init.d.ts
CHANGED
|
@@ -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
|
-
|
|
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). */
|