@davidrydberg/proofy 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +64 -2
- package/dist/args.d.ts +10 -0
- package/dist/args.js +36 -0
- package/dist/bin/proofy.d.ts +2 -0
- package/dist/bin/proofy.js +4 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +140 -0
- package/dist/commands/approve.d.ts +4 -0
- package/dist/commands/approve.js +48 -0
- package/dist/commands/context.d.ts +16 -0
- package/dist/commands/context.js +27 -0
- package/dist/commands/dev.d.ts +4 -0
- package/dist/commands/dev.js +161 -0
- package/dist/commands/home.d.ts +3 -0
- package/dist/commands/home.js +122 -0
- package/dist/commands/init.d.ts +4 -0
- package/dist/commands/init.js +122 -0
- package/dist/commands/report.d.ts +4 -0
- package/dist/commands/report.js +26 -0
- package/dist/commands/run.d.ts +6 -0
- package/dist/commands/run.js +166 -0
- package/dist/commands/types.d.ts +8 -0
- package/dist/commands/types.js +1 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.js +62 -0
- package/dist/deps.d.ts +10 -0
- package/dist/deps.js +1 -0
- package/dist/detect.d.ts +17 -0
- package/dist/detect.js +96 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +21 -0
- package/dist/github.d.ts +16 -0
- package/dist/github.js +121 -0
- package/dist/human.d.ts +30 -0
- package/dist/human.js +160 -0
- package/dist/output.d.ts +8 -0
- package/dist/output.js +21 -0
- package/dist/project.d.ts +21 -0
- package/dist/project.js +71 -0
- package/dist/run/approvals.d.ts +15 -0
- package/dist/run/approvals.js +26 -0
- package/dist/run/engine.d.ts +48 -0
- package/dist/run/engine.js +168 -0
- package/dist/run/fingerprint.d.ts +11 -0
- package/dist/run/fingerprint.js +40 -0
- package/dist/run/history.d.ts +29 -0
- package/dist/run/history.js +85 -0
- package/dist/run/report.d.ts +14 -0
- package/dist/run/report.js +115 -0
- package/dist/run/results.d.ts +70 -0
- package/dist/run/results.js +129 -0
- package/dist/run/spec.d.ts +6 -0
- package/dist/run/spec.js +16 -0
- package/dist/sandbox/compose.d.ts +38 -0
- package/dist/sandbox/compose.js +132 -0
- package/dist/sandbox/contract.d.ts +19 -0
- package/dist/sandbox/contract.js +20 -0
- package/dist/sandbox/docker.d.ts +17 -0
- package/dist/sandbox/docker.js +48 -0
- package/dist/sandbox/sandbox.d.ts +46 -0
- package/dist/sandbox/sandbox.js +239 -0
- package/dist/standin/ca.d.ts +6 -0
- package/dist/standin/ca.js +40 -0
- package/dist/standin/dns.d.ts +10 -0
- package/dist/standin/dns.js +53 -0
- package/dist/standin/forward.d.ts +1 -0
- package/dist/standin/forward.js +17 -0
- package/dist/standin/handle.d.ts +49 -0
- package/dist/standin/handle.js +130 -0
- package/dist/standin/server.d.ts +1 -0
- package/dist/standin/server.js +88 -0
- package/dist/style.d.ts +23 -0
- package/dist/style.js +38 -0
- package/dist/test/global-setup.d.ts +1 -0
- package/dist/test/global-setup.js +20 -0
- package/dist/test/index.d.ts +102 -0
- package/dist/test/index.js +184 -0
- package/docker/standin.Dockerfile +3 -0
- package/package.json +68 -4
- package/schema/proofy.schema.json +179 -0
- package/templates/e2e/AGENTS.md +90 -0
- package/templates/e2e/templates/compose.yml +33 -0
- package/templates/e2e/templates/spec.md +20 -0
- package/templates/e2e/templates/standin.mjs +29 -0
- package/templates/e2e/templates/test.spec.ts +28 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Config } from "../config.js";
|
|
2
|
+
import type { Paths } from "../project.js";
|
|
3
|
+
import type { Proc } from "../sandbox/docker.js";
|
|
4
|
+
import { type Sandbox, type SandboxOptions } from "../sandbox/sandbox.js";
|
|
5
|
+
import type { Approvals } from "./approvals.js";
|
|
6
|
+
import { type PlannedFlow, type RunReport } from "./results.js";
|
|
7
|
+
export interface RunContext {
|
|
8
|
+
proc: Proc;
|
|
9
|
+
env: NodeJS.ProcessEnv;
|
|
10
|
+
now: () => Date;
|
|
11
|
+
log: (line: string) => void;
|
|
12
|
+
/** Where results and approvals live: always the working copy. */
|
|
13
|
+
base: Paths;
|
|
14
|
+
/** Where the app, tests and fakes come from: the working copy, or an exported pull request. */
|
|
15
|
+
source: Paths;
|
|
16
|
+
config: Config;
|
|
17
|
+
approvals: Approvals;
|
|
18
|
+
cacheDir: string;
|
|
19
|
+
sourceInfo: RunReport["source"];
|
|
20
|
+
}
|
|
21
|
+
export declare function planFlows(ctx: Pick<RunContext, "source" | "config" | "approvals">, names: string[]): Promise<PlannedFlow[]>;
|
|
22
|
+
export declare function newRunId(now: Date): string;
|
|
23
|
+
export declare function sandboxOptions(ctx: RunContext, mode: "run" | "dev", project: string, logDir: string): SandboxOptions;
|
|
24
|
+
/** A whole sealed run: fresh sandbox, tests, results, teardown. Results are written even when the sandbox fails. */
|
|
25
|
+
export declare function sealedRun(ctx: RunContext, planned: PlannedFlow[], options: {
|
|
26
|
+
keep: boolean;
|
|
27
|
+
project: string;
|
|
28
|
+
}): Promise<RunReport>;
|
|
29
|
+
/** Runs the planned flows in a sandbox that is already up: a sealed run's, or the dev sandbox. */
|
|
30
|
+
export declare function testsInSandbox(ctx: RunContext, sandbox: Sandbox, planned: PlannedFlow[], extraArgs: string[]): Promise<void>;
|
|
31
|
+
export declare function finishRun(ctx: RunContext, run: {
|
|
32
|
+
id: string;
|
|
33
|
+
kind: "run" | "dev";
|
|
34
|
+
startedAt: string;
|
|
35
|
+
runDir: string;
|
|
36
|
+
planned: PlannedFlow[];
|
|
37
|
+
notes: string[];
|
|
38
|
+
environmentError?: string;
|
|
39
|
+
sandbox?: Sandbox;
|
|
40
|
+
offsets?: Offsets;
|
|
41
|
+
}): Promise<RunReport>;
|
|
42
|
+
export interface Offsets {
|
|
43
|
+
requests: number;
|
|
44
|
+
dns: number;
|
|
45
|
+
}
|
|
46
|
+
/** How far the dev sandbox's request logs are, so one dev test only reports its own calls. */
|
|
47
|
+
export declare function logOffsets(sandbox: Sandbox): Promise<Offsets>;
|
|
48
|
+
export declare function reserveRunId(ctx: Pick<RunContext, "base" | "now">): Promise<string>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { appendFile, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { MOUNT } from "../sandbox/contract.js";
|
|
5
|
+
import { baseUrl } from "../sandbox/compose.js";
|
|
6
|
+
import { installImages, prepare, runTests, saveLogs, start, stop } from "../sandbox/sandbox.js";
|
|
7
|
+
import { fingerprint, sameFingerprint } from "./fingerprint.js";
|
|
8
|
+
import { outDir, pruneRuns } from "./history.js";
|
|
9
|
+
import { renderMarkdown } from "./report.js";
|
|
10
|
+
import { collectResults } from "./results.js";
|
|
11
|
+
import { parseRequirements } from "./spec.js";
|
|
12
|
+
export async function planFlows(ctx, names) {
|
|
13
|
+
const planned = [];
|
|
14
|
+
for (const name of names) {
|
|
15
|
+
const print = await fingerprint(ctx.source, ctx.config, name);
|
|
16
|
+
const specText = await readFile(join(ctx.source.specs, `${name}.md`), "utf8");
|
|
17
|
+
planned.push({ name, approved: sameFingerprint(ctx.approvals.flows[name], print), fingerprint: print, requirements: parseRequirements(specText) });
|
|
18
|
+
}
|
|
19
|
+
return planned;
|
|
20
|
+
}
|
|
21
|
+
export function newRunId(now) {
|
|
22
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
23
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
24
|
+
}
|
|
25
|
+
export function sandboxOptions(ctx, mode, project, logDir) {
|
|
26
|
+
return {
|
|
27
|
+
sourceDir: ctx.source.root,
|
|
28
|
+
e2eDir: ctx.source.e2e,
|
|
29
|
+
logDir,
|
|
30
|
+
config: ctx.config,
|
|
31
|
+
mode,
|
|
32
|
+
project,
|
|
33
|
+
cacheDir: ctx.cacheDir,
|
|
34
|
+
env: ctx.env,
|
|
35
|
+
log: ctx.log,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** A whole sealed run: fresh sandbox, tests, results, teardown. Results are written even when the sandbox fails. */
|
|
39
|
+
export async function sealedRun(ctx, planned, options) {
|
|
40
|
+
const id = await reserveRunId(ctx);
|
|
41
|
+
const runDir = join(outDir(ctx.base), id);
|
|
42
|
+
const startedAt = ctx.now().toISOString();
|
|
43
|
+
const sandboxOpts = sandboxOptions(ctx, "run", options.project, runDir);
|
|
44
|
+
let sandbox;
|
|
45
|
+
let environmentError;
|
|
46
|
+
const notes = [];
|
|
47
|
+
try {
|
|
48
|
+
sandbox = await prepare(ctx.proc, sandboxOpts);
|
|
49
|
+
notes.push(...sandbox.notes);
|
|
50
|
+
await installImages(ctx.proc, sandboxOpts, sandbox);
|
|
51
|
+
sandbox = await start(ctx.proc, sandboxOpts, sandbox);
|
|
52
|
+
await testsInSandbox(ctx, sandbox, planned, []);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (!(error instanceof CliError) || error.code !== "ENVIRONMENT_ERROR")
|
|
56
|
+
throw error;
|
|
57
|
+
environmentError = [error.message, ...error.suggestions].join("\n");
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
if (sandbox) {
|
|
61
|
+
await saveLogs(ctx.proc, sandbox, runDir);
|
|
62
|
+
if (options.keep) {
|
|
63
|
+
notes.push(`Sandbox kept running: docker compose -p ${sandbox.project} -f ${sandbox.composeFile} down -v`);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
ctx.log("tearing down the sandbox");
|
|
67
|
+
await stop(ctx.proc, sandbox);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return finishRun(ctx, { id, kind: "run", startedAt, runDir, planned, notes, ...(environmentError ? { environmentError } : {}), ...(sandbox ? { sandbox } : {}) });
|
|
72
|
+
}
|
|
73
|
+
/** Runs the planned flows in a sandbox that is already up: a sealed run's, or the dev sandbox. */
|
|
74
|
+
export async function testsInSandbox(ctx, sandbox, planned, extraArgs) {
|
|
75
|
+
const out = join(sandbox.workDir, "out");
|
|
76
|
+
for (const leftover of ["flows", "playwright", "playwright.json", "playwright.log"]) {
|
|
77
|
+
await rm(join(out, leftover), { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
await writeFile(join(sandbox.workDir, "runner", "playwright.config.mjs"), playwrightConfig(ctx.config, planned.map((flow) => flow.name)));
|
|
80
|
+
ctx.log(`running ${planned.map((flow) => flow.name).join(", ")}`);
|
|
81
|
+
await runTests(ctx.proc, sandboxOptions(ctx, "run", sandbox.project, out), sandbox, extraArgs);
|
|
82
|
+
}
|
|
83
|
+
export async function finishRun(ctx, run) {
|
|
84
|
+
await mkdir(run.runDir, { recursive: true });
|
|
85
|
+
if (run.sandbox)
|
|
86
|
+
await harvest(run.sandbox, run.runDir, run.offsets);
|
|
87
|
+
const collected = await collectResults(run.runDir, run.planned);
|
|
88
|
+
const environmentError = run.environmentError ?? collected.environmentError;
|
|
89
|
+
const report = {
|
|
90
|
+
id: run.id,
|
|
91
|
+
kind: run.kind,
|
|
92
|
+
startedAt: run.startedAt,
|
|
93
|
+
finishedAt: ctx.now().toISOString(),
|
|
94
|
+
source: ctx.sourceInfo,
|
|
95
|
+
flows: environmentError ? collected.flows.map((flow) => ({ ...flow, status: "failed" })) : collected.flows,
|
|
96
|
+
outside: collected.outside,
|
|
97
|
+
lookedUpOnly: collected.lookedUpOnly,
|
|
98
|
+
notes: run.notes,
|
|
99
|
+
...(environmentError ? { environmentError } : {}),
|
|
100
|
+
};
|
|
101
|
+
await writeFile(join(run.runDir, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
102
|
+
await writeFile(join(run.runDir, "report.md"), renderMarkdown(report, (path) => path));
|
|
103
|
+
await pruneRuns(ctx.base);
|
|
104
|
+
return report;
|
|
105
|
+
}
|
|
106
|
+
/** How far the dev sandbox's request logs are, so one dev test only reports its own calls. */
|
|
107
|
+
export async function logOffsets(sandbox) {
|
|
108
|
+
return { requests: await lineCount(join(sandbox.workDir, "out", "requests.jsonl")), dns: await lineCount(join(sandbox.workDir, "out", "dns.jsonl")) };
|
|
109
|
+
}
|
|
110
|
+
async function harvest(sandbox, runDir, offsets = { requests: 0, dns: 0 }) {
|
|
111
|
+
const out = join(sandbox.workDir, "out");
|
|
112
|
+
for (const entry of ["flows", "playwright", "playwright.json", "playwright.log"]) {
|
|
113
|
+
try {
|
|
114
|
+
await cp(join(out, entry), join(runDir, entry), { recursive: true });
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Not every run produces every file, for example when the sandbox never started.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const [file, skip] of [["requests.jsonl", offsets.requests], ["dns.jsonl", offsets.dns]]) {
|
|
121
|
+
const lines = (await readText(join(out, file))).split("\n").filter(Boolean).slice(skip);
|
|
122
|
+
if (lines.length > 0)
|
|
123
|
+
await appendFile(join(runDir, file), `${lines.join("\n")}\n`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export async function reserveRunId(ctx) {
|
|
127
|
+
const base = newRunId(ctx.now());
|
|
128
|
+
await mkdir(outDir(ctx.base), { recursive: true });
|
|
129
|
+
for (let n = 0;; n++) {
|
|
130
|
+
const id = n === 0 ? base : `${base}-${n}`;
|
|
131
|
+
try {
|
|
132
|
+
await mkdir(join(outDir(ctx.base), id));
|
|
133
|
+
return id;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error.code !== "EEXIST")
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function playwrightConfig(config, flows) {
|
|
142
|
+
const settings = {
|
|
143
|
+
testDir: `${MOUNT.e2e}/tests`,
|
|
144
|
+
testMatch: flows.map((flow) => `**/${flow}.spec.ts`),
|
|
145
|
+
outputDir: `${MOUNT.out}/playwright`,
|
|
146
|
+
timeout: config.limits.testMinutes * 60_000,
|
|
147
|
+
retries: 0,
|
|
148
|
+
workers: config.limits.parallel,
|
|
149
|
+
fullyParallel: true,
|
|
150
|
+
globalSetup: `${MOUNT.pkg}/dist/test/global-setup.js`,
|
|
151
|
+
reporter: [["json", { outputFile: `${MOUNT.out}/playwright.json` }], ["line"]],
|
|
152
|
+
// A step that waits for something that never appears fails after 30 seconds with its locator, not after the whole test timeout.
|
|
153
|
+
use: { baseURL: baseUrl(config), ignoreHTTPSErrors: true, trace: "retain-on-failure", screenshot: "off", video: "off", actionTimeout: 30_000, navigationTimeout: 60_000 },
|
|
154
|
+
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
|
|
155
|
+
};
|
|
156
|
+
return `// Generated by proofy for one run.\nexport default ${JSON.stringify(settings, null, 2)};\n`;
|
|
157
|
+
}
|
|
158
|
+
async function readText(file) {
|
|
159
|
+
try {
|
|
160
|
+
return await readFile(file, "utf8");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function lineCount(file) {
|
|
167
|
+
return (await readText(file)).split("\n").filter(Boolean).length;
|
|
168
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Config } from "../config.js";
|
|
2
|
+
import type { Paths } from "../project.js";
|
|
3
|
+
export interface Fingerprint {
|
|
4
|
+
spec: string;
|
|
5
|
+
test: string;
|
|
6
|
+
/** Every fake and the hosts list together: a change to any fake can change any flow's result. */
|
|
7
|
+
fakes: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function fingerprint(paths: Paths, config: Config, flow: string): Promise<Fingerprint>;
|
|
10
|
+
export declare function fakesHash(paths: Paths, config: Config): Promise<string>;
|
|
11
|
+
export declare function sameFingerprint(a: Fingerprint | undefined, b: Fingerprint | undefined): boolean;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export async function fingerprint(paths, config, flow) {
|
|
5
|
+
return {
|
|
6
|
+
spec: await hashFile(join(paths.specs, `${flow}.md`)),
|
|
7
|
+
test: await hashFile(join(paths.tests, `${flow}.spec.ts`)),
|
|
8
|
+
fakes: await fakesHash(paths, config),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export async function fakesHash(paths, config) {
|
|
12
|
+
const hash = createHash("sha256");
|
|
13
|
+
hash.update(JSON.stringify(Object.entries(config.hosts).sort()));
|
|
14
|
+
let files = [];
|
|
15
|
+
try {
|
|
16
|
+
files = (await readdir(paths.standins)).filter((file) => file.endsWith(".mjs")).sort();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// No fakes yet.
|
|
20
|
+
}
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
hash.update(file);
|
|
23
|
+
hash.update(await readFile(join(paths.standins, file)));
|
|
24
|
+
}
|
|
25
|
+
return short(hash.digest("hex"));
|
|
26
|
+
}
|
|
27
|
+
export function sameFingerprint(a, b) {
|
|
28
|
+
return a !== undefined && b !== undefined && a.spec === b.spec && a.test === b.test && a.fakes === b.fakes;
|
|
29
|
+
}
|
|
30
|
+
async function hashFile(file) {
|
|
31
|
+
try {
|
|
32
|
+
return short(createHash("sha256").update(await readFile(file)).digest("hex"));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return "missing";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function short(hex) {
|
|
39
|
+
return hex.slice(0, 16);
|
|
40
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Config } from "../config.js";
|
|
2
|
+
import { type Paths } from "../project.js";
|
|
3
|
+
import type { Approvals } from "./approvals.js";
|
|
4
|
+
import { type Fingerprint } from "./fingerprint.js";
|
|
5
|
+
import { type FlowResult, type RunReport } from "./results.js";
|
|
6
|
+
export declare const KEEP_RUNS = 20;
|
|
7
|
+
export type FlowState = "draft" | "unproven" | "unstable" | "ready" | "approved" | "out-of-date";
|
|
8
|
+
export interface FlowStatus {
|
|
9
|
+
name: string;
|
|
10
|
+
state: FlowState;
|
|
11
|
+
/** Runs with the current files, newest first, up to three. */
|
|
12
|
+
runs: string[];
|
|
13
|
+
/** Why a flow with three runs is still not ready, such as a requirement no test checks. */
|
|
14
|
+
reason?: string;
|
|
15
|
+
fingerprint: Fingerprint;
|
|
16
|
+
}
|
|
17
|
+
export declare function outDir(paths: Paths): string;
|
|
18
|
+
export declare function runIds(paths: Paths): Promise<string[]>;
|
|
19
|
+
export declare function readReport(paths: Paths, id: string): Promise<RunReport | undefined>;
|
|
20
|
+
/** Newest first. */
|
|
21
|
+
export declare function recentReports(paths: Paths): Promise<RunReport[]>;
|
|
22
|
+
export declare function pruneRuns(paths: Paths): Promise<void>;
|
|
23
|
+
export declare function flowStatuses(paths: Paths, config: Config, names: string[], approvals: Approvals, reports: RunReport[]): Promise<FlowStatus[]>;
|
|
24
|
+
/** Three runs with the current files and identical results per requirement, every requirement checked. */
|
|
25
|
+
export declare function proven(flows: FlowResult[]): {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
unstable: boolean;
|
|
28
|
+
reason?: string;
|
|
29
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readdir, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { exists } from "../project.js";
|
|
4
|
+
import { fingerprint, sameFingerprint } from "./fingerprint.js";
|
|
5
|
+
import { outcome } from "./results.js";
|
|
6
|
+
export const KEEP_RUNS = 20;
|
|
7
|
+
const RUN_ID = /^\d{8}-\d{6}(-\d+)?$/;
|
|
8
|
+
export function outDir(paths) {
|
|
9
|
+
return join(paths.e2e, "out");
|
|
10
|
+
}
|
|
11
|
+
export async function runIds(paths) {
|
|
12
|
+
try {
|
|
13
|
+
return (await readdir(outDir(paths))).filter((name) => RUN_ID.test(name)).sort();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function readReport(paths, id) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(await readFile(join(outDir(paths), id, "report.json"), "utf8"));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Newest first. */
|
|
28
|
+
export async function recentReports(paths) {
|
|
29
|
+
const ids = (await runIds(paths)).reverse();
|
|
30
|
+
const reports = await Promise.all(ids.map((id) => readReport(paths, id)));
|
|
31
|
+
return reports.filter((report) => report !== undefined);
|
|
32
|
+
}
|
|
33
|
+
export async function pruneRuns(paths) {
|
|
34
|
+
const ids = await runIds(paths);
|
|
35
|
+
for (const id of ids.slice(0, Math.max(0, ids.length - KEEP_RUNS))) {
|
|
36
|
+
await rm(join(outDir(paths), id), { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function flowStatuses(paths, config, names, approvals, reports) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
const current = await fingerprint(paths, config, name);
|
|
43
|
+
if (!(await exists(join(paths.tests, `${name}.spec.ts`)))) {
|
|
44
|
+
out.push({ name, state: "draft", runs: [], fingerprint: current });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const approval = approvals.flows[name];
|
|
48
|
+
const matching = [];
|
|
49
|
+
for (const report of reports) {
|
|
50
|
+
if (report.environmentError)
|
|
51
|
+
continue;
|
|
52
|
+
const flow = report.flows.find((candidate) => candidate.name === name);
|
|
53
|
+
if (flow && sameFingerprint(flow.fingerprint, current))
|
|
54
|
+
matching.push({ id: report.id, flow });
|
|
55
|
+
if (matching.length === 3)
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
const runs = matching.map((match) => match.id);
|
|
59
|
+
if (approval && sameFingerprint(approval, current)) {
|
|
60
|
+
out.push({ name, state: "approved", runs, fingerprint: current });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const proof = proven(matching.map((match) => match.flow));
|
|
64
|
+
const state = approval ? "out-of-date" : proof.ok ? "ready" : proof.unstable ? "unstable" : "unproven";
|
|
65
|
+
out.push({ name, state, runs, fingerprint: current, ...(proof.reason ? { reason: proof.reason } : {}) });
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/** Three runs with the current files and identical results per requirement, every requirement checked. */
|
|
70
|
+
export function proven(flows) {
|
|
71
|
+
if (flows.length < 3)
|
|
72
|
+
return { ok: false, unstable: false, reason: `${flows.length} of 3 runs with the current files` };
|
|
73
|
+
const outcomes = new Set(flows.map(outcome));
|
|
74
|
+
if (outcomes.size > 1)
|
|
75
|
+
return { ok: false, unstable: true, reason: "the last 3 runs gave different results" };
|
|
76
|
+
const latest = flows[0];
|
|
77
|
+
const unchecked = latest?.requirements.filter((requirement) => requirement.result === "not-checked").map((requirement) => requirement.id) ?? [];
|
|
78
|
+
if (unchecked.length > 0)
|
|
79
|
+
return { ok: false, unstable: false, reason: `no claim checks ${unchecked.join(", ")}` };
|
|
80
|
+
if (latest && latest.unknownClaims.length > 0)
|
|
81
|
+
return { ok: false, unstable: false, reason: `claims cite ${latest.unknownClaims.join(", ")}, which the spec does not have` };
|
|
82
|
+
if (latest && latest.requirements.length === 0)
|
|
83
|
+
return { ok: false, unstable: false, reason: "the spec has no numbered requirements" };
|
|
84
|
+
return { ok: true, unstable: false };
|
|
85
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FlowResult, RunReport } from "./results.js";
|
|
2
|
+
export type Verdict = "pass" | "fail" | "untrusted" | "environment";
|
|
3
|
+
/** The run's one-word result, matching the exit code: environment 3, a failed approved flow 1, untrusted 4, pass 0. */
|
|
4
|
+
export declare function verdict(report: RunReport): Verdict;
|
|
5
|
+
export declare const EXIT_FOR: Record<Verdict, number>;
|
|
6
|
+
/**
|
|
7
|
+
* The report a person reads: per requirement what was asked, what happened and the evidence.
|
|
8
|
+
* `image` turns a screenshot path relative to the run folder into a link, local or on GitHub.
|
|
9
|
+
*/
|
|
10
|
+
export declare function renderMarkdown(report: RunReport, image: (path: string) => string, options?: {
|
|
11
|
+
title?: string;
|
|
12
|
+
maxShots?: number;
|
|
13
|
+
}): string;
|
|
14
|
+
export declare function countResults(flow: FlowResult): string;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const LABEL = {
|
|
2
|
+
pass: "pass",
|
|
3
|
+
fail: "FAIL",
|
|
4
|
+
"not-covered": "not covered",
|
|
5
|
+
"not-reached": "FAIL (not reached)",
|
|
6
|
+
"not-checked": "not checked",
|
|
7
|
+
};
|
|
8
|
+
/** The run's one-word result, matching the exit code: environment 3, a failed approved flow 1, untrusted 4, pass 0. */
|
|
9
|
+
export function verdict(report) {
|
|
10
|
+
if (report.environmentError)
|
|
11
|
+
return "environment";
|
|
12
|
+
if (report.flows.some((flow) => flow.approved && flow.status === "failed"))
|
|
13
|
+
return "fail";
|
|
14
|
+
if (report.flows.some((flow) => !flow.approved))
|
|
15
|
+
return "untrusted";
|
|
16
|
+
return "pass";
|
|
17
|
+
}
|
|
18
|
+
export const EXIT_FOR = { pass: 0, fail: 1, environment: 3, untrusted: 4 };
|
|
19
|
+
const HEADLINE = {
|
|
20
|
+
pass: "PASS: every requirement of every approved flow held",
|
|
21
|
+
fail: "FAIL: an approved flow has a requirement that did not hold",
|
|
22
|
+
untrusted: "NOT TRUSTED: some flows are not approved, so their results prove nothing yet",
|
|
23
|
+
environment: "ENVIRONMENT FAILURE: the sandbox did not work, so this run says nothing about the app",
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The report a person reads: per requirement what was asked, what happened and the evidence.
|
|
27
|
+
* `image` turns a screenshot path relative to the run folder into a link, local or on GitHub.
|
|
28
|
+
*/
|
|
29
|
+
export function renderMarkdown(report, image, options = {}) {
|
|
30
|
+
const result = verdict(report);
|
|
31
|
+
const lines = [`## ${options.title ?? "proofy"}: ${HEADLINE[result]}`, ""];
|
|
32
|
+
const source = [report.source.pr ? `PR #${report.source.pr}` : undefined, report.source.commit ? `commit ${report.source.commit.slice(0, 12)}` : undefined, report.source.dirty ? "with uncommitted changes" : undefined].filter(Boolean);
|
|
33
|
+
lines.push(`Run ${report.id} (${report.kind}), ${source.join(", ") || "working tree"}. Sealed: the app had no way out; every outside call went to a fake or was refused.`, "");
|
|
34
|
+
if (report.environmentError) {
|
|
35
|
+
lines.push("```", report.environmentError, "```", "");
|
|
36
|
+
}
|
|
37
|
+
if (report.flows.length > 0) {
|
|
38
|
+
lines.push("| Flow | Result | Approved |", "|---|---|---|");
|
|
39
|
+
for (const flow of report.flows) {
|
|
40
|
+
const counts = countResults(flow);
|
|
41
|
+
lines.push(`| ${flow.name} | ${flow.status === "passed" ? "passed" : "FAILED"} (${counts}) | ${flow.approved ? "yes" : "no, result not trusted"} |`);
|
|
42
|
+
}
|
|
43
|
+
lines.push("");
|
|
44
|
+
}
|
|
45
|
+
for (const flow of report.flows) {
|
|
46
|
+
lines.push(...flowSection(flow, image, options.maxShots));
|
|
47
|
+
}
|
|
48
|
+
lines.push(...outsideSection(report));
|
|
49
|
+
if (report.notes.length > 0) {
|
|
50
|
+
lines.push("### Notes", "", ...report.notes.map((note) => `- ${note}`), "");
|
|
51
|
+
}
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
function flowSection(flow, image, maxShots = Infinity) {
|
|
55
|
+
const lines = [`### ${flow.name}`, ""];
|
|
56
|
+
lines.push("| Req | Result | Requirement | What the test did | Expected | Actual | Relied on fakes |", "|---|---|---|---|---|---|---|");
|
|
57
|
+
for (const requirement of flow.requirements) {
|
|
58
|
+
const did = requirement.result === "not-covered" ? `not covered: ${requirement.why ?? ""}` : (requirement.did ?? "");
|
|
59
|
+
lines.push(`| ${requirement.id} | ${LABEL[requirement.result]} | ${cell(requirement.text)} | ${cell(did)} | ${cell(requirement.expected ?? "")} | ${cell(requirement.actual ?? "")} | ${cell(requirement.fakes.join(", ") || "none")} |`);
|
|
60
|
+
}
|
|
61
|
+
lines.push("");
|
|
62
|
+
const evidence = flow.requirements.filter((requirement) => requirement.evidence !== undefined);
|
|
63
|
+
if (evidence.length > 0) {
|
|
64
|
+
lines.push("<details><summary>Evidence</summary>", "");
|
|
65
|
+
for (const requirement of evidence) {
|
|
66
|
+
lines.push(`${requirement.id}:`, "", "```json", JSON.stringify(requirement.evidence, null, 2).slice(0, 4000), "```", "");
|
|
67
|
+
}
|
|
68
|
+
lines.push("</details>", "");
|
|
69
|
+
}
|
|
70
|
+
const broken = flow.tests.filter((test) => test.status !== "passed");
|
|
71
|
+
for (const test of broken) {
|
|
72
|
+
lines.push(`**${test.title}**: ${test.status}${test.trace ? ` (trace: \`${test.trace}\`, open with \`npx playwright show-trace\`)` : ""}`, "", "```", (test.error ?? "").slice(0, 1500), "```", "");
|
|
73
|
+
}
|
|
74
|
+
if (flow.unknownClaims.length > 0) {
|
|
75
|
+
lines.push(`Claims cite ${flow.unknownClaims.join(", ")}, which the spec does not have.`, "");
|
|
76
|
+
}
|
|
77
|
+
if (flow.shots.length > 0) {
|
|
78
|
+
lines.push("<details><summary>Screenshots</summary>", "");
|
|
79
|
+
for (const shot of flow.shots.slice(0, maxShots)) {
|
|
80
|
+
lines.push(`${shot.label}`, "", `<img src="${image(`flows/${flow.name}/${shot.file}`)}" width="360">`, "");
|
|
81
|
+
}
|
|
82
|
+
if (flow.shots.length > maxShots)
|
|
83
|
+
lines.push(`and ${flow.shots.length - maxShots} more in the run folder`, "");
|
|
84
|
+
lines.push("</details>", "");
|
|
85
|
+
}
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
|
88
|
+
function outsideSection(report) {
|
|
89
|
+
if (report.outside.length === 0 && report.lookedUpOnly.length === 0)
|
|
90
|
+
return [];
|
|
91
|
+
const lines = ["### Outside calls", ""];
|
|
92
|
+
if (report.outside.length > 0) {
|
|
93
|
+
lines.push("| Host | Handled as | Requests |", "|---|---|---|");
|
|
94
|
+
for (const call of report.outside)
|
|
95
|
+
lines.push(`| ${call.host} | ${call.handledAs} | ${call.count} |`);
|
|
96
|
+
lines.push("");
|
|
97
|
+
}
|
|
98
|
+
const refused = report.outside.filter((call) => call.handledAs === "refused").map((call) => call.host);
|
|
99
|
+
if (refused.length > 0) {
|
|
100
|
+
lines.push(`Refused: ${refused.join(", ")}. If the app needs them, list them in e2e/proofy.json hosts.`, "");
|
|
101
|
+
}
|
|
102
|
+
if (report.lookedUpOnly.length > 0) {
|
|
103
|
+
lines.push(`Looked up but never called over HTTP (another protocol, or the call gave up): ${report.lookedUpOnly.join(", ")}.`, "");
|
|
104
|
+
}
|
|
105
|
+
return lines;
|
|
106
|
+
}
|
|
107
|
+
export function countResults(flow) {
|
|
108
|
+
const counts = new Map();
|
|
109
|
+
for (const requirement of flow.requirements)
|
|
110
|
+
counts.set(requirement.result, (counts.get(requirement.result) ?? 0) + 1);
|
|
111
|
+
return [...counts].map(([result, count]) => `${count} ${result}`).join(", ") || "no requirements";
|
|
112
|
+
}
|
|
113
|
+
function cell(text) {
|
|
114
|
+
return text.replace(/\|/g, "\\|").replace(/\r?\n/g, " ").slice(0, 300);
|
|
115
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Entry } from "../test/index.js";
|
|
2
|
+
import type { Fingerprint } from "./fingerprint.js";
|
|
3
|
+
import type { Requirement } from "./spec.js";
|
|
4
|
+
export type RequirementOutcome = "pass" | "fail" | "not-covered" | "not-reached" | "not-checked";
|
|
5
|
+
export interface RequirementResult {
|
|
6
|
+
id: string;
|
|
7
|
+
text: string;
|
|
8
|
+
result: RequirementOutcome;
|
|
9
|
+
did?: string;
|
|
10
|
+
expected?: string;
|
|
11
|
+
actual?: string;
|
|
12
|
+
evidence?: unknown;
|
|
13
|
+
why?: string;
|
|
14
|
+
fakes: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface TestResult {
|
|
17
|
+
title: string;
|
|
18
|
+
status: string;
|
|
19
|
+
durationMs: number;
|
|
20
|
+
error?: string;
|
|
21
|
+
trace?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface FlowResult {
|
|
24
|
+
name: string;
|
|
25
|
+
approved: boolean;
|
|
26
|
+
fingerprint: Fingerprint;
|
|
27
|
+
status: "passed" | "failed";
|
|
28
|
+
requirements: RequirementResult[];
|
|
29
|
+
tests: TestResult[];
|
|
30
|
+
shots: Array<{
|
|
31
|
+
test: string;
|
|
32
|
+
file: string;
|
|
33
|
+
label: string;
|
|
34
|
+
}>;
|
|
35
|
+
/** Claims citing a requirement number the spec does not have. */
|
|
36
|
+
unknownClaims: string[];
|
|
37
|
+
}
|
|
38
|
+
export interface OutsideCall {
|
|
39
|
+
host: string;
|
|
40
|
+
handledAs: string;
|
|
41
|
+
count: number;
|
|
42
|
+
}
|
|
43
|
+
export interface RunReport {
|
|
44
|
+
id: string;
|
|
45
|
+
kind: "run" | "dev";
|
|
46
|
+
startedAt: string;
|
|
47
|
+
finishedAt: string;
|
|
48
|
+
source: {
|
|
49
|
+
commit?: string;
|
|
50
|
+
dirty?: boolean;
|
|
51
|
+
pr?: number;
|
|
52
|
+
};
|
|
53
|
+
flows: FlowResult[];
|
|
54
|
+
outside: OutsideCall[];
|
|
55
|
+
/** Names the app looked up but never sent an HTTP request to, such as an SMTP host. */
|
|
56
|
+
lookedUpOnly: string[];
|
|
57
|
+
notes: string[];
|
|
58
|
+
environmentError?: string;
|
|
59
|
+
}
|
|
60
|
+
export interface PlannedFlow {
|
|
61
|
+
name: string;
|
|
62
|
+
approved: boolean;
|
|
63
|
+
fingerprint: Fingerprint;
|
|
64
|
+
requirements: Requirement[];
|
|
65
|
+
}
|
|
66
|
+
/** Reads what the runner and the fake-service container wrote to the run folder. */
|
|
67
|
+
export declare function collectResults(outDir: string, planned: PlannedFlow[]): Promise<Pick<RunReport, "flows" | "outside" | "lookedUpOnly" | "environmentError">>;
|
|
68
|
+
export declare function flowResult(plan: PlannedFlow, entries: Entry[], tests: TestResult[]): FlowResult;
|
|
69
|
+
/** The per-requirement results that must be identical across three runs before a flow can be approved. */
|
|
70
|
+
export declare function outcome(flow: FlowResult): string;
|