@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.
- package/LICENSE +202 -0
- package/README.md +437 -0
- package/bin/geonosis-testbed.mjs +4 -0
- package/dist/chunk-ULJUJOSR.js +51 -0
- package/dist/chunk-UZPJWPL3.js +59 -0
- package/dist/cli.js +201 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.js +235 -0
- package/dist/medusa-built/index.d.ts +82 -0
- package/dist/medusa-built/index.js +91 -0
- package/dist/types-D-uHas5h.d.ts +68 -0
- package/dist/workerd/index.d.ts +117 -0
- package/dist/workerd/index.js +143 -0
- package/package.json +45 -0
- package/templates/ci/db-matrix.yml +46 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/workerd/strict.ts
|
|
2
|
+
import { spawn } from "child_process";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { dirname, join } from "path";
|
|
6
|
+
var DEFAULT_ARGS = ["vitest", "run", "--reporter=default", "--reporter=json"];
|
|
7
|
+
var verdictOf = (report) => {
|
|
8
|
+
if (report === null) {
|
|
9
|
+
return {
|
|
10
|
+
ok: false,
|
|
11
|
+
why: "the runner wrote no JSON report \u2014 a crash before the reporter is not a pass"
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const failed = report.numFailedTests ?? 0;
|
|
15
|
+
if (report.success !== true) {
|
|
16
|
+
const named = failed > 0 ? `, naming ${failed} failing test(s)` : " and named no failing test";
|
|
17
|
+
return { ok: false, why: `the runner reported success=${String(report.success)}${named}` };
|
|
18
|
+
}
|
|
19
|
+
return failed > 0 ? { ok: false, why: `${failed} test(s) failed` } : { ok: true, why: "" };
|
|
20
|
+
};
|
|
21
|
+
var readReport = (path) => {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
var runStrict = async (options = {}) => {
|
|
29
|
+
const command = options.command ?? "npx";
|
|
30
|
+
if (command === "") throw new Error("runStrict needs a command to run");
|
|
31
|
+
const own = options.reportPath === void 0;
|
|
32
|
+
const reportPath = options.reportPath ?? join(mkdtempSync(join(tmpdir(), "geonosis-strict-")), "report.json");
|
|
33
|
+
const reportArg = options.reportArg ?? ((path) => `--outputFile=${path}`);
|
|
34
|
+
const args = [...options.args ?? DEFAULT_ARGS, reportArg(reportPath)];
|
|
35
|
+
try {
|
|
36
|
+
const code = await new Promise((done, fail) => {
|
|
37
|
+
const child = spawn(command, args, { stdio: options.stdio ?? "inherit" });
|
|
38
|
+
child.on("error", fail);
|
|
39
|
+
child.on("exit", (exited) => done(exited ?? 0));
|
|
40
|
+
});
|
|
41
|
+
return { code, reportPath, verdict: verdictOf(readReport(reportPath)) };
|
|
42
|
+
} finally {
|
|
43
|
+
if (own) rmSync(dirname(reportPath), { force: true, recursive: true });
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
verdictOf,
|
|
49
|
+
readReport,
|
|
50
|
+
runStrict
|
|
51
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// src/database-name.ts
|
|
2
|
+
var MAX_IDENTIFIER_BYTES = 63;
|
|
3
|
+
var createDatabaseNamer = (derive) => {
|
|
4
|
+
const issued = /* @__PURE__ */ new Map();
|
|
5
|
+
return (key) => {
|
|
6
|
+
const name = derive(key);
|
|
7
|
+
if (name === "") throw new Error(`the database name for "${key}" is empty`);
|
|
8
|
+
const bytes = Buffer.byteLength(name);
|
|
9
|
+
if (bytes > MAX_IDENTIFIER_BYTES) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
`the database name for "${key}" is ${bytes} bytes; Postgres truncates at ${MAX_IDENTIFIER_BYTES}, so a longer one is a collision waiting to be silent`
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
const taken = issued.get(name);
|
|
15
|
+
if (taken !== void 0 && taken !== key) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`"${key}" and "${taken}" both derive the database "${name}" \u2014 they would collide, and each one's reset would empty the other's rows`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
issued.set(name, key);
|
|
21
|
+
return name;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// src/fixtures.ts
|
|
26
|
+
import { realpathSync } from "fs";
|
|
27
|
+
import { isAbsolute, join, resolve, sep } from "path";
|
|
28
|
+
var fixturesOnly = (root) => {
|
|
29
|
+
if (root === "") throw new Error('fixturesOnly needs a fixtures directory; it was given ""');
|
|
30
|
+
const base = realOf(resolve(root));
|
|
31
|
+
return (...segments) => {
|
|
32
|
+
const absolute = segments.find((segment) => isAbsolute(segment));
|
|
33
|
+
if (absolute !== void 0) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`"${absolute}" is an absolute path, and a fixture is named relative to the fixtures directory ${base}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const asked = resolve(join(base, ...segments));
|
|
39
|
+
const real = realOf(asked);
|
|
40
|
+
if (real !== base && !real.startsWith(base + sep)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`${segments.join("/")} resolves to ${real}, which is outside the fixtures directory ${base}`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return real;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
var realOf = (path) => {
|
|
49
|
+
try {
|
|
50
|
+
return realpathSync(path);
|
|
51
|
+
} catch {
|
|
52
|
+
return path;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export {
|
|
57
|
+
createDatabaseNamer,
|
|
58
|
+
fixturesOnly
|
|
59
|
+
};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
runStrict
|
|
4
|
+
} from "./chunk-ULJUJOSR.js";
|
|
5
|
+
|
|
6
|
+
// src/cli.ts
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
8
|
+
import { dirname, resolve } from "path";
|
|
9
|
+
|
|
10
|
+
// src/ci/db-matrix.ts
|
|
11
|
+
var SAYS = {
|
|
12
|
+
boolean: "true or false",
|
|
13
|
+
string: "a string",
|
|
14
|
+
strings: "an array of strings"
|
|
15
|
+
};
|
|
16
|
+
var SHAPE = {
|
|
17
|
+
command: { kind: "string", required: true },
|
|
18
|
+
connectionEnv: { kind: "string", required: true },
|
|
19
|
+
connectionString: { kind: "string", required: true },
|
|
20
|
+
embedded: { kind: "boolean", required: false },
|
|
21
|
+
image: { kind: "string", required: false },
|
|
22
|
+
majors: { kind: "strings", required: true },
|
|
23
|
+
name: { kind: "string", required: true },
|
|
24
|
+
runsOn: { kind: "string", required: false },
|
|
25
|
+
setup: { kind: "strings", required: false }
|
|
26
|
+
};
|
|
27
|
+
var holds = (kind, value) => {
|
|
28
|
+
if (kind === "strings")
|
|
29
|
+
return Array.isArray(value) && value.every((one) => typeof one === "string");
|
|
30
|
+
return typeof value === (kind === "boolean" ? "boolean" : "string");
|
|
31
|
+
};
|
|
32
|
+
var said = (value) => value === void 0 ? "undefined" : JSON.stringify(value);
|
|
33
|
+
var problemsIn = (block) => [
|
|
34
|
+
...Object.entries(SHAPE).flatMap(([key, want]) => {
|
|
35
|
+
const value = block[key];
|
|
36
|
+
if (value === void 0)
|
|
37
|
+
return want.required ? [`${key} is missing \u2014 it takes ${SAYS[want.kind]}`] : [];
|
|
38
|
+
return holds(want.kind, value) ? [] : [`${key} is ${said(value)} \u2014 it takes ${SAYS[want.kind]}`];
|
|
39
|
+
}),
|
|
40
|
+
...Object.keys(block).filter((key) => SHAPE[key] === void 0).map(
|
|
41
|
+
(key) => `${key} is not a key of \`testbed.matrix\`, which takes ${Object.keys(SHAPE).join(", ")}`
|
|
42
|
+
)
|
|
43
|
+
];
|
|
44
|
+
var readDbMatrix = (value, where) => {
|
|
45
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`${where}: \`testbed.matrix\` is ${said(value)}, and a database matrix is a block of settings`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const block = value;
|
|
51
|
+
const problems = problemsIn(block);
|
|
52
|
+
if (problems.length > 0) {
|
|
53
|
+
throw new Error([`${where}: \`testbed.matrix\` cannot be read \u2014`, ...problems].join("\n "));
|
|
54
|
+
}
|
|
55
|
+
return block;
|
|
56
|
+
};
|
|
57
|
+
var JOB_NAME = /^[A-Za-z][\w-]*$/;
|
|
58
|
+
var DEFAULT_IMAGE = "postgres";
|
|
59
|
+
var DEFAULT_RUNS_ON = "ubuntu-latest";
|
|
60
|
+
var steps = (setup, command, env) => [
|
|
61
|
+
" - uses: actions/checkout@v4",
|
|
62
|
+
"",
|
|
63
|
+
...setup.flatMap((step) => [` - run: ${step}`, ""]),
|
|
64
|
+
" - name: The data layer",
|
|
65
|
+
...env === void 0 ? [] : [" env:", ` ${env[0]}: ${env[1]}`],
|
|
66
|
+
` run: ${command}`
|
|
67
|
+
];
|
|
68
|
+
var renderDbMatrix = (matrix2) => {
|
|
69
|
+
if (!JOB_NAME.test(matrix2.name)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`"${matrix2.name}" is not usable as a job name; it becomes "<name>-embedded" and "<name>-postgres"`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (matrix2.command === "") throw new Error("a database matrix needs a command to run");
|
|
75
|
+
const embedded = matrix2.embedded ?? true;
|
|
76
|
+
const majors = matrix2.majors;
|
|
77
|
+
if (!embedded && majors.length === 0) {
|
|
78
|
+
throw new Error("a database matrix with no embedded leg and no majors has no legs to run");
|
|
79
|
+
}
|
|
80
|
+
if (majors.length > 0 && matrix2.connectionString === "") {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`the majors ${majors.join(", ")} have no connection string to be reached by, so every one of them would run the embedded cell under a container's name`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const runsOn = matrix2.runsOn ?? DEFAULT_RUNS_ON;
|
|
86
|
+
const image = matrix2.image ?? DEFAULT_IMAGE;
|
|
87
|
+
const setup = matrix2.setup ?? [];
|
|
88
|
+
const embeddedJob = [
|
|
89
|
+
` ${matrix2.name}-embedded:`,
|
|
90
|
+
` runs-on: ${runsOn}`,
|
|
91
|
+
" steps:",
|
|
92
|
+
...steps(setup, matrix2.command)
|
|
93
|
+
];
|
|
94
|
+
const postgresJob = [
|
|
95
|
+
` ${matrix2.name}-postgres:`,
|
|
96
|
+
` runs-on: ${runsOn}`,
|
|
97
|
+
" strategy:",
|
|
98
|
+
// One major failing must not hide whether the other passes — that is the question a matrix asks.
|
|
99
|
+
" fail-fast: false",
|
|
100
|
+
" matrix:",
|
|
101
|
+
` postgres: [${majors.map((major) => `'${major}'`).join(", ")}]`,
|
|
102
|
+
" services:",
|
|
103
|
+
" cell:",
|
|
104
|
+
` image: ${image}:\${{ matrix.postgres }}`,
|
|
105
|
+
" env:",
|
|
106
|
+
" POSTGRES_PASSWORD: postgres",
|
|
107
|
+
" POSTGRES_DB: postgres",
|
|
108
|
+
" ports: ['5432:5432']",
|
|
109
|
+
// Without this the first connection races the container: the job starts, the client connects
|
|
110
|
+
// before Postgres is accepting, and the suite fails for a reason that is not in the code.
|
|
111
|
+
" options: >-",
|
|
112
|
+
' --health-cmd "pg_isready -U postgres"',
|
|
113
|
+
" --health-interval 5s",
|
|
114
|
+
" --health-timeout 5s",
|
|
115
|
+
" --health-retries 20",
|
|
116
|
+
" steps:",
|
|
117
|
+
...steps(setup, matrix2.command, [matrix2.connectionEnv, matrix2.connectionString])
|
|
118
|
+
];
|
|
119
|
+
return [
|
|
120
|
+
`name: ${matrix2.name}`,
|
|
121
|
+
"",
|
|
122
|
+
"on:",
|
|
123
|
+
" push:",
|
|
124
|
+
" branches: [main]",
|
|
125
|
+
" pull_request:",
|
|
126
|
+
" branches: [main]",
|
|
127
|
+
"",
|
|
128
|
+
"jobs:",
|
|
129
|
+
...embedded ? [...embeddedJob, ""] : [],
|
|
130
|
+
...majors.length > 0 ? postgresJob : [],
|
|
131
|
+
""
|
|
132
|
+
].join("\n");
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// src/cli.ts
|
|
136
|
+
var CONFIG = "geonosis.json";
|
|
137
|
+
var flag = (argv, name) => {
|
|
138
|
+
const at = argv.indexOf(`--${name}`);
|
|
139
|
+
return at < 0 ? void 0 : argv[at + 1];
|
|
140
|
+
};
|
|
141
|
+
var matrixOf = (argv) => {
|
|
142
|
+
const at = resolve(flag(argv, "config") ?? CONFIG);
|
|
143
|
+
if (!existsSync(at)) throw new Error(`no ${CONFIG} at ${at}`);
|
|
144
|
+
const config = JSON.parse(readFileSync(at, "utf8"));
|
|
145
|
+
const matrix2 = config.testbed?.matrix;
|
|
146
|
+
if (matrix2 === void 0) {
|
|
147
|
+
throw new Error(`${at} has no \`testbed.matrix\` \u2014 there is nothing to render a workflow from`);
|
|
148
|
+
}
|
|
149
|
+
return readDbMatrix(matrix2, at);
|
|
150
|
+
};
|
|
151
|
+
var matrix = (argv) => {
|
|
152
|
+
const rendered = renderDbMatrix(matrixOf(argv));
|
|
153
|
+
const out = flag(argv, "out");
|
|
154
|
+
if (out === void 0) {
|
|
155
|
+
process.stdout.write(rendered);
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
mkdirSync(dirname(resolve(out)), { recursive: true });
|
|
159
|
+
writeFileSync(resolve(out), rendered);
|
|
160
|
+
return 0;
|
|
161
|
+
};
|
|
162
|
+
var strict = async (argv) => {
|
|
163
|
+
const at = argv.indexOf("--");
|
|
164
|
+
const passthrough = at < 0 ? [] : argv.slice(at + 1);
|
|
165
|
+
const command = flag(argv, "command");
|
|
166
|
+
const { code, verdict } = await runStrict({
|
|
167
|
+
...command === void 0 ? {} : { command },
|
|
168
|
+
args: command === void 0 ? ["vitest", "run", "--reporter=default", "--reporter=json", ...passthrough] : passthrough
|
|
169
|
+
});
|
|
170
|
+
if (!verdict.ok) {
|
|
171
|
+
process.stderr.write(`
|
|
172
|
+
geonosis-testbed strict: ${verdict.why} (the runner exited ${code})
|
|
173
|
+
`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
return code;
|
|
177
|
+
};
|
|
178
|
+
var USAGE = `geonosis-testbed <command>
|
|
179
|
+
|
|
180
|
+
matrix [--config geonosis.json] [--out path]
|
|
181
|
+
Render the database matrix workflow from \`testbed.matrix\`.
|
|
182
|
+
|
|
183
|
+
strict [--command bin] [-- ...args]
|
|
184
|
+
Run a test runner and judge its JSON report, not its exit code.
|
|
185
|
+
`;
|
|
186
|
+
var main = async (argv) => {
|
|
187
|
+
const [command] = argv;
|
|
188
|
+
if (command === "matrix") return matrix(argv.slice(1));
|
|
189
|
+
if (command === "strict") return strict(argv.slice(1));
|
|
190
|
+
process.stderr.write(command === void 0 ? USAGE : `unknown command "${command}"
|
|
191
|
+
|
|
192
|
+
${USAGE}`);
|
|
193
|
+
return 2;
|
|
194
|
+
};
|
|
195
|
+
main(process.argv.slice(2)).then((code) => {
|
|
196
|
+
process.exitCode = code;
|
|
197
|
+
}).catch((error) => {
|
|
198
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
199
|
+
`);
|
|
200
|
+
process.exitCode = 2;
|
|
201
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { T as TestbedAdapter, C as ConformanceReport, a as TestbedConfig, b as Testbed } from './types-D-uHas5h.js';
|
|
2
|
+
export { B as BootContext, c as BootResult, d as ConformanceCheck, D as DatabaseLifetime, e as DatabaseSpec, N as NetworkPolicy } from './types-D-uHas5h.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How a caller lets the conformance suite see state change. The kit cannot know how to write a row
|
|
6
|
+
* through someone else's database handle, and without writing one a no-op `restore()` is
|
|
7
|
+
* indistinguishable from a real one — so that check is SKIPPED, by name, when no probe is given.
|
|
8
|
+
*/
|
|
9
|
+
type ConformanceProbe<Db> = {
|
|
10
|
+
read: (db: Db) => Promise<unknown> | unknown;
|
|
11
|
+
write: (db: Db) => Promise<void> | void;
|
|
12
|
+
};
|
|
13
|
+
type ConformanceOptions<Db> = {
|
|
14
|
+
probe?: ConformanceProbe<Db>;
|
|
15
|
+
/**
|
|
16
|
+
* How long a `waitFor` gets before the suite calls it hung. Set it ABOVE the adapter's own
|
|
17
|
+
* deadline: a correct rejection that arrives later is reported here as a hang.
|
|
18
|
+
*/
|
|
19
|
+
waitForMs?: number;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The contract every testbed adapter holds, run against the adapter itself.
|
|
23
|
+
*
|
|
24
|
+
* Exported because a consumer writes adapters this repo never sees, and an adapter that has not
|
|
25
|
+
* been through this is a claim. Each check is a property something in one of the two harnesses
|
|
26
|
+
* really depends on — teardown fires from `afterAll` and from SIGTERM, so it is called twice; a
|
|
27
|
+
* restore before any snapshot returns whatever the template database happened to hold.
|
|
28
|
+
*
|
|
29
|
+
* It never throws for the adapter's sake: a broken adapter is a failed check with its own message
|
|
30
|
+
* in `detail`, because a conformance run that dies tells the reader less than one that reports.
|
|
31
|
+
*/
|
|
32
|
+
declare const runAdapterConformance: <Db>(adapter: TestbedAdapter<Db>, options?: ConformanceOptions<Db>) => Promise<ConformanceReport>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A database name, derived by the repo and then checked.
|
|
36
|
+
*
|
|
37
|
+
* The check that earns its keep is the last one. `sha1(description).slice(0, 12)` — the derivation
|
|
38
|
+
* both this kit's `medusa-built` adapter and the repo it came from use — is 48 bits, and two suites
|
|
39
|
+
* that collide do not fail: they share a database, and whichever one resets first empties the
|
|
40
|
+
* other's rows, with both green. Neither source repo notices.
|
|
41
|
+
*/
|
|
42
|
+
declare const createDatabaseNamer: (derive: (key: string) => string) => ((key: string) => string);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A testbed is the arrangement around one engine call: where fixtures live, what a database is
|
|
46
|
+
* called and how long it lives, what the network may reach, and which adapter boots it.
|
|
47
|
+
*
|
|
48
|
+
* Everything it can refuse, it refuses HERE rather than at the first boot — a half-declared
|
|
49
|
+
* adapter or an allow-list of nothing is the kind of configuration that reads as working and
|
|
50
|
+
* gates nothing, which is the failure this kit exists to catch.
|
|
51
|
+
*/
|
|
52
|
+
declare const defineTestbed: <Db>(config: TestbedConfig<Db>) => Testbed<Db>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The fixtures law as a function: a resolver that returns a path inside `root` or throws.
|
|
56
|
+
*
|
|
57
|
+
* during.day's §4.3 — "a test reads and writes ONLY `apps/web/__tests__/__fixtures__/`" — exists
|
|
58
|
+
* because the directory next door in that repo holds real customers' purchase orders. Its own
|
|
59
|
+
* guard (`resolveWithinRoot`) compares strings, which two shapes get past: a sibling directory
|
|
60
|
+
* whose name merely starts with the root, and a symlink whose every character is inside it. Both
|
|
61
|
+
* are resolved here.
|
|
62
|
+
*/
|
|
63
|
+
declare const fixturesOnly: (root: string) => ((...segments: string[]) => string);
|
|
64
|
+
|
|
65
|
+
export { type ConformanceOptions, type ConformanceProbe, ConformanceReport, Testbed, TestbedAdapter, TestbedConfig, createDatabaseNamer, defineTestbed, fixturesOnly, runAdapterConformance };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDatabaseNamer,
|
|
3
|
+
fixturesOnly
|
|
4
|
+
} from "./chunk-UZPJWPL3.js";
|
|
5
|
+
|
|
6
|
+
// src/conformance.ts
|
|
7
|
+
var BOOT = "boot returns a handle";
|
|
8
|
+
var REBOOT = "boots again after teardown";
|
|
9
|
+
var RESTORE_REFUSED = "restore before a snapshot is refused";
|
|
10
|
+
var RESTORE_ROUND_TRIP = "restore returns the snapshot";
|
|
11
|
+
var TEARDOWN = "teardown is idempotent";
|
|
12
|
+
var WAIT_KINDS = "waitFor declares the kinds it knows";
|
|
13
|
+
var WAIT_REFUSES = "waitFor refuses a kind it does not know";
|
|
14
|
+
var WAIT_REJECTS = "waitFor rejects when it never comes true";
|
|
15
|
+
var WAIT_RESOLVES = "waitFor resolves when it comes true";
|
|
16
|
+
var UNKNOWN_KIND = "a-kind-no-adapter-declares";
|
|
17
|
+
var EVERY_CHECK = [
|
|
18
|
+
BOOT,
|
|
19
|
+
RESTORE_REFUSED,
|
|
20
|
+
RESTORE_ROUND_TRIP,
|
|
21
|
+
WAIT_KINDS,
|
|
22
|
+
WAIT_REJECTS,
|
|
23
|
+
WAIT_RESOLVES,
|
|
24
|
+
WAIT_REFUSES,
|
|
25
|
+
TEARDOWN,
|
|
26
|
+
REBOOT
|
|
27
|
+
];
|
|
28
|
+
var detailOf = (error) => error instanceof Error ? error.message : String(error);
|
|
29
|
+
var runAdapterConformance = async (adapter, options = {}) => {
|
|
30
|
+
const checks = [];
|
|
31
|
+
const skipped = [];
|
|
32
|
+
const waitForMs = options.waitForMs ?? 500;
|
|
33
|
+
const ran = (name, ok, detail = "") => {
|
|
34
|
+
checks.push({ detail, name, ok });
|
|
35
|
+
};
|
|
36
|
+
const attempt = async (name, body) => {
|
|
37
|
+
try {
|
|
38
|
+
const detail = await body();
|
|
39
|
+
ran(name, detail === "", detail);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
ran(name, false, detailOf(error));
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const ctx = Object.freeze({
|
|
45
|
+
databaseName: "geonosis_conformance",
|
|
46
|
+
fixtures: "",
|
|
47
|
+
network: "none"
|
|
48
|
+
});
|
|
49
|
+
let booted;
|
|
50
|
+
try {
|
|
51
|
+
booted = await adapter.boot(ctx);
|
|
52
|
+
ran(
|
|
53
|
+
BOOT,
|
|
54
|
+
booted.db !== void 0,
|
|
55
|
+
booted.db === void 0 ? "boot resolved with db: undefined" : ""
|
|
56
|
+
);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
ran(BOOT, false, detailOf(error));
|
|
59
|
+
return report(
|
|
60
|
+
adapter.id,
|
|
61
|
+
checks,
|
|
62
|
+
EVERY_CHECK.filter((name) => name !== BOOT)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const handle = booted;
|
|
66
|
+
if (adapter.restore && adapter.snapshot) {
|
|
67
|
+
const { restore, snapshot } = adapter;
|
|
68
|
+
await attempt(RESTORE_REFUSED, async () => {
|
|
69
|
+
try {
|
|
70
|
+
await restore();
|
|
71
|
+
} catch {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
return "restore() resolved with no snapshot taken \u2014 it restored something nobody asked for";
|
|
75
|
+
});
|
|
76
|
+
const probe = options.probe;
|
|
77
|
+
if (probe) {
|
|
78
|
+
await attempt(RESTORE_ROUND_TRIP, async () => {
|
|
79
|
+
const before = await probe.read(handle.db);
|
|
80
|
+
await snapshot();
|
|
81
|
+
await probe.write(handle.db);
|
|
82
|
+
const after = await probe.read(handle.db);
|
|
83
|
+
if (same(before, after)) return "the probe wrote nothing its read could see";
|
|
84
|
+
await restore();
|
|
85
|
+
const restored = await probe.read(handle.db);
|
|
86
|
+
return same(before, restored) ? "" : `after restore the probe read ${json(restored)}, not the snapshot's ${json(before)}`;
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
skipped.push(RESTORE_ROUND_TRIP);
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
skipped.push(RESTORE_REFUSED, RESTORE_ROUND_TRIP);
|
|
93
|
+
}
|
|
94
|
+
if (adapter.waitFor) {
|
|
95
|
+
const { waitFor } = adapter;
|
|
96
|
+
const kinds = adapter.waitKinds ?? [];
|
|
97
|
+
const kind = kinds[0];
|
|
98
|
+
ran(
|
|
99
|
+
WAIT_KINDS,
|
|
100
|
+
kind !== void 0,
|
|
101
|
+
kind === void 0 ? "waitFor() is there and waitKinds names nothing to ask it for" : ""
|
|
102
|
+
);
|
|
103
|
+
if (kind === void 0) {
|
|
104
|
+
skipped.push(WAIT_REJECTS, WAIT_RESOLVES, WAIT_REFUSES);
|
|
105
|
+
} else {
|
|
106
|
+
await attempt(WAIT_REJECTS, async () => {
|
|
107
|
+
const settled = await race(
|
|
108
|
+
waitFor(kind, () => false),
|
|
109
|
+
waitForMs
|
|
110
|
+
);
|
|
111
|
+
if (settled === "timeout") {
|
|
112
|
+
return `waitFor did not settle within ${waitForMs}ms \u2014 either it hangs, or its own deadline is longer than this suite's waitForMs`;
|
|
113
|
+
}
|
|
114
|
+
return settled === "rejected" ? "" : "waitFor resolved for a predicate that is never true";
|
|
115
|
+
});
|
|
116
|
+
await attempt(WAIT_RESOLVES, async () => {
|
|
117
|
+
let seen = 0;
|
|
118
|
+
const settled = await race(
|
|
119
|
+
waitFor(kind, () => {
|
|
120
|
+
seen += 1;
|
|
121
|
+
return seen > 1;
|
|
122
|
+
}),
|
|
123
|
+
waitForMs
|
|
124
|
+
);
|
|
125
|
+
if (settled === "timeout") return `waitFor did not settle within ${waitForMs}ms`;
|
|
126
|
+
return settled === "resolved" ? "" : "waitFor rejected for a predicate that came true";
|
|
127
|
+
});
|
|
128
|
+
await attempt(WAIT_REFUSES, async () => {
|
|
129
|
+
const settled = await race(
|
|
130
|
+
waitFor(UNKNOWN_KIND, () => true),
|
|
131
|
+
waitForMs
|
|
132
|
+
);
|
|
133
|
+
if (settled === "timeout") return `waitFor hung on "${UNKNOWN_KIND}" for ${waitForMs}ms`;
|
|
134
|
+
return settled === "rejected" ? "" : `waitFor accepted "${UNKNOWN_KIND}", a kind it never declared`;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
} else if ((adapter.waitKinds ?? []).length > 0) {
|
|
138
|
+
ran(WAIT_KINDS, false, "waitKinds names kinds and there is no waitFor() to ask");
|
|
139
|
+
skipped.push(WAIT_REJECTS, WAIT_RESOLVES, WAIT_REFUSES);
|
|
140
|
+
} else {
|
|
141
|
+
skipped.push(WAIT_KINDS, WAIT_REJECTS, WAIT_RESOLVES, WAIT_REFUSES);
|
|
142
|
+
}
|
|
143
|
+
await attempt(TEARDOWN, async () => {
|
|
144
|
+
await handle.teardown();
|
|
145
|
+
await handle.teardown();
|
|
146
|
+
return "";
|
|
147
|
+
});
|
|
148
|
+
await attempt(REBOOT, async () => {
|
|
149
|
+
const again = await adapter.boot(ctx);
|
|
150
|
+
await again.teardown();
|
|
151
|
+
return "";
|
|
152
|
+
});
|
|
153
|
+
return report(adapter.id, checks, skipped);
|
|
154
|
+
};
|
|
155
|
+
var report = (adapterId, checks, skipped) => ({
|
|
156
|
+
adapterId,
|
|
157
|
+
checks,
|
|
158
|
+
passed: checks.every((entry) => entry.ok),
|
|
159
|
+
skipped: [...skipped]
|
|
160
|
+
});
|
|
161
|
+
var json = (value) => JSON.stringify(value) ?? String(value);
|
|
162
|
+
var same = (left, right) => json(left) === json(right);
|
|
163
|
+
var race = async (promise, ms) => {
|
|
164
|
+
let timer;
|
|
165
|
+
const timeout = new Promise((done) => {
|
|
166
|
+
timer = setTimeout(() => done("timeout"), ms);
|
|
167
|
+
});
|
|
168
|
+
try {
|
|
169
|
+
return await Promise.race([
|
|
170
|
+
promise.then(
|
|
171
|
+
() => "resolved",
|
|
172
|
+
() => "rejected"
|
|
173
|
+
),
|
|
174
|
+
timeout
|
|
175
|
+
]);
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// src/define.ts
|
|
182
|
+
var LIFETIMES = ["file", "run", "suite"];
|
|
183
|
+
var defineTestbed = (config) => {
|
|
184
|
+
const { adapter, database, fixtures, network } = config;
|
|
185
|
+
if (adapter.id === "") throw new Error("a testbed adapter needs an id; every diagnostic names it");
|
|
186
|
+
if (typeof adapter.boot !== "function") {
|
|
187
|
+
throw new TypeError(`the adapter "${adapter.id}" has no boot()`);
|
|
188
|
+
}
|
|
189
|
+
if (Boolean(adapter.snapshot) !== Boolean(adapter.restore)) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`the adapter "${adapter.id}" declares ${adapter.snapshot ? "snapshot" : "restore"} without ${adapter.snapshot ? "restore" : "snapshot"} \u2014 half of per-test isolation is not isolation`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const kinds = adapter.waitKinds ?? [];
|
|
195
|
+
if (adapter.waitFor && kinds.length === 0) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`the adapter "${adapter.id}" has waitFor() but no waitKinds \u2014 a caller cannot ask for a kind nobody named`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (!adapter.waitFor && kinds.length > 0) {
|
|
201
|
+
throw new Error(`the adapter "${adapter.id}" declares waitKinds but has no waitFor() to call`);
|
|
202
|
+
}
|
|
203
|
+
if (!LIFETIMES.includes(database.per)) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`a database's "per" is one of ${LIFETIMES.join(", ")}; it was ${JSON.stringify(database.per)}`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (typeof network !== "string" && network.allow.length === 0) {
|
|
209
|
+
throw new Error('an allow list of nothing is `network: "none"` spelt so it reads as permission');
|
|
210
|
+
}
|
|
211
|
+
const fixture = fixturesOnly(fixtures);
|
|
212
|
+
const root = fixture();
|
|
213
|
+
const databaseNameFor = createDatabaseNamer(database.name);
|
|
214
|
+
return {
|
|
215
|
+
adapterId: adapter.id,
|
|
216
|
+
allowsHost: (host) => typeof network !== "string" && network.allow.includes(host),
|
|
217
|
+
boot: (key) => adapter.boot(
|
|
218
|
+
Object.freeze({
|
|
219
|
+
databaseName: databaseNameFor(key),
|
|
220
|
+
fixtures: root,
|
|
221
|
+
network: frozen(network)
|
|
222
|
+
})
|
|
223
|
+
),
|
|
224
|
+
databaseNameFor,
|
|
225
|
+
fixture,
|
|
226
|
+
per: database.per
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
var frozen = (network) => typeof network === "string" ? network : Object.freeze({ allow: Object.freeze([...network.allow]) });
|
|
230
|
+
export {
|
|
231
|
+
createDatabaseNamer,
|
|
232
|
+
defineTestbed,
|
|
233
|
+
fixturesOnly,
|
|
234
|
+
runAdapterConformance
|
|
235
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { T as TestbedAdapter } from '../types-D-uHas5h.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What `medusaIntegrationTestRunner` hands a suite, narrowed to what this adapter touches. Typed
|
|
5
|
+
* structurally rather than imported: `@medusajs/test-utils` is a PEER — a repo that runs Medusa
|
|
6
|
+
* already has it, and a repo that does not must not gain it by installing a testbed.
|
|
7
|
+
*/
|
|
8
|
+
type MedusaSuiteLike = {
|
|
9
|
+
dbConfig: {
|
|
10
|
+
clientUrl: string;
|
|
11
|
+
dbName: string;
|
|
12
|
+
schema: string;
|
|
13
|
+
};
|
|
14
|
+
dbConnection: unknown;
|
|
15
|
+
dbUtils: {
|
|
16
|
+
restore: (options?: {
|
|
17
|
+
templateName?: string;
|
|
18
|
+
}) => Promise<void>;
|
|
19
|
+
snapshot: (options?: {
|
|
20
|
+
templateName?: string;
|
|
21
|
+
}) => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
getContainer: () => unknown;
|
|
24
|
+
utils: {
|
|
25
|
+
waitWorkflowExecutions: () => Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
type MedusaRunnerLike = (config: {
|
|
29
|
+
cwd?: string;
|
|
30
|
+
env?: Record<string, string>;
|
|
31
|
+
moduleName?: string;
|
|
32
|
+
testSuite: (options: MedusaSuiteLike) => void;
|
|
33
|
+
}) => void;
|
|
34
|
+
type MedusaIntegrationTestOptions = {
|
|
35
|
+
/** Where the app is built. Medusa's own default, and dielime's. */
|
|
36
|
+
builtDir?: string;
|
|
37
|
+
/** dielime's `sha1(description).slice(0, 12)` unless a repo names its databases another way. */
|
|
38
|
+
databaseName?: (description: string) => string;
|
|
39
|
+
/** The runner's own `describe`; the engine does not name the suite it registers. */
|
|
40
|
+
describe: (name: string, body: () => void) => void;
|
|
41
|
+
/**
|
|
42
|
+
* Which environment variables carry the connection. Medusa DEFAULTS every one of them, so an
|
|
43
|
+
* unset variable is not an error — it is a silent connection to whatever is on 5432.
|
|
44
|
+
*/
|
|
45
|
+
connectionEnv?: readonly string[];
|
|
46
|
+
env?: Record<string, string>;
|
|
47
|
+
/** Optional: dielime's suites build their world through the booted app and have no fixtures. */
|
|
48
|
+
fixtures?: string;
|
|
49
|
+
/** `false` when a repo checks NODE_ENV itself. */
|
|
50
|
+
nodeEnv?: false | string;
|
|
51
|
+
requireBuilt?: boolean;
|
|
52
|
+
root: string;
|
|
53
|
+
runner: MedusaRunnerLike;
|
|
54
|
+
waitForMs?: number;
|
|
55
|
+
};
|
|
56
|
+
type MedusaIntegrationTest = {
|
|
57
|
+
adapterFor: (suite: MedusaSuiteLike) => TestbedAdapter<MedusaSuiteLike>;
|
|
58
|
+
/** Present only when the harness was given a fixtures directory to be contained by. */
|
|
59
|
+
fixture?: (...segments: string[]) => string;
|
|
60
|
+
runIntegrationTest: (description: string, suite: (options: MedusaSuiteLike) => void) => void;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* dielime's 26-line `runIntegrationTest`, generalised — plus the four refusals its comment
|
|
64
|
+
* describes and its code does not make.
|
|
65
|
+
*
|
|
66
|
+
* The engine is injected, never imported: `medusaIntegrationTestRunner` boots a real Medusa app
|
|
67
|
+
* against a real Postgres, and a package that reached for it could not be installed by a repo on
|
|
68
|
+
* another runtime, nor tested without a database.
|
|
69
|
+
*/
|
|
70
|
+
declare const createMedusaIntegrationTest: (options: MedusaIntegrationTestOptions) => MedusaIntegrationTest;
|
|
71
|
+
/**
|
|
72
|
+
* The suite options as a testbed adapter, so `runAdapterConformance` can judge them and so a suite
|
|
73
|
+
* can wait for the engine through the same call every other adapter answers.
|
|
74
|
+
*
|
|
75
|
+
* `boot` attaches rather than boots: the runner owns the application's lifecycle — it creates the
|
|
76
|
+
* database, migrates, snapshots to a TEMPLATE and restores from it before every test. What is
|
|
77
|
+
* missing from what it hands the suite is a guard on `restore`, and a way to wait that a test can
|
|
78
|
+
* reach before its assertion instead of after it.
|
|
79
|
+
*/
|
|
80
|
+
declare const medusaBuiltAdapter: (suite: MedusaSuiteLike, waitForMs?: number) => TestbedAdapter<MedusaSuiteLike>;
|
|
81
|
+
|
|
82
|
+
export { type MedusaIntegrationTest, type MedusaIntegrationTestOptions, type MedusaRunnerLike, type MedusaSuiteLike, createMedusaIntegrationTest, medusaBuiltAdapter };
|