@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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -2
  3. package/dist/args.d.ts +10 -0
  4. package/dist/args.js +36 -0
  5. package/dist/bin/proofy.d.ts +2 -0
  6. package/dist/bin/proofy.js +4 -0
  7. package/dist/cli.d.ts +9 -0
  8. package/dist/cli.js +140 -0
  9. package/dist/commands/approve.d.ts +4 -0
  10. package/dist/commands/approve.js +48 -0
  11. package/dist/commands/context.d.ts +16 -0
  12. package/dist/commands/context.js +27 -0
  13. package/dist/commands/dev.d.ts +4 -0
  14. package/dist/commands/dev.js +161 -0
  15. package/dist/commands/home.d.ts +3 -0
  16. package/dist/commands/home.js +122 -0
  17. package/dist/commands/init.d.ts +4 -0
  18. package/dist/commands/init.js +122 -0
  19. package/dist/commands/report.d.ts +4 -0
  20. package/dist/commands/report.js +26 -0
  21. package/dist/commands/run.d.ts +6 -0
  22. package/dist/commands/run.js +166 -0
  23. package/dist/commands/types.d.ts +8 -0
  24. package/dist/commands/types.js +1 -0
  25. package/dist/config.d.ts +34 -0
  26. package/dist/config.js +62 -0
  27. package/dist/deps.d.ts +10 -0
  28. package/dist/deps.js +1 -0
  29. package/dist/detect.d.ts +17 -0
  30. package/dist/detect.js +96 -0
  31. package/dist/errors.d.ts +6 -0
  32. package/dist/errors.js +21 -0
  33. package/dist/github.d.ts +16 -0
  34. package/dist/github.js +121 -0
  35. package/dist/human.d.ts +30 -0
  36. package/dist/human.js +160 -0
  37. package/dist/output.d.ts +8 -0
  38. package/dist/output.js +21 -0
  39. package/dist/project.d.ts +21 -0
  40. package/dist/project.js +71 -0
  41. package/dist/run/approvals.d.ts +15 -0
  42. package/dist/run/approvals.js +26 -0
  43. package/dist/run/engine.d.ts +48 -0
  44. package/dist/run/engine.js +168 -0
  45. package/dist/run/fingerprint.d.ts +11 -0
  46. package/dist/run/fingerprint.js +40 -0
  47. package/dist/run/history.d.ts +29 -0
  48. package/dist/run/history.js +85 -0
  49. package/dist/run/report.d.ts +14 -0
  50. package/dist/run/report.js +115 -0
  51. package/dist/run/results.d.ts +70 -0
  52. package/dist/run/results.js +129 -0
  53. package/dist/run/spec.d.ts +6 -0
  54. package/dist/run/spec.js +16 -0
  55. package/dist/sandbox/compose.d.ts +38 -0
  56. package/dist/sandbox/compose.js +132 -0
  57. package/dist/sandbox/contract.d.ts +19 -0
  58. package/dist/sandbox/contract.js +20 -0
  59. package/dist/sandbox/docker.d.ts +17 -0
  60. package/dist/sandbox/docker.js +48 -0
  61. package/dist/sandbox/sandbox.d.ts +46 -0
  62. package/dist/sandbox/sandbox.js +239 -0
  63. package/dist/standin/ca.d.ts +6 -0
  64. package/dist/standin/ca.js +40 -0
  65. package/dist/standin/dns.d.ts +10 -0
  66. package/dist/standin/dns.js +53 -0
  67. package/dist/standin/forward.d.ts +1 -0
  68. package/dist/standin/forward.js +17 -0
  69. package/dist/standin/handle.d.ts +49 -0
  70. package/dist/standin/handle.js +130 -0
  71. package/dist/standin/server.d.ts +1 -0
  72. package/dist/standin/server.js +88 -0
  73. package/dist/style.d.ts +23 -0
  74. package/dist/style.js +38 -0
  75. package/dist/test/global-setup.d.ts +1 -0
  76. package/dist/test/global-setup.js +20 -0
  77. package/dist/test/index.d.ts +102 -0
  78. package/dist/test/index.js +184 -0
  79. package/docker/standin.Dockerfile +3 -0
  80. package/package.json +68 -4
  81. package/schema/proofy.schema.json +179 -0
  82. package/templates/e2e/AGENTS.md +90 -0
  83. package/templates/e2e/templates/compose.yml +33 -0
  84. package/templates/e2e/templates/spec.md +20 -0
  85. package/templates/e2e/templates/standin.mjs +29 -0
  86. package/templates/e2e/templates/test.spec.ts +28 -0
@@ -0,0 +1,90 @@
1
+ # End-to-end tests with proofy
2
+
3
+ This folder holds end-to-end tests that check what the people who run this app asked for, through the real app in a real browser.
4
+ You, the agent, write the requirements, the tests and the fake outside services.
5
+ The human approves them.
6
+ `proofy run` then checks the app in a sealed Docker sandbox with no AI, and every result must be one the human can trust.
7
+
8
+ Run `proofy` at any time: it shows what is set up, every flow's state and the next step.
9
+
10
+ ## Rules
11
+
12
+ These are not optional.
13
+ A test suite that breaks them produces green reports nobody can trust, which is worse than no tests.
14
+
15
+ 1. Never run `proofy approve`. Only the human approves.
16
+ 2. Never weaken a check to make a run green. A red result caused by the app is a finding: report it to the human with the evidence.
17
+ 3. Never edit an approved requirements file without asking the human first.
18
+ 4. Write requirements before reading the app's code, from what the human asked for. Read the code only after they are written, to find buttons and fields.
19
+ 5. Write fakes for outside services from the provider's public documentation, never from what this app sends. Link the documentation at the top of the fake.
20
+ 6. Every claim compares a real value: an amount, a status, a count, a stored record, a request a fake received or an email in Mailpit. Text on the screen alone does not prove a requirement, unless the requirement is about what the screen shows.
21
+ 7. Every test creates its own data with `tag()`. Never rely on another test's data or on order.
22
+
23
+ ## Files
24
+
25
+ | Path | What |
26
+ |---|---|
27
+ | `proofy.json` | Config. Every field is described in its `$schema` |
28
+ | `proofy.lock.json` | Approvals, written only by `proofy approve`. Commit it |
29
+ | `specs/<flow>.md` | Requirements for one flow, numbered R1, R2 ... |
30
+ | `tests/<flow>.spec.ts` | The test for that flow, same name |
31
+ | `standins/<host>.mjs` | A fake for one outside host |
32
+ | `templates/` | Examples to copy |
33
+ | `out/` | Runs, ignored by git |
34
+
35
+ ## Setup
36
+
37
+ Do this once, when the human asks you to set up proofy.
38
+ `proofy` lists what is missing.
39
+
40
+ 1. **The app in Docker.** `compose` in `proofy.json` lists the app's compose files. If the repository has none, draft one from `templates/compose.yml`, show it to the human and wait for their OK. The app must start from an empty database, and everything it needs must be installed when the image is built: the sandbox has no internet once it runs. Give the database a `healthcheck`, so the app starts only when it is ready.
41
+ 2. **Where the browser starts.** `app` is the compose service and the port inside the container, not a published port.
42
+ 3. **Settings.** `env` holds the app's settings per service. `proofy init` filled it from `.env.example` and replaced secret-looking values with fakes. The repository's `.env` is never used. Check every value makes sense for an empty sandbox.
43
+ 4. **The seed.** `seed` is one command that prepares the empty database, such as migrations and the reference data every test needs. Test-specific data does not belong here. Leave it out if the app needs nothing.
44
+ 5. **Commands for tests.** `exec` names the commands tests may run inside a service, such as reading a stored order or running a scheduled job. A test calls `exec("name", [extra, args])` and gets stdout. Prefer the app's own CLI over raw SQL.
45
+ 6. **Outside services.** Find every host the app calls: search the code and config for `http://` and `https://`, SDK clients and mail settings. List each in `hosts`:
46
+ - `fake` when the app needs a real answer, such as a payment or a shipping quote. Write `standins/<host>.mjs` from `templates/standin.mjs` and the provider's public docs.
47
+ - `record` when the app ignores the answer, such as analytics or tracking.
48
+ - Leave a host out on purpose only if the app copes with it being down. Every refused call is listed in the report.
49
+ 7. **HTTPS.** Fakes answer HTTPS with a certificate authority proofy creates. Make the app trust it in the compose file; `templates/compose.yml` shows how for PHP, Node and Python.
50
+ 8. **Mail.** Point the app's SMTP settings at host `mailpit`, port `1025`, no TLS.
51
+ 9. **Check it.** Run `proofy dev up`. It builds the images, starts the sealed sandbox and seeds it. Fix what fails, using `e2e/out/sandbox.log` and `e2e/out/seed.log`. Run `proofy` until setup shows no gaps, then tell the human what you set up and what you could not.
52
+
53
+ ## Adding a flow
54
+
55
+ 1. **Interview.** Ask until every wish is checkable. Always cover:
56
+ - Who does it: a guest, a customer, an admin? Starting from which page?
57
+ - With what: which product, quantity, country, currency, payment method?
58
+ - What counts as success, as something you can read: the order's status and total, an email to whom, a stock count, a request to which outside service?
59
+ - What must never happen: charging twice, an order without payment, an email to the wrong person?
60
+ Ask one round of questions at a time. Do not guess an answer the human has not given.
61
+ 2. **Requirements.** Write `specs/<flow>.md` from `templates/spec.md`. Short lowercase name with dashes, such as `guest-checkout`. Show it to the human and wait for their OK.
62
+ 3. **Walk it.** Start `proofy dev up`, open the app address it prints and go through the flow once by hand to learn the buttons and fields. chrome-devtools-axi or Playwright's own tools are fine here. If the app redirects to its in-sandbox name, learn the page from its templates instead, or from a first draft of the test with `shot()` after each step.
63
+ 4. **Test.** Write `tests/<flow>.spec.ts` from `templates/test.spec.ts`. Import only from `@davidrydberg/proofy/test` and Node's built-in modules; nothing else is installed in the runner. From that module:
64
+ - `test`, `expect`: Playwright's, with proofy's bookkeeping. The browser starts at the `app` address.
65
+ - `claim({ spec, did, expected, actual, ok, evidence })`: one per requirement, citing its number. `ok` comes from real values.
66
+ - `notCovered(spec, why)`: for what the sandbox cannot show, such as a payment inside a provider's own card fields.
67
+ - `shot(label)`: a screenshot after every meaningful step.
68
+ - `tag()`: a value unique to the test for names, emails and phone numbers.
69
+ - `exec(name, args)`: a command from `exec` in `proofy.json`; returns stdout.
70
+ - `mail.for(address, { atLeast, settleMs })`: that person's email from Mailpit, waiting until `atLeast` arrived. `settleMs` keeps reading until nothing new came for that long, so "exactly one" also catches a late second email. Use `atLeast: 0` to check nothing was sent.
71
+ - `mail.search(query, { atLeast, settleMs })`: every email matching a Mailpit search, whoever it went to. Search for something only this test's data has, such as a `tag()` value. Each email has `to`, `cc` and `bcc`.
72
+ - `standin.requests(host, { atLeast })`: what the app sent to a fake or recorded host.
73
+ 5. **Prove it.** With `proofy dev up` running, run `proofy dev test <flow>` until three runs in a row give identical results per requirement. `proofy` shows the count. A requirement that fails because the app is wrong stays red: that is a finding, not a broken test. A flow whose runs differ is flaky: fix the test, never add retries.
74
+ 6. **Second opinion.** Ask a separate agent, given only `specs/<flow>.md` and `tests/<flow>.spec.ts` and no app code, to answer for each requirement: "what wrong behaviour of the app would still pass this check?" Tighten the checks it finds, then give the human its answers.
75
+ 7. **Hand over.** Tell the human the flow is ready, what each requirement checks, any findings, and that they can run `proofy approve <flow>`.
76
+
77
+ ## Changing a flow
78
+
79
+ - When a change to the app touches a covered flow, propose the requirement changes to the human before writing code.
80
+ - A changed requirements file, test or fake makes the flow out of date until the human approves it again. Other flows keep running.
81
+ - When the app changed on purpose, update the requirements first, then the test, then prove it again.
82
+
83
+ ## When a run is red
84
+
85
+ 1. Read `out/<run>/report.md`: per requirement what was expected, what happened, the evidence and the screenshots. Red tests also have a Playwright trace.
86
+ 2. Decide which it is and say which, with the evidence:
87
+ - **The app is wrong.** Report it as a bug. Do not touch the test.
88
+ - **The test is wrong.** For example it typed into a field that was already filled in. Fix it, prove it again, and tell the human; it needs approval again.
89
+ - **A fake is wrong.** Check it against the provider's docs, fix it, and tell the human.
90
+ - **The requirement is unclear.** Ask the human.
@@ -0,0 +1,33 @@
1
+ # Example compose file for an app proofy can test.
2
+ # proofy adds its own network, fakes, Mailpit and the browser around it. Keep this file about the app only.
3
+ #
4
+ # Rules for a sandbox-ready compose file:
5
+ # - No named volumes for data: every run starts from an empty database.
6
+ # - No published ports needed: the browser runs inside the sandbox. A published port is fine for your own use.
7
+ # - Settings come from e2e/proofy.json "env", not from an env_file pointing at .env.
8
+ # - The app trusts proofy's certificate authority, mounted at /proofy/ca.crt, so it accepts the fakes' HTTPS.
9
+
10
+ services:
11
+ app:
12
+ build: .
13
+ depends_on: [db]
14
+ expose: ["8080"]
15
+ environment:
16
+ DB_HOST: db
17
+ MAIL_HOST: mailpit
18
+ MAIL_PORT: "1025"
19
+ # Trust proofy's certificate authority. Keep the lines for your stack:
20
+ # Node:
21
+ NODE_EXTRA_CA_CERTS: /proofy/ca.crt
22
+ # Python (requests, httpx):
23
+ # REQUESTS_CA_BUNDLE: /proofy/ca-bundle.crt
24
+ # SSL_CERT_FILE: /proofy/ca-bundle.crt
25
+ # PHP (curl, Guzzle): add to the image or entrypoint:
26
+ # cp /proofy/ca.crt /usr/local/share/ca-certificates/proofy.crt && update-ca-certificates
27
+
28
+ db:
29
+ image: mysql:8.4
30
+ environment:
31
+ MYSQL_DATABASE: app
32
+ MYSQL_ROOT_PASSWORD: sandbox
33
+ tmpfs: [/var/lib/mysql]
@@ -0,0 +1,20 @@
1
+ # Guest checkout
2
+
3
+ Asked by: the shop owner, 2026-10-20.
4
+ A guest buys one product and pays with an offline payment method.
5
+
6
+ ## Requirements
7
+
8
+ R1. After paying, the guest sees a thank-you page with an order number.
9
+ R2. The order with that number has status "processing" and a total of 249.00 SEK including shipping.
10
+ R3. The guest receives one confirmation email with the order number.
11
+ R4. Stock for the product goes down by one.
12
+
13
+ ## Must never happen
14
+
15
+ R5. Leaving the payment step without paying creates no order.
16
+ R6. The confirmation email goes to anyone but the guest.
17
+
18
+ ## Not covered
19
+
20
+ - Paying by card inside the provider's own card fields: the sandbox is sealed, so the provider's browser code cannot load.
@@ -0,0 +1,29 @@
1
+ // Fake for api.example-payments.com.
2
+ // Written from the provider's public docs: https://docs.example-payments.com/api/payments
3
+ // Never from what this app sends: a fake that copies the app agrees with every bug in it.
4
+ // Save as e2e/standins/<host>.mjs and list the host as "fake" in e2e/proofy.json.
5
+
6
+ /**
7
+ * Called for every request to this host. proofy records every request before calling it.
8
+ * @param {{ method: string, path: string, query: Record<string, string>, headers: Record<string, string>, body: string, json: unknown }} req
9
+ * @param {{ state: Map<string, unknown> }} ctx state lives for one run, shared by every request to this host
10
+ * @returns {{ status: number, json?: unknown, body?: string, headers?: Record<string, string> }}
11
+ */
12
+ export default function handle(req, ctx) {
13
+ if (req.method === "POST" && req.path === "/v1/payments") {
14
+ const amount = req.json?.amount;
15
+ // Reject what the real service rejects, as its docs describe, so a wrong request from the app shows up as red.
16
+ if (!Number.isInteger(amount) || amount <= 0) {
17
+ return { status: 400, json: { error: { code: "amount_invalid", message: "amount must be a positive integer in minor units" } } };
18
+ }
19
+ const id = `pay_${ctx.state.size + 1}`;
20
+ const payment = { id, amount, currency: req.json.currency, status: "succeeded" };
21
+ ctx.state.set(id, payment);
22
+ return { status: 201, json: payment };
23
+ }
24
+ if (req.method === "GET" && req.path.startsWith("/v1/payments/")) {
25
+ const payment = ctx.state.get(req.path.slice("/v1/payments/".length));
26
+ return payment ? { status: 200, json: payment } : { status: 404, json: { error: { code: "not_found" } } };
27
+ }
28
+ return { status: 404, json: { error: { code: "not_faked", message: `${req.method} ${req.path} is not in this fake` } } };
29
+ }
@@ -0,0 +1,28 @@
1
+ // Requirements: e2e/specs/guest-checkout.md
2
+ import { claim, exec, mail, notCovered, shot, tag, test } from "@davidrydberg/proofy/test";
3
+
4
+ test("guest checkout", async ({ page }) => {
5
+ const guest = { name: `Guest ${tag()}`, email: `guest-${tag()}@example.test` };
6
+
7
+ await page.goto("/product/coffee/");
8
+ await page.getByRole("button", { name: "Add to basket" }).click();
9
+ await shot("coffee in the basket");
10
+
11
+ await page.goto("/checkout/");
12
+ await page.getByLabel("Email").fill(guest.email);
13
+ await page.getByLabel("First name").fill(guest.name);
14
+ await page.getByRole("button", { name: "Place order" }).click();
15
+ await shot("thank-you page");
16
+
17
+ const orderNumber = (await page.getByTestId("order-number").textContent())?.trim() ?? "";
18
+ claim({ spec: "R1", did: "placed the order as a guest", expected: "an order number on the thank-you page", actual: orderNumber, ok: /^\d+$/.test(orderNumber) });
19
+
20
+ // Read the stored order, not the screen: the page can say "thank you" while nothing was saved.
21
+ const order = JSON.parse(await exec("order", [orderNumber])) as { status: string; total: string };
22
+ claim({ spec: "R2", did: `read order ${orderNumber}`, expected: "processing, 249.00", actual: `${order.status}, ${order.total}`, ok: order.status === "processing" && order.total === "249.00", evidence: order });
23
+
24
+ const messages = await mail.for(guest.email);
25
+ claim({ spec: "R3", did: `read ${guest.email}'s mail`, expected: "one email with the order number", actual: `${messages.length} emails`, ok: messages.length === 1 && messages[0]!.text.includes(orderNumber) });
26
+
27
+ notCovered("R4", "example only: read the stock count with exec() before and after");
28
+ });