@asrunjs/asrun 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/explore-case.md +15 -0
- package/.claude/commands/gen-spec.md +13 -0
- package/.claude/commands/heal-spec.md +23 -0
- package/.claude/commands/make-case.md +8 -0
- package/.claude/settings.json +5 -0
- package/.claude/skills/exploring-cases/SKILL.md +132 -0
- package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -0
- package/.claude/skills/exploring-cases/references/handoff-card.js +128 -0
- package/.claude/skills/exploring-cases/references/open-questions.md +112 -0
- package/.claude/skills/exploring-cases/references/readiness.md +137 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +147 -0
- package/.claude/skills/generating-specs/SKILL.md +207 -0
- package/.claude/skills/generating-specs/references/conventions.md +148 -0
- package/.claude/skills/making-cases/SKILL.md +64 -0
- package/.claude/skills/making-cases/references/gaps.md +36 -0
- package/.claude/skills/making-cases/references/normalizing.md +38 -0
- package/.claude/skills/making-cases/references/splitting.md +28 -0
- package/.claude/skills/making-cases/references/writes-and-email.md +36 -0
- package/AGENTS.md +154 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +410 -3
- package/bin/asrun.mjs +49 -0
- package/dist/lib/account-lock.d.ts +6 -0
- package/dist/lib/account-lock.js +52 -0
- package/dist/lib/assertions.d.ts +14 -0
- package/dist/lib/assertions.js +39 -0
- package/dist/lib/baselines.d.ts +43 -0
- package/dist/lib/baselines.js +108 -0
- package/dist/lib/case-id.d.ts +11 -0
- package/dist/lib/case-id.js +17 -0
- package/dist/lib/case-ops.d.ts +26 -0
- package/dist/lib/case-ops.js +110 -0
- package/dist/lib/cases.d.ts +29 -0
- package/dist/lib/cases.js +124 -0
- package/dist/lib/cli.d.ts +10 -0
- package/dist/lib/cli.js +18 -0
- package/dist/lib/downloads.d.ts +42 -0
- package/dist/lib/downloads.js +84 -0
- package/dist/lib/email.d.ts +10 -0
- package/dist/lib/email.js +19 -0
- package/dist/lib/ffmpeg.d.ts +8 -0
- package/dist/lib/ffmpeg.js +50 -0
- package/dist/lib/fixtures.d.ts +40 -0
- package/dist/lib/fixtures.js +149 -0
- package/dist/lib/node-cli.d.ts +11 -0
- package/dist/lib/node-cli.js +26 -0
- package/dist/lib/paths.d.ts +25 -0
- package/dist/lib/paths.js +88 -0
- package/dist/lib/pdf.d.ts +71 -0
- package/dist/lib/pdf.js +140 -0
- package/dist/lib/profiles.d.ts +38 -0
- package/dist/lib/profiles.js +121 -0
- package/dist/lib/ready.d.ts +36 -0
- package/dist/lib/ready.js +53 -0
- package/dist/lib/records.d.ts +33 -0
- package/dist/lib/records.js +61 -0
- package/dist/lib/run-filters.d.ts +19 -0
- package/dist/lib/run-filters.js +30 -0
- package/dist/lib/run-summary.d.ts +90 -0
- package/dist/lib/run-summary.js +35 -0
- package/dist/lib/session.d.ts +18 -0
- package/dist/lib/session.js +45 -0
- package/dist/lib/summary-reporter.d.ts +16 -0
- package/dist/lib/summary-reporter.js +146 -0
- package/dist/lib/targets.d.ts +27 -0
- package/dist/lib/targets.js +51 -0
- package/dist/lib/ui-build.d.ts +10 -0
- package/dist/lib/ui-build.js +30 -0
- package/dist/lib/values.d.ts +29 -0
- package/dist/lib/values.js +85 -0
- package/dist/lib/write-gate.d.ts +41 -0
- package/dist/lib/write-gate.js +129 -0
- package/dist/playwright.config.d.ts +2 -0
- package/dist/playwright.config.js +92 -0
- package/dist/scripts/build.d.ts +1 -0
- package/dist/scripts/build.js +20 -0
- package/dist/scripts/check-cases.d.ts +1 -0
- package/dist/scripts/check-cases.js +81 -0
- package/dist/scripts/cleanup.d.ts +1 -0
- package/dist/scripts/cleanup.js +25 -0
- package/dist/scripts/cli.d.ts +1 -0
- package/dist/scripts/cli.js +28 -0
- package/dist/scripts/doctor.d.ts +1 -0
- package/dist/scripts/doctor.js +185 -0
- package/dist/scripts/explore.d.ts +1 -0
- package/dist/scripts/explore.js +54 -0
- package/dist/scripts/go.mjs +188 -0
- package/dist/scripts/heal-evidence.mjs +143 -0
- package/dist/scripts/init.d.ts +16 -0
- package/dist/scripts/init.js +284 -0
- package/dist/scripts/lint-specs.d.ts +1 -0
- package/dist/scripts/lint-specs.js +138 -0
- package/dist/scripts/login.d.ts +1 -0
- package/dist/scripts/login.js +47 -0
- package/dist/scripts/new-case.d.ts +1 -0
- package/dist/scripts/new-case.js +19 -0
- package/dist/scripts/render-pdf.d.ts +1 -0
- package/dist/scripts/render-pdf.js +44 -0
- package/dist/scripts/report.d.ts +1 -0
- package/dist/scripts/report.js +14 -0
- package/dist/scripts/review-baselines.d.ts +1 -0
- package/dist/scripts/review-baselines.js +30 -0
- package/dist/scripts/run.d.ts +1 -0
- package/dist/scripts/run.js +423 -0
- package/dist/scripts/status.d.ts +1 -0
- package/dist/scripts/status.js +31 -0
- package/dist/scripts/ui-build.d.ts +1 -0
- package/dist/scripts/ui-build.js +7 -0
- package/dist/scripts/ui-dev.d.ts +1 -0
- package/dist/scripts/ui-dev.js +3 -0
- package/dist/scripts/ui.d.ts +1 -0
- package/dist/scripts/ui.js +13 -0
- package/dist/ui/dev.d.ts +1 -0
- package/dist/ui/dev.js +36 -0
- package/dist/ui/server/actions.d.ts +91 -0
- package/dist/ui/server/actions.js +352 -0
- package/dist/ui/server/ai-stream.d.ts +41 -0
- package/dist/ui/server/ai-stream.js +202 -0
- package/dist/ui/server/app.d.ts +4 -0
- package/dist/ui/server/app.js +333 -0
- package/dist/ui/server/cases.d.ts +23 -0
- package/dist/ui/server/cases.js +170 -0
- package/dist/ui/server/files.d.ts +5 -0
- package/dist/ui/server/files.js +67 -0
- package/dist/ui/server/index.d.ts +1 -0
- package/dist/ui/server/index.js +33 -0
- package/dist/ui/server/jobs.d.ts +74 -0
- package/dist/ui/server/jobs.js +263 -0
- package/dist/ui/server/profiles.d.ts +16 -0
- package/dist/ui/server/profiles.js +195 -0
- package/dist/ui/server/watch-files.d.ts +4 -0
- package/dist/ui/server/watch-files.js +36 -0
- package/dist/ui/shared/ai-commands.d.ts +78 -0
- package/dist/ui/shared/ai-commands.js +154 -0
- package/dist/ui/shared/case-id.d.ts +1 -0
- package/dist/ui/shared/case-id.js +1 -0
- package/dist/ui/shared/jobs.d.ts +6 -0
- package/dist/ui/shared/jobs.js +7 -0
- package/dist/ui/shared/product.d.ts +3 -0
- package/dist/ui/shared/product.js +3 -0
- package/dist/ui/shared/run-filters.d.ts +1 -0
- package/dist/ui/shared/run-filters.js +1 -0
- package/dist/ui/shared/site-address.d.ts +6 -0
- package/dist/ui/shared/site-address.js +11 -0
- package/dist/ui/shared/types.d.ts +257 -0
- package/dist/ui/shared/types.js +1 -0
- package/docs/theme-guide.html +692 -0
- package/package.json +98 -3
- package/templates/.claude/commands/explore-case.md +21 -0
- package/templates/.claude/commands/gen-spec.md +20 -0
- package/templates/.claude/commands/heal-spec.md +30 -0
- package/templates/.claude/commands/make-case.md +12 -0
- package/templates/.claude/settings.json +5 -0
- package/templates/.mcp.json +8 -0
- package/templates/AGENTS.md +45 -0
- package/templates/CLAUDE.md +1 -0
- package/templates/cases/_template.md +63 -0
- package/templates/gitignore +13 -0
- package/templates/opencode.jsonc +11 -0
- package/templates/package.json +31 -0
- package/templates/playwright.config.ts +3 -0
- package/templates/pnpm-workspace.yaml +5 -0
- package/templates/profiles.yaml +29 -0
- package/templates/seed.spec.ts +9 -0
- package/templates/tsconfig.json +14 -0
- package/ui/dist/apple-touch-icon.png +0 -0
- package/ui/dist/assets/apl-gfMMiTNf.js +1 -0
- package/ui/dist/assets/asciiarmor-C_eisQCp.js +1 -0
- package/ui/dist/assets/asn1-DCl_8MCW.js +1 -0
- package/ui/dist/assets/asterisk-BEn0Cwpx.js +1 -0
- package/ui/dist/assets/brainfuck-D5EjA2JK.js +1 -0
- package/ui/dist/assets/clike-xvONpD0Z.js +1 -0
- package/ui/dist/assets/clojure-BflJGmDX.js +1 -0
- package/ui/dist/assets/cmake-84nbDH0I.js +1 -0
- package/ui/dist/assets/cobol-DHLQUbN7.js +1 -0
- package/ui/dist/assets/coffeescript-De_zI4J0.js +1 -0
- package/ui/dist/assets/commonlisp-DgAKBe0r.js +1 -0
- package/ui/dist/assets/crystal-Bl6YKCG_.js +1 -0
- package/ui/dist/assets/css-CnaYoQib.js +1 -0
- package/ui/dist/assets/cypher-p9eYesGn.js +1 -0
- package/ui/dist/assets/d-DWkQyNnU.js +1 -0
- package/ui/dist/assets/diff-ChtP43wD.js +1 -0
- package/ui/dist/assets/dist-BHaIOZdW.js +1 -0
- package/ui/dist/assets/dist-BSJMM_jp.js +1 -0
- package/ui/dist/assets/dist-BiE4suo9.js +1 -0
- package/ui/dist/assets/dist-BqAROxzI.js +1 -0
- package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
- package/ui/dist/assets/dist-BwYRbe5s.js +9 -0
- package/ui/dist/assets/dist-BzulKuec.js +1 -0
- package/ui/dist/assets/dist-CBV7_vDN.js +2 -0
- package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
- package/ui/dist/assets/dist-DDJNvivM.js +1 -0
- package/ui/dist/assets/dist-DF3F_I8N.js +1 -0
- package/ui/dist/assets/dist-DHCY9KpV.js +1 -0
- package/ui/dist/assets/dist-DX8knLVr.js +1 -0
- package/ui/dist/assets/dist-D_I45xgJ.js +11 -0
- package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
- package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
- package/ui/dist/assets/dist-gF2bFn-5.js +1 -0
- package/ui/dist/assets/dockerfile-DJkHQgkl.js +1 -0
- package/ui/dist/assets/dtd-DTF72YNO.js +1 -0
- package/ui/dist/assets/dylan-S7ucR4pR.js +1 -0
- package/ui/dist/assets/ebnf-D4c0_ac3.js +1 -0
- package/ui/dist/assets/ecl-BVIeWmwY.js +1 -0
- package/ui/dist/assets/eiffel-BEjRio4Q.js +1 -0
- package/ui/dist/assets/elm-Cshzl8qu.js +1 -0
- package/ui/dist/assets/erlang-BV0c4Hdn.js +1 -0
- package/ui/dist/assets/factor-LKH5gN8L.js +1 -0
- package/ui/dist/assets/fcl-ClOhbFR7.js +1 -0
- package/ui/dist/assets/forth-D6RzAl64.js +1 -0
- package/ui/dist/assets/fortran-Bt6PBEDR.js +1 -0
- package/ui/dist/assets/gas-BHEdbvp9.js +1 -0
- package/ui/dist/assets/gherkin-oBAE_ms0.js +1 -0
- package/ui/dist/assets/groovy-CUrWKPia.js +1 -0
- package/ui/dist/assets/haskell-BRsoo5mP.js +1 -0
- package/ui/dist/assets/haxe-DjVOVvNP.js +1 -0
- package/ui/dist/assets/http-CLVgA2GD.js +1 -0
- package/ui/dist/assets/idl-BUZw3wgd.js +1 -0
- package/ui/dist/assets/index-DmasQ6Rq.css +2 -0
- package/ui/dist/assets/index-I7uU93kk.js +294 -0
- package/ui/dist/assets/javascript-lS2r2ifg.js +1 -0
- package/ui/dist/assets/julia-CQfgDRfP.js +1 -0
- package/ui/dist/assets/livescript-BXxG0Yva.js +1 -0
- package/ui/dist/assets/lua-DPCEkfD_.js +1 -0
- package/ui/dist/assets/mathematica-u8YMmbU0.js +1 -0
- package/ui/dist/assets/mbox-BEMVcqjs.js +1 -0
- package/ui/dist/assets/mirc-C9zpzAZU.js +1 -0
- package/ui/dist/assets/mllike-B45xgp2S.js +1 -0
- package/ui/dist/assets/modelica-CqHI_q-D.js +1 -0
- package/ui/dist/assets/mscgen-qgSLujhx.js +1 -0
- package/ui/dist/assets/mumps-BSzj6Xyz.js +1 -0
- package/ui/dist/assets/nginx-C-ORUuei.js +1 -0
- package/ui/dist/assets/nsis-viPvXFiq.js +1 -0
- package/ui/dist/assets/ntriples-CzMBulkp.js +1 -0
- package/ui/dist/assets/octave-D2Q8cUp1.js +1 -0
- package/ui/dist/assets/oz-BwTct_5T.js +1 -0
- package/ui/dist/assets/pascal-Dp_KdFgX.js +1 -0
- package/ui/dist/assets/perl-BkSkiI9m.js +1 -0
- package/ui/dist/assets/pig-AF4Hwhju.js +1 -0
- package/ui/dist/assets/powershell-D8WpQPaX.js +1 -0
- package/ui/dist/assets/properties-C357ku5U.js +1 -0
- package/ui/dist/assets/protobuf-D1ij_kNL.js +1 -0
- package/ui/dist/assets/pug-BqCHZ5xT.js +1 -0
- package/ui/dist/assets/puppet-BZyAS7I4.js +1 -0
- package/ui/dist/assets/python-B_1Jd8HM.js +1 -0
- package/ui/dist/assets/q-B9NhS7L_.js +1 -0
- package/ui/dist/assets/r-CxDmU1I4.js +1 -0
- package/ui/dist/assets/rpm-gpDK5sbt.js +1 -0
- package/ui/dist/assets/ruby-ChfNwlCY.js +1 -0
- package/ui/dist/assets/sas-C2OyD2Y6.js +1 -0
- package/ui/dist/assets/scheme-D9qYN0V5.js +1 -0
- package/ui/dist/assets/shell-DwuoZtxw.js +1 -0
- package/ui/dist/assets/sieve-tLWDfEkZ.js +1 -0
- package/ui/dist/assets/simple-mode-DXiLM1-G.js +1 -0
- package/ui/dist/assets/smalltalk-BLp5-jwC.js +1 -0
- package/ui/dist/assets/solr-B78wSQJQ.js +1 -0
- package/ui/dist/assets/sparql-Dz-mlCAC.js +1 -0
- package/ui/dist/assets/spreadsheet-CTIncYxj.js +1 -0
- package/ui/dist/assets/sql-DuQMFDvy.js +1 -0
- package/ui/dist/assets/stex-DNzd62Q_.js +1 -0
- package/ui/dist/assets/stylus-BOVgKnng.js +1 -0
- package/ui/dist/assets/swift-DKB6_1j6.js +1 -0
- package/ui/dist/assets/tcl-BGfruO1u.js +1 -0
- package/ui/dist/assets/textile-C4jVoHG1.js +1 -0
- package/ui/dist/assets/tiddlywiki-VlUiK86J.js +1 -0
- package/ui/dist/assets/tiki-CvUEFusT.js +1 -0
- package/ui/dist/assets/toml-BPTmHmyx.js +1 -0
- package/ui/dist/assets/troff-C2dAgh7P.js +1 -0
- package/ui/dist/assets/ttcn-PPSTk1ow.js +1 -0
- package/ui/dist/assets/ttcn-cfg-2b4Gb5sE.js +1 -0
- package/ui/dist/assets/turtle-Cx3rYX2g.js +1 -0
- package/ui/dist/assets/vb-BthdM_4N.js +1 -0
- package/ui/dist/assets/vbscript-D9f6rSsU.js +1 -0
- package/ui/dist/assets/velocity-CqftRsjg.js +1 -0
- package/ui/dist/assets/verilog-CjI9Y6RI.js +1 -0
- package/ui/dist/assets/vhdl-kS471soi.js +1 -0
- package/ui/dist/assets/webidl-D5jiJvOU.js +1 -0
- package/ui/dist/assets/xquery-BKJx4mf9.js +1 -0
- package/ui/dist/assets/yacas-CXXgtw0p.js +1 -0
- package/ui/dist/assets/z80-Co3PbHdE.js +1 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/favicon.svg +11 -0
- package/ui/dist/index.html +17 -0
- package/ui/dist/logos/claude.svg +1 -0
- package/ui/dist/logos/opencode.svg +1 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { commandScript } from '../lib/node-cli.js';
|
|
5
|
+
import { FRAMEWORK_ROOT } from '../lib/paths.js';
|
|
6
|
+
// Compiles the framework to `dist/`: JavaScript and type declarations that Node and Playwright can load from a
|
|
7
|
+
// node_modules folder (Node refuses to strip types there). The plain `.mjs` tools are copied as they are.
|
|
8
|
+
const tsc = commandScript('typescript', 'tsc');
|
|
9
|
+
const result = spawnSync(process.execPath, [tsc, '-p', path.join(FRAMEWORK_ROOT, 'tsconfig.build.json')], {
|
|
10
|
+
cwd: FRAMEWORK_ROOT,
|
|
11
|
+
stdio: 'inherit',
|
|
12
|
+
});
|
|
13
|
+
if ((result.status ?? 1) !== 0)
|
|
14
|
+
process.exit(result.status ?? 1);
|
|
15
|
+
for (const file of ['go.mjs', 'heal-evidence.mjs']) {
|
|
16
|
+
const from = path.join(FRAMEWORK_ROOT, 'scripts', file);
|
|
17
|
+
const to = path.join(FRAMEWORK_ROOT, 'dist', 'scripts', file);
|
|
18
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
19
|
+
fs.copyFileSync(from, to);
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { CASES_DIR } from '../lib/paths.js';
|
|
3
|
+
import { listCaseFileIds, readCaseMeta } from '../lib/cases.js';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { ROOT } from '../lib/paths.js';
|
|
6
|
+
import { checksNeedingTarget, parseTargets, readCaseText, REQUIRED_FIELDS, targetRefsIn } from '../lib/targets.js';
|
|
7
|
+
import { parseCaseValues } from '../lib/values.js';
|
|
8
|
+
/** Step numbers of the lines marked `[input]` or `[writes]` in "## Steps" (a person does them), and the numbers that "## Inputs" has a bullet for. */
|
|
9
|
+
function inputSteps(id) {
|
|
10
|
+
const text = fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8');
|
|
11
|
+
const section = (name) => new RegExp(`^## ${name}\\s*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm').exec(text)?.[1] ?? '';
|
|
12
|
+
const marked = [...section('Steps').matchAll(/^(\d+)\.\s.*\[(?:input|writes)\]/gm)].map((match) => Number(match[1]));
|
|
13
|
+
const recorded = [...section('Inputs').matchAll(/^- Step (\d+)\b/gm)].map((match) => Number(match[1]));
|
|
14
|
+
return { marked, recorded };
|
|
15
|
+
}
|
|
16
|
+
/** Problems with the target cards (`## Targets`) of a case: `fail` stops, `warn` is what `/explore-case` still has to settle. */
|
|
17
|
+
function targetProblems(id) {
|
|
18
|
+
const text = readCaseText(id);
|
|
19
|
+
const cards = parseTargets(text);
|
|
20
|
+
const fail = [];
|
|
21
|
+
const warn = [];
|
|
22
|
+
const known = new Set(cards.map((card) => card.id));
|
|
23
|
+
for (const card of cards) {
|
|
24
|
+
const missing = REQUIRED_FIELDS.filter((field) => !card.fields[field]);
|
|
25
|
+
if (missing.length > 0 && !card.blocking)
|
|
26
|
+
fail.push(`target ${card.id} "${card.name}" has no ${missing.map((field) => `"${field}"`).join(', ')}`);
|
|
27
|
+
for (const [label, file] of Object.entries(card.references)) {
|
|
28
|
+
if (/^none\b/i.test(file))
|
|
29
|
+
continue;
|
|
30
|
+
// References live in .explore/ (git-ignored, they show logged-in pages), so a fresh checkout has none: a warning.
|
|
31
|
+
if (!file.startsWith('.explore/'))
|
|
32
|
+
fail.push(`target ${card.id}: the reference image for ${label} must be saved under .explore/targets/<case-id>/ (got ${file})`);
|
|
33
|
+
else if (!fs.existsSync(path.join(ROOT, file)))
|
|
34
|
+
warn.push(`target ${card.id}: the reference image for ${label} is not on this machine: ${file}`);
|
|
35
|
+
}
|
|
36
|
+
if (Object.keys(card.references).length === 0 && !card.blocking) {
|
|
37
|
+
warn.push(`target ${card.id} "${card.name}" has no "Reference <profile-id>:" line (a highlighted screenshot of what the check looks at, or "none: <reason>")`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const body = text.replace(/^## Targets\s*$[\s\S]*?(?=^## |(?![\s\S]))/m, '').replace(/^## Source\s*$[\s\S]*?(?=^## |(?![\s\S]))/m, '');
|
|
41
|
+
for (const ref of targetRefsIn(body))
|
|
42
|
+
if (!known.has(ref))
|
|
43
|
+
fail.push(`a check refers to [${ref}] but "## Targets" has no card ${ref}`);
|
|
44
|
+
for (const check of checksNeedingTarget(text)) {
|
|
45
|
+
warn.push(`"${check.section}" has a check on an image, file or logo without a target card ([T<n>]): ${check.line.slice(0, 90)}`);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const values = parseCaseValues(text, `cases/${id}.md`);
|
|
49
|
+
if (Object.keys(values).length === 0) {
|
|
50
|
+
warn.push('has no "## Values" block; a spec that asserts expected values cannot run for any profile');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
fail.push(error.message);
|
|
55
|
+
}
|
|
56
|
+
return { fail, warn };
|
|
57
|
+
}
|
|
58
|
+
const ids = process.argv.slice(2).length > 0 ? process.argv.slice(2) : listCaseFileIds();
|
|
59
|
+
let problems = 0;
|
|
60
|
+
for (const id of ids) {
|
|
61
|
+
try {
|
|
62
|
+
const meta = readCaseMeta(id);
|
|
63
|
+
console.log(`ok ${id} suites=[${meta.suites.join(', ')}] writes=[${meta.writes.join(', ')}]` +
|
|
64
|
+
`${meta.envs ? ` envs=[${meta.envs.join(', ')}]` : ''}`);
|
|
65
|
+
const targets = targetProblems(id);
|
|
66
|
+
for (const message of targets.warn)
|
|
67
|
+
console.log(`warn ${id}: ${message}`);
|
|
68
|
+
for (const message of targets.fail)
|
|
69
|
+
throw new Error(message);
|
|
70
|
+
const { marked, recorded } = inputSteps(id);
|
|
71
|
+
const missing = marked.filter((step) => !recorded.includes(step));
|
|
72
|
+
if (missing.length > 0) {
|
|
73
|
+
console.log(`warn ${id}: step ${missing.join(', ')} is marked [input] or [writes] but has no recipe under "## Inputs" (run /explore-case --attended)`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
problems++;
|
|
78
|
+
console.error(`FAIL ${id}: ${error.message}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
process.exit(problems > 0 ? 1 : 0);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { listEnvs } from '../lib/profiles.js';
|
|
2
|
+
import { describeLeftovers, findLeftovers, forgetLedgers } from '../lib/records.js';
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
const envArg = args.find((arg) => arg.startsWith('--env='))?.slice('--env='.length) ?? args[args.indexOf('--env') + 1];
|
|
5
|
+
const forget = args.includes('--forget');
|
|
6
|
+
if (!envArg || !listEnvs().includes(envArg)) {
|
|
7
|
+
console.error('Usage: pnpm cleanup --env <env> [--forget]\n' +
|
|
8
|
+
` --env is required (${listEnvs().join(' | ')}); there is no default.\n` +
|
|
9
|
+
' Lists test data that cases created and could not remove (from .tmp/records).\n' +
|
|
10
|
+
' Delete it by hand in the product, then run with --forget to clear the list.');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
const leftovers = findLeftovers({ env: envArg });
|
|
14
|
+
if (leftovers.length === 0) {
|
|
15
|
+
console.log(`No test data left behind in ${envArg.toUpperCase()}.`);
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
console.log(`Test data left behind in ${envArg.toUpperCase()} (named "[asrun] ..."):\n${describeLeftovers(leftovers)}`);
|
|
19
|
+
if (forget) {
|
|
20
|
+
console.log(`\nForgot ${forgetLedgers(envArg)} ledger file(s). Only do this after the data is deleted.`);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
console.log('\nDelete these by hand in the product, then run: pnpm cleanup --env ' + envArg + ' --forget');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// `pnpm asrun <command>`: a thin dispatcher so every subcommand keeps one entry point.
|
|
2
|
+
// Anything that is not a subcommand is passed to the run command unchanged.
|
|
3
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
4
|
+
async function main() {
|
|
5
|
+
if (command === 'init') {
|
|
6
|
+
const { main: init } = await import('./init.js');
|
|
7
|
+
process.exitCode = await init(rest);
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (command === 'doctor') {
|
|
11
|
+
const { main: doctor } = await import('./doctor.js');
|
|
12
|
+
process.exitCode = await doctor(rest);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (command === 'help' || command === '--help-init') {
|
|
16
|
+
console.log(USAGE);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
await import('./run.js');
|
|
20
|
+
}
|
|
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.`;
|
|
27
|
+
await main();
|
|
28
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(args: string[]): Promise<number>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadProfiles, listEnvs, PROFILES_FILE } from '../lib/profiles.js';
|
|
4
|
+
import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR } from '../lib/paths.js';
|
|
5
|
+
import { GENERATED, frameworkPath, frameworkVersion, renderTemplate } from './init.js';
|
|
6
|
+
/**
|
|
7
|
+
* Checks that a workspace is complete and consistent with the framework it points at. It never writes anything.
|
|
8
|
+
* Errors exit with 1; warnings do not.
|
|
9
|
+
*/
|
|
10
|
+
const SKILLS = ['making-cases', 'exploring-cases', 'generating-specs'];
|
|
11
|
+
function read(file) {
|
|
12
|
+
try {
|
|
13
|
+
return fs.readFileSync(file, 'utf8');
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** The ids of the framework rules that a project may not narrow, parsed from the framework's AGENTS.md. */
|
|
20
|
+
function nonNegotiableRules() {
|
|
21
|
+
const ids = new Set();
|
|
22
|
+
for (const line of (read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '').split('\n')) {
|
|
23
|
+
const match = /^- \*\*(F\d+)\b/.exec(line.trim());
|
|
24
|
+
if (match && line.includes('[non-negotiable]'))
|
|
25
|
+
ids.add(match[1]);
|
|
26
|
+
}
|
|
27
|
+
return ids;
|
|
28
|
+
}
|
|
29
|
+
export async function main(args) {
|
|
30
|
+
if (args.includes('--help')) {
|
|
31
|
+
console.log('Usage: pnpm asrun doctor');
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
const errors = [];
|
|
35
|
+
const warnings = [];
|
|
36
|
+
const facts = [];
|
|
37
|
+
if (path.resolve(ROOT) === path.resolve(FRAMEWORK_ROOT)) {
|
|
38
|
+
console.error('No workspace found: this is the framework folder. Run the command in the project that uses the framework, ' +
|
|
39
|
+
'or set ASRUN_ROOT (the framework develops against examples/demo).');
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
facts.push(`workspace: ${ROOT}`);
|
|
43
|
+
const actualPath = frameworkPath(ROOT);
|
|
44
|
+
facts.push(`framework: ${path.resolve(ROOT, actualPath)}`);
|
|
45
|
+
// The manifest, written by init: it names where the workspace reaches the framework.
|
|
46
|
+
const manifest = read(path.join(ROOT, '.asrun.json'));
|
|
47
|
+
let recordedPath;
|
|
48
|
+
const installedVersion = frameworkVersion();
|
|
49
|
+
if (manifest === null) {
|
|
50
|
+
warnings.push('No .asrun.json: run "pnpm asrun init" in this folder.');
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(manifest);
|
|
55
|
+
recordedPath = parsed.frameworkPath;
|
|
56
|
+
facts.push(`version: asrun ${parsed.version ?? '?'} (${parsed.commit ?? '?'})`);
|
|
57
|
+
if (parsed.version !== undefined && parsed.version !== installedVersion.version) {
|
|
58
|
+
warnings.push(`asrun is installed at ${installedVersion.version}, but this workspace was set up with ${parsed.version}: run "pnpm asrun init --update".`);
|
|
59
|
+
}
|
|
60
|
+
if (recordedPath !== undefined && !fs.existsSync(path.resolve(ROOT, recordedPath))) {
|
|
61
|
+
errors.push(`.asrun.json points at "${recordedPath}", which does not exist. Run "pnpm add -D @asrunjs/asrun" and then "pnpm asrun init".`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
errors.push('.asrun.json is not valid JSON: delete it and run "pnpm asrun init" again.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const framework = recordedPath ?? actualPath;
|
|
69
|
+
// Profiles: they are the only source of environments, and a broken file stops every command.
|
|
70
|
+
try {
|
|
71
|
+
const profiles = loadProfiles();
|
|
72
|
+
if (profiles.length === 0) {
|
|
73
|
+
errors.push(`${PROFILES_FILE} has no profiles.`);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
facts.push(`profiles: ${profiles.length} (${listEnvs(profiles).join(', ')})`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
81
|
+
}
|
|
82
|
+
// How the specs reach the framework: through the package name (`@asrunjs/asrun/fixtures`), so only the file must exist.
|
|
83
|
+
if (read(path.join(ROOT, 'tsconfig.json')) === null) {
|
|
84
|
+
errors.push('tsconfig.json is missing: run "pnpm asrun init".');
|
|
85
|
+
}
|
|
86
|
+
const config = read(path.join(ROOT, 'playwright.config.ts'));
|
|
87
|
+
if (config === null) {
|
|
88
|
+
errors.push('playwright.config.ts is missing: run "pnpm asrun init".');
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const target = /from\s+['"]([^'"]+)['"]/.exec(config)?.[1];
|
|
92
|
+
if (!target) {
|
|
93
|
+
errors.push('playwright.config.ts does not re-export the framework config (see templates/playwright.config.ts).');
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const resolved = path.resolve(ROOT, target);
|
|
97
|
+
if (!fs.existsSync(resolved) && !fs.existsSync(`${resolved}.ts`)) {
|
|
98
|
+
errors.push(`playwright.config.ts points at "${target}", which does not exist. Run "pnpm asrun init --force playwright.config.ts".`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const gitignore = read(path.join(ROOT, '.gitignore')) ?? '';
|
|
103
|
+
// A bare "*" ignores everything (a folder that only commits selected files); nothing can leak there.
|
|
104
|
+
const ignoresEverything = gitignore.split('\n').some((line) => line.trim() === '*');
|
|
105
|
+
for (const entry of ['.auth/', 'reports/']) {
|
|
106
|
+
if (!ignoresEverything && !gitignore.includes(entry)) {
|
|
107
|
+
errors.push(`.gitignore does not ignore "${entry}". Saved logins must never be committed.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Files generated from the templates: missing or edited ones are refreshed with `init --update`.
|
|
111
|
+
for (const entry of GENERATED) {
|
|
112
|
+
const current = read(path.join(ROOT, entry.file));
|
|
113
|
+
const template = renderTemplate(entry.template, ROOT);
|
|
114
|
+
if (current === null)
|
|
115
|
+
warnings.push(`${entry.file} is missing: run "pnpm asrun init".`);
|
|
116
|
+
else if (current !== template) {
|
|
117
|
+
warnings.push(`${entry.file} differs from the framework template: run "pnpm asrun init --update".`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Skills: the framework's, plus any project override.
|
|
121
|
+
const missingSkills = SKILLS.filter((skill) => !fs.existsSync(path.join(SKILLS_DIR, skill, 'SKILL.md')));
|
|
122
|
+
if (missingSkills.length > 0)
|
|
123
|
+
errors.push(`The framework folder is incomplete: no skill ${missingSkills.join(', ')}.`);
|
|
124
|
+
const skillsDir = path.join(ROOT, '.claude', 'skills');
|
|
125
|
+
const overrides = fs.existsSync(skillsDir) ? fs.readdirSync(skillsDir).filter((name) => name.endsWith('.md')) : [];
|
|
126
|
+
facts.push(`skills: ${SKILLS.join(', ')}${overrides.length > 0 ? ` (project overrides: ${overrides.map((name) => name.replace(/\.md$/, '')).join(', ')})` : ''}`);
|
|
127
|
+
// Project rules may only narrow the baseline, never a rule marked [non-negotiable].
|
|
128
|
+
const agents = read(path.join(ROOT, 'AGENTS.md'));
|
|
129
|
+
if (agents === null) {
|
|
130
|
+
warnings.push('AGENTS.md is missing: run "pnpm asrun init" and fill in the project facts.');
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
if (!agents.includes(`${framework}/AGENTS.md`)) {
|
|
134
|
+
errors.push(`AGENTS.md does not point at the framework baseline (${framework}/AGENTS.md).`);
|
|
135
|
+
}
|
|
136
|
+
const locked = nonNegotiableRules();
|
|
137
|
+
for (const line of agents.split('\n')) {
|
|
138
|
+
const match = /^\|\s*(F\d+)\s*\|/.exec(line.trim());
|
|
139
|
+
if (match && locked.has(match[1])) {
|
|
140
|
+
errors.push(`AGENTS.md narrows ${match[1]}, which the framework marks [non-negotiable].`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// The install and the commands the docs use.
|
|
145
|
+
const workspaceFile = read(path.join(ROOT, 'pnpm-workspace.yaml'));
|
|
146
|
+
if (framework.startsWith('..')) {
|
|
147
|
+
// The framework is outside the workspace (examples/demo): the framework repository manages itself.
|
|
148
|
+
}
|
|
149
|
+
else if (workspaceFile === null) {
|
|
150
|
+
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.');
|
|
151
|
+
}
|
|
152
|
+
else if (!workspaceFile.includes('allowBuilds')) {
|
|
153
|
+
warnings.push('pnpm-workspace.yaml has no "allowBuilds": pnpm skips the build scripts of esbuild and ffmpeg-static.');
|
|
154
|
+
}
|
|
155
|
+
else if (/^packages\s*:/m.test(workspaceFile)) {
|
|
156
|
+
warnings.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
|
|
157
|
+
}
|
|
158
|
+
let scripts = null;
|
|
159
|
+
try {
|
|
160
|
+
const packageJson = read(path.join(ROOT, 'package.json'));
|
|
161
|
+
if (packageJson !== null)
|
|
162
|
+
scripts = JSON.parse(packageJson).scripts ?? {};
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
warnings.push('package.json is not valid JSON.');
|
|
166
|
+
}
|
|
167
|
+
for (const name of ['asrun', 'signin', 'ui']) {
|
|
168
|
+
if (scripts !== null && !scripts[name])
|
|
169
|
+
warnings.push(`package.json has no "${name}" script: run "pnpm asrun init".`);
|
|
170
|
+
}
|
|
171
|
+
const settings = read(path.join(ROOT, '.claude', 'settings.json'));
|
|
172
|
+
if (settings === null) {
|
|
173
|
+
warnings.push('.claude/settings.json is missing: run "pnpm asrun init" (Claude Code needs its sandbox exclusions for pnpm asrun).');
|
|
174
|
+
}
|
|
175
|
+
else if (!settings.includes('pnpm asrun')) {
|
|
176
|
+
warnings.push('.claude/settings.json does not exclude "pnpm asrun" from the sandbox: the browser cannot start in it.');
|
|
177
|
+
}
|
|
178
|
+
console.log(facts.join('\n'));
|
|
179
|
+
for (const warning of warnings)
|
|
180
|
+
console.log(`\n! ${warning}`);
|
|
181
|
+
for (const error of errors)
|
|
182
|
+
console.log(`\nx ${error}`);
|
|
183
|
+
console.log(`\nDoctor: ${errors.length} error(s), ${warnings.length} warning(s).`);
|
|
184
|
+
return errors.length > 0 ? 1 : 0;
|
|
185
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { chromium } from '@playwright/test';
|
|
2
|
+
import { getProfile, listProfiles, profileLabel } from '../lib/profiles.js';
|
|
3
|
+
import { parseCliTarget } from '../lib/cli.js';
|
|
4
|
+
import { authFile } from '../lib/paths.js';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
const parsed = parseCliTarget(process.argv.slice(2));
|
|
9
|
+
const pathname = parsed.rest[0] ?? '/';
|
|
10
|
+
if (!parsed.profileId || parsed.help) {
|
|
11
|
+
console.log('Usage: pnpm explore <profile-id> [path] (opens a logged-in browser to look around)\n\n' +
|
|
12
|
+
'Profiles (the environment is part of each profile):\n' +
|
|
13
|
+
listProfiles()
|
|
14
|
+
.map((profile) => ` ${profile.id.padEnd(24)} ${profile.env.padEnd(10)} ${profile.baseURL}`)
|
|
15
|
+
.join('\n'));
|
|
16
|
+
process.exit(parsed.help ? 0 : 1);
|
|
17
|
+
}
|
|
18
|
+
if (parsed.envGiven) {
|
|
19
|
+
console.error('explore takes only the profile: its environment is part of profiles.yaml. Drop --env.');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const profile = getProfile(parsed.profileId);
|
|
23
|
+
const file = authFile(profile.id);
|
|
24
|
+
const browser = await chromium.launch({ headless: false });
|
|
25
|
+
const context = await browser.newContext({
|
|
26
|
+
viewport: null,
|
|
27
|
+
...(fs.existsSync(file) && { storageState: file }),
|
|
28
|
+
});
|
|
29
|
+
// Playwright keeps downloads in a temporary folder and deletes it on close, so save each file to ~/Downloads.
|
|
30
|
+
const downloadsDir = path.join(os.homedir(), 'Downloads');
|
|
31
|
+
const freeName = (name) => {
|
|
32
|
+
const { name: base, ext } = path.parse(path.basename(name) || 'download');
|
|
33
|
+
let candidate = path.join(downloadsDir, `${base}${ext}`);
|
|
34
|
+
for (let n = 1; fs.existsSync(candidate); n++)
|
|
35
|
+
candidate = path.join(downloadsDir, `${base} (${n})${ext}`);
|
|
36
|
+
return candidate;
|
|
37
|
+
};
|
|
38
|
+
const saveDownload = async (download) => {
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(downloadsDir, { recursive: true });
|
|
41
|
+
const target = freeName(download.suggestedFilename());
|
|
42
|
+
await download.saveAs(target);
|
|
43
|
+
console.log(`Saved download: ${target}`);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
console.error(`Could not save download "${download.suggestedFilename()}": ${error.message}`);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const watchPage = (page) => page.on('download', (download) => void saveDownload(download));
|
|
50
|
+
context.on('page', watchPage);
|
|
51
|
+
const page = await context.newPage();
|
|
52
|
+
await page.goto(new URL(pathname, profile.baseURL).href);
|
|
53
|
+
console.log(`Opened ${profileLabel(profile)}. Close the browser window to exit.`);
|
|
54
|
+
await new Promise((resolve) => browser.on('disconnected', resolve));
|
|
@@ -0,0 +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#v0.1.0)
|
|
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
|
+
}
|