@asrunjs/asrun 0.5.2 → 0.6.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/AGENTS.md +10 -0
- package/LICENSE +9 -21
- package/README.md +178 -469
- package/dist/scripts/go.mjs +188 -188
- package/dist/scripts/init.js +1 -1
- package/docs/agents/case-format.md +193 -0
- package/docs/agents/framework-dev.md +94 -6
- package/docs/ai-workflow.md +235 -0
- package/docs/cases.md +189 -0
- package/docs/commands.md +175 -0
- package/docs/install.md +174 -0
- package/docs/profiles.md +139 -0
- package/docs/troubleshooting.md +101 -0
- package/docs/web-ui.md +139 -0
- package/package.json +10 -2
- package/templates/AGENTS.md +2 -0
- package/ui/dist/assets/{dist-DSy3dVf4.js → dist-BudNAjOv.js} +1 -1
- package/ui/dist/assets/{dist-Ba-HC662.js → dist-C-eCSFja.js} +1 -1
- package/ui/dist/assets/{dist-C-Oyo1ZT.js → dist-CO4heSzh.js} +1 -1
- package/ui/dist/assets/{dist-C8MdhqgG.js → dist-CPLhRzom.js} +1 -1
- package/ui/dist/assets/{dist-Dq6sW5Ur.js → dist-DEPJZ3Me.js} +1 -1
- package/ui/dist/assets/{dist-CmpteMYY.js → dist-DZ7RiqmA.js} +1 -1
- package/ui/dist/assets/{dist-BGbe0-Aa.js → dist-LixzGniF.js} +1 -1
- package/ui/dist/assets/{dist-CG1pHn5m.js → dist-O_RVi4tP.js} +1 -1
- package/ui/dist/assets/{dist-BtMLMthJ.js → dist-jPS6HCJ5.js} +1 -1
- package/ui/dist/assets/{dist-jjxQutEm.js → dist-vmf3fnkM.js} +1 -1
- package/ui/dist/assets/{index-P_Yo0ZZn.js → index-BIGt8Hfz.js} +3 -3
- package/ui/dist/index.html +1 -1
package/dist/scripts/go.mjs
CHANGED
|
@@ -1,188 +1,188 @@
|
|
|
1
|
-
// One-liner for a fresh machine: install everything, prepare the workspace, then start the web UI and open it.
|
|
2
|
-
// Plain Node on purpose: it may run before the dependencies are installed.
|
|
3
|
-
//
|
|
4
|
-
// pnpm add -D @asrunjs/asrun (or github:wizcas/asrun
|
|
5
|
-
// pnpm exec asrun init
|
|
6
|
-
// pnpm go
|
|
7
|
-
import {spawn, spawnSync} from 'node:child_process';
|
|
8
|
-
import fs from 'node:fs';
|
|
9
|
-
import path from 'node:path';
|
|
10
|
-
|
|
11
|
-
const FRAMEWORK = path.resolve(import.meta.dirname, '..');
|
|
12
|
-
const PACKAGE = '@asrunjs/asrun';
|
|
13
|
-
const MARKERS = ['.asrun.json', 'profiles.yaml'];
|
|
14
|
-
const port = Number(process.env.ASRUN_UI_PORT ?? 4173);
|
|
15
|
-
const url = `http://127.0.0.1:${port}`;
|
|
16
|
-
const openBrowser = !process.argv.includes('--no-open');
|
|
17
|
-
const shell = process.platform === 'win32';
|
|
18
|
-
|
|
19
|
-
// `.env` next to the command, or the framework's own `.env`; a real environment variable always wins.
|
|
20
|
-
function loadRootEnv(file) {
|
|
21
|
-
try {
|
|
22
|
-
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
23
|
-
if (process.env.ASRUN_ROOT !== undefined) return;
|
|
24
|
-
const match = /^(?:export\s+)?ASRUN_ROOT\s*=\s*(.*)$/.exec(raw.trim());
|
|
25
|
-
if (match) {
|
|
26
|
-
process.env.ASRUN_ROOT = path.resolve(path.dirname(file), match[1].trim().replace(/^(['"])(.*)\1$/, '$2'));
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
} catch {
|
|
30
|
-
// no .env: nothing to do
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
loadRootEnv(path.join(process.cwd(), '.env'));
|
|
34
|
-
if (process.env.ASRUN_ROOT === undefined) loadRootEnv(path.join(FRAMEWORK, '.env'));
|
|
35
|
-
|
|
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.
|
|
38
|
-
function findWorkspace(from) {
|
|
39
|
-
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
|
|
40
|
-
if (MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) return dir;
|
|
41
|
-
if (path.dirname(dir) === dir) return null;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
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)) {
|
|
47
|
-
console.error('No workspace found: run this command in the project that uses asrun, or set ASRUN_ROOT.');
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const step = (n, text) => console.log(`\n\x1b[1;36m[${n}/4] ${text}\x1b[0m`);
|
|
52
|
-
const warn = (text) => console.log(`\x1b[33m! ${text}\x1b[0m`);
|
|
53
|
-
const fail = (text) => {
|
|
54
|
-
console.error(`\x1b[31m${text}\x1b[0m`);
|
|
55
|
-
process.exit(1);
|
|
56
|
-
};
|
|
57
|
-
const run = (command, args, options = {}) =>
|
|
58
|
-
spawnSync(command, args, {cwd: ROOT, stdio: 'inherit', shell, ...options}).status === 0;
|
|
59
|
-
const exists = (command) => spawnSync(command, ['--version'], {stdio: 'ignore', shell}).status === 0;
|
|
60
|
-
|
|
61
|
-
/** The JavaScript entry point of a package's command-line tool, without relying on node_modules/.bin shims. */
|
|
62
|
-
function packageBin(name, command = name) {
|
|
63
|
-
for (const base of [FRAMEWORK, path.dirname(path.dirname(FRAMEWORK)), ROOT]) {
|
|
64
|
-
const dir = path.join(base, 'node_modules', name);
|
|
65
|
-
const manifest = path.join(dir, 'package.json');
|
|
66
|
-
if (!fs.existsSync(manifest)) continue;
|
|
67
|
-
const bin = JSON.parse(fs.readFileSync(manifest, 'utf8')).bin;
|
|
68
|
-
const entry = typeof bin === 'string' ? bin : bin?.[command];
|
|
69
|
-
if (entry) return path.join(dir, entry);
|
|
70
|
-
}
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** 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');
|
|
76
|
-
/** The workspace's copy of the package, if any. */
|
|
77
|
-
const installed = fs.existsSync(path.join(ROOT, 'node_modules', PACKAGE, 'package.json'));
|
|
78
|
-
const frameworkRelative = installed
|
|
79
|
-
? './' + path.relative(ROOT, path.join(ROOT, 'node_modules', PACKAGE)).split(path.sep).join('/')
|
|
80
|
-
: (() => {
|
|
81
|
-
const relative = path.relative(ROOT, FRAMEWORK).split(path.sep).join('/');
|
|
82
|
-
return relative === '' ? '.' : relative.startsWith('.') ? relative : `./${relative}`;
|
|
83
|
-
})();
|
|
84
|
-
|
|
85
|
-
/** pnpm only installs dependencies normally; its build scripts need an explicit approval in pnpm-workspace.yaml. */
|
|
86
|
-
function ensurePnpmWorkspace() {
|
|
87
|
-
if (frameworkRelative.startsWith('..')) return 'skipped';
|
|
88
|
-
const file = path.join(ROOT, 'pnpm-workspace.yaml');
|
|
89
|
-
const block = 'allowBuilds:\n esbuild: true\n ffmpeg-static: true\n';
|
|
90
|
-
if (!fs.existsSync(file)) {
|
|
91
|
-
fs.writeFileSync(file, `# asrun's dependencies (esbuild through tsx, ffmpeg-static) compile a binary on install.\n${block}`);
|
|
92
|
-
return 'created';
|
|
93
|
-
}
|
|
94
|
-
const text = fs.readFileSync(file, 'utf8');
|
|
95
|
-
if (text.includes('allowBuilds')) return 'kept';
|
|
96
|
-
fs.writeFileSync(file, `${text.replace(/\n*$/, '\n')}\n${block}`);
|
|
97
|
-
return 'updated';
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function uiIsUp() {
|
|
101
|
-
try {
|
|
102
|
-
return (await fetch(`${url}/api/info`, {signal: AbortSignal.timeout(1500)})).ok;
|
|
103
|
-
} catch {
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function open(target) {
|
|
109
|
-
const [command, args] =
|
|
110
|
-
process.platform === 'darwin'
|
|
111
|
-
? ['open', [target]]
|
|
112
|
-
: process.platform === 'win32'
|
|
113
|
-
? ['cmd', ['/c', 'start', '', target]]
|
|
114
|
-
: ['xdg-open', [target]];
|
|
115
|
-
spawn(command, args, {stdio: 'ignore', detached: true}).on('error', () => {}).unref();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ffmpeg joins the videos of a test's browser tabs into one. Never fatal: the optional ffmpeg-static package is the fallback.
|
|
119
|
-
function ensureFfmpeg() {
|
|
120
|
-
// ffmpeg does not know `--version`, only `-version`.
|
|
121
|
-
if (spawnSync('ffmpeg', ['-version'], {stdio: 'ignore', shell}).status === 0) return console.log('ffmpeg: ok');
|
|
122
|
-
const installer =
|
|
123
|
-
process.platform === 'darwin' && exists('brew')
|
|
124
|
-
? ['brew', ['install', 'ffmpeg']]
|
|
125
|
-
: process.platform === 'win32' && exists('winget')
|
|
126
|
-
? ['winget', ['install', '--id', 'Gyan.FFmpeg', '-e', '--accept-source-agreements', '--accept-package-agreements']]
|
|
127
|
-
: null;
|
|
128
|
-
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');
|
|
130
|
-
}
|
|
131
|
-
console.log(`ffmpeg not found, installing it: ${installer[0]} ${installer[1].join(' ')}`);
|
|
132
|
-
if (run(...installer)) console.log('ffmpeg: installed');
|
|
133
|
-
else warn('Could not install ffmpeg; the bundled ffmpeg-static is used instead.');
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
step(1, 'Checking this machine');
|
|
137
|
-
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
138
|
-
if (nodeMajor < 22) fail(`Node 22 or newer is required (this is ${process.versions.node}). Install it from https://nodejs.org and run "pnpm go" again.`);
|
|
139
|
-
console.log(`Node ${process.versions.node}: ok`);
|
|
140
|
-
console.log(`workspace: ${ROOT}`);
|
|
141
|
-
console.log(`framework: ${FRAMEWORK}${installed ? ' (installed)' : ' (development checkout)'}`);
|
|
142
|
-
ensureFfmpeg();
|
|
143
|
-
if (!exists('claude') && !exists('opencode')) {
|
|
144
|
-
warn('Neither "claude" (Claude Code) nor "opencode" found. Everything works except the AI steps (make cases, review, generate specs).');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
step(2, 'Installing dependencies and the browser (a minute the first time, seconds after that)');
|
|
148
|
-
const workspaceFile = ensurePnpmWorkspace();
|
|
149
|
-
if (workspaceFile === 'created') console.log('pnpm-workspace.yaml: created (allowBuilds)');
|
|
150
|
-
else if (workspaceFile === 'updated') console.log('pnpm-workspace.yaml: updated (allowBuilds)');
|
|
151
|
-
const installRoot = frameworkRelative.startsWith('..') ? FRAMEWORK : ROOT;
|
|
152
|
-
if (!run('pnpm', ['install'], {cwd: installRoot})) fail('Install failed. Fix the error above and run "pnpm go" again.');
|
|
153
|
-
const playwright = packageBin('@playwright/test', 'playwright');
|
|
154
|
-
if (playwright) {
|
|
155
|
-
if (!run(process.execPath, [playwright, 'install', 'chromium'])) warn('Could not install the Chromium browser; run "pnpm exec playwright install chromium".');
|
|
156
|
-
} else {
|
|
157
|
-
warn('@playwright/test is not installed yet. Run this command again after the workspace files are in place.');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
step(3, 'Preparing the workspace');
|
|
161
|
-
// `--root` names the workspace, but the command runs where the person is: that is where `.env` belongs.
|
|
162
|
-
if (!run(process.execPath, [bin, 'init', '--root', ROOT], {cwd: process.cwd()})) {
|
|
163
|
-
fail('Could not initialize the workspace. Run "pnpm asrun init" and read the message.');
|
|
164
|
-
}
|
|
165
|
-
run(process.execPath, [bin, 'doctor']);
|
|
166
|
-
|
|
167
|
-
step(4, `Starting the web UI at ${url}`);
|
|
168
|
-
if (await uiIsUp()) {
|
|
169
|
-
console.log('The UI is already running.');
|
|
170
|
-
if (openBrowser) open(url);
|
|
171
|
-
process.exit(0);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const env = {...process.env, ASRUN_ROOT: ROOT};
|
|
175
|
-
const child = spawn(process.execPath, [bin, 'ui'], {cwd: ROOT, stdio: 'inherit', env, shell});
|
|
176
|
-
// Ctrl+C reaches the UI server directly (same terminal); we only wait for it to shut down.
|
|
177
|
-
process.on('SIGINT', () => {});
|
|
178
|
-
process.on('SIGTERM', () => child.kill('SIGTERM'));
|
|
179
|
-
child.on('exit', (code) => process.exit(code ?? 0));
|
|
180
|
-
|
|
181
|
-
for (let i = 0; i < 240 && child.exitCode === null; i++) {
|
|
182
|
-
if (await uiIsUp()) {
|
|
183
|
-
console.log(`\n\x1b[1;32mReady: ${url}\x1b[0m (Ctrl+C to stop)`);
|
|
184
|
-
if (openBrowser) open(url);
|
|
185
|
-
break;
|
|
186
|
-
}
|
|
187
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
188
|
-
}
|
|
1
|
+
// One-liner for a fresh machine: install everything, prepare the workspace, then start the web UI and open it.
|
|
2
|
+
// Plain Node on purpose: it may run before the dependencies are installed.
|
|
3
|
+
//
|
|
4
|
+
// pnpm add -D @asrunjs/asrun (or github:wizcas/asrun#<tag>)
|
|
5
|
+
// pnpm exec asrun init
|
|
6
|
+
// pnpm go
|
|
7
|
+
import {spawn, spawnSync} from 'node:child_process';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const FRAMEWORK = path.resolve(import.meta.dirname, '..');
|
|
12
|
+
const PACKAGE = '@asrunjs/asrun';
|
|
13
|
+
const MARKERS = ['.asrun.json', 'profiles.yaml'];
|
|
14
|
+
const port = Number(process.env.ASRUN_UI_PORT ?? 4173);
|
|
15
|
+
const url = `http://127.0.0.1:${port}`;
|
|
16
|
+
const openBrowser = !process.argv.includes('--no-open');
|
|
17
|
+
const shell = process.platform === 'win32';
|
|
18
|
+
|
|
19
|
+
// `.env` next to the command, or the framework's own `.env`; a real environment variable always wins.
|
|
20
|
+
function loadRootEnv(file) {
|
|
21
|
+
try {
|
|
22
|
+
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
23
|
+
if (process.env.ASRUN_ROOT !== undefined) return;
|
|
24
|
+
const match = /^(?:export\s+)?ASRUN_ROOT\s*=\s*(.*)$/.exec(raw.trim());
|
|
25
|
+
if (match) {
|
|
26
|
+
process.env.ASRUN_ROOT = path.resolve(path.dirname(file), match[1].trim().replace(/^(['"])(.*)\1$/, '$2'));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// no .env: nothing to do
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
loadRootEnv(path.join(process.cwd(), '.env'));
|
|
34
|
+
if (process.env.ASRUN_ROOT === undefined) loadRootEnv(path.join(FRAMEWORK, '.env'));
|
|
35
|
+
|
|
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.
|
|
38
|
+
function findWorkspace(from) {
|
|
39
|
+
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
|
|
40
|
+
if (MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) return dir;
|
|
41
|
+
if (path.dirname(dir) === dir) return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
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)) {
|
|
47
|
+
console.error('No workspace found: run this command in the project that uses asrun, or set ASRUN_ROOT.');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const step = (n, text) => console.log(`\n\x1b[1;36m[${n}/4] ${text}\x1b[0m`);
|
|
52
|
+
const warn = (text) => console.log(`\x1b[33m! ${text}\x1b[0m`);
|
|
53
|
+
const fail = (text) => {
|
|
54
|
+
console.error(`\x1b[31m${text}\x1b[0m`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
};
|
|
57
|
+
const run = (command, args, options = {}) =>
|
|
58
|
+
spawnSync(command, args, {cwd: ROOT, stdio: 'inherit', shell, ...options}).status === 0;
|
|
59
|
+
const exists = (command) => spawnSync(command, ['--version'], {stdio: 'ignore', shell}).status === 0;
|
|
60
|
+
|
|
61
|
+
/** The JavaScript entry point of a package's command-line tool, without relying on node_modules/.bin shims. */
|
|
62
|
+
function packageBin(name, command = name) {
|
|
63
|
+
for (const base of [FRAMEWORK, path.dirname(path.dirname(FRAMEWORK)), ROOT]) {
|
|
64
|
+
const dir = path.join(base, 'node_modules', name);
|
|
65
|
+
const manifest = path.join(dir, 'package.json');
|
|
66
|
+
if (!fs.existsSync(manifest)) continue;
|
|
67
|
+
const bin = JSON.parse(fs.readFileSync(manifest, 'utf8')).bin;
|
|
68
|
+
const entry = typeof bin === 'string' ? bin : bin?.[command];
|
|
69
|
+
if (entry) return path.join(dir, entry);
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 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');
|
|
76
|
+
/** The workspace's copy of the package, if any. */
|
|
77
|
+
const installed = fs.existsSync(path.join(ROOT, 'node_modules', PACKAGE, 'package.json'));
|
|
78
|
+
const frameworkRelative = installed
|
|
79
|
+
? './' + path.relative(ROOT, path.join(ROOT, 'node_modules', PACKAGE)).split(path.sep).join('/')
|
|
80
|
+
: (() => {
|
|
81
|
+
const relative = path.relative(ROOT, FRAMEWORK).split(path.sep).join('/');
|
|
82
|
+
return relative === '' ? '.' : relative.startsWith('.') ? relative : `./${relative}`;
|
|
83
|
+
})();
|
|
84
|
+
|
|
85
|
+
/** pnpm only installs dependencies normally; its build scripts need an explicit approval in pnpm-workspace.yaml. */
|
|
86
|
+
function ensurePnpmWorkspace() {
|
|
87
|
+
if (frameworkRelative.startsWith('..')) return 'skipped';
|
|
88
|
+
const file = path.join(ROOT, 'pnpm-workspace.yaml');
|
|
89
|
+
const block = 'allowBuilds:\n esbuild: true\n ffmpeg-static: true\n';
|
|
90
|
+
if (!fs.existsSync(file)) {
|
|
91
|
+
fs.writeFileSync(file, `# asrun's dependencies (esbuild through tsx, ffmpeg-static) compile a binary on install.\n${block}`);
|
|
92
|
+
return 'created';
|
|
93
|
+
}
|
|
94
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
95
|
+
if (text.includes('allowBuilds')) return 'kept';
|
|
96
|
+
fs.writeFileSync(file, `${text.replace(/\n*$/, '\n')}\n${block}`);
|
|
97
|
+
return 'updated';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function uiIsUp() {
|
|
101
|
+
try {
|
|
102
|
+
return (await fetch(`${url}/api/info`, {signal: AbortSignal.timeout(1500)})).ok;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function open(target) {
|
|
109
|
+
const [command, args] =
|
|
110
|
+
process.platform === 'darwin'
|
|
111
|
+
? ['open', [target]]
|
|
112
|
+
: process.platform === 'win32'
|
|
113
|
+
? ['cmd', ['/c', 'start', '', target]]
|
|
114
|
+
: ['xdg-open', [target]];
|
|
115
|
+
spawn(command, args, {stdio: 'ignore', detached: true}).on('error', () => {}).unref();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ffmpeg joins the videos of a test's browser tabs into one. Never fatal: the optional ffmpeg-static package is the fallback.
|
|
119
|
+
function ensureFfmpeg() {
|
|
120
|
+
// ffmpeg does not know `--version`, only `-version`.
|
|
121
|
+
if (spawnSync('ffmpeg', ['-version'], {stdio: 'ignore', shell}).status === 0) return console.log('ffmpeg: ok');
|
|
122
|
+
const installer =
|
|
123
|
+
process.platform === 'darwin' && exists('brew')
|
|
124
|
+
? ['brew', ['install', 'ffmpeg']]
|
|
125
|
+
: process.platform === 'win32' && exists('winget')
|
|
126
|
+
? ['winget', ['install', '--id', 'Gyan.FFmpeg', '-e', '--accept-source-agreements', '--accept-package-agreements']]
|
|
127
|
+
: null;
|
|
128
|
+
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');
|
|
130
|
+
}
|
|
131
|
+
console.log(`ffmpeg not found, installing it: ${installer[0]} ${installer[1].join(' ')}`);
|
|
132
|
+
if (run(...installer)) console.log('ffmpeg: installed');
|
|
133
|
+
else warn('Could not install ffmpeg; the bundled ffmpeg-static is used instead.');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
step(1, 'Checking this machine');
|
|
137
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
138
|
+
if (nodeMajor < 22) fail(`Node 22 or newer is required (this is ${process.versions.node}). Install it from https://nodejs.org and run "pnpm go" again.`);
|
|
139
|
+
console.log(`Node ${process.versions.node}: ok`);
|
|
140
|
+
console.log(`workspace: ${ROOT}`);
|
|
141
|
+
console.log(`framework: ${FRAMEWORK}${installed ? ' (installed)' : ' (development checkout)'}`);
|
|
142
|
+
ensureFfmpeg();
|
|
143
|
+
if (!exists('claude') && !exists('opencode')) {
|
|
144
|
+
warn('Neither "claude" (Claude Code) nor "opencode" found. Everything works except the AI steps (make cases, review, generate specs).');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
step(2, 'Installing dependencies and the browser (a minute the first time, seconds after that)');
|
|
148
|
+
const workspaceFile = ensurePnpmWorkspace();
|
|
149
|
+
if (workspaceFile === 'created') console.log('pnpm-workspace.yaml: created (allowBuilds)');
|
|
150
|
+
else if (workspaceFile === 'updated') console.log('pnpm-workspace.yaml: updated (allowBuilds)');
|
|
151
|
+
const installRoot = frameworkRelative.startsWith('..') ? FRAMEWORK : ROOT;
|
|
152
|
+
if (!run('pnpm', ['install'], {cwd: installRoot})) fail('Install failed. Fix the error above and run "pnpm go" again.');
|
|
153
|
+
const playwright = packageBin('@playwright/test', 'playwright');
|
|
154
|
+
if (playwright) {
|
|
155
|
+
if (!run(process.execPath, [playwright, 'install', 'chromium'])) warn('Could not install the Chromium browser; run "pnpm exec playwright install chromium".');
|
|
156
|
+
} else {
|
|
157
|
+
warn('@playwright/test is not installed yet. Run this command again after the workspace files are in place.');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
step(3, 'Preparing the workspace');
|
|
161
|
+
// `--root` names the workspace, but the command runs where the person is: that is where `.env` belongs.
|
|
162
|
+
if (!run(process.execPath, [bin, 'init', '--root', ROOT], {cwd: process.cwd()})) {
|
|
163
|
+
fail('Could not initialize the workspace. Run "pnpm asrun init" and read the message.');
|
|
164
|
+
}
|
|
165
|
+
run(process.execPath, [bin, 'doctor']);
|
|
166
|
+
|
|
167
|
+
step(4, `Starting the web UI at ${url}`);
|
|
168
|
+
if (await uiIsUp()) {
|
|
169
|
+
console.log('The UI is already running.');
|
|
170
|
+
if (openBrowser) open(url);
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const env = {...process.env, ASRUN_ROOT: ROOT};
|
|
175
|
+
const child = spawn(process.execPath, [bin, 'ui'], {cwd: ROOT, stdio: 'inherit', env, shell});
|
|
176
|
+
// Ctrl+C reaches the UI server directly (same terminal); we only wait for it to shut down.
|
|
177
|
+
process.on('SIGINT', () => {});
|
|
178
|
+
process.on('SIGTERM', () => child.kill('SIGTERM'));
|
|
179
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
180
|
+
|
|
181
|
+
for (let i = 0; i < 240 && child.exitCode === null; i++) {
|
|
182
|
+
if (await uiIsUp()) {
|
|
183
|
+
console.log(`\n\x1b[1;32mReady: ${url}\x1b[0m (Ctrl+C to stop)`);
|
|
184
|
+
if (openBrowser) open(url);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
188
|
+
}
|
package/dist/scripts/init.js
CHANGED
|
@@ -14,7 +14,7 @@ const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force <path>]
|
|
|
14
14
|
--force <path> overwrite one file this tool writes, e.g. --force playwright.config.ts (may be repeated)
|
|
15
15
|
--reset-rules allow --force on AGENTS.md, CLAUDE.md or .claude/skills/** (project-owned rules)
|
|
16
16
|
--no-env do not write .env (ASRUN_ROOT)
|
|
17
|
-
--install [spec] also add the dependency (default "asrun"; e.g. --install github
|
|
17
|
+
--install [spec] also add the dependency (default "asrun"; e.g. --install github:<owner>/<repo>#<tag>)`;
|
|
18
18
|
/** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
|
|
19
19
|
export const GENERATED = [
|
|
20
20
|
{ file: 'playwright.config.ts', template: 'playwright.config.ts' },
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Case document contract (for AI agents)
|
|
2
|
+
|
|
3
|
+
This file describes the shape that `lib/case-doc.ts`, `lib/values.ts` and `lib/targets.ts` read, and how a generated
|
|
4
|
+
spec consumes it. It holds no rules of its own: the rules are in the framework `AGENTS.md`, in the skill, and in the
|
|
5
|
+
references listed at the end.
|
|
6
|
+
|
|
7
|
+
Read `AGENTS.md` first. If a project has `.claude/skills/<skill>.md`, that override comes after the skill; the
|
|
8
|
+
project's `AGENTS.md` §2 comes last and wins over both. `F1`–`F6` always win.
|
|
9
|
+
|
|
10
|
+
## Identity and paths
|
|
11
|
+
|
|
12
|
+
- A case id is its path below `cases/` without `.md`: `header-logo`, or `documents/landing-page` with **one** group
|
|
13
|
+
folder (`^[a-z0-9]+(-[a-z0-9]+)*(/[a-z0-9]+(-[a-z0-9]+)*)?$`).
|
|
14
|
+
- The spec is at the same path below `tests/` (`tests/documents/landing-page.spec.ts`), with its `*-snapshots/`
|
|
15
|
+
folder next to it.
|
|
16
|
+
- `cases/_template.md` and names starting with `_` are not cases.
|
|
17
|
+
- Specs import the framework by package name (`@asrunjs/asrun/*`), so a group folder changes no import.
|
|
18
|
+
|
|
19
|
+
## Fixed tokens
|
|
20
|
+
|
|
21
|
+
The parser and the web panel match these literals. A project language never translates them:
|
|
22
|
+
|
|
23
|
+
- Section headings: `## Goal`, `## Steps`, `## Expected`, `## Values`, `## Visual checks`, `## Targets`, `## Notes`,
|
|
24
|
+
and `## Source` when the case was made from notes.
|
|
25
|
+
- Front matter keys: `id`, `title`, `login`, `suites`, `writes`, `writes-note`, `envs`.
|
|
26
|
+
- Step tags at the end of a step line: `[input]`, `[writes]`, `[email]`.
|
|
27
|
+
- Under a step: `- Ready when:` lines and `- Recipe (phase):` lines.
|
|
28
|
+
- Target cards: `### T<n> <name>` and their references `[T<n>]`.
|
|
29
|
+
- Questions: `🚫 TODO [blocking]`, `❓ TODO [non-blocking]`, `✍️ Answer:`, and the markers
|
|
30
|
+
`🔍 to be confirmed by QA` / `👁️ not observed`.
|
|
31
|
+
- The last line of a run: `RESULT: ready` or `RESULT: needs-input`.
|
|
32
|
+
|
|
33
|
+
## Front matter
|
|
34
|
+
|
|
35
|
+
`---` on the first line, YAML, `---` to close. `pnpm case:check` calls `parseCaseMeta`, which refuses the file when
|
|
36
|
+
`suites`, `writes` or `envs` break the rules below. `id`, `title` and `login` are required by convention: keep them
|
|
37
|
+
present (the editor shows them, and the generator reads the title and the login mode), but `case:check` does not
|
|
38
|
+
enforce them.
|
|
39
|
+
|
|
40
|
+
| Key | Required | Rule |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `id` | yes | The case id; the editor keeps it in step with the path. |
|
|
43
|
+
| `title` | yes | One line. |
|
|
44
|
+
| `login` | no | `required` (default) or `none`. `none` → the spec sets `test.use({mustLogin: false})`. |
|
|
45
|
+
| `suites` | yes | Non-empty list of lowercase words. Missing `suites` is an error. |
|
|
46
|
+
| `writes` | no | List of environments. Omit for read-only. Any value other than a known environment is refused; the old `writes: test-data` is refused with a hint. |
|
|
47
|
+
| `writes-note` | with `writes` | Non-empty text: what the case creates or changes. |
|
|
48
|
+
| `envs` | no | List of environments; every environment in `writes` must also be in `envs`. |
|
|
49
|
+
|
|
50
|
+
`priority: <level>` is the old name of `suites`: it is read as `suites: [<level>]`, and naming both is an error.
|
|
51
|
+
Unknown environments are refused against the environments of `profiles.yaml`.
|
|
52
|
+
|
|
53
|
+
`caseTags(id)` (`@asrunjs/asrun/cases`) turns the front matter into Playwright tags: one `@<suite>` per suite, plus
|
|
54
|
+
`@writes` for a case that writes. The spec passes `{tag: caseTags('<id>')}` to the top-level `test.describe`; it never
|
|
55
|
+
hard-codes tags.
|
|
56
|
+
|
|
57
|
+
## Sections
|
|
58
|
+
|
|
59
|
+
### Steps
|
|
60
|
+
|
|
61
|
+
- Numbered Markdown lines (`1.`, `2.`, ...). The spec wraps each one in `test.step('<n>. <text>', ...)` with the text
|
|
62
|
+
verbatim, including the number. Do not paraphrase it.
|
|
63
|
+
- A step's tags are the last tokens of its line: `[input]` (a person makes a choice), `[writes]` (it changes data),
|
|
64
|
+
`[email]` (it sends email). All may appear.
|
|
65
|
+
- `- Ready when:` is a sub-line of the step: the positive signal that what the step loads is there. The spec waits
|
|
66
|
+
for it before reading; it is not an assertion.
|
|
67
|
+
- `- Recipe (phase):` is a sub-line of an `[input]`/`[writes]` step: what a person did in the browser during an
|
|
68
|
+
attended exploration (positions, `[asrun]` names, control labels; no selectors). A tagged step with no recipe is a
|
|
69
|
+
`needs-input` stop for `/gen-spec`.
|
|
70
|
+
- A question block is indented under the sentence it belongs to: 4 spaces under a numbered step, 2 under a bullet.
|
|
71
|
+
The parent line is the suggestion, written as a finished sentence; the `TODO` line holds one question and optional
|
|
72
|
+
`(evidence)`; the block ends with `- ✍️ Answer:` at one level deeper and that line is the last line of the block.
|
|
73
|
+
Details: `.claude/skills/exploring-cases/references/open-questions.md`.
|
|
74
|
+
|
|
75
|
+
### Expected
|
|
76
|
+
|
|
77
|
+
A bullet list of behaviour that must hold for every profile. Profile differences are values, not prose.
|
|
78
|
+
|
|
79
|
+
### Values
|
|
80
|
+
|
|
81
|
+
One fenced `yaml` block, and nothing else in `## Values`:
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
default:
|
|
85
|
+
createButton: Create New
|
|
86
|
+
example-staging:
|
|
87
|
+
previewLogoAlt: Example
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- The top level is a map of profile id (or `default`) to a map of keys. Any other shape, an unknown profile id, or
|
|
91
|
+
a value that is not a map is an error.
|
|
92
|
+
- `requireValues('<id>', profile)` deep-merges `default` under the profile's own entry.
|
|
93
|
+
- A profile with no entry and no `default` fails with "Missing values ...". An empty value or one that starts with
|
|
94
|
+
`TODO` fails with "Incomplete values ...". Fill them before running; never make a spec read the YAML itself.
|
|
95
|
+
- Values hold literals only: names, labels, options, test-data names. The checks themselves stay in `## Steps` and
|
|
96
|
+
`## Expected`, and selectors come from `## Targets`.
|
|
97
|
+
|
|
98
|
+
### Visual checks
|
|
99
|
+
|
|
100
|
+
Bullets naming what must match a screenshot baseline. A check on a logo, image, icon, file, PDF, screenshot or
|
|
101
|
+
repeated text needs a `[T<n>]` card.
|
|
102
|
+
|
|
103
|
+
### Targets
|
|
104
|
+
|
|
105
|
+
One card per distinct thing a check looks at:
|
|
106
|
+
|
|
107
|
+
```markdown
|
|
108
|
+
### T1 Header logo
|
|
109
|
+
- What: <the thing itself, and what it is not when a look-alike exists>
|
|
110
|
+
- Where: <region of the page, dialog, tab or file page>
|
|
111
|
+
- Identified by: <role and name, test id, alt text or title; never a position>
|
|
112
|
+
- Not: <look-alikes that must not be matched>
|
|
113
|
+
- Ready when: <what shows that the content has loaded>
|
|
114
|
+
- Reference <profile-id>: .explore/targets/<case-id>/T1-<profile-id>.png
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- `What`, `Where` and `Identified by` are required for a card that is not blocking.
|
|
118
|
+
- `Reference <profile-id>:` must point under `.explore/targets/<case-id>/` (git-ignored), or be `none: <reason>`.
|
|
119
|
+
A missing file on this machine is a warning, not a failure: a fresh checkout has the card but not the screenshot.
|
|
120
|
+
- A card may hold a `🚫 TODO [blocking]` block. Such a card is not complete; `pnpm case:check` does not fail on it,
|
|
121
|
+
and the check that uses it is not generated yet.
|
|
122
|
+
- Every `[T<n>]` in a step, a ready signal or a check resolves to a card. `pnpm case:check` fails on a reference with
|
|
123
|
+
no card.
|
|
124
|
+
- The binding rules for choosing a target (never guess, list candidates) are in
|
|
125
|
+
`.claude/skills/exploring-cases/references/assertion-targets.md`.
|
|
126
|
+
|
|
127
|
+
### Notes and Source
|
|
128
|
+
|
|
129
|
+
`## Notes` is free prose for a reviewer. `## Source` holds the original lines of a memo, written by `/make-case`.
|
|
130
|
+
The panel shows both read-only.
|
|
131
|
+
|
|
132
|
+
## How a spec consumes the case
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// case: cases/<id>.md
|
|
136
|
+
import {caseTags} from '@asrunjs/asrun/cases';
|
|
137
|
+
import {configureCase, expect, test} from '@asrunjs/asrun/fixtures';
|
|
138
|
+
import {requireValues} from '@asrunjs/asrun/values';
|
|
139
|
+
|
|
140
|
+
test.describe('<Case title>', {tag: caseTags('<id>')}, () => {
|
|
141
|
+
configureCase('<id>'); // no retries and a longer timeout for a case that writes data
|
|
142
|
+
test('<single outcome>', async ({page, profile}) => {
|
|
143
|
+
const expected = requireValues<{/* keys */}>('<id>', profile);
|
|
144
|
+
|
|
145
|
+
await test.step('1. <step text, verbatim>', async () => {
|
|
146
|
+
// an action that loads content: actAndWait(page, () => click(), {responses, ready})
|
|
147
|
+
// a confirmation that may appear: clickOptionalUntilHidden(confirm, target, {message})
|
|
148
|
+
// a download: verifyDownload(page, trigger, {kind})
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- The first line `// case: cases/<id>.md` is what `pnpm spec:lint` uses to find the case and its targets.
|
|
155
|
+
- `// target: T<n>` goes on the line before an image/screenshot/PDF check. `// ready: <signal>` goes before a check
|
|
156
|
+
for absence ("missing", "empty", "hidden") and needs a positive wait before it. `// pages: <the case's words>` goes
|
|
157
|
+
before an `expectPdf` that follows a case limit written in strong words ("only", "exactly", "must").
|
|
158
|
+
- Import `test`/`expect` from `@asrunjs/asrun/fixtures`, never from `@playwright/test`. Locators:
|
|
159
|
+
`getByTestId` > `getByRole` > `getByLabel` > text.
|
|
160
|
+
- No branching on `profile.id` or `profile.env`: differences go into `## Values`, selectors into `## Targets`.
|
|
161
|
+
- Paths are relative (`page.goto('/account/')`); the Playwright project sets `baseURL`.
|
|
162
|
+
|
|
163
|
+
## Profiles, values and the write gate
|
|
164
|
+
|
|
165
|
+
- Each entry in `profiles.yaml` is one Playwright project named after its `id`. A spec runs once per profile.
|
|
166
|
+
- The `profile` fixture exposes `id`, `env`, `baseURL`, `account`, `tags`, `email`.
|
|
167
|
+
- The `caseGate` fixture enforces `envs` and `writes` at run time, also when `playwright test` is called directly:
|
|
168
|
+
a case limited to some environments is skipped elsewhere; a case that writes elsewhere only is skipped with a
|
|
169
|
+
notice; a write in a non-staging environment needs the confirmation the CLI sets, never an environment variable
|
|
170
|
+
that was merely present.
|
|
171
|
+
- Write cases: name everything `[asrun] ...`, register it with `records.track` right after creating it, remove it
|
|
172
|
+
newest first (also after a failure), turn retries off with `configureCase`, and keep email steps behind
|
|
173
|
+
`emailStepAllowed(profile, '<step title>')`.
|
|
174
|
+
|
|
175
|
+
## Checks to run before reporting
|
|
176
|
+
|
|
177
|
+
| Command | Must |
|
|
178
|
+
|---|---|
|
|
179
|
+
| `pnpm case:check [<id>]` | no `FAIL` |
|
|
180
|
+
| `pnpm typecheck` | no errors |
|
|
181
|
+
| `pnpm spec:lint <id>` | no `FAIL` |
|
|
182
|
+
| `pnpm baselines:review <id>` | every new image is looked at (a person's job) |
|
|
183
|
+
|
|
184
|
+
## Related references
|
|
185
|
+
|
|
186
|
+
In the framework package:
|
|
187
|
+
|
|
188
|
+
- `.claude/skills/exploring-cases/references/assertion-targets.md` — what a check looks at, and when to ask.
|
|
189
|
+
- `.claude/skills/exploring-cases/references/readiness.md` — wait for the content, optional confirmations, downloads.
|
|
190
|
+
- `.claude/skills/exploring-cases/references/open-questions.md` — question blocks and how answers are folded in.
|
|
191
|
+
- `.claude/skills/exploring-cases/references/user-handoff.md` — attended exploration and the handoff card.
|
|
192
|
+
- `.claude/skills/generating-specs/references/conventions.md` — spec shape, assertion layers, readable failures.
|
|
193
|
+
- `docs/cases.md` — the same format for people writing a case by hand.
|