@geonosis/testbed 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ import {
2
+ createDatabaseNamer,
3
+ fixturesOnly
4
+ } from "../chunk-UZPJWPL3.js";
5
+
6
+ // src/medusa-built/index.ts
7
+ import { createHash } from "crypto";
8
+ import { existsSync } from "fs";
9
+ import { resolve } from "path";
10
+ var DEFAULT_BUILT_DIR = ".medusa/server";
11
+ var DEFAULT_CONNECTION_ENV = ["DB_HOST", "DB_PORT", "DB_USERNAME"];
12
+ var sha1Twelve = (description) => createHash("sha1").update(description).digest("hex").slice(0, 12);
13
+ var createMedusaIntegrationTest = (options) => {
14
+ const builtDir = options.builtDir ?? DEFAULT_BUILT_DIR;
15
+ const connectionEnv = options.connectionEnv ?? DEFAULT_CONNECTION_ENV;
16
+ const nodeEnv = options.nodeEnv ?? "test";
17
+ const requireBuilt = options.requireBuilt ?? true;
18
+ const waitForMs = options.waitForMs ?? 3e4;
19
+ const cwd = resolve(options.root, builtDir);
20
+ const databaseNameFor = createDatabaseNamer(options.databaseName ?? sha1Twelve);
21
+ const refuseIfNotReady = () => {
22
+ if (nodeEnv !== false && process.env.NODE_ENV !== nodeEnv) {
23
+ throw new Error(
24
+ `NODE_ENV must be "${nodeEnv}" before Medusa loads; it is "${process.env.NODE_ENV ?? "(unset)"}". It selects the in-memory cache, event bus and workflow engine, and it is read while the app loads, so setting it afterwards is too late.`
25
+ );
26
+ }
27
+ if (requireBuilt && !existsSync(cwd)) {
28
+ throw new Error(
29
+ `the built server is not at ${cwd} \u2014 build the app before the integration suite. Unbuilt, the run dies inside a Medusa loader after the database has already been created.`
30
+ );
31
+ }
32
+ const unset = connectionEnv.filter((name) => (process.env[name] ?? "") === "");
33
+ if (unset.length > 0) {
34
+ throw new Error(
35
+ `${unset.join(", ")} ${unset.length === 1 ? "is" : "are"} unset, and Medusa has a default for each \u2014 so the suite would not fail, it would quietly connect to whatever is on the default host and port, which on a shared machine is another project's database.`
36
+ );
37
+ }
38
+ };
39
+ return {
40
+ adapterFor: (suite) => medusaBuiltAdapter(suite, waitForMs),
41
+ ...options.fixtures === void 0 ? {} : { fixture: fixturesOnly(options.fixtures) },
42
+ runIntegrationTest: (description, testSuite) => {
43
+ refuseIfNotReady();
44
+ const moduleName = databaseNameFor(description);
45
+ options.describe(description, () => {
46
+ options.runner({ cwd, env: options.env, moduleName, testSuite });
47
+ });
48
+ }
49
+ };
50
+ };
51
+ var medusaBuiltAdapter = (suite, waitForMs = 3e4) => {
52
+ let snapshotted = false;
53
+ return {
54
+ boot: async () => ({ db: suite, teardown: async () => {
55
+ } }),
56
+ id: "medusa-built",
57
+ restore: async () => {
58
+ if (!snapshotted) {
59
+ throw new Error(
60
+ "restore() before any snapshot() would return whichever template the runner last wrote, which on a reused database name is the previous suite. Take a snapshot first."
61
+ );
62
+ }
63
+ await suite.dbUtils.restore();
64
+ },
65
+ snapshot: async () => {
66
+ await suite.dbUtils.snapshot();
67
+ snapshotted = true;
68
+ },
69
+ waitKinds: ["workflows"],
70
+ waitFor: async (kind, predicate) => {
71
+ if (kind !== "workflows") {
72
+ throw new Error(
73
+ `the medusa-built adapter waits for "workflows"; it was asked for "${kind}"`
74
+ );
75
+ }
76
+ const deadline = Date.now() + waitForMs;
77
+ for (; ; ) {
78
+ await suite.utils.waitWorkflowExecutions();
79
+ if (await predicate()) return;
80
+ if (Date.now() >= deadline) {
81
+ throw new Error(`workflows did not settle the predicate within ${waitForMs}ms`);
82
+ }
83
+ await new Promise((done) => setTimeout(done, 25));
84
+ }
85
+ }
86
+ };
87
+ };
88
+ export {
89
+ createMedusaIntegrationTest,
90
+ medusaBuiltAdapter
91
+ };
@@ -0,0 +1,68 @@
1
+ /** Where a test may read and write, and nowhere else. during.day's §4.3, as a value. */
2
+ type NetworkPolicy = 'none' | {
3
+ allow: readonly string[];
4
+ };
5
+ /** How long one database lives. dielime is `suite`; during.day is `run`. */
6
+ type DatabaseLifetime = 'file' | 'run' | 'suite';
7
+ type DatabaseSpec = {
8
+ /** The consumer's derivation. dielime's is `sha1(description).slice(0, 12)`. */
9
+ name: (key: string) => string;
10
+ per: DatabaseLifetime;
11
+ };
12
+ /** Everything an adapter is told, and the only thing it is told. Frozen before it is handed over. */
13
+ type BootContext = {
14
+ readonly databaseName: string;
15
+ readonly fixtures: string;
16
+ readonly network: NetworkPolicy;
17
+ };
18
+ type BootResult<Db> = {
19
+ db: Db;
20
+ /** Called on the way out AND on a signal, so it has to be idempotent. */
21
+ teardown: () => Promise<void>;
22
+ };
23
+ /**
24
+ * The seam a testbed sits behind. `boot` is required; the rest are what a particular engine can
25
+ * do — Medusa can snapshot to a template database, workerd's cell cannot and truncates instead.
26
+ * Declaring one of `snapshot`/`restore` without the other is refused at define time.
27
+ */
28
+ type TestbedAdapter<Db> = {
29
+ boot: (ctx: BootContext) => Promise<BootResult<Db>>;
30
+ id: string;
31
+ restore?: () => Promise<void>;
32
+ snapshot?: () => Promise<void>;
33
+ waitFor?: (kind: string, predicate: () => boolean | Promise<boolean>) => Promise<void>;
34
+ /**
35
+ * What this adapter can wait for — `['workflows']` for Medusa. Required alongside `waitFor` and
36
+ * refused without it: a wait whose kinds nobody knows is a wait nobody can call, and the
37
+ * conformance suite inventing one of its own is how that went unnoticed.
38
+ */
39
+ waitKinds?: readonly string[];
40
+ };
41
+ type TestbedConfig<Db> = {
42
+ adapter: TestbedAdapter<Db>;
43
+ database: DatabaseSpec;
44
+ fixtures: string;
45
+ network: NetworkPolicy;
46
+ };
47
+ type Testbed<Db> = {
48
+ adapterId: string;
49
+ allowsHost: (host: string) => boolean;
50
+ boot: (key: string) => Promise<BootResult<Db>>;
51
+ databaseNameFor: (key: string) => string;
52
+ fixture: (...segments: string[]) => string;
53
+ per: DatabaseLifetime;
54
+ };
55
+ type ConformanceCheck = {
56
+ detail: string;
57
+ name: string;
58
+ ok: boolean;
59
+ };
60
+ type ConformanceReport = {
61
+ adapterId: string;
62
+ checks: ConformanceCheck[];
63
+ passed: boolean;
64
+ /** Named, because a check that did not run is not a check that passed. */
65
+ skipped: string[];
66
+ };
67
+
68
+ export type { BootContext as B, ConformanceReport as C, DatabaseLifetime as D, NetworkPolicy as N, TestbedAdapter as T, TestbedConfig as a, Testbed as b, BootResult as c, ConformanceCheck as d, DatabaseSpec as e };
@@ -0,0 +1,117 @@
1
+ import { T as TestbedAdapter } from '../types-D-uHas5h.js';
2
+
3
+ type ClusterLike = {
4
+ initialise: () => Promise<void>;
5
+ start: () => Promise<void>;
6
+ stop: () => Promise<void>;
7
+ };
8
+ type Cell = {
9
+ /** The role the code under test runs as — production's, not the migrator's. */
10
+ appConnectionString: string;
11
+ connectionString: string;
12
+ /** Owned clusters only: a database somebody else started is not this suite's to stop. */
13
+ stop: () => Promise<void>;
14
+ };
15
+ type CellServerOptions = {
16
+ /** `embedded-postgres` or another cluster, injected — never imported, so it stays a peer. */
17
+ cluster: (options: {
18
+ databaseDir: string;
19
+ port: number;
20
+ }) => ClusterLike;
21
+ connectionStringEnv?: string;
22
+ connectionStringOf?: (port: number) => string;
23
+ port?: number;
24
+ /** Migrate as the owner and grant the app role; returns the app's connection string. */
25
+ prepare: (connectionString: string) => Promise<string>;
26
+ };
27
+ /**
28
+ * One real Postgres for the whole run, migrated from zero.
29
+ *
30
+ * `CELL_CONNECTION_STRING` — or whatever a repo calls it — takes an externally-owned cell instead,
31
+ * which is how the same suite runs against the versions a deployment might actually use. That one
32
+ * is prepared and never stopped: in CI the container is thrown away; against a managed instance,
33
+ * stopping it is not the suite's to do.
34
+ */
35
+ declare const createCellServer: (options: CellServerOptions) => Promise<Cell>;
36
+ /**
37
+ * One statement, one round trip. A DELETE per table inside a transaction cost twenty-eight of them
38
+ * before every test in the repo this comes from.
39
+ */
40
+ declare const truncateAllStatement: (tables: readonly string[]) => string;
41
+ type WorkerdAdapterOptions = {
42
+ /** Statements to run before each reset — a trigger a test planted outlives the rows it refused. */
43
+ before?: readonly string[];
44
+ cell: Cell;
45
+ execute: (sql: string) => Promise<unknown>;
46
+ id?: string;
47
+ tables: readonly string[];
48
+ };
49
+ /**
50
+ * The cell as a testbed adapter.
51
+ *
52
+ * There is no template database here, so `restore()` is a TRUNCATE — and empty is the only state a
53
+ * truncate can return to. `snapshot()` therefore does not record the current rows: it EMPTIES the
54
+ * tables and marks that point, which is exactly the split the repo this comes from already has
55
+ * between its two setup files. A suite that seeds re-seeds after each restore.
56
+ *
57
+ * No `waitFor`: that repo has none, and its raised timeouts carry the waiting. Declaring a
58
+ * capability it does not have is the inert-capability failure this whole kit is written against.
59
+ */
60
+ declare const workerdAdapter: (options: WorkerdAdapterOptions) => TestbedAdapter<Cell>;
61
+
62
+ type LocalOnlyOptions = {
63
+ /** Vars naming something that lives outside the worker, so a suite's own seam is what is reached. */
64
+ dropVars?: readonly string[];
65
+ /** Bindings the platform cannot emulate locally. */
66
+ remoteOnly?: readonly string[];
67
+ source: string;
68
+ /** A file name — it must stay beside the source, and the refusal below says why. */
69
+ target?: string;
70
+ };
71
+ /**
72
+ * The real wrangler config, minus what cannot run locally — DERIVED on every start rather than kept
73
+ * as a second file, so a binding added to the real one is in the suites the same day and the one
74
+ * kind of binding that cannot be emulated is the one kind the suites never get.
75
+ *
76
+ * This is what makes "tests never call a model" a property of the runtime: in the test worker there
77
+ * is no `env.AI` to call.
78
+ */
79
+ declare const deriveLocalOnlyConfig: (options: LocalOnlyOptions) => string;
80
+
81
+ type VitestReport = {
82
+ numFailedTests?: number;
83
+ success?: boolean;
84
+ } | null;
85
+ type Verdict = {
86
+ ok: boolean;
87
+ why: string;
88
+ };
89
+ type StrictRun = {
90
+ /** The runner's own exit code, kept: a clean report does not make a non-zero exit fine. */
91
+ code: number;
92
+ reportPath: string;
93
+ verdict: Verdict;
94
+ };
95
+ type StrictOptions = {
96
+ args?: readonly string[];
97
+ command?: string;
98
+ /** How the runner is told where to write its report. */
99
+ reportArg?: (path: string) => string;
100
+ /** Where to keep it. Given one, the caller owns it; otherwise it is a temp file, removed. */
101
+ reportPath?: string;
102
+ stdio?: 'ignore' | 'inherit';
103
+ };
104
+ /**
105
+ * The runner's answer, from what it RECORDED rather than from how it exited.
106
+ *
107
+ * `@cloudflare/vitest-pool-workers` exits 0 with failing tests, so every workerd green in the repo
108
+ * this comes from was hollow until something read the report instead. The order of the two checks
109
+ * is load-bearing: `success !== true` is asked FIRST, because `{ success: false, numFailedTests: 0 }`
110
+ * — the run that died before it finished — reads as a pass to anything that counts failures.
111
+ */
112
+ declare const verdictOf: (report: VitestReport) => Verdict;
113
+ declare const readReport: (path: string) => VitestReport;
114
+ /** Run a test runner and judge its report. The exit code is reported, never believed. */
115
+ declare const runStrict: (options?: StrictOptions) => Promise<StrictRun>;
116
+
117
+ export { type Cell, type CellServerOptions, type ClusterLike, type LocalOnlyOptions, type StrictOptions, type StrictRun, type Verdict, type VitestReport, type WorkerdAdapterOptions, createCellServer, deriveLocalOnlyConfig, readReport, runStrict, truncateAllStatement, verdictOf, workerdAdapter };
@@ -0,0 +1,143 @@
1
+ import {
2
+ readReport,
3
+ runStrict,
4
+ verdictOf
5
+ } from "../chunk-ULJUJOSR.js";
6
+
7
+ // src/workerd/cell.ts
8
+ import { mkdtempSync } from "fs";
9
+ import { createServer } from "net";
10
+ import { tmpdir } from "os";
11
+ import { join } from "path";
12
+ var DEFAULT_CONNECTION_ENV = "CELL_CONNECTION_STRING";
13
+ var freePort = () => new Promise((done) => {
14
+ const probe = createServer();
15
+ probe.listen(0, "127.0.0.1", () => {
16
+ const address = probe.address();
17
+ probe.close(() => done(typeof address === "object" && address ? address.port : 0));
18
+ });
19
+ });
20
+ var createCellServer = async (options) => {
21
+ const external = process.env[options.connectionStringEnv ?? DEFAULT_CONNECTION_ENV];
22
+ if (external !== void 0 && external !== "") {
23
+ return {
24
+ appConnectionString: await options.prepare(external),
25
+ connectionString: external,
26
+ stop: async () => {
27
+ }
28
+ };
29
+ }
30
+ const port = options.port ?? await freePort();
31
+ const cluster = options.cluster({
32
+ databaseDir: mkdtempSync(join(tmpdir(), "geonosis-cell-")),
33
+ port
34
+ });
35
+ await cluster.initialise();
36
+ await cluster.start();
37
+ let stopped = false;
38
+ const stop = async () => {
39
+ if (stopped) return;
40
+ stopped = true;
41
+ await cluster.stop();
42
+ };
43
+ const connectionString = options.connectionStringOf?.(port) ?? `postgres://postgres:postgres@127.0.0.1:${port}/postgres`;
44
+ try {
45
+ return { appConnectionString: await options.prepare(connectionString), connectionString, stop };
46
+ } catch (error) {
47
+ await stop();
48
+ throw error;
49
+ }
50
+ };
51
+ var IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
52
+ var truncateAllStatement = (tables) => {
53
+ if (tables.length === 0) {
54
+ throw new Error(
55
+ "truncateAllStatement needs at least one table; a reset that resets nothing reads as isolation"
56
+ );
57
+ }
58
+ for (const table of tables) {
59
+ if (!IDENTIFIER.test(table)) {
60
+ throw new Error(
61
+ `"${table}" is not a plain identifier, and this list is usually derived from a schema object \u2014 a name that can close its own quoting can start a statement`
62
+ );
63
+ }
64
+ }
65
+ return `truncate table ${tables.map((table) => `"${table}"`).join(", ")} restart identity cascade`;
66
+ };
67
+ var workerdAdapter = (options) => {
68
+ const statement = truncateAllStatement(options.tables);
69
+ let marked = false;
70
+ const reset = async () => {
71
+ for (const before of options.before ?? []) await options.execute(before);
72
+ await options.execute(statement);
73
+ };
74
+ return {
75
+ boot: async () => ({ db: options.cell, teardown: options.cell.stop }),
76
+ id: options.id ?? "workerd",
77
+ restore: async () => {
78
+ if (!marked) {
79
+ throw new Error(
80
+ "restore() before any snapshot() would empty tables nobody asked to be emptied. Take a snapshot first \u2014 on a truncating cell it is the empty state."
81
+ );
82
+ }
83
+ await reset();
84
+ },
85
+ snapshot: async () => {
86
+ await reset();
87
+ marked = true;
88
+ }
89
+ };
90
+ };
91
+
92
+ // src/workerd/local-only-config.ts
93
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
94
+ import { dirname, join as join2, resolve } from "path";
95
+ var DEFAULT_REMOTE_ONLY = ["ai"];
96
+ var DEFAULT_TARGET = ".wrangler.local-only.json";
97
+ var stripJsonc = (source) => source.replaceAll(/^[ \t]*\/\/.*$/gm, "").replaceAll(/,(\s*[}\]])/g, "$1");
98
+ var withoutRemoteOnly = (block, remoteOnly, dropVars) => {
99
+ const cleaned = { ...block };
100
+ for (const binding of remoteOnly) delete cleaned[binding];
101
+ const vars = cleaned.vars;
102
+ if (vars) {
103
+ const kept = { ...vars };
104
+ for (const name of dropVars) delete kept[name];
105
+ cleaned.vars = kept;
106
+ }
107
+ return cleaned;
108
+ };
109
+ var deriveLocalOnlyConfig = (options) => {
110
+ const source = resolve(options.source);
111
+ if (!existsSync(source)) throw new Error(`no wrangler config at ${source}`);
112
+ const target = options.target ?? DEFAULT_TARGET;
113
+ if (target.includes("/") || target.includes("\\")) {
114
+ throw new Error(
115
+ `the derived config must sit in the same directory as ${source}: a wrangler config's relative paths resolve against its own directory, so "${target}" would break main and migrations_dir. Give a file name, not a path.`
116
+ );
117
+ }
118
+ const remoteOnly = options.remoteOnly ?? DEFAULT_REMOTE_ONLY;
119
+ const dropVars = options.dropVars ?? [];
120
+ const parsed = JSON.parse(stripJsonc(readFileSync(source, "utf8")));
121
+ const cleaned = withoutRemoteOnly(parsed, remoteOnly, dropVars);
122
+ if (cleaned.env) {
123
+ cleaned.env = Object.fromEntries(
124
+ Object.entries(cleaned.env).map(([name, block]) => [
125
+ name,
126
+ withoutRemoteOnly(block, remoteOnly, dropVars)
127
+ ])
128
+ );
129
+ }
130
+ const at = join2(dirname(source), target);
131
+ mkdirSync(dirname(at), { recursive: true });
132
+ writeFileSync(at, JSON.stringify(cleaned, null, 2));
133
+ return at;
134
+ };
135
+ export {
136
+ createCellServer,
137
+ deriveLocalOnlyConfig,
138
+ readReport,
139
+ runStrict,
140
+ truncateAllStatement,
141
+ verdictOf,
142
+ workerdAdapter
143
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@geonosis/testbed",
3
+ "version": "1.0.0",
4
+ "description": "The fixtures law as code, one adapter contract for an integration harness, and the conformance suite an adapter has to pass. Extracted from dielime's Medusa runner and during.day's workerd cell.",
5
+ "keywords": [
6
+ "testing",
7
+ "fixtures",
8
+ "integration",
9
+ "medusa",
10
+ "workerd",
11
+ "postgres"
12
+ ],
13
+ "homepage": "https://github.com/microcompanies/geonosis/tree/main/packages/testbed",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/microcompanies/geonosis.git",
17
+ "directory": "packages/testbed"
18
+ },
19
+ "license": "Apache-2.0",
20
+ "type": "module",
21
+ "main": "dist/index.js",
22
+ "exports": {
23
+ ".": "./dist/index.js",
24
+ "./medusa-built": "./dist/medusa-built/index.js",
25
+ "./workerd": "./dist/workerd/index.js"
26
+ },
27
+ "files": [
28
+ "bin",
29
+ "dist",
30
+ "templates"
31
+ ],
32
+ "engines": {
33
+ "node": ">=22"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "bin": {
39
+ "geonosis-testbed": "bin/geonosis-testbed.mjs"
40
+ },
41
+ "scripts": {
42
+ "build": "tsup",
43
+ "typecheck": "tsc --noEmit"
44
+ }
45
+ }
@@ -0,0 +1,46 @@
1
+ name: cells
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ cells-embedded:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - run: bun install --frozen-lockfile
16
+
17
+ - name: The data layer
18
+ run: bun run --cwd packages/db test
19
+
20
+ cells-postgres:
21
+ runs-on: ubuntu-latest
22
+ strategy:
23
+ fail-fast: false
24
+ matrix:
25
+ postgres: ['17', '18']
26
+ services:
27
+ cell:
28
+ image: postgres:${{ matrix.postgres }}
29
+ env:
30
+ POSTGRES_PASSWORD: postgres
31
+ POSTGRES_DB: postgres
32
+ ports: ['5432:5432']
33
+ options: >-
34
+ --health-cmd "pg_isready -U postgres"
35
+ --health-interval 5s
36
+ --health-timeout 5s
37
+ --health-retries 20
38
+ steps:
39
+ - uses: actions/checkout@v4
40
+
41
+ - run: bun install --frozen-lockfile
42
+
43
+ - name: The data layer
44
+ env:
45
+ CELL_CONNECTION_STRING: postgres://postgres:postgres@127.0.0.1:5432/postgres
46
+ run: bun run --cwd packages/db test