@asrunjs/asrun 0.0.0-stage → 0.5.2
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 +18 -0
- package/.claude/commands/gen-spec.md +16 -0
- package/.claude/commands/heal-spec.md +26 -0
- package/.claude/commands/make-case.md +11 -0
- package/.claude/settings.json +5 -0
- package/.claude/skills/exploring-cases/SKILL.md +146 -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 +116 -0
- package/.claude/skills/exploring-cases/references/readiness.md +146 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +157 -0
- package/.claude/skills/generating-specs/SKILL.md +219 -0
- package/.claude/skills/generating-specs/references/conventions.md +148 -0
- package/.claude/skills/making-cases/SKILL.md +72 -0
- package/.claude/skills/making-cases/references/gaps.md +36 -0
- package/.claude/skills/making-cases/references/normalizing.md +39 -0
- package/.claude/skills/making-cases/references/splitting.md +28 -0
- package/.claude/skills/making-cases/references/writes-and-email.md +37 -0
- package/AGENTS.md +159 -0
- package/CLAUDE.md +2 -0
- package/LICENSE +21 -0
- package/README.md +511 -2
- 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/build-artifacts.d.ts +8 -0
- package/dist/lib/build-artifacts.js +10 -0
- package/dist/lib/case-doc.d.ts +311 -0
- package/dist/lib/case-doc.js +1117 -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 +16 -0
- package/dist/lib/cases.js +51 -0
- package/dist/lib/cli.d.ts +10 -0
- package/dist/lib/cli.js +18 -0
- package/dist/lib/command.d.ts +24 -0
- package/dist/lib/command.js +90 -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/login-card.d.ts +22 -0
- package/dist/lib/login-card.js +183 -0
- package/dist/lib/node-cli.d.ts +15 -0
- package/dist/lib/node-cli.js +36 -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 +62 -0
- package/dist/lib/profiles.js +174 -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 +38 -0
- package/dist/lib/run-summary.d.ts +90 -0
- package/dist/lib/run-summary.js +35 -0
- package/dist/lib/session.d.ts +30 -0
- package/dist/lib/session.js +111 -0
- package/dist/lib/summary-reporter.d.ts +16 -0
- package/dist/lib/summary-reporter.js +146 -0
- package/dist/lib/targets.d.ts +19 -0
- package/dist/lib/targets.js +26 -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 +21 -0
- package/dist/scripts/check-cases.d.ts +1 -0
- package/dist/scripts/check-cases.js +149 -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 +296 -0
- package/dist/scripts/explore.d.ts +1 -0
- package/dist/scripts/explore.js +75 -0
- package/dist/scripts/go.mjs +188 -0
- package/dist/scripts/heal-evidence.mjs +143 -0
- package/dist/scripts/init.d.ts +22 -0
- package/dist/scripts/init.js +332 -0
- package/dist/scripts/lint-specs.d.ts +1 -0
- package/dist/scripts/lint-specs.js +141 -0
- package/dist/scripts/login.d.ts +1 -0
- package/dist/scripts/login.js +75 -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 +95 -0
- package/dist/ui/server/actions.js +365 -0
- package/dist/ui/server/ai-models.d.ts +3 -0
- package/dist/ui/server/ai-models.js +347 -0
- package/dist/ui/server/ai-stream.d.ts +66 -0
- package/dist/ui/server/ai-stream.js +408 -0
- package/dist/ui/server/app.d.ts +4 -0
- package/dist/ui/server/app.js +395 -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 +80 -0
- package/dist/ui/server/jobs.js +317 -0
- package/dist/ui/server/profiles.d.ts +35 -0
- package/dist/ui/server/profiles.js +371 -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 +88 -0
- package/dist/ui/shared/ai-commands.js +177 -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 +371 -0
- package/dist/ui/shared/types.js +1 -0
- package/docs/agents/framework-dev.md +79 -0
- package/docs/theme-guide.html +922 -0
- package/package.json +109 -3
- package/templates/.claude/commands/explore-case.md +22 -0
- package/templates/.claude/commands/gen-spec.md +21 -0
- package/templates/.claude/commands/heal-spec.md +31 -0
- package/templates/.claude/commands/make-case.md +13 -0
- package/templates/.claude/settings.json +5 -0
- package/templates/.mcp.json +8 -0
- package/templates/AGENTS.md +64 -0
- package/templates/CLAUDE.md +1 -0
- package/templates/cases/_template.md +57 -0
- package/templates/gitignore +13 -0
- package/templates/opencode.jsonc +16 -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 +42 -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-BGbe0-Aa.js +1 -0
- package/ui/dist/assets/dist-Ba-HC662.js +2 -0
- package/ui/dist/assets/dist-BtMLMthJ.js +1 -0
- package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
- package/ui/dist/assets/dist-BzulKuec.js +1 -0
- package/ui/dist/assets/dist-C-Oyo1ZT.js +1 -0
- package/ui/dist/assets/dist-C8MdhqgG.js +1 -0
- package/ui/dist/assets/dist-CG1pHn5m.js +1 -0
- package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
- package/ui/dist/assets/dist-CmpteMYY.js +9 -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-DSy3dVf4.js +11 -0
- package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
- package/ui/dist/assets/dist-Dq6sW5Ur.js +1 -0
- package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
- package/ui/dist/assets/dist-jjxQutEm.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-CTmnF0Wp.css +2 -0
- package/ui/dist/assets/index-P_Yo0ZZn.js +338 -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/help/ai-tools.png +0 -0
- package/ui/dist/help/case-explore.png +0 -0
- package/ui/dist/help/case-generate.png +0 -0
- package/ui/dist/help/cases-memo.png +0 -0
- package/ui/dist/help/profile-form.png +0 -0
- package/ui/dist/help/profiles-login.png +0 -0
- package/ui/dist/help/reports.png +0 -0
- package/ui/dist/help/run-tests.png +0 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/logos/claude.svg +1 -0
- package/ui/dist/logos/opencode.svg +1 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { LedgerEntry } from './records.js';
|
|
2
|
+
/** One failure, split so that people read the meaning first and the technical noise only on demand. */
|
|
3
|
+
export interface ParsedError {
|
|
4
|
+
/** First line of the message: what went wrong, in the words of the assertion message. */
|
|
5
|
+
headline: string;
|
|
6
|
+
/** The rest of the message before the call log (Locator / Expected / Received ...). */
|
|
7
|
+
context: string;
|
|
8
|
+
/** Playwright's call log: what it tried and waited for. */
|
|
9
|
+
callLog: string;
|
|
10
|
+
/** Code frame around the failing line. */
|
|
11
|
+
snippet: string;
|
|
12
|
+
/** Stack frames only (no message). */
|
|
13
|
+
stack: string;
|
|
14
|
+
}
|
|
15
|
+
export type ResultStatus = 'passed' | 'failed' | 'flaky' | 'skipped';
|
|
16
|
+
export interface TestRunResult {
|
|
17
|
+
/** Playwright's id of the test (unique per project); the HTML report deep-links with it. */
|
|
18
|
+
testId: string;
|
|
19
|
+
caseId: string;
|
|
20
|
+
/** Profile id, without the environment. */
|
|
21
|
+
profile: string;
|
|
22
|
+
/** Environment of the run (`production`, `staging`, ...). Absent in summaries written before environments existed. */
|
|
23
|
+
env?: string;
|
|
24
|
+
/** The case writes test data (`writes` in its front matter). Absent in older summaries. */
|
|
25
|
+
writes?: boolean;
|
|
26
|
+
title: string;
|
|
27
|
+
status: ResultStatus;
|
|
28
|
+
durationMs: number;
|
|
29
|
+
/** How many times the test ran (retries included). */
|
|
30
|
+
attempts: number;
|
|
31
|
+
/** Title of the `test.step` that failed, e.g. "2. Find the first published document". */
|
|
32
|
+
failedStep: string | null;
|
|
33
|
+
errors: ParsedError[];
|
|
34
|
+
/** Paths relative to the HTML report folder (`data/<sha1>.png`). */
|
|
35
|
+
screenshots: string[];
|
|
36
|
+
/** Per-page recordings (`data/<sha1>.webm`). */
|
|
37
|
+
videos: string[];
|
|
38
|
+
/** All recordings of the test joined in order, relative to the report folder (`videos/<testId>.webm`). */
|
|
39
|
+
video?: string;
|
|
40
|
+
}
|
|
41
|
+
/** Playwright's overall result of the run. */
|
|
42
|
+
export type RunStatus = 'passed' | 'failed' | 'timedout' | 'interrupted';
|
|
43
|
+
/** How the run was started, so the report can run it again the same way. */
|
|
44
|
+
export interface RunSettings {
|
|
45
|
+
stepText: boolean;
|
|
46
|
+
readOnly: boolean;
|
|
47
|
+
/** The profiles that were selected; null = every profile of the environment. */
|
|
48
|
+
profiles: string[] | null;
|
|
49
|
+
/** Tags that selected the profiles (absent = no tag filter). */
|
|
50
|
+
tags?: string[];
|
|
51
|
+
suites: string[];
|
|
52
|
+
/** The user's own `--grep`. */
|
|
53
|
+
grep?: string;
|
|
54
|
+
/** The run was limited to these cases (the part of an `--update-baselines` run that is not, or is, being rewritten). */
|
|
55
|
+
only?: string[];
|
|
56
|
+
}
|
|
57
|
+
/** Environment variable that carries the settings (JSON) from `pnpm asrun` to the summary reporter. */
|
|
58
|
+
export declare const RUN_SETTINGS_ENV = "ASRUN_RUN_SETTINGS";
|
|
59
|
+
export interface RunSummary {
|
|
60
|
+
runId: string;
|
|
61
|
+
/** Absent in summaries written before settings were recorded, and for runs not started by `pnpm asrun`. */
|
|
62
|
+
settings?: RunSettings;
|
|
63
|
+
/** Environment the whole run targeted. Absent in summaries written before environments existed (those were production). */
|
|
64
|
+
env?: string;
|
|
65
|
+
startedAt: string;
|
|
66
|
+
durationMs: number;
|
|
67
|
+
/** Absent in summaries written before run status was recorded. */
|
|
68
|
+
status?: RunStatus;
|
|
69
|
+
/**
|
|
70
|
+
* Errors that belong to the run, not to a test: a spec that fails to load (broken import, syntax
|
|
71
|
+
* error), a failing global setup or worker teardown. Absent in older summaries.
|
|
72
|
+
*/
|
|
73
|
+
errors?: ParsedError[];
|
|
74
|
+
counts: Record<ResultStatus, number>;
|
|
75
|
+
/** Test data that was created and not removed again (needs a person). Absent in older summaries. */
|
|
76
|
+
leftovers?: LedgerEntry[];
|
|
77
|
+
results: TestRunResult[];
|
|
78
|
+
}
|
|
79
|
+
/** Turns a Playwright `TestError` into the parts shown by the UI. */
|
|
80
|
+
export declare function parseError(error: {
|
|
81
|
+
message?: string;
|
|
82
|
+
stack?: string;
|
|
83
|
+
snippet?: string;
|
|
84
|
+
value?: string;
|
|
85
|
+
}): ParsedError;
|
|
86
|
+
/**
|
|
87
|
+
* `tests/<caseId>.spec.ts` -> `<caseId>`, with the group folder: `tests/documents/landing-page.spec.ts` -> `documents/landing-page`.
|
|
88
|
+
* Accepts the absolute path (`testInfo.file`) and the path relative to `tests/` (what `--list` reports).
|
|
89
|
+
*/
|
|
90
|
+
export declare function caseIdFromFile(file: string): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { TESTS_DIR } from './paths.js';
|
|
4
|
+
/** Environment variable that carries the settings (JSON) from `pnpm asrun` to the summary reporter. */
|
|
5
|
+
export const RUN_SETTINGS_ENV = 'ASRUN_RUN_SETTINGS';
|
|
6
|
+
const CALL_LOG = /\n[ \t]*Call log:[ \t]*\n/;
|
|
7
|
+
/** Turns a Playwright `TestError` into the parts shown by the UI. */
|
|
8
|
+
export function parseError(error) {
|
|
9
|
+
const message = stripVTControlCharacters(error.message ?? error.value ?? 'Unknown error')
|
|
10
|
+
.replace(/^Error: /, '')
|
|
11
|
+
.trim();
|
|
12
|
+
const [body, callLog = ''] = message.split(CALL_LOG, 2);
|
|
13
|
+
const [headline = '', ...rest] = body.trim().split('\n');
|
|
14
|
+
const stack = stripVTControlCharacters(error.stack ?? '')
|
|
15
|
+
.split('\n')
|
|
16
|
+
.filter((line) => /^\s+at /.test(line))
|
|
17
|
+
.join('\n');
|
|
18
|
+
return {
|
|
19
|
+
headline: headline.trim(),
|
|
20
|
+
context: rest.join('\n').trim(),
|
|
21
|
+
callLog: callLog.trim(),
|
|
22
|
+
snippet: stripVTControlCharacters(error.snippet ?? '').trim(),
|
|
23
|
+
stack,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* `tests/<caseId>.spec.ts` -> `<caseId>`, with the group folder: `tests/documents/landing-page.spec.ts` -> `documents/landing-page`.
|
|
28
|
+
* Accepts the absolute path (`testInfo.file`) and the path relative to `tests/` (what `--list` reports).
|
|
29
|
+
*/
|
|
30
|
+
export function caseIdFromFile(file) {
|
|
31
|
+
let relative = path.isAbsolute(file) ? path.relative(TESTS_DIR, file) : file;
|
|
32
|
+
if (relative.startsWith('..'))
|
|
33
|
+
relative = path.basename(file);
|
|
34
|
+
return relative.split(path.sep).join('/').replace(/\.spec\.[cm]?[jt]s$/, '');
|
|
35
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Browser } from '@playwright/test';
|
|
2
|
+
import { type Profile, type SessionCheck } from './profiles.js';
|
|
3
|
+
export type SessionStatus = 'ok' | 'missing' | 'expired';
|
|
4
|
+
/**
|
|
5
|
+
* Set by `pnpm asrun` after its pre-run login check: `profile-id:status,profile-id:status`
|
|
6
|
+
* for every profile that is not logged in. Lets the config and fixtures skip a second check.
|
|
7
|
+
*/
|
|
8
|
+
export declare const NOT_LOGGED_IN_ENV = "ASRUN_NOT_LOGGED_IN";
|
|
9
|
+
export declare const hasSessionFile: (profile: Profile) => boolean;
|
|
10
|
+
/** True when the address is (or starts below) the address a site sends a signed-out visitor to. */
|
|
11
|
+
export declare function isLoggedOutUrl(url: string, check?: SessionCheck): boolean;
|
|
12
|
+
/** Kept for callers that only ask about the conventional `/login` address. */
|
|
13
|
+
export declare const isLoginUrl: (url: string) => boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Opens `sessionCheckPath` with the saved session and decides whether the profile is still signed in.
|
|
16
|
+
*
|
|
17
|
+
* A site can show "signed out" in more than one way, so every signal the profile names is watched at the
|
|
18
|
+
* same time: a redirect to `sessionCheck.loggedOutUrl` (server side or, in a single-page app, after the app
|
|
19
|
+
* has started), a 401/403 response, `loggedOutSelector`, or `loggedInSelector` proving the opposite. The
|
|
20
|
+
* page is read only after a signal arrived, never right after the navigation (which would still show the
|
|
21
|
+
* page before a client-side redirect happened).
|
|
22
|
+
*/
|
|
23
|
+
export declare function checkSession(browser: Browser, profile: Profile): Promise<SessionStatus>;
|
|
24
|
+
export declare function parseNotLoggedIn(value?: string | undefined): Map<string, SessionStatus>;
|
|
25
|
+
export declare const formatNotLoggedIn: (entries: Map<string, SessionStatus>) => string;
|
|
26
|
+
export declare const signinCommand: (profile: {
|
|
27
|
+
id: string;
|
|
28
|
+
}) => string;
|
|
29
|
+
export declare const skipProfileHint: (profileIds: string[], env: string) => string;
|
|
30
|
+
export declare const sessionProblemMessage: (profile: Profile, status: SessionStatus) => string;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { DEFAULT_SESSION_CHECK } from './profiles.js';
|
|
3
|
+
import { authFile } from './paths.js';
|
|
4
|
+
/**
|
|
5
|
+
* Set by `pnpm asrun` after its pre-run login check: `profile-id:status,profile-id:status`
|
|
6
|
+
* for every profile that is not logged in. Lets the config and fixtures skip a second check.
|
|
7
|
+
*/
|
|
8
|
+
export const NOT_LOGGED_IN_ENV = 'ASRUN_NOT_LOGGED_IN';
|
|
9
|
+
/** A response that means "you are not allowed in", for sites that answer with a status instead of a redirect. */
|
|
10
|
+
const EXPIRED_STATUSES = new Set([401, 403]);
|
|
11
|
+
export const hasSessionFile = (profile) => fs.existsSync(authFile(profile.id));
|
|
12
|
+
/** True when the address is (or starts below) the address a site sends a signed-out visitor to. */
|
|
13
|
+
export function isLoggedOutUrl(url, check = DEFAULT_SESSION_CHECK) {
|
|
14
|
+
let target;
|
|
15
|
+
try {
|
|
16
|
+
target = new URL(url);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
if (/^https?:\/\//i.test(check.loggedOutUrl)) {
|
|
22
|
+
try {
|
|
23
|
+
const expected = new URL(check.loggedOutUrl);
|
|
24
|
+
return target.origin === expected.origin && underPath(target.pathname, expected.pathname);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return underPath(target.pathname, check.loggedOutUrl);
|
|
31
|
+
}
|
|
32
|
+
const underPath = (pathname, expected) => pathname === expected || pathname.startsWith(expected.endsWith('/') ? expected : `${expected}/`);
|
|
33
|
+
/** Kept for callers that only ask about the conventional `/login` address. */
|
|
34
|
+
export const isLoginUrl = (url) => isLoggedOutUrl(url);
|
|
35
|
+
/**
|
|
36
|
+
* Opens `sessionCheckPath` with the saved session and decides whether the profile is still signed in.
|
|
37
|
+
*
|
|
38
|
+
* A site can show "signed out" in more than one way, so every signal the profile names is watched at the
|
|
39
|
+
* same time: a redirect to `sessionCheck.loggedOutUrl` (server side or, in a single-page app, after the app
|
|
40
|
+
* has started), a 401/403 response, `loggedOutSelector`, or `loggedInSelector` proving the opposite. The
|
|
41
|
+
* page is read only after a signal arrived, never right after the navigation (which would still show the
|
|
42
|
+
* page before a client-side redirect happened).
|
|
43
|
+
*/
|
|
44
|
+
export async function checkSession(browser, profile) {
|
|
45
|
+
if (!hasSessionFile(profile))
|
|
46
|
+
return 'missing';
|
|
47
|
+
const check = profile.sessionCheck;
|
|
48
|
+
const context = await browser.newContext({ storageState: authFile(profile.id) });
|
|
49
|
+
try {
|
|
50
|
+
const page = await context.newPage();
|
|
51
|
+
const response = await page.goto(new URL(profile.sessionCheckPath, profile.baseURL).href, {
|
|
52
|
+
waitUntil: 'domcontentloaded',
|
|
53
|
+
});
|
|
54
|
+
if (response && EXPIRED_STATUSES.has(response.status()))
|
|
55
|
+
return 'expired';
|
|
56
|
+
if (isLoggedOutUrl(page.url(), check))
|
|
57
|
+
return 'expired';
|
|
58
|
+
const signal = await firstSignal(page, check);
|
|
59
|
+
if (signal)
|
|
60
|
+
return signal;
|
|
61
|
+
// Nothing showed up: fall back to the address, which is all a plain redirect leaves behind. A profile
|
|
62
|
+
// that named a signed-in marker gets an error instead, so a wrong check path is visible instead of "ok".
|
|
63
|
+
if (isLoggedOutUrl(page.url(), check))
|
|
64
|
+
return 'expired';
|
|
65
|
+
if (check.loggedInSelector) {
|
|
66
|
+
throw new Error(`${profile.sessionCheckPath} neither showed \`${check.loggedInSelector}\` nor went to ${check.loggedOutUrl} ` +
|
|
67
|
+
`within ${check.timeoutMs} ms, so the session could not be checked. Fix "sessionCheck" in profiles.yaml.`);
|
|
68
|
+
}
|
|
69
|
+
return 'ok';
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await context.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** The first signal that arrives wins; null when every one of them times out. */
|
|
76
|
+
async function firstSignal(page, check) {
|
|
77
|
+
const timeout = check.timeoutMs;
|
|
78
|
+
const signals = [
|
|
79
|
+
page
|
|
80
|
+
.waitForURL((url) => isLoggedOutUrl(url.href, check), { timeout, waitUntil: 'commit' })
|
|
81
|
+
.then(() => 'expired'),
|
|
82
|
+
];
|
|
83
|
+
if (check.loggedOutSelector) {
|
|
84
|
+
signals.push(page
|
|
85
|
+
.waitForSelector(check.loggedOutSelector, { state: 'visible', timeout })
|
|
86
|
+
.then(() => 'expired'));
|
|
87
|
+
}
|
|
88
|
+
if (check.loggedInSelector) {
|
|
89
|
+
signals.push(page.waitForSelector(check.loggedInSelector, { state: 'visible', timeout }).then(() => 'ok'));
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return await Promise.any(signals);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Every signal timed out: the caller decides what an inconclusive page means.
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function parseNotLoggedIn(value = process.env[NOT_LOGGED_IN_ENV]) {
|
|
100
|
+
const result = new Map();
|
|
101
|
+
for (const entry of (value ?? '').split(',').filter(Boolean)) {
|
|
102
|
+
const [id, status] = entry.split(':');
|
|
103
|
+
result.set(id, status === 'missing' ? 'missing' : 'expired');
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
export const formatNotLoggedIn = (entries) => [...entries].map(([id, status]) => `${id}:${status}`).join(',');
|
|
108
|
+
export const signinCommand = (profile) => `pnpm signin ${profile.id}`;
|
|
109
|
+
export const skipProfileHint = (profileIds, env) => `pnpm asrun --env ${env} --skip-profile ${profileIds.join(',')}`;
|
|
110
|
+
export const sessionProblemMessage = (profile, status) => `Profile "${profile.id}" is not logged in to ${profile.env} (session ${status}). ` +
|
|
111
|
+
`Log in first: ${signinCommand(profile)} - or skip this profile: ${skipProfileHint([profile.id], profile.env)}`;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FullResult, Reporter, TestCase, TestError } from '@playwright/test/reporter';
|
|
2
|
+
/**
|
|
3
|
+
* Writes `reports/<runId>/summary.json`: one entry per test (case x profile) with the error split into
|
|
4
|
+
* a readable headline and the technical details, so the UI can show what went wrong at a glance.
|
|
5
|
+
*/
|
|
6
|
+
export default class SummaryReporter implements Reporter {
|
|
7
|
+
private startedAt;
|
|
8
|
+
private readonly tests;
|
|
9
|
+
private readonly errors;
|
|
10
|
+
onBegin(): void;
|
|
11
|
+
onTestEnd(test: TestCase): void;
|
|
12
|
+
onError(error: TestError): void;
|
|
13
|
+
onEnd(fullResult: FullResult): void;
|
|
14
|
+
printsToStdio(): boolean;
|
|
15
|
+
private summarize;
|
|
16
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getProfile } from './profiles.js';
|
|
5
|
+
import { caseWrites, hasCaseFile, readCaseMeta } from './cases.js';
|
|
6
|
+
import { concatVideos } from './ffmpeg.js';
|
|
7
|
+
import { ROOT } from './paths.js';
|
|
8
|
+
import { findLeftovers } from './records.js';
|
|
9
|
+
import { caseIdFromFile, parseError, RUN_SETTINGS_ENV, } from './run-summary.js';
|
|
10
|
+
const runId = process.env.ASRUN_RUN_ID ?? 'adhoc';
|
|
11
|
+
const runEnv = process.env.ASRUN_ENV;
|
|
12
|
+
function readSettings() {
|
|
13
|
+
try {
|
|
14
|
+
return process.env[RUN_SETTINGS_ENV] ? JSON.parse(process.env[RUN_SETTINGS_ENV]) : undefined;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const settings = readSettings();
|
|
21
|
+
function writesData(caseId) {
|
|
22
|
+
try {
|
|
23
|
+
return hasCaseFile(caseId) && caseWrites(readCaseMeta(caseId));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function splitProject(name) {
|
|
30
|
+
try {
|
|
31
|
+
return { profile: name, env: getProfile(name).env };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { profile: name };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Where the HTML reporter stores an attachment: `data/<sha1 of the content><extension>`. */
|
|
38
|
+
function reportDataPath(file) {
|
|
39
|
+
try {
|
|
40
|
+
const sha1 = createHash('sha1').update(fs.readFileSync(file)).digest('hex');
|
|
41
|
+
return `data/${sha1}${path.extname(file)}`;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function firstFailedStep(steps) {
|
|
48
|
+
for (const step of steps) {
|
|
49
|
+
if (!step.error)
|
|
50
|
+
continue;
|
|
51
|
+
return (step.category === 'test.step' ? step.title : null) ?? firstFailedStep(step.steps);
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function attachmentFiles(result, prefix) {
|
|
56
|
+
return result.attachments
|
|
57
|
+
.filter((attachment) => attachment.path && attachment.contentType.startsWith(prefix))
|
|
58
|
+
.map((attachment) => attachment.path);
|
|
59
|
+
}
|
|
60
|
+
function attachmentPaths(result, prefix) {
|
|
61
|
+
return attachmentFiles(result, prefix)
|
|
62
|
+
.map(reportDataPath)
|
|
63
|
+
.filter((value) => value !== null);
|
|
64
|
+
}
|
|
65
|
+
/** Joins the recordings of all pages of one test (a new tab has its own) into `reports/<runId>/videos/<testId>.webm`. */
|
|
66
|
+
function joinVideos(result, testId) {
|
|
67
|
+
const inputs = attachmentFiles(result, 'video/').filter((file) => fs.existsSync(file));
|
|
68
|
+
if (inputs.length < 2)
|
|
69
|
+
return undefined;
|
|
70
|
+
const name = `videos/${testId.replace(/[^\w-]/g, '_')}.webm`;
|
|
71
|
+
const error = concatVideos(inputs, path.join(ROOT, 'reports', runId, name));
|
|
72
|
+
if (error) {
|
|
73
|
+
console.warn(`Could not join the ${inputs.length} videos of test ${testId}, showing them separately: ${error}`);
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
return name;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Writes `reports/<runId>/summary.json`: one entry per test (case x profile) with the error split into
|
|
80
|
+
* a readable headline and the technical details, so the UI can show what went wrong at a glance.
|
|
81
|
+
*/
|
|
82
|
+
export default class SummaryReporter {
|
|
83
|
+
startedAt = new Date();
|
|
84
|
+
tests = [];
|
|
85
|
+
errors = [];
|
|
86
|
+
onBegin() {
|
|
87
|
+
this.startedAt = new Date();
|
|
88
|
+
}
|
|
89
|
+
onTestEnd(test) {
|
|
90
|
+
this.tests.push(test);
|
|
91
|
+
}
|
|
92
|
+
// Errors outside any test, e.g. a spec that fails to load never reaches onTestEnd.
|
|
93
|
+
onError(error) {
|
|
94
|
+
const parsed = parseError(error);
|
|
95
|
+
const known = this.errors.some((item) => item.headline === parsed.headline && item.stack === parsed.stack);
|
|
96
|
+
if (!known)
|
|
97
|
+
this.errors.push(parsed);
|
|
98
|
+
}
|
|
99
|
+
onEnd(fullResult) {
|
|
100
|
+
const results = [...new Map(this.tests.map((test) => [test.id, test])).values()].map((test) => this.summarize(test));
|
|
101
|
+
const counts = { passed: 0, failed: 0, flaky: 0, skipped: 0 };
|
|
102
|
+
for (const result of results)
|
|
103
|
+
counts[result.status]++;
|
|
104
|
+
const summary = {
|
|
105
|
+
runId,
|
|
106
|
+
...(runEnv && { env: runEnv }),
|
|
107
|
+
...(settings && { settings }),
|
|
108
|
+
startedAt: this.startedAt.toISOString(),
|
|
109
|
+
durationMs: Date.now() - this.startedAt.getTime(),
|
|
110
|
+
status: fullResult.status,
|
|
111
|
+
errors: this.errors,
|
|
112
|
+
counts,
|
|
113
|
+
leftovers: findLeftovers({ runId }),
|
|
114
|
+
results,
|
|
115
|
+
};
|
|
116
|
+
const dir = path.join(ROOT, 'reports', runId);
|
|
117
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
118
|
+
fs.writeFileSync(path.join(dir, 'summary.json'), JSON.stringify(summary, null, 2));
|
|
119
|
+
}
|
|
120
|
+
printsToStdio() {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
summarize(test) {
|
|
124
|
+
const outcome = test.outcome();
|
|
125
|
+
const status = outcome === 'expected' ? 'passed' : outcome === 'skipped' ? 'skipped' : outcome === 'flaky' ? 'flaky' : 'failed';
|
|
126
|
+
// The last attempt explains a failure; for a flaky test the last attempt passed, so use the last failed one.
|
|
127
|
+
const attempt = [...test.results].reverse().find((result) => result.status !== 'passed') ?? test.results.at(-1);
|
|
128
|
+
const failed = status === 'failed' || status === 'flaky';
|
|
129
|
+
const caseId = caseIdFromFile(test.location.file);
|
|
130
|
+
return {
|
|
131
|
+
testId: test.id,
|
|
132
|
+
caseId,
|
|
133
|
+
...splitProject(test.parent.project()?.name ?? ''),
|
|
134
|
+
writes: writesData(caseId),
|
|
135
|
+
title: test.title,
|
|
136
|
+
status,
|
|
137
|
+
durationMs: test.results.reduce((total, result) => total + result.duration, 0),
|
|
138
|
+
attempts: test.results.length,
|
|
139
|
+
failedStep: failed && attempt ? firstFailedStep(attempt.steps) : null,
|
|
140
|
+
errors: failed && attempt ? attempt.errors.map(parseError) : [],
|
|
141
|
+
screenshots: attempt ? attachmentPaths(attempt, 'image/') : [],
|
|
142
|
+
videos: attempt ? attachmentPaths(attempt, 'video/') : [],
|
|
143
|
+
video: attempt ? joinVideos(attempt, test.id) : undefined,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type CaseTargetCard } from './case-doc.js';
|
|
2
|
+
/**
|
|
3
|
+
* An assertion target card: the exact image, text, file or place that a check looks at, written in the case under
|
|
4
|
+
* `## Targets` as `### T<n> <name>` with `- What:`, `- Where:`, `- Identified by:` and `- Reference <profile-id>:` bullets.
|
|
5
|
+
* Parsing lives in `lib/case-doc.ts` (pure), so the web panel reads the same cards.
|
|
6
|
+
*/
|
|
7
|
+
export type TargetCard = CaseTargetCard;
|
|
8
|
+
export declare const TARGET_REF: RegExp;
|
|
9
|
+
export declare const REQUIRED_FIELDS: readonly ["what", "where", "identified by"];
|
|
10
|
+
export declare const parseTargets: (text: string) => TargetCard[];
|
|
11
|
+
export declare const readCaseText: (id: string) => string;
|
|
12
|
+
/** The target ids that a text mentions as `[T1]`. */
|
|
13
|
+
export declare const targetRefsIn: (text: string) => string[];
|
|
14
|
+
/** The lines of "Expected" and "Visual checks" (and their sub-bullets) that need a target card. */
|
|
15
|
+
export declare const NEEDS_TARGET: RegExp;
|
|
16
|
+
export declare function checksNeedingTarget(text: string): {
|
|
17
|
+
line: string;
|
|
18
|
+
section: string;
|
|
19
|
+
}[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { caseSectionText, parseTargetCards, TARGET_REQUIRED_FIELDS } from './case-doc.js';
|
|
4
|
+
import { CASES_DIR } from './paths.js';
|
|
5
|
+
export const TARGET_REF = /\[(T\d+)\]/g;
|
|
6
|
+
export const REQUIRED_FIELDS = TARGET_REQUIRED_FIELDS;
|
|
7
|
+
export const parseTargets = (text) => parseTargetCards(text);
|
|
8
|
+
export const readCaseText = (id) => fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8');
|
|
9
|
+
/** The target ids that a text mentions as `[T1]`. */
|
|
10
|
+
export const targetRefsIn = (text) => [...new Set([...text.matchAll(TARGET_REF)].map((match) => match[1]))];
|
|
11
|
+
/** The lines of "Expected" and "Visual checks" (and their sub-bullets) that need a target card. */
|
|
12
|
+
export const NEEDS_TARGET = /\b(logo|image|icon|screenshot|snapshot|pdf|download(?:ed)?|file|photo|picture|banner|favicon|wordmark|colou?r)\b/i;
|
|
13
|
+
export function checksNeedingTarget(text) {
|
|
14
|
+
const found = [];
|
|
15
|
+
for (const name of ['Expected', 'Visual checks']) {
|
|
16
|
+
for (const line of caseSectionText(text, name).split('\n')) {
|
|
17
|
+
// A bullet that is only a question or an answer is part of a TODO block, not a check.
|
|
18
|
+
if (!/^\s*-\s/.test(line) || /TODO|Answer:/.test(line))
|
|
19
|
+
continue;
|
|
20
|
+
if (NEEDS_TARGET.test(line) && !TARGET_REF.test(line))
|
|
21
|
+
found.push({ line: line.trim(), section: name });
|
|
22
|
+
TARGET_REF.lastIndex = 0;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return found;
|
|
26
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built web app. It is shipped inside the package (`prepack` builds it), so an installed asrun needs no
|
|
3
|
+
* front-end toolchain; in the framework repository it is rebuilt on demand.
|
|
4
|
+
*/
|
|
5
|
+
export declare const UI_DIST: string;
|
|
6
|
+
/** True when a front-end toolchain is present (the framework repository, not an installed package). */
|
|
7
|
+
export declare const canBuildUi: () => boolean;
|
|
8
|
+
/** Builds the web UI into {@link UI_DIST}. Returns the exit code; `null` when there is no toolchain to build with. */
|
|
9
|
+
export declare function buildUi(): number | null;
|
|
10
|
+
export declare const uiIsBuilt: () => boolean;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { commandScript } from './node-cli.js';
|
|
5
|
+
import { FRAMEWORK_ROOT, ROOT } from './paths.js';
|
|
6
|
+
/**
|
|
7
|
+
* The built web app. It is shipped inside the package (`prepack` builds it), so an installed asrun needs no
|
|
8
|
+
* front-end toolchain; in the framework repository it is rebuilt on demand.
|
|
9
|
+
*/
|
|
10
|
+
export const UI_DIST = path.join(FRAMEWORK_ROOT, 'ui', 'dist');
|
|
11
|
+
/** True when a front-end toolchain is present (the framework repository, not an installed package). */
|
|
12
|
+
export const canBuildUi = () => {
|
|
13
|
+
try {
|
|
14
|
+
commandScript('vite');
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/** Builds the web UI into {@link UI_DIST}. Returns the exit code; `null` when there is no toolchain to build with. */
|
|
22
|
+
export function buildUi() {
|
|
23
|
+
if (!canBuildUi())
|
|
24
|
+
return null;
|
|
25
|
+
const result = spawnSync(process.execPath, [commandScript('vite'), 'build', '--config', path.join(FRAMEWORK_ROOT, 'ui', 'web', 'vite.config.ts')], { cwd: ROOT, stdio: 'inherit', env: { ...process.env, ASRUN_UI_DIST: UI_DIST } });
|
|
26
|
+
if (result.error)
|
|
27
|
+
throw result.error;
|
|
28
|
+
return result.status ?? 1;
|
|
29
|
+
}
|
|
30
|
+
export const uiIsBuilt = () => fs.existsSync(path.join(UI_DIST, 'index.html'));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Profile } from './profiles.js';
|
|
2
|
+
/**
|
|
3
|
+
* The values a case asserts, written in the case document itself under `## Values`:
|
|
4
|
+
*
|
|
5
|
+
* ```md
|
|
6
|
+
* ## Values
|
|
7
|
+
* ```yaml
|
|
8
|
+
* default:
|
|
9
|
+
* createButton: Create New
|
|
10
|
+
* example-staging:
|
|
11
|
+
* previewLogoAlt: Example
|
|
12
|
+
* ```
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* `default` applies to every profile; an entry named after a profile id is deep-merged over it.
|
|
16
|
+
* The case document is the single source of truth: profiles.yaml never holds expected values.
|
|
17
|
+
*/
|
|
18
|
+
export type RawCaseValues = Record<string, Record<string, unknown>>;
|
|
19
|
+
/** Parses and validates the `## Values` block. Throws an Error that starts with `label` and says what to fix. */
|
|
20
|
+
export declare function parseCaseValues(text: string, label?: string): RawCaseValues;
|
|
21
|
+
export declare function readCaseValues(caseId: string): RawCaseValues;
|
|
22
|
+
/** `default`, then the values of the profile, deep-merged. */
|
|
23
|
+
export declare function resolveValues(values: RawCaseValues, profileId: string): Record<string, any>;
|
|
24
|
+
/**
|
|
25
|
+
* The expected values of one case for the profile that is running.
|
|
26
|
+
* Fails with a plain-language message, instead of a "cannot read properties of undefined" crash,
|
|
27
|
+
* when the case has no values for this profile yet or still has empty/TODO values.
|
|
28
|
+
*/
|
|
29
|
+
export declare function requireValues<T>(caseId: string, profile: Profile): T;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { caseSectionText } from './case-doc.js';
|
|
5
|
+
import { CASES_DIR } from './paths.js';
|
|
6
|
+
import { loadProfiles } from './profiles.js';
|
|
7
|
+
const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8
|
+
const caseFile = (caseId) => path.join(CASES_DIR, `${caseId}.md`);
|
|
9
|
+
/** Parses and validates the `## Values` block. Throws an Error that starts with `label` and says what to fix. */
|
|
10
|
+
export function parseCaseValues(text, label = 'case document') {
|
|
11
|
+
const body = caseSectionText(text, 'Values').trim();
|
|
12
|
+
if (body === '')
|
|
13
|
+
return {};
|
|
14
|
+
const block = /```ya?ml\r?\n([\s\S]*?)```/i.exec(body);
|
|
15
|
+
if (!block) {
|
|
16
|
+
throw new Error(`${label}: "## Values" must hold one fenced yaml block (\`\`\`yaml ... \`\`\`)`);
|
|
17
|
+
}
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = parse(block[1]);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
throw new Error(`${label}: the yaml in "## Values" is not valid: ${error.message}`);
|
|
24
|
+
}
|
|
25
|
+
if (parsed === null || parsed === undefined)
|
|
26
|
+
return {};
|
|
27
|
+
if (!isPlainObject(parsed)) {
|
|
28
|
+
throw new Error(`${label}: "## Values" must be a map of profile id (or "default") to a map of values`);
|
|
29
|
+
}
|
|
30
|
+
const known = new Set(['default', ...loadProfiles().map((profile) => profile.id)]);
|
|
31
|
+
const values = {};
|
|
32
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
33
|
+
if (!isPlainObject(value)) {
|
|
34
|
+
throw new Error(`${label}: "## Values" entry "${key}" must be a map of keys to values`);
|
|
35
|
+
}
|
|
36
|
+
if (!known.has(key)) {
|
|
37
|
+
const others = [...known].join(', ');
|
|
38
|
+
throw new Error(`${label}: "## Values" has an entry for unknown profile "${key}". Known: ${others}`);
|
|
39
|
+
}
|
|
40
|
+
values[key] = value;
|
|
41
|
+
}
|
|
42
|
+
return values;
|
|
43
|
+
}
|
|
44
|
+
export function readCaseValues(caseId) {
|
|
45
|
+
const file = caseFile(caseId);
|
|
46
|
+
if (!fs.existsSync(file))
|
|
47
|
+
throw new Error(`Case "${caseId}" does not exist (expected ${path.relative(process.cwd(), file)}).`);
|
|
48
|
+
return parseCaseValues(fs.readFileSync(file, 'utf8'), `cases/${caseId}.md`);
|
|
49
|
+
}
|
|
50
|
+
/** `default`, then the values of the profile, deep-merged. */
|
|
51
|
+
export function resolveValues(values, profileId) {
|
|
52
|
+
const merge = (base, override) => {
|
|
53
|
+
const result = { ...base };
|
|
54
|
+
for (const [key, value] of Object.entries(override)) {
|
|
55
|
+
result[key] = isPlainObject(value) && isPlainObject(result[key]) ? merge(result[key], value) : value;
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
};
|
|
59
|
+
return merge(values.default ?? {}, values[profileId] ?? {});
|
|
60
|
+
}
|
|
61
|
+
const isPlaceholder = (value) => value === null || value === undefined || value === '' || (typeof value === 'string' && /^\s*TODO\b/i.test(value));
|
|
62
|
+
function findPlaceholders(value, prefix) {
|
|
63
|
+
if (value !== null && typeof value === 'object') {
|
|
64
|
+
return Object.entries(value).flatMap(([key, child]) => findPlaceholders(child, `${prefix}.${key}`));
|
|
65
|
+
}
|
|
66
|
+
return isPlaceholder(value) ? [prefix] : [];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The expected values of one case for the profile that is running.
|
|
70
|
+
* Fails with a plain-language message, instead of a "cannot read properties of undefined" crash,
|
|
71
|
+
* when the case has no values for this profile yet or still has empty/TODO values.
|
|
72
|
+
*/
|
|
73
|
+
export function requireValues(caseId, profile) {
|
|
74
|
+
const values = resolveValues(readCaseValues(caseId), profile.id);
|
|
75
|
+
if (Object.keys(values).length === 0) {
|
|
76
|
+
throw new Error(`Missing values: cases/${caseId}.md has no "## Values" for profile "${profile.id}" and no "default" entry. ` +
|
|
77
|
+
`Add the expected values for this profile to the case document.`);
|
|
78
|
+
}
|
|
79
|
+
const placeholders = findPlaceholders(values, 'values');
|
|
80
|
+
if (placeholders.length > 0) {
|
|
81
|
+
throw new Error(`Incomplete values: cases/${caseId}.md still has empty or TODO values for profile "${profile.id}": ${placeholders.join(', ')}. ` +
|
|
82
|
+
`Fill them in before running this test.`);
|
|
83
|
+
}
|
|
84
|
+
return values;
|
|
85
|
+
}
|