@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,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 workspace root: the project that uses the framework. Same rule as lib/paths.ts, in plain Node.
|
|
8
|
+
const findWorkspace = (from) => {
|
|
9
|
+
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
|
|
10
|
+
if (fs.existsSync(path.join(dir, 'profiles.yaml')) || fs.existsSync(path.join(dir, '.asrun.json'))) return dir;
|
|
11
|
+
if (path.dirname(dir) === dir) return null;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
// `.env` next to the command, or the framework's own `.env`; a real environment variable always wins.
|
|
15
|
+
const loadRootEnv = (file) => {
|
|
16
|
+
try {
|
|
17
|
+
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
18
|
+
if (process.env.ASRUN_ROOT !== undefined) return;
|
|
19
|
+
const match = /^(?:export\s+)?ASRUN_ROOT\s*=\s*(.*)$/.exec(raw.trim());
|
|
20
|
+
if (match) {
|
|
21
|
+
process.env.ASRUN_ROOT = path.resolve(path.dirname(file), match[1].trim().replace(/^(['"])(.*)\1$/, '$2'));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// no .env: nothing to do
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
loadRootEnv(path.join(process.cwd(), '.env'));
|
|
29
|
+
if (process.env.ASRUN_ROOT === undefined) loadRootEnv(path.join(path.resolve(import.meta.dirname, '..'), '.env'));
|
|
30
|
+
const ROOT = process.env.ASRUN_ROOT
|
|
31
|
+
? path.resolve(process.env.ASRUN_ROOT)
|
|
32
|
+
: (findWorkspace(process.cwd()) ?? path.resolve(import.meta.dirname, '..'));
|
|
33
|
+
const REPORTS = path.join(ROOT, 'reports');
|
|
34
|
+
const RUN_ID = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(-[a-z0-9]+(-[a-z0-9]+)*)?$/;
|
|
35
|
+
const NOT_LOGGED_IN = /is not logged in to/;
|
|
36
|
+
|
|
37
|
+
const args = process.argv.slice(2);
|
|
38
|
+
const option = (name) => {
|
|
39
|
+
const index = args.indexOf(name);
|
|
40
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
41
|
+
};
|
|
42
|
+
const caseId = option('--case');
|
|
43
|
+
const env = option('--env');
|
|
44
|
+
const profileFilter = (option('--profile') ?? '').split(',').filter(Boolean);
|
|
45
|
+
const keep = Number(option('--max-reports') ?? 1);
|
|
46
|
+
|
|
47
|
+
if (args.includes('--help') || !caseId || !env) {
|
|
48
|
+
console.log('Usage: pnpm heal:evidence --case <case-id> --env <env> [--profile a,b] [--max-reports 1]');
|
|
49
|
+
process.exit(args.includes('--help') ? 0 : 1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const readSummary = (id) => {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(path.join(REPORTS, id, 'summary.json'), 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const mtime = (file) => (fs.existsSync(file) ? fs.statSync(file).mtimeMs : 0);
|
|
60
|
+
const iso = (ms) => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');
|
|
61
|
+
const firstLines = (text, count) => text.split('\n').slice(0, count).join('\n');
|
|
62
|
+
const indent = (text, prefix) => text.split('\n').map((line) => prefix + line).join('\n');
|
|
63
|
+
|
|
64
|
+
const runIds = fs.existsSync(REPORTS)
|
|
65
|
+
? fs.readdirSync(REPORTS).filter((name) => RUN_ID.test(name)).sort().reverse()
|
|
66
|
+
: [];
|
|
67
|
+
|
|
68
|
+
const matches = [];
|
|
69
|
+
for (const id of runIds) {
|
|
70
|
+
const summary = readSummary(id);
|
|
71
|
+
if (!summary) continue;
|
|
72
|
+
const runEnv = summary.env ?? /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)$/.exec(id)?.[1] ?? 'production';
|
|
73
|
+
if (runEnv !== env) continue;
|
|
74
|
+
const results = summary.results.filter(
|
|
75
|
+
(result) => result.caseId === caseId && (profileFilter.length === 0 || profileFilter.includes(result.profile))
|
|
76
|
+
);
|
|
77
|
+
if (results.length === 0) continue;
|
|
78
|
+
matches.push({id, summary, results});
|
|
79
|
+
if (matches.length >= keep) break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(`Heal evidence for case "${caseId}" in ${env}`);
|
|
83
|
+
|
|
84
|
+
if (matches.length === 0) {
|
|
85
|
+
console.log(`\nNO REPORT: no run in ${env} has results for this case${profileFilter.length ? ` and these profiles (${profileFilter})` : ''}.`);
|
|
86
|
+
console.log('EVIDENCE: none -> run the case once: pnpm asrun --env ' + env + ' --logged-in --grep ' + caseId);
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const watched = [
|
|
91
|
+
['spec', path.join(ROOT, 'tests', `${caseId}.spec.ts`)],
|
|
92
|
+
['case', path.join(ROOT, 'cases', `${caseId}.md`)],
|
|
93
|
+
['profiles.yaml', path.join(ROOT, 'profiles.yaml')],
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
let newest = null;
|
|
97
|
+
for (const {id, summary, results} of matches) {
|
|
98
|
+
const endedAt = Date.parse(summary.startedAt) + summary.durationMs;
|
|
99
|
+
const changed = watched
|
|
100
|
+
.map(([label, file]) => ({label, at: mtime(file)}))
|
|
101
|
+
.filter((item) => item.at > endedAt);
|
|
102
|
+
newest ??= {changed};
|
|
103
|
+
|
|
104
|
+
console.log(`\n=== Report ${id} (${summary.startedAt}) ===`);
|
|
105
|
+
console.log(`report: reports/${id}/summary.json html: reports/${id}/html`);
|
|
106
|
+
if (changed.length > 0) {
|
|
107
|
+
console.log(`STALE: changed after this run: ${changed.map((item) => `${item.label} (${iso(item.at)})`).join(', ')}`);
|
|
108
|
+
} else {
|
|
109
|
+
console.log('FRESH: the spec, the case and profiles.yaml have not changed since this run.');
|
|
110
|
+
}
|
|
111
|
+
if (summary.errors?.length) {
|
|
112
|
+
console.log('Run-level errors (not tied to a test):');
|
|
113
|
+
for (const error of summary.errors) console.log(indent(error.headline, ' - '));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const result of results) {
|
|
117
|
+
const loginProblem = result.errors.some((error) => NOT_LOGGED_IN.test(error.headline));
|
|
118
|
+
console.log(`\n[${result.profile}] ${result.status.toUpperCase()} (${result.attempts} attempt${result.attempts === 1 ? '' : 's'}, ${result.durationMs} ms)`);
|
|
119
|
+
if (result.status === 'passed' || result.status === 'skipped') continue;
|
|
120
|
+
if (loginProblem) {
|
|
121
|
+
console.log(' Not a test failure: the profile was not logged in during this run. Not evidence about the spec.');
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (result.failedStep) console.log(` failed step: ${result.failedStep}`);
|
|
125
|
+
for (const error of result.errors) {
|
|
126
|
+
console.log(indent(error.headline, ' error: '));
|
|
127
|
+
if (error.context) console.log(indent(firstLines(error.context, 8), ' '));
|
|
128
|
+
if (error.callLog) console.log(indent(firstLines(error.callLog, 12), ' log: '));
|
|
129
|
+
if (error.snippet) console.log(indent(firstLines(error.snippet, 8), ' code: '));
|
|
130
|
+
}
|
|
131
|
+
for (const file of result.screenshots) console.log(` screenshot: reports/${id}/html/${file}`);
|
|
132
|
+
for (const file of result.videos) console.log(` video: reports/${id}/html/${file}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const usable = matches[0].results.filter(
|
|
137
|
+
(result) =>
|
|
138
|
+
(result.status === 'failed' || result.status === 'flaky') &&
|
|
139
|
+
!result.errors.some((error) => NOT_LOGGED_IN.test(error.headline))
|
|
140
|
+
);
|
|
141
|
+
const state = newest.changed.length > 0 ? 'stale' : usable.length === 0 ? 'no-failure' : 'fresh';
|
|
142
|
+
console.log(`\nEVIDENCE: ${state}`);
|
|
143
|
+
if (usable.length > 0) console.log(`failing profiles in the newest report: ${usable.map((result) => result.profile).join(',')}`);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
|
|
2
|
+
export declare const GENERATED: {
|
|
3
|
+
file: string;
|
|
4
|
+
template: string;
|
|
5
|
+
}[];
|
|
6
|
+
/** How the workspace reaches the framework: `./node_modules/@asrunjs/asrun`, or a relative folder in the framework repo. */
|
|
7
|
+
export declare function frameworkPath(root: string): string;
|
|
8
|
+
/** True when this workspace has the package installed (or is the framework's own checkout). */
|
|
9
|
+
export declare function isInstalled(root: string): boolean;
|
|
10
|
+
/** Fills the `{{framework}}` placeholder of a template. */
|
|
11
|
+
export declare function renderTemplate(name: string, root: string): string;
|
|
12
|
+
export declare function frameworkVersion(): {
|
|
13
|
+
version: string;
|
|
14
|
+
commit: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function main(args: string[]): Promise<number>;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { FRAMEWORK_ROOT, TEMPLATES_DIR } from '../lib/paths.js';
|
|
5
|
+
/**
|
|
6
|
+
* Creates the files a workspace needs. It is safe to run many times: it never overwrites a file the project owns, and
|
|
7
|
+
* `--update` refreshes only the files generated from the framework templates.
|
|
8
|
+
*/
|
|
9
|
+
const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force <path>] [--no-env] [--install [spec]]
|
|
10
|
+
|
|
11
|
+
--root <dir> the workspace to initialize (default: the current folder)
|
|
12
|
+
--update refresh the generated files (playwright.config.ts, tests/seed.spec.ts, .claude/commands/*)
|
|
13
|
+
--force <path> overwrite one file, e.g. --force playwright.config.ts (may be repeated)
|
|
14
|
+
--no-env do not write .env (ASRUN_ROOT)
|
|
15
|
+
--install [spec] also add the dependency (default "asrun"; e.g. --install github:wizcas/asrun#v0.1.0)`;
|
|
16
|
+
/** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
|
|
17
|
+
export const GENERATED = [
|
|
18
|
+
{ file: 'playwright.config.ts', template: 'playwright.config.ts' },
|
|
19
|
+
{ file: 'tests/seed.spec.ts', template: 'seed.spec.ts' },
|
|
20
|
+
{ file: '.claude/commands/make-case.md', template: '.claude/commands/make-case.md' },
|
|
21
|
+
{ file: '.claude/commands/explore-case.md', template: '.claude/commands/explore-case.md' },
|
|
22
|
+
{ file: '.claude/commands/gen-spec.md', template: '.claude/commands/gen-spec.md' },
|
|
23
|
+
{ file: '.claude/commands/heal-spec.md', template: '.claude/commands/heal-spec.md' },
|
|
24
|
+
];
|
|
25
|
+
/** Files `init` creates once; after that the project owns them and may edit them freely. */
|
|
26
|
+
const ONCE = [
|
|
27
|
+
{ file: 'AGENTS.md', template: 'AGENTS.md' },
|
|
28
|
+
{ file: 'CLAUDE.md', template: 'CLAUDE.md' },
|
|
29
|
+
{ file: '.mcp.json', template: '.mcp.json' },
|
|
30
|
+
{ file: '.claude/settings.json', template: '.claude/settings.json' },
|
|
31
|
+
{ file: 'tsconfig.json', template: 'tsconfig.json' },
|
|
32
|
+
{ file: 'pnpm-workspace.yaml', template: 'pnpm-workspace.yaml' },
|
|
33
|
+
{ file: 'profiles.yaml', template: 'profiles.yaml' },
|
|
34
|
+
{ file: 'cases/_template.md', template: 'cases/_template.md' },
|
|
35
|
+
{ file: 'package.json', template: 'package.json' },
|
|
36
|
+
];
|
|
37
|
+
// npm never packs a `.gitignore`, so the template is named `gitignore`; init writes it as `.gitignore`.
|
|
38
|
+
const GITIGNORE_BLOCK = fs.readFileSync(path.join(TEMPLATES_DIR, 'gitignore'), 'utf8').trimEnd();
|
|
39
|
+
const MARKER = '.asrun.json';
|
|
40
|
+
const DOTENV_HEADER = '# asrun: the workspace these commands run against (relative to this file).';
|
|
41
|
+
const PACKAGE = '@asrunjs/asrun';
|
|
42
|
+
function readTemplate(name) {
|
|
43
|
+
return fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8');
|
|
44
|
+
}
|
|
45
|
+
/** True in the framework's own repository (not an installed package). */
|
|
46
|
+
function isRepoCheckout() {
|
|
47
|
+
return !FRAMEWORK_ROOT.split(path.sep).includes('node_modules') && fs.existsSync(path.join(FRAMEWORK_ROOT, 'examples', 'demo'));
|
|
48
|
+
}
|
|
49
|
+
/** How the workspace reaches the framework: `./node_modules/@asrunjs/asrun`, or a relative folder in the framework repo. */
|
|
50
|
+
export function frameworkPath(root) {
|
|
51
|
+
if (!isRepoCheckout()) {
|
|
52
|
+
// The standard install location, or a hoisted one in a monorepo.
|
|
53
|
+
for (let dir = path.resolve(root);; dir = path.dirname(dir)) {
|
|
54
|
+
const candidate = path.join(dir, 'node_modules', PACKAGE);
|
|
55
|
+
if (fs.existsSync(path.join(candidate, 'package.json'))) {
|
|
56
|
+
const relative = path.relative(root, candidate).split(path.sep).join('/');
|
|
57
|
+
return relative.startsWith('.') ? relative : `./${relative}`;
|
|
58
|
+
}
|
|
59
|
+
if (path.dirname(dir) === dir)
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
// Not installed yet (a scaffolding run): name the place the package will live.
|
|
63
|
+
return `./node_modules/${PACKAGE}`;
|
|
64
|
+
}
|
|
65
|
+
const relative = path.relative(root, FRAMEWORK_ROOT).split(path.sep).join('/');
|
|
66
|
+
if (relative === '')
|
|
67
|
+
return '.';
|
|
68
|
+
return relative.startsWith('.') ? relative : `./${relative}`;
|
|
69
|
+
}
|
|
70
|
+
/** True when this workspace has the package installed (or is the framework's own checkout). */
|
|
71
|
+
export function isInstalled(root) {
|
|
72
|
+
return fs.existsSync(path.join(path.resolve(root, frameworkPath(root)), 'package.json'));
|
|
73
|
+
}
|
|
74
|
+
/** Fills the `{{framework}}` placeholder of a template. */
|
|
75
|
+
export function renderTemplate(name, root) {
|
|
76
|
+
return readTemplate(name).replaceAll('{{framework}}', frameworkPath(root));
|
|
77
|
+
}
|
|
78
|
+
export function frameworkVersion() {
|
|
79
|
+
let version = '0.0.0';
|
|
80
|
+
try {
|
|
81
|
+
version = JSON.parse(fs.readFileSync(path.join(FRAMEWORK_ROOT, 'package.json'), 'utf8')).version ?? version;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// keep the default
|
|
85
|
+
}
|
|
86
|
+
let commit = 'unknown';
|
|
87
|
+
try {
|
|
88
|
+
commit = spawnSync('git', ['-C', FRAMEWORK_ROOT, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).stdout.trim() || 'unknown';
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// keep the default
|
|
92
|
+
}
|
|
93
|
+
return { version, commit };
|
|
94
|
+
}
|
|
95
|
+
/** Merges the template's npm scripts and devDependencies into an existing package.json (never overwrites). */
|
|
96
|
+
function mergePackageJson(root) {
|
|
97
|
+
const file = path.join(root, 'package.json');
|
|
98
|
+
if (!fs.existsSync(file))
|
|
99
|
+
return [];
|
|
100
|
+
const current = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
101
|
+
const template = JSON.parse(readTemplate('package.json'));
|
|
102
|
+
const added = [];
|
|
103
|
+
current.scripts ??= {};
|
|
104
|
+
for (const [name, command] of Object.entries(template.scripts)) {
|
|
105
|
+
if (current.scripts[name] === undefined) {
|
|
106
|
+
current.scripts[name] = command;
|
|
107
|
+
added.push(`scripts.${name}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
current.devDependencies ??= {};
|
|
111
|
+
for (const [name, range] of Object.entries(template.devDependencies)) {
|
|
112
|
+
if (current.devDependencies[name] === undefined) {
|
|
113
|
+
current.devDependencies[name] = range;
|
|
114
|
+
added.push(`devDependencies.${name}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (added.length > 0)
|
|
118
|
+
fs.writeFileSync(file, JSON.stringify(current, null, 2) + '\n');
|
|
119
|
+
return added;
|
|
120
|
+
}
|
|
121
|
+
/** Adds the ignore block, or refreshes the one that is already there. */
|
|
122
|
+
function ensureGitignore(root, update) {
|
|
123
|
+
const file = path.join(root, '.gitignore');
|
|
124
|
+
if (!fs.existsSync(file)) {
|
|
125
|
+
fs.writeFileSync(file, GITIGNORE_BLOCK + '\n');
|
|
126
|
+
return 'created';
|
|
127
|
+
}
|
|
128
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
129
|
+
const existing = /# >>> asrun[\s\S]*?# <<< asrun/;
|
|
130
|
+
if (!existing.test(text)) {
|
|
131
|
+
fs.writeFileSync(file, text.replace(/\n*$/, '\n\n') + GITIGNORE_BLOCK + '\n');
|
|
132
|
+
return 'appended';
|
|
133
|
+
}
|
|
134
|
+
if (!update)
|
|
135
|
+
return 'kept';
|
|
136
|
+
fs.writeFileSync(file, text.replace(existing, GITIGNORE_BLOCK));
|
|
137
|
+
return 'updated';
|
|
138
|
+
}
|
|
139
|
+
/** Points ASRUN_ROOT at the workspace, in the `.env` of the folder the commands run in. Keeps every other line. */
|
|
140
|
+
function ensureDotEnv(dir, workspaceRoot) {
|
|
141
|
+
const file = path.join(dir, '.env');
|
|
142
|
+
const relative = path.relative(dir, workspaceRoot).split(path.sep).join('/') || '.';
|
|
143
|
+
const line = `ASRUN_ROOT=${relative}`;
|
|
144
|
+
if (!fs.existsSync(file)) {
|
|
145
|
+
fs.writeFileSync(file, `${DOTENV_HEADER}\n${line}\n`);
|
|
146
|
+
return 'created';
|
|
147
|
+
}
|
|
148
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
149
|
+
const cleaned = text.replace(/^#\s*asrun: the workspace.*$/m, DOTENV_HEADER);
|
|
150
|
+
const pattern = /^ASRUN_ROOT\s*=/m;
|
|
151
|
+
if (pattern.test(cleaned)) {
|
|
152
|
+
const next = cleaned.replace(/^ASRUN_ROOT\s*=.*$/m, line);
|
|
153
|
+
if (next === text)
|
|
154
|
+
return 'kept';
|
|
155
|
+
fs.writeFileSync(file, next);
|
|
156
|
+
return 'updated';
|
|
157
|
+
}
|
|
158
|
+
fs.writeFileSync(file, cleaned.replace(/\n*$/, '\n') + `\n${DOTENV_HEADER}\n${line}\n`);
|
|
159
|
+
return 'updated';
|
|
160
|
+
}
|
|
161
|
+
function readManifest(root) {
|
|
162
|
+
for (const name of [MARKER]) {
|
|
163
|
+
try {
|
|
164
|
+
return JSON.parse(fs.readFileSync(path.join(root, name), 'utf8'));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// try the next name
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
export async function main(args) {
|
|
173
|
+
if (args.includes('--help')) {
|
|
174
|
+
console.log(USAGE);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
const value = (name) => {
|
|
178
|
+
const index = args.indexOf(name);
|
|
179
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
180
|
+
};
|
|
181
|
+
const root = path.resolve(value('--root') ?? process.cwd());
|
|
182
|
+
const update = args.includes('--update');
|
|
183
|
+
const installAt = args.indexOf('--install');
|
|
184
|
+
const install = installAt >= 0;
|
|
185
|
+
const installSpec = install && args[installAt + 1] && !args[installAt + 1].startsWith('-') ? args[installAt + 1] : PACKAGE;
|
|
186
|
+
const forced = args.flatMap((arg, index) => (arg === '--force' && args[index + 1] ? [args[index + 1]] : []));
|
|
187
|
+
const skipEnv = args.includes('--no-env');
|
|
188
|
+
if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
|
|
189
|
+
console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun init" inside the project that uses ' +
|
|
190
|
+
'asrun, or pass --root <dir> (the framework develops against examples/demo).');
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
const firstRun = !fs.existsSync(path.join(root, MARKER));
|
|
194
|
+
const written = [];
|
|
195
|
+
const kept = [];
|
|
196
|
+
const notes = [];
|
|
197
|
+
const framework = frameworkPath(root);
|
|
198
|
+
const place = (entry, overwrite) => {
|
|
199
|
+
const target = path.join(root, entry.file);
|
|
200
|
+
if (fs.existsSync(target) && !overwrite) {
|
|
201
|
+
kept.push(entry.file);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
205
|
+
fs.writeFileSync(target, renderTemplate(entry.template, root));
|
|
206
|
+
written.push(entry.file);
|
|
207
|
+
};
|
|
208
|
+
for (const entry of GENERATED)
|
|
209
|
+
place(entry, update || forced.includes(entry.file));
|
|
210
|
+
for (const entry of ONCE) {
|
|
211
|
+
// The demo workspace lives inside the framework repository: it has no package manager settings of its own.
|
|
212
|
+
if (framework.startsWith('..') && (entry.file === 'pnpm-workspace.yaml' || entry.file === 'package.json'))
|
|
213
|
+
continue;
|
|
214
|
+
place(entry, forced.includes(entry.file));
|
|
215
|
+
}
|
|
216
|
+
const gitignore = ensureGitignore(root, update);
|
|
217
|
+
const merged = mergePackageJson(root);
|
|
218
|
+
const dotenv = skipEnv ? 'skipped' : ensureDotEnv(process.cwd(), root);
|
|
219
|
+
if (fs.existsSync(path.join(root, 'tsconfig.json')) && !written.includes('tsconfig.json')) {
|
|
220
|
+
const tsconfig = fs.readFileSync(path.join(root, 'tsconfig.json'), 'utf8');
|
|
221
|
+
if (tsconfig.includes('@asrun/')) {
|
|
222
|
+
notes.push('Specs import the framework by package name now ("@asrunjs/asrun/fixtures"); the old "@..." path alias in tsconfig.json can go.');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (fs.existsSync(path.join(root, 'pnpm-workspace.yaml')) && !written.includes('pnpm-workspace.yaml')) {
|
|
226
|
+
const workspace = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8');
|
|
227
|
+
if (!workspace.includes('allowBuilds')) {
|
|
228
|
+
notes.push('Add "allowBuilds: {esbuild: true, ffmpeg-static: true}" to pnpm-workspace.yaml, or pnpm skips their build scripts.');
|
|
229
|
+
}
|
|
230
|
+
if (/^packages\s*:/m.test(workspace)) {
|
|
231
|
+
notes.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (fs.existsSync(path.join(root, '.mcp.json'))) {
|
|
235
|
+
const mcp = fs.readFileSync(path.join(root, '.mcp.json'), 'utf8');
|
|
236
|
+
if (!mcp.includes('playwright-test'))
|
|
237
|
+
notes.push('Add the "playwright-test" MCP server to .mcp.json (see templates/.mcp.json).');
|
|
238
|
+
}
|
|
239
|
+
if (fs.existsSync(path.join(root, '.claude', 'settings.json'))) {
|
|
240
|
+
const settings = fs.readFileSync(path.join(root, '.claude', 'settings.json'), 'utf8');
|
|
241
|
+
if (!settings.includes('pnpm asrun')) {
|
|
242
|
+
notes.push('Add "pnpm asrun *" to sandbox.excludedCommands in .claude/settings.json (see templates/.claude/settings.json).');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Install last, so pnpm-workspace.yaml (the build-script approval) already exists. Its absence is the reason
|
|
246
|
+
// "pnpm add -D @asrunjs/asrun" fails on pnpm 12 with ERR_PNPM_IGNORED_BUILDS.
|
|
247
|
+
const installed = isInstalled(root);
|
|
248
|
+
if (install && !installed) {
|
|
249
|
+
if (spawnSync('pnpm', ['add', '-D', installSpec], { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' }).status !== 0) {
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const version = frameworkVersion();
|
|
254
|
+
fs.writeFileSync(path.join(root, MARKER), JSON.stringify({
|
|
255
|
+
framework: PACKAGE,
|
|
256
|
+
frameworkPath: framework,
|
|
257
|
+
version: version.version,
|
|
258
|
+
commit: version.commit,
|
|
259
|
+
generated: GENERATED.map((entry) => entry.file),
|
|
260
|
+
updatedAt: new Date().toISOString(),
|
|
261
|
+
}, null, 2) + '\n');
|
|
262
|
+
const label = firstRun ? 'Initialized' : update ? 'Refreshed' : 'Checked';
|
|
263
|
+
console.log(`${label} workspace: ${root}`);
|
|
264
|
+
console.log(` framework: ${PACKAGE} ${version.version} at ${framework}`);
|
|
265
|
+
if (written.length > 0)
|
|
266
|
+
console.log(` written: ${written.join(', ')}`);
|
|
267
|
+
if (kept.length > 0)
|
|
268
|
+
console.log(` kept: ${kept.join(', ')}`);
|
|
269
|
+
if (merged.length > 0)
|
|
270
|
+
console.log(` merged: package.json -> ${merged.join(', ')}`);
|
|
271
|
+
if (gitignore !== 'kept')
|
|
272
|
+
console.log(` .gitignore: ${gitignore}`);
|
|
273
|
+
if (dotenv !== 'skipped')
|
|
274
|
+
console.log(` .env: ${dotenv} (ASRUN_ROOT in ${process.cwd()})`);
|
|
275
|
+
for (const note of notes)
|
|
276
|
+
console.log(`\n ! ${note}`);
|
|
277
|
+
if (!installed && !install) {
|
|
278
|
+
console.log(`\n ! ${PACKAGE} is not installed yet. Next: pnpm add -D ${installSpec}`);
|
|
279
|
+
}
|
|
280
|
+
if (firstRun) {
|
|
281
|
+
console.log('\nNext: edit profiles.yaml, run "pnpm install", then "pnpm asrun doctor" and "pnpm signin <profile-id>".');
|
|
282
|
+
}
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { listCaseFileIds } from '../lib/cases.js';
|
|
4
|
+
import { TESTS_DIR } from '../lib/paths.js';
|
|
5
|
+
import { parseTargets, readCaseText, TARGET_REF } from '../lib/targets.js';
|
|
6
|
+
const specFiles = (dir) => fs.existsSync(dir)
|
|
7
|
+
? fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
8
|
+
const full = path.join(dir, entry.name);
|
|
9
|
+
if (entry.isDirectory())
|
|
10
|
+
return entry.name.endsWith('-snapshots') ? [] : specFiles(full);
|
|
11
|
+
return entry.name.endsWith('.spec.ts') && entry.name !== 'seed.spec.ts' ? [full] : [];
|
|
12
|
+
})
|
|
13
|
+
: [];
|
|
14
|
+
const IMAGE_WORDS = /\b(logo|image|img|icon|avatar|screenshot|snapshot)\b/i;
|
|
15
|
+
const TARGET_COMMENT = /\/\/\s*target:\s*(T\d+)/;
|
|
16
|
+
const near = (lines, index, back = 4) => lines.slice(Math.max(0, index - back), index + 1).join('\n');
|
|
17
|
+
const withoutStringsAndComments = (line) => line
|
|
18
|
+
.replace(/\/\/.*$/, '')
|
|
19
|
+
.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/g, '""');
|
|
20
|
+
/**
|
|
21
|
+
* Marks every line that is inside the callback of `expect(async () => {...}).toPass(...)`. The callback is retried until it
|
|
22
|
+
* passes, so a single `isVisible()` look in it is not a one-look decision. `toPass` comes after the closing brace, so the
|
|
23
|
+
* block is found by counting braces from the `expect(async () => {` line.
|
|
24
|
+
*/
|
|
25
|
+
function linesInsideToPass(lines) {
|
|
26
|
+
const inside = new Array(lines.length).fill(false);
|
|
27
|
+
const clean = lines.map(withoutStringsAndComments);
|
|
28
|
+
for (let start = 0; start < lines.length; start++) {
|
|
29
|
+
if (!/\bexpect\(\s*async\s*\(\s*\)\s*=>\s*\{/.test(clean[start]))
|
|
30
|
+
continue;
|
|
31
|
+
let depth = 0;
|
|
32
|
+
let opened = false;
|
|
33
|
+
for (let end = start; end < lines.length; end++) {
|
|
34
|
+
const row = clean[end];
|
|
35
|
+
for (let col = end === start ? row.search(/\bexpect\(\s*async/) : 0; col < row.length; col++) {
|
|
36
|
+
if (row[col] === '{') {
|
|
37
|
+
depth++;
|
|
38
|
+
opened = true;
|
|
39
|
+
}
|
|
40
|
+
else if (row[col] === '}') {
|
|
41
|
+
depth--;
|
|
42
|
+
if (opened && depth === 0) {
|
|
43
|
+
const after = row.slice(col) + (clean[end + 1] ?? '');
|
|
44
|
+
if (/^\}\s*\)\s*\.toPass\s*\(/.test(after))
|
|
45
|
+
for (let line = start; line <= end; line++)
|
|
46
|
+
inside[line] = true;
|
|
47
|
+
col = row.length;
|
|
48
|
+
end = lines.length;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return inside;
|
|
55
|
+
}
|
|
56
|
+
function lint(file) {
|
|
57
|
+
const findings = [];
|
|
58
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
59
|
+
const insideToPass = linesInsideToPass(lines);
|
|
60
|
+
const rel = path.relative(path.join(TESTS_DIR, '..'), file);
|
|
61
|
+
const add = (level, index, rule, message) => findings.push({ level, file: rel, line: index + 1, rule, message });
|
|
62
|
+
const caseId = /^\/\/ case: cases\/(.+)\.md/m.exec(lines.join('\n'))?.[1];
|
|
63
|
+
const cards = caseId && listCaseFileIds().includes(caseId) ? parseTargets(readCaseText(caseId)) : [];
|
|
64
|
+
const cardIds = new Set(cards.map((card) => card.id));
|
|
65
|
+
lines.forEach((line, i) => {
|
|
66
|
+
const code = line.replace(/\/\/.*$/, '');
|
|
67
|
+
const target = TARGET_COMMENT.exec(near(lines, i));
|
|
68
|
+
if (/\bwaitForTimeout\s*\(/.test(code))
|
|
69
|
+
add('fail', i, 'no-sleep', 'waitForTimeout waits for time, not for the page. Wait for the request (actAndWait) or for a visible signal.');
|
|
70
|
+
if (/networkidle/.test(code))
|
|
71
|
+
add('fail', i, 'no-networkidle', 'networkidle is not a readiness signal. Wait for the key request or for the content (actAndWait).');
|
|
72
|
+
if (/waitForEvent\(\s*['"]download['"]/.test(code) || /\.on\(\s*['"]download['"]/.test(code)) {
|
|
73
|
+
add('fail', i, 'download', 'Verify downloads with verifyDownload() from lib/downloads: start, finish, file exists, file valid.');
|
|
74
|
+
}
|
|
75
|
+
if (/kind:\s*['"]pdf['"]/.test(code) && !/expectPdf\s*\(/.test(lines.join('\n'))) {
|
|
76
|
+
add('warn', i, 'pdf-content', 'A PDF download is opened but its content is never checked. Render the pages and check the text with expectPdf() from lib/pdf.');
|
|
77
|
+
}
|
|
78
|
+
if (/expectPdf\s*\(/.test(code) && !target) {
|
|
79
|
+
add('fail', i, 'pdf-target', 'expectPdf needs "// target: T<n>" (a card in the case\'s "## Targets": which pages, which region, what must be there).');
|
|
80
|
+
}
|
|
81
|
+
if (/pages:\s*(['"](?:first|last)['"]|\[)/.test(code) && !/\/\/\s*pages:\s*\S/.test(near(lines, i, 3) + ' ' + line)) {
|
|
82
|
+
add('fail', i, 'pdf-pages', 'Checking only some pages of a PDF needs "// pages: <the case\'s words>" with the strong requirement (only / exactly / must). Otherwise check every page (pages: \'all\').');
|
|
83
|
+
}
|
|
84
|
+
if (/%PDF/.test(code) && !/lib\/downloads/.test(code))
|
|
85
|
+
add('fail', i, 'download', 'A %PDF magic-number check alone is not a download verification. Use verifyDownload(page, trigger, {kind: "pdf"}).');
|
|
86
|
+
if (/toHaveScreenshot\s*\(/.test(code)) {
|
|
87
|
+
if (!target)
|
|
88
|
+
add('fail', i, 'screenshot-target', 'A screenshot check needs "// target: T<n>" (a card in the case\'s "## Targets": what is shown, where it is, how it is found).');
|
|
89
|
+
}
|
|
90
|
+
const imageCheck = /toHaveScreenshot|expectImageLoaded|expectLogo|expectImageSha256|naturalWidth/.test(code);
|
|
91
|
+
const genericImage = /locator\(\s*['"`](?:img|svg|img,\s*svg|svg,\s*img|img\[alt\][^'"`]*)['"`]\s*\)/.test(code);
|
|
92
|
+
const pickedByPosition = /\.(first|last)\(\)|\.nth\(/.test(code);
|
|
93
|
+
if ((imageCheck || IMAGE_WORDS.test(code)) && genericImage && !target) {
|
|
94
|
+
add('fail', i, 'generic-image-locator', 'A bare img/svg locator can match any image on the page (a content image, an avatar). Locate the exact element by its role, name or test id and mark it "// target: T<n>".');
|
|
95
|
+
}
|
|
96
|
+
if (pickedByPosition && IMAGE_WORDS.test(code) && /\b(img|svg|logo|image)\b/i.test(code) && !target) {
|
|
97
|
+
add('fail', i, 'position-pick', '.first()/.last()/.nth() on an image or logo picks by position, not by identity. Name the exact element and mark it "// target: T<n>".');
|
|
98
|
+
}
|
|
99
|
+
if (/\.(isVisible|isHidden)\(\s*\{/.test(code)) {
|
|
100
|
+
add('fail', i, 'isvisible-timeout', 'isVisible()/isHidden() do not wait and ignore their timeout. Use expect(...).toBeVisible({timeout}), or for an optional confirmation clickOptionalUntilHidden() from lib/ready.');
|
|
101
|
+
}
|
|
102
|
+
else if (/\.isVisible\(\s*\)/.test(code) && /\bif\s*\(/.test(code)) {
|
|
103
|
+
const insideRetry = insideToPass[i];
|
|
104
|
+
if (/\.click\(/.test(code) && !insideRetry) {
|
|
105
|
+
add('fail', i, 'isvisible-click', 'if (await x.isVisible()) await x.click() decides from one look: a dialog that shows up a moment later is skipped. Use clickOptionalUntilHidden() from lib/ready, or put it inside expect(...).toPass().');
|
|
106
|
+
}
|
|
107
|
+
else if (!insideRetry && !/ready:/.test(near(lines, i, 6))) {
|
|
108
|
+
add('fail', i, 'isvisible-now', 'if (await x.isVisible()) checks only this instant, before the content may have loaded. Wait for a ready signal and mark it "// ready: <signal>", or move the check into expect(...).toPass().');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (/\.catch\(\s*\(\)\s*=>\s*(false|null|undefined)\s*\)/.test(code) && /\b(isVisible|waitFor|toBeVisible)\b/.test(code)) {
|
|
112
|
+
add('warn', i, 'swallowed-wait', '.catch(() => false) turns a failed wait into "not there". For something that may not appear, use clickOptionalUntilHidden() or wait for a signal that proves the page finished.');
|
|
113
|
+
}
|
|
114
|
+
if (/Not verified|not verified on the page|unverified/i.test(line)) {
|
|
115
|
+
add('warn', i, 'unverified', 'This check was written without seeing it on the page. Say so in the report; confirm it by running /explore-case before trusting it.');
|
|
116
|
+
}
|
|
117
|
+
const absence = /toHaveCount\(\s*0\s*\)|\.not\.toBeVisible|\.not\.toBeAttached|toBeEmpty\(\)|toBeHidden/.test(code);
|
|
118
|
+
if (absence && /missing|empty|no |not (shown|found|listed)|without/i.test(line) && !/ready:/.test(near(lines, i, 6))) {
|
|
119
|
+
add('warn', i, 'absence-without-ready', 'An absence check passes while the page is still loading. Add a positive ready signal before it and mark it "// ready: <signal>".');
|
|
120
|
+
}
|
|
121
|
+
for (const match of line.matchAll(/\/\/\s*target:\s*(T\d+)/g)) {
|
|
122
|
+
if (!cardIds.has(match[1]))
|
|
123
|
+
add('fail', i, 'unknown-target', `${match[1]} is not a card in "## Targets" of ${caseId ?? 'the case'}.`);
|
|
124
|
+
else if (cards.find((card) => card.id === match[1])?.blocking)
|
|
125
|
+
add('fail', i, 'blocking-target', `${match[1]} still has an open [blocking] question in the case; do not build a check on it (use test.fixme).`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
TARGET_REF.lastIndex = 0;
|
|
129
|
+
return findings;
|
|
130
|
+
}
|
|
131
|
+
const wanted = process.argv.slice(2).filter((arg) => !arg.startsWith('-'));
|
|
132
|
+
const files = specFiles(TESTS_DIR).filter((file) => wanted.length === 0 || wanted.some((id) => file.endsWith(`${id}.spec.ts`)));
|
|
133
|
+
const findings = files.flatMap(lint);
|
|
134
|
+
for (const f of findings)
|
|
135
|
+
console.log(`${f.level.toUpperCase().padEnd(4)} ${f.file}:${f.line} [${f.rule}] ${f.message}`);
|
|
136
|
+
const fails = findings.filter((f) => f.level === 'fail').length;
|
|
137
|
+
console.log(`\nspec lint: ${files.length} spec(s), ${fails} failure(s), ${findings.length - fails} warning(s)`);
|
|
138
|
+
process.exit(fails > 0 ? 1 : 0);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline/promises';
|
|
4
|
+
import { chromium } from '@playwright/test';
|
|
5
|
+
import { getProfile, listProfiles, profileLabel } from '../lib/profiles.js';
|
|
6
|
+
import { parseCliTarget } from '../lib/cli.js';
|
|
7
|
+
import { authFile } from '../lib/paths.js';
|
|
8
|
+
import { checkSession } from '../lib/session.js';
|
|
9
|
+
const parsed = parseCliTarget(process.argv.slice(2));
|
|
10
|
+
if (!parsed.profileId || parsed.help) {
|
|
11
|
+
console.log('Usage: pnpm signin <profile-id>\n\nProfiles:');
|
|
12
|
+
for (const profile of listProfiles()) {
|
|
13
|
+
console.log(` ${profile.id.padEnd(24)} ${profile.env.padEnd(10)} ${profile.baseURL}`);
|
|
14
|
+
}
|
|
15
|
+
process.exit(parsed.help ? 0 : 1);
|
|
16
|
+
}
|
|
17
|
+
if (parsed.envGiven) {
|
|
18
|
+
console.error('signin takes only the profile: its environment is part of profiles.yaml. Drop --env.');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const profile = getProfile(parsed.profileId);
|
|
22
|
+
const file = authFile(profile.id);
|
|
23
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
24
|
+
const browser = await chromium.launch({ headless: false });
|
|
25
|
+
const context = await browser.newContext({ viewport: null });
|
|
26
|
+
const page = await context.newPage();
|
|
27
|
+
await page.goto(new URL('/login/', profile.baseURL).href);
|
|
28
|
+
console.log(`\nA browser window opened for "${profileLabel(profile)}" (${profile.baseURL}).`);
|
|
29
|
+
console.log('1. Log in as usual, including the 2FA code.');
|
|
30
|
+
console.log('2. Wait until you can see the logged-in page.');
|
|
31
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
32
|
+
await rl.question('3. Come back here and press Enter to save the session... ');
|
|
33
|
+
rl.close();
|
|
34
|
+
await context.storageState({ path: file });
|
|
35
|
+
await browser.close();
|
|
36
|
+
fs.chmodSync(file, 0o600);
|
|
37
|
+
const verifyBrowser = await chromium.launch();
|
|
38
|
+
const status = await checkSession(verifyBrowser, profile);
|
|
39
|
+
await verifyBrowser.close();
|
|
40
|
+
if (status === 'ok') {
|
|
41
|
+
console.log(`\nSaved and verified: ${file}`);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
fs.rmSync(file);
|
|
45
|
+
console.error(`\nThe saved session does not look logged in (${status}). Nothing was kept. Please try again.`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isValidCaseId } from '../lib/case-id.js';
|
|
4
|
+
import { CASES_DIR } from '../lib/paths.js';
|
|
5
|
+
const id = process.argv[2];
|
|
6
|
+
if (!id || !isValidCaseId(id)) {
|
|
7
|
+
console.error('Usage: pnpm case:new <kebab-case-id> e.g. pnpm case:new header-logo\n' +
|
|
8
|
+
' pnpm case:new <group>/<kebab-case-id> puts the case in cases/<group>/, e.g. pnpm case:new documents/header-logo');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
const target = path.join(CASES_DIR, `${id}.md`);
|
|
12
|
+
if (fs.existsSync(target)) {
|
|
13
|
+
console.error(`Already exists: ${target}`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
const template = fs.readFileSync(path.join(CASES_DIR, '_template.md'), 'utf8');
|
|
17
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
18
|
+
fs.writeFileSync(target, template.replaceAll('{{id}}', id));
|
|
19
|
+
console.log(`Created ${target}\nFill it in (check it with: pnpm case:check), then ask your AI agent (OpenCode or Claude Code): /gen-spec ${id}`);
|