@asrunjs/asrun 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/explore-case.md +15 -0
- package/.claude/commands/gen-spec.md +13 -0
- package/.claude/commands/heal-spec.md +23 -0
- package/.claude/commands/make-case.md +8 -0
- package/.claude/settings.json +5 -0
- package/.claude/skills/exploring-cases/SKILL.md +132 -0
- package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -0
- package/.claude/skills/exploring-cases/references/handoff-card.js +128 -0
- package/.claude/skills/exploring-cases/references/open-questions.md +112 -0
- package/.claude/skills/exploring-cases/references/readiness.md +137 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +147 -0
- package/.claude/skills/generating-specs/SKILL.md +207 -0
- package/.claude/skills/generating-specs/references/conventions.md +148 -0
- package/.claude/skills/making-cases/SKILL.md +64 -0
- package/.claude/skills/making-cases/references/gaps.md +36 -0
- package/.claude/skills/making-cases/references/normalizing.md +38 -0
- package/.claude/skills/making-cases/references/splitting.md +28 -0
- package/.claude/skills/making-cases/references/writes-and-email.md +36 -0
- package/AGENTS.md +154 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +410 -3
- package/bin/asrun.mjs +49 -0
- package/dist/lib/account-lock.d.ts +6 -0
- package/dist/lib/account-lock.js +52 -0
- package/dist/lib/assertions.d.ts +14 -0
- package/dist/lib/assertions.js +39 -0
- package/dist/lib/baselines.d.ts +43 -0
- package/dist/lib/baselines.js +108 -0
- package/dist/lib/case-id.d.ts +11 -0
- package/dist/lib/case-id.js +17 -0
- package/dist/lib/case-ops.d.ts +26 -0
- package/dist/lib/case-ops.js +110 -0
- package/dist/lib/cases.d.ts +29 -0
- package/dist/lib/cases.js +124 -0
- package/dist/lib/cli.d.ts +10 -0
- package/dist/lib/cli.js +18 -0
- package/dist/lib/downloads.d.ts +42 -0
- package/dist/lib/downloads.js +84 -0
- package/dist/lib/email.d.ts +10 -0
- package/dist/lib/email.js +19 -0
- package/dist/lib/ffmpeg.d.ts +8 -0
- package/dist/lib/ffmpeg.js +50 -0
- package/dist/lib/fixtures.d.ts +40 -0
- package/dist/lib/fixtures.js +149 -0
- package/dist/lib/node-cli.d.ts +11 -0
- package/dist/lib/node-cli.js +26 -0
- package/dist/lib/paths.d.ts +25 -0
- package/dist/lib/paths.js +88 -0
- package/dist/lib/pdf.d.ts +71 -0
- package/dist/lib/pdf.js +140 -0
- package/dist/lib/profiles.d.ts +38 -0
- package/dist/lib/profiles.js +121 -0
- package/dist/lib/ready.d.ts +36 -0
- package/dist/lib/ready.js +53 -0
- package/dist/lib/records.d.ts +33 -0
- package/dist/lib/records.js +61 -0
- package/dist/lib/run-filters.d.ts +19 -0
- package/dist/lib/run-filters.js +30 -0
- package/dist/lib/run-summary.d.ts +90 -0
- package/dist/lib/run-summary.js +35 -0
- package/dist/lib/session.d.ts +18 -0
- package/dist/lib/session.js +45 -0
- package/dist/lib/summary-reporter.d.ts +16 -0
- package/dist/lib/summary-reporter.js +146 -0
- package/dist/lib/targets.d.ts +27 -0
- package/dist/lib/targets.js +51 -0
- package/dist/lib/ui-build.d.ts +10 -0
- package/dist/lib/ui-build.js +30 -0
- package/dist/lib/values.d.ts +29 -0
- package/dist/lib/values.js +85 -0
- package/dist/lib/write-gate.d.ts +41 -0
- package/dist/lib/write-gate.js +129 -0
- package/dist/playwright.config.d.ts +2 -0
- package/dist/playwright.config.js +92 -0
- package/dist/scripts/build.d.ts +1 -0
- package/dist/scripts/build.js +20 -0
- package/dist/scripts/check-cases.d.ts +1 -0
- package/dist/scripts/check-cases.js +81 -0
- package/dist/scripts/cleanup.d.ts +1 -0
- package/dist/scripts/cleanup.js +25 -0
- package/dist/scripts/cli.d.ts +1 -0
- package/dist/scripts/cli.js +28 -0
- package/dist/scripts/doctor.d.ts +1 -0
- package/dist/scripts/doctor.js +185 -0
- package/dist/scripts/explore.d.ts +1 -0
- package/dist/scripts/explore.js +54 -0
- package/dist/scripts/go.mjs +188 -0
- package/dist/scripts/heal-evidence.mjs +143 -0
- package/dist/scripts/init.d.ts +16 -0
- package/dist/scripts/init.js +284 -0
- package/dist/scripts/lint-specs.d.ts +1 -0
- package/dist/scripts/lint-specs.js +138 -0
- package/dist/scripts/login.d.ts +1 -0
- package/dist/scripts/login.js +47 -0
- package/dist/scripts/new-case.d.ts +1 -0
- package/dist/scripts/new-case.js +19 -0
- package/dist/scripts/render-pdf.d.ts +1 -0
- package/dist/scripts/render-pdf.js +44 -0
- package/dist/scripts/report.d.ts +1 -0
- package/dist/scripts/report.js +14 -0
- package/dist/scripts/review-baselines.d.ts +1 -0
- package/dist/scripts/review-baselines.js +30 -0
- package/dist/scripts/run.d.ts +1 -0
- package/dist/scripts/run.js +423 -0
- package/dist/scripts/status.d.ts +1 -0
- package/dist/scripts/status.js +31 -0
- package/dist/scripts/ui-build.d.ts +1 -0
- package/dist/scripts/ui-build.js +7 -0
- package/dist/scripts/ui-dev.d.ts +1 -0
- package/dist/scripts/ui-dev.js +3 -0
- package/dist/scripts/ui.d.ts +1 -0
- package/dist/scripts/ui.js +13 -0
- package/dist/ui/dev.d.ts +1 -0
- package/dist/ui/dev.js +36 -0
- package/dist/ui/server/actions.d.ts +91 -0
- package/dist/ui/server/actions.js +352 -0
- package/dist/ui/server/ai-stream.d.ts +41 -0
- package/dist/ui/server/ai-stream.js +202 -0
- package/dist/ui/server/app.d.ts +4 -0
- package/dist/ui/server/app.js +333 -0
- package/dist/ui/server/cases.d.ts +23 -0
- package/dist/ui/server/cases.js +170 -0
- package/dist/ui/server/files.d.ts +5 -0
- package/dist/ui/server/files.js +67 -0
- package/dist/ui/server/index.d.ts +1 -0
- package/dist/ui/server/index.js +33 -0
- package/dist/ui/server/jobs.d.ts +74 -0
- package/dist/ui/server/jobs.js +263 -0
- package/dist/ui/server/profiles.d.ts +16 -0
- package/dist/ui/server/profiles.js +195 -0
- package/dist/ui/server/watch-files.d.ts +4 -0
- package/dist/ui/server/watch-files.js +36 -0
- package/dist/ui/shared/ai-commands.d.ts +78 -0
- package/dist/ui/shared/ai-commands.js +154 -0
- package/dist/ui/shared/case-id.d.ts +1 -0
- package/dist/ui/shared/case-id.js +1 -0
- package/dist/ui/shared/jobs.d.ts +6 -0
- package/dist/ui/shared/jobs.js +7 -0
- package/dist/ui/shared/product.d.ts +3 -0
- package/dist/ui/shared/product.js +3 -0
- package/dist/ui/shared/run-filters.d.ts +1 -0
- package/dist/ui/shared/run-filters.js +1 -0
- package/dist/ui/shared/site-address.d.ts +6 -0
- package/dist/ui/shared/site-address.js +11 -0
- package/dist/ui/shared/types.d.ts +257 -0
- package/dist/ui/shared/types.js +1 -0
- package/docs/theme-guide.html +692 -0
- package/package.json +98 -3
- package/templates/.claude/commands/explore-case.md +21 -0
- package/templates/.claude/commands/gen-spec.md +20 -0
- package/templates/.claude/commands/heal-spec.md +30 -0
- package/templates/.claude/commands/make-case.md +12 -0
- package/templates/.claude/settings.json +5 -0
- package/templates/.mcp.json +8 -0
- package/templates/AGENTS.md +45 -0
- package/templates/CLAUDE.md +1 -0
- package/templates/cases/_template.md +63 -0
- package/templates/gitignore +13 -0
- package/templates/opencode.jsonc +11 -0
- package/templates/package.json +31 -0
- package/templates/playwright.config.ts +3 -0
- package/templates/pnpm-workspace.yaml +5 -0
- package/templates/profiles.yaml +29 -0
- package/templates/seed.spec.ts +9 -0
- package/templates/tsconfig.json +14 -0
- package/ui/dist/apple-touch-icon.png +0 -0
- package/ui/dist/assets/apl-gfMMiTNf.js +1 -0
- package/ui/dist/assets/asciiarmor-C_eisQCp.js +1 -0
- package/ui/dist/assets/asn1-DCl_8MCW.js +1 -0
- package/ui/dist/assets/asterisk-BEn0Cwpx.js +1 -0
- package/ui/dist/assets/brainfuck-D5EjA2JK.js +1 -0
- package/ui/dist/assets/clike-xvONpD0Z.js +1 -0
- package/ui/dist/assets/clojure-BflJGmDX.js +1 -0
- package/ui/dist/assets/cmake-84nbDH0I.js +1 -0
- package/ui/dist/assets/cobol-DHLQUbN7.js +1 -0
- package/ui/dist/assets/coffeescript-De_zI4J0.js +1 -0
- package/ui/dist/assets/commonlisp-DgAKBe0r.js +1 -0
- package/ui/dist/assets/crystal-Bl6YKCG_.js +1 -0
- package/ui/dist/assets/css-CnaYoQib.js +1 -0
- package/ui/dist/assets/cypher-p9eYesGn.js +1 -0
- package/ui/dist/assets/d-DWkQyNnU.js +1 -0
- package/ui/dist/assets/diff-ChtP43wD.js +1 -0
- package/ui/dist/assets/dist-BHaIOZdW.js +1 -0
- package/ui/dist/assets/dist-BSJMM_jp.js +1 -0
- package/ui/dist/assets/dist-BiE4suo9.js +1 -0
- package/ui/dist/assets/dist-BqAROxzI.js +1 -0
- package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
- package/ui/dist/assets/dist-BwYRbe5s.js +9 -0
- package/ui/dist/assets/dist-BzulKuec.js +1 -0
- package/ui/dist/assets/dist-CBV7_vDN.js +2 -0
- package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
- package/ui/dist/assets/dist-DDJNvivM.js +1 -0
- package/ui/dist/assets/dist-DF3F_I8N.js +1 -0
- package/ui/dist/assets/dist-DHCY9KpV.js +1 -0
- package/ui/dist/assets/dist-DX8knLVr.js +1 -0
- package/ui/dist/assets/dist-D_I45xgJ.js +11 -0
- package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
- package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
- package/ui/dist/assets/dist-gF2bFn-5.js +1 -0
- package/ui/dist/assets/dockerfile-DJkHQgkl.js +1 -0
- package/ui/dist/assets/dtd-DTF72YNO.js +1 -0
- package/ui/dist/assets/dylan-S7ucR4pR.js +1 -0
- package/ui/dist/assets/ebnf-D4c0_ac3.js +1 -0
- package/ui/dist/assets/ecl-BVIeWmwY.js +1 -0
- package/ui/dist/assets/eiffel-BEjRio4Q.js +1 -0
- package/ui/dist/assets/elm-Cshzl8qu.js +1 -0
- package/ui/dist/assets/erlang-BV0c4Hdn.js +1 -0
- package/ui/dist/assets/factor-LKH5gN8L.js +1 -0
- package/ui/dist/assets/fcl-ClOhbFR7.js +1 -0
- package/ui/dist/assets/forth-D6RzAl64.js +1 -0
- package/ui/dist/assets/fortran-Bt6PBEDR.js +1 -0
- package/ui/dist/assets/gas-BHEdbvp9.js +1 -0
- package/ui/dist/assets/gherkin-oBAE_ms0.js +1 -0
- package/ui/dist/assets/groovy-CUrWKPia.js +1 -0
- package/ui/dist/assets/haskell-BRsoo5mP.js +1 -0
- package/ui/dist/assets/haxe-DjVOVvNP.js +1 -0
- package/ui/dist/assets/http-CLVgA2GD.js +1 -0
- package/ui/dist/assets/idl-BUZw3wgd.js +1 -0
- package/ui/dist/assets/index-DmasQ6Rq.css +2 -0
- package/ui/dist/assets/index-I7uU93kk.js +294 -0
- package/ui/dist/assets/javascript-lS2r2ifg.js +1 -0
- package/ui/dist/assets/julia-CQfgDRfP.js +1 -0
- package/ui/dist/assets/livescript-BXxG0Yva.js +1 -0
- package/ui/dist/assets/lua-DPCEkfD_.js +1 -0
- package/ui/dist/assets/mathematica-u8YMmbU0.js +1 -0
- package/ui/dist/assets/mbox-BEMVcqjs.js +1 -0
- package/ui/dist/assets/mirc-C9zpzAZU.js +1 -0
- package/ui/dist/assets/mllike-B45xgp2S.js +1 -0
- package/ui/dist/assets/modelica-CqHI_q-D.js +1 -0
- package/ui/dist/assets/mscgen-qgSLujhx.js +1 -0
- package/ui/dist/assets/mumps-BSzj6Xyz.js +1 -0
- package/ui/dist/assets/nginx-C-ORUuei.js +1 -0
- package/ui/dist/assets/nsis-viPvXFiq.js +1 -0
- package/ui/dist/assets/ntriples-CzMBulkp.js +1 -0
- package/ui/dist/assets/octave-D2Q8cUp1.js +1 -0
- package/ui/dist/assets/oz-BwTct_5T.js +1 -0
- package/ui/dist/assets/pascal-Dp_KdFgX.js +1 -0
- package/ui/dist/assets/perl-BkSkiI9m.js +1 -0
- package/ui/dist/assets/pig-AF4Hwhju.js +1 -0
- package/ui/dist/assets/powershell-D8WpQPaX.js +1 -0
- package/ui/dist/assets/properties-C357ku5U.js +1 -0
- package/ui/dist/assets/protobuf-D1ij_kNL.js +1 -0
- package/ui/dist/assets/pug-BqCHZ5xT.js +1 -0
- package/ui/dist/assets/puppet-BZyAS7I4.js +1 -0
- package/ui/dist/assets/python-B_1Jd8HM.js +1 -0
- package/ui/dist/assets/q-B9NhS7L_.js +1 -0
- package/ui/dist/assets/r-CxDmU1I4.js +1 -0
- package/ui/dist/assets/rpm-gpDK5sbt.js +1 -0
- package/ui/dist/assets/ruby-ChfNwlCY.js +1 -0
- package/ui/dist/assets/sas-C2OyD2Y6.js +1 -0
- package/ui/dist/assets/scheme-D9qYN0V5.js +1 -0
- package/ui/dist/assets/shell-DwuoZtxw.js +1 -0
- package/ui/dist/assets/sieve-tLWDfEkZ.js +1 -0
- package/ui/dist/assets/simple-mode-DXiLM1-G.js +1 -0
- package/ui/dist/assets/smalltalk-BLp5-jwC.js +1 -0
- package/ui/dist/assets/solr-B78wSQJQ.js +1 -0
- package/ui/dist/assets/sparql-Dz-mlCAC.js +1 -0
- package/ui/dist/assets/spreadsheet-CTIncYxj.js +1 -0
- package/ui/dist/assets/sql-DuQMFDvy.js +1 -0
- package/ui/dist/assets/stex-DNzd62Q_.js +1 -0
- package/ui/dist/assets/stylus-BOVgKnng.js +1 -0
- package/ui/dist/assets/swift-DKB6_1j6.js +1 -0
- package/ui/dist/assets/tcl-BGfruO1u.js +1 -0
- package/ui/dist/assets/textile-C4jVoHG1.js +1 -0
- package/ui/dist/assets/tiddlywiki-VlUiK86J.js +1 -0
- package/ui/dist/assets/tiki-CvUEFusT.js +1 -0
- package/ui/dist/assets/toml-BPTmHmyx.js +1 -0
- package/ui/dist/assets/troff-C2dAgh7P.js +1 -0
- package/ui/dist/assets/ttcn-PPSTk1ow.js +1 -0
- package/ui/dist/assets/ttcn-cfg-2b4Gb5sE.js +1 -0
- package/ui/dist/assets/turtle-Cx3rYX2g.js +1 -0
- package/ui/dist/assets/vb-BthdM_4N.js +1 -0
- package/ui/dist/assets/vbscript-D9f6rSsU.js +1 -0
- package/ui/dist/assets/velocity-CqftRsjg.js +1 -0
- package/ui/dist/assets/verilog-CjI9Y6RI.js +1 -0
- package/ui/dist/assets/vhdl-kS471soi.js +1 -0
- package/ui/dist/assets/webidl-D5jiJvOU.js +1 -0
- package/ui/dist/assets/xquery-BKJx4mf9.js +1 -0
- package/ui/dist/assets/yacas-CXXgtw0p.js +1 -0
- package/ui/dist/assets/z80-Co3PbHdE.js +1 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/favicon.svg +11 -0
- package/ui/dist/index.html +17 -0
- package/ui/dist/logos/claude.svg +1 -0
- package/ui/dist/logos/opencode.svg +1 -0
|
@@ -0,0 +1,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 };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { expect, test as base } from '@playwright/test';
|
|
2
|
+
import { getProfile } from './profiles.js';
|
|
3
|
+
import { caseWrites, caseWritesIn, hasCaseFile, readCaseMeta } from './cases.js';
|
|
4
|
+
import { acquireAccountLock } from './account-lock.js';
|
|
5
|
+
import { ledgerFile, TEST_DATA_PREFIX, writeLedger } from './records.js';
|
|
6
|
+
import { caseIdFromFile } from './run-summary.js';
|
|
7
|
+
import { ALLOW_WRITES_ENV, isProtectedEnv } from './write-gate.js';
|
|
8
|
+
import { checkSession, isLoginUrl, parseNotLoggedIn, sessionProblemMessage, signinCommand, } from './session.js';
|
|
9
|
+
export const test = base.extend({
|
|
10
|
+
profile: [
|
|
11
|
+
async ({}, use, workerInfo) => {
|
|
12
|
+
await use(getProfile(workerInfo.project.name));
|
|
13
|
+
},
|
|
14
|
+
{ scope: 'worker' },
|
|
15
|
+
],
|
|
16
|
+
mustLogin: [true, { scope: 'worker', option: true }],
|
|
17
|
+
// Login is not something we test: a profile without a valid session fails right away with an
|
|
18
|
+
// actionable message instead of a confusing login-page failure.
|
|
19
|
+
sessionGuard: [
|
|
20
|
+
async ({ browser, profile, mustLogin }, use) => {
|
|
21
|
+
if (mustLogin) {
|
|
22
|
+
// `pnpm asrun` already checked every profile before the run; reuse its result.
|
|
23
|
+
const status = parseNotLoggedIn().get(profile.id) ?? (await checkSession(browser, profile));
|
|
24
|
+
if (status !== 'ok') {
|
|
25
|
+
throw new Error(sessionProblemMessage(profile, status));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
await use();
|
|
29
|
+
},
|
|
30
|
+
{ scope: 'worker', auto: true },
|
|
31
|
+
],
|
|
32
|
+
// Enforced here, not only in `pnpm asrun`, so that running `playwright test` directly cannot bypass it:
|
|
33
|
+
// a case may be limited to some environments (`envs`), a case that writes data only runs in the environments
|
|
34
|
+
// it lists under `writes` (elsewhere it is skipped), and writing in any environment except staging needs a
|
|
35
|
+
// human's confirmation.
|
|
36
|
+
caseGate: [
|
|
37
|
+
async ({ profile }, use, testInfo) => {
|
|
38
|
+
const caseId = caseIdFromFile(testInfo.file);
|
|
39
|
+
if (hasCaseFile(caseId)) {
|
|
40
|
+
const meta = readCaseMeta(caseId);
|
|
41
|
+
testInfo.skip(meta.envs !== null && !meta.envs.includes(profile.env), `This case is limited to: ${meta.envs?.join(', ')}`);
|
|
42
|
+
testInfo.skip(caseWrites(meta) && !caseWritesIn(meta, profile.env), `This case writes data and has no permission to write in ${profile.env.toUpperCase()} (it lists: ${meta.writes.join(', ')}).`);
|
|
43
|
+
if (caseWritesIn(meta, profile.env) && isProtectedEnv(profile.env) && process.env[ALLOW_WRITES_ENV] !== profile.env) {
|
|
44
|
+
throw new Error(`This case writes data and has no permission to run in ${profile.env.toUpperCase()}. ` +
|
|
45
|
+
`Start it with "pnpm asrun --env ${profile.env}" and confirm, or run it in staging.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
await use();
|
|
49
|
+
},
|
|
50
|
+
{ scope: 'test', auto: true },
|
|
51
|
+
],
|
|
52
|
+
// Cases that write to an account run one at a time per profile, whatever the worker count.
|
|
53
|
+
accountLock: [
|
|
54
|
+
async ({ profile, caseGate: _gate }, use, testInfo) => {
|
|
55
|
+
const caseId = caseIdFromFile(testInfo.file);
|
|
56
|
+
if (!hasCaseFile(caseId) || !caseWrites(readCaseMeta(caseId))) {
|
|
57
|
+
await use();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const release = await acquireAccountLock(profile.id, WRITE_CASE_TIMEOUT_MS);
|
|
61
|
+
try {
|
|
62
|
+
await use();
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
release();
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{ scope: 'test', auto: true },
|
|
69
|
+
],
|
|
70
|
+
// Tracks test data and removes it again, newest first. See `Records.track`.
|
|
71
|
+
records: async ({ profile, page: _page }, use, testInfo) => {
|
|
72
|
+
const caseId = caseIdFromFile(testInfo.file);
|
|
73
|
+
const runId = process.env.ASRUN_RUN_ID ?? 'adhoc';
|
|
74
|
+
const file = ledgerFile(runId, testInfo.testId);
|
|
75
|
+
const entries = [];
|
|
76
|
+
const save = () => writeLedger(file, entries.map((item) => item.entry));
|
|
77
|
+
await use({
|
|
78
|
+
async track(data, remove) {
|
|
79
|
+
if (!data.name.startsWith(TEST_DATA_PREFIX)) {
|
|
80
|
+
throw new Error(`Test data must be named with the prefix "${TEST_DATA_PREFIX}" (got "${data.name}"). ` +
|
|
81
|
+
`Only data that is clearly a test's own may be created and removed.`);
|
|
82
|
+
}
|
|
83
|
+
entries.push({
|
|
84
|
+
entry: {
|
|
85
|
+
id: `${entries.length + 1}`,
|
|
86
|
+
runId,
|
|
87
|
+
env: profile.env,
|
|
88
|
+
profile: profile.id,
|
|
89
|
+
caseId,
|
|
90
|
+
kind: data.kind,
|
|
91
|
+
name: data.name,
|
|
92
|
+
where: data.where ?? null,
|
|
93
|
+
status: 'open',
|
|
94
|
+
error: null,
|
|
95
|
+
createdAt: new Date().toISOString(),
|
|
96
|
+
},
|
|
97
|
+
remove,
|
|
98
|
+
});
|
|
99
|
+
// Written at once: if the process dies, the ledger still says what was left behind.
|
|
100
|
+
save();
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
const failures = [];
|
|
104
|
+
for (const item of [...entries].reverse()) {
|
|
105
|
+
try {
|
|
106
|
+
await item.remove();
|
|
107
|
+
item.entry.status = 'cleaned';
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
item.entry.status = 'leftover';
|
|
111
|
+
item.entry.error = error.message.split('\n')[0];
|
|
112
|
+
failures.push(`${item.entry.kind} "${item.entry.name}"${item.entry.where ? ` (look in ${item.entry.where})` : ''}`);
|
|
113
|
+
}
|
|
114
|
+
save();
|
|
115
|
+
}
|
|
116
|
+
if (failures.length > 0) {
|
|
117
|
+
testInfo.annotations.push({ type: 'leftover-data', description: failures.join('; ') });
|
|
118
|
+
throw new Error(`Test data was not removed and must be deleted by hand: ${failures.join('; ')}`);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
// The session can also expire in the middle of a run; say so when a failed test ends on /login.
|
|
122
|
+
sessionExpiryHint: [
|
|
123
|
+
async ({ page, profile, mustLogin }, use, testInfo) => {
|
|
124
|
+
await use();
|
|
125
|
+
if (!mustLogin || testInfo.status === testInfo.expectedStatus) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (page.isClosed() || !isLoginUrl(page.url())) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const description = `The session of profile "${profile.id}" in ${profile.env} expired during the run. Log in again: ${signinCommand(profile)}`;
|
|
132
|
+
testInfo.annotations.push({ type: 'session-expired', description });
|
|
133
|
+
console.error(`\n${description}\n`);
|
|
134
|
+
},
|
|
135
|
+
{ scope: 'test', auto: true },
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
/** Longest a write case may take, and how long it waits for another case that uses the same account. */
|
|
139
|
+
const WRITE_CASE_TIMEOUT_MS = 180_000;
|
|
140
|
+
/**
|
|
141
|
+
* Call at the top of the `test.describe` of every spec (`configureCase('<id>')`). Cases that write data are
|
|
142
|
+
* never retried (a retry would create the data a second time) and get more time than a read-only case.
|
|
143
|
+
*/
|
|
144
|
+
export function configureCase(caseId) {
|
|
145
|
+
if (hasCaseFile(caseId) && caseWrites(readCaseMeta(caseId))) {
|
|
146
|
+
test.describe.configure({ retries: 0, timeout: WRITE_CASE_TIMEOUT_MS });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export { expect };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arguments that run one of the framework's own scripts (for example `login.ts`): the compiled file when the package
|
|
3
|
+
* is built, the TypeScript source in the framework repository.
|
|
4
|
+
*/
|
|
5
|
+
export declare function scriptArgs(name: string): string[];
|
|
6
|
+
/**
|
|
7
|
+
* The JavaScript entry point of a package's command-line tool. The shims in `node_modules/.bin` are POSIX shell
|
|
8
|
+
* scripts on Windows and cannot be spawned there, so tools are always started with the current Node instead.
|
|
9
|
+
* The path comes from the package manifest, so a dependency upgrade cannot silently break it.
|
|
10
|
+
*/
|
|
11
|
+
export declare function commandScript(packageName: string, command?: string): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { FRAMEWORK_ROOT, packageDir } from './paths.js';
|
|
4
|
+
/**
|
|
5
|
+
* Arguments that run one of the framework's own scripts (for example `login.ts`): the compiled file when the package
|
|
6
|
+
* is built, the TypeScript source in the framework repository.
|
|
7
|
+
*/
|
|
8
|
+
export function scriptArgs(name) {
|
|
9
|
+
const compiled = path.join(FRAMEWORK_ROOT, 'dist', 'scripts', name.replace(/\.ts$/, '.js'));
|
|
10
|
+
if (fs.existsSync(compiled))
|
|
11
|
+
return [compiled];
|
|
12
|
+
return [commandScript('tsx'), path.join(FRAMEWORK_ROOT, 'scripts', name)];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The JavaScript entry point of a package's command-line tool. The shims in `node_modules/.bin` are POSIX shell
|
|
16
|
+
* scripts on Windows and cannot be spawned there, so tools are always started with the current Node instead.
|
|
17
|
+
* The path comes from the package manifest, so a dependency upgrade cannot silently break it.
|
|
18
|
+
*/
|
|
19
|
+
export function commandScript(packageName, command = packageName) {
|
|
20
|
+
const dir = packageDir(packageName);
|
|
21
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
22
|
+
const script = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[command];
|
|
23
|
+
if (!script)
|
|
24
|
+
throw new Error(`${packageName} has no command-line entry point "${command}".`);
|
|
25
|
+
return path.join(dir, script);
|
|
26
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare const FRAMEWORK_ROOT: string;
|
|
2
|
+
/**
|
|
3
|
+
* The workspace root: the project that uses the framework. Everything the framework reads or writes for a project
|
|
4
|
+
* (`profiles.yaml`, `cases/`, `tests/`, `.auth/`, `reports/`, `.tmp/`) is relative to it.
|
|
5
|
+
*
|
|
6
|
+
* `ASRUN_ROOT` chooses it explicitly. Otherwise it is the nearest folder at or above the current working
|
|
7
|
+
* directory that holds `profiles.yaml` or `.asrun.json`; without one the framework's own folder is used, which is
|
|
8
|
+
* how the framework runs against `examples/`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const ROOT: string;
|
|
11
|
+
/** True when `ROOT` is the framework folder itself, i.e. no workspace was found. */
|
|
12
|
+
export declare const isFrameworkRoot: () => boolean;
|
|
13
|
+
/** One saved login per profile: `.auth/<profile-id>.json`. */
|
|
14
|
+
export declare const authFile: (profileId: string) => string;
|
|
15
|
+
export declare const CASES_DIR: string;
|
|
16
|
+
export declare const TESTS_DIR: string;
|
|
17
|
+
export declare const REPORTS_DIR: string;
|
|
18
|
+
export declare const TMP_DIR: string;
|
|
19
|
+
export declare const TEMPLATES_DIR: string;
|
|
20
|
+
export declare const SKILLS_DIR: string;
|
|
21
|
+
/**
|
|
22
|
+
* The `node_modules/<package>` folder of the framework's own install, or the workspace's (a hoisted or linked
|
|
23
|
+
* install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use this.
|
|
24
|
+
*/
|
|
25
|
+
export declare function packageDir(packageName: string): string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Where the framework's own code and assets live: the package folder (`node_modules/@asrunjs/asrun`). The compiled files sit in
|
|
5
|
+
* `dist/lib`, so one level up from there is the package root again.
|
|
6
|
+
*/
|
|
7
|
+
const MODULE_DIR = import.meta.dirname;
|
|
8
|
+
export const FRAMEWORK_ROOT = path.basename(path.dirname(MODULE_DIR)) === 'dist' ? path.resolve(MODULE_DIR, '..', '..') : path.resolve(MODULE_DIR, '..');
|
|
9
|
+
/**
|
|
10
|
+
* Reads a `.env` file into `process.env`. A variable that already exists (a real environment variable, or one from an
|
|
11
|
+
* earlier file) is never overridden. `ASRUN_ROOT` is resolved against the folder of the file, so `.` in
|
|
12
|
+
* `<workspace>/.env` means the workspace and `examples/demo` in the framework's `.env` means that folder.
|
|
13
|
+
*/
|
|
14
|
+
function loadEnvFile(file) {
|
|
15
|
+
let text;
|
|
16
|
+
try {
|
|
17
|
+
text = fs.readFileSync(file, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
23
|
+
const line = raw.trim();
|
|
24
|
+
if (line === '' || line.startsWith('#'))
|
|
25
|
+
continue;
|
|
26
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
27
|
+
if (!match)
|
|
28
|
+
continue;
|
|
29
|
+
const [, key, rawValue] = match;
|
|
30
|
+
if (process.env[key] !== undefined)
|
|
31
|
+
continue;
|
|
32
|
+
const value = rawValue.trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
33
|
+
process.env[key] = key === 'ASRUN_ROOT' ? path.resolve(path.dirname(file), value) : value;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// The workspace can be chosen per machine without exporting anything: the `.env` next to the command, or the
|
|
37
|
+
// framework's own `.env` (that is how the framework itself develops against examples/).
|
|
38
|
+
loadEnvFile(path.join(process.cwd(), '.env'));
|
|
39
|
+
if (process.env.ASRUN_ROOT === undefined)
|
|
40
|
+
loadEnvFile(path.join(FRAMEWORK_ROOT, '.env'));
|
|
41
|
+
/** Files that mark the root of a workspace: the project that owns `profiles.yaml`, `cases/` and `tests/`. */
|
|
42
|
+
const WORKSPACE_MARKERS = ['.asrun.json', 'profiles.yaml'];
|
|
43
|
+
function findWorkspace(from) {
|
|
44
|
+
let dir = path.resolve(from);
|
|
45
|
+
for (;;) {
|
|
46
|
+
if (WORKSPACE_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker))))
|
|
47
|
+
return dir;
|
|
48
|
+
const parent = path.dirname(dir);
|
|
49
|
+
if (parent === dir)
|
|
50
|
+
return null;
|
|
51
|
+
dir = parent;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The workspace root: the project that uses the framework. Everything the framework reads or writes for a project
|
|
56
|
+
* (`profiles.yaml`, `cases/`, `tests/`, `.auth/`, `reports/`, `.tmp/`) is relative to it.
|
|
57
|
+
*
|
|
58
|
+
* `ASRUN_ROOT` chooses it explicitly. Otherwise it is the nearest folder at or above the current working
|
|
59
|
+
* directory that holds `profiles.yaml` or `.asrun.json`; without one the framework's own folder is used, which is
|
|
60
|
+
* how the framework runs against `examples/`.
|
|
61
|
+
*/
|
|
62
|
+
export const ROOT = process.env.ASRUN_ROOT ? path.resolve(process.env.ASRUN_ROOT) : (findWorkspace(process.cwd()) ?? FRAMEWORK_ROOT);
|
|
63
|
+
// Children (Playwright, the web UI jobs) inherit the resolved absolute path: a relative ASRUN_ROOT would point
|
|
64
|
+
// somewhere else once the working directory changes.
|
|
65
|
+
process.env.ASRUN_ROOT = ROOT;
|
|
66
|
+
/** True when `ROOT` is the framework folder itself, i.e. no workspace was found. */
|
|
67
|
+
export const isFrameworkRoot = () => path.resolve(ROOT) === path.resolve(FRAMEWORK_ROOT);
|
|
68
|
+
/** One saved login per profile: `.auth/<profile-id>.json`. */
|
|
69
|
+
export const authFile = (profileId) => path.join(ROOT, '.auth', `${profileId}.json`);
|
|
70
|
+
export const CASES_DIR = path.join(ROOT, 'cases');
|
|
71
|
+
export const TESTS_DIR = path.join(ROOT, 'tests');
|
|
72
|
+
export const REPORTS_DIR = path.join(ROOT, 'reports');
|
|
73
|
+
export const TMP_DIR = path.join(ROOT, '.tmp');
|
|
74
|
+
export const TEMPLATES_DIR = path.join(FRAMEWORK_ROOT, 'templates');
|
|
75
|
+
export const SKILLS_DIR = path.join(FRAMEWORK_ROOT, '.claude', 'skills');
|
|
76
|
+
/**
|
|
77
|
+
* The `node_modules/<package>` folder of the framework's own install, or the workspace's (a hoisted or linked
|
|
78
|
+
* install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use this.
|
|
79
|
+
*/
|
|
80
|
+
export function packageDir(packageName) {
|
|
81
|
+
// The package's own install, the pnpm virtual store that holds its dependencies, or a hoisted install in the workspace.
|
|
82
|
+
for (const base of [FRAMEWORK_ROOT, path.dirname(path.dirname(FRAMEWORK_ROOT)), ROOT]) {
|
|
83
|
+
const dir = path.join(base, 'node_modules', packageName);
|
|
84
|
+
if (fs.existsSync(dir))
|
|
85
|
+
return dir;
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`Package "${packageName}" is not installed. Run "pnpm install" in the workspace root.`);
|
|
88
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
type PdfJs = typeof import('pdfjs-dist/legacy/build/pdf.mjs');
|
|
2
|
+
type PdfTask = ReturnType<PdfJs['getDocument']>;
|
|
3
|
+
type PdfDoc = Awaited<PdfTask['promise']>;
|
|
4
|
+
/** Which pages a check covers: all of them, the first, the last, or the listed 1-based page numbers. */
|
|
5
|
+
export type PageSelection = 'all' | 'first' | 'last' | number[];
|
|
6
|
+
/** A rectangle of the page in fractions (0 to 1) of its width and height, from the top-left corner. */
|
|
7
|
+
export interface Region {
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
}
|
|
13
|
+
export type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
14
|
+
/** A corner of the page. Defaults: 30% of the width and 12% of the height, enough for a logo and nothing else. */
|
|
15
|
+
export declare function corner(name: Corner, size?: {
|
|
16
|
+
width?: number;
|
|
17
|
+
height?: number;
|
|
18
|
+
}): Region;
|
|
19
|
+
export declare function selectPages(selection: PageSelection, pageCount: number): number[];
|
|
20
|
+
export interface RenderedRegion {
|
|
21
|
+
png: Buffer;
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
/** Share of pixels that are not (almost) white: 0 is a blank region. */
|
|
25
|
+
inkRatio: number;
|
|
26
|
+
}
|
|
27
|
+
export declare class PdfFile {
|
|
28
|
+
private readonly doc;
|
|
29
|
+
private readonly task;
|
|
30
|
+
readonly pageCount: number;
|
|
31
|
+
/** The text of each page, in reading order. */
|
|
32
|
+
readonly texts: string[];
|
|
33
|
+
constructor(doc: PdfDoc, task: PdfTask, pageCount: number,
|
|
34
|
+
/** The text of each page, in reading order. */
|
|
35
|
+
texts: string[]);
|
|
36
|
+
/** Renders one page (or a region of it) to a PNG. Scale 2 is about 150 dpi. */
|
|
37
|
+
render(pageNumber: number, region?: Region, scale?: number): Promise<RenderedRegion>;
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
export declare function openPdf(bytes: Buffer): Promise<PdfFile>;
|
|
41
|
+
export interface PdfRegionCheck {
|
|
42
|
+
/** Short name of the check, used in messages and in the baseline file name (`<name>-p<page>.png`). Lower case words and dashes. */
|
|
43
|
+
name: string;
|
|
44
|
+
pages: PageSelection;
|
|
45
|
+
region: Region;
|
|
46
|
+
/** The region must show something (not blank). Default true. */
|
|
47
|
+
notBlank?: boolean;
|
|
48
|
+
/** Compare the region with the baseline image of the profile (2% tolerance). Default true. */
|
|
49
|
+
baseline?: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface PdfChecks {
|
|
52
|
+
regions?: PdfRegionCheck[];
|
|
53
|
+
/** Texts that must appear somewhere in the PDF (case-insensitive). */
|
|
54
|
+
textContains?: string[];
|
|
55
|
+
/** Texts that must not appear on any page (for example the names that belong to another profile). */
|
|
56
|
+
textNotContains?: string[];
|
|
57
|
+
/** At least this many pages. */
|
|
58
|
+
minPages?: number;
|
|
59
|
+
scale?: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The one way a spec checks the content of a downloaded PDF. For every region check it renders each selected page,
|
|
63
|
+
* fails when the region is blank, and compares the region with the profile's baseline image (the first run with
|
|
64
|
+
* `--update-snapshots` creates it; a person must look at it). Then it checks the extracted text.
|
|
65
|
+
* Every failure names the page and the check. Pair every call with `// target: T<n>` in the spec.
|
|
66
|
+
*
|
|
67
|
+
* const pdf = await openPdf(file.bytes);
|
|
68
|
+
* await expectPdf(pdf, {regions: [{name: 'logo', pages: 'first', region: corner('top-left')}], textNotContains: otherProfileNames});
|
|
69
|
+
*/
|
|
70
|
+
export declare function expectPdf(pdf: PdfFile, checks: PdfChecks): Promise<void>;
|
|
71
|
+
export {};
|
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,38 @@
|
|
|
1
|
+
export declare const PRODUCTION = "production";
|
|
2
|
+
export declare const STAGING = "staging";
|
|
3
|
+
export declare const PROFILES_FILE: string;
|
|
4
|
+
/**
|
|
5
|
+
* One entry of profiles.yaml: one environment of the system under test. It holds only connection
|
|
6
|
+
* settings. Expected values belong to the case document that checks them (see `lib/values.ts`).
|
|
7
|
+
*/
|
|
8
|
+
export interface Profile {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
/** The environment this profile points at, e.g. `production` or `staging`. */
|
|
12
|
+
env: string;
|
|
13
|
+
baseURL: string;
|
|
14
|
+
/** A label for humans (a mailbox or username). Never a password. */
|
|
15
|
+
account: string | null;
|
|
16
|
+
/** Free-form labels for grouping and `--tag`; lowercase words. */
|
|
17
|
+
tags: string[];
|
|
18
|
+
/** Tests may send real email in this environment. Never true in production. */
|
|
19
|
+
email: boolean;
|
|
20
|
+
/** A page that redirects to /login when the session is missing or expired. */
|
|
21
|
+
sessionCheckPath: string;
|
|
22
|
+
}
|
|
23
|
+
/** The Playwright project name of a profile: its id. */
|
|
24
|
+
export declare const projectName: (profile: {
|
|
25
|
+
id: string;
|
|
26
|
+
}) => string;
|
|
27
|
+
export declare const profileLabel: (profile: {
|
|
28
|
+
id: string;
|
|
29
|
+
env: string;
|
|
30
|
+
}) => string;
|
|
31
|
+
export declare function loadProfiles(file?: string): Profile[];
|
|
32
|
+
/** The profiles of one environment (`env` omitted: all of them), production first. */
|
|
33
|
+
export declare const listProfiles: (env?: string, profiles?: Profile[]) => Profile[];
|
|
34
|
+
export declare function getProfile(id: string, profiles?: Profile[]): Profile;
|
|
35
|
+
/** The profiles that carry this tag. */
|
|
36
|
+
export declare const listProfilesWithTag: (tag: string, env?: string, profiles?: Profile[]) => Profile[];
|
|
37
|
+
/** Every environment used by a profile: production, staging, then the rest. */
|
|
38
|
+
export declare const listEnvs: (profiles?: Profile[]) => string[];
|