From ed6e766d30f3d383f07f0a705a7d22f9d72dc40b Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 11 Jul 2026 17:15:52 +0200 Subject: [PATCH 1/9] docs(design,drive): H3 testing model + reusable-system-testing slice spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design: docs/design/10-domains/testing.md — the two-altitude testing model. Every dependency flows through one seam, service.load(); stubLoad replaces its output (unit), bootstrapService feeds its input (integration), both without touching the code under test. drive: the H3 slice spec — reusable auth System (owns its db), the two testing utilities (stubLoad in core, bootstrapService via a runForTest runnable seam), the fake shipped from the auth package, and the three proofs (unit, integration, live e2e). Two open items flagged for an operator ruling before dispatch. Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/reusable-system-testing/spec.md | 105 ++++++++++++++++ docs/design/10-domains/testing.md | 118 ++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 .drive/projects/hex-composition/slices/reusable-system-testing/spec.md create mode 100644 docs/design/10-domains/testing.md diff --git a/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md b/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md new file mode 100644 index 000000000..9da9da000 --- /dev/null +++ b/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md @@ -0,0 +1,105 @@ +# Slice spec: reusable auth System + the testing seam, proven live + +The last slice of the system-composition project (H3). It turns the storefront's +auth from an inline service into a **reusable System that owns its database**, +and ships the **testing utilities** that make any app built on the framework +testable at two altitudes — proven by a unit test, an integration test, and the +existing live deploy. + +Design contract: [`docs/design/10-domains/testing.md`](../../../../../docs/design/10-domains/testing.md) +(the testing model) + [`docs/design/10-domains/system-composition.md`](../../../../../docs/design/10-domains/system-composition.md) +(the System boundary). Deviations amend the docs first. + +## Why + +The composition machinery (H1 boundary + ADR-0017 control plane) is built and +merged, but nothing yet proves the value it exists for: that a System can be +**published, reused, and faked**. Today `examples/storefront-auth/systems/auth` +is a bare service whose Postgres is provisioned by the root and wired into its +`db` input. That is not a reusable unit — a consumer would have to know to +provision auth's storage. H3 makes auth a self-contained System and proves an +app composing it can be tested without a cloud. + +## Deliverables + +### 1. The reusable auth System (owns its db) + +`auth` becomes a **System**, not a service: its body provisions its own Postgres +and its own compute service, wires the db in, and exposes the RPC contract as +the System's output. Its boundary has **no `db` input** — it exposes only +`{ rpc: authContract }`. The package declares `@prisma/*` as **peer +dependencies** (as a published reusable System would) and builds via its own +turbo `build` (standing in for publish-time build). The root `system.ts` no +longer provisions the database; it provisions the auth System and storefront, +wiring auth's exposed `rpc` into storefront's `auth` dependency. + +### 2. `@prisma/app/testing` → `stubLoad` (unit seam) + +Core, target-agnostic. `stubLoad(service, overrides)` returns a service node +whose `load()` yields `overrides` merged with the service's param defaults, +**typed against the service's `deps`** (a double not assignable to the dep's +hydrated type is a compile error). New export `@prisma/app/testing` +(`packages/app/src/testing.ts`; add `./testing` to the manifest + its tsdown +entry). It performs no module mocking itself — that stays in the test. + +### 3. `bootstrapService` (integration seam) + +The in-process counterpart of the deploy bootstrap. A `runForTest(config, boot)` +capability on the runnable node (implemented in `@prisma/app-cloud`'s +`compute.ts`, reusing the existing `stash` + `configOf`), wrapped by a generic +`bootstrapService(service, config)` in `@prisma/app/testing` that returns a +handle `{ url, fetch, close }`. It writes the chosen config to the environment +exactly as `run` does, boots the real entry, and hands back a driveable server. +**`server.ts` is not modified.** + +### 4. The fake auth (ships from the auth package) + +A `/fake` export on the auth package: an in-memory `verify` (`serve(fakeAuth, { +rpc: { verify: async ({ token }) => ({ ok: token.length > 0 }) } })`), no +Postgres, sharing the real `authContract` so its handler map is typed against +the same contract. Used by both proof tests. + +## Proof + +- **Unit test** — renders storefront's `page.tsx` with `load()` mocked via + `stubLoad` to a fake `auth`; asserts the rendered output. No server, no env, + no cloud. (vitest — the storefront's runner.) +- **Integration test** — runs the fake auth on a loopback port, boots storefront + via `bootstrapService` with `auth.url` pointed at it, drives the page over + HTTP, asserts the round trip. No cloud. +- **Live e2e** — the existing "Deploy, verify, destroy" job, unchanged in shape, + now deploys the composed **auth System + storefront** to real Prisma Cloud and + verifies the round trip. This is the reusable-System-deployed-for-real proof. +- All repo gates green (typecheck, test, lint, build, casts delta ≤ 0). + +## Out of scope + +- Running a whole composed graph locally (multi-service `dev` orchestration) — + a separate capability the testing doc lists as a non-goal. +- A runner-agnostic module-mock abstraction — `stubLoad` ships the typed + payload; the `vi.mock`/`mock.module` wiring stays in the tests. +- The post-merge cleanups (LoadedControl-style lookup dedup; folding + `@prisma/alchemy` into `@prisma/app-cloud`) — tracked separately. + +## Open — needs an operator ruling before dispatch + +1. **`bootstrapService` teardown/handle.** `server.ts` calls `Bun.serve(...)` at + import and does not surface the server handle, so a clean `close()` needs the + handle. Options: (a) rely on bun-test per-file process isolation and drop + `close()` (simplest, leaks a listener within a file); (b) have the target's + `run`/`runForTest` capture the `Bun.serve` return so `close()` works, without + touching `server.ts`. (b) is cleaner but adds a run-path seam. **Which?** +2. **Integration test target.** Boot the full Next.js storefront in-process + (heaviest, truest) vs. drive a lighter RPC consumer against the fake. Booting + Next is the honest proof but the slower, fiddlier test. **Full storefront, or + a lighter consumer?** + +## Notes for implementation + +- `stubLoad`'s override type is the service's hydrated deps + (`Client` for rpc, the resource binding for resources) plus optional param + overrides — derive it from the node's `Deps`/params, do not hand-roll. +- `runForTest` reuses `stash` (serializer.ts) + `configOf` — it must not add a + second serialize path; writer/reader parity with deploy is the whole point. +- Keep the auth System's `authContract` and the fake in one package so the + contract cannot drift. diff --git a/docs/design/10-domains/testing.md b/docs/design/10-domains/testing.md new file mode 100644 index 000000000..7f1e4d6eb --- /dev/null +++ b/docs/design/10-domains/testing.md @@ -0,0 +1,118 @@ +# Testing apps built on the framework + +How a Prisma App is tested — from a single page component up to the full +request path — with no deployment, no cloud account, and no change to the code +under test. It rests on one fact: **every dependency an app touches flows +through one seam, `service.load()`**, so a test injects its doubles at that +single point and nothing about the application code moves. + +## The one seam: `service.load()` + +A service declares its dependencies (`deps`) and the ports it exposes +(`expose`). Its runtime code — the RPC server, a Next.js page, a server action, +a plain helper — never receives its dependencies as arguments and never reaches +for a global. It calls `service.load()`, which reads the process config +(deserialized from the environment the deploy injected — see +[`deploy-cli.md`](deploy-cli.md)), hydrates each dependency to its client or +binding (ADR-0015), and returns them typed. + +Because that is the *only* way application code obtains a dependency, it is the +only place a test has to touch. A test never edits the code under test to accept +a fake; it controls what `load()` yields. Two altitudes need two tools — but +they hit the same seam from opposite sides: one replaces `load()`'s output, the +other feeds `load()`'s input. + +## Unit — `stubLoad`: replace the seam's output + +For code that calls `load()` directly and is exercised directly — a page +component, a server action, a utility — the test mocks the service module so +`load()` returns doubles, then calls the code with no server and no environment. + +```ts +// storefront/app/page.test.tsx +import { stubLoad } from '@prisma/app/testing'; +// mock storefront's own service module so load() returns a typed fake auth +vi.mock('../src/service.ts', () => ({ + default: stubLoad(realService, { auth: { verify: async () => ({ ok: true }) } }), +})); +import Page from './page.tsx'; + +// the double is checked against Client; a wrong shape fails to compile +expect(await Page()).toContain('true'); +``` + +`stubLoad(service, overrides)` returns a service node whose `load()` yields the +overrides merged with the service's param defaults, **typed against the +service's own `deps`** — a double that does not satisfy `Client` +is a compile error. This is ordinary dependency-injection testing; the app code +runs unchanged. It is target-agnostic (every service node has a `load()`), so it +lives in core, `@prisma/app/testing`. The one runner-specific step — how the +module substitution is wired (`vi.mock`, bun `mock.module`) — stays in the test; +the framework supplies the typed payload, not the mock call. + +## Integration — `bootstrapService`: feed the seam's input + +For the real request path — the actual boot, the real client, the real wire +format — the test is the in-process counterpart of the deploy bootstrap. The +deploy bootstrap is `main.run(address, () => import(appEntry))` +([deploy-cli.md](deploy-cli.md)); `run` writes the resolved config into the +environment and boots the entry. `bootstrapService` does exactly that with a +config the test chooses: + +```ts +// boot storefront in-process against an in-process fake auth +const fake = Bun.serve({ port: 0, fetch: serve(fakeAuth, { rpc: { verify: async () => ({ ok: true }) } }) }); +const app = await bootstrapService(storefront, { inputs: { auth: { url: fake.url } }, port: 0 }); + +const res = await app.fetch(new Request(app.url)); +expect(await res.text()).toContain('true'); +await app.close(); await fake.stop(); +``` + +Nothing about `server.ts` changes — it boots and listens exactly as in +production; the test just points its `auth` binding at a local fake and drives +it over loopback. `load()` deserializes the injected environment identically to +a deployed process. The fidelity is the point: an integration test exercises the +production code path, not a stubbed one. + +`bootstrapService` is target-specific — it uses the target's serializer to write +the environment (`stash`) and the target's `run` to boot. It is exposed as a +`runForTest(config, boot)` capability on the runnable (each target implements +it) that a thin `@prisma/app/testing` wrapper drives, so the generic entry stays +in core and the target owns the environment mapping. + +## The doubles: same contract, by type + +A dependency's *hydrated type* is its contract. An RPC dependency +`rpc(authContract)` hydrates to `Client` — `{ verify(input): +Promise }` — so a double is any value of that type, checked at compile +time. Three grades, increasing fidelity: + +- **A bare object** — `{ verify: async () => ({ ok: true }) }`. Fastest; skips + the wire entirely. For unit tests where only the return value matters. +- **A contract-faithful in-process fake** — `makeClient(authContract, url, { + fetch: serve(fakeAuth, handlers) })`: the *real* client over a *fake* + transport, so JSON encode/decode and both schema validations still run. Same + process, no socket. +- **A real local server** — the fake `serve()`d on a loopback port, reached by + the real client over real HTTP. What `bootstrapService` uses. + +The fake ships from the dependency's own package (a `/fake` export), so its +handler map is typed against the same `authContract` the real service exposes — +the contract cannot drift between the real service and its fake. + +## Non-goals + +- **Running a whole composed graph locally.** `bootstrapService` boots one + service; wiring several services together in-process (a local `dev` + orchestration) is a separate capability, not this seam. +- **A runner-agnostic mock abstraction.** The framework ships the typed double + builder and documents the `vi.mock` / `mock.module` patterns; it does not wrap + every test runner's module system. + +## Related + +- [`core-model.md`](core-model.md) — the `run`/`load` split these tools drive. +- [`deploy-cli.md`](deploy-cli.md) — the deploy bootstrap `bootstrapService` mirrors. +- [`../90-decisions/ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md`](../90-decisions/ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md) + — why the hydrated dependency is a client the seam can double. From c1388039487f314d6a184a04b82cf496d10e092d Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 11 Jul 2026 17:26:35 +0200 Subject: [PATCH 2/9] =?UTF-8?q?docs(drive):=20resolve=20H3=20open=20items?= =?UTF-8?q?=20=E2=80=94=20(a)=20teardown,=20both=20test=20paths=20ship?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator rulings: teardown via bun-test process isolation (no close(), server.ts untouched); both the unit (stubLoad) and integration (bootstrapService) paths are deliverables, integration boots the full Next storefront against a loopback fake auth with a minimal-RPC-consumer fallback. Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/reusable-system-testing/spec.md | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md b/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md index 9da9da000..8c53ad9ed 100644 --- a/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md +++ b/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md @@ -48,9 +48,12 @@ The in-process counterpart of the deploy bootstrap. A `runForTest(config, boot)` capability on the runnable node (implemented in `@prisma/app-cloud`'s `compute.ts`, reusing the existing `stash` + `configOf`), wrapped by a generic `bootstrapService(service, config)` in `@prisma/app/testing` that returns a -handle `{ url, fetch, close }`. It writes the chosen config to the environment -exactly as `run` does, boots the real entry, and hands back a driveable server. -**`server.ts` is not modified.** +handle `{ url, fetch }`. It writes the chosen config to the environment exactly +as `run` does, boots the real entry, and hands back a driveable server. +**`server.ts` is not modified.** No `close()`: the entry owns its `Bun.serve` +handle, so teardown rides on bun-test's per-file process isolation (a single +boot per test file, cleaned up when the file's process ends). This is the +accepted trade for leaving the entry untouched. ### 4. The fake auth (ships from the auth package) @@ -81,18 +84,17 @@ the same contract. Used by both proof tests. - The post-merge cleanups (LoadedControl-style lookup dedup; folding `@prisma/alchemy` into `@prisma/app-cloud`) — tracked separately. -## Open — needs an operator ruling before dispatch - -1. **`bootstrapService` teardown/handle.** `server.ts` calls `Bun.serve(...)` at - import and does not surface the server handle, so a clean `close()` needs the - handle. Options: (a) rely on bun-test per-file process isolation and drop - `close()` (simplest, leaks a listener within a file); (b) have the target's - `run`/`runForTest` capture the `Bun.serve` return so `close()` works, without - touching `server.ts`. (b) is cleaner but adds a run-path seam. **Which?** -2. **Integration test target.** Boot the full Next.js storefront in-process - (heaviest, truest) vs. drive a lighter RPC consumer against the fake. Booting - Next is the honest proof but the slower, fiddlier test. **Full storefront, or - a lighter consumer?** +## Decisions (resolved) + +- **Teardown:** option (a) — no `close()`; bun-test's per-file process isolation + cleans up. `server.ts` stays untouched. (A cleaner "target owns the listen" + refactor is explicitly out of scope for H3.) +- **Both paths ship, not either/or.** The unit test (`stubLoad`) AND the + integration test (`bootstrapService`) are both deliverables. The integration + test boots the **full Next storefront** in-process against a loopback fake auth + — the real round trip. If Next-in-process boot proves genuinely intractable, + fall back to driving a minimal RPC consumer through `bootstrapService` and flag + it in the final report — do not drop the integration path. ## Notes for implementation From 8837182cbb5994ac0fa502e422423bb28f4f30a9 Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 11 Jul 2026 17:37:53 +0200 Subject: [PATCH 3/9] feat(storefront-auth): turn auth into a reusable System that owns its db MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth's default export is now a System (src/system.ts) that provisions its own Postgres and the compute service, wiring db in and exposing only the rpc contract — no db input on the boundary. The root system.ts no longer provisions the database; it provisions the auth System and wires its exposed rpc into storefront. The package declares @prisma/* as peer dependencies, standing in for how a published reusable System would ship. Part of the H3 slice (reusable-system-testing). Signed-off-by: willbot Signed-off-by: Will Madden --- examples/storefront-auth/system.ts | 23 +++++++------------ .../storefront-auth/systems/auth/package.json | 12 +++++++--- .../systems/auth/src/system.ts | 23 +++++++++++++++++++ pnpm-lock.yaml | 8 +++---- 4 files changed, 44 insertions(+), 22 deletions(-) create mode 100644 examples/storefront-auth/systems/auth/src/system.ts diff --git a/examples/storefront-auth/system.ts b/examples/storefront-auth/system.ts index 8d1cb6f59..8935e6480 100644 --- a/examples/storefront-auth/system.ts +++ b/examples/storefront-auth/system.ts @@ -1,26 +1,19 @@ import { system } from '@prisma/app'; -import { postgres } from '@prisma/app-cloud'; -import authService from '@storefront-auth/auth'; +import authSystem from '@storefront-auth/auth'; import storefrontService from '@storefront-auth/storefront'; /** - * The storefront-auth app: two services and their shared Postgres in one system. - * The system owns the database and wires it into auth's `db` slot; `auth` exposes - * an RPC contract; `storefront` consumes it (auth's `rpc` port → storefront's - * `auth` slot, compat-checked). Transparent wiring, executed at Load. - * - * The provision id is `database`, not `db`: the prisma-cloud target passes it - * through as the Prisma resource name, and the Connection API rejects names - * shorter than 3 characters. The wiring key stays `db` (auth's input name), so - * the deployed env key is still `AUTH_DB_URL` — it derives from the input - * name, not the provision id. + * The storefront-auth app: the reusable auth System (owns its own Postgres) + * and the storefront service, composed in one root. The root provisions + * nothing of auth's internals — it wires auth's exposed `rpc` port into + * storefront's `auth` slot exactly as it would for any other producer of + * that contract. * * A closed root: empty boundary (no inputs, no outputs) — nothing wires into * or out of this system from the outside. */ export default system('storefront-auth', {}, ({ provision }) => { - const db = provision('database', postgres({ name: 'database' })); - const authRef = provision('auth', authService, { db }); - provision('storefront', storefrontService, { auth: authRef.rpc }); + const auth = provision('auth', authSystem); + provision('storefront', storefrontService, { auth: auth.rpc }); return {}; }); diff --git a/examples/storefront-auth/systems/auth/package.json b/examples/storefront-auth/systems/auth/package.json index 714182929..80dc35f57 100644 --- a/examples/storefront-auth/systems/auth/package.json +++ b/examples/storefront-auth/systems/auth/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "exports": { - ".": "./src/service.ts", + ".": "./src/system.ts", "./contract": "./src/contract.ts" }, "scripts": { @@ -13,13 +13,19 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "arktype": "^2.2.3" + }, + "peerDependencies": { "@prisma/app": "workspace:0.2.0", "@prisma/app-node": "workspace:0.2.0", "@prisma/app-cloud": "workspace:0.2.0", - "@prisma/app-rpc": "workspace:0.2.0", - "arktype": "^2.2.3" + "@prisma/app-rpc": "workspace:0.2.0" }, "devDependencies": { + "@prisma/app": "workspace:0.2.0", + "@prisma/app-node": "workspace:0.2.0", + "@prisma/app-cloud": "workspace:0.2.0", + "@prisma/app-rpc": "workspace:0.2.0", "@types/bun": "^1.3.13", "tsdown": "^0.15.9", "typescript": "^6.0.3" diff --git a/examples/storefront-auth/systems/auth/src/system.ts b/examples/storefront-auth/systems/auth/src/system.ts new file mode 100644 index 000000000..3041a8a53 --- /dev/null +++ b/examples/storefront-auth/systems/auth/src/system.ts @@ -0,0 +1,23 @@ +/** + * The auth System: a reusable unit that owns its own Postgres. Provisions the + * database and the auth compute service (service.ts), wires the db into the + * service's `db` input, and exposes the service's `rpc` port as the System's + * own output. A consumer never provisions auth's storage itself — it wires + * only the exposed `rpc` contract (system-composition.md). + * + * The provision id for the database is "database", not "db": the + * prisma-cloud target passes it through as the Prisma resource name, and the + * Connection API rejects names shorter than 3 characters. The wiring key + * (the service's own input name) stays "db" — the deployed env key derives + * from that, not the provision id. + */ +import { system } from '@prisma/app'; +import { postgres } from '@prisma/app-cloud'; +import { authContract } from './contract.ts'; +import authService from './service.ts'; + +export default system('auth', { expose: { rpc: authContract } }, ({ provision }) => { + const db = provision('database', postgres({ name: 'database' })); + const service = provision('service', authService, { db }); + return { rpc: service.rpc }; +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30720a965..b2a75ee6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,10 @@ importers: examples/storefront-auth/systems/auth: dependencies: + arktype: + specifier: ^2.2.3 + version: 2.2.3 + devDependencies: '@prisma/app': specifier: workspace:0.2.0 version: link:../../../../packages/app @@ -96,10 +100,6 @@ importers: '@prisma/app-rpc': specifier: workspace:0.2.0 version: link:../../../../packages/app-rpc - arktype: - specifier: ^2.2.3 - version: 2.2.3 - devDependencies: '@types/bun': specifier: ^1.3.13 version: 1.3.14 From 6fb4f9a578a0a16708b16e22800859b8e614678f Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 11 Jul 2026 17:43:48 +0200 Subject: [PATCH 4/9] =?UTF-8?q?feat(app):=20add=20stubLoad=20=E2=80=94=20t?= =?UTF-8?q?he=20unit-test=20seam=20for=20service.load()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @prisma/app/testing exports stubLoad(service, overrides): a service node whose load() returns overrides merged with the service's own param defaults, typed against the service's declared deps so a double of the wrong shape fails to compile. Target-agnostic, no module mocking of its own — that stays in the test. New ./testing tsdown entry regenerates the package manifest. Part of the H3 slice (reusable-system-testing). Signed-off-by: willbot Signed-off-by: Will Madden --- packages/app/package.json | 1 + packages/app/src/__tests__/invariants.test.ts | 11 ++- packages/app/src/__tests__/testing.test-d.ts | 84 +++++++++++++++++ packages/app/src/__tests__/testing.test.ts | 90 +++++++++++++++++++ packages/app/src/testing.ts | 57 ++++++++++++ packages/app/tsdown.config.ts | 1 + 6 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/__tests__/testing.test-d.ts create mode 100644 packages/app/src/__tests__/testing.test.ts create mode 100644 packages/app/src/testing.ts diff --git a/packages/app/package.json b/packages/app/package.json index def1d1243..17f567e42 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -8,6 +8,7 @@ "./casts": "./dist/casts.mjs", "./config": "./dist/config.mjs", "./deploy": "./dist/deploy.mjs", + "./testing": "./dist/testing.mjs", "./package.json": "./package.json" }, "main": "./dist/index.mjs", diff --git a/packages/app/src/__tests__/invariants.test.ts b/packages/app/src/__tests__/invariants.test.ts index 568ce97a5..f2bfe9b9a 100644 --- a/packages/app/src/__tests__/invariants.test.ts +++ b/packages/app/src/__tests__/invariants.test.ts @@ -33,11 +33,18 @@ const leanTokens = [ ]; describe('entry map: core splits into authoring + deploy + pure utils — no runtime entry', () => { - test("package.json exports '.', './deploy', './config', and the ./casts + ./assertions utilities", () => { + test("package.json exports '.', './deploy', './config', './testing', and the ./casts + ./assertions utilities", () => { const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')); // `./package.json` is a conventional manifest export, not a code entry. const codeEntries = Object.keys(pkg.exports).filter((k) => k !== './package.json'); - expect(codeEntries.sort()).toEqual(['.', './assertions', './casts', './config', './deploy']); + expect(codeEntries.sort()).toEqual([ + '.', + './assertions', + './casts', + './config', + './deploy', + './testing', + ]); }); }); diff --git a/packages/app/src/__tests__/testing.test-d.ts b/packages/app/src/__tests__/testing.test-d.ts new file mode 100644 index 000000000..68d685f98 --- /dev/null +++ b/packages/app/src/__tests__/testing.test-d.ts @@ -0,0 +1,84 @@ +/** + * `stubLoad`'s override argument is typed against the service's own `deps` + * (`HydratedDeps`) and `params` (`Partial>`) — a double that + * doesn't satisfy a dep's hydrated shape, or a param of the wrong type, must + * fail to compile. Type-only (vitest `--typecheck`, never executed): see + * testing.test.ts for the runtime behavior. + */ +import { expectTypeOf, test } from 'vitest'; +import type { ConfigParam } from '../config.ts'; +import type { BuildAdapter, RunnableServiceNode } from '../node.ts'; +import { dependency, service } from '../node.ts'; +import { stubLoad } from '../testing.ts'; +import { conn } from './helpers.ts'; + +const build: BuildAdapter = { + extension: '@prisma/app-node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', +}; + +interface Verify { + verify(input: { token: string }): Promise<{ ok: boolean }>; +} + +const authDep = () => + dependency<{ url: { type: 'string' } }, Verify>({ + type: 'fake/rpc', + connection: conn( + { url: { type: 'string' } }, + (): Verify => ({ + verify: async () => ({ ok: false }), + }), + ), + }); + +type ConsumerDeps = { auth: ReturnType }; +type ConsumerParams = { port: ConfigParam<'number'> }; + +const consumer = (): RunnableServiceNode => + Object.freeze({ + ...service({ + name: 'consumer', + extension: 'test/pack', + type: 'fake/compute', + inputs: { auth: authDep() }, + params: { port: { type: 'number', default: 3000 } }, + build, + }), + async run(): Promise { + throw new Error('unused — type-only file, never executed'); + }, + load() { + throw new Error('unused — type-only file, never executed'); + }, + }); + +test('a correctly-shaped double, with or without the optional param override, compiles', () => { + const withoutParam = stubLoad(consumer(), { + auth: { verify: async ({ token }: { token: string }) => ({ ok: token.length > 0 }) }, + }); + const withParam = stubLoad(consumer(), { + auth: { verify: async () => ({ ok: true }) }, + port: 8080, + }); + + expectTypeOf(withoutParam).toEqualTypeOf>(); + expectTypeOf(withParam).toEqualTypeOf>(); +}); + +test('omitting the required "auth" override is a compile error', () => { + // @ts-expect-error "auth" is a declared dep with no default — it must be supplied + stubLoad(consumer(), {}); +}); + +test("a double whose method return shape doesn't satisfy the dep's hydrated contract is a compile error", () => { + // @ts-expect-error verify must resolve to `{ ok: boolean }`, not `{ status: string }` + stubLoad(consumer(), { auth: { verify: async () => ({ status: 'ok' }) } }); +}); + +test('overriding a param with the wrong type is a compile error', () => { + // @ts-expect-error port is declared `number` + stubLoad(consumer(), { auth: { verify: async () => ({ ok: true }) }, port: 'nope' }); +}); diff --git a/packages/app/src/__tests__/testing.test.ts b/packages/app/src/__tests__/testing.test.ts new file mode 100644 index 000000000..93f74b0e8 --- /dev/null +++ b/packages/app/src/__tests__/testing.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test'; +import type { ConfigParam } from '../config.ts'; +import type { BuildAdapter, RunnableServiceNode } from '../node.ts'; +import { dependency, service } from '../node.ts'; +import { stubLoad } from '../testing.ts'; +import { conn } from './helpers.ts'; + +const build: BuildAdapter = { + extension: '@prisma/app-node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', +}; + +interface Verify { + verify(input: { token: string }): Promise<{ ok: boolean }>; +} + +const authDep = () => + dependency<{ url: { type: 'string' } }, Verify>({ + type: 'fake/rpc', + connection: conn( + { url: { type: 'string' } }, + (): Verify => ({ + verify: async () => ({ ok: false }), + }), + ), + }); + +type ConsumerDeps = { auth: ReturnType }; +type ConsumerParams = { port: ConfigParam<'number'> }; + +/** A RunnableServiceNode fixture whose own run()/load() must never actually run — stubLoad replaces load() entirely, and run() is never called under a stub. */ +const consumer = (): RunnableServiceNode => + Object.freeze({ + ...service({ + name: 'consumer', + extension: 'test/pack', + type: 'fake/compute', + inputs: { auth: authDep() }, + params: { port: { type: 'number', default: 3000 } }, + build, + }), + async run(): Promise { + throw new Error('consumer.run() should never be called under stubLoad.'); + }, + load() { + throw new Error('consumer.load() should never be reached — stubLoad replaces it entirely.'); + }, + }); + +describe('stubLoad', () => { + test("load() yields the override merged with the service's param defaults", async () => { + const stub = stubLoad(consumer(), { + auth: { verify: async ({ token }) => ({ ok: token.length > 0 }) }, + }); + + const { auth, port } = stub.load(); + expect(port).toBe(3000); + expect(await auth.verify({ token: 'x' })).toEqual({ ok: true }); + }); + + test('an overridden param wins over the default', () => { + const stub = stubLoad(consumer(), { + auth: { verify: async () => ({ ok: true }) }, + port: 8080, + }); + + expect(stub.load().port).toBe(8080); + }); + + test('load() returns the same object on every call', () => { + const stub = stubLoad(consumer(), { auth: { verify: async () => ({ ok: true }) } }); + expect(stub.load()).toBe(stub.load()); + }); + + test('run() throws, naming the service', () => { + const stub = stubLoad(consumer(), { auth: { verify: async () => ({ ok: true }) } }); + expect(() => stub.run('addr', async () => undefined)).toThrow(/"consumer".*load\(\)-only stub/); + }); + + test('deps/params/build/name pass through unchanged', () => { + const original = consumer(); + const stub = stubLoad(original, { auth: { verify: async () => ({ ok: true }) } }); + expect(stub.inputs).toBe(original.inputs); + expect(stub.params).toBe(original.params); + expect(stub.build).toBe(original.build); + expect(stub.name).toBe(original.name); + }); +}); diff --git a/packages/app/src/testing.ts b/packages/app/src/testing.ts new file mode 100644 index 000000000..31a85284d --- /dev/null +++ b/packages/app/src/testing.ts @@ -0,0 +1,57 @@ +/** + * The unit-test seam (testing.md § Unit): `stubLoad` replaces a service + * node's `load()` output with a typed double, so code that calls + * `service.load()` runs with no server and no environment. Target-agnostic — + * every service node has a `load()` — and performs no module mocking itself; + * wiring the substitution into a test runner (`vi.mock`, `mock.module`) stays + * in the test. + */ +import { blindCast } from './casts.ts'; +import type { Params, Values } from './config.ts'; +import type { Deps, Expose, HydratedDeps, Loaded, RunnableServiceNode } from './node.ts'; + +/** + * `stubLoad`'s override argument: every declared dependency, typed against + * its own hydrated shape (`Client` for an RPC dep, the resource binding + * for a resource dep) — a double of the wrong shape is a compile error. The + * service's own params are optional; an omitted one falls back to its + * declared default, same as a real `load()`. + */ +export type LoadOverrides = HydratedDeps & Partial>; + +function paramDefaults

(params: P): Partial> { + const defaults: Record = {}; + for (const [name, param] of Object.entries(params)) { + if (param.default !== undefined) defaults[name] = param.default; + } + return blindCast< + Partial>, + "assembled from each param declaration's own default value, one key per param that declares one — exactly Partial> by construction" + >(defaults); +} + +/** + * Returns a service node whose `load()` yields `overrides` merged with the + * service's own param defaults — everything else about the node (its deps, + * params, build, expose) is unchanged. `run()` is not meaningful on a stub + * (there is no boot, no environment) and throws if called. + */ +export function stubLoad( + service: RunnableServiceNode, + overrides: LoadOverrides, +): RunnableServiceNode { + const loaded = blindCast< + Loaded, + 'merges the param defaults with the caller-supplied overrides, which LoadOverrides already types against HydratedDeps & Partial> — exactly Loaded once params are filled in' + >({ ...paramDefaults(service.params), ...overrides }); + + return Object.freeze({ + ...service, + run(): Promise { + throw new Error( + `stubLoad(): "${service.name}" is a load()-only stub — it has no run() (no boot, no environment).`, + ); + }, + load: () => loaded, + }); +} diff --git a/packages/app/tsdown.config.ts b/packages/app/tsdown.config.ts index 5f6c95911..f70301b10 100644 --- a/packages/app/tsdown.config.ts +++ b/packages/app/tsdown.config.ts @@ -10,5 +10,6 @@ export default defineConfig({ casts: 'src/casts.ts', assertions: 'src/assertions.ts', config: 'src/app-config.ts', + testing: 'src/testing.ts', }, }); From 5a188f55654eaf2663918798d8d2f6035856749d Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 11 Jul 2026 17:51:31 +0200 Subject: [PATCH 5/9] =?UTF-8?q?feat(app,app-cloud):=20add=20bootstrapServi?= =?UTF-8?q?ce=20=E2=80=94=20the=20integration-test=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compute()'s runnable node gets runForTest(config, boot): stash the given Config address-free (the same writer run() uses, minus the address -> Config deserialize step) and call boot(). @prisma/app/testing wraps it generically as bootstrapService(service, config): dynamically imports the service's real entry (build.entry resolved against build.module, exactly as the deploy bootstrap does) and hands back { url, fetch }. No close() — teardown rides bun-test's per-file process isolation. server.ts is untouched. Part of the H3 slice (reusable-system-testing). Signed-off-by: willbot Signed-off-by: Will Madden --- .../app-cloud/src/__tests__/extension.test.ts | 42 ++++++++++++ packages/app-cloud/src/compute.ts | 17 +++-- packages/app/src/testing.ts | 65 +++++++++++++++++-- 3 files changed, 113 insertions(+), 11 deletions(-) diff --git a/packages/app-cloud/src/__tests__/extension.test.ts b/packages/app-cloud/src/__tests__/extension.test.ts index 5adce2a41..0b846b780 100644 --- a/packages/app-cloud/src/__tests__/extension.test.ts +++ b/packages/app-cloud/src/__tests__/extension.test.ts @@ -345,6 +345,48 @@ describe('compute().run(address, boot) → load() — the round trip', () => { }); }); +describe('compute().runForTest(config, boot) — the in-process test seam', () => { + test("stashes the given Config address-free (like run('', ...)) and calls boot()", async () => { + const app = compute({ name: 'test-service', deps: { db: postgres() }, build }); + + let loaded: unknown; + await withEnv({ DB_URL: '', PORT: '' }, () => + app.runForTest( + { service: { port: 4321 }, inputs: { db: { url: 'postgres://runfortest' } } }, + async () => { + loaded = app.load(); + }, + ), + ); + + expect(loaded).toEqual({ db: { url: 'postgres://runfortest' }, port: 4321 }); + }); + + test('needs no pre-set environment — no address, no deserialize step, unlike run()', async () => { + const app = compute({ name: 'test-service', deps: { db: postgres() }, build }); + + let loaded: unknown; + await withEnv({ DB_URL: 'stale', PORT: 'stale' }, () => + app.runForTest( + { service: { port: 5555 }, inputs: { db: { url: 'postgres://fresh' } } }, + async () => { + loaded = app.load(); + }, + ), + ); + + expect(loaded).toEqual({ db: { url: 'postgres://fresh' }, port: 5555 }); + }); + + test('returns whatever boot() yields', async () => { + const app = compute({ name: 'test-service', deps: {}, build }); + + const result = await app.runForTest({ service: {}, inputs: {} }, async () => 'boot-result'); + + expect(result).toBe('boot-result'); + }); +}); + describe('compute().load()', () => { test('returns the deps merged with resolved params, memoized per process (same object on re-load)', async () => { const app = compute({ diff --git a/packages/app-cloud/src/compute.ts b/packages/app-cloud/src/compute.ts index 8d63c44c4..9832fdcf7 100644 --- a/packages/app-cloud/src/compute.ts +++ b/packages/app-cloud/src/compute.ts @@ -1,6 +1,7 @@ -import type { BuildAdapter, Deps, Expose, Loaded, RunnableServiceNode } from '@prisma/app'; +import type { BuildAdapter, Config, Deps, Expose, Loaded, RunnableServiceNode } from '@prisma/app'; import { configOf, hydrateSync, service } from '@prisma/app'; import { blindCast } from '@prisma/app/casts'; +import type { Testable } from '@prisma/app/testing'; import { deserialize, stash } from './serializer.ts'; const computeParams = { port: { type: 'number', default: 3000 } } as const; @@ -15,6 +16,10 @@ const computeParams = { port: { type: 'number', default: 3000 } } as const; * · load() — called from inside the app's entry: read the stash, hydrate the * deps synchronously, memoize per process, return them merged with the * resolved service params (typed). + * · runForTest(config, boot) — the in-process test counterpart of run(): the + * caller hands a concrete Config directly (no address, no environment to + * read), stash() writes it exactly as run() does, then boot() runs. The + * `@prisma/app/testing` seam (bootstrapService) drives this. * * `service()`'s underlying node carries `extension: '@prisma/app-cloud'` — * the control-plane registry key `prisma-app deploy` resolves through the @@ -26,7 +31,7 @@ export const compute = > deps: D; build: BuildAdapter; expose?: E; -}): RunnableServiceNode => { +}): RunnableServiceNode & Testable => { // load() merges deps and service params into one object; a dep whose name // collides with a service param would be silently clobbered. Fail at // authoring instead. @@ -56,6 +61,10 @@ export const compute = > stash(shape, deserialize(shape, address)); return boot(); }, + async runForTest(config: Config, boot: () => Promise): Promise { + stash(configOf(node), config); + return boot(); + }, load() { if (loaded === undefined) { const shape = configOf(node); @@ -70,8 +79,8 @@ export const compute = > }; return Object.freeze( blindCast< - RunnableServiceNode, - "the spread copies node's own enumerable data (including the Symbol.for brand) and adds run/load — exactly RunnableServiceNode's shape" + RunnableServiceNode & Testable, + "the spread copies node's own enumerable data (including the Symbol.for brand) and adds run/runForTest/load — exactly RunnableServiceNode & Testable's shape" >(runnable), ); }; diff --git a/packages/app/src/testing.ts b/packages/app/src/testing.ts index 31a85284d..dd2e17fb4 100644 --- a/packages/app/src/testing.ts +++ b/packages/app/src/testing.ts @@ -1,13 +1,15 @@ /** - * The unit-test seam (testing.md § Unit): `stubLoad` replaces a service - * node's `load()` output with a typed double, so code that calls - * `service.load()` runs with no server and no environment. Target-agnostic — - * every service node has a `load()` — and performs no module mocking itself; - * wiring the substitution into a test runner (`vi.mock`, `mock.module`) stays - * in the test. + * The two testing seams (testing.md): `stubLoad` replaces a service node's + * `load()` output (unit); `bootstrapService` feeds `load()`'s input by + * booting the real entry against a chosen Config (integration). Both are + * target-agnostic — `stubLoad` because every service node has a `load()`, + * `bootstrapService` because it drives a target-supplied `runForTest` + * capability rather than knowing any target's environment encoding itself. + * Neither does module mocking; wiring a substitution into a test runner + * (`vi.mock`, `mock.module`) stays in the test. */ import { blindCast } from './casts.ts'; -import type { Params, Values } from './config.ts'; +import type { Config, Params, Values } from './config.ts'; import type { Deps, Expose, HydratedDeps, Loaded, RunnableServiceNode } from './node.ts'; /** @@ -55,3 +57,52 @@ export function stubLoad( load: () => loaded, }); } + +/** + * The in-process test capability a target's runnable node adds alongside + * `run`/`load`: write a caller-chosen Config to the environment (exactly as + * `run` does, minus the address→Config deserialize step) and call `boot()`. + * Implemented once per target (e.g. `@prisma/app-cloud`'s `compute()`); a + * structural interface, not a core node shape, so core names it without + * depending on any target. + */ +export interface Testable { + runForTest(config: Config, boot: () => Promise): Promise; +} + +/** What `bootstrapService` hands back: a live, driveable instance of the booted entry. */ +export interface BootstrappedService { + readonly url: string; + readonly fetch: typeof fetch; +} + +/** + * The in-process counterpart of the deploy bootstrap (testing.md § Integration): + * writes `config` into the environment via the target's `runForTest`, then + * imports the app's real entry — `service.build.entry`, resolved relative to + * `service.build.module`, exactly how the printed deploy bootstrap imports it + * (see `@prisma/alchemy`'s artifact.ts). The entry's own top-level code is + * what starts listening (the compute service's `server.ts`, unmodified); + * `config.service.port` is required and concrete because the entry never + * reports an OS-assigned port back to the caller. No `close()` — teardown + * rides bun-test's per-file process isolation (H3's resolved decision). + */ +export async function bootstrapService( + service: RunnableServiceNode & Testable, + config: Config, +): Promise { + const port = config.service['port']; + if (typeof port !== 'number') { + throw new Error( + 'bootstrapService(): config.service.port must be a concrete port number — the booted entry ' + + 'self-listens with no way to report an OS-assigned one back to the caller.', + ); + } + const url = `http://localhost:${port}/`; + const entryUrl = new URL(service.build.entry, service.build.module).href; + + return service.runForTest(config, async () => { + await import(entryUrl); + return { url, fetch }; + }); +} From 9bafb0afc340e72e5b9d9093c58db73d99631ea0 Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 11 Jul 2026 18:05:04 +0200 Subject: [PATCH 6/9] feat(storefront-auth): the fake auth + both proof tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth ships a /fake export: an in-memory verify with no Postgres, sharing the real authContract so the handler map is typed against the same contract the real service exposes. Storefront gets both proof tests: a unit test renders page.tsx with load() stubbed to the fake via stubLoad (vitest, no server, no environment), and an integration test drives the real Next.js standalone entry through bootstrapService against the fake served on a loopback port (bun test — needs Bun.serve, and the resolved no-close() teardown decision rests on bun-test's per-file process isolation). bootstrapService gains an optional third `boot` argument: its default derivation (build.entry resolved against build.module) fits a `node` build adapter, but nextjs's entry is a bare filename inside the Next standalone output, not module-relative, so the integration test supplies its own boot thunk built from @prisma/app-nextjs/control's nextStandaloneDir — the same path math the deploy assembly step uses. This makes the integration test boot the *real* Next.js storefront in-process rather than falling back to a minimal RPC consumer. Part of the H3 slice (reusable-system-testing). Signed-off-by: willbot Signed-off-by: Will Madden --- .../storefront-auth/systems/auth/package.json | 3 +- .../storefront-auth/systems/auth/src/fake.ts | 24 +++++++ .../storefront/app/page.integration.test.ts | 68 +++++++++++++++++++ .../systems/storefront/app/page.test.tsx | 28 ++++++++ .../systems/storefront/package.json | 7 +- .../systems/storefront/vitest.config.ts | 17 +++++ packages/app/src/testing.ts | 22 ++++-- pnpm-lock.yaml | 6 ++ 8 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 examples/storefront-auth/systems/auth/src/fake.ts create mode 100644 examples/storefront-auth/systems/storefront/app/page.integration.test.ts create mode 100644 examples/storefront-auth/systems/storefront/app/page.test.tsx create mode 100644 examples/storefront-auth/systems/storefront/vitest.config.ts diff --git a/examples/storefront-auth/systems/auth/package.json b/examples/storefront-auth/systems/auth/package.json index 80dc35f57..1814c9909 100644 --- a/examples/storefront-auth/systems/auth/package.json +++ b/examples/storefront-auth/systems/auth/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./src/system.ts", - "./contract": "./src/contract.ts" + "./contract": "./src/contract.ts", + "./fake": "./src/fake.ts" }, "scripts": { "dev": "bun run scripts/dev.ts", diff --git a/examples/storefront-auth/systems/auth/src/fake.ts b/examples/storefront-auth/systems/auth/src/fake.ts new file mode 100644 index 000000000..c159aafd1 --- /dev/null +++ b/examples/storefront-auth/systems/auth/src/fake.ts @@ -0,0 +1,24 @@ +/** + * The auth package's fake (testing.md § "the doubles"): an in-memory `verify` + * — no Postgres, no db input — sharing the real `authContract` so its handler + * map is typed against the same contract the real service exposes. Never + * provisioned into a System (it has no db to own); consumed directly by + * tests, `serve()`d on a loopback port for the integration proof. + */ +import { compute } from '@prisma/app-cloud'; +import node from '@prisma/app-node'; +import { serve } from '@prisma/app-rpc'; +import { authContract } from './contract.ts'; + +const fakeAuth = compute({ + name: 'auth-fake', + deps: {}, + build: node({ module: import.meta.url, entry: '../dist/fake.js' }), + expose: { rpc: authContract }, +}); + +export default serve(fakeAuth, { + rpc: { + verify: async ({ token }) => ({ ok: token.length > 0 }), + }, +}); diff --git a/examples/storefront-auth/systems/storefront/app/page.integration.test.ts b/examples/storefront-auth/systems/storefront/app/page.integration.test.ts new file mode 100644 index 000000000..1fe988c19 --- /dev/null +++ b/examples/storefront-auth/systems/storefront/app/page.integration.test.ts @@ -0,0 +1,68 @@ +/// +/** + * Integration proof (testing.md § Integration): the real request path — the + * actual Next.js standalone entry, the real RPC client, real HTTP — driven + * by `bootstrapService` against a fake auth listening on a loopback port. No + * cloud, no deploy; `server.ts`/the Next build output are untouched. Run via + * `bun test` (not vitest — the unit test's runner): it needs `Bun.serve` for + * the loopback fake, and the H3 teardown decision (no `close()`) rests on + * bun-test's per-file process isolation. + * + * storefront's build adapter is `nextjs()`: its `entry` is a bare filename + * inside Next's standalone OUTPUT directory, not a path relative to + * `build.module` — `bootstrapService`'s default derivation fits the `node` + * adapter (see auth), not this one, so this test supplies its own boot + * thunk, reusing the same standalone-path math + * `@prisma/app-nextjs/control`'s deploy assembly uses (`nextStandaloneDir`). + * Requires `next build` to have already produced `.next/standalone` (turbo's + * `test` task depends on `build`, so `pnpm -w test` always has it). + */ +import { describe, expect, it } from 'bun:test'; +import * as path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { BuildAdapter } from '@prisma/app'; +import { bootstrapService } from '@prisma/app/testing'; +import type { NextjsBuildAdapter } from '@prisma/app-nextjs'; +import { nextStandaloneDir } from '@prisma/app-nextjs/control'; +import fakeAuthHandler from '@storefront-auth/auth/fake'; +import storefrontService from '../src/service.ts'; + +const PORT = 4310; + +function isNextjsBuild(build: BuildAdapter): build is NextjsBuildAdapter { + return build.type === 'nextjs' && 'appDir' in build && typeof build.appDir === 'string'; +} + +/** Imports the built standalone `server.js` — Next's own entry, unmodified — exactly as the deploy artifact's bootstrap would. */ +function bootStandaloneNext(build: NextjsBuildAdapter): () => Promise { + const moduleDir = path.dirname(fileURLToPath(build.module)); + const appDir = path.resolve(moduleDir, build.appDir); + const entryPath = path.join(nextStandaloneDir(appDir), build.entry); + return async () => { + await import(pathToFileURL(entryPath).href); + }; +} + +/** React renders adjacent text/expression children with an interleaved `` comment marker — strip it before asserting on rendered text (mirrors scripts/e2e-verify.sh's own technique). */ +const stripComments = (html: string): string => html.replace(//g, ''); + +describe('storefront -> auth round trip, driven over real HTTP (bootstrapService)', () => { + it('renders auth.verify() -> { ok: true } served by the fake auth on a loopback port', async () => { + if (!isNextjsBuild(storefrontService.build)) { + throw new Error('expected the storefront service to use the nextjs build adapter'); + } + + const fake = Bun.serve({ port: 0, fetch: fakeAuthHandler }); + + const app = await bootstrapService( + storefrontService, + { service: { port: PORT }, inputs: { auth: { url: fake.url.href } } }, + bootStandaloneNext(storefrontService.build), + ); + + const res = await app.fetch(new Request(app.url)); + const html = stripComments(await res.text()); + + expect(html).toContain('Auth /verify says: true'); + }); +}); diff --git a/examples/storefront-auth/systems/storefront/app/page.test.tsx b/examples/storefront-auth/systems/storefront/app/page.test.tsx new file mode 100644 index 000000000..d8c22af77 --- /dev/null +++ b/examples/storefront-auth/systems/storefront/app/page.test.tsx @@ -0,0 +1,28 @@ +/** + * Unit proof (testing.md § Unit): mocks storefront's own service module so + * `load()` returns a typed fake auth via `stubLoad`, then renders the page + * directly — no server, no environment, no cloud. + */ +import { stubLoad } from '@prisma/app/testing'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import type Service from '../src/service.ts'; + +vi.mock('../src/service.ts', async () => { + const actual = await vi.importActual<{ default: typeof Service }>('../src/service.ts'); + return { + default: stubLoad(actual.default, { + auth: { verify: async ({ token }) => ({ ok: token.length > 0 }) }, + }), + }; +}); + +describe('Home (page.tsx)', () => { + it('renders the storefront -> auth round trip with load() stubbed to a fake auth', async () => { + const { default: Home } = await import('./page.tsx'); + + const html = renderToStaticMarkup(await Home()); + + expect(html).toContain('Auth /verify says: true'); + }); +}); diff --git a/examples/storefront-auth/systems/storefront/package.json b/examples/storefront-auth/systems/storefront/package.json index 481701d02..3dae9a4e2 100644 --- a/examples/storefront-auth/systems/storefront/package.json +++ b/examples/storefront-auth/systems/storefront/package.json @@ -9,7 +9,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run && bun test app/page.integration.test.ts" }, "dependencies": { "@prisma/app": "workspace:0.2.0", @@ -23,9 +24,11 @@ "react-dom": "19.2.0" }, "devDependencies": { + "@types/bun": "^1.3.13", "@types/node": "^24.9.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.9" } } diff --git a/examples/storefront-auth/systems/storefront/vitest.config.ts b/examples/storefront-auth/systems/storefront/vitest.config.ts new file mode 100644 index 000000000..9e0a8024f --- /dev/null +++ b/examples/storefront-auth/systems/storefront/vitest.config.ts @@ -0,0 +1,17 @@ +import { configDefaults, defineConfig } from 'vitest/config'; + +// page.tsx relies on Next's automatic JSX runtime (no `import React` in +// scope) and its own tsconfig sets `jsx: "preserve"` for Next's own +// compiler — vite's oxc transform needs an explicit override so it doesn't +// inherit that setting. +// +// *.integration.test.ts runs under `bun test` instead (see that file) — it +// needs `Bun.serve` and the H3 teardown decision rests on bun-test's +// per-file process isolation — so vitest must not also pick it up. +export default defineConfig({ + oxc: { jsx: { runtime: 'automatic' } }, + test: { + environment: 'node', + exclude: [...configDefaults.exclude, '**/*.integration.test.ts'], + }, +}); diff --git a/packages/app/src/testing.ts b/packages/app/src/testing.ts index dd2e17fb4..27609c40d 100644 --- a/packages/app/src/testing.ts +++ b/packages/app/src/testing.ts @@ -79,10 +79,15 @@ export interface BootstrappedService { /** * The in-process counterpart of the deploy bootstrap (testing.md § Integration): * writes `config` into the environment via the target's `runForTest`, then - * imports the app's real entry — `service.build.entry`, resolved relative to - * `service.build.module`, exactly how the printed deploy bootstrap imports it - * (see `@prisma/alchemy`'s artifact.ts). The entry's own top-level code is - * what starts listening (the compute service's `server.ts`, unmodified); + * imports the app's real entry and hands back `{ url, fetch }`. By default + * the entry is `service.build.entry` resolved against `service.build.module` + * — exactly how the printed deploy bootstrap imports it (see + * `@prisma/alchemy`'s artifact.ts) — which fits a build adapter whose + * `entry` is a plain module-relative path (e.g. `@prisma/app-node`'s). A + * build adapter whose bootable path isn't module-relative (e.g. + * `@prisma/app-nextjs`'s standalone output) supplies its own `boot` thunk; + * the target owns that resolution, not this generic wrapper. + * * `config.service.port` is required and concrete because the entry never * reports an OS-assigned port back to the caller. No `close()` — teardown * rides bun-test's per-file process isolation (H3's resolved decision). @@ -90,6 +95,7 @@ export interface BootstrappedService { export async function bootstrapService( service: RunnableServiceNode & Testable, config: Config, + boot?: () => Promise, ): Promise { const port = config.service['port']; if (typeof port !== 'number') { @@ -99,10 +105,14 @@ export async function bootstrapService { + await import(new URL(service.build.entry, service.build.module).href); + }); return service.runForTest(config, async () => { - await import(entryUrl); + await bootEntry(); return { url, fetch }; }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2a75ee6c..0f904f41f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: specifier: 19.2.0 version: 19.2.0(react@19.2.0) devDependencies: + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 '@types/node': specifier: ^24.9.0 version: 24.10.13 @@ -152,6 +155,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@24.10.13)(vite@8.1.2(@types/node@24.10.13)(esbuild@0.27.0)(jiti@2.6.1)(yaml@2.9.0)) packages/alchemy: dependencies: From b681774c5e12ddc26ba260387deab196c38321c8 Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 11 Jul 2026 18:49:21 +0200 Subject: [PATCH 7/9] =?UTF-8?q?refactor(system-composition):=20address=20H?= =?UTF-8?q?3=20review=20=E2=80=94=20no=20test=20infra=20in=20production,?= =?UTF-8?q?=20mockService,=20doc=20truth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Your review (CHANGES_REQUESTED) + the Opus pass: - Move the integration seam OUT of production code. `compute()` no longer carries runForTest/Testable — it ships only run/load. `bootstrapService` moves to a new target-specific `@prisma/app-cloud/testing`, doing stash+boot directly via the internal `stash` + `configOf`. `mockService` (the unit seam, renamed from the hard-to-read `stubLoad`) stays in core `@prisma/app/testing`. - The fake moves out of production `src/` to a test-only entrypoint (`auth/testing/fake.ts`, still exported as `./fake`) with its purpose spelled out; storefronts production `next build` no longer type-checks test files. - CodeQL: drop the regex comment-strip in the integration test; assert the exact render instead. - Opus: rewrite `testing.md`s `bootstrapService` example to the shipped API (real Config shape, no `close()`, an explicit Next boot thunk, target-specific); single-source the Next standalone entry path (`standaloneEntryPath`, shared by `assemble` + the test); wire `test:types` into CI. Gates green (typecheck, test, test:types, lint, build, casts delta 0); both proof tests run live (unit + Next-standalone integration). Signed-off-by: willbot Signed-off-by: Will Madden --- .../slices/reusable-system-testing/spec.md | 30 ++++--- .github/workflows/ci.yml | 2 + docs/design/10-domains/testing.md | 42 ++++++--- .../storefront-auth/systems/auth/package.json | 2 +- .../storefront-auth/systems/auth/src/fake.ts | 24 ------ .../systems/auth/testing/fake.ts | 30 +++++++ .../storefront/app/page.integration.test.ts | 22 ++--- .../systems/storefront/app/page.test.tsx | 6 +- .../systems/storefront/tsconfig.json | 2 +- packages/app-cloud/package.json | 1 + .../app-cloud/src/__tests__/extension.test.ts | 36 +++++--- .../src/__tests__/invariants.test.ts | 6 +- packages/app-cloud/src/compute.ts | 17 +--- packages/app-cloud/src/testing.ts | 55 ++++++++++++ packages/app-cloud/tsdown.config.ts | 2 +- packages/app-nextjs/src/control.ts | 8 +- packages/app/src/__tests__/testing.test-d.ts | 14 +-- packages/app/src/__tests__/testing.test.ts | 24 +++--- packages/app/src/testing.ts | 85 +++---------------- 19 files changed, 220 insertions(+), 188 deletions(-) delete mode 100644 examples/storefront-auth/systems/auth/src/fake.ts create mode 100644 examples/storefront-auth/systems/auth/testing/fake.ts create mode 100644 packages/app-cloud/src/testing.ts diff --git a/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md b/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md index 8c53ad9ed..9a7885c36 100644 --- a/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md +++ b/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md @@ -33,9 +33,9 @@ turbo `build` (standing in for publish-time build). The root `system.ts` no longer provisions the database; it provisions the auth System and storefront, wiring auth's exposed `rpc` into storefront's `auth` dependency. -### 2. `@prisma/app/testing` → `stubLoad` (unit seam) +### 2. `@prisma/app/testing` → `mockService` (unit seam) -Core, target-agnostic. `stubLoad(service, overrides)` returns a service node +Core, target-agnostic. `mockService(service, overrides)` returns a service node whose `load()` yields `overrides` merged with the service's param defaults, **typed against the service's `deps`** (a double not assignable to the dep's hydrated type is a compile error). New export `@prisma/app/testing` @@ -44,12 +44,12 @@ entry). It performs no module mocking itself — that stays in the test. ### 3. `bootstrapService` (integration seam) -The in-process counterpart of the deploy bootstrap. A `runForTest(config, boot)` -capability on the runnable node (implemented in `@prisma/app-cloud`'s -`compute.ts`, reusing the existing `stash` + `configOf`), wrapped by a generic -`bootstrapService(service, config)` in `@prisma/app/testing` that returns a -handle `{ url, fetch }`. It writes the chosen config to the environment exactly -as `run` does, boots the real entry, and hands back a driveable server. +The in-process counterpart of the deploy bootstrap. `bootstrapService(service, +config, boot?)` lives in **`@prisma/app-cloud/testing`** (target-specific — +writing the environment is the serializer's job) and returns a handle +`{ url, fetch }`. It writes the chosen config with the target's own `stash` + +`configOf`, boots the real entry, and hands back a driveable server. **No test +code on the production node** — the `compute()` runtime ships only `run`/`load`. **`server.ts` is not modified.** No `close()`: the entry owns its `Bun.serve` handle, so teardown rides on bun-test's per-file process isolation (a single boot per test file, cleaned up when the file's process ends). This is the @@ -65,7 +65,7 @@ the same contract. Used by both proof tests. ## Proof - **Unit test** — renders storefront's `page.tsx` with `load()` mocked via - `stubLoad` to a fake `auth`; asserts the rendered output. No server, no env, + `mockService` to a fake `auth`; asserts the rendered output. No server, no env, no cloud. (vitest — the storefront's runner.) - **Integration test** — runs the fake auth on a loopback port, boots storefront via `bootstrapService` with `auth.url` pointed at it, drives the page over @@ -79,7 +79,7 @@ the same contract. Used by both proof tests. - Running a whole composed graph locally (multi-service `dev` orchestration) — a separate capability the testing doc lists as a non-goal. -- A runner-agnostic module-mock abstraction — `stubLoad` ships the typed +- A runner-agnostic module-mock abstraction — `mockService` ships the typed payload; the `vi.mock`/`mock.module` wiring stays in the tests. - The post-merge cleanups (LoadedControl-style lookup dedup; folding `@prisma/alchemy` into `@prisma/app-cloud`) — tracked separately. @@ -89,7 +89,7 @@ the same contract. Used by both proof tests. - **Teardown:** option (a) — no `close()`; bun-test's per-file process isolation cleans up. `server.ts` stays untouched. (A cleaner "target owns the listen" refactor is explicitly out of scope for H3.) -- **Both paths ship, not either/or.** The unit test (`stubLoad`) AND the +- **Both paths ship, not either/or.** The unit test (`mockService`) AND the integration test (`bootstrapService`) are both deliverables. The integration test boots the **full Next storefront** in-process against a loopback fake auth — the real round trip. If Next-in-process boot proves genuinely intractable, @@ -98,10 +98,12 @@ the same contract. Used by both proof tests. ## Notes for implementation -- `stubLoad`'s override type is the service's hydrated deps +- `mockService`'s override type is the service's hydrated deps (`Client` for rpc, the resource binding for resources) plus optional param overrides — derive it from the node's `Deps`/params, do not hand-roll. -- `runForTest` reuses `stash` (serializer.ts) + `configOf` — it must not add a - second serialize path; writer/reader parity with deploy is the whole point. +- `bootstrapService` (`@prisma/app-cloud/testing`) reuses `stash` + (serializer.ts) + `configOf` — it must not add a second serialize path, and + must add nothing to the production `compute()` node; writer/reader parity with + deploy is the whole point. - Keep the auth System's `authContract` and the fake in one package so the contract cannot drift. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e0dd8eea..ee9a36f77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,8 @@ jobs: env: STATE_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres run: pnpm test + - name: Type-only tests (vitest --typecheck) + run: pnpm turbo run test:types - name: Test scripts (cast-ratchet unit tests) run: pnpm test:scripts diff --git a/docs/design/10-domains/testing.md b/docs/design/10-domains/testing.md index 7f1e4d6eb..b00368d93 100644 --- a/docs/design/10-domains/testing.md +++ b/docs/design/10-domains/testing.md @@ -22,7 +22,7 @@ a fake; it controls what `load()` yields. Two altitudes need two tools — but they hit the same seam from opposite sides: one replaces `load()`'s output, the other feeds `load()`'s input. -## Unit — `stubLoad`: replace the seam's output +## Unit — `mockService`: replace the seam's output For code that calls `load()` directly and is exercised directly — a page component, a server action, a utility — the test mocks the service module so @@ -30,10 +30,10 @@ component, a server action, a utility — the test mocks the service module so ```ts // storefront/app/page.test.tsx -import { stubLoad } from '@prisma/app/testing'; +import { mockService } from '@prisma/app/testing'; // mock storefront's own service module so load() returns a typed fake auth vi.mock('../src/service.ts', () => ({ - default: stubLoad(realService, { auth: { verify: async () => ({ ok: true }) } }), + default: mockService(realService, { auth: { verify: async () => ({ ok: true }) } }), })); import Page from './page.tsx'; @@ -41,7 +41,7 @@ import Page from './page.tsx'; expect(await Page()).toContain('true'); ``` -`stubLoad(service, overrides)` returns a service node whose `load()` yields the +`mockService(service, overrides)` returns a service node whose `load()` yields the overrides merged with the service's param defaults, **typed against the service's own `deps`** — a double that does not satisfy `Client` is a compile error. This is ordinary dependency-injection testing; the app code @@ -60,13 +60,23 @@ environment and boots the entry. `bootstrapService` does exactly that with a config the test chooses: ```ts -// boot storefront in-process against an in-process fake auth -const fake = Bun.serve({ port: 0, fetch: serve(fakeAuth, { rpc: { verify: async () => ({ ok: true }) } }) }); -const app = await bootstrapService(storefront, { inputs: { auth: { url: fake.url } }, port: 0 }); +import { bootstrapService } from '@prisma/app-cloud/testing'; +import fakeAuth from '@storefront-auth/auth/fake'; // a serve() handler, no db +import storefront from '../src/service.ts'; + +const fake = Bun.serve({ port: 0, fetch: fakeAuth }); + +// storefront's build is nextjs(), whose entry lives in Next's standalone output +// dir — not module-relative — so it supplies an explicit boot thunk. A `node` +// service (like auth) needs none: the default derivation fits it. +const app = await bootstrapService( + storefront, + { service: { port: 4310 }, inputs: { auth: { url: fake.url.href } } }, + bootStandaloneNext(storefront.build), +); const res = await app.fetch(new Request(app.url)); -expect(await res.text()).toContain('true'); -await app.close(); await fake.stop(); +expect(await res.text()).toContain('Auth /verify says: true'); ``` Nothing about `server.ts` changes — it boots and listens exactly as in @@ -75,11 +85,15 @@ it over loopback. `load()` deserializes the injected environment identically to a deployed process. The fidelity is the point: an integration test exercises the production code path, not a stubbed one. -`bootstrapService` is target-specific — it uses the target's serializer to write -the environment (`stash`) and the target's `run` to boot. It is exposed as a -`runForTest(config, boot)` capability on the runnable (each target implements -it) that a thin `@prisma/app/testing` wrapper drives, so the generic entry stays -in core and the target owns the environment mapping. +`bootstrapService` is **target-specific** — writing the environment is the +target's serializer's job — so it ships in the target's testing entry +(`@prisma/app-cloud/testing`), not core. It reuses the exact `stash` the deploy +boot uses, so `load()` reads the injected config identically to a deployed +process; nothing about it lives in the production runtime. `config.service.port` +must be concrete (the entry self-listens and never reports an OS-assigned port +back), and there is no `close()` — teardown rides bun-test's per-file process +isolation. `mockService` stays in core (`@prisma/app/testing`); it is +target-agnostic because every service node has a `load()`. ## The doubles: same contract, by type diff --git a/examples/storefront-auth/systems/auth/package.json b/examples/storefront-auth/systems/auth/package.json index 1814c9909..e97b47a78 100644 --- a/examples/storefront-auth/systems/auth/package.json +++ b/examples/storefront-auth/systems/auth/package.json @@ -6,7 +6,7 @@ "exports": { ".": "./src/system.ts", "./contract": "./src/contract.ts", - "./fake": "./src/fake.ts" + "./fake": "./testing/fake.ts" }, "scripts": { "dev": "bun run scripts/dev.ts", diff --git a/examples/storefront-auth/systems/auth/src/fake.ts b/examples/storefront-auth/systems/auth/src/fake.ts deleted file mode 100644 index c159aafd1..000000000 --- a/examples/storefront-auth/systems/auth/src/fake.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * The auth package's fake (testing.md § "the doubles"): an in-memory `verify` - * — no Postgres, no db input — sharing the real `authContract` so its handler - * map is typed against the same contract the real service exposes. Never - * provisioned into a System (it has no db to own); consumed directly by - * tests, `serve()`d on a loopback port for the integration proof. - */ -import { compute } from '@prisma/app-cloud'; -import node from '@prisma/app-node'; -import { serve } from '@prisma/app-rpc'; -import { authContract } from './contract.ts'; - -const fakeAuth = compute({ - name: 'auth-fake', - deps: {}, - build: node({ module: import.meta.url, entry: '../dist/fake.js' }), - expose: { rpc: authContract }, -}); - -export default serve(fakeAuth, { - rpc: { - verify: async ({ token }) => ({ ok: token.length > 0 }), - }, -}); diff --git a/examples/storefront-auth/systems/auth/testing/fake.ts b/examples/storefront-auth/systems/auth/testing/fake.ts new file mode 100644 index 000000000..d854a1eb9 --- /dev/null +++ b/examples/storefront-auth/systems/auth/testing/fake.ts @@ -0,0 +1,30 @@ +/** + * A dummy auth service for TESTING a system that depends on auth — inject it in + * place of the real one so a consumer's tests need no Postgres and no deploy. + * It serves the real `authContract` (so its handler map is type-checked against + * the same contract the real auth exposes) with an in-memory `verify`. A + * test-only entrypoint, deliberately outside `src/`, so it can never ride into + * the deployed artifact. + * + * `serve()` needs a service node with the right `expose`, so this wraps a + * minimal `compute()`. That node's `build` is inert — the fake is never + * assembled or deployed, only `serve()`d on a loopback port by a consumer's + * integration test. + */ +import { compute } from '@prisma/app-cloud'; +import node from '@prisma/app-node'; +import { serve } from '@prisma/app-rpc'; +import { authContract } from '../src/contract.ts'; + +const fakeAuth = compute({ + name: 'auth-fake', + deps: {}, + build: node({ module: import.meta.url, entry: 'fake.ts' }), + expose: { rpc: authContract }, +}); + +export default serve(fakeAuth, { + rpc: { + verify: async ({ token }) => ({ ok: token.length > 0 }), + }, +}); diff --git a/examples/storefront-auth/systems/storefront/app/page.integration.test.ts b/examples/storefront-auth/systems/storefront/app/page.integration.test.ts index 1fe988c19..36097c516 100644 --- a/examples/storefront-auth/systems/storefront/app/page.integration.test.ts +++ b/examples/storefront-auth/systems/storefront/app/page.integration.test.ts @@ -18,12 +18,11 @@ * `test` task depends on `build`, so `pnpm -w test` always has it). */ import { describe, expect, it } from 'bun:test'; -import * as path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; import type { BuildAdapter } from '@prisma/app'; -import { bootstrapService } from '@prisma/app/testing'; +import { bootstrapService } from '@prisma/app-cloud/testing'; import type { NextjsBuildAdapter } from '@prisma/app-nextjs'; -import { nextStandaloneDir } from '@prisma/app-nextjs/control'; +import { standaloneEntryPath } from '@prisma/app-nextjs/control'; import fakeAuthHandler from '@storefront-auth/auth/fake'; import storefrontService from '../src/service.ts'; @@ -33,19 +32,14 @@ function isNextjsBuild(build: BuildAdapter): build is NextjsBuildAdapter { return build.type === 'nextjs' && 'appDir' in build && typeof build.appDir === 'string'; } -/** Imports the built standalone `server.js` — Next's own entry, unmodified — exactly as the deploy artifact's bootstrap would. */ +/** Boots the built standalone Next entry — its own `server.js`, unmodified — via the same path `assemble()` resolves for deploy (not the same bootstrap chain: deploy goes bootstrap.js -> main.mjs -> server.js; here `bootstrapService`'s `stash` stands in for the wrapper's env write). */ function bootStandaloneNext(build: NextjsBuildAdapter): () => Promise { - const moduleDir = path.dirname(fileURLToPath(build.module)); - const appDir = path.resolve(moduleDir, build.appDir); - const entryPath = path.join(nextStandaloneDir(appDir), build.entry); + const entryPath = standaloneEntryPath(build); return async () => { await import(pathToFileURL(entryPath).href); }; } -/** React renders adjacent text/expression children with an interleaved `` comment marker — strip it before asserting on rendered text (mirrors scripts/e2e-verify.sh's own technique). */ -const stripComments = (html: string): string => html.replace(//g, ''); - describe('storefront -> auth round trip, driven over real HTTP (bootstrapService)', () => { it('renders auth.verify() -> { ok: true } served by the fake auth on a loopback port', async () => { if (!isNextjsBuild(storefrontService.build)) { @@ -61,8 +55,10 @@ describe('storefront -> auth round trip, driven over real HTTP (bootstrapService ); const res = await app.fetch(new Request(app.url)); - const html = stripComments(await res.text()); + // React separates the static text from the {String(ok)} expression with an + // empty `` comment; assert around it rather than stripping HTML. + const html = await res.text(); - expect(html).toContain('Auth /verify says: true'); + expect(html).toContain('Auth /verify says: true'); }); }); diff --git a/examples/storefront-auth/systems/storefront/app/page.test.tsx b/examples/storefront-auth/systems/storefront/app/page.test.tsx index d8c22af77..ebaeb2b54 100644 --- a/examples/storefront-auth/systems/storefront/app/page.test.tsx +++ b/examples/storefront-auth/systems/storefront/app/page.test.tsx @@ -1,9 +1,9 @@ /** * Unit proof (testing.md § Unit): mocks storefront's own service module so - * `load()` returns a typed fake auth via `stubLoad`, then renders the page + * `load()` returns a typed fake auth via `mockService`, then renders the page * directly — no server, no environment, no cloud. */ -import { stubLoad } from '@prisma/app/testing'; +import { mockService } from '@prisma/app/testing'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; import type Service from '../src/service.ts'; @@ -11,7 +11,7 @@ import type Service from '../src/service.ts'; vi.mock('../src/service.ts', async () => { const actual = await vi.importActual<{ default: typeof Service }>('../src/service.ts'); return { - default: stubLoad(actual.default, { + default: mockService(actual.default, { auth: { verify: async ({ token }) => ({ ok: token.length > 0 }) }, }), }; diff --git a/examples/storefront-auth/systems/storefront/tsconfig.json b/examples/storefront-auth/systems/storefront/tsconfig.json index 205e8aa68..004a32138 100644 --- a/examples/storefront-auth/systems/storefront/tsconfig.json +++ b/examples/storefront-auth/systems/storefront/tsconfig.json @@ -17,5 +17,5 @@ "plugins": [{ "name": "next" }] }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "**/*.integration.test.ts"] } diff --git a/packages/app-cloud/package.json b/packages/app-cloud/package.json index 8b5389b78..c932b24fe 100644 --- a/packages/app-cloud/package.json +++ b/packages/app-cloud/package.json @@ -5,6 +5,7 @@ "exports": { ".": "./dist/index.mjs", "./control": "./dist/control.mjs", + "./testing": "./dist/testing.mjs", "./package.json": "./package.json" }, "scripts": { diff --git a/packages/app-cloud/src/__tests__/extension.test.ts b/packages/app-cloud/src/__tests__/extension.test.ts index 0b846b780..7cfdec5e0 100644 --- a/packages/app-cloud/src/__tests__/extension.test.ts +++ b/packages/app-cloud/src/__tests__/extension.test.ts @@ -3,6 +3,7 @@ import type { Contract } from '@prisma/app'; import { configOf, hydrateSync, isNode } from '@prisma/app'; import { compute, postgres, postgresContract } from '../index.ts'; import { configKey, deserialize } from '../serializer.ts'; +import { bootstrapService } from '../testing.ts'; const build = { extension: '@prisma/app-node', @@ -345,29 +346,31 @@ describe('compute().run(address, boot) → load() — the round trip', () => { }); }); -describe('compute().runForTest(config, boot) — the in-process test seam', () => { - test("stashes the given Config address-free (like run('', ...)) and calls boot()", async () => { +describe('bootstrapService(service, config, boot) — the in-process integration seam', () => { + test("stashes the given Config address-free (like run('', ...)) so the booted entry's load() reads it", async () => { const app = compute({ name: 'test-service', deps: { db: postgres() }, build }); let loaded: unknown; await withEnv({ DB_URL: '', PORT: '' }, () => - app.runForTest( - { service: { port: 4321 }, inputs: { db: { url: 'postgres://runfortest' } } }, + bootstrapService( + app, + { service: { port: 4321 }, inputs: { db: { url: 'postgres://bootstrap' } } }, async () => { loaded = app.load(); }, ), ); - expect(loaded).toEqual({ db: { url: 'postgres://runfortest' }, port: 4321 }); + expect(loaded).toEqual({ db: { url: 'postgres://bootstrap' }, port: 4321 }); }); - test('needs no pre-set environment — no address, no deserialize step, unlike run()', async () => { + test('needs no pre-set environment — the caller supplies the Config directly, unlike run()', async () => { const app = compute({ name: 'test-service', deps: { db: postgres() }, build }); let loaded: unknown; await withEnv({ DB_URL: 'stale', PORT: 'stale' }, () => - app.runForTest( + bootstrapService( + app, { service: { port: 5555 }, inputs: { db: { url: 'postgres://fresh' } } }, async () => { loaded = app.load(); @@ -378,12 +381,25 @@ describe('compute().runForTest(config, boot) — the in-process test seam', () = expect(loaded).toEqual({ db: { url: 'postgres://fresh' }, port: 5555 }); }); - test('returns whatever boot() yields', async () => { + test('returns { url, fetch } pointing at the configured port', async () => { const app = compute({ name: 'test-service', deps: {}, build }); - const result = await app.runForTest({ service: {}, inputs: {} }, async () => 'boot-result'); + const svc = await bootstrapService( + app, + { service: { port: 6789 }, inputs: {} }, + async () => {}, + ); + + expect(svc.url).toBe('http://localhost:6789/'); + expect(typeof svc.fetch).toBe('function'); + }); + + test('rejects a Config with no concrete port — the entry self-listens', async () => { + const app = compute({ name: 'test-service', deps: {}, build }); - expect(result).toBe('boot-result'); + await expect( + bootstrapService(app, { service: {}, inputs: {} }, async () => {}), + ).rejects.toThrow(/concrete port number/); }); }); diff --git a/packages/app-cloud/src/__tests__/invariants.test.ts b/packages/app-cloud/src/__tests__/invariants.test.ts index fbcb276fd..fd4a31ad5 100644 --- a/packages/app-cloud/src/__tests__/invariants.test.ts +++ b/packages/app-cloud/src/__tests__/invariants.test.ts @@ -22,12 +22,12 @@ function shippedSources(): { file: string; text: string }[] { return out; } -describe('entry map: the extension splits into authoring + control only', () => { - test("package.json exports exactly '.' and './control' — no runtime entry", () => { +describe('entry map: authoring + control + testing, no runtime entry', () => { + test("package.json exports '.', './control', and './testing'", () => { const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')); // `./package.json` is a conventional manifest export, not a code entry. const codeEntries = Object.keys(pkg.exports).filter((k) => k !== './package.json'); - expect(codeEntries.sort()).toEqual(['.', './control']); + expect(codeEntries.sort()).toEqual(['.', './control', './testing']); }); }); diff --git a/packages/app-cloud/src/compute.ts b/packages/app-cloud/src/compute.ts index 9832fdcf7..8d63c44c4 100644 --- a/packages/app-cloud/src/compute.ts +++ b/packages/app-cloud/src/compute.ts @@ -1,7 +1,6 @@ -import type { BuildAdapter, Config, Deps, Expose, Loaded, RunnableServiceNode } from '@prisma/app'; +import type { BuildAdapter, Deps, Expose, Loaded, RunnableServiceNode } from '@prisma/app'; import { configOf, hydrateSync, service } from '@prisma/app'; import { blindCast } from '@prisma/app/casts'; -import type { Testable } from '@prisma/app/testing'; import { deserialize, stash } from './serializer.ts'; const computeParams = { port: { type: 'number', default: 3000 } } as const; @@ -16,10 +15,6 @@ const computeParams = { port: { type: 'number', default: 3000 } } as const; * · load() — called from inside the app's entry: read the stash, hydrate the * deps synchronously, memoize per process, return them merged with the * resolved service params (typed). - * · runForTest(config, boot) — the in-process test counterpart of run(): the - * caller hands a concrete Config directly (no address, no environment to - * read), stash() writes it exactly as run() does, then boot() runs. The - * `@prisma/app/testing` seam (bootstrapService) drives this. * * `service()`'s underlying node carries `extension: '@prisma/app-cloud'` — * the control-plane registry key `prisma-app deploy` resolves through the @@ -31,7 +26,7 @@ export const compute = > deps: D; build: BuildAdapter; expose?: E; -}): RunnableServiceNode & Testable => { +}): RunnableServiceNode => { // load() merges deps and service params into one object; a dep whose name // collides with a service param would be silently clobbered. Fail at // authoring instead. @@ -61,10 +56,6 @@ export const compute = > stash(shape, deserialize(shape, address)); return boot(); }, - async runForTest(config: Config, boot: () => Promise): Promise { - stash(configOf(node), config); - return boot(); - }, load() { if (loaded === undefined) { const shape = configOf(node); @@ -79,8 +70,8 @@ export const compute = > }; return Object.freeze( blindCast< - RunnableServiceNode & Testable, - "the spread copies node's own enumerable data (including the Symbol.for brand) and adds run/runForTest/load — exactly RunnableServiceNode & Testable's shape" + RunnableServiceNode, + "the spread copies node's own enumerable data (including the Symbol.for brand) and adds run/load — exactly RunnableServiceNode's shape" >(runnable), ); }; diff --git a/packages/app-cloud/src/testing.ts b/packages/app-cloud/src/testing.ts new file mode 100644 index 000000000..c8a4781c8 --- /dev/null +++ b/packages/app-cloud/src/testing.ts @@ -0,0 +1,55 @@ +/** + * Prisma Cloud's integration-test seam (testing.md § Integration). + * `bootstrapService` boots a compute service's real entry in-process against a + * caller-chosen Config, writing that Config to the environment with the exact + * `stash` the deploy boot uses — so `load()` reads it identically to a + * deployed process. Target-specific, and here rather than in core, because + * writing the environment is this extension's serializer's job; nothing about + * this reaches the production runtime (`compute()` ships only `run`/`load`). + */ + +import type { Deps, Expose, Params, RunnableServiceNode } from '@prisma/app'; +import { type Config, configOf } from '@prisma/app'; +import { stash } from './serializer.ts'; + +/** What `bootstrapService` hands back: a live, driveable instance of the booted entry. */ +export interface BootstrappedService { + readonly url: string; + readonly fetch: typeof fetch; +} + +/** + * Boots `service`'s real entry against `config`, in-process. By default the + * entry is `service.build.entry` resolved against `service.build.module` — + * exactly how the printed deploy bootstrap imports it (see `@prisma/alchemy`'s + * artifact.ts) — which fits a build adapter whose `entry` is a plain + * module-relative path (`@prisma/app-node`'s). A build adapter whose bootable + * path isn't module-relative (`@prisma/app-nextjs`'s standalone output) + * supplies its own `boot` thunk; the target owns that resolution. + * + * `config.service.port` must be concrete — the entry self-listens and never + * reports an OS-assigned port back. No `close()`: teardown rides bun-test's + * per-file process isolation (H3's resolved decision). + */ +export async function bootstrapService( + service: RunnableServiceNode, + config: Config, + boot?: () => Promise, +): Promise { + const port = config.service['port']; + if (typeof port !== 'number') { + throw new Error( + 'bootstrapService(): config.service.port must be a concrete port number — the booted entry ' + + 'self-listens with no way to report an OS-assigned one back to the caller.', + ); + } + const bootEntry = + boot ?? + (async () => { + await import(new URL(service.build.entry, service.build.module).href); + }); + + stash(configOf(service), config); + await bootEntry(); + return { url: `http://localhost:${port}/`, fetch }; +} diff --git a/packages/app-cloud/tsdown.config.ts b/packages/app-cloud/tsdown.config.ts index af0526ad3..3cd8e5fab 100644 --- a/packages/app-cloud/tsdown.config.ts +++ b/packages/app-cloud/tsdown.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from '@prisma/app-tsdown'; export default defineConfig({ - entry: ['src/index.ts', 'src/control.ts'], + entry: ['src/index.ts', 'src/control.ts', 'src/testing.ts'], }); diff --git a/packages/app-nextjs/src/control.ts b/packages/app-nextjs/src/control.ts index 838e78449..f6ab14d37 100644 --- a/packages/app-nextjs/src/control.ts +++ b/packages/app-nextjs/src/control.ts @@ -53,6 +53,12 @@ export function nextStandaloneDir(appDir: string): string { return path.join(resolvedApp, '.next', 'standalone', rel); } +/** The bootable standalone entry for a nextjs build — the output dir joined with the adapter's `entry`. Single-sourced so `assemble()` (deploy) and the integration-test seam can't drift. */ +export function standaloneEntryPath(build: NextjsBuildAdapter): string { + const resolvedApp = path.resolve(path.dirname(fileURLToPath(build.module)), build.appDir); + return path.join(nextStandaloneDir(resolvedApp), build.entry); +} + export async function assemble(input: AssembleInput): Promise { if (!isNextjsBuild(input.build)) { throw new Error( @@ -65,7 +71,7 @@ export async function assemble(input: AssembleInput): Promise { const moduleDir = path.dirname(serviceModule); const resolvedApp = path.resolve(moduleDir, buildDescriptor.appDir); const appOut = nextStandaloneDir(resolvedApp); - const entryPath = path.join(appOut, buildDescriptor.entry); + const entryPath = standaloneEntryPath(buildDescriptor); if (!fs.existsSync(entryPath)) { throw new Error( `no standalone ${buildDescriptor.entry} at ${appOut} — run \`next build\` with output: "standalone" first`, diff --git a/packages/app/src/__tests__/testing.test-d.ts b/packages/app/src/__tests__/testing.test-d.ts index 68d685f98..8696aec44 100644 --- a/packages/app/src/__tests__/testing.test-d.ts +++ b/packages/app/src/__tests__/testing.test-d.ts @@ -1,5 +1,5 @@ /** - * `stubLoad`'s override argument is typed against the service's own `deps` + * `mockService`'s override argument is typed against the service's own `deps` * (`HydratedDeps`) and `params` (`Partial>`) — a double that * doesn't satisfy a dep's hydrated shape, or a param of the wrong type, must * fail to compile. Type-only (vitest `--typecheck`, never executed): see @@ -9,7 +9,7 @@ import { expectTypeOf, test } from 'vitest'; import type { ConfigParam } from '../config.ts'; import type { BuildAdapter, RunnableServiceNode } from '../node.ts'; import { dependency, service } from '../node.ts'; -import { stubLoad } from '../testing.ts'; +import { mockService } from '../testing.ts'; import { conn } from './helpers.ts'; const build: BuildAdapter = { @@ -56,10 +56,10 @@ const consumer = (): RunnableServiceNode => }); test('a correctly-shaped double, with or without the optional param override, compiles', () => { - const withoutParam = stubLoad(consumer(), { + const withoutParam = mockService(consumer(), { auth: { verify: async ({ token }: { token: string }) => ({ ok: token.length > 0 }) }, }); - const withParam = stubLoad(consumer(), { + const withParam = mockService(consumer(), { auth: { verify: async () => ({ ok: true }) }, port: 8080, }); @@ -70,15 +70,15 @@ test('a correctly-shaped double, with or without the optional param override, co test('omitting the required "auth" override is a compile error', () => { // @ts-expect-error "auth" is a declared dep with no default — it must be supplied - stubLoad(consumer(), {}); + mockService(consumer(), {}); }); test("a double whose method return shape doesn't satisfy the dep's hydrated contract is a compile error", () => { // @ts-expect-error verify must resolve to `{ ok: boolean }`, not `{ status: string }` - stubLoad(consumer(), { auth: { verify: async () => ({ status: 'ok' }) } }); + mockService(consumer(), { auth: { verify: async () => ({ status: 'ok' }) } }); }); test('overriding a param with the wrong type is a compile error', () => { // @ts-expect-error port is declared `number` - stubLoad(consumer(), { auth: { verify: async () => ({ ok: true }) }, port: 'nope' }); + mockService(consumer(), { auth: { verify: async () => ({ ok: true }) }, port: 'nope' }); }); diff --git a/packages/app/src/__tests__/testing.test.ts b/packages/app/src/__tests__/testing.test.ts index 93f74b0e8..c95cc2c3e 100644 --- a/packages/app/src/__tests__/testing.test.ts +++ b/packages/app/src/__tests__/testing.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import type { ConfigParam } from '../config.ts'; import type { BuildAdapter, RunnableServiceNode } from '../node.ts'; import { dependency, service } from '../node.ts'; -import { stubLoad } from '../testing.ts'; +import { mockService } from '../testing.ts'; import { conn } from './helpers.ts'; const build: BuildAdapter = { @@ -30,7 +30,7 @@ const authDep = () => type ConsumerDeps = { auth: ReturnType }; type ConsumerParams = { port: ConfigParam<'number'> }; -/** A RunnableServiceNode fixture whose own run()/load() must never actually run — stubLoad replaces load() entirely, and run() is never called under a stub. */ +/** A RunnableServiceNode fixture whose own run()/load() must never actually run — mockService replaces load() entirely, and run() is never called under a stub. */ const consumer = (): RunnableServiceNode => Object.freeze({ ...service({ @@ -42,16 +42,18 @@ const consumer = (): RunnableServiceNode => build, }), async run(): Promise { - throw new Error('consumer.run() should never be called under stubLoad.'); + throw new Error('consumer.run() should never be called under mockService.'); }, load() { - throw new Error('consumer.load() should never be reached — stubLoad replaces it entirely.'); + throw new Error( + 'consumer.load() should never be reached — mockService replaces it entirely.', + ); }, }); -describe('stubLoad', () => { +describe('mockService', () => { test("load() yields the override merged with the service's param defaults", async () => { - const stub = stubLoad(consumer(), { + const stub = mockService(consumer(), { auth: { verify: async ({ token }) => ({ ok: token.length > 0 }) }, }); @@ -61,7 +63,7 @@ describe('stubLoad', () => { }); test('an overridden param wins over the default', () => { - const stub = stubLoad(consumer(), { + const stub = mockService(consumer(), { auth: { verify: async () => ({ ok: true }) }, port: 8080, }); @@ -70,18 +72,18 @@ describe('stubLoad', () => { }); test('load() returns the same object on every call', () => { - const stub = stubLoad(consumer(), { auth: { verify: async () => ({ ok: true }) } }); + const stub = mockService(consumer(), { auth: { verify: async () => ({ ok: true }) } }); expect(stub.load()).toBe(stub.load()); }); test('run() throws, naming the service', () => { - const stub = stubLoad(consumer(), { auth: { verify: async () => ({ ok: true }) } }); - expect(() => stub.run('addr', async () => undefined)).toThrow(/"consumer".*load\(\)-only stub/); + const stub = mockService(consumer(), { auth: { verify: async () => ({ ok: true }) } }); + expect(() => stub.run('addr', async () => undefined)).toThrow(/"consumer".*load\(\)-only mock/); }); test('deps/params/build/name pass through unchanged', () => { const original = consumer(); - const stub = stubLoad(original, { auth: { verify: async () => ({ ok: true }) } }); + const stub = mockService(original, { auth: { verify: async () => ({ ok: true }) } }); expect(stub.inputs).toBe(original.inputs); expect(stub.params).toBe(original.params); expect(stub.build).toBe(original.build); diff --git a/packages/app/src/testing.ts b/packages/app/src/testing.ts index 27609c40d..1c93c0352 100644 --- a/packages/app/src/testing.ts +++ b/packages/app/src/testing.ts @@ -1,19 +1,19 @@ /** - * The two testing seams (testing.md): `stubLoad` replaces a service node's - * `load()` output (unit); `bootstrapService` feeds `load()`'s input by - * booting the real entry against a chosen Config (integration). Both are - * target-agnostic — `stubLoad` because every service node has a `load()`, - * `bootstrapService` because it drives a target-supplied `runForTest` - * capability rather than knowing any target's environment encoding itself. - * Neither does module mocking; wiring a substitution into a test runner - * (`vi.mock`, `mock.module`) stays in the test. + * The unit-test seam (testing.md § Unit): `mockService` replaces a service + * node's `load()` output so any code that pulls dependencies through + * `service.load()` — a page, a server action, a helper — runs against typed + * doubles with no server and no environment. Target-agnostic: every service + * node has a `load()`. It does no module mocking; wiring the substitution into + * a test runner (`vi.mock`, `mock.module`) stays in the test. The integration + * seam (`bootstrapService`) is target-specific and lives in the target's own + * testing entry (e.g. `@prisma/app-cloud/testing`). */ import { blindCast } from './casts.ts'; -import type { Config, Params, Values } from './config.ts'; +import type { Params, Values } from './config.ts'; import type { Deps, Expose, HydratedDeps, Loaded, RunnableServiceNode } from './node.ts'; /** - * `stubLoad`'s override argument: every declared dependency, typed against + * `mockService`'s override argument: every declared dependency, typed against * its own hydrated shape (`Client` for an RPC dep, the resource binding * for a resource dep) — a double of the wrong shape is a compile error. The * service's own params are optional; an omitted one falls back to its @@ -35,10 +35,10 @@ function paramDefaults

(params: P): Partial> { /** * Returns a service node whose `load()` yields `overrides` merged with the * service's own param defaults — everything else about the node (its deps, - * params, build, expose) is unchanged. `run()` is not meaningful on a stub + * params, build, expose) is unchanged. `run()` is not meaningful on a mock * (there is no boot, no environment) and throws if called. */ -export function stubLoad( +export function mockService( service: RunnableServiceNode, overrides: LoadOverrides, ): RunnableServiceNode { @@ -51,68 +51,9 @@ export function stubLoad( ...service, run(): Promise { throw new Error( - `stubLoad(): "${service.name}" is a load()-only stub — it has no run() (no boot, no environment).`, + `mockService(): "${service.name}" is a load()-only mock — it has no run() (no boot, no environment).`, ); }, load: () => loaded, }); } - -/** - * The in-process test capability a target's runnable node adds alongside - * `run`/`load`: write a caller-chosen Config to the environment (exactly as - * `run` does, minus the address→Config deserialize step) and call `boot()`. - * Implemented once per target (e.g. `@prisma/app-cloud`'s `compute()`); a - * structural interface, not a core node shape, so core names it without - * depending on any target. - */ -export interface Testable { - runForTest(config: Config, boot: () => Promise): Promise; -} - -/** What `bootstrapService` hands back: a live, driveable instance of the booted entry. */ -export interface BootstrappedService { - readonly url: string; - readonly fetch: typeof fetch; -} - -/** - * The in-process counterpart of the deploy bootstrap (testing.md § Integration): - * writes `config` into the environment via the target's `runForTest`, then - * imports the app's real entry and hands back `{ url, fetch }`. By default - * the entry is `service.build.entry` resolved against `service.build.module` - * — exactly how the printed deploy bootstrap imports it (see - * `@prisma/alchemy`'s artifact.ts) — which fits a build adapter whose - * `entry` is a plain module-relative path (e.g. `@prisma/app-node`'s). A - * build adapter whose bootable path isn't module-relative (e.g. - * `@prisma/app-nextjs`'s standalone output) supplies its own `boot` thunk; - * the target owns that resolution, not this generic wrapper. - * - * `config.service.port` is required and concrete because the entry never - * reports an OS-assigned port back to the caller. No `close()` — teardown - * rides bun-test's per-file process isolation (H3's resolved decision). - */ -export async function bootstrapService( - service: RunnableServiceNode & Testable, - config: Config, - boot?: () => Promise, -): Promise { - const port = config.service['port']; - if (typeof port !== 'number') { - throw new Error( - 'bootstrapService(): config.service.port must be a concrete port number — the booted entry ' + - 'self-listens with no way to report an OS-assigned one back to the caller.', - ); - } - const url = `http://localhost:${port}/`; - const bootEntry = - boot ?? - (async () => { - await import(new URL(service.build.entry, service.build.module).href); - }); - - return service.runForTest(config, async () => { - await bootEntry(); - return { url, fetch }; - }); -} From eb0788453f6f58bcceba464b889fe7ba8635f67b Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 11 Jul 2026 18:59:37 +0200 Subject: [PATCH 8/9] chore: sync pnpm-lock.yaml after the main merge The merge auto-merged a broken lockfile (missing the vitest peer-hash entry after the @types/node bump); it was committed before the clean reinstall regenerated it. Regenerate so --frozen-lockfile passes in CI. Signed-off-by: willbot Signed-off-by: Will Madden --- pnpm-lock.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0581f6d02..99737dc3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,7 +157,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@24.10.13)(vite@8.1.2(@types/node@24.10.13)(esbuild@0.27.0)(jiti@2.6.1)(yaml@2.9.0)) + version: 4.1.9(@types/node@25.9.5)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.27.0)(jiti@2.6.1)(yaml@2.9.0)) packages/alchemy: dependencies: @@ -5026,7 +5026,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.2.4 From 0f84d563fe832654cc25f97361a1b3a0cb0db70b Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 11 Jul 2026 19:10:30 +0200 Subject: [PATCH 9/9] docs(testing): rewrite testing.md for a fresh reader + add testing-prisma-apps skill - Rewrite docs/design/10-domains/testing.md: lead with the decision, open with a worked example, build the topic up plainly, alternatives-considered at the end. Drops the contrived "replace/feed the seam" phrasing. - Add skills/testing-prisma-apps: a user-facing how-to for mockService (unit) and bootstrapService (integration). Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/10-domains/testing.md | 251 +++++++++++++++++----------- skills/testing-prisma-apps/SKILL.md | 110 ++++++++++++ 2 files changed, 262 insertions(+), 99 deletions(-) create mode 100644 skills/testing-prisma-apps/SKILL.md diff --git a/docs/design/10-domains/testing.md b/docs/design/10-domains/testing.md index b00368d93..eaef468ca 100644 --- a/docs/design/10-domains/testing.md +++ b/docs/design/10-domains/testing.md @@ -1,132 +1,185 @@ -# Testing apps built on the framework +# Testing an app built on the framework -How a Prisma App is tested — from a single page component up to the full -request path — with no deployment, no cloud account, and no change to the code -under test. It rests on one fact: **every dependency an app touches flows -through one seam, `service.load()`**, so a test injects its doubles at that -single point and nothing about the application code moves. +You test a Prisma App by controlling one function: `service.load()`, the single +call through which application code gets its dependencies. You never change the +code under test to make it testable — you decide what `load()` hands it. Two +tools cover the two situations you'll meet: -## The one seam: `service.load()` +- **`mockService`** — unit-test a piece of code (a page, a server action, a + helper) with fake dependencies. +- **`bootstrapService`** — integration-test the real request path: the service + actually boots and serves, talking to stand-ins you run yourself. -A service declares its dependencies (`deps`) and the ports it exposes -(`expose`). Its runtime code — the RPC server, a Next.js page, a server action, -a plain helper — never receives its dependencies as arguments and never reaches -for a global. It calls `service.load()`, which reads the process config -(deserialized from the environment the deploy injected — see -[`deploy-cli.md`](deploy-cli.md)), hydrates each dependency to its client or -binding (ADR-0015), and returns them typed. +## A worked example -Because that is the *only* way application code obtains a dependency, it is the -only place a test has to touch. A test never edits the code under test to accept -a fake; it controls what `load()` yields. Two altitudes need two tools — but -they hit the same seam from opposite sides: one replaces `load()`'s output, the -other feeds `load()`'s input. +The `storefront` app has a page that depends on an `auth` service: -## Unit — `mockService`: replace the seam's output +```tsx +// storefront/app/page.tsx — ordinary application code +import service from '../src/service.ts'; -For code that calls `load()` directly and is exercised directly — a page -component, a server action, a utility — the test mocks the service module so -`load()` returns doubles, then calls the code with no server and no environment. +export default async function Page() { + const { auth } = service.load(); + const { ok } = await auth.verify({ token: 'demo' }); + return

Signed in: {String(ok)}

; +} +``` -```ts +The page gets `auth` by calling `service.load()`. To test it without a real auth +service — no database, no deployment, no cloud account — you replace what +`load()` returns: + +```tsx // storefront/app/page.test.tsx import { mockService } from '@prisma/app/testing'; -// mock storefront's own service module so load() returns a typed fake auth + vi.mock('../src/service.ts', () => ({ - default: mockService(realService, { auth: { verify: async () => ({ ok: true }) } }), + default: mockService(realService, { + auth: { verify: async () => ({ ok: true }) }, + }), })); + import Page from './page.tsx'; -// the double is checked against Client; a wrong shape fails to compile -expect(await Page()).toContain('true'); +expect(renderToString(await Page())).toContain('Signed in: true'); ``` -`mockService(service, overrides)` returns a service node whose `load()` yields the -overrides merged with the service's param defaults, **typed against the -service's own `deps`** — a double that does not satisfy `Client` -is a compile error. This is ordinary dependency-injection testing; the app code -runs unchanged. It is target-agnostic (every service node has a `load()`), so it -lives in core, `@prisma/app/testing`. The one runner-specific step — how the -module substitution is wired (`vi.mock`, bun `mock.module`) — stays in the test; -the framework supplies the typed payload, not the mock call. +The page runs its real logic; only `auth` is a stand-in. Everything below +expands on this one move. + +## Why one function is enough + +A service declares the dependencies it needs and the ports it exposes. Its code +— a page, a server action, an RPC handler, a plain function — never receives +those dependencies as arguments and never reaches for a global. It calls +`service.load()`. + +`load()` does three things: it reads the service's configuration (which a +deployment places in the process environment), turns each dependency into the +concrete client the code will call (ADR-0015), and returns them with their real +types. Because this is the *only* way application code reaches a dependency, it +is the only place a test has to intervene — which is what lets the tests leave +the application code completely untouched. + +The two tools intervene at the same point from opposite directions. +`mockService` decides what `load()` **returns**; `bootstrapService` decides what +`load()` **reads**. Which you want depends on how much of the real path you're +testing. + +## Unit tests: `mockService` + +When you want to test a piece of code in isolation — call it directly, assert on +what it returns or renders — use `mockService`. You mock the service module so +`load()` yields doubles, then exercise the code with no server and no +environment. That is the worked example above. + +`mockService(service, doubles)` returns a copy of the service whose `load()` +returns your doubles merged with the service's own parameter defaults. The +doubles are typed against the service's declared dependencies: a fake `auth` +must be a valid `authContract` client, so a wrong-shaped fake is a compile +error, not a test that passes by accident. + +It works for any service — every service has a `load()` — so it lives in the +framework core, `@prisma/app/testing`. The one part that depends on your test +runner is *how* you substitute the module (`vi.mock` in Vitest, `mock.module` +in bun test). The framework gives you the typed value to substitute; wiring it +into the runner stays in your test. -## Integration — `bootstrapService`: feed the seam's input +## Integration tests: `bootstrapService` -For the real request path — the actual boot, the real client, the real wire -format — the test is the in-process counterpart of the deploy bootstrap. The -deploy bootstrap is `main.run(address, () => import(appEntry))` -([deploy-cli.md](deploy-cli.md)); `run` writes the resolved config into the -environment and boots the entry. `bootstrapService` does exactly that with a -config the test chooses: +When you want the real request path — the actual server boot, the real network +client, the real wire format — use `bootstrapService`. It starts your service's +real entry point in a configuration you choose, in-process, and hands you +something you can send HTTP requests to. You point one of its dependencies at a +stand-in you run yourself, and drive the round trip. ```ts +// storefront/app/page.integration.test.ts import { bootstrapService } from '@prisma/app-cloud/testing'; -import fakeAuth from '@storefront-auth/auth/fake'; // a serve() handler, no db +import fakeAuth from '@storefront-auth/auth/fake'; // an in-memory auth handler, no database import storefront from '../src/service.ts'; +// run the fake auth on a loopback port const fake = Bun.serve({ port: 0, fetch: fakeAuth }); -// storefront's build is nextjs(), whose entry lives in Next's standalone output -// dir — not module-relative — so it supplies an explicit boot thunk. A `node` -// service (like auth) needs none: the default derivation fits it. -const app = await bootstrapService( - storefront, - { service: { port: 4310 }, inputs: { auth: { url: fake.url.href } } }, - bootStandaloneNext(storefront.build), -); +const app = await bootstrapService(storefront, { + service: { port: 4310 }, + inputs: { auth: { url: fake.url.href } }, // point storefront's auth dependency at the fake +}); const res = await app.fetch(new Request(app.url)); -expect(await res.text()).toContain('Auth /verify says: true'); +expect(await res.text()).toContain('Signed in: true'); ``` -Nothing about `server.ts` changes — it boots and listens exactly as in -production; the test just points its `auth` binding at a local fake and drives -it over loopback. `load()` deserializes the injected environment identically to -a deployed process. The fidelity is the point: an integration test exercises the -production code path, not a stubbed one. - -`bootstrapService` is **target-specific** — writing the environment is the -target's serializer's job — so it ships in the target's testing entry -(`@prisma/app-cloud/testing`), not core. It reuses the exact `stash` the deploy -boot uses, so `load()` reads the injected config identically to a deployed -process; nothing about it lives in the production runtime. `config.service.port` -must be concrete (the entry self-listens and never reports an OS-assigned port -back), and there is no `close()` — teardown rides bun-test's per-file process -isolation. `mockService` stays in core (`@prisma/app/testing`); it is -target-agnostic because every service node has a `load()`. - -## The doubles: same contract, by type - -A dependency's *hydrated type* is its contract. An RPC dependency -`rpc(authContract)` hydrates to `Client` — `{ verify(input): -Promise }` — so a double is any value of that type, checked at compile -time. Three grades, increasing fidelity: - -- **A bare object** — `{ verify: async () => ({ ok: true }) }`. Fastest; skips - the wire entirely. For unit tests where only the return value matters. -- **A contract-faithful in-process fake** — `makeClient(authContract, url, { - fetch: serve(fakeAuth, handlers) })`: the *real* client over a *fake* - transport, so JSON encode/decode and both schema validations still run. Same - process, no socket. -- **A real local server** — the fake `serve()`d on a loopback port, reached by - the real client over real HTTP. What `bootstrapService` uses. - -The fake ships from the dependency's own package (a `/fake` export), so its -handler map is typed against the same `authContract` the real service exposes — -the contract cannot drift between the real service and its fake. - -## Non-goals - -- **Running a whole composed graph locally.** `bootstrapService` boots one - service; wiring several services together in-process (a local `dev` - orchestration) is a separate capability, not this seam. -- **A runner-agnostic mock abstraction.** The framework ships the typed double - builder and documents the `vi.mock` / `mock.module` patterns; it does not wrap - every test runner's module system. +The point is what *doesn't* change: `storefront`'s server code is untouched. It +boots and listens exactly as it does in production; the test only chooses the +configuration it boots with. `load()` reads that configuration the same way a +deployed process would, so pointing `auth` at `http://localhost:…` is the same +mechanism a deployment uses to point it at the real service. You exercise the +production code path, not a rewrite of it. + +Starting a service the way a deployment does is specific to the platform you +deploy to, so `bootstrapService` ships in that platform's testing entry +(`@prisma/app-cloud/testing`), not the core. (`mockService` only substitutes a +return value, so it needs to know nothing about deployment and stays in core.) + +Three practical notes: + +- **You choose the port.** The service listens on it and never reports an + OS-assigned one back, so pass a concrete number. +- **There is no `close()`.** Run each integration-test file in its own process + (bun test does), and the server it started is cleaned up when the file ends. +- **A Next.js service needs one extra argument.** `bootstrapService` finds most + services' entry points automatically, but a Next.js app's built entry lives + inside Next's standalone output directory, so you pass a small function that + imports it: + + ```ts + import { standaloneEntryPath } from '@prisma/app-nextjs/control'; + + await bootstrapService(storefront, config, async () => { + await import(standaloneEntryPath(storefront.build)); + }); + ``` + +## The stand-in: same contract, checked by the compiler + +What do you pass as the fake? A dependency's type *is* its contract. An RPC +dependency on `authContract` becomes a client with a `verify(input) => +Promise` method, so any value of that shape is a valid double and the +compiler rejects one that isn't. You choose how realistic to make it: + +- **A bare object** — `{ verify: async () => ({ ok: true }) }`. Fastest; no + network, no serialization. Right when only the return value matters. +- **The real client over an in-memory handler** — the framework's own client + talking to your fake through an in-process function instead of the network. + JSON encoding and both schema validations still run; there is just no socket. +- **A real local server** — the fake served on a loopback port and reached over + real HTTP. This is what `bootstrapService` drives. + +A dependency's package can ship its own fake as a separate entry point, kept out +of the deployed code. Because that fake is written against the same contract the +real service exposes, the two cannot drift apart. + +## Alternatives considered + +- **Add injection points to the code under test** (constructor or parameter + injection, so a test passes fakes in directly). Rejected: application code + would carry test-only seams, and there is nothing to add — `load()` already + *is* the single point every dependency flows through. +- **Boot the whole composed app locally**, several services wired together in + one process. Rejected as a *different* capability: `bootstrapService` boots + one service. Running a full graph locally (a local `dev`) is worth building on + its own terms, but it is not this seam. +- **A runner-agnostic mock wrapper** hiding `vi.mock`/`mock.module` behind one + API. Rejected: runners differ in module-mock mechanics (hoisting, ESM + handling) in ways not worth papering over. The framework supplies the typed + value and documents the per-runner pattern instead. ## Related - [`core-model.md`](core-model.md) — the `run`/`load` split these tools drive. -- [`deploy-cli.md`](deploy-cli.md) — the deploy bootstrap `bootstrapService` mirrors. +- [`deploy-cli.md`](deploy-cli.md) — the deployment boot that `bootstrapService` + mirrors. - [`../90-decisions/ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md`](../90-decisions/ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md) - — why the hydrated dependency is a client the seam can double. + — why a hydrated dependency is a client a test can stand in for. diff --git a/skills/testing-prisma-apps/SKILL.md b/skills/testing-prisma-apps/SKILL.md new file mode 100644 index 000000000..f9fe7bd6c --- /dev/null +++ b/skills/testing-prisma-apps/SKILL.md @@ -0,0 +1,110 @@ +--- +name: testing-prisma-apps +description: >- + How to test an app built on the Prisma App Framework. Every dependency an app + uses arrives through one call, `service.load()`; you test by controlling what + it returns or reads, never by editing the code under test. Two tools: + `mockService` (from `@prisma/app/testing`) for unit tests, and + `bootstrapService` (from `@prisma/app-cloud/testing`) for integration tests. + Use when writing tests for a Prisma App, faking a service dependency, + unit-testing a page / server action / RPC handler, or integration-testing the + real request path without a deployment. Triggers on "test a prisma app", + "mockService", "bootstrapService", "fake a service dependency", + "test a page/action without deploying", "@prisma/app/testing". +--- + +# Testing Prisma Apps + +An app's code gets its dependencies from exactly one place — `service.load()` — +and never as arguments or globals. So you test by deciding what `load()` gives +the code, and you leave the code under test unchanged. Pick the tool by how much +of the real path you want to run. + +| You want to… | Use | From | +| --- | --- | --- | +| Test a page / action / handler in isolation | `mockService` | `@prisma/app/testing` | +| Run the real boot + request path against a fake dependency | `bootstrapService` | `@prisma/app-cloud/testing` | + +The full model is in +[`docs/design/10-domains/testing.md`](../../docs/design/10-domains/testing.md). + +## Unit test — `mockService` + +For code you call directly. Mock the code's own service module so `load()` +returns doubles, then run the code with no server and no environment: + +```tsx +// page.test.tsx +import { mockService } from '@prisma/app/testing'; +import realService from '../src/service.ts'; + +vi.mock('../src/service.ts', () => ({ + default: mockService(realService, { + // typed against the dependency's contract — a wrong-shaped fake won't compile + auth: { verify: async () => ({ ok: true }) }, + }), +})); + +import Page from './page.tsx'; +expect(renderToString(await Page())).toContain('Signed in: true'); +``` + +- `mockService(service, doubles)` returns a copy of the service whose `load()` + yields your `doubles` merged with the service's parameter defaults. +- The doubles are type-checked against the service's declared dependencies. +- Wiring the module substitution is your runner's job: `vi.mock` (Vitest), + `mock.module` (bun test). `mockService` only supplies the typed value. + +## Integration test — `bootstrapService` + +For the real request path: the service actually boots and serves, talking to a +stand-in you run. Point a dependency at the stand-in via the config, then drive +HTTP requests: + +```ts +// service.integration.test.ts (run under `bun test`) +import { bootstrapService } from '@prisma/app-cloud/testing'; +import fakeAuth from '@storefront-auth/auth/fake'; // an in-memory handler, no db +import storefront from '../src/service.ts'; + +const fake = Bun.serve({ port: 0, fetch: fakeAuth }); + +const app = await bootstrapService(storefront, { + service: { port: 4310 }, + inputs: { auth: { url: fake.url.href } }, +}); + +const res = await app.fetch(new Request(app.url)); +expect(await res.text()).toContain('Signed in: true'); +``` + +- The service's own code (`server.ts`) is not modified — it boots exactly as in + production; you only choose its configuration. +- **Pass a concrete `service.port`** — the service listens on it; there's no + OS-assigned port reported back. +- **No `close()`** — run each integration-test file in its own process (bun test + does), so the started server is cleaned up when the file ends. +- **Next.js services take a third argument** — a boot thunk, because the built + entry lives in Next's standalone output directory: + + ```ts + import { standaloneEntryPath } from '@prisma/app-nextjs/control'; + await bootstrapService(storefront, config, async () => { + await import(standaloneEntryPath(storefront.build)); + }); + ``` + +## The fake you pass + +A dependency's type *is* its contract, so any value of that shape is a valid +double, checked by the compiler. Three levels of realism: + +- **A bare object** — `{ verify: async () => ({ ok: true }) }`. Fastest. +- **The real client over an in-memory handler** — runs JSON encoding + schema + validation, no socket. +- **A real local server** — the fake served on a loopback port over real HTTP + (what `bootstrapService` drives). + +Ship a dependency's fake from its own package (a `/fake` entry point, outside +`src/` so it never reaches production) so the fake and the real service always +share one contract.