@geonosis/testbed
v3.8.1
Published
The fixtures law as code, one adapter contract for an integration harness, and the conformance suite an adapter has to pass. Extracted from a Medusa storefront's Medusa runner and a Workers + D1 app's workerd cell.
Maintainers
Readme
@geonosis/testbed
Through the front door: geonosis testbed — the metapackage pins this and every other kit tool at
ONE version, and passes the exit code through unchanged.
An integration harness is not its engine. It is the arrangement around one engine call: where
fixtures live and that nothing outside them is readable, what a database is called and how long it
lives, what the network may reach, and who boots it. Two shipping repos wrote that arrangement
separately — a Medusa storefront around medusaIntegrationTestRunner, a Workers + D1 app around workerd and a Postgres
cell — and neither one's arrangement needs its engine in order to be expressed. This package is the
arrangement; the engines stay peer dependencies of the repo that runs them.
What the two harnesses really do, measured file by file, is in
docs/testbed-inventory-2026-08-30.md.
pnpm add -D @geonosis/testbedThe fixtures law
import { fixturesOnly } from '@geonosis/testbed'
const fixture = fixturesOnly(new URL('./__fixtures__', import.meta.url).pathname)
fixture('invoices', 'march.yaml') // -> /…/__fixtures__/invoices/march.yaml
fixture('..', 'Purchase Orders & Specs') // throwsA Workers + D1 app's §4.3 — "a test reads and writes ONLY apps/web/__tests__/__fixtures__/" — exists
because the directory next door in that repo holds real customers' documents. Its own guard compares
strings, which three shapes get past, and all three are refused here:
| shape | what a string-prefix guard does |
|---|---|
| fixture('/etc/passwd') | resolve reads /etc/passwd; join quietly reads <root>/etc/passwd |
| a sibling named <root>-production | resolved.startsWith(root) is true, and it is a different directory |
| a symlink inside the root pointing out | every character of the path is inside the root |
The first is refused for naming an absolute path rather than reinterpreted; the second by comparing
against root + path.sep; the third by resolving through realpath. A path that does not exist yet
— a fixture about to be written — is judged as if it had been.
Defining a testbed
import { createHash } from 'node:crypto'
import { defineTestbed } from '@geonosis/testbed'
export const testbed = defineTestbed({
adapter: myAdapter,
database: {
name: (suite) => createHash('sha1').update(suite).digest('hex').slice(0, 12),
per: 'suite',
},
fixtures: new URL('./__fixtures__', import.meta.url).pathname,
network: 'none',
})per is 'suite' (one database per describe), 'file', or 'run' (one cell for the whole run,
emptied per test). Both are idioms a consumer already runs.
Everything it can refuse, it refuses when it is defined, not at the first boot:
- an adapter with no
id— every diagnostic names it - an adapter declaring
snapshotwithoutrestore, or the reverse: half of per-test isolation is not isolation - a
perit does not know — a config read from JSON has whatever spelling someone typed network: { allow: [] }— an allow list of nothing is'none'spelt so it reads as permission- no fixtures directory
And when a database name is derived:
- an empty name
- a name over 63 bytes — Postgres truncates there, and the truncation is a NOTICE nobody reads
- a name a different key already took.
sha1(description).slice(0, 12)is 48 bits; two suites that collide share a database, and the second one's reset empties the first one's rows with both green. Neither source repo notices this; this refuses it.
The adapter contract
type TestbedAdapter<Db> = {
id: string
boot: (ctx: BootContext) => Promise<{ db: Db; teardown: () => Promise<void> }>
snapshot?: () => Promise<void>
restore?: () => Promise<void>
waitFor?: (kind: string, predicate: () => boolean | Promise<boolean>) => Promise<void>
}boot is all that is required. The rest is what a particular engine can do: Medusa snapshots to a
Postgres TEMPLATE database and restores from it before every test; a Workers + D1 app's cell cannot, and
truncates instead. The BootContext is frozen, including its allow-list array — an adapter that
wrote its own state onto it would carry it into the next boot.
The conformance suite
import { runAdapterConformance } from '@geonosis/testbed'
it('conforms', async () => {
const report = await runAdapterConformance(myAdapter, {
probe: { read: (db) => db.rows(), write: (db) => db.insert(…) },
})
expect(report.passed, JSON.stringify(report.checks, null, 2)).toBe(true)
})Exported because consumers write adapters this repo never sees, and an adapter that has not been through this is a claim. Each check is a property one of the two harnesses really depends on:
| check | why |
|---|---|
| boot returns a handle | a boot that resolves with db: undefined fails later, somewhere else |
| restore before a snapshot is refused | Medusa spends a databaseTemplateReady flag on exactly this; restoring a template nobody took returns whatever the database happened to hold |
| restore returns the snapshot | a no-op restore is invisible without writing a row — see the probe below |
| waitFor rejects when it never comes true | a waitFor that resolves on timeout is worse than none |
| waitFor resolves when it comes true | and one that never resolves is a hung suite |
| teardown is idempotent | a Medusa storefront wires cleanup to afterAll and to SIGTERM/SIGINT; on a cancelled run both fire |
| boots again after teardown | a run-lifetime adapter has to survive re-entry |
It never throws for the adapter's sake — a broken adapter is a failed check carrying its own message
in detail, because a conformance run that dies tells the reader less than one that reports.
The probe. This package cannot write a row through someone else's handle, and without writing
one a no-op restore() is indistinguishable from a real one. So restore returns the snapshot is
skipped by name when no probe is given — report.skipped is a separate list from
report.checks, because a check that did not run is not a check that passed. Adapters with no
snapshot/restore and no waitFor skip those checks and still pass; they are optional, not
absent.
@geonosis/testbed/medusa-built
A Medusa storefront's runIntegrationTest — 26 lines that 106 integration suites go through — generalised, and
carrying the refusals its own comments describe but its code does not make.
import { medusaIntegrationTestRunner } from '@medusajs/test-utils'
import { createMedusaIntegrationTest } from '@geonosis/testbed/medusa-built'
import { describe } from 'vitest'
export const { adapterFor, runIntegrationTest } = createMedusaIntegrationTest({
describe,
root: resolve(import.meta.dirname, '../..'),
runner: medusaIntegrationTestRunner,
})The engine is injected, never imported. @medusajs/test-utils is a peer of the repo that runs
it — a package that reached for it could not be installed by a repo on another runtime, and could
not be tested without a database. MedusaSuiteLike is a structural type over what the runner really
hands a suite.
| option | default | why |
|---|---|---|
| root | required | the app directory |
| builtDir | .medusa/server | Medusa's own output; the loaders and migrations must run as in production |
| databaseName | sha1(description).slice(0, 12) | a Medusa storefront's derivation, kept exactly — the engine appends -integration-<worker> and lowercases |
| connectionEnv | ['DB_HOST', 'DB_PORT', 'DB_USERNAME'] | Medusa's own getDatabaseURL keys; [] to take its defaults deliberately |
| nodeEnv | 'test' | false when the repo checks it itself |
| requireBuilt | true | |
| env, fixtures, waitForMs | — | fixtures is optional: a Medusa storefront's suites build their world through the booted app |
What it refuses, and why each one is worth a refusal
- an unbuilt server. "
.medusa/servermust be rebuilt before integration tests" is a law in a Medusa storefront's docs enforced by nothing. Unbuilt, the run dies inside a Medusa loader after the database has been created. NODE_ENVthat is nottest. It selects the in-memory cache, event bus and workflow engine, and it is read while the app loads — setting it afterwards is too late.- a connection variable that is unset. This is the sharp one. Medusa defaults every one of
DB_HOST/DB_PORT/DB_USERNAME, so an unset variable is not an error: the suite quietly connects to whatever is on the default host and port. A Medusa storefront's Postgres moved from 5433 to 5434 because another project's embedded cell took 5433; with the defaults, a stale environment reaches the other project's database and the suite goes green against it. - two suites that derive one database name. 48 bits of sha1. They share a database, and each one's reset empties the other's rows, with both green.
The adapter
adapterFor(suiteOptions) wraps what the runner hands a suite. It attaches rather than boots —
the runner owns the application's lifecycle, creating the database, migrating, snapshotting to a
Postgres TEMPLATE and restoring from it before every test. Two things it adds:
restore()refuses when this adapter took nosnapshot(). The runner keeps adatabaseTemplateReadyflag for its ownbeforeEach; thedbUtilsobject it hands the suite carries no such guard, so a directrestore()returns whichever template was last written.waitFor('workflows', predicate)drainsutils.waitWorkflowExecutions()and then reads the predicate, on a deadline, rejecting when it never comes true. The engine callswaitWorkflowExecutionsin everyafterEach; a test that asserts on a workflow's side effect needs it before the assertion. A Medusa storefront never names it —grepover its tree returns nothing.
The real-engine smoke is the consumer's step
This package is proven against a stub of medusaIntegrationTestRunner, because the contract is
what it owns. Booting a Medusa app needs a Postgres, a built server and about two minutes, and none
of those belong in a kit's suite. The consumer's adoption step is: point one existing integration
suite at createMedusaIntegrationTest, run pnpm test:integration, and confirm the same pass line
and the same database name as before.
@geonosis/testbed/workerd
A Workers + D1 app's harness, in three pieces. Each one is a mechanism its repo built after a specific failure, and each is proven here against stubs of the engine — the real workerd pool and a real Postgres are the consumer's.
1. The runner whose exit code lies
import { runStrict, verdictOf } from '@geonosis/testbed/workerd'
const { code, verdict } = await runStrict({ args: ['vitest', 'run', ...process.argv.slice(2)] })
if (!verdict.ok) {
process.stderr.write(`\n${verdict.why}\n`)
process.exit(1)
}
process.exit(code)@cloudflare/vitest-pool-workers exits 0 with failing tests. Every workerd green in the repo
this comes from was hollow until something read the report instead of the exit code. verdictOf
refuses three things:
| report | verdict |
|---|---|
| absent | a crash before the reporter is not a pass |
| { success: false, numFailedTests: 0 } | the run did not finish; checked first, because anything counting failures reads this as a pass |
| { success: true, numFailedTests: n > 0 } | n test(s) failed |
When the report names no failing test, or none exists, the refusal quotes the last lines the runner wrote to stderr: a runner that died in a native load says so there and nowhere else (#333).
A clean report does not launder a non-zero exit: code is returned unchanged and the caller
exits with it.
This judgement is deliberately not shared with @geonosis/ratchet's testFailures counter,
which reads the same field. They answer different questions — "should this process fail" versus "how
much debt is there" — and they are installed for different reasons; coupling them would put a
ratchet in every repo that only wanted a harness. The contract is written down here, and each
package holds it independently. The proof is a real spawned process that lies in each of the three
ways above, not a mocked one.
2. The config that has no remote binding to call
import { deriveLocalOnlyConfig } from '@geonosis/testbed/workerd'
const configPath = deriveLocalOnlyConfig({
dropVars: ['LOCAL_AI_URL'],
source: new URL('./wrangler.jsonc', import.meta.url).pathname,
})An ai binding is always remote: the pool resolves the account against api.cloudflare.com the
moment it sees one, before a single test runs — a network call, a credential and a bill in the
middle of the suite. So the test worker gets the real config with those bindings removed.
- Derived, not duplicated. Read from the real
wrangler.jsoncon every start, so a binding added there is in the suites the same day. This is what makes "tests never call a model" a property of the runtime rather than a promise about the code: in the test worker there is noenv.AIto call. - Every named environment too. They inherit nothing per binding, so each repeats it, and a block missed is a network call nobody expected.
- Written BESIDE the source, and a
targetcontaining a path separator is refused: every relative path in a wrangler config (main,migrations_dir) resolves against the directory the config sits in, so a copy one level deeper resolvessrc/index.tsto a file that is not there. - The source is read by
jsonc-parser: line and block comments and trailing commas are read, and a comment between two tokens (1/* why */0) is refused naming its line, as wrangler refuses it. The copy is written as plain JSON.
remoteOnly defaults to ['ai'] — a platform fact, not a repo's — and is overridable.
3. One cell for the run
import EmbeddedPostgres from 'embedded-postgres'
import { createCellServer, workerdAdapter } from '@geonosis/testbed/workerd'
const cell = await createCellServer({
cluster: ({ databaseDir, port }) =>
new EmbeddedPostgres({ databaseDir, password: 'postgres', persistent: false, port, user: 'postgres' }),
prepare: async (connectionString) => {
await migrateCell(connectionString)
return appConnectionStringOf(connectionString, 'app-password')
},
})embedded-postgres is a peer, injected as a factory. CELL_CONNECTION_STRING (rename it with
connectionStringEnv) takes an externally-owned cell instead — which is how the same suite runs
against every Postgres major a deployment might use — and that one is prepared and never
stopped: in CI the container is thrown away, and a managed instance is not the suite's to stop.
Otherwise a throwaway cluster starts on a port found by binding :0, in a fresh temp directory.
Two things this adds to the shape it came from: stop() is idempotent, and a cluster whose
prepare throws is stopped rather than leaked — a failed migration in that repo leaves a Postgres
and its data directory up for the rest of the session.
workerdAdapter({ cell, execute, tables, before }) is the adapter. There is no template database,
so restore() is a TRUNCATE, and empty is the only state a truncate can return to:
snapshot() therefore does not record the current rows, it empties the tables and marks that
point. That is exactly the split the source repo already has between its two setup files — the
truncate-per-test suites are the unseeded ones. A suite that seeds re-seeds after each restore.
truncateAllStatement(tables) is one statement for any number of tables, because a DELETE per
table inside a transaction cost twenty-eight round trips before every test. It refuses an empty
table list (a reset that resets nothing reads as isolation) and any name that is not a plain
identifier, since the list is usually derived from a schema object.
No waitFor. The source repo has none — its raised timeouts carry the waiting — so this adapter
declares none, and the conformance suite reports those checks as skipped rather than passed.
postgresForSuite is those three pieces as one call — boot the cell, migrate it from zero as its
owner, empty the tenant tables between files:
import EmbeddedPostgres from 'embedded-postgres'
import { postgresForSuite } from '@geonosis/testbed/workerd'
const suite = await postgresForSuite({
cluster: ({ databaseDir, port }) => new EmbeddedPostgres({ databaseDir, password: 'postgres', persistent: false, port, user: 'postgres' }),
connect: async (connectionString) => openOwnerConnection(connectionString),
migrate: async (connectionString) => {
await migrateCell(connectionString)
return appConnectionStringOf(connectionString, 'app-password')
},
tables: ['documents', 'document_revisions'],
})
beforeEach(() => suite.reset())
afterAll(() => suite.stop())connect is the owner's connection, handed in because this package imports no driver; reset() is one
truncate … restart identity cascade over tables through it. The table list is judged before a cluster
boots, a failed migrate or connect stops the cluster it started, and stop() closes the connection
and then the cluster, once. It runs against a real embedded Postgres in src/workerd/postgres-suite.exam.test.ts.
What a workerd suite costs
The floor is per spec FILE, and it is workerd instantiating the worker under test — not your setup, not your fixtures.
| measured | figure |
|---|---|
| a worker-under-test spec file, @cloudflare/vitest-pool-workers on a wrangler main worker (2026-09-02, a consumer's Workers repo) | ~4.5 s per file; setup hooks 0.2 s of it |
| this repo's own single workerd spec file (2026-09-03, macOS, node 24.16) | 4.61 s wall, of which 1.09 s is the tests and 0.19 s collection — the remaining ~3.3 s is the pool |
So a suite of twelve spec files costs a minute before a single assertion runs, and splitting a file in two costs another 4.5 s. Fewer spec files, or a targeted run, is the only lever that moves that number. Cutting setup work moves 0.2 s of it.
singleWorker is a @cloudflare/vitest-pool-workers option that @cloudflare/vitest-plugin 1.3.6 does
not have: on vitest 4 the same thing is spelled --no-isolate --max-workers=1. Measured on this
repo's own workerd suite (foundations/workflows: three spec files, 60 tests, macOS, vitest 4.1.11,
2026-10-08), three runs each:
| spelling | wall | what vitest reports |
|---|---|---|
| default — files in parallel, each isolated | 2.7–2.8 s | 2.2–2.3 s |
| --no-isolate --max-workers=1 | 3.8–3.9 s | 3.3–3.4 s |
| --max-workers=1 (serial, isolated) | 4.7–4.9 s | 4.1–4.3 s |
One shared worker is slower than the default here, because the default runs the three files at once and the tests inside them cost 2.4 s of the total either way. It saves nothing a suite of a few files can spend; the 53-file suite below is the case it was asked about, and it was not run from here (that repo is read-only to this one).
isolate: false and singleWorker share one module graph across files. Measured on the same day:
they changed the worker-under-test suite's time by nothing at all and hung it once, while
cutting a plain database package from 77 s to 12.5 s. The rule that follows:
- a library suite — no worker under test, no bindings, code that would run anywhere — may share a module graph, and that is where the 77 s → 12.5 s came from;
- never for a worker-under-test suite: the cost there is instantiating the worker, which is the thing the isolation was paying for, and sharing it buys nothing and can hang the run.
@geonosis/testbed/e2e
A Playwright suite that can reach only the stack it was pointed at, and can mail nobody. It exists because three failures read as a pass: a URL with a fallback tests whichever stack the fallback names, a local app driven against a remote API acts on that API's data, and a test that signs up a real address sends real mail.
// e2e/test.ts, the one file that imports Playwright
import { test as base } from '@playwright/test'
import { hermeticTest, readStack, requireEnv } from '@geonosis/testbed/e2e'
export const stack = readStack({ api: 'E2E_API_URL', app: 'E2E_APP_URL' })
export const password = requireEnv('E2E_PASSWORD')
export const test = hermeticTest(base, {
allow: [requireEnv('E2E_LOGIN')],
apiUrl: stack.apiUrl,
domain: 'example.test'
})
export { expect } from '@playwright/test'The engine is injected, never imported, as in medusa-built: playwright is an optional peer,
and hermeticTest calls extend on the test it is given, so the suite never carries a second
copy of Playwright. Any test works, including one the repo has already extended.
| export | what it does |
|---|---|
| requireEnv(name, env?) | The value. Unset, empty or blank throws naming the variable. |
| requireUrl(name, env?) | An http or https URL with no trailing slash. A value that is not one throws naming the variable, and never prints the value. |
| readStack({ api, app, env?, stackOf? }) | { apiUrl, appUrl, urlOf }. Throws when app is on another stack than api. urlOf(name) reads a third URL when a test asks for it, held to the same stack, so a run that never opens it does not need it set. |
| stackOfUrl(url) | The default reading: local for a loopback address, the address itself for any other IP, and otherwise the hostname less its first label, so app.staging.example.com and api.staging.example.com are one stack and api.example.com is another. |
| hermeticTest(base, options) | base with a context fixture that intercepts before a test can use it, so a page the test opens itself is covered too. |
| interceptEmails(context, options) | The same on a context you made yourself. |
| rewriteEmails(body, { allow?, domain? }) | Every address in a body moved to domain, in JSON, form-encoded and multipart text alike. |
options are apiUrl, domain and allow. apiUrl limits interception to that host and, when
it has one, that path: routing every request costs a round trip per asset, so an app's own traffic
is left alone. An API on the app's host therefore takes its path, https://example.test/api.
domain defaults to example.test, and a domain that is not reserved is refused when the wrapper
is defined, since a real inbox could answer it. An address already on a reserved domain is left
alone. allow lists the login identities that must reach the API as written; a rewritten login
fails as a 401.
stackOfUrl cannot know a public suffix, so example.co.uk and api.example.co.uk read as two
stacks, and two hosts on one domain that differ by a word (app-staging, api) read as one. Pass
stackOf for either: it takes a URL and answers a name.
Interception sees what the browser sends. A test's own request.post and page.request go past it,
so a call that can send mail goes through the page, or through rewriteEmails by hand.
Ban the import that walks around it. @playwright/test imported anywhere else is a test with
no interception, and it looks the same as one that has it:
// .oxlintrc.json
{
"rules": {
"no-restricted-imports": ["error", {
"paths": [{
"name": "@playwright/test",
"message": "Import test and expect from e2e/test.ts, which intercepts outgoing email."
}]
}]
},
"overrides": [{
"files": ["e2e/test.ts", "playwright.config.ts"],
"rules": { "no-restricted-imports": "off" }
}]
}The database matrix, and geonosis-testbed
// geonosis.json
{
"testbed": {
"matrix": {
"command": "bun run --cwd packages/db test",
"connectionEnv": "CELL_CONNECTION_STRING",
"connectionString": "postgres://postgres:[email protected]:5432/postgres",
"majors": ["17", "18"],
"name": "cells",
"setup": ["bun install --frozen-lockfile"]
}
}
}geonosis-testbed matrix --out .github/workflows/cells.yml
geonosis-testbed strict -- packages/db # judge the report, not the exit codeThe rendered workflow is templates/ci/db-matrix.yml, which is checked in as the renderer's own
output for the documented example and tested to still equal it — a template committed beside a
generator drifts from it the first time either is touched, and then the thing people copy is not the
thing the code produces.
Two jobs, not one, and that is the point. The source repo runs its data layer against Postgres
17 and 18 in a matrix, and against an embedded cluster in every other job — because the connection
variable is unset there. The embedded leg is real, it is tested, and it is invisible: nothing in
that workflow says the suite has a second backend. Here it is <name>-embedded, with a name.
What the renderer refuses: a matrix with no legs (embedded: false and no majors), no command, a
job name that is not a name, and — the one that matters — majors with an empty connection string,
which would run the embedded cell under a container's name and go green twice against the same
database while claiming to have tested two.
fail-fast: false and the pg_isready health check are not decoration: one major failing must not
hide whether the other passes, and without the health check the first connection races the container
and the suite fails for a reason that is not in the code.
The rule: no-mock-db-in-integration
Preset testbed, in @geonosis/oxlint-plugin-biological-architecture.
"biological-architecture/no-mock-db-in-integration": ["error", {
"dbModules": ["@repo/db"],
"integrationDirs": ["__tests__/integration"],
"mockNames": []
}]An integration suite that mocks its database proves that a procedure calls what it says it calls, and nothing about what comes back — which is what an integration test exists to check.
Both consumers of this kit pass it today, measured: a Medusa storefront's 106 integration files contain zero
vi.mock calls, and a Workers + D1 app's whole tree contains zero. The landmine is a third repo's, where 35
of 43 test files share one context helper carrying db: mockDb, and they are the gate. That is why
integrationDirs and dbModules are required with no defaults — a default drawn from two repos
that do not have the pattern would match nothing and report a clean tree it never looked at.
mockNames is for the shape no mock call can be seen in: a handle built in a setup file and threaded
through a helper. It is empty by default; a repo that has one names it.
Adoption status (D-027)
Read on 2026-10-17, both consumers depend on this package. A Medusa storefront's integration runner
is createMedusaIntegrationTest, and its adapter runs runAdapterConformance. A Workers + D1 app
runs three packages' tests through geonosis-testbed strict and boots its cell with
createCellServer; its local-only config and its reset are still its own. What follows is each
migration in full, written from what each repo had when it was measured — see
docs/testbed-inventory-2026-08-30.md for the
numbers.
A Medusa backend
Replace the one integration runner every suite imports with:
import { medusaIntegrationTestRunner } from '@medusajs/test-utils'
import { createMedusaIntegrationTest } from '@geonosis/testbed/medusa-built'
import { describe } from 'vitest'
export const { adapterFor, runIntegrationTest } = createMedusaIntegrationTest({
describe,
root: resolve(import.meta.dirname, '../..'),
runner: medusaIntegrationTestRunner,
})Nothing else changes: the default builtDir is .medusa/server, the default derivation is the
sha1(description).slice(0, 12) it already uses, and the default connectionEnv is the DB_* set
its setup.ts already fills in. The same 106 suites must produce the same database names, which
is the equivalence check — moduleName is printed by the engine's own Creating database … line.
What it gains on the day it lands: a refusal instead of a loader crash when .medusa/server is
stale, a refusal instead of a silent connection to port 5432 when the DB_* environment is not
set — the failure that moving Postgres from 5433 to 5434 made possible, and which lands on another
project's database — and a refusal instead of a shared database when two suite descriptions collide
in 48 bits of sha1.
Then .github/workflows/: a Medusa storefront's CI is 26 lines and has no integration job at all. 106
suites exist, none runs on a push. geonosis-testbed matrix renders one; majors for a Medusa
backend is the Postgres versions it supports, and the embedded leg does not apply, so
embedded: false.
no-mock-db-in-integration costs nothing to enable — 0 findings today, proven not silent in
proofs/022-W9-consumers/.
a Workers + D1 app
Four replacements, none of which changes what runs:
| today | becomes |
|---|---|
| scripts/vitest-strict.ts (57 lines, the test script of 3 packages) | geonosis-testbed strict, or runStrict/verdictOf |
| apps/api/testing/local-only-config.ts (98 lines) | deriveLocalOnlyConfig({ dropVars: ['LOCAL_AI_URL'], source }) |
| packages/db/testing/cell-server.ts (72 lines) | createCellServer({ cluster, prepare }) with EmbeddedPostgres injected |
| packages/db/testing/reset.ts | workerdAdapter({ before: ['drop trigger …'], tables }), or truncateAllStatement |
Its .github/workflows/ci.yml cells job becomes geonosis-testbed matrix output, with one
difference that is the reason to do it: the embedded leg becomes a named job. Today it is real
and tested and invisible, running in every job where CELL_CONNECTION_STRING happens to be unset.
Two behaviour changes it gets for free: stop() becomes idempotent, and a cluster whose prepare
throws is stopped instead of left running with its data directory for the rest of the session.
Nothing its CLAUDE.md names is proposed for deletion here, per the 2026-08-30 boundary: §4.3's
fixtures law stays its law — fixturesOnly is that law with three more refusals in it, not a
replacement for the sentence.
Consumable from CommonJS
A Medusa backend is module: Node16 CommonJS by upstream requirement, not by preference. This
package ships both builds and a require condition carrying its own .d.cts, so a static import
type-checks and require() works:
import { defineTestbed } from '@geonosis/testbed' // ESM, module: Node16 or ESNext
const { defineTestbed } = require('@geonosis/testbed') // CommonJSAn ESM-only package is a wall for such a backend, and no amount of parity gets over it: a Medusa storefront
measured @geonosis/testbed at 15/15 identical and still could not adopt it, because a static
import is TS1479 and await import() cannot serve the synchronous describe() registration its 107
suites are built on.
