@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,129 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
/** Reads what the runner and the fake-service container wrote to the run folder. */
|
|
4
|
+
export async function collectResults(outDir, planned) {
|
|
5
|
+
const pw = (await readJson(join(outDir, "playwright.json")));
|
|
6
|
+
const tests = new Map();
|
|
7
|
+
for (const spec of specsOf(pw?.suites ?? [])) {
|
|
8
|
+
const flow = basename(spec.file).replace(/\.spec\.[cm]?[jt]s$/, "");
|
|
9
|
+
for (const test of spec.tests) {
|
|
10
|
+
const result = test.results.at(-1);
|
|
11
|
+
if (!result)
|
|
12
|
+
continue;
|
|
13
|
+
const messages = [...new Set([result.error?.message, ...(result.errors ?? []).map((error) => error.message)].filter((message) => Boolean(message)))];
|
|
14
|
+
const error = messages.join("\n\n");
|
|
15
|
+
const trace = result.attachments?.find((attachment) => attachment.name === "trace")?.path;
|
|
16
|
+
const list = tests.get(flow) ?? [];
|
|
17
|
+
list.push({
|
|
18
|
+
title: spec.title,
|
|
19
|
+
status: result.status,
|
|
20
|
+
durationMs: result.duration,
|
|
21
|
+
...(error ? { error: stripAnsi(error).slice(0, 2000) } : {}),
|
|
22
|
+
...(trace ? { trace: trace.replace(/^\/out\//, "") } : {}),
|
|
23
|
+
});
|
|
24
|
+
tests.set(flow, list);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const flows = [];
|
|
28
|
+
for (const plan of planned) {
|
|
29
|
+
const entries = await readLines(join(outDir, "flows", plan.name, "claims.jsonl"));
|
|
30
|
+
const flowTests = tests.get(plan.name) ?? [];
|
|
31
|
+
flows.push(flowResult(plan, entries, flowTests));
|
|
32
|
+
}
|
|
33
|
+
const requests = await readLines(join(outDir, "requests.jsonl"));
|
|
34
|
+
const counts = new Map();
|
|
35
|
+
for (const request of requests) {
|
|
36
|
+
const key = `${request.host} ${request.handledAs}`;
|
|
37
|
+
const entry = counts.get(key) ?? { host: request.host, handledAs: request.handledAs, count: 0 };
|
|
38
|
+
entry.count++;
|
|
39
|
+
counts.set(key, entry);
|
|
40
|
+
}
|
|
41
|
+
const contacted = new Set(requests.map((request) => request.host));
|
|
42
|
+
const lookups = await readLines(join(outDir, "dns.jsonl"));
|
|
43
|
+
const environmentError = pw?.errors?.map((error) => stripAnsi(error.message ?? "")).find(Boolean);
|
|
44
|
+
return {
|
|
45
|
+
flows,
|
|
46
|
+
outside: [...counts.values()].sort((a, b) => a.host.localeCompare(b.host) || a.handledAs.localeCompare(b.handledAs)),
|
|
47
|
+
lookedUpOnly: [...new Set(lookups.map((lookup) => lookup.name))].filter((name) => !contacted.has(name)).sort(),
|
|
48
|
+
...(environmentError ? { environmentError: environmentError.slice(0, 2000) } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function flowResult(plan, entries, tests) {
|
|
52
|
+
const known = new Set(plan.requirements.map((requirement) => requirement.id));
|
|
53
|
+
const anyTestBroke = tests.length === 0 || tests.some((test) => test.status !== "passed" && !/Requirements not met/.test(test.error ?? ""));
|
|
54
|
+
const requirements = plan.requirements.map((requirement) => {
|
|
55
|
+
const claims = entries.filter((entry) => entry.type === "claim" && entry.spec === requirement.id);
|
|
56
|
+
const failed = claims.find((entry) => !entry.ok);
|
|
57
|
+
const shown = failed ?? claims[0];
|
|
58
|
+
if (shown) {
|
|
59
|
+
const fakes = [...new Set(claims.flatMap((entry) => entry.fakes))].sort();
|
|
60
|
+
return {
|
|
61
|
+
id: requirement.id,
|
|
62
|
+
text: requirement.text,
|
|
63
|
+
result: failed ? "fail" : "pass",
|
|
64
|
+
did: shown.did,
|
|
65
|
+
expected: shown.expected,
|
|
66
|
+
actual: shown.actual,
|
|
67
|
+
...(shown.evidence === undefined ? {} : { evidence: shown.evidence }),
|
|
68
|
+
fakes,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const skipped = entries.find((entry) => entry.type === "not-covered" && entry.spec === requirement.id);
|
|
72
|
+
if (skipped)
|
|
73
|
+
return { id: requirement.id, text: requirement.text, result: "not-covered", why: skipped.why, fakes: [] };
|
|
74
|
+
return { id: requirement.id, text: requirement.text, result: anyTestBroke ? "not-reached" : "not-checked", fakes: [] };
|
|
75
|
+
});
|
|
76
|
+
const unknownClaims = [...new Set(entries.flatMap((entry) => (entry.type === "shot" || known.has(entry.spec) ? [] : [entry.spec])))];
|
|
77
|
+
return {
|
|
78
|
+
name: plan.name,
|
|
79
|
+
approved: plan.approved,
|
|
80
|
+
fingerprint: plan.fingerprint,
|
|
81
|
+
status: requirements.some((requirement) => requirement.result === "fail" || requirement.result === "not-reached") ? "failed" : "passed",
|
|
82
|
+
requirements,
|
|
83
|
+
tests,
|
|
84
|
+
shots: entries.filter((entry) => entry.type === "shot").map(({ test, file, label }) => ({ test, file, label })),
|
|
85
|
+
unknownClaims,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** The per-requirement results that must be identical across three runs before a flow can be approved. */
|
|
89
|
+
export function outcome(flow) {
|
|
90
|
+
return flow.requirements.map((requirement) => `${requirement.id}:${requirement.result}`).join(" ");
|
|
91
|
+
}
|
|
92
|
+
function* specsOf(suites) {
|
|
93
|
+
for (const suite of suites) {
|
|
94
|
+
yield* suite.specs ?? [];
|
|
95
|
+
yield* specsOf(suite.suites ?? []);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function readJson(file) {
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function readLines(file) {
|
|
107
|
+
let raw;
|
|
108
|
+
try {
|
|
109
|
+
raw = await readFile(file, "utf8");
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
return raw
|
|
115
|
+
.split("\n")
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.flatMap((line) => {
|
|
118
|
+
try {
|
|
119
|
+
return [JSON.parse(line)];
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
127
|
+
function stripAnsi(text) {
|
|
128
|
+
return text.replace(ANSI, "");
|
|
129
|
+
}
|
package/dist/run/spec.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Numbered requirements from a spec file: lines starting with R1., R2: or R3). */
|
|
2
|
+
export function parseRequirements(markdown) {
|
|
3
|
+
const out = [];
|
|
4
|
+
const seen = new Set();
|
|
5
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
6
|
+
const match = /^\s*(?:[-*]\s+)?\**(R\d+)\**[.:)]\s+(.+?)\s*$/.exec(line);
|
|
7
|
+
if (!match)
|
|
8
|
+
continue;
|
|
9
|
+
const [, id = "", text = ""] = match;
|
|
10
|
+
if (seen.has(id))
|
|
11
|
+
continue;
|
|
12
|
+
seen.add(id);
|
|
13
|
+
out.push({ id, text });
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Config } from "../config.js";
|
|
2
|
+
export type Mode = "run" | "dev";
|
|
3
|
+
export interface ComposeInputs {
|
|
4
|
+
/** The app's compose files, normalized by `docker compose config --format json`. */
|
|
5
|
+
model: Record<string, unknown>;
|
|
6
|
+
config: Config;
|
|
7
|
+
mode: Mode;
|
|
8
|
+
subnet: string;
|
|
9
|
+
standinIp: string;
|
|
10
|
+
standinImage: string;
|
|
11
|
+
runnerImage: string;
|
|
12
|
+
/** "uid:gid" of the person running proofy, so files written into mounted folders stay theirs on Linux. */
|
|
13
|
+
user: string;
|
|
14
|
+
dirs: {
|
|
15
|
+
pkg: string;
|
|
16
|
+
ca: string;
|
|
17
|
+
runSetup: string;
|
|
18
|
+
out: string;
|
|
19
|
+
e2e: string;
|
|
20
|
+
work: string;
|
|
21
|
+
nodeModules: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface Generated {
|
|
25
|
+
compose: Record<string, unknown>;
|
|
26
|
+
notes: string[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Rewrites the app's own compose model into a sealed sandbox: one internal network with no way out, every
|
|
30
|
+
* hostname answered by the fake-service container, no published ports, settings only from the proofy config.
|
|
31
|
+
*/
|
|
32
|
+
export declare function sandboxCompose(inputs: ComposeInputs): Generated;
|
|
33
|
+
export declare function baseUrl(config: Config): string;
|
|
34
|
+
/** A /24 per project, away from the ranges Docker and home networks usually take. */
|
|
35
|
+
export declare function subnetFor(seed: string, attempt: number): {
|
|
36
|
+
subnet: string;
|
|
37
|
+
standinIp: string;
|
|
38
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { CliError } from "../errors.js";
|
|
2
|
+
import { FORWARD, MAILPIT, MAILPIT_IMAGE, MOUNT, RUNNER, STANDIN } from "./contract.js";
|
|
3
|
+
/**
|
|
4
|
+
* Rewrites the app's own compose model into a sealed sandbox: one internal network with no way out, every
|
|
5
|
+
* hostname answered by the fake-service container, no published ports, settings only from the proofy config.
|
|
6
|
+
*/
|
|
7
|
+
export function sandboxCompose(inputs) {
|
|
8
|
+
const { model, config, mode, dirs } = inputs;
|
|
9
|
+
const notes = [];
|
|
10
|
+
const userServices = (model.services ?? {});
|
|
11
|
+
const services = {};
|
|
12
|
+
for (const name of Object.keys(config.env)) {
|
|
13
|
+
if (!(name in userServices)) {
|
|
14
|
+
throw new CliError(`e2e/proofy.json env names service "${name}", which is not in the compose files`, "CONFIG_INVALID", [
|
|
15
|
+
`Services: ${Object.keys(userServices).join(", ") || "none"}`,
|
|
16
|
+
]);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const named = [
|
|
20
|
+
["app", config.app],
|
|
21
|
+
["seed", config.seed],
|
|
22
|
+
...Object.entries(config.exec).map(([key, value]) => [`exec.${key}`, value]),
|
|
23
|
+
];
|
|
24
|
+
for (const [label, entry] of named) {
|
|
25
|
+
if (entry && !(entry.service in userServices)) {
|
|
26
|
+
throw new CliError(`e2e/proofy.json ${label} names service "${entry.service}", which is not in the compose files`, "CONFIG_INVALID", [
|
|
27
|
+
`Services: ${Object.keys(userServices).join(", ") || "none"}`,
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (const [name, original] of Object.entries(userServices)) {
|
|
32
|
+
if (name.startsWith("proofy-") || name === MAILPIT) {
|
|
33
|
+
throw new CliError(`The compose service name "${name}" is reserved by proofy`, "CONFIG_INVALID", ["Rename the service in your compose file"]);
|
|
34
|
+
}
|
|
35
|
+
const service = { ...original };
|
|
36
|
+
if (service.network_mode !== undefined) {
|
|
37
|
+
throw new CliError(`Service ${name} sets network_mode, which would bypass the sealed network`, "CONFIG_INVALID", [
|
|
38
|
+
"Remove network_mode from the compose file proofy uses",
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
if (service.env_file !== undefined) {
|
|
42
|
+
notes.push(`${name}: env_file ignored, settings come only from e2e/proofy.json env`);
|
|
43
|
+
delete service.env_file;
|
|
44
|
+
}
|
|
45
|
+
delete service.ports;
|
|
46
|
+
delete service.container_name;
|
|
47
|
+
service.networks = { proofy: { aliases: aliasesOf(original.networks) } };
|
|
48
|
+
service.dns = [inputs.standinIp];
|
|
49
|
+
const environment = { ...(original.environment ?? {}), ...(config.env[name] ?? {}) };
|
|
50
|
+
service.environment = environment;
|
|
51
|
+
service.volumes = [...(original.volumes ?? []), { type: "bind", source: dirs.ca, target: MOUNT.appCa, read_only: true }];
|
|
52
|
+
service.depends_on = { ...(original.depends_on ?? {}), [STANDIN]: { condition: "service_healthy", required: true } };
|
|
53
|
+
services[name] = service;
|
|
54
|
+
}
|
|
55
|
+
services[STANDIN] = {
|
|
56
|
+
image: inputs.standinImage,
|
|
57
|
+
user: inputs.user,
|
|
58
|
+
command: ["node", `${MOUNT.pkg}/dist/standin/server.js`],
|
|
59
|
+
volumes: [
|
|
60
|
+
bind(dirs.pkg, MOUNT.pkg, true),
|
|
61
|
+
bind(dirs.ca, MOUNT.ca, false),
|
|
62
|
+
bind(dirs.runSetup, MOUNT.runSetup, true),
|
|
63
|
+
bind(dirs.out, MOUNT.out, false),
|
|
64
|
+
bind(dirs.e2e, MOUNT.e2e, true),
|
|
65
|
+
],
|
|
66
|
+
networks: { proofy: { ipv4_address: inputs.standinIp } },
|
|
67
|
+
healthcheck: { test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8099/health"], interval: "1s", timeout: "2s", retries: 60 },
|
|
68
|
+
};
|
|
69
|
+
services[MAILPIT] = { image: MAILPIT_IMAGE, networks: { proofy: {} }, environment: { MP_SMTP_AUTH_ACCEPT_ANY: "1", MP_SMTP_AUTH_ALLOW_INSECURE: "1" } };
|
|
70
|
+
services[RUNNER] = {
|
|
71
|
+
image: inputs.runnerImage,
|
|
72
|
+
user: inputs.user,
|
|
73
|
+
profiles: [RUNNER],
|
|
74
|
+
working_dir: MOUNT.work,
|
|
75
|
+
shm_size: "1gb",
|
|
76
|
+
dns: [inputs.standinIp],
|
|
77
|
+
networks: { proofy: {} },
|
|
78
|
+
environment: { CI: "1", HOME: "/tmp", PROOFY_BASE_URL: baseUrl(config), PROOFY_START_MS: String(config.limits.startMinutes * 60_000) },
|
|
79
|
+
volumes: [
|
|
80
|
+
bind(dirs.work, MOUNT.work, false),
|
|
81
|
+
bind(dirs.pkg, MOUNT.pkg, true),
|
|
82
|
+
bind(dirs.nodeModules, "/node_modules", true),
|
|
83
|
+
bind(dirs.e2e, MOUNT.e2e, true),
|
|
84
|
+
bind(dirs.out, MOUNT.out, false),
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
const networks = { proofy: { internal: true, ipam: { config: [{ subnet: inputs.subnet }] } } };
|
|
88
|
+
if (mode === "dev" && config.app) {
|
|
89
|
+
// The only container with a second network: it forwards host ports in and gives nothing a way out.
|
|
90
|
+
services[FORWARD] = {
|
|
91
|
+
image: inputs.standinImage,
|
|
92
|
+
user: inputs.user,
|
|
93
|
+
command: ["node", `${MOUNT.pkg}/dist/standin/forward.js`, `8080=${config.app.service}:${config.app.port}`, `8025=${MAILPIT}:8025`],
|
|
94
|
+
volumes: [bind(dirs.pkg, MOUNT.pkg, true)],
|
|
95
|
+
networks: { proofy: {}, "proofy-host": {} },
|
|
96
|
+
ports: ["127.0.0.1::8080", "127.0.0.1::8025"],
|
|
97
|
+
};
|
|
98
|
+
networks["proofy-host"] = {};
|
|
99
|
+
}
|
|
100
|
+
const compose = { services, networks };
|
|
101
|
+
if (model.volumes)
|
|
102
|
+
compose.volumes = model.volumes;
|
|
103
|
+
return { compose, notes };
|
|
104
|
+
}
|
|
105
|
+
export function baseUrl(config) {
|
|
106
|
+
return config.app ? `http://${config.app.service}:${config.app.port}${config.app.path}` : "";
|
|
107
|
+
}
|
|
108
|
+
/** A /24 per project, away from the ranges Docker and home networks usually take. */
|
|
109
|
+
export function subnetFor(seed, attempt) {
|
|
110
|
+
let hash = 0;
|
|
111
|
+
for (const char of `${seed}:${attempt}`)
|
|
112
|
+
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
|
113
|
+
const second = 200 + (hash % 50);
|
|
114
|
+
const third = (hash >>> 8) % 256;
|
|
115
|
+
return { subnet: `10.${second}.${third}.0/24`, standinIp: `10.${second}.${third}.53` };
|
|
116
|
+
}
|
|
117
|
+
function aliasesOf(networks) {
|
|
118
|
+
if (!networks || typeof networks !== "object" || Array.isArray(networks))
|
|
119
|
+
return [];
|
|
120
|
+
const aliases = new Set();
|
|
121
|
+
for (const value of Object.values(networks)) {
|
|
122
|
+
if (value && typeof value === "object" && Array.isArray(value.aliases)) {
|
|
123
|
+
for (const alias of value.aliases)
|
|
124
|
+
if (typeof alias === "string")
|
|
125
|
+
aliases.add(alias);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return [...aliases];
|
|
129
|
+
}
|
|
130
|
+
function bind(source, target, readOnly) {
|
|
131
|
+
return { type: "bind", source, target, read_only: readOnly };
|
|
132
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const STANDIN = "proofy-standin";
|
|
2
|
+
export declare const MAILPIT = "mailpit";
|
|
3
|
+
export declare const RUNNER = "proofy-runner";
|
|
4
|
+
export declare const FORWARD = "proofy-forward";
|
|
5
|
+
export declare const ADMIN_PORT = 8099;
|
|
6
|
+
export declare const MAILPIT_API = "http://mailpit:8025";
|
|
7
|
+
export declare const STANDIN_ADMIN = "http://proofy-standin:8099";
|
|
8
|
+
/** Mount points inside containers. */
|
|
9
|
+
export declare const MOUNT: {
|
|
10
|
+
readonly ca: "/proofy-ca";
|
|
11
|
+
readonly appCa: "/proofy";
|
|
12
|
+
readonly runSetup: "/proofy-run";
|
|
13
|
+
readonly out: "/out";
|
|
14
|
+
readonly e2e: "/e2e";
|
|
15
|
+
readonly work: "/work";
|
|
16
|
+
readonly pkg: "/proofy-pkg";
|
|
17
|
+
readonly exec: "/work/exec";
|
|
18
|
+
};
|
|
19
|
+
export declare const MAILPIT_IMAGE = "axllent/mailpit:v1.27.11";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Names and paths shared by the host side and the containers. Changing one changes both.
|
|
2
|
+
export const STANDIN = "proofy-standin";
|
|
3
|
+
export const MAILPIT = "mailpit";
|
|
4
|
+
export const RUNNER = "proofy-runner";
|
|
5
|
+
export const FORWARD = "proofy-forward";
|
|
6
|
+
export const ADMIN_PORT = 8099;
|
|
7
|
+
export const MAILPIT_API = `http://${MAILPIT}:8025`;
|
|
8
|
+
export const STANDIN_ADMIN = `http://${STANDIN}:${ADMIN_PORT}`;
|
|
9
|
+
/** Mount points inside containers. */
|
|
10
|
+
export const MOUNT = {
|
|
11
|
+
ca: "/proofy-ca",
|
|
12
|
+
appCa: "/proofy",
|
|
13
|
+
runSetup: "/proofy-run",
|
|
14
|
+
out: "/out",
|
|
15
|
+
e2e: "/e2e",
|
|
16
|
+
work: "/work",
|
|
17
|
+
pkg: "/proofy-pkg",
|
|
18
|
+
exec: "/work/exec",
|
|
19
|
+
};
|
|
20
|
+
export const MAILPIT_IMAGE = "axllent/mailpit:v1.27.11";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ProcResult {
|
|
2
|
+
code: number;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
timedOut: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface ProcOptions {
|
|
8
|
+
cwd?: string;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
/** Streams output as it comes, for long steps such as image builds and test runs. */
|
|
11
|
+
onOutput?: (chunk: string) => void;
|
|
12
|
+
}
|
|
13
|
+
/** Runs a program without a shell. Injected so tests never start Docker. */
|
|
14
|
+
export type Proc = (program: string, args: string[], options?: ProcOptions) => Promise<ProcResult>;
|
|
15
|
+
export declare const spawnProc: Proc;
|
|
16
|
+
/** The last lines of a command's output, for error messages that point at the cause. */
|
|
17
|
+
export declare function tail(text: string, lines?: number): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { CliError } from "../errors.js";
|
|
3
|
+
export const spawnProc = (program, args, options = {}) => new Promise((resolve, reject) => {
|
|
4
|
+
const child = spawn(program, args, { cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
5
|
+
let stdout = "";
|
|
6
|
+
let stderr = "";
|
|
7
|
+
let timedOut = false;
|
|
8
|
+
const timer = options.timeoutMs === undefined
|
|
9
|
+
? undefined
|
|
10
|
+
: setTimeout(() => {
|
|
11
|
+
timedOut = true;
|
|
12
|
+
child.kill("SIGTERM");
|
|
13
|
+
}, options.timeoutMs);
|
|
14
|
+
child.stdout.on("data", (chunk) => {
|
|
15
|
+
stdout += chunk.toString();
|
|
16
|
+
options.onOutput?.(chunk.toString());
|
|
17
|
+
});
|
|
18
|
+
child.stderr.on("data", (chunk) => {
|
|
19
|
+
stderr += chunk.toString();
|
|
20
|
+
options.onOutput?.(chunk.toString());
|
|
21
|
+
});
|
|
22
|
+
child.on("error", (error) => {
|
|
23
|
+
if (timer)
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
if (error.code === "ENOENT") {
|
|
26
|
+
reject(new CliError(`${program} is not installed or not on PATH`, "ENVIRONMENT_ERROR", [installHint(program)]));
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
reject(error);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
child.on("close", (code) => {
|
|
33
|
+
if (timer)
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
resolve({ code: code ?? 1, stdout, stderr, timedOut });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
function installHint(program) {
|
|
39
|
+
if (program === "docker")
|
|
40
|
+
return "Install Docker (Docker Desktop, OrbStack or Docker Engine) and start it";
|
|
41
|
+
if (program === "gh")
|
|
42
|
+
return "Install the GitHub CLI: https://cli.github.com, then run `gh auth login`";
|
|
43
|
+
return `Install ${program}`;
|
|
44
|
+
}
|
|
45
|
+
/** The last lines of a command's output, for error messages that point at the cause. */
|
|
46
|
+
export function tail(text, lines = 15) {
|
|
47
|
+
return text.trim().split("\n").slice(-lines).join("\n");
|
|
48
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Config } from "../config.js";
|
|
2
|
+
import { type Mode } from "./compose.js";
|
|
3
|
+
import { type Proc } from "./docker.js";
|
|
4
|
+
export declare const PKG_DIR: string;
|
|
5
|
+
export interface SandboxOptions {
|
|
6
|
+
/** Where the app's compose files resolve: the repository, or an exported pull request. */
|
|
7
|
+
sourceDir: string;
|
|
8
|
+
/** The e2e folder the tests and fakes come from. */
|
|
9
|
+
e2eDir: string;
|
|
10
|
+
/** Where the sandbox's own logs land: the run folder, or e2e/out for a dev sandbox. */
|
|
11
|
+
logDir: string;
|
|
12
|
+
config: Config;
|
|
13
|
+
mode: Mode;
|
|
14
|
+
/** Unique per sandbox; also the compose project name. */
|
|
15
|
+
project: string;
|
|
16
|
+
/** Shared by every run of this repository: the certificate authority. */
|
|
17
|
+
cacheDir: string;
|
|
18
|
+
env: NodeJS.ProcessEnv;
|
|
19
|
+
log: (line: string) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface Sandbox {
|
|
22
|
+
project: string;
|
|
23
|
+
composeFile: string;
|
|
24
|
+
workDir: string;
|
|
25
|
+
notes: string[];
|
|
26
|
+
}
|
|
27
|
+
export declare function cacheDirFor(env: NodeJS.ProcessEnv, root: string): string;
|
|
28
|
+
export declare function playwrightVersion(): string;
|
|
29
|
+
export declare function runnerImage(): string;
|
|
30
|
+
export declare function standinImage(): string;
|
|
31
|
+
/** Install phase, with internet: images and builds. Nothing of the app runs yet. */
|
|
32
|
+
export declare function installImages(proc: Proc, options: SandboxOptions, sandbox: Sandbox): Promise<void>;
|
|
33
|
+
/** Writes the sealed compose file and everything the containers mount. */
|
|
34
|
+
export declare function prepare(proc: Proc, options: SandboxOptions, attempt?: number): Promise<Sandbox>;
|
|
35
|
+
/** Sealed phase: starts the app with no way out, waits for it, seeds the empty database. */
|
|
36
|
+
export declare function start(proc: Proc, options: SandboxOptions, sandbox: Sandbox): Promise<Sandbox>;
|
|
37
|
+
/** Runs Playwright in the runner container, answering exec() requests from the tests meanwhile. */
|
|
38
|
+
export declare function runTests(proc: Proc, options: SandboxOptions, sandbox: Sandbox, playwrightArgs: string[]): Promise<number>;
|
|
39
|
+
export declare function saveLogs(proc: Proc, sandbox: Sandbox, dir: string): Promise<void>;
|
|
40
|
+
export declare function stop(proc: Proc, sandbox: Sandbox): Promise<void>;
|
|
41
|
+
export declare function forwardedPorts(proc: Proc, sandbox: Sandbox): Promise<{
|
|
42
|
+
app?: string;
|
|
43
|
+
mail?: string;
|
|
44
|
+
}>;
|
|
45
|
+
export declare function hostUser(): string;
|
|
46
|
+
export declare function compose(sandbox: Pick<Sandbox, "project" | "composeFile">): string[];
|