@asrunjs/asrun 0.0.0-stage → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/explore-case.md +18 -0
- package/.claude/commands/gen-spec.md +16 -0
- package/.claude/commands/heal-spec.md +26 -0
- package/.claude/commands/make-case.md +11 -0
- package/.claude/settings.json +5 -0
- package/.claude/skills/exploring-cases/SKILL.md +146 -0
- package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -0
- package/.claude/skills/exploring-cases/references/handoff-card.js +128 -0
- package/.claude/skills/exploring-cases/references/open-questions.md +116 -0
- package/.claude/skills/exploring-cases/references/readiness.md +146 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +157 -0
- package/.claude/skills/generating-specs/SKILL.md +219 -0
- package/.claude/skills/generating-specs/references/conventions.md +148 -0
- package/.claude/skills/making-cases/SKILL.md +72 -0
- package/.claude/skills/making-cases/references/gaps.md +36 -0
- package/.claude/skills/making-cases/references/normalizing.md +39 -0
- package/.claude/skills/making-cases/references/splitting.md +28 -0
- package/.claude/skills/making-cases/references/writes-and-email.md +37 -0
- package/AGENTS.md +159 -0
- package/CLAUDE.md +2 -0
- package/LICENSE +21 -0
- package/README.md +511 -2
- package/bin/asrun.mjs +49 -0
- package/dist/lib/account-lock.d.ts +6 -0
- package/dist/lib/account-lock.js +52 -0
- package/dist/lib/assertions.d.ts +14 -0
- package/dist/lib/assertions.js +39 -0
- package/dist/lib/baselines.d.ts +43 -0
- package/dist/lib/baselines.js +108 -0
- package/dist/lib/build-artifacts.d.ts +8 -0
- package/dist/lib/build-artifacts.js +10 -0
- package/dist/lib/case-doc.d.ts +311 -0
- package/dist/lib/case-doc.js +1117 -0
- package/dist/lib/case-id.d.ts +11 -0
- package/dist/lib/case-id.js +17 -0
- package/dist/lib/case-ops.d.ts +26 -0
- package/dist/lib/case-ops.js +110 -0
- package/dist/lib/cases.d.ts +16 -0
- package/dist/lib/cases.js +51 -0
- package/dist/lib/cli.d.ts +10 -0
- package/dist/lib/cli.js +18 -0
- package/dist/lib/command.d.ts +24 -0
- package/dist/lib/command.js +90 -0
- package/dist/lib/downloads.d.ts +42 -0
- package/dist/lib/downloads.js +84 -0
- package/dist/lib/email.d.ts +10 -0
- package/dist/lib/email.js +19 -0
- package/dist/lib/ffmpeg.d.ts +8 -0
- package/dist/lib/ffmpeg.js +50 -0
- package/dist/lib/fixtures.d.ts +40 -0
- package/dist/lib/fixtures.js +149 -0
- package/dist/lib/login-card.d.ts +22 -0
- package/dist/lib/login-card.js +183 -0
- package/dist/lib/node-cli.d.ts +15 -0
- package/dist/lib/node-cli.js +36 -0
- package/dist/lib/paths.d.ts +25 -0
- package/dist/lib/paths.js +88 -0
- package/dist/lib/pdf.d.ts +71 -0
- package/dist/lib/pdf.js +140 -0
- package/dist/lib/profiles.d.ts +62 -0
- package/dist/lib/profiles.js +174 -0
- package/dist/lib/ready.d.ts +36 -0
- package/dist/lib/ready.js +53 -0
- package/dist/lib/records.d.ts +33 -0
- package/dist/lib/records.js +61 -0
- package/dist/lib/run-filters.d.ts +19 -0
- package/dist/lib/run-filters.js +38 -0
- package/dist/lib/run-summary.d.ts +90 -0
- package/dist/lib/run-summary.js +35 -0
- package/dist/lib/session.d.ts +30 -0
- package/dist/lib/session.js +111 -0
- package/dist/lib/summary-reporter.d.ts +16 -0
- package/dist/lib/summary-reporter.js +146 -0
- package/dist/lib/targets.d.ts +19 -0
- package/dist/lib/targets.js +26 -0
- package/dist/lib/ui-build.d.ts +10 -0
- package/dist/lib/ui-build.js +30 -0
- package/dist/lib/values.d.ts +29 -0
- package/dist/lib/values.js +85 -0
- package/dist/lib/write-gate.d.ts +41 -0
- package/dist/lib/write-gate.js +129 -0
- package/dist/playwright.config.d.ts +2 -0
- package/dist/playwright.config.js +92 -0
- package/dist/scripts/build.d.ts +1 -0
- package/dist/scripts/build.js +21 -0
- package/dist/scripts/check-cases.d.ts +1 -0
- package/dist/scripts/check-cases.js +149 -0
- package/dist/scripts/cleanup.d.ts +1 -0
- package/dist/scripts/cleanup.js +25 -0
- package/dist/scripts/cli.d.ts +1 -0
- package/dist/scripts/cli.js +28 -0
- package/dist/scripts/doctor.d.ts +1 -0
- package/dist/scripts/doctor.js +296 -0
- package/dist/scripts/explore.d.ts +1 -0
- package/dist/scripts/explore.js +75 -0
- package/dist/scripts/go.mjs +188 -0
- package/dist/scripts/heal-evidence.mjs +143 -0
- package/dist/scripts/init.d.ts +22 -0
- package/dist/scripts/init.js +332 -0
- package/dist/scripts/lint-specs.d.ts +1 -0
- package/dist/scripts/lint-specs.js +141 -0
- package/dist/scripts/login.d.ts +1 -0
- package/dist/scripts/login.js +75 -0
- package/dist/scripts/new-case.d.ts +1 -0
- package/dist/scripts/new-case.js +19 -0
- package/dist/scripts/render-pdf.d.ts +1 -0
- package/dist/scripts/render-pdf.js +44 -0
- package/dist/scripts/report.d.ts +1 -0
- package/dist/scripts/report.js +14 -0
- package/dist/scripts/review-baselines.d.ts +1 -0
- package/dist/scripts/review-baselines.js +30 -0
- package/dist/scripts/run.d.ts +1 -0
- package/dist/scripts/run.js +423 -0
- package/dist/scripts/status.d.ts +1 -0
- package/dist/scripts/status.js +31 -0
- package/dist/scripts/ui-build.d.ts +1 -0
- package/dist/scripts/ui-build.js +7 -0
- package/dist/scripts/ui-dev.d.ts +1 -0
- package/dist/scripts/ui-dev.js +3 -0
- package/dist/scripts/ui.d.ts +1 -0
- package/dist/scripts/ui.js +13 -0
- package/dist/ui/dev.d.ts +1 -0
- package/dist/ui/dev.js +36 -0
- package/dist/ui/server/actions.d.ts +95 -0
- package/dist/ui/server/actions.js +365 -0
- package/dist/ui/server/ai-models.d.ts +3 -0
- package/dist/ui/server/ai-models.js +347 -0
- package/dist/ui/server/ai-stream.d.ts +66 -0
- package/dist/ui/server/ai-stream.js +408 -0
- package/dist/ui/server/app.d.ts +4 -0
- package/dist/ui/server/app.js +395 -0
- package/dist/ui/server/cases.d.ts +23 -0
- package/dist/ui/server/cases.js +170 -0
- package/dist/ui/server/files.d.ts +5 -0
- package/dist/ui/server/files.js +67 -0
- package/dist/ui/server/index.d.ts +1 -0
- package/dist/ui/server/index.js +33 -0
- package/dist/ui/server/jobs.d.ts +80 -0
- package/dist/ui/server/jobs.js +317 -0
- package/dist/ui/server/profiles.d.ts +35 -0
- package/dist/ui/server/profiles.js +371 -0
- package/dist/ui/server/watch-files.d.ts +4 -0
- package/dist/ui/server/watch-files.js +36 -0
- package/dist/ui/shared/ai-commands.d.ts +88 -0
- package/dist/ui/shared/ai-commands.js +177 -0
- package/dist/ui/shared/case-id.d.ts +1 -0
- package/dist/ui/shared/case-id.js +1 -0
- package/dist/ui/shared/jobs.d.ts +6 -0
- package/dist/ui/shared/jobs.js +7 -0
- package/dist/ui/shared/product.d.ts +3 -0
- package/dist/ui/shared/product.js +3 -0
- package/dist/ui/shared/run-filters.d.ts +1 -0
- package/dist/ui/shared/run-filters.js +1 -0
- package/dist/ui/shared/site-address.d.ts +6 -0
- package/dist/ui/shared/site-address.js +11 -0
- package/dist/ui/shared/types.d.ts +371 -0
- package/dist/ui/shared/types.js +1 -0
- package/docs/agents/framework-dev.md +79 -0
- package/docs/theme-guide.html +922 -0
- package/package.json +109 -3
- package/templates/.claude/commands/explore-case.md +22 -0
- package/templates/.claude/commands/gen-spec.md +21 -0
- package/templates/.claude/commands/heal-spec.md +31 -0
- package/templates/.claude/commands/make-case.md +13 -0
- package/templates/.claude/settings.json +5 -0
- package/templates/.mcp.json +8 -0
- package/templates/AGENTS.md +64 -0
- package/templates/CLAUDE.md +1 -0
- package/templates/cases/_template.md +57 -0
- package/templates/gitignore +13 -0
- package/templates/opencode.jsonc +16 -0
- package/templates/package.json +31 -0
- package/templates/playwright.config.ts +3 -0
- package/templates/pnpm-workspace.yaml +5 -0
- package/templates/profiles.yaml +42 -0
- package/templates/seed.spec.ts +9 -0
- package/templates/tsconfig.json +14 -0
- package/ui/dist/apple-touch-icon.png +0 -0
- package/ui/dist/assets/apl-gfMMiTNf.js +1 -0
- package/ui/dist/assets/asciiarmor-C_eisQCp.js +1 -0
- package/ui/dist/assets/asn1-DCl_8MCW.js +1 -0
- package/ui/dist/assets/asterisk-BEn0Cwpx.js +1 -0
- package/ui/dist/assets/brainfuck-D5EjA2JK.js +1 -0
- package/ui/dist/assets/clike-xvONpD0Z.js +1 -0
- package/ui/dist/assets/clojure-BflJGmDX.js +1 -0
- package/ui/dist/assets/cmake-84nbDH0I.js +1 -0
- package/ui/dist/assets/cobol-DHLQUbN7.js +1 -0
- package/ui/dist/assets/coffeescript-De_zI4J0.js +1 -0
- package/ui/dist/assets/commonlisp-DgAKBe0r.js +1 -0
- package/ui/dist/assets/crystal-Bl6YKCG_.js +1 -0
- package/ui/dist/assets/css-CnaYoQib.js +1 -0
- package/ui/dist/assets/cypher-p9eYesGn.js +1 -0
- package/ui/dist/assets/d-DWkQyNnU.js +1 -0
- package/ui/dist/assets/diff-ChtP43wD.js +1 -0
- package/ui/dist/assets/dist-BGbe0-Aa.js +1 -0
- package/ui/dist/assets/dist-Ba-HC662.js +2 -0
- package/ui/dist/assets/dist-BtMLMthJ.js +1 -0
- package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
- package/ui/dist/assets/dist-BzulKuec.js +1 -0
- package/ui/dist/assets/dist-C-Oyo1ZT.js +1 -0
- package/ui/dist/assets/dist-C8MdhqgG.js +1 -0
- package/ui/dist/assets/dist-CG1pHn5m.js +1 -0
- package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
- package/ui/dist/assets/dist-CmpteMYY.js +9 -0
- package/ui/dist/assets/dist-DDJNvivM.js +1 -0
- package/ui/dist/assets/dist-DF3F_I8N.js +1 -0
- package/ui/dist/assets/dist-DSy3dVf4.js +11 -0
- package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
- package/ui/dist/assets/dist-Dq6sW5Ur.js +1 -0
- package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
- package/ui/dist/assets/dist-jjxQutEm.js +1 -0
- package/ui/dist/assets/dockerfile-DJkHQgkl.js +1 -0
- package/ui/dist/assets/dtd-DTF72YNO.js +1 -0
- package/ui/dist/assets/dylan-S7ucR4pR.js +1 -0
- package/ui/dist/assets/ebnf-D4c0_ac3.js +1 -0
- package/ui/dist/assets/ecl-BVIeWmwY.js +1 -0
- package/ui/dist/assets/eiffel-BEjRio4Q.js +1 -0
- package/ui/dist/assets/elm-Cshzl8qu.js +1 -0
- package/ui/dist/assets/erlang-BV0c4Hdn.js +1 -0
- package/ui/dist/assets/factor-LKH5gN8L.js +1 -0
- package/ui/dist/assets/fcl-ClOhbFR7.js +1 -0
- package/ui/dist/assets/forth-D6RzAl64.js +1 -0
- package/ui/dist/assets/fortran-Bt6PBEDR.js +1 -0
- package/ui/dist/assets/gas-BHEdbvp9.js +1 -0
- package/ui/dist/assets/gherkin-oBAE_ms0.js +1 -0
- package/ui/dist/assets/groovy-CUrWKPia.js +1 -0
- package/ui/dist/assets/haskell-BRsoo5mP.js +1 -0
- package/ui/dist/assets/haxe-DjVOVvNP.js +1 -0
- package/ui/dist/assets/http-CLVgA2GD.js +1 -0
- package/ui/dist/assets/idl-BUZw3wgd.js +1 -0
- package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
- package/ui/dist/assets/index-P_Yo0ZZn.js +338 -0
- package/ui/dist/assets/javascript-lS2r2ifg.js +1 -0
- package/ui/dist/assets/julia-CQfgDRfP.js +1 -0
- package/ui/dist/assets/livescript-BXxG0Yva.js +1 -0
- package/ui/dist/assets/lua-DPCEkfD_.js +1 -0
- package/ui/dist/assets/mathematica-u8YMmbU0.js +1 -0
- package/ui/dist/assets/mbox-BEMVcqjs.js +1 -0
- package/ui/dist/assets/mirc-C9zpzAZU.js +1 -0
- package/ui/dist/assets/mllike-B45xgp2S.js +1 -0
- package/ui/dist/assets/modelica-CqHI_q-D.js +1 -0
- package/ui/dist/assets/mscgen-qgSLujhx.js +1 -0
- package/ui/dist/assets/mumps-BSzj6Xyz.js +1 -0
- package/ui/dist/assets/nginx-C-ORUuei.js +1 -0
- package/ui/dist/assets/nsis-viPvXFiq.js +1 -0
- package/ui/dist/assets/ntriples-CzMBulkp.js +1 -0
- package/ui/dist/assets/octave-D2Q8cUp1.js +1 -0
- package/ui/dist/assets/oz-BwTct_5T.js +1 -0
- package/ui/dist/assets/pascal-Dp_KdFgX.js +1 -0
- package/ui/dist/assets/perl-BkSkiI9m.js +1 -0
- package/ui/dist/assets/pig-AF4Hwhju.js +1 -0
- package/ui/dist/assets/powershell-D8WpQPaX.js +1 -0
- package/ui/dist/assets/properties-C357ku5U.js +1 -0
- package/ui/dist/assets/protobuf-D1ij_kNL.js +1 -0
- package/ui/dist/assets/pug-BqCHZ5xT.js +1 -0
- package/ui/dist/assets/puppet-BZyAS7I4.js +1 -0
- package/ui/dist/assets/python-B_1Jd8HM.js +1 -0
- package/ui/dist/assets/q-B9NhS7L_.js +1 -0
- package/ui/dist/assets/r-CxDmU1I4.js +1 -0
- package/ui/dist/assets/rpm-gpDK5sbt.js +1 -0
- package/ui/dist/assets/ruby-ChfNwlCY.js +1 -0
- package/ui/dist/assets/sas-C2OyD2Y6.js +1 -0
- package/ui/dist/assets/scheme-D9qYN0V5.js +1 -0
- package/ui/dist/assets/shell-DwuoZtxw.js +1 -0
- package/ui/dist/assets/sieve-tLWDfEkZ.js +1 -0
- package/ui/dist/assets/simple-mode-DXiLM1-G.js +1 -0
- package/ui/dist/assets/smalltalk-BLp5-jwC.js +1 -0
- package/ui/dist/assets/solr-B78wSQJQ.js +1 -0
- package/ui/dist/assets/sparql-Dz-mlCAC.js +1 -0
- package/ui/dist/assets/spreadsheet-CTIncYxj.js +1 -0
- package/ui/dist/assets/sql-DuQMFDvy.js +1 -0
- package/ui/dist/assets/stex-DNzd62Q_.js +1 -0
- package/ui/dist/assets/stylus-BOVgKnng.js +1 -0
- package/ui/dist/assets/swift-DKB6_1j6.js +1 -0
- package/ui/dist/assets/tcl-BGfruO1u.js +1 -0
- package/ui/dist/assets/textile-C4jVoHG1.js +1 -0
- package/ui/dist/assets/tiddlywiki-VlUiK86J.js +1 -0
- package/ui/dist/assets/tiki-CvUEFusT.js +1 -0
- package/ui/dist/assets/toml-BPTmHmyx.js +1 -0
- package/ui/dist/assets/troff-C2dAgh7P.js +1 -0
- package/ui/dist/assets/ttcn-PPSTk1ow.js +1 -0
- package/ui/dist/assets/ttcn-cfg-2b4Gb5sE.js +1 -0
- package/ui/dist/assets/turtle-Cx3rYX2g.js +1 -0
- package/ui/dist/assets/vb-BthdM_4N.js +1 -0
- package/ui/dist/assets/vbscript-D9f6rSsU.js +1 -0
- package/ui/dist/assets/velocity-CqftRsjg.js +1 -0
- package/ui/dist/assets/verilog-CjI9Y6RI.js +1 -0
- package/ui/dist/assets/vhdl-kS471soi.js +1 -0
- package/ui/dist/assets/webidl-D5jiJvOU.js +1 -0
- package/ui/dist/assets/xquery-BKJx4mf9.js +1 -0
- package/ui/dist/assets/yacas-CXXgtw0p.js +1 -0
- package/ui/dist/assets/z80-Co3PbHdE.js +1 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/favicon.svg +11 -0
- package/ui/dist/help/ai-tools.png +0 -0
- package/ui/dist/help/case-explore.png +0 -0
- package/ui/dist/help/case-generate.png +0 -0
- package/ui/dist/help/cases-memo.png +0 -0
- package/ui/dist/help/profile-form.png +0 -0
- package/ui/dist/help/profiles-login.png +0 -0
- package/ui/dist/help/reports.png +0 -0
- package/ui/dist/help/run-tests.png +0 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/logos/claude.svg +1 -0
- package/ui/dist/logos/opencode.svg +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const NAME_PATTERN: RegExp;
|
|
2
|
+
export declare const CASE_ID_PATTERN: RegExp;
|
|
3
|
+
export declare const isValidCaseName: (name: string) => boolean;
|
|
4
|
+
export declare const isValidCaseId: (id: string) => boolean;
|
|
5
|
+
export declare function splitCaseId(id: string): {
|
|
6
|
+
group: string | null;
|
|
7
|
+
name: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const joinCaseId: (group: string | null | undefined, name: string) => string;
|
|
10
|
+
/** Folders between `tests/` and the spec; the import path to `lib/` is `../` times (depth + 1). */
|
|
11
|
+
export declare const caseDepth: (id: string) => number;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A case id is the path of its document below `cases/`, without `.md`: `hub-landing` or `documents/landing-page`.
|
|
3
|
+
* One optional folder level (the group). The spec of a case lives at the same path below `tests/`.
|
|
4
|
+
* No Node imports here: the web UI uses this file too.
|
|
5
|
+
*/
|
|
6
|
+
const WORD = '[a-z0-9]+(?:-[a-z0-9]+)*';
|
|
7
|
+
export const NAME_PATTERN = new RegExp(`^${WORD}$`);
|
|
8
|
+
export const CASE_ID_PATTERN = new RegExp(`^(?:${WORD}/)?${WORD}$`);
|
|
9
|
+
export const isValidCaseName = (name) => NAME_PATTERN.test(name);
|
|
10
|
+
export const isValidCaseId = (id) => CASE_ID_PATTERN.test(id);
|
|
11
|
+
export function splitCaseId(id) {
|
|
12
|
+
const slash = id.indexOf('/');
|
|
13
|
+
return slash === -1 ? { group: null, name: id } : { group: id.slice(0, slash), name: id.slice(slash + 1) };
|
|
14
|
+
}
|
|
15
|
+
export const joinCaseId = (group, name) => (group ? `${group}/${name}` : name);
|
|
16
|
+
/** Folders between `tests/` and the spec; the import path to `lib/` is `../` times (depth + 1). */
|
|
17
|
+
export const caseDepth = (id) => (id.match(/\//g) ?? []).length;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare class CaseError extends Error {
|
|
2
|
+
readonly status: 400 | 404 | 409;
|
|
3
|
+
constructor(message: string, status: 400 | 404 | 409);
|
|
4
|
+
}
|
|
5
|
+
export declare const caseFilePath: (id: string) => string;
|
|
6
|
+
export declare const specFilePath: (id: string) => string;
|
|
7
|
+
/** Playwright keeps the baseline images of a spec next to it, in `<spec file>-snapshots/`. */
|
|
8
|
+
export declare const snapshotDirPath: (id: string) => string;
|
|
9
|
+
export declare function assertValidNewId(id: string): void;
|
|
10
|
+
/** Everything that belongs to one case besides its document. */
|
|
11
|
+
export interface CaseImpact {
|
|
12
|
+
id: string;
|
|
13
|
+
caseFile: string;
|
|
14
|
+
specFile: string | null;
|
|
15
|
+
snapshotDir: string | null;
|
|
16
|
+
snapshotCount: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function getCaseImpact(id: string): CaseImpact;
|
|
19
|
+
/**
|
|
20
|
+
* What a spec says about its case: the document path, the id passed to `caseTags` / `configureCase` /
|
|
21
|
+
* `requireValues`, and the relative path to `lib/` (it depends on the folder depth).
|
|
22
|
+
*/
|
|
23
|
+
export declare function rewriteSpec(text: string, from: string, to: string): string;
|
|
24
|
+
export declare function renameCase(from: string, to: string): CaseImpact;
|
|
25
|
+
/** Deletes the document, the spec and the baseline images of one case. */
|
|
26
|
+
export declare function deleteCase(id: string): CaseImpact;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { caseDepth, isValidCaseId } from './case-id.js';
|
|
4
|
+
import { listCaseFileIds } from './cases.js';
|
|
5
|
+
import { CASES_DIR, ROOT, TESTS_DIR } from './paths.js';
|
|
6
|
+
/** `memo` is the route of the "From memo" page of the web UI. */
|
|
7
|
+
const RESERVED_IDS = ['memo'];
|
|
8
|
+
export class CaseError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
constructor(message, status) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export const caseFilePath = (id) => path.join(CASES_DIR, `${id}.md`);
|
|
16
|
+
export const specFilePath = (id) => path.join(TESTS_DIR, `${id}.spec.ts`);
|
|
17
|
+
/** Playwright keeps the baseline images of a spec next to it, in `<spec file>-snapshots/`. */
|
|
18
|
+
export const snapshotDirPath = (id) => `${specFilePath(id)}-snapshots`;
|
|
19
|
+
const relative = (file) => path.relative(ROOT, file).split(path.sep).join('/');
|
|
20
|
+
export function assertValidNewId(id) {
|
|
21
|
+
if (!isValidCaseId(id)) {
|
|
22
|
+
throw new CaseError(`Invalid case id "${id}". Use lowercase words with dashes, optionally in one folder: "header-logo" or "documents/header-logo".`, 400);
|
|
23
|
+
}
|
|
24
|
+
if (RESERVED_IDS.includes(id))
|
|
25
|
+
throw new CaseError(`The id "${id}" is reserved.`, 400);
|
|
26
|
+
}
|
|
27
|
+
export function getCaseImpact(id) {
|
|
28
|
+
const spec = specFilePath(id);
|
|
29
|
+
const snapshots = snapshotDirPath(id);
|
|
30
|
+
const snapshotCount = fs.existsSync(snapshots) ? fs.readdirSync(snapshots, { recursive: true }).length : 0;
|
|
31
|
+
return {
|
|
32
|
+
id,
|
|
33
|
+
caseFile: relative(caseFilePath(id)),
|
|
34
|
+
specFile: fs.existsSync(spec) ? relative(spec) : null,
|
|
35
|
+
snapshotDir: fs.existsSync(snapshots) ? relative(snapshots) : null,
|
|
36
|
+
snapshotCount,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const writeAtomic = (file, text) => {
|
|
40
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
41
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
42
|
+
fs.writeFileSync(temp, text);
|
|
43
|
+
fs.renameSync(temp, file);
|
|
44
|
+
};
|
|
45
|
+
/** Removes a group folder once its last file is gone. Never touches `cases/` or `tests/` themselves. */
|
|
46
|
+
function pruneEmptyDir(dir, root) {
|
|
47
|
+
if (path.resolve(dir) === path.resolve(root) || !path.resolve(dir).startsWith(path.resolve(root)))
|
|
48
|
+
return;
|
|
49
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0)
|
|
50
|
+
fs.rmdirSync(dir);
|
|
51
|
+
}
|
|
52
|
+
function setFrontMatterId(text, id) {
|
|
53
|
+
const match = /^---\r?\n[\s\S]*?\r?\n---/.exec(text);
|
|
54
|
+
if (!match)
|
|
55
|
+
return text;
|
|
56
|
+
return match[0].replace(/^id:[ \t]*.*$/m, `id: ${id}`) + text.slice(match[0].length);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* What a spec says about its case: the document path, the id passed to `caseTags` / `configureCase` /
|
|
60
|
+
* `requireValues`, and the relative path to `lib/` (it depends on the folder depth).
|
|
61
|
+
*/
|
|
62
|
+
export function rewriteSpec(text, from, to) {
|
|
63
|
+
const quoted = (value) => new RegExp(`(['"\`])${value.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}\\1`, 'g');
|
|
64
|
+
const result = text.replaceAll(`cases/${from}.md`, `cases/${to}.md`).replace(quoted(from), `$1${to}$1`);
|
|
65
|
+
const up = '../'.repeat(caseDepth(to) + 1);
|
|
66
|
+
return result.replace(/(\bfrom\s+['"]|\bimport\(\s*['"])\.\.\/(?:\.\.\/)*lib\//g, `$1${up}lib/`);
|
|
67
|
+
}
|
|
68
|
+
export function renameCase(from, to) {
|
|
69
|
+
assertValidNewId(to);
|
|
70
|
+
const ids = listCaseFileIds();
|
|
71
|
+
if (!isValidCaseId(from) || !ids.includes(from))
|
|
72
|
+
throw new CaseError(`Case "${from}" does not exist.`, 404);
|
|
73
|
+
if (from === to)
|
|
74
|
+
throw new CaseError('The new id is the same as the old one.', 400);
|
|
75
|
+
if (ids.includes(to))
|
|
76
|
+
throw new CaseError(`Case "${to}" already exists.`, 409);
|
|
77
|
+
for (const leftover of [specFilePath(to), snapshotDirPath(to)]) {
|
|
78
|
+
if (fs.existsSync(leftover)) {
|
|
79
|
+
throw new CaseError(`${relative(leftover)} already exists (a spec of a deleted case?). Remove it first.`, 409);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Everything is checked; now move the files. New files first, the old ones are removed at the end.
|
|
83
|
+
const impact = getCaseImpact(from);
|
|
84
|
+
writeAtomic(caseFilePath(to), setFrontMatterId(fs.readFileSync(caseFilePath(from), 'utf8'), to));
|
|
85
|
+
if (impact.specFile) {
|
|
86
|
+
writeAtomic(specFilePath(to), rewriteSpec(fs.readFileSync(specFilePath(from), 'utf8'), from, to));
|
|
87
|
+
}
|
|
88
|
+
if (impact.snapshotDir) {
|
|
89
|
+
fs.mkdirSync(path.dirname(snapshotDirPath(to)), { recursive: true });
|
|
90
|
+
fs.renameSync(snapshotDirPath(from), snapshotDirPath(to));
|
|
91
|
+
}
|
|
92
|
+
fs.rmSync(caseFilePath(from));
|
|
93
|
+
if (impact.specFile)
|
|
94
|
+
fs.rmSync(specFilePath(from));
|
|
95
|
+
pruneEmptyDir(path.dirname(caseFilePath(from)), CASES_DIR);
|
|
96
|
+
pruneEmptyDir(path.dirname(specFilePath(from)), TESTS_DIR);
|
|
97
|
+
return getCaseImpact(to);
|
|
98
|
+
}
|
|
99
|
+
/** Deletes the document, the spec and the baseline images of one case. */
|
|
100
|
+
export function deleteCase(id) {
|
|
101
|
+
if (!isValidCaseId(id) || !fs.existsSync(caseFilePath(id)))
|
|
102
|
+
throw new CaseError(`Case "${id}" does not exist.`, 404);
|
|
103
|
+
const impact = getCaseImpact(id);
|
|
104
|
+
fs.rmSync(caseFilePath(id));
|
|
105
|
+
fs.rmSync(specFilePath(id), { force: true });
|
|
106
|
+
fs.rmSync(snapshotDirPath(id), { recursive: true, force: true });
|
|
107
|
+
pruneEmptyDir(path.dirname(caseFilePath(id)), CASES_DIR);
|
|
108
|
+
pruneEmptyDir(path.dirname(specFilePath(id)), TESTS_DIR);
|
|
109
|
+
return impact;
|
|
110
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type CaseMeta } from './case-doc.js';
|
|
2
|
+
export type { CaseMeta } from './case-doc.js';
|
|
3
|
+
/** Does the case write data at all (in some environment)? */
|
|
4
|
+
export declare const caseWrites: (meta: CaseMeta) => boolean;
|
|
5
|
+
/** May the case write data in this environment? A case that writes elsewhere only is skipped here. */
|
|
6
|
+
export declare const caseWritesIn: (meta: CaseMeta, env: string) => boolean;
|
|
7
|
+
/** Parses and validates the front matter. Throws an Error that starts with `label` and says what to fix. */
|
|
8
|
+
export declare function parseCaseMeta(id: string, text: string, label?: string): CaseMeta;
|
|
9
|
+
export declare const hasCaseFile: (caseId: string) => boolean;
|
|
10
|
+
export declare function readCaseMeta(caseId: string): CaseMeta;
|
|
11
|
+
/** Every case document except the template: `cases/<name>.md` and `cases/<group>/<name>.md` (one folder level). */
|
|
12
|
+
export declare function listCaseFileIds(): string[];
|
|
13
|
+
/** Playwright tags of a case: one `@<suite>` per suite, plus `@writes` for cases that write data. */
|
|
14
|
+
export declare function caseTags(caseId: string): string[];
|
|
15
|
+
/** Every distinct suite used by the cases. */
|
|
16
|
+
export declare const listSuites: () => string[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseCaseFrontMatter, validateCaseMeta } from './case-doc.js';
|
|
4
|
+
import { listEnvs } from './profiles.js';
|
|
5
|
+
import { CASES_DIR } from './paths.js';
|
|
6
|
+
/** Does the case write data at all (in some environment)? */
|
|
7
|
+
export const caseWrites = (meta) => meta.writes.length > 0;
|
|
8
|
+
/** May the case write data in this environment? A case that writes elsewhere only is skipped here. */
|
|
9
|
+
export const caseWritesIn = (meta, env) => meta.writes.includes(env);
|
|
10
|
+
/** Parses and validates the front matter. Throws an Error that starts with `label` and says what to fix. */
|
|
11
|
+
export function parseCaseMeta(id, text, label = `cases/${id}.md`) {
|
|
12
|
+
const front = parseCaseFrontMatter(text);
|
|
13
|
+
if (!front)
|
|
14
|
+
throw new Error(`${label}: the file must start with a front matter block (--- ... ---)`);
|
|
15
|
+
if (front.problem)
|
|
16
|
+
throw new Error(`${label}: ${front.problem}`);
|
|
17
|
+
return validateCaseMeta(id, front.data, listEnvs(), label);
|
|
18
|
+
}
|
|
19
|
+
const caseFile = (caseId) => path.join(CASES_DIR, `${caseId}.md`);
|
|
20
|
+
export const hasCaseFile = (caseId) => fs.existsSync(caseFile(caseId));
|
|
21
|
+
export function readCaseMeta(caseId) {
|
|
22
|
+
return parseCaseMeta(caseId, fs.readFileSync(caseFile(caseId), 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
/** Every case document except the template: `cases/<name>.md` and `cases/<group>/<name>.md` (one folder level). */
|
|
25
|
+
export function listCaseFileIds() {
|
|
26
|
+
const ids = [];
|
|
27
|
+
if (!fs.existsSync(CASES_DIR))
|
|
28
|
+
return ids;
|
|
29
|
+
const isCaseFile = (name) => name.endsWith('.md') && !name.startsWith('_');
|
|
30
|
+
for (const entry of fs.readdirSync(CASES_DIR, { withFileTypes: true })) {
|
|
31
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.'))
|
|
32
|
+
continue;
|
|
33
|
+
if (entry.isFile() && isCaseFile(entry.name)) {
|
|
34
|
+
ids.push(entry.name.slice(0, -3));
|
|
35
|
+
}
|
|
36
|
+
else if (entry.isDirectory()) {
|
|
37
|
+
for (const name of fs.readdirSync(path.join(CASES_DIR, entry.name))) {
|
|
38
|
+
if (isCaseFile(name))
|
|
39
|
+
ids.push(`${entry.name}/${name.slice(0, -3)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return ids.sort();
|
|
44
|
+
}
|
|
45
|
+
/** Playwright tags of a case: one `@<suite>` per suite, plus `@writes` for cases that write data. */
|
|
46
|
+
export function caseTags(caseId) {
|
|
47
|
+
const meta = readCaseMeta(caseId);
|
|
48
|
+
return [...meta.suites.map((suite) => `@${suite}`), ...(caseWrites(meta) ? ['@writes'] : [])];
|
|
49
|
+
}
|
|
50
|
+
/** Every distinct suite used by the cases. */
|
|
51
|
+
export const listSuites = () => [...new Set(listCaseFileIds().flatMap((id) => readCaseMeta(id).suites))].sort();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Arguments shared by the single-profile commands: `<profile-id> [path]`. */
|
|
2
|
+
export interface CliTarget {
|
|
3
|
+
profileId?: string;
|
|
4
|
+
/** Positional arguments after the profile id. */
|
|
5
|
+
rest: string[];
|
|
6
|
+
help: boolean;
|
|
7
|
+
/** Set when the caller passed `--env`; those commands do not need it (the profile has its env). */
|
|
8
|
+
envGiven: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function parseCliTarget(args: string[]): CliTarget;
|
package/dist/lib/cli.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function parseCliTarget(args) {
|
|
2
|
+
const positional = [];
|
|
3
|
+
let help = false;
|
|
4
|
+
let envGiven = false;
|
|
5
|
+
for (let i = 0; i < args.length; i++) {
|
|
6
|
+
const arg = args[i];
|
|
7
|
+
if (arg === '--help')
|
|
8
|
+
help = true;
|
|
9
|
+
else if (arg === '--env' || arg.startsWith('--env=')) {
|
|
10
|
+
envGiven = true;
|
|
11
|
+
if (arg === '--env')
|
|
12
|
+
i++;
|
|
13
|
+
}
|
|
14
|
+
else
|
|
15
|
+
positional.push(arg);
|
|
16
|
+
}
|
|
17
|
+
return { profileId: positional[0], rest: positional.slice(1), help, envGiven };
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a command on PATH is started: the executable itself, or, on Windows, a `.cmd`/`.bat` shim that only cmd.exe
|
|
3
|
+
* can run. A CLI installed globally with npm or pnpm is such a shim (it ends up as `opencode.cmd`), and `spawn`
|
|
4
|
+
* without a shell can never start it: the bare name is not found (ENOENT) and the shim itself is refused (EINVAL).
|
|
5
|
+
*/
|
|
6
|
+
export interface SpawnTarget {
|
|
7
|
+
/** The file to spawn. */
|
|
8
|
+
file: string;
|
|
9
|
+
args: string[];
|
|
10
|
+
/** The file that was found on PATH, for messages. */
|
|
11
|
+
path: string;
|
|
12
|
+
/** True when `file` is cmd.exe and `args` carry the quoted command line. */
|
|
13
|
+
throughShell: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The file a command starts. A path (absolute or with a separator) is taken as it is; anything else is looked up
|
|
17
|
+
* on PATH, trying PATHEXT on Windows, so `opencode` finds `opencode.cmd`. Returns null when nothing matches.
|
|
18
|
+
*/
|
|
19
|
+
export declare function findCommand(command: string): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* What to spawn for a command, or null when the command is not installed. Only a Windows shim goes through a shell;
|
|
22
|
+
* everything else is spawned directly, as before.
|
|
23
|
+
*/
|
|
24
|
+
export declare function spawnTarget(command: string, args?: string[]): SpawnTarget | null;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** The extensions Windows would try when it looks a command up; PATHEXT normally lists them. */
|
|
4
|
+
const WINDOWS_EXTENSIONS = ['.COM', '.EXE', '.BAT', '.CMD'];
|
|
5
|
+
const pathEntries = () => (process.env.PATH ?? process.env.Path ?? '')
|
|
6
|
+
.split(path.delimiter)
|
|
7
|
+
.map((entry) => entry.trim().replace(/^"(.*)"$/, '$1'))
|
|
8
|
+
.filter((entry) => entry !== '');
|
|
9
|
+
const windowsExtensions = () => {
|
|
10
|
+
const listed = (process.env.PATHEXT ?? '').split(';').filter((entry) => entry !== '');
|
|
11
|
+
return listed.length > 0 ? listed : WINDOWS_EXTENSIONS;
|
|
12
|
+
};
|
|
13
|
+
/** A file that exists and, off Windows, may be executed. */
|
|
14
|
+
function executable(file) {
|
|
15
|
+
try {
|
|
16
|
+
if (!fs.statSync(file).isFile())
|
|
17
|
+
return false;
|
|
18
|
+
fs.accessSync(file, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The file a command starts. A path (absolute or with a separator) is taken as it is; anything else is looked up
|
|
27
|
+
* on PATH, trying PATHEXT on Windows, so `opencode` finds `opencode.cmd`. Returns null when nothing matches.
|
|
28
|
+
*/
|
|
29
|
+
export function findCommand(command) {
|
|
30
|
+
if (path.isAbsolute(command) || command.includes('/') || command.includes('\\')) {
|
|
31
|
+
return executable(command) ? command : null;
|
|
32
|
+
}
|
|
33
|
+
const given = path.extname(command).toLowerCase();
|
|
34
|
+
const extensions = process.platform === 'win32'
|
|
35
|
+
? windowsExtensions().some((extension) => extension.toLowerCase() === given)
|
|
36
|
+
? ['']
|
|
37
|
+
: windowsExtensions()
|
|
38
|
+
: [''];
|
|
39
|
+
for (const dir of pathEntries()) {
|
|
40
|
+
for (const extension of extensions) {
|
|
41
|
+
const candidate = path.join(dir, command + extension);
|
|
42
|
+
if (executable(candidate))
|
|
43
|
+
return candidate;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** MSVC quoting of one argument: a backslash is literal unless it comes right before a quote. */
|
|
49
|
+
function quoteWindows(value) {
|
|
50
|
+
let quoted = '"';
|
|
51
|
+
let slashes = 0;
|
|
52
|
+
for (const char of value) {
|
|
53
|
+
if (char === '\\') {
|
|
54
|
+
slashes += 1;
|
|
55
|
+
}
|
|
56
|
+
else if (char === '"') {
|
|
57
|
+
quoted += `${'\\'.repeat(slashes * 2 + 1)}"`;
|
|
58
|
+
slashes = 0;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
quoted += '\\'.repeat(slashes) + char;
|
|
62
|
+
slashes = 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return `${quoted}${'\\'.repeat(slashes * 2)}"`;
|
|
66
|
+
}
|
|
67
|
+
/** A `.cmd`/`.bat` shim, started through cmd.exe the way npm's own shims are: one command line, every part quoted. */
|
|
68
|
+
function shimTarget(file, args) {
|
|
69
|
+
// cmd.exe takes a single line, so a line break inside an argument would end the command.
|
|
70
|
+
const line = [file, ...args].map((value) => quoteWindows(value.replace(/[\r\n]+/g, ' '))).join(' ');
|
|
71
|
+
// `/s` strips exactly one pair of outer quotes, so the whole line is wrapped once more.
|
|
72
|
+
return {
|
|
73
|
+
file: process.env.ComSpec ?? 'cmd.exe',
|
|
74
|
+
args: ['/d', '/s', '/c', `"${line}"`],
|
|
75
|
+
path: file,
|
|
76
|
+
throughShell: true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* What to spawn for a command, or null when the command is not installed. Only a Windows shim goes through a shell;
|
|
81
|
+
* everything else is spawned directly, as before.
|
|
82
|
+
*/
|
|
83
|
+
export function spawnTarget(command, args = []) {
|
|
84
|
+
const found = findCommand(command);
|
|
85
|
+
if (found === null)
|
|
86
|
+
return null;
|
|
87
|
+
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(found))
|
|
88
|
+
return shimTarget(found, args);
|
|
89
|
+
return { file: found, args, path: found, throughShell: false };
|
|
90
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
import { type PdfFile } from './pdf.js';
|
|
3
|
+
export type DownloadKind = 'pdf' | 'png' | 'jpeg' | 'zip' | 'csv' | 'any';
|
|
4
|
+
export interface DownloadOptions {
|
|
5
|
+
/** What the file must be; `any` only checks that it is not empty. */
|
|
6
|
+
kind?: DownloadKind;
|
|
7
|
+
/** The suggested file name must match. */
|
|
8
|
+
fileName?: RegExp;
|
|
9
|
+
/** Smallest plausible size in bytes. Default: 1 (a PDF: 500). */
|
|
10
|
+
minBytes?: number;
|
|
11
|
+
/** How long the download may take to start after the trigger, in ms. Default 30 s. */
|
|
12
|
+
startTimeout?: number;
|
|
13
|
+
/** How long it may take to finish once started, in ms. Default 120 s. */
|
|
14
|
+
finishTimeout?: number;
|
|
15
|
+
/** Extra check on the file; throw an Error with a plain message when it is not valid. */
|
|
16
|
+
validate?: (file: {
|
|
17
|
+
path: string;
|
|
18
|
+
bytes: Buffer;
|
|
19
|
+
fileName: string;
|
|
20
|
+
}) => void | Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export interface DownloadResult {
|
|
23
|
+
path: string;
|
|
24
|
+
fileName: string;
|
|
25
|
+
size: number;
|
|
26
|
+
bytes: Buffer;
|
|
27
|
+
/** `kind: 'pdf'` only: the opened PDF (its pages can be rendered and its text read), see `expectPdf` in `lib/pdf`. */
|
|
28
|
+
pdf?: PdfFile;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The one way a spec checks a download. Every stage has its own plain error, and any failure or timeout fails the
|
|
32
|
+
* whole verification:
|
|
33
|
+
* 1. the download starts after `trigger` (the browser reports it),
|
|
34
|
+
* 2. the download finishes,
|
|
35
|
+
* 3. the file exists and has content,
|
|
36
|
+
* 4. the file is valid for its kind (and for `validate`, when given). A PDF must open and have pages; its content is
|
|
37
|
+
* then checked by reading it like a person, with `expectPdf(file.pdf!, {...})` from `lib/pdf`: every page is
|
|
38
|
+
* rendered to an image, the logo areas are compared with the profile's baseline, the extracted text is checked.
|
|
39
|
+
*
|
|
40
|
+
* const file = await verifyDownload(page, () => option.click(), {kind: 'pdf'});
|
|
41
|
+
*/
|
|
42
|
+
export declare function verifyDownload(page: Page, trigger: () => Promise<unknown>, options?: DownloadOptions): Promise<DownloadResult>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { openPdf } from './pdf.js';
|
|
4
|
+
const fail = (message) => {
|
|
5
|
+
throw new Error(message);
|
|
6
|
+
};
|
|
7
|
+
const startsWith = (bytes, signature) => signature.every((byte, index) => bytes[index] === byte);
|
|
8
|
+
/** Content checks per kind: magic bytes first, then the structure that proves the file was written completely. */
|
|
9
|
+
function checkKind(kind, bytes, fileName) {
|
|
10
|
+
if (kind === 'pdf') {
|
|
11
|
+
if (bytes.subarray(0, 5).toString('latin1') !== '%PDF-')
|
|
12
|
+
fail(`The downloaded file "${fileName}" is not a PDF (it does not start with %PDF-)`);
|
|
13
|
+
if (!bytes.subarray(Math.max(0, bytes.length - 2048)).toString('latin1').includes('%%EOF')) {
|
|
14
|
+
fail(`The downloaded PDF "${fileName}" is cut off (it has no end marker)`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
else if (kind === 'png') {
|
|
18
|
+
if (!startsWith(bytes, [0x89, 0x50, 0x4e, 0x47]))
|
|
19
|
+
fail(`The downloaded file "${fileName}" is not a PNG image`);
|
|
20
|
+
}
|
|
21
|
+
else if (kind === 'jpeg') {
|
|
22
|
+
if (!startsWith(bytes, [0xff, 0xd8, 0xff]))
|
|
23
|
+
fail(`The downloaded file "${fileName}" is not a JPEG image`);
|
|
24
|
+
}
|
|
25
|
+
else if (kind === 'zip') {
|
|
26
|
+
if (!startsWith(bytes, [0x50, 0x4b]))
|
|
27
|
+
fail(`The downloaded file "${fileName}" is not a ZIP-based file`);
|
|
28
|
+
}
|
|
29
|
+
else if (kind === 'csv') {
|
|
30
|
+
const head = bytes.subarray(0, 4096).toString('utf8');
|
|
31
|
+
if (head.includes('\u0000') || head.trim() === '')
|
|
32
|
+
fail(`The downloaded file "${fileName}" is not a text (CSV) file`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The one way a spec checks a download. Every stage has its own plain error, and any failure or timeout fails the
|
|
37
|
+
* whole verification:
|
|
38
|
+
* 1. the download starts after `trigger` (the browser reports it),
|
|
39
|
+
* 2. the download finishes,
|
|
40
|
+
* 3. the file exists and has content,
|
|
41
|
+
* 4. the file is valid for its kind (and for `validate`, when given). A PDF must open and have pages; its content is
|
|
42
|
+
* then checked by reading it like a person, with `expectPdf(file.pdf!, {...})` from `lib/pdf`: every page is
|
|
43
|
+
* rendered to an image, the logo areas are compared with the profile's baseline, the extracted text is checked.
|
|
44
|
+
*
|
|
45
|
+
* const file = await verifyDownload(page, () => option.click(), {kind: 'pdf'});
|
|
46
|
+
*/
|
|
47
|
+
export async function verifyDownload(page, trigger, options = {}) {
|
|
48
|
+
const { kind = 'any', fileName: namePattern, startTimeout = 30_000, finishTimeout = 120_000, validate } = options;
|
|
49
|
+
const minBytes = options.minBytes ?? (kind === 'pdf' ? 500 : 1);
|
|
50
|
+
// 1. Start. The waiter exists before the trigger, so a fast download is not missed.
|
|
51
|
+
const started = page.waitForEvent('download', { timeout: startTimeout }).then((download) => ({ download, error: null }), () => ({ download: null, error: new Error(`The download did not start within ${startTimeout / 1000}s after clicking`) }));
|
|
52
|
+
await trigger();
|
|
53
|
+
const { download, error } = await started;
|
|
54
|
+
if (!download)
|
|
55
|
+
throw error;
|
|
56
|
+
const fileName = download.suggestedFilename();
|
|
57
|
+
if (namePattern && !namePattern.test(fileName)) {
|
|
58
|
+
fail(`The download has an unexpected file name "${fileName}" (expected to match ${namePattern})`);
|
|
59
|
+
}
|
|
60
|
+
// 2. Finish. `path()` resolves when the file is complete; `failure()` says why it was not.
|
|
61
|
+
let timer;
|
|
62
|
+
const filePath = await Promise.race([
|
|
63
|
+
download.path(),
|
|
64
|
+
new Promise((_, reject) => {
|
|
65
|
+
timer = setTimeout(() => reject(new Error(`The download "${fileName}" did not finish within ${finishTimeout / 1000}s`)), finishTimeout);
|
|
66
|
+
}),
|
|
67
|
+
]).finally(() => clearTimeout(timer));
|
|
68
|
+
const failure = await download.failure();
|
|
69
|
+
if (failure)
|
|
70
|
+
fail(`The download "${fileName}" failed: ${failure}`);
|
|
71
|
+
if (!filePath)
|
|
72
|
+
fail(`The download "${fileName}" produced no file`);
|
|
73
|
+
// 3. The file exists and has content.
|
|
74
|
+
if (!fs.existsSync(filePath))
|
|
75
|
+
fail(`The downloaded file "${fileName}" does not exist on disk`);
|
|
76
|
+
const bytes = fs.readFileSync(filePath);
|
|
77
|
+
if (bytes.length < minBytes)
|
|
78
|
+
fail(`The downloaded file "${fileName}" is too small (${bytes.length} bytes, expected at least ${minBytes})`);
|
|
79
|
+
// 4. Valid for its kind.
|
|
80
|
+
checkKind(kind, bytes, fileName);
|
|
81
|
+
const pdf = kind === 'pdf' ? await openPdf(bytes) : undefined;
|
|
82
|
+
await validate?.({ path: filePath, bytes, fileName: path.basename(fileName) });
|
|
83
|
+
return { path: filePath, fileName, size: bytes.length, bytes, pdf };
|
|
84
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Profile } from './profiles.js';
|
|
2
|
+
/** Tests never send email in production. In other environments only when a real mailbox is connected (`email: true`). */
|
|
3
|
+
export declare const emailAllowed: (profile: Profile) => boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Call at the start of a step that sends email. Returns false, and records the skip in the report, when
|
|
6
|
+
* email must not be sent here; the step then does nothing.
|
|
7
|
+
*
|
|
8
|
+
* if (!emailStepAllowed(profile, '5. Send the document to the recipient')) return;
|
|
9
|
+
*/
|
|
10
|
+
export declare function emailStepAllowed(profile: Profile, stepTitle: string): boolean;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { test } from '@playwright/test';
|
|
2
|
+
/** Tests never send email in production. In other environments only when a real mailbox is connected (`email: true`). */
|
|
3
|
+
export const emailAllowed = (profile) => profile.env !== 'production' && profile.email;
|
|
4
|
+
/**
|
|
5
|
+
* Call at the start of a step that sends email. Returns false, and records the skip in the report, when
|
|
6
|
+
* email must not be sent here; the step then does nothing.
|
|
7
|
+
*
|
|
8
|
+
* if (!emailStepAllowed(profile, '5. Send the document to the recipient')) return;
|
|
9
|
+
*/
|
|
10
|
+
export function emailStepAllowed(profile, stepTitle) {
|
|
11
|
+
if (emailAllowed(profile))
|
|
12
|
+
return true;
|
|
13
|
+
const reason = profile.env === 'production'
|
|
14
|
+
? 'email is never sent in production'
|
|
15
|
+
: `email is off for ${profile.id} (${profile.env}) (no mailbox connected: email: false in profiles.yaml)`;
|
|
16
|
+
test.info().annotations.push({ type: 'email-step-skipped', description: `${stepTitle}: ${reason}` });
|
|
17
|
+
console.log(` skipped email step "${stepTitle}": ${reason}`);
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The system `ffmpeg` if there is one, else the binary of the optional `ffmpeg-static` package, else null. */
|
|
2
|
+
export declare function findFfmpeg(): string | null;
|
|
3
|
+
/**
|
|
4
|
+
* Joins `inputs` (in order) into one webm at `output`. Re-encodes on purpose: Playwright's recordings have loose
|
|
5
|
+
* timestamps, and copying the streams gives files with a wrong duration that cannot be seeked.
|
|
6
|
+
* Returns an error message, or null on success.
|
|
7
|
+
*/
|
|
8
|
+
export declare function concatVideos(inputs: string[], output: string): string | null;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
let cached;
|
|
7
|
+
/** The system `ffmpeg` if there is one, else the binary of the optional `ffmpeg-static` package, else null. */
|
|
8
|
+
export function findFfmpeg() {
|
|
9
|
+
if (cached !== undefined)
|
|
10
|
+
return cached;
|
|
11
|
+
if (spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0)
|
|
12
|
+
return (cached = 'ffmpeg');
|
|
13
|
+
try {
|
|
14
|
+
const bundled = createRequire(import.meta.url)('ffmpeg-static');
|
|
15
|
+
if (bundled && fs.existsSync(bundled))
|
|
16
|
+
return (cached = bundled);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// ffmpeg-static is optional and may not be installed.
|
|
20
|
+
}
|
|
21
|
+
return (cached = null);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Joins `inputs` (in order) into one webm at `output`. Re-encodes on purpose: Playwright's recordings have loose
|
|
25
|
+
* timestamps, and copying the streams gives files with a wrong duration that cannot be seeked.
|
|
26
|
+
* Returns an error message, or null on success.
|
|
27
|
+
*/
|
|
28
|
+
export function concatVideos(inputs, output) {
|
|
29
|
+
const ffmpeg = findFfmpeg();
|
|
30
|
+
if (!ffmpeg)
|
|
31
|
+
return 'ffmpeg not found (install ffmpeg or the optional ffmpeg-static package)';
|
|
32
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
33
|
+
const listDir = fs.mkdtempSync(path.join(os.tmpdir(), 'asrun-concat-'));
|
|
34
|
+
const list = path.join(listDir, 'inputs.txt');
|
|
35
|
+
try {
|
|
36
|
+
fs.writeFileSync(list, inputs.map((file) => `file '${file.replace(/'/g, "'\\''")}'`).join('\n'));
|
|
37
|
+
const result = spawnSync(ffmpeg, [
|
|
38
|
+
...['-y', '-loglevel', 'error', '-f', 'concat', '-safe', '0', '-i', list],
|
|
39
|
+
...['-vf', 'fps=25', '-c:v', 'libvpx', '-b:v', '0', '-crf', '30', '-deadline', 'realtime', '-cpu-used', '8'],
|
|
40
|
+
...['-an', output],
|
|
41
|
+
], { encoding: 'utf8' });
|
|
42
|
+
if (result.status === 0 && fs.existsSync(output))
|
|
43
|
+
return null;
|
|
44
|
+
fs.rmSync(output, { force: true });
|
|
45
|
+
return (result.error?.message ?? result.stderr?.trim()) || `ffmpeg exited with ${result.status}`;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
fs.rmSync(listDir, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { expect } from '@playwright/test';
|
|
2
|
+
import { type Profile } from './profiles.js';
|
|
3
|
+
/** What a case tells the framework about the data it creates; see `records.track`. */
|
|
4
|
+
export interface TrackedData {
|
|
5
|
+
/** What it is, e.g. "Document draft". */
|
|
6
|
+
kind: string;
|
|
7
|
+
/** Its name in the product. Must start with `[asrun]`, so cleanup can never touch real data. */
|
|
8
|
+
name: string;
|
|
9
|
+
/** Where a person finds it by hand if the cleanup fails, e.g. "/app/documents". */
|
|
10
|
+
where?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface Records {
|
|
13
|
+
/**
|
|
14
|
+
* Register data right after the case created it, with the function that removes it. All registered data
|
|
15
|
+
* is removed after the test, newest first, also when the test failed. A cleanup that fails fails the test
|
|
16
|
+
* and the data is listed as leftover in the run summary. A cleanup function must open its own page
|
|
17
|
+
* (it cannot assume where the test stopped) and must not assume the data still exists.
|
|
18
|
+
*/
|
|
19
|
+
track(data: TrackedData, remove: () => Promise<void>): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
interface TestFixtures {
|
|
22
|
+
sessionExpiryHint: void;
|
|
23
|
+
caseGate: void;
|
|
24
|
+
accountLock: void;
|
|
25
|
+
records: Records;
|
|
26
|
+
}
|
|
27
|
+
interface WorkerFixtures {
|
|
28
|
+
/** The profile of the current Playwright project (the project name is the profile id). */
|
|
29
|
+
profile: Profile;
|
|
30
|
+
/** Set `test.use({mustLogin: false})` in specs for public pages. */
|
|
31
|
+
mustLogin: boolean;
|
|
32
|
+
sessionGuard: void;
|
|
33
|
+
}
|
|
34
|
+
export declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & TestFixtures, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions & WorkerFixtures>;
|
|
35
|
+
/**
|
|
36
|
+
* Call at the top of the `test.describe` of every spec (`configureCase('<id>')`). Cases that write data are
|
|
37
|
+
* never retried (a retry would create the data a second time) and get more time than a read-only case.
|
|
38
|
+
*/
|
|
39
|
+
export declare function configureCase(caseId: string): void;
|
|
40
|
+
export { expect };
|