@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,121 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { ROOT } from './paths.js';
|
|
5
|
+
const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
6
|
+
const DEFAULT_SESSION_CHECK_PATH = '/account/';
|
|
7
|
+
export const PRODUCTION = 'production';
|
|
8
|
+
export const STAGING = 'staging';
|
|
9
|
+
export const PROFILES_FILE = path.join(ROOT, 'profiles.yaml');
|
|
10
|
+
/** The Playwright project name of a profile: its id. */
|
|
11
|
+
export const projectName = (profile) => profile.id;
|
|
12
|
+
export const profileLabel = (profile) => `${profile.id} (${profile.env})`;
|
|
13
|
+
function validateTags(where, raw) {
|
|
14
|
+
if (raw === undefined || raw === null)
|
|
15
|
+
return [];
|
|
16
|
+
if (!Array.isArray(raw) || raw.some((tag) => typeof tag !== 'string' || !NAME_PATTERN.test(tag))) {
|
|
17
|
+
throw new Error(`${where}: "tags" must be a list of lowercase words (letters, digits, dashes), e.g. [us, core]`);
|
|
18
|
+
}
|
|
19
|
+
const duplicate = raw.find((tag, index) => raw.indexOf(tag) !== index);
|
|
20
|
+
if (duplicate)
|
|
21
|
+
throw new Error(`${where}: "tags" lists "${duplicate}" twice`);
|
|
22
|
+
return raw;
|
|
23
|
+
}
|
|
24
|
+
function validateSiteAddress(where, value) {
|
|
25
|
+
if (typeof value !== 'string' || value === '')
|
|
26
|
+
throw new Error(`${where} is missing "baseURL"`);
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(value);
|
|
29
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
30
|
+
throw new Error('protocol');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error(`${where}: "baseURL" must be a full http(s) URL, e.g. https://www.example.com`);
|
|
34
|
+
}
|
|
35
|
+
return value.replace(/\/$/, '');
|
|
36
|
+
}
|
|
37
|
+
function validate(raw, index) {
|
|
38
|
+
const label = `profiles.yaml: profile #${index + 1}`;
|
|
39
|
+
for (const key of ['id', 'name', 'env']) {
|
|
40
|
+
if (typeof raw?.[key] !== 'string' || raw[key] === '') {
|
|
41
|
+
throw new Error(`${label} is missing "${key}"`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const where = `profiles.yaml: profile "${raw.id}"`;
|
|
45
|
+
if (!NAME_PATTERN.test(raw.id)) {
|
|
46
|
+
throw new Error(`${where}: "id" must be lowercase letters and digits, separated by single dashes (e.g. "example-staging")`);
|
|
47
|
+
}
|
|
48
|
+
if (!NAME_PATTERN.test(raw.env)) {
|
|
49
|
+
throw new Error(`${where}: "env" must be lowercase letters, digits and single dashes (e.g. "production", "staging")`);
|
|
50
|
+
}
|
|
51
|
+
if (raw.envs !== undefined) {
|
|
52
|
+
throw new Error(`${where} has an "envs" map. Each profile is one environment: give it an "env" and a "baseURL" instead.`);
|
|
53
|
+
}
|
|
54
|
+
if (raw.baseURL === undefined) {
|
|
55
|
+
throw new Error(`${where} is missing "baseURL" (the site address of ${raw.env})`);
|
|
56
|
+
}
|
|
57
|
+
const baseURL = validateSiteAddress(where, raw.baseURL);
|
|
58
|
+
if (raw.account !== undefined && raw.account !== null && typeof raw.account !== 'string') {
|
|
59
|
+
throw new Error(`${where}: "account" must be text (a mailbox or username), or omitted`);
|
|
60
|
+
}
|
|
61
|
+
if (raw.email !== undefined && typeof raw.email !== 'boolean') {
|
|
62
|
+
throw new Error(`${where}: "email" must be true or false`);
|
|
63
|
+
}
|
|
64
|
+
if (raw.env === PRODUCTION && raw.email === true) {
|
|
65
|
+
throw new Error(`${where}: "email: true" is not allowed. Tests never send email in production.`);
|
|
66
|
+
}
|
|
67
|
+
const sessionCheckPath = raw.sessionCheckPath ?? DEFAULT_SESSION_CHECK_PATH;
|
|
68
|
+
if (typeof sessionCheckPath !== 'string' || !sessionCheckPath.startsWith('/') || /\s/.test(sessionCheckPath)) {
|
|
69
|
+
throw new Error(`${where}: "sessionCheckPath" must start with "/" and contain no spaces`);
|
|
70
|
+
}
|
|
71
|
+
const account = typeof raw.account === 'string' && raw.account.trim() !== '' ? raw.account.trim() : null;
|
|
72
|
+
return {
|
|
73
|
+
id: raw.id,
|
|
74
|
+
name: raw.name,
|
|
75
|
+
env: raw.env,
|
|
76
|
+
baseURL,
|
|
77
|
+
account,
|
|
78
|
+
tags: validateTags(where, raw.tags),
|
|
79
|
+
email: raw.email === true,
|
|
80
|
+
sessionCheckPath,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function loadProfiles(file = PROFILES_FILE) {
|
|
84
|
+
if (!fs.existsSync(file)) {
|
|
85
|
+
throw new Error(`profiles.yaml is missing. Create it in the project root (see the comments in the file for the fields).`);
|
|
86
|
+
}
|
|
87
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
88
|
+
const parsed = parse(text, { merge: true });
|
|
89
|
+
const raw = parsed?.profiles;
|
|
90
|
+
if (!Array.isArray(raw)) {
|
|
91
|
+
throw new Error('profiles.yaml needs a "profiles:" list (it may be empty while you set the suite up).');
|
|
92
|
+
}
|
|
93
|
+
const profiles = raw.map(validate);
|
|
94
|
+
const ids = profiles.map((profile) => profile.id);
|
|
95
|
+
const duplicate = ids.find((id, index) => ids.indexOf(id) !== index);
|
|
96
|
+
if (duplicate)
|
|
97
|
+
throw new Error(`profiles.yaml: duplicate profile id "${duplicate}"`);
|
|
98
|
+
return profiles;
|
|
99
|
+
}
|
|
100
|
+
function sortProfiles(profiles) {
|
|
101
|
+
const rank = (env) => (env === PRODUCTION ? 0 : env === STAGING ? 1 : 2);
|
|
102
|
+
return [...profiles].sort((a, b) => rank(a.env) - rank(b.env) || a.id.localeCompare(b.id));
|
|
103
|
+
}
|
|
104
|
+
/** The profiles of one environment (`env` omitted: all of them), production first. */
|
|
105
|
+
export const listProfiles = (env, profiles = loadProfiles()) => sortProfiles(profiles.filter((profile) => !env || profile.env === env));
|
|
106
|
+
export function getProfile(id, profiles = loadProfiles()) {
|
|
107
|
+
const profile = profiles.find((candidate) => candidate.id === id);
|
|
108
|
+
if (!profile) {
|
|
109
|
+
const known = profiles.map((candidate) => candidate.id).join(', ') || '(none)';
|
|
110
|
+
throw new Error(`Unknown profile "${id}". Known profiles: ${known}. Check profiles.yaml.`);
|
|
111
|
+
}
|
|
112
|
+
return profile;
|
|
113
|
+
}
|
|
114
|
+
/** The profiles that carry this tag. */
|
|
115
|
+
export const listProfilesWithTag = (tag, env, profiles = loadProfiles()) => listProfiles(env, profiles).filter((profile) => profile.tags.includes(tag));
|
|
116
|
+
/** Every environment used by a profile: production, staging, then the rest. */
|
|
117
|
+
export const listEnvs = (profiles = loadProfiles()) => {
|
|
118
|
+
const envs = [...new Set(profiles.map((profile) => profile.env))];
|
|
119
|
+
const rank = (env) => (env === PRODUCTION ? 0 : env === STAGING ? 1 : 2);
|
|
120
|
+
return envs.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
121
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Locator, type Page } from '@playwright/test';
|
|
2
|
+
export interface ReadyOptions {
|
|
3
|
+
/** Key requests the action starts (URL substring or RegExp). Each must finish with a status below 400. */
|
|
4
|
+
responses?: (string | RegExp)[];
|
|
5
|
+
/** What must be visible once the content is really there (not a spinner, a frame or a placeholder). */
|
|
6
|
+
ready?: Locator | Locator[];
|
|
7
|
+
/** Per request and per locator, in ms. */
|
|
8
|
+
timeout?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Runs `action` (a click, a navigation) and waits until its result is there: first the key requests it starts have
|
|
12
|
+
* finished, then the `ready` locators are visible. Read the result only after this returns.
|
|
13
|
+
*
|
|
14
|
+
* The response waiters are registered before the action runs, so a fast response is never missed.
|
|
15
|
+
*
|
|
16
|
+
* await actAndWait(page, () => share.click(), {responses: [/\/api\/documents\/.+\/email/], ready: composer.getByRole('textbox', {name: 'To'})});
|
|
17
|
+
*/
|
|
18
|
+
export declare function actAndWait<T>(page: Page, action: () => Promise<T>, options?: ReadyOptions): Promise<T>;
|
|
19
|
+
/**
|
|
20
|
+
* For an action that may or may not raise a confirmation (a "Discard draft?" dialog after closing a composer): clicks
|
|
21
|
+
* `optional` whenever it is visible, and returns once `target` is hidden. Both happen in one retry loop, so a
|
|
22
|
+
* confirmation that shows up late is still clicked, and no confirmation does not cost a wait.
|
|
23
|
+
*
|
|
24
|
+
* `isVisible()` alone is not enough: it answers for this instant only and its `timeout` option is ignored.
|
|
25
|
+
* Prefer a `target` that stays on the same element (a role/name or test id locator); a `.last()` locator moves to the
|
|
26
|
+
* confirm dialog while that dialog is open.
|
|
27
|
+
*
|
|
28
|
+
* await composer.getByRole('button', {name: 'close', exact: true}).click();
|
|
29
|
+
* await clickOptionalUntilHidden(page.getByRole('button', {name: 'Discard', exact: true}), composer, {message: 'The email composer did not close'});
|
|
30
|
+
*/
|
|
31
|
+
export declare function clickOptionalUntilHidden(optional: Locator, target: Locator, options?: {
|
|
32
|
+
timeout?: number;
|
|
33
|
+
message?: string;
|
|
34
|
+
}): Promise<void>;
|
|
35
|
+
/** Waits until nothing inside `scope` says it is still loading (busy regions, progress bars, spinners and skeletons that have a role or test id). */
|
|
36
|
+
export declare function expectNotBusy(scope: Locator | Page, timeout?: number): Promise<void>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { expect } from '@playwright/test';
|
|
2
|
+
const matches = (pattern, url) => (typeof pattern === 'string' ? url.includes(pattern) : pattern.test(url));
|
|
3
|
+
/**
|
|
4
|
+
* Runs `action` (a click, a navigation) and waits until its result is there: first the key requests it starts have
|
|
5
|
+
* finished, then the `ready` locators are visible. Read the result only after this returns.
|
|
6
|
+
*
|
|
7
|
+
* The response waiters are registered before the action runs, so a fast response is never missed.
|
|
8
|
+
*
|
|
9
|
+
* await actAndWait(page, () => share.click(), {responses: [/\/api\/documents\/.+\/email/], ready: composer.getByRole('textbox', {name: 'To'})});
|
|
10
|
+
*/
|
|
11
|
+
export async function actAndWait(page, action, options = {}) {
|
|
12
|
+
const { responses = [], ready = [], timeout = 30_000 } = options;
|
|
13
|
+
const waiters = responses.map((pattern) => page
|
|
14
|
+
.waitForResponse((response) => matches(pattern, response.url()), { timeout })
|
|
15
|
+
.then((response) => ({ pattern, response }), () => ({ pattern, response: null })));
|
|
16
|
+
const result = await action();
|
|
17
|
+
for (const { pattern, response } of await Promise.all(waiters)) {
|
|
18
|
+
if (!response)
|
|
19
|
+
throw new Error(`The page did not finish loading: no response for the request ${pattern} within ${timeout / 1000}s`);
|
|
20
|
+
if (response.status() >= 400) {
|
|
21
|
+
throw new Error(`The page did not load its data: the request ${pattern} failed (HTTP ${response.status()})`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const locator of Array.isArray(ready) ? ready : [ready]) {
|
|
25
|
+
await expect(locator, 'The content did not finish loading').toBeVisible({ timeout });
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* For an action that may or may not raise a confirmation (a "Discard draft?" dialog after closing a composer): clicks
|
|
31
|
+
* `optional` whenever it is visible, and returns once `target` is hidden. Both happen in one retry loop, so a
|
|
32
|
+
* confirmation that shows up late is still clicked, and no confirmation does not cost a wait.
|
|
33
|
+
*
|
|
34
|
+
* `isVisible()` alone is not enough: it answers for this instant only and its `timeout` option is ignored.
|
|
35
|
+
* Prefer a `target` that stays on the same element (a role/name or test id locator); a `.last()` locator moves to the
|
|
36
|
+
* confirm dialog while that dialog is open.
|
|
37
|
+
*
|
|
38
|
+
* await composer.getByRole('button', {name: 'close', exact: true}).click();
|
|
39
|
+
* await clickOptionalUntilHidden(page.getByRole('button', {name: 'Discard', exact: true}), composer, {message: 'The email composer did not close'});
|
|
40
|
+
*/
|
|
41
|
+
export async function clickOptionalUntilHidden(optional, target, options = {}) {
|
|
42
|
+
const { timeout = 30_000, message = 'The element did not close' } = options;
|
|
43
|
+
await expect(async () => {
|
|
44
|
+
if (await optional.isVisible())
|
|
45
|
+
await optional.click({ timeout: 2_000 });
|
|
46
|
+
await expect(target).toBeHidden({ timeout: 1_000 });
|
|
47
|
+
}, message).toPass({ timeout, intervals: [250, 500, 1_000] });
|
|
48
|
+
}
|
|
49
|
+
/** Waits until nothing inside `scope` says it is still loading (busy regions, progress bars, spinners and skeletons that have a role or test id). */
|
|
50
|
+
export async function expectNotBusy(scope, timeout = 30_000) {
|
|
51
|
+
const busy = scope.locator('[aria-busy="true"], [role="progressbar"], [role="status"][aria-live][aria-busy="true"], [data-tn*="loading" i], [data-tn*="skeleton" i], [data-testid*="loading" i], [data-testid*="skeleton" i]');
|
|
52
|
+
await expect(busy, 'The content is still loading').toHaveCount(0, { timeout });
|
|
53
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Every piece of data a test creates carries this prefix, so it can never be mistaken for real data. */
|
|
2
|
+
export declare const TEST_DATA_PREFIX = "[asrun]";
|
|
3
|
+
export type RecordStatus = 'open' | 'cleaned' | 'leftover';
|
|
4
|
+
/** One piece of test data a case created, as written to the ledger. */
|
|
5
|
+
export interface LedgerEntry {
|
|
6
|
+
id: string;
|
|
7
|
+
runId: string;
|
|
8
|
+
env: string;
|
|
9
|
+
profile: string;
|
|
10
|
+
caseId: string;
|
|
11
|
+
/** What kind of thing it is, e.g. "Document draft". */
|
|
12
|
+
kind: string;
|
|
13
|
+
/** Its name in the product; always starts with TEST_DATA_PREFIX. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Where a person can find and remove it by hand, e.g. "/app/documents". */
|
|
16
|
+
where: string | null;
|
|
17
|
+
/** `open`: created, not yet removed (also what a crashed run leaves behind). */
|
|
18
|
+
status: RecordStatus;
|
|
19
|
+
error: string | null;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
}
|
|
22
|
+
export declare const RECORDS_DIR: string;
|
|
23
|
+
/** The ledger of one test: `.tmp/records/<runId>/<file>.json`. One file per test, so parallel workers never share a file. */
|
|
24
|
+
export declare const ledgerFile: (runId: string, testId: string) => string;
|
|
25
|
+
export declare function writeLedger(file: string, entries: LedgerEntry[]): void;
|
|
26
|
+
/** Entries of one run (or of every run) that are not known to be removed. */
|
|
27
|
+
export declare function findLeftovers(options?: {
|
|
28
|
+
runId?: string;
|
|
29
|
+
env?: string;
|
|
30
|
+
}): LedgerEntry[];
|
|
31
|
+
/** Forget the ledgers of runs whose leftovers a person has removed by hand. */
|
|
32
|
+
export declare function forgetLedgers(env: string): number;
|
|
33
|
+
export declare function describeLeftovers(entries: LedgerEntry[]): string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ROOT } from './paths.js';
|
|
4
|
+
/** Every piece of data a test creates carries this prefix, so it can never be mistaken for real data. */
|
|
5
|
+
export const TEST_DATA_PREFIX = '[asrun]';
|
|
6
|
+
export const RECORDS_DIR = path.join(ROOT, '.tmp', 'records');
|
|
7
|
+
/** The ledger of one test: `.tmp/records/<runId>/<file>.json`. One file per test, so parallel workers never share a file. */
|
|
8
|
+
export const ledgerFile = (runId, testId) => path.join(RECORDS_DIR, runId, `${testId.replace(/[^\w-]/g, '_')}.json`);
|
|
9
|
+
export function writeLedger(file, entries) {
|
|
10
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
11
|
+
fs.writeFileSync(file, JSON.stringify(entries, null, 2));
|
|
12
|
+
}
|
|
13
|
+
function readLedger(file) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Entries of one run (or of every run) that are not known to be removed. */
|
|
22
|
+
export function findLeftovers(options = {}) {
|
|
23
|
+
if (!fs.existsSync(RECORDS_DIR))
|
|
24
|
+
return [];
|
|
25
|
+
const runs = options.runId ? [options.runId] : fs.readdirSync(RECORDS_DIR);
|
|
26
|
+
return runs
|
|
27
|
+
.flatMap((run) => {
|
|
28
|
+
const dir = path.join(RECORDS_DIR, run);
|
|
29
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
|
|
30
|
+
return [];
|
|
31
|
+
return fs.readdirSync(dir).filter((name) => name.endsWith('.json')).flatMap((name) => readLedger(path.join(dir, name)));
|
|
32
|
+
})
|
|
33
|
+
.filter((entry) => entry.status !== 'cleaned' && (!options.env || entry.env === options.env));
|
|
34
|
+
}
|
|
35
|
+
/** Forget the ledgers of runs whose leftovers a person has removed by hand. */
|
|
36
|
+
export function forgetLedgers(env) {
|
|
37
|
+
if (!fs.existsSync(RECORDS_DIR))
|
|
38
|
+
return 0;
|
|
39
|
+
let removed = 0;
|
|
40
|
+
for (const run of fs.readdirSync(RECORDS_DIR)) {
|
|
41
|
+
const dir = path.join(RECORDS_DIR, run);
|
|
42
|
+
if (!fs.statSync(dir).isDirectory())
|
|
43
|
+
continue;
|
|
44
|
+
for (const name of fs.readdirSync(dir)) {
|
|
45
|
+
const file = path.join(dir, name);
|
|
46
|
+
if (readLedger(file).some((entry) => entry.env === env)) {
|
|
47
|
+
fs.rmSync(file);
|
|
48
|
+
removed++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (fs.readdirSync(dir).length === 0)
|
|
52
|
+
fs.rmdirSync(dir);
|
|
53
|
+
}
|
|
54
|
+
return removed;
|
|
55
|
+
}
|
|
56
|
+
export function describeLeftovers(entries) {
|
|
57
|
+
return entries
|
|
58
|
+
.map((entry) => ` - [${entry.profile} (${entry.env})] ${entry.kind} "${entry.name}" (case ${entry.caseId}, run ${entry.runId}` +
|
|
59
|
+
`${entry.where ? `, look in ${entry.where}` : ''})${entry.error ? `\n cleanup said: ${entry.error}` : ''}`)
|
|
60
|
+
.join('\n');
|
|
61
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Matches the spec file of one case (`documents/published-flow` must not match `documents/published-flow-2`). */
|
|
2
|
+
export declare const casePattern: (id: string) => string;
|
|
3
|
+
export interface RunFilters {
|
|
4
|
+
env: string;
|
|
5
|
+
/** `null` = every profile of the environment (no `--project` filter). */
|
|
6
|
+
profiles: string[] | null;
|
|
7
|
+
suites: string[];
|
|
8
|
+
/** The user's own `--grep`. */
|
|
9
|
+
grep?: string;
|
|
10
|
+
/** Leave out the cases that write data. */
|
|
11
|
+
readOnly: boolean;
|
|
12
|
+
/** Only these cases (ids such as `documents/published-flow`), e.g. the ones whose baselines are being updated. */
|
|
13
|
+
only?: string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The Playwright arguments that decide WHICH tests run. Shared by `pnpm asrun` and by the web UI's
|
|
17
|
+
* "what would this run do" preview, so both always agree.
|
|
18
|
+
*/
|
|
19
|
+
export declare function filterArgs(filters: RunFilters): string[];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** `--grep` pattern that matches a tag exactly (`@smoke` must not match `@smoke-extra`). */
|
|
2
|
+
const tagPattern = (name) => `@${name}(?![\\w-])`;
|
|
3
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
4
|
+
/** Matches the spec file of one case (`documents/published-flow` must not match `documents/published-flow-2`). */
|
|
5
|
+
export const casePattern = (id) => `(?:^|\\s)${escapeRegExp(id)}\\.spec\\.ts`;
|
|
6
|
+
/**
|
|
7
|
+
* The Playwright arguments that decide WHICH tests run. Shared by `pnpm asrun` and by the web UI's
|
|
8
|
+
* "what would this run do" preview, so both always agree.
|
|
9
|
+
*/
|
|
10
|
+
export function filterArgs(filters) {
|
|
11
|
+
// Playwright accepts a single --grep and matches it against the file, titles and tags, so the
|
|
12
|
+
// suite filter and the user's own --grep are combined into one regular expression.
|
|
13
|
+
const grepParts = [];
|
|
14
|
+
if (filters.suites.length > 0) {
|
|
15
|
+
grepParts.push(`(?:${filters.suites.map(tagPattern).join('|')})`);
|
|
16
|
+
}
|
|
17
|
+
if (filters.grep) {
|
|
18
|
+
grepParts.push(`(?:${filters.grep})`);
|
|
19
|
+
}
|
|
20
|
+
if (filters.only && filters.only.length > 0) {
|
|
21
|
+
grepParts.push(`(?:${filters.only.map(casePattern).join('|')})`);
|
|
22
|
+
}
|
|
23
|
+
const invert = ['@seed', ...(filters.readOnly ? ['@writes'] : [])].join('|');
|
|
24
|
+
return [
|
|
25
|
+
...(filters.profiles ? filters.profiles.flatMap((id) => ['--project', id]) : []),
|
|
26
|
+
...(grepParts.length > 0 ? ['--grep', grepParts.map((part) => `(?=.*${part})`).join('')] : []),
|
|
27
|
+
'--grep-invert',
|
|
28
|
+
invert,
|
|
29
|
+
];
|
|
30
|
+
}
|
|
@@ -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,18 @@
|
|
|
1
|
+
import type { Browser } from '@playwright/test';
|
|
2
|
+
import type { Profile } 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
|
+
export declare const isLoginUrl: (url: string) => boolean;
|
|
11
|
+
export declare function checkSession(browser: Browser, profile: Profile): Promise<SessionStatus>;
|
|
12
|
+
export declare function parseNotLoggedIn(value?: string | undefined): Map<string, SessionStatus>;
|
|
13
|
+
export declare const formatNotLoggedIn: (entries: Map<string, SessionStatus>) => string;
|
|
14
|
+
export declare const signinCommand: (profile: {
|
|
15
|
+
id: string;
|
|
16
|
+
}) => string;
|
|
17
|
+
export declare const skipProfileHint: (profileIds: string[], env: string) => string;
|
|
18
|
+
export declare const sessionProblemMessage: (profile: Profile, status: SessionStatus) => string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { authFile } from './paths.js';
|
|
3
|
+
/**
|
|
4
|
+
* Set by `pnpm asrun` after its pre-run login check: `profile-id:status,profile-id:status`
|
|
5
|
+
* for every profile that is not logged in. Lets the config and fixtures skip a second check.
|
|
6
|
+
*/
|
|
7
|
+
export const NOT_LOGGED_IN_ENV = 'ASRUN_NOT_LOGGED_IN';
|
|
8
|
+
export const hasSessionFile = (profile) => fs.existsSync(authFile(profile.id));
|
|
9
|
+
export const isLoginUrl = (url) => {
|
|
10
|
+
try {
|
|
11
|
+
return new URL(url).pathname.startsWith('/login');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
export async function checkSession(browser, profile) {
|
|
18
|
+
if (!hasSessionFile(profile)) {
|
|
19
|
+
return 'missing';
|
|
20
|
+
}
|
|
21
|
+
const context = await browser.newContext({ storageState: authFile(profile.id) });
|
|
22
|
+
try {
|
|
23
|
+
const page = await context.newPage();
|
|
24
|
+
await page.goto(new URL(profile.sessionCheckPath, profile.baseURL).href, {
|
|
25
|
+
waitUntil: 'domcontentloaded',
|
|
26
|
+
});
|
|
27
|
+
return isLoginUrl(page.url()) ? 'expired' : 'ok';
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
await context.close();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function parseNotLoggedIn(value = process.env[NOT_LOGGED_IN_ENV]) {
|
|
34
|
+
const result = new Map();
|
|
35
|
+
for (const entry of (value ?? '').split(',').filter(Boolean)) {
|
|
36
|
+
const [id, status] = entry.split(':');
|
|
37
|
+
result.set(id, status === 'missing' ? 'missing' : 'expired');
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
export const formatNotLoggedIn = (entries) => [...entries].map(([id, status]) => `${id}:${status}`).join(',');
|
|
42
|
+
export const signinCommand = (profile) => `pnpm signin ${profile.id}`;
|
|
43
|
+
export const skipProfileHint = (profileIds, env) => `pnpm asrun --env ${env} --skip-profile ${profileIds.join(',')}`;
|
|
44
|
+
export const sessionProblemMessage = (profile, status) => `Profile "${profile.id}" is not logged in to ${profile.env} (session ${status}). ` +
|
|
45
|
+
`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
|
+
}
|