@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,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,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An assertion target card: the exact image, text, file or place that a check looks at, written in the case under
|
|
3
|
+
* `## Targets` as `### T<n> <name>` with `- What:`, `- Where:`, `- Identified by:` and `- Reference <profile-id>:` bullets.
|
|
4
|
+
*/
|
|
5
|
+
export interface TargetCard {
|
|
6
|
+
/** `T1`, `T2`, ... */
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
/** Field name (lower case, e.g. `what`, `where`, `identified by`, `not`, `ready when`, `kind`) to its text. */
|
|
10
|
+
fields: Record<string, string>;
|
|
11
|
+
/** `- Reference <profile-id>: <path>` bullets, keyed by what is written after `Reference`. */
|
|
12
|
+
references: Record<string, string>;
|
|
13
|
+
/** The card still has an open `🚫 TODO [blocking]` block: the target is not settled and no check may be built on it. */
|
|
14
|
+
blocking: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare const TARGET_REF: RegExp;
|
|
17
|
+
export declare const REQUIRED_FIELDS: readonly ['what', 'where', 'identified by'];
|
|
18
|
+
export declare function parseTargets(text: string): TargetCard[];
|
|
19
|
+
export declare const readCaseText: (id: string) => string;
|
|
20
|
+
/** The target ids that a text mentions as `[T1]`. */
|
|
21
|
+
export declare const targetRefsIn: (text: string) => string[];
|
|
22
|
+
/** The lines of "Expected" and "Visual checks" (and their sub-bullets) that need a target card. */
|
|
23
|
+
export declare const NEEDS_TARGET: RegExp;
|
|
24
|
+
export declare function checksNeedingTarget(text: string): {
|
|
25
|
+
line: string;
|
|
26
|
+
section: string;
|
|
27
|
+
}[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CASES_DIR } from './paths.js';
|
|
4
|
+
export const TARGET_REF = /\[(T\d+)\]/g;
|
|
5
|
+
export const REQUIRED_FIELDS = ['what', 'where', 'identified by'];
|
|
6
|
+
const section = (text, name) => new RegExp(`^## ${name}\\s*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm').exec(text)?.[1] ?? '';
|
|
7
|
+
export function parseTargets(text) {
|
|
8
|
+
const body = section(text, 'Targets');
|
|
9
|
+
const cards = [];
|
|
10
|
+
const parts = body.split(/^### /m).slice(1);
|
|
11
|
+
for (const part of parts) {
|
|
12
|
+
const [heading, ...lines] = part.split('\n');
|
|
13
|
+
const head = /^(T\d+)\b[\s:.-]*(.*)$/.exec(heading.trim());
|
|
14
|
+
if (!head)
|
|
15
|
+
continue;
|
|
16
|
+
const card = { id: head[1], name: head[2], fields: {}, references: {}, blocking: false };
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
if (/^\s*-\s*🚫 TODO \[blocking\]/.test(line))
|
|
19
|
+
card.blocking = true;
|
|
20
|
+
const reference = /^-\s+Reference\s+([^:]+):\s*(.*)$/.exec(line);
|
|
21
|
+
if (reference) {
|
|
22
|
+
card.references[reference[1].trim()] = reference[2].trim();
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const field = /^-\s+([A-Za-z][A-Za-z ]*?):\s*(.*)$/.exec(line);
|
|
26
|
+
if (field)
|
|
27
|
+
card.fields[field[1].trim().toLowerCase()] = field[2].trim();
|
|
28
|
+
}
|
|
29
|
+
cards.push(card);
|
|
30
|
+
}
|
|
31
|
+
return cards;
|
|
32
|
+
}
|
|
33
|
+
export const readCaseText = (id) => fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8');
|
|
34
|
+
/** The target ids that a text mentions as `[T1]`. */
|
|
35
|
+
export const targetRefsIn = (text) => [...new Set([...text.matchAll(TARGET_REF)].map((match) => match[1]))];
|
|
36
|
+
/** The lines of "Expected" and "Visual checks" (and their sub-bullets) that need a target card. */
|
|
37
|
+
export const NEEDS_TARGET = /\b(logo|image|icon|screenshot|snapshot|pdf|download(?:ed)?|file|photo|picture|banner|favicon|wordmark|colou?r)\b/i;
|
|
38
|
+
export function checksNeedingTarget(text) {
|
|
39
|
+
const found = [];
|
|
40
|
+
for (const name of ['Expected', 'Visual checks']) {
|
|
41
|
+
for (const line of section(text, name).split('\n')) {
|
|
42
|
+
// A bullet that is only a question or an answer is part of a TODO block, not a check.
|
|
43
|
+
if (!/^\s*-\s/.test(line) || /TODO|Answer:/.test(line))
|
|
44
|
+
continue;
|
|
45
|
+
if (NEEDS_TARGET.test(line) && !TARGET_REF.test(line))
|
|
46
|
+
found.push({ line: line.trim(), section: name });
|
|
47
|
+
TARGET_REF.lastIndex = 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
@@ -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 { CASES_DIR } from './paths.js';
|
|
5
|
+
import { loadProfiles } from './profiles.js';
|
|
6
|
+
const section = (text, name) => new RegExp(`^## ${name}\\s*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm').exec(text)?.[1] ?? '';
|
|
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 = section(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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type RunFilters } from './run-filters.js';
|
|
2
|
+
/** Set by `pnpm asrun` once a human confirmed that cases may write test data in this (protected) environment. */
|
|
3
|
+
export declare const ALLOW_WRITES_ENV = "ASRUN_ALLOW_WRITES";
|
|
4
|
+
/** Every environment except staging needs an explicit permission before a case may write data. */
|
|
5
|
+
export declare const isProtectedEnv: (env: string) => boolean;
|
|
6
|
+
export interface WriteCase {
|
|
7
|
+
id: string;
|
|
8
|
+
note: string;
|
|
9
|
+
/** Profiles whose test data the case will write to in this run. */
|
|
10
|
+
profiles: string[];
|
|
11
|
+
}
|
|
12
|
+
/** The write cases of a run, split by whether the environment is one the case may write in. */
|
|
13
|
+
export interface WriteCases {
|
|
14
|
+
/** The case lists this environment under `writes`: it writes here, after the confirmation in a protected one. */
|
|
15
|
+
writing: WriteCase[];
|
|
16
|
+
/** The case writes only in other environments: it is skipped here with a notice, not failed. */
|
|
17
|
+
skipped: WriteCase[];
|
|
18
|
+
}
|
|
19
|
+
/** Can a case run in this environment at all (`envs` in its front matter)? */
|
|
20
|
+
export declare function caseRunsIn(caseId: string, env: string): boolean;
|
|
21
|
+
/** One case of a run and the profiles it runs for. `skip` says why it is skipped in this environment, if it is. */
|
|
22
|
+
export interface PlannedCase {
|
|
23
|
+
id: string;
|
|
24
|
+
profiles: string[];
|
|
25
|
+
writes: boolean;
|
|
26
|
+
skip: string | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Every case that a run with these filters would list, as Playwright itself reports it (`--list`), so project, grep
|
|
30
|
+
* and suite filters are applied exactly as in the real run. Async, so a slow list of tests never blocks the UI server.
|
|
31
|
+
* Rejects with a readable message when Playwright cannot list the tests (for example an invalid grep expression).
|
|
32
|
+
*/
|
|
33
|
+
export declare function planRun(filters: RunFilters): Promise<PlannedCase[]>;
|
|
34
|
+
/**
|
|
35
|
+
* The cases that a run with these filters would execute and that write data, split into those that write in this
|
|
36
|
+
* environment and those that are skipped here. Asks Playwright itself
|
|
37
|
+
* (`--list`), so project, grep and suite filters are applied exactly as in the real run.
|
|
38
|
+
*/
|
|
39
|
+
export declare function findWriteCases(filters: RunFilters): WriteCases;
|
|
40
|
+
export declare function describeWriteCases(env: string, cases: WriteCase[]): string;
|
|
41
|
+
export declare function describeSkippedWriteCases(env: string, cases: WriteCase[]): string;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { STAGING } from './profiles.js';
|
|
3
|
+
import { caseWrites, caseWritesIn, readCaseMeta } from './cases.js';
|
|
4
|
+
import { commandScript } from './node-cli.js';
|
|
5
|
+
import { ROOT } from './paths.js';
|
|
6
|
+
import { filterArgs } from './run-filters.js';
|
|
7
|
+
import { caseIdFromFile } from './run-summary.js';
|
|
8
|
+
/** Set by `pnpm asrun` once a human confirmed that cases may write test data in this (protected) environment. */
|
|
9
|
+
export const ALLOW_WRITES_ENV = 'ASRUN_ALLOW_WRITES';
|
|
10
|
+
/** Every environment except staging needs an explicit permission before a case may write data. */
|
|
11
|
+
export const isProtectedEnv = (env) => env !== STAGING;
|
|
12
|
+
/** Can a case run in this environment at all (`envs` in its front matter)? */
|
|
13
|
+
export function caseRunsIn(caseId, env) {
|
|
14
|
+
const { envs } = readCaseMeta(caseId);
|
|
15
|
+
return envs === null || envs.includes(env);
|
|
16
|
+
}
|
|
17
|
+
const PLAYWRIGHT = commandScript('@playwright/test', 'playwright');
|
|
18
|
+
const listArgs = (filters) => ['test', '--list', '--reporter=json', ...filterArgs(filters)];
|
|
19
|
+
const listEnv = (env) => ({ ...process.env, ASRUN_ENV: env });
|
|
20
|
+
/** The profiles that a `--list` report schedules for each case. Throws when Playwright reported a real error. */
|
|
21
|
+
function profilesByCaseOf(stdout, stderr) {
|
|
22
|
+
let report;
|
|
23
|
+
try {
|
|
24
|
+
report = JSON.parse(stdout);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error(`Could not list the tests: ${(stderr || stdout).trim().slice(0, 400)}`);
|
|
28
|
+
}
|
|
29
|
+
// "No tests found" is an empty result, not a failure; anything else (an invalid expression, a broken spec) is.
|
|
30
|
+
const problem = (report.errors ?? []).find((error) => !/No tests found/.test(error.message ?? ''));
|
|
31
|
+
if (problem)
|
|
32
|
+
throw new Error(String(problem.message ?? 'Playwright could not list the tests.').trim().slice(0, 400));
|
|
33
|
+
const profilesByCase = new Map();
|
|
34
|
+
const visit = (suite) => {
|
|
35
|
+
for (const spec of suite.specs ?? []) {
|
|
36
|
+
const caseId = caseIdFromFile(spec.file);
|
|
37
|
+
for (const test of spec.tests ?? []) {
|
|
38
|
+
const profile = String(test.projectName);
|
|
39
|
+
if (!profilesByCase.has(caseId))
|
|
40
|
+
profilesByCase.set(caseId, new Set());
|
|
41
|
+
profilesByCase.get(caseId).add(profile);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const child of suite.suites ?? [])
|
|
45
|
+
visit(child);
|
|
46
|
+
};
|
|
47
|
+
for (const suite of report.suites ?? [])
|
|
48
|
+
visit(suite);
|
|
49
|
+
return profilesByCase;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Every case that a run with these filters would list, as Playwright itself reports it (`--list`), so project, grep
|
|
53
|
+
* and suite filters are applied exactly as in the real run. Async, so a slow list of tests never blocks the UI server.
|
|
54
|
+
* Rejects with a readable message when Playwright cannot list the tests (for example an invalid grep expression).
|
|
55
|
+
*/
|
|
56
|
+
export async function planRun(filters) {
|
|
57
|
+
const { stdout, stderr } = await new Promise((resolve, reject) => {
|
|
58
|
+
const child = spawn(process.execPath, [PLAYWRIGHT, ...listArgs(filters)], { cwd: ROOT, env: listEnv(filters.env) });
|
|
59
|
+
let out = '';
|
|
60
|
+
let err = '';
|
|
61
|
+
child.stdout.on('data', (chunk) => (out += chunk));
|
|
62
|
+
child.stderr.on('data', (chunk) => (err += chunk));
|
|
63
|
+
child.on('error', reject);
|
|
64
|
+
// The exit code is not checked: "no tests found" exits with 1 but still prints a valid (empty) report.
|
|
65
|
+
child.on('close', () => resolve({ stdout: out, stderr: err }));
|
|
66
|
+
});
|
|
67
|
+
const planned = [];
|
|
68
|
+
for (const [id, profiles] of profilesByCaseOf(stdout, stderr)) {
|
|
69
|
+
let meta;
|
|
70
|
+
try {
|
|
71
|
+
meta = readCaseMeta(id);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
continue; // seed.spec.ts and other specs without a case document
|
|
75
|
+
}
|
|
76
|
+
const skip = !caseRunsIn(id, filters.env)
|
|
77
|
+
? `Limited to: ${meta.envs?.join(', ')}`
|
|
78
|
+
: caseWrites(meta) && !caseWritesIn(meta, filters.env)
|
|
79
|
+
? `Writes data, no permission in ${filters.env.toUpperCase()}`
|
|
80
|
+
: null;
|
|
81
|
+
planned.push({ id, profiles: [...profiles].sort(), writes: caseWrites(meta), skip });
|
|
82
|
+
}
|
|
83
|
+
return planned.sort((a, b) => a.id.localeCompare(b.id));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The cases that a run with these filters would execute and that write data, split into those that write in this
|
|
87
|
+
* environment and those that are skipped here. Asks Playwright itself
|
|
88
|
+
* (`--list`), so project, grep and suite filters are applied exactly as in the real run.
|
|
89
|
+
*/
|
|
90
|
+
export function findWriteCases(filters) {
|
|
91
|
+
const env = filters.env;
|
|
92
|
+
const result = spawnSync(process.execPath, [PLAYWRIGHT, ...listArgs(filters)], {
|
|
93
|
+
cwd: ROOT,
|
|
94
|
+
encoding: 'utf8',
|
|
95
|
+
env: listEnv(env),
|
|
96
|
+
maxBuffer: 50_000_000,
|
|
97
|
+
});
|
|
98
|
+
const profilesByCase = profilesByCaseOf(result.stdout, result.stderr);
|
|
99
|
+
const writing = [];
|
|
100
|
+
const skipped = [];
|
|
101
|
+
for (const [id, profiles] of profilesByCase) {
|
|
102
|
+
let meta;
|
|
103
|
+
try {
|
|
104
|
+
meta = readCaseMeta(id);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
continue; // seed.spec.ts and other specs without a case document
|
|
108
|
+
}
|
|
109
|
+
if (!caseWrites(meta) || !caseRunsIn(id, env))
|
|
110
|
+
continue;
|
|
111
|
+
(caseWritesIn(meta, env) ? writing : skipped).push({ id, note: meta.writesNote ?? '', profiles: [...profiles].sort() });
|
|
112
|
+
}
|
|
113
|
+
const byId = (a, b) => a.id.localeCompare(b.id);
|
|
114
|
+
return { writing: writing.sort(byId), skipped: skipped.sort(byId) };
|
|
115
|
+
}
|
|
116
|
+
export function describeWriteCases(env, cases) {
|
|
117
|
+
return [
|
|
118
|
+
`${cases.length} case(s) in this run write data in ${env.toUpperCase()}:`,
|
|
119
|
+
...cases.flatMap((item) => [` - ${item.id} (profiles: ${item.profiles.join(', ')})`, ` ${item.note}`]),
|
|
120
|
+
'They only touch data of the test accounts. They never send email in production.',
|
|
121
|
+
].join('\n');
|
|
122
|
+
}
|
|
123
|
+
export function describeSkippedWriteCases(env, cases) {
|
|
124
|
+
return [
|
|
125
|
+
`Skipped: ${cases.length} case(s) write data but have no permission to write in ${env.toUpperCase()}:`,
|
|
126
|
+
...cases.map((item) => ` - ${item.id}`),
|
|
127
|
+
`To allow one, add "${env}" to "writes" in its front matter (and make sure it cleans up).`,
|
|
128
|
+
].join('\n');
|
|
129
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
4
|
+
import { listProfiles } from './lib/profiles.js';
|
|
5
|
+
import { authFile, isFrameworkRoot, REPORTS_DIR, ROOT, TEMPLATES_DIR, TMP_DIR } from './lib/paths.js';
|
|
6
|
+
import { parseNotLoggedIn } from './lib/session.js';
|
|
7
|
+
import { PRODUCT_NAME } from './ui/shared/product.js';
|
|
8
|
+
const runId = process.env.ASRUN_RUN_ID ?? 'adhoc';
|
|
9
|
+
// Set by `pnpm asrun --env <env>`. Without it (MCP exploration, `--list`) every profile is a project.
|
|
10
|
+
const runEnv = process.env.ASRUN_ENV;
|
|
11
|
+
const stepText = process.env.ASRUN_STEP_TEXT === '1';
|
|
12
|
+
// Exploration through the MCP (and `--list`) has no environment. Nobody needs videos, traces or screenshots there,
|
|
13
|
+
// and encoding a video of a long, busy session can stall the browser.
|
|
14
|
+
const recording = runEnv !== undefined;
|
|
15
|
+
const notLoggedIn = parseNotLoggedIn();
|
|
16
|
+
const profiles = listProfiles();
|
|
17
|
+
const selected = profiles.filter((profile) => !runEnv || profile.env === runEnv);
|
|
18
|
+
if (profiles.length === 0) {
|
|
19
|
+
throw new Error(`No profiles in ${path.join(ROOT, 'profiles.yaml')}. Add one entry per environment (see ` +
|
|
20
|
+
`${path.join(TEMPLATES_DIR, 'profiles.yaml')}), then log in with "pnpm signin <profile-id>".` +
|
|
21
|
+
(isFrameworkRoot()
|
|
22
|
+
? ' This looks like the framework folder itself: run the command in a project that uses the framework, or set ASRUN_ROOT.'
|
|
23
|
+
: ''));
|
|
24
|
+
}
|
|
25
|
+
if (runEnv !== undefined && selected.length === 0) {
|
|
26
|
+
throw new Error(`profiles.yaml has no profile with "env: ${runEnv}". Known environments: ${[...new Set(profiles.map((profile) => profile.env))].join(', ')}.`);
|
|
27
|
+
}
|
|
28
|
+
// Every path is absolute on purpose: a project that uses the framework re-exports this file from its own
|
|
29
|
+
// `playwright.config.ts`, and Playwright resolves relative paths against the file that holds them (this one).
|
|
30
|
+
// The compiled package has `summary-reporter.js`; the framework repository runs the `.ts` source.
|
|
31
|
+
const summaryReporter = [path.join(import.meta.dirname, 'lib', 'summary-reporter.js'), path.join(import.meta.dirname, 'lib', 'summary-reporter.ts')].find((file) => fs.existsSync(file));
|
|
32
|
+
export default defineConfig({
|
|
33
|
+
testDir: path.join(ROOT, 'tests'),
|
|
34
|
+
outputDir: path.join(TMP_DIR, 'results'),
|
|
35
|
+
timeout: 60_000,
|
|
36
|
+
expect: {
|
|
37
|
+
timeout: 10_000,
|
|
38
|
+
toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: 'disabled' },
|
|
39
|
+
},
|
|
40
|
+
retries: 1,
|
|
41
|
+
// Be gentle with the sites under test.
|
|
42
|
+
workers: 2,
|
|
43
|
+
metadata: {
|
|
44
|
+
environment: runEnv ? runEnv.toUpperCase() : 'not set',
|
|
45
|
+
runId,
|
|
46
|
+
workspace: ROOT,
|
|
47
|
+
},
|
|
48
|
+
reporter: [
|
|
49
|
+
['list'],
|
|
50
|
+
[
|
|
51
|
+
'html',
|
|
52
|
+
{
|
|
53
|
+
outputFolder: path.join(REPORTS_DIR, runId, 'html'),
|
|
54
|
+
open: 'never',
|
|
55
|
+
title: runEnv ? `${PRODUCT_NAME} report - ${runEnv.toUpperCase()} - ${runId}` : `${PRODUCT_NAME} report - ${runId}`,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
[summaryReporter],
|
|
59
|
+
],
|
|
60
|
+
use: {
|
|
61
|
+
// Project convention in uc-frontend: data-tn attributes.
|
|
62
|
+
testIdAttribute: 'data-tn',
|
|
63
|
+
// `pnpm asrun --step-text` overlays the current test.step() title on the video.
|
|
64
|
+
video: recording
|
|
65
|
+
? {
|
|
66
|
+
mode: 'on',
|
|
67
|
+
size: { width: 1440, height: 900 },
|
|
68
|
+
...(stepText && { show: { test: { level: 'step', position: 'top-left' } } }),
|
|
69
|
+
}
|
|
70
|
+
: 'off',
|
|
71
|
+
trace: recording ? 'on' : 'off',
|
|
72
|
+
screenshot: recording ? 'on' : 'off',
|
|
73
|
+
// Without these a locator that matches nothing (a dialog that never opened) waits for ever. In a test the 60 s
|
|
74
|
+
// test timeout ended it anyway; in an MCP exploration nothing did, and the agent hung.
|
|
75
|
+
actionTimeout: 15_000,
|
|
76
|
+
navigationTimeout: 30_000,
|
|
77
|
+
locale: 'en-US',
|
|
78
|
+
timezoneId: 'America/New_York',
|
|
79
|
+
},
|
|
80
|
+
// One project per profile. Baseline images are stored per project, so every environment keeps its own.
|
|
81
|
+
projects: selected.map((profile) => ({
|
|
82
|
+
name: profile.id,
|
|
83
|
+
// Retrying cannot fix a missing login.
|
|
84
|
+
...(notLoggedIn.has(profile.id) && { retries: 0 }),
|
|
85
|
+
use: {
|
|
86
|
+
...devices['Desktop Chrome'],
|
|
87
|
+
viewport: { width: 1440, height: 900 },
|
|
88
|
+
baseURL: profile.baseURL,
|
|
89
|
+
...(fs.existsSync(authFile(profile.id)) && { storageState: authFile(profile.id) }),
|
|
90
|
+
},
|
|
91
|
+
})),
|
|
92
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|