@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
package/dist/lib/pdf.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { createCanvas } from '@napi-rs/canvas';
|
|
4
|
+
import { expect } from '@playwright/test';
|
|
5
|
+
import { packageDir } from './paths.js';
|
|
6
|
+
let pdfjs;
|
|
7
|
+
const loadPdfjs = () => (pdfjs ??= import('pdfjs-dist/legacy/build/pdf.mjs'));
|
|
8
|
+
// pdf.js loads its fonts, cmaps and wasm files from the package folder, not from the consuming project.
|
|
9
|
+
let pdfjsDir;
|
|
10
|
+
const dirUrl = (dir) => pathToFileURL(path.join((pdfjsDir ??= packageDir('pdfjs-dist')), dir)).href + '/';
|
|
11
|
+
/** A corner of the page. Defaults: 30% of the width and 12% of the height, enough for a logo and nothing else. */
|
|
12
|
+
export function corner(name, size = {}) {
|
|
13
|
+
const width = size.width ?? 0.3;
|
|
14
|
+
const height = size.height ?? 0.12;
|
|
15
|
+
return {
|
|
16
|
+
x: name.endsWith('left') ? 0 : 1 - width,
|
|
17
|
+
y: name.startsWith('top') ? 0 : 1 - height,
|
|
18
|
+
width,
|
|
19
|
+
height,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function selectPages(selection, pageCount) {
|
|
23
|
+
if (selection === 'all')
|
|
24
|
+
return Array.from({ length: pageCount }, (_, i) => i + 1);
|
|
25
|
+
if (selection === 'first')
|
|
26
|
+
return [1];
|
|
27
|
+
if (selection === 'last')
|
|
28
|
+
return [pageCount];
|
|
29
|
+
const outside = selection.filter((n) => !Number.isInteger(n) || n < 1 || n > pageCount);
|
|
30
|
+
if (outside.length > 0)
|
|
31
|
+
throw new Error(`The PDF has ${pageCount} page(s), but the check asks for page ${outside.join(', ')}`);
|
|
32
|
+
return selection;
|
|
33
|
+
}
|
|
34
|
+
export class PdfFile {
|
|
35
|
+
doc;
|
|
36
|
+
task;
|
|
37
|
+
pageCount;
|
|
38
|
+
texts;
|
|
39
|
+
constructor(doc, task, pageCount,
|
|
40
|
+
/** The text of each page, in reading order. */
|
|
41
|
+
texts) {
|
|
42
|
+
this.doc = doc;
|
|
43
|
+
this.task = task;
|
|
44
|
+
this.pageCount = pageCount;
|
|
45
|
+
this.texts = texts;
|
|
46
|
+
}
|
|
47
|
+
/** Renders one page (or a region of it) to a PNG. Scale 2 is about 150 dpi. */
|
|
48
|
+
async render(pageNumber, region, scale = 2) {
|
|
49
|
+
const page = await this.doc.getPage(pageNumber);
|
|
50
|
+
const viewport = page.getViewport({ scale });
|
|
51
|
+
const full = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
|
|
52
|
+
const context = full.getContext('2d');
|
|
53
|
+
context.fillStyle = '#ffffff';
|
|
54
|
+
context.fillRect(0, 0, full.width, full.height);
|
|
55
|
+
await page.render({ canvas: full, canvasContext: context, viewport }).promise;
|
|
56
|
+
let out = full;
|
|
57
|
+
if (region) {
|
|
58
|
+
const x = Math.round(region.x * full.width);
|
|
59
|
+
const y = Math.round(region.y * full.height);
|
|
60
|
+
const width = Math.max(1, Math.round(region.width * full.width));
|
|
61
|
+
const height = Math.max(1, Math.round(region.height * full.height));
|
|
62
|
+
out = createCanvas(width, height);
|
|
63
|
+
out.getContext('2d').drawImage(full, x, y, width, height, 0, 0, width, height);
|
|
64
|
+
}
|
|
65
|
+
const data = out.getContext('2d').getImageData(0, 0, out.width, out.height).data;
|
|
66
|
+
let ink = 0;
|
|
67
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
68
|
+
if (data[i + 3] > 0 && (data[i] < 245 || data[i + 1] < 245 || data[i + 2] < 245))
|
|
69
|
+
ink++;
|
|
70
|
+
}
|
|
71
|
+
return { png: out.toBuffer('image/png'), width: out.width, height: out.height, inkRatio: ink / (out.width * out.height) };
|
|
72
|
+
}
|
|
73
|
+
async close() {
|
|
74
|
+
await this.task.destroy();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export async function openPdf(bytes) {
|
|
78
|
+
const lib = await loadPdfjs();
|
|
79
|
+
let doc;
|
|
80
|
+
const task = lib.getDocument({
|
|
81
|
+
data: new Uint8Array(bytes),
|
|
82
|
+
standardFontDataUrl: dirUrl('standard_fonts'),
|
|
83
|
+
cMapUrl: dirUrl('cmaps'),
|
|
84
|
+
cMapPacked: true,
|
|
85
|
+
iccUrl: dirUrl('iccs'),
|
|
86
|
+
wasmUrl: dirUrl('wasm'),
|
|
87
|
+
useSystemFonts: false,
|
|
88
|
+
verbosity: 0,
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
doc = await task.promise;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw new Error(`The downloaded PDF cannot be opened: ${error.message}`);
|
|
95
|
+
}
|
|
96
|
+
if (doc.numPages < 1)
|
|
97
|
+
throw new Error('The downloaded PDF has no pages');
|
|
98
|
+
const texts = [];
|
|
99
|
+
for (let n = 1; n <= doc.numPages; n++) {
|
|
100
|
+
const content = await (await doc.getPage(n)).getTextContent();
|
|
101
|
+
texts.push(content.items.map((item) => ('str' in item ? item.str : '')).join(' ').replace(/\s+/g, ' ').trim());
|
|
102
|
+
}
|
|
103
|
+
return new PdfFile(doc, task, doc.numPages, texts);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The one way a spec checks the content of a downloaded PDF. For every region check it renders each selected page,
|
|
107
|
+
* fails when the region is blank, and compares the region with the profile's baseline image (the first run with
|
|
108
|
+
* `--update-snapshots` creates it; a person must look at it). Then it checks the extracted text.
|
|
109
|
+
* Every failure names the page and the check. Pair every call with `// target: T<n>` in the spec.
|
|
110
|
+
*
|
|
111
|
+
* const pdf = await openPdf(file.bytes);
|
|
112
|
+
* await expectPdf(pdf, {regions: [{name: 'logo', pages: 'first', region: corner('top-left')}], textNotContains: otherProfileNames});
|
|
113
|
+
*/
|
|
114
|
+
export async function expectPdf(pdf, checks) {
|
|
115
|
+
const { regions = [], textContains = [], textNotContains = [], minPages, scale = 2 } = checks;
|
|
116
|
+
if (minPages !== undefined)
|
|
117
|
+
expect(pdf.pageCount, `The PDF has too few pages (${pdf.pageCount}, expected at least ${minPages})`).toBeGreaterThanOrEqual(minPages);
|
|
118
|
+
for (const check of regions) {
|
|
119
|
+
for (const page of selectPages(check.pages, pdf.pageCount)) {
|
|
120
|
+
const shown = await pdf.render(page, check.region, scale);
|
|
121
|
+
if (check.notBlank ?? true) {
|
|
122
|
+
expect(shown.inkRatio, `The "${check.name}" area on page ${page} of the PDF is blank (nothing is drawn there)`).toBeGreaterThan(0.002);
|
|
123
|
+
}
|
|
124
|
+
if (check.baseline ?? true) {
|
|
125
|
+
expect(shown.png, `The "${check.name}" area on page ${page} of the PDF does not look right`).toMatchSnapshot(`${check.name}-p${page}.png`, {
|
|
126
|
+
maxDiffPixelRatio: 0.02,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const all = pdf.texts.join(' \n ').toLowerCase();
|
|
132
|
+
for (const text of textContains) {
|
|
133
|
+
expect(all.includes(text.toLowerCase()), `The PDF does not contain the text "${text}"`).toBe(true);
|
|
134
|
+
}
|
|
135
|
+
for (const text of textNotContains) {
|
|
136
|
+
const lower = text.toLowerCase();
|
|
137
|
+
const page = pdf.texts.findIndex((value) => value.toLowerCase().includes(lower));
|
|
138
|
+
expect(page, `The PDF shows the text "${text}" on page ${page + 1}, which does not belong to this profile`).toBe(-1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export declare const DEFAULT_SESSION_CHECK_PATH = "/account/";
|
|
2
|
+
/** The page "Visit" opens by default: the site root. */
|
|
3
|
+
export declare const DEFAULT_START_PATH = "";
|
|
4
|
+
/**
|
|
5
|
+
* How to tell "signed in" from "signed out" on one site. A site can say it in different ways: a redirect to
|
|
6
|
+
* a login page (server or client side), a 401/403, a login form on the same address, or a "you must sign in"
|
|
7
|
+
* message. Every profile names the signal it has; the defaults cover the most common one, a redirect to `/login`.
|
|
8
|
+
*/
|
|
9
|
+
export interface SessionCheck {
|
|
10
|
+
/** The address a visitor without a valid session is sent to: a path on the site, or a full URL (single sign-on). */
|
|
11
|
+
loggedOutUrl: string;
|
|
12
|
+
/** A selector that is only on the page while signed in, e.g. the username in the top bar. The most reliable signal. */
|
|
13
|
+
loggedInSelector: string | null;
|
|
14
|
+
/** A selector that shows the visitor is not signed in, e.g. the password field of the login form. */
|
|
15
|
+
loggedOutSelector: string | null;
|
|
16
|
+
/** How long to wait for one of those signals before closing the page again. */
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
}
|
|
19
|
+
export declare const DEFAULT_SESSION_CHECK: SessionCheck;
|
|
20
|
+
export declare const PRODUCTION = "production";
|
|
21
|
+
export declare const STAGING = "staging";
|
|
22
|
+
export declare const PROFILES_FILE: string;
|
|
23
|
+
/**
|
|
24
|
+
* One entry of profiles.yaml: one environment of the system under test. It holds only connection
|
|
25
|
+
* settings. Expected values belong to the case document that checks them (see `lib/values.ts`).
|
|
26
|
+
*/
|
|
27
|
+
export interface Profile {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
/** The environment this profile points at, e.g. `production` or `staging`. */
|
|
31
|
+
env: string;
|
|
32
|
+
baseURL: string;
|
|
33
|
+
/** A label for humans (a mailbox or username). Never a password. */
|
|
34
|
+
account: string | null;
|
|
35
|
+
/** Free-form labels for grouping and `--tag`; lowercase words. */
|
|
36
|
+
tags: string[];
|
|
37
|
+
/** Tests may send real email in this environment. Never true in production. */
|
|
38
|
+
email: boolean;
|
|
39
|
+
/** The page "Visit" opens after a login, e.g. `/app/home`; empty = the site root. */
|
|
40
|
+
startPath: string;
|
|
41
|
+
/** A page that only a signed-in visitor sees; the page "Verify" opens. */
|
|
42
|
+
sessionCheckPath: string;
|
|
43
|
+
/** How that page shows a missing or expired login (see `SessionCheck`). */
|
|
44
|
+
sessionCheck: SessionCheck;
|
|
45
|
+
}
|
|
46
|
+
/** The Playwright project name of a profile: its id. */
|
|
47
|
+
export declare const projectName: (profile: {
|
|
48
|
+
id: string;
|
|
49
|
+
}) => string;
|
|
50
|
+
export declare const profileLabel: (profile: {
|
|
51
|
+
id: string;
|
|
52
|
+
env: string;
|
|
53
|
+
}) => string;
|
|
54
|
+
export declare function validateSessionCheck(where: string, raw: unknown): SessionCheck;
|
|
55
|
+
export declare function loadProfiles(file?: string): Profile[];
|
|
56
|
+
/** The profiles of one environment (`env` omitted: all of them), production first. */
|
|
57
|
+
export declare const listProfiles: (env?: string, profiles?: Profile[]) => Profile[];
|
|
58
|
+
export declare function getProfile(id: string, profiles?: Profile[]): Profile;
|
|
59
|
+
/** The profiles that carry this tag. */
|
|
60
|
+
export declare const listProfilesWithTag: (tag: string, env?: string, profiles?: Profile[]) => Profile[];
|
|
61
|
+
/** Every environment used by a profile: production, staging, then the rest. */
|
|
62
|
+
export declare const listEnvs: (profiles?: Profile[]) => string[];
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
export const DEFAULT_SESSION_CHECK_PATH = '/account/';
|
|
7
|
+
/** The page "Visit" opens by default: the site root. */
|
|
8
|
+
export const DEFAULT_START_PATH = '';
|
|
9
|
+
export const DEFAULT_SESSION_CHECK = {
|
|
10
|
+
loggedOutUrl: '/login',
|
|
11
|
+
loggedInSelector: null,
|
|
12
|
+
loggedOutSelector: null,
|
|
13
|
+
timeoutMs: 3000,
|
|
14
|
+
};
|
|
15
|
+
export const PRODUCTION = 'production';
|
|
16
|
+
export const STAGING = 'staging';
|
|
17
|
+
export const PROFILES_FILE = path.join(ROOT, 'profiles.yaml');
|
|
18
|
+
/** The Playwright project name of a profile: its id. */
|
|
19
|
+
export const projectName = (profile) => profile.id;
|
|
20
|
+
export const profileLabel = (profile) => `${profile.id} (${profile.env})`;
|
|
21
|
+
function validateTags(where, raw) {
|
|
22
|
+
if (raw === undefined || raw === null)
|
|
23
|
+
return [];
|
|
24
|
+
if (!Array.isArray(raw) || raw.some((tag) => typeof tag !== 'string' || !NAME_PATTERN.test(tag))) {
|
|
25
|
+
throw new Error(`${where}: "tags" must be a list of lowercase words (letters, digits, dashes), e.g. [us, core]`);
|
|
26
|
+
}
|
|
27
|
+
const duplicate = raw.find((tag, index) => raw.indexOf(tag) !== index);
|
|
28
|
+
if (duplicate)
|
|
29
|
+
throw new Error(`${where}: "tags" lists "${duplicate}" twice`);
|
|
30
|
+
return raw;
|
|
31
|
+
}
|
|
32
|
+
function validateSiteAddress(where, value) {
|
|
33
|
+
if (typeof value !== 'string' || value === '')
|
|
34
|
+
throw new Error(`${where} is missing "baseURL"`);
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(value);
|
|
37
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
38
|
+
throw new Error('protocol');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new Error(`${where}: "baseURL" must be a full http(s) URL, e.g. https://www.example.com`);
|
|
42
|
+
}
|
|
43
|
+
return value.replace(/\/$/, '');
|
|
44
|
+
}
|
|
45
|
+
export function validateSessionCheck(where, raw) {
|
|
46
|
+
if (raw === undefined || raw === null)
|
|
47
|
+
return DEFAULT_SESSION_CHECK;
|
|
48
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
49
|
+
throw new Error(`${where}: "sessionCheck" must be a mapping with "loggedOutUrl", "loggedInSelector", "loggedOutSelector" or "timeoutMs"`);
|
|
50
|
+
}
|
|
51
|
+
const map = raw;
|
|
52
|
+
const known = ['loggedOutUrl', 'loggedInSelector', 'loggedOutSelector', 'timeoutMs'];
|
|
53
|
+
const unknown = Object.keys(map).find((key) => !known.includes(key));
|
|
54
|
+
if (unknown) {
|
|
55
|
+
throw new Error(`${where}: "sessionCheck" has an unknown key "${unknown}". Known: ${known.join(', ')}`);
|
|
56
|
+
}
|
|
57
|
+
const loggedOutUrl = map.loggedOutUrl ?? DEFAULT_SESSION_CHECK.loggedOutUrl;
|
|
58
|
+
if (typeof loggedOutUrl !== 'string' ||
|
|
59
|
+
(!loggedOutUrl.startsWith('/') && !/^https?:\/\//i.test(loggedOutUrl)) ||
|
|
60
|
+
/\s/.test(loggedOutUrl)) {
|
|
61
|
+
throw new Error(`${where}: "sessionCheck.loggedOutUrl" must be a path starting with "/" (e.g. /login) or a full http(s) URL`);
|
|
62
|
+
}
|
|
63
|
+
const selector = (key) => {
|
|
64
|
+
const value = map[key];
|
|
65
|
+
if (value === undefined || value === null || value === '')
|
|
66
|
+
return null;
|
|
67
|
+
if (typeof value !== 'string') {
|
|
68
|
+
throw new Error(`${where}: "sessionCheck.${key}" must be a selector such as "input[name='username']"`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
};
|
|
72
|
+
const timeoutMs = map.timeoutMs ?? DEFAULT_SESSION_CHECK.timeoutMs;
|
|
73
|
+
if (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs < 500 || timeoutMs > 60000) {
|
|
74
|
+
throw new Error(`${where}: "sessionCheck.timeoutMs" must be a whole number of milliseconds between 500 and 60000`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
loggedOutUrl,
|
|
78
|
+
loggedInSelector: selector('loggedInSelector'),
|
|
79
|
+
loggedOutSelector: selector('loggedOutSelector'),
|
|
80
|
+
timeoutMs,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function validate(raw, index) {
|
|
84
|
+
const label = `profiles.yaml: profile #${index + 1}`;
|
|
85
|
+
for (const key of ['id', 'name', 'env']) {
|
|
86
|
+
if (typeof raw?.[key] !== 'string' || raw[key] === '') {
|
|
87
|
+
throw new Error(`${label} is missing "${key}"`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const where = `profiles.yaml: profile "${raw.id}"`;
|
|
91
|
+
if (!NAME_PATTERN.test(raw.id)) {
|
|
92
|
+
throw new Error(`${where}: "id" must be lowercase letters and digits, separated by single dashes (e.g. "example-staging")`);
|
|
93
|
+
}
|
|
94
|
+
if (!NAME_PATTERN.test(raw.env)) {
|
|
95
|
+
throw new Error(`${where}: "env" must be lowercase letters, digits and single dashes (e.g. "production", "staging")`);
|
|
96
|
+
}
|
|
97
|
+
if (raw.envs !== undefined) {
|
|
98
|
+
throw new Error(`${where} has an "envs" map. Each profile is one environment: give it an "env" and a "baseURL" instead.`);
|
|
99
|
+
}
|
|
100
|
+
if (raw.baseURL === undefined) {
|
|
101
|
+
throw new Error(`${where} is missing "baseURL" (the site address of ${raw.env})`);
|
|
102
|
+
}
|
|
103
|
+
const baseURL = validateSiteAddress(where, raw.baseURL);
|
|
104
|
+
if (raw.account !== undefined && raw.account !== null && typeof raw.account !== 'string') {
|
|
105
|
+
throw new Error(`${where}: "account" must be text (a mailbox or username), or omitted`);
|
|
106
|
+
}
|
|
107
|
+
if (raw.email !== undefined && typeof raw.email !== 'boolean') {
|
|
108
|
+
throw new Error(`${where}: "email" must be true or false`);
|
|
109
|
+
}
|
|
110
|
+
if (raw.env === PRODUCTION && raw.email === true) {
|
|
111
|
+
throw new Error(`${where}: "email: true" is not allowed. Tests never send email in production.`);
|
|
112
|
+
}
|
|
113
|
+
const sessionCheckPath = raw.sessionCheckPath ?? DEFAULT_SESSION_CHECK_PATH;
|
|
114
|
+
if (typeof sessionCheckPath !== 'string' || !sessionCheckPath.startsWith('/') || /\s/.test(sessionCheckPath)) {
|
|
115
|
+
throw new Error(`${where}: "sessionCheckPath" must start with "/" and contain no spaces`);
|
|
116
|
+
}
|
|
117
|
+
const startPath = raw.startPath ?? DEFAULT_START_PATH;
|
|
118
|
+
if (typeof startPath !== 'string' ||
|
|
119
|
+
(startPath !== '' && (!startPath.startsWith('/') || startPath.startsWith('//') || /\s/.test(startPath)))) {
|
|
120
|
+
throw new Error(`${where}: "startPath" must be empty (the site root) or a path on the site that starts with a single "/" and has no spaces`);
|
|
121
|
+
}
|
|
122
|
+
const account = typeof raw.account === 'string' && raw.account.trim() !== '' ? raw.account.trim() : null;
|
|
123
|
+
return {
|
|
124
|
+
id: raw.id,
|
|
125
|
+
name: raw.name,
|
|
126
|
+
env: raw.env,
|
|
127
|
+
baseURL,
|
|
128
|
+
account,
|
|
129
|
+
tags: validateTags(where, raw.tags),
|
|
130
|
+
email: raw.email === true,
|
|
131
|
+
startPath,
|
|
132
|
+
sessionCheckPath,
|
|
133
|
+
sessionCheck: validateSessionCheck(where, raw.sessionCheck),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export function loadProfiles(file = PROFILES_FILE) {
|
|
137
|
+
if (!fs.existsSync(file)) {
|
|
138
|
+
throw new Error(`profiles.yaml is missing. Create it in the project root (see the comments in the file for the fields).`);
|
|
139
|
+
}
|
|
140
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
141
|
+
const parsed = parse(text, { merge: true });
|
|
142
|
+
const raw = parsed?.profiles;
|
|
143
|
+
if (!Array.isArray(raw)) {
|
|
144
|
+
throw new Error('profiles.yaml needs a "profiles:" list (it may be empty while you set the suite up).');
|
|
145
|
+
}
|
|
146
|
+
const profiles = raw.map(validate);
|
|
147
|
+
const ids = profiles.map((profile) => profile.id);
|
|
148
|
+
const duplicate = ids.find((id, index) => ids.indexOf(id) !== index);
|
|
149
|
+
if (duplicate)
|
|
150
|
+
throw new Error(`profiles.yaml: duplicate profile id "${duplicate}"`);
|
|
151
|
+
return profiles;
|
|
152
|
+
}
|
|
153
|
+
function sortProfiles(profiles) {
|
|
154
|
+
const rank = (env) => (env === PRODUCTION ? 0 : env === STAGING ? 1 : 2);
|
|
155
|
+
return [...profiles].sort((a, b) => rank(a.env) - rank(b.env) || a.id.localeCompare(b.id));
|
|
156
|
+
}
|
|
157
|
+
/** The profiles of one environment (`env` omitted: all of them), production first. */
|
|
158
|
+
export const listProfiles = (env, profiles = loadProfiles()) => sortProfiles(profiles.filter((profile) => !env || profile.env === env));
|
|
159
|
+
export function getProfile(id, profiles = loadProfiles()) {
|
|
160
|
+
const profile = profiles.find((candidate) => candidate.id === id);
|
|
161
|
+
if (!profile) {
|
|
162
|
+
const known = profiles.map((candidate) => candidate.id).join(', ') || '(none)';
|
|
163
|
+
throw new Error(`Unknown profile "${id}". Known profiles: ${known}. Check profiles.yaml.`);
|
|
164
|
+
}
|
|
165
|
+
return profile;
|
|
166
|
+
}
|
|
167
|
+
/** The profiles that carry this tag. */
|
|
168
|
+
export const listProfilesWithTag = (tag, env, profiles = loadProfiles()) => listProfiles(env, profiles).filter((profile) => profile.tags.includes(tag));
|
|
169
|
+
/** Every environment used by a profile: production, staging, then the rest. */
|
|
170
|
+
export const listEnvs = (profiles = loadProfiles()) => {
|
|
171
|
+
const envs = [...new Set(profiles.map((profile) => profile.env))];
|
|
172
|
+
const rank = (env) => (env === PRODUCTION ? 0 : env === STAGING ? 1 : 2);
|
|
173
|
+
return envs.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
174
|
+
};
|
|
@@ -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,38 @@
|
|
|
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
|
+
/**
|
|
5
|
+
* Playwright matches `--grep` against a title that holds the spec file with the separator of this platform
|
|
6
|
+
* (`conduit/header-navigation.spec.ts` on macOS and Linux, `conduit\header-navigation.spec.ts` on Windows), so a
|
|
7
|
+
* `/` in a case id or in the user's search has to match either one. `\/` is left alone: it is already a plain `/`.
|
|
8
|
+
*/
|
|
9
|
+
const bothSeparators = (text) => text.replace(/(?<!\\)\//g, '[\\\\/]');
|
|
10
|
+
/** Matches the spec file of one case (`documents/published-flow` must not match `documents/published-flow-2`). */
|
|
11
|
+
export const casePattern = (id) => `(?:^|\\s)${bothSeparators(escapeRegExp(id))}\\.spec\\.ts`;
|
|
12
|
+
/**
|
|
13
|
+
* The Playwright arguments that decide WHICH tests run. Shared by `pnpm asrun` and by the web UI's
|
|
14
|
+
* "what would this run do" preview, so both always agree.
|
|
15
|
+
*/
|
|
16
|
+
export function filterArgs(filters) {
|
|
17
|
+
// Playwright accepts a single --grep and matches it against the file, titles and tags, so the
|
|
18
|
+
// suite filter and the user's own --grep are combined into one regular expression.
|
|
19
|
+
const grepParts = [];
|
|
20
|
+
if (filters.suites.length > 0) {
|
|
21
|
+
grepParts.push(`(?:${filters.suites.map(tagPattern).join('|')})`);
|
|
22
|
+
}
|
|
23
|
+
if (filters.grep) {
|
|
24
|
+
// A case id (`conduit/header-navigation`) and a part of one (`conduit/head`) both have to match the spec file
|
|
25
|
+
// in Playwright's title, whatever separator this platform puts in it.
|
|
26
|
+
grepParts.push(`(?:${bothSeparators(filters.grep)})`);
|
|
27
|
+
}
|
|
28
|
+
if (filters.only && filters.only.length > 0) {
|
|
29
|
+
grepParts.push(`(?:${filters.only.map(casePattern).join('|')})`);
|
|
30
|
+
}
|
|
31
|
+
const invert = ['@seed', ...(filters.readOnly ? ['@writes'] : [])].join('|');
|
|
32
|
+
return [
|
|
33
|
+
...(filters.profiles ? filters.profiles.flatMap((id) => ['--project', id]) : []),
|
|
34
|
+
...(grepParts.length > 0 ? ['--grep', grepParts.map((part) => `(?=.*${part})`).join('')] : []),
|
|
35
|
+
'--grep-invert',
|
|
36
|
+
invert,
|
|
37
|
+
];
|
|
38
|
+
}
|