@xenosisorg/testing 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +117 -0
- package/dist/index.js +224 -0
- package/package.json +57 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { AwilixContainer } from 'awilix';
|
|
2
|
+
import { PeerApi, PeerClient, SchemaPackage, AutoloadOptions } from '@xenosisorg/xenosis-core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Apply a schema package's Prisma migrations onto a fresh in-memory engine by
|
|
6
|
+
* replaying the raw `migration.sql` files in order. Prisma's migrations are
|
|
7
|
+
* plain Postgres DDL, which PGlite executes natively — no Prisma CLI, no schema
|
|
8
|
+
* engine, no running server.
|
|
9
|
+
*
|
|
10
|
+
* `migrationsPath` is the package's `schema.migrationsPath` (the `prisma/
|
|
11
|
+
* migrations` dir). Each timestamped subfolder holds one `migration.sql`;
|
|
12
|
+
* lexical sort on the folder name is chronological by Prisma convention.
|
|
13
|
+
*
|
|
14
|
+
* `exec` runs one SQL string against the engine (e.g. `pglite.exec`).
|
|
15
|
+
*/
|
|
16
|
+
declare function replayPrismaMigrations(migrationsPath: string, exec: (sql: string) => Promise<unknown>): Promise<number>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the effective test config for a service.
|
|
20
|
+
*
|
|
21
|
+
* Base is the service's real `xenosis.config.json` (single source of structure:
|
|
22
|
+
* which schemas, peers, boundaries it has). Over it we layer
|
|
23
|
+
* `__tests__/test.config.json` — a DELTA holding only what a test needs to
|
|
24
|
+
* change (e.g. disable auth, point a schema at a test connector). Both files are
|
|
25
|
+
* optional; the test delta wins on conflicts.
|
|
26
|
+
*
|
|
27
|
+
* The delta may name a different base via `extends` (relative to the test file).
|
|
28
|
+
*/
|
|
29
|
+
declare function resolveTestConfig(serviceRoot: string): Promise<Record<string, any>>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a type-safe client for an API spec, routed at the in-process app.
|
|
33
|
+
* Returned by `ctx.client(apiSpec)`.
|
|
34
|
+
*/
|
|
35
|
+
declare function createInProcessClient<TApi extends Record<string, (...args: any[]) => Promise<any>>>(app: unknown, api: PeerApi<TApi>): PeerClient<TApi>;
|
|
36
|
+
|
|
37
|
+
/** A schema binding for the test container: the package + its cradle key. */
|
|
38
|
+
interface TestSchema {
|
|
39
|
+
/** Cradle key the service injects (e.g. `mainDb`). */
|
|
40
|
+
cradleKey: string;
|
|
41
|
+
/** The schema package module (default export or `{ createTestClient, schema }`). */
|
|
42
|
+
pkg: SchemaPackage;
|
|
43
|
+
/** Connector this binding would use in prod — only `type` matters for test dispatch. */
|
|
44
|
+
connector?: {
|
|
45
|
+
type: string;
|
|
46
|
+
} & Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
interface CreateTestContainerOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Auto mode. Point at a service directory and the kit does the rest:
|
|
51
|
+
* - reads `xenosis.config.json` layered with `__tests__/test.config.json`
|
|
52
|
+
* - boots an in-memory engine for each `config.schemas` binding, importing
|
|
53
|
+
* the package by its npm name and replaying its migrations
|
|
54
|
+
* - autoloads repositories/services/controllers by the standard convention
|
|
55
|
+
* Override any piece with the explicit options below.
|
|
56
|
+
*/
|
|
57
|
+
serviceRoot?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The service config object. In `serviceRoot` mode this is the resolved
|
|
60
|
+
* config and is usually omitted; pass it to fully override. The schemas block
|
|
61
|
+
* drives in-memory engine setup.
|
|
62
|
+
*/
|
|
63
|
+
config?: Record<string, unknown>;
|
|
64
|
+
/** Schema packages to back with an in-memory engine (explicit mode). */
|
|
65
|
+
schemas?: TestSchema[];
|
|
66
|
+
/**
|
|
67
|
+
* Peer mocks. Each key becomes both `cradle.<name>` and `cradle.api.<name>`,
|
|
68
|
+
* so a service can inject either `this.api.billing` or `billing` directly.
|
|
69
|
+
*/
|
|
70
|
+
peers?: Record<string, Record<string, (...args: any[]) => unknown>>;
|
|
71
|
+
/**
|
|
72
|
+
* Autoload globs. Defaults (serviceRoot mode) to the standard convention
|
|
73
|
+
* rooted at serviceRoot: repository/*.repository, services/*.service,
|
|
74
|
+
* api/**\/*.controller.
|
|
75
|
+
*/
|
|
76
|
+
autoload?: AutoloadOptions;
|
|
77
|
+
/** Extra cradle registrations (asValue) applied before autoload. */
|
|
78
|
+
register?: Record<string, unknown>;
|
|
79
|
+
/**
|
|
80
|
+
* Seed callback run after schema clients are ready and before the proof
|
|
81
|
+
* query. Receives the cradle so you can write rows via `cradle.<schemaKey>`.
|
|
82
|
+
*/
|
|
83
|
+
seed?: (cradle: any) => Promise<void> | void;
|
|
84
|
+
}
|
|
85
|
+
interface TestContainer {
|
|
86
|
+
container: AwilixContainer;
|
|
87
|
+
/** The awilix cradle — read clients/services off it. */
|
|
88
|
+
cradle: any;
|
|
89
|
+
/** The live Express app — pass to supertest(app) without listening. */
|
|
90
|
+
server: any;
|
|
91
|
+
/**
|
|
92
|
+
* Build a type-safe client for an API contract, routed at the in-process app.
|
|
93
|
+
* Calls go through the same Proxy + zod + path-param machinery production
|
|
94
|
+
* callers use — but hit `server` directly, no port. Use it to exercise a
|
|
95
|
+
* service through its own `defineServiceApi` contract:
|
|
96
|
+
*
|
|
97
|
+
* const billing = ctx.client(billingApi);
|
|
98
|
+
* const charge = await billing.createCharge({ userId, amount, currency });
|
|
99
|
+
*/
|
|
100
|
+
client: <TApi extends Record<string, (...args: any[]) => Promise<any>>>(api: PeerApi<TApi>) => PeerClient<TApi>;
|
|
101
|
+
/** Tear down in-memory engines and clients. Call in afterAll/afterEach. */
|
|
102
|
+
cleanup: () => Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Boot a Xenosis service in-process for tests: real schema on an in-memory
|
|
106
|
+
* Postgres (PGlite), real controllers, peer calls replaced by mocks. No Docker,
|
|
107
|
+
* no network, no migrations CLI — the kit replays the package's migration SQL
|
|
108
|
+
* onto a fresh PGlite, then the package wraps it in its own Prisma client via
|
|
109
|
+
* `createTestClient`.
|
|
110
|
+
*
|
|
111
|
+
* This intentionally re-implements the xenosisBootstrap sequence (rather than
|
|
112
|
+
* calling it) so it can swap the schema layer for in-memory engines — bootstrap
|
|
113
|
+
* always builds the real connector clients.
|
|
114
|
+
*/
|
|
115
|
+
declare function createTestContainer(options?: CreateTestContainerOptions): Promise<TestContainer>;
|
|
116
|
+
|
|
117
|
+
export { type CreateTestContainerOptions, type TestContainer, type TestSchema, createInProcessClient, createTestContainer, replayPrismaMigrations, resolveTestConfig };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createContainer, asValue, asFunction } from "awilix";
|
|
3
|
+
import {
|
|
4
|
+
loggerProvider,
|
|
5
|
+
serverProvider,
|
|
6
|
+
loadSharedModules,
|
|
7
|
+
runAutoload,
|
|
8
|
+
loadServiceApis,
|
|
9
|
+
mountOpenapi,
|
|
10
|
+
buildRequestContextMiddleware,
|
|
11
|
+
errorHandlerMiddleware
|
|
12
|
+
} from "@xenosisorg/xenosis-core";
|
|
13
|
+
import { join as join3 } from "path";
|
|
14
|
+
|
|
15
|
+
// src/migrate.ts
|
|
16
|
+
import { readdir, readFile } from "fs/promises";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
async function replayPrismaMigrations(migrationsPath, exec) {
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = (await readdir(migrationsPath, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
22
|
+
} catch {
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
let applied = 0;
|
|
26
|
+
for (const dir of entries) {
|
|
27
|
+
const sqlPath = join(migrationsPath, dir, "migration.sql");
|
|
28
|
+
let sql;
|
|
29
|
+
try {
|
|
30
|
+
sql = await readFile(sqlPath, "utf-8");
|
|
31
|
+
} catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (sql.trim()) {
|
|
35
|
+
await exec(sql);
|
|
36
|
+
applied++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return applied;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/config.ts
|
|
43
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
44
|
+
import { join as join2, isAbsolute, resolve } from "path";
|
|
45
|
+
async function resolveTestConfig(serviceRoot) {
|
|
46
|
+
const base = await readJson(join2(serviceRoot, "xenosis.config.json"));
|
|
47
|
+
const testCfgPath = join2(serviceRoot, "__tests__", "test.config.json");
|
|
48
|
+
const delta = await readJson(testCfgPath);
|
|
49
|
+
let resolvedBase = base;
|
|
50
|
+
if (delta.extends) {
|
|
51
|
+
const extendsPath = isAbsolute(delta.extends) ? delta.extends : resolve(serviceRoot, "__tests__", delta.extends);
|
|
52
|
+
resolvedBase = await readJson(extendsPath);
|
|
53
|
+
}
|
|
54
|
+
const { extends: _drop, ...deltaRest } = delta;
|
|
55
|
+
return mergeConfig(resolvedBase, deltaRest);
|
|
56
|
+
}
|
|
57
|
+
async function readJson(path) {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(await readFile2(path, "utf-8"));
|
|
60
|
+
} catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function mergeConfig(base, delta) {
|
|
65
|
+
const out = { ...base };
|
|
66
|
+
for (const [k, v] of Object.entries(delta)) {
|
|
67
|
+
if (v && typeof v === "object" && !Array.isArray(v) && base[k] && typeof base[k] === "object" && !Array.isArray(base[k])) {
|
|
68
|
+
out[k] = { ...base[k], ...v };
|
|
69
|
+
} else {
|
|
70
|
+
out[k] = v;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/inProcessClient.ts
|
|
77
|
+
import {
|
|
78
|
+
createPeerClient,
|
|
79
|
+
buildReliabilityPolicy
|
|
80
|
+
} from "@xenosisorg/xenosis-core";
|
|
81
|
+
import request from "supertest";
|
|
82
|
+
function inProcessTransport(app) {
|
|
83
|
+
return {
|
|
84
|
+
async execute(req) {
|
|
85
|
+
const agent = request(app);
|
|
86
|
+
const method = req.method.toLowerCase();
|
|
87
|
+
let r = agent[method](req.url);
|
|
88
|
+
for (const [k, v] of Object.entries(req.headers ?? {})) r = r.set(k, String(v));
|
|
89
|
+
if (req.body !== void 0 && req.method !== "GET") r = r.send(req.body);
|
|
90
|
+
const res = await r;
|
|
91
|
+
if (res.status >= 400) {
|
|
92
|
+
const err = new Error(
|
|
93
|
+
`in-process ${req.method} ${req.url} \u2192 HTTP ${res.status}`
|
|
94
|
+
);
|
|
95
|
+
err.status = res.status;
|
|
96
|
+
err.body = res.body;
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
return res.body;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function createInProcessClient(app, api) {
|
|
104
|
+
return createPeerClient({
|
|
105
|
+
api,
|
|
106
|
+
transport: inProcessTransport(app),
|
|
107
|
+
policy: buildReliabilityPolicy({})
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/index.ts
|
|
112
|
+
async function createTestContainer(options = {}) {
|
|
113
|
+
const cleanups = [];
|
|
114
|
+
let config = options.config ?? {};
|
|
115
|
+
let schemas = options.schemas ?? [];
|
|
116
|
+
let autoload = options.autoload;
|
|
117
|
+
if (options.serviceRoot) {
|
|
118
|
+
const root = options.serviceRoot;
|
|
119
|
+
if (!options.config) config = await resolveTestConfig(root);
|
|
120
|
+
if (!options.schemas) {
|
|
121
|
+
const cfgSchemas = config.schemas ?? {};
|
|
122
|
+
const cfgConnectors = config.connectors ?? {};
|
|
123
|
+
const derived = [];
|
|
124
|
+
for (const [cradleKey, binding] of Object.entries(cfgSchemas)) {
|
|
125
|
+
const mod = await import(binding.package);
|
|
126
|
+
const pkg = typeof mod.createClient === "function" ? mod : mod.default;
|
|
127
|
+
const connector = cfgConnectors[binding.connector] ?? { type: pkg.schema.type };
|
|
128
|
+
derived.push({ cradleKey, pkg, connector });
|
|
129
|
+
}
|
|
130
|
+
schemas = derived;
|
|
131
|
+
}
|
|
132
|
+
if (!options.autoload) {
|
|
133
|
+
autoload = {
|
|
134
|
+
repositories: { pattern: join3(root, "src/repository/*.repository.{ts,js}"), lifetime: "singleton" },
|
|
135
|
+
services: { pattern: join3(root, "src/services/*.service.{ts,js}"), lifetime: "singleton" },
|
|
136
|
+
controllers: { pattern: join3(root, "src/api/**/*.controller.{ts,js}"), style: "build" }
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const container = createContainer();
|
|
141
|
+
container.register({
|
|
142
|
+
config: asValue(config),
|
|
143
|
+
logger: asFunction(loggerProvider).singleton(),
|
|
144
|
+
context: asValue({}),
|
|
145
|
+
errorHandlerMiddleware: asValue(errorHandlerMiddleware),
|
|
146
|
+
server: asFunction(serverProvider).singleton(),
|
|
147
|
+
// Disconnect arrays the loaders/commands expect to exist.
|
|
148
|
+
schemaDisconnects: asValue([]),
|
|
149
|
+
peerDisconnects: asValue([])
|
|
150
|
+
});
|
|
151
|
+
const logger = container.cradle.logger;
|
|
152
|
+
for (const s of schemas) {
|
|
153
|
+
const type = s.connector?.type ?? s.pkg.schema.type;
|
|
154
|
+
if (type === "postgres" || type === "prisma") {
|
|
155
|
+
if (typeof s.pkg.createTestClient !== "function") {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`[testing] schema "${s.cradleKey}": package has no createTestClient \u2014 add it to support in-memory tests (see @xenosisorg/testing docs)`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const { PGlite } = await import("@electric-sql/pglite");
|
|
161
|
+
const pglite = new PGlite();
|
|
162
|
+
await pglite.waitReady;
|
|
163
|
+
if (s.pkg.schema.migrationsPath) {
|
|
164
|
+
await replayPrismaMigrations(
|
|
165
|
+
s.pkg.schema.migrationsPath,
|
|
166
|
+
(sql) => pglite.exec(sql)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const client = await s.pkg.createTestClient(pglite, s.connector ?? { type });
|
|
170
|
+
container.register({ [s.cradleKey]: asValue(client) });
|
|
171
|
+
cleanups.push(async () => {
|
|
172
|
+
if (typeof s.pkg.disconnect === "function") await s.pkg.disconnect(client);
|
|
173
|
+
await pglite.close();
|
|
174
|
+
});
|
|
175
|
+
} else {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`[testing] schema "${s.cradleKey}": in-memory engine for type "${type}" is not implemented yet (prototype supports postgres/prisma)`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const api = {};
|
|
182
|
+
for (const [name, impl] of Object.entries(options.peers ?? {})) {
|
|
183
|
+
container.register({ [name]: asValue(impl) });
|
|
184
|
+
api[name] = impl;
|
|
185
|
+
}
|
|
186
|
+
await loadServiceApis(container, { ...config, peers: void 0 }, logger).catch(() => {
|
|
187
|
+
});
|
|
188
|
+
container.register({ api: asValue(api) });
|
|
189
|
+
for (const [k, v] of Object.entries(options.register ?? {})) {
|
|
190
|
+
container.register({ [k]: asValue(v) });
|
|
191
|
+
}
|
|
192
|
+
await loadSharedModules(container, logger).catch(() => {
|
|
193
|
+
});
|
|
194
|
+
const server = container.cradle.server;
|
|
195
|
+
server.use(buildRequestContextMiddleware(container, logger, config));
|
|
196
|
+
if (autoload) {
|
|
197
|
+
await runAutoload(container, autoload, logger);
|
|
198
|
+
}
|
|
199
|
+
if (config.openapi?.enabled !== false) {
|
|
200
|
+
try {
|
|
201
|
+
mountOpenapi(server, { ...config.openapi, name: config.name });
|
|
202
|
+
} catch {
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
server.use(errorHandlerMiddleware);
|
|
206
|
+
if (options.seed) {
|
|
207
|
+
await options.seed(container.cradle);
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
container,
|
|
211
|
+
cradle: container.cradle,
|
|
212
|
+
server,
|
|
213
|
+
client: (api2) => createInProcessClient(server, api2),
|
|
214
|
+
cleanup: async () => {
|
|
215
|
+
for (const c of cleanups.reverse()) await c();
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
export {
|
|
220
|
+
createInProcessClient,
|
|
221
|
+
createTestContainer,
|
|
222
|
+
replayPrismaMigrations,
|
|
223
|
+
resolveTestConfig
|
|
224
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xenosisorg/testing",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "In-memory test harness for Xenosis services — real schema, real SQL, no Docker.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/aVujovic/xenosis.git",
|
|
9
|
+
"directory": "packages/testing-kit"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://xenosis.org",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/aVujovic/xenosis/issues"
|
|
14
|
+
},
|
|
15
|
+
"main": "dist/index.js",
|
|
16
|
+
"types": "dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsup",
|
|
25
|
+
"type-check": "tsc --noEmit",
|
|
26
|
+
"prepublishOnly": "pnpm build"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20.0.0"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"awilix": "^12.0.5",
|
|
33
|
+
"supertest": "^7.0.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@electric-sql/pglite": "^0.2.0",
|
|
37
|
+
"@xenosisorg/xenosis-core": ">=0.0.5"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"@electric-sql/pglite": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@electric-sql/pglite": "^0.2.0",
|
|
46
|
+
"@example/psql-main": "workspace:*",
|
|
47
|
+
"@types/node": "^20.11.19",
|
|
48
|
+
"@types/supertest": "^6.0.2",
|
|
49
|
+
"@xenosisorg/xenosis-core": "workspace:*",
|
|
50
|
+
"pglite-prisma-adapter": "^0.6.1",
|
|
51
|
+
"tsup": "^8.4.0",
|
|
52
|
+
"typescript": "^5.3.3"
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"dist"
|
|
56
|
+
]
|
|
57
|
+
}
|