@classytic/arc-testkit 0.5.0 → 0.7.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/README.md +52 -13
- package/dist/conformance.d.mts +439 -0
- package/dist/conformance.mjs +977 -0
- package/dist/index.d.mts +5 -8
- package/dist/index.mjs +1 -2
- package/dist/mongo-commands.d.mts +6 -8
- package/dist/mongo-commands.mjs +1 -2
- package/dist/mongo-explain.d.mts +57 -7
- package/dist/mongo-explain.mjs +72 -3
- package/dist/mongo.d.mts +122 -12
- package/dist/mongo.mjs +296 -10
- package/dist/pool.d.mts +13 -10
- package/dist/pool.mjs +27 -25
- package/dist/process-cleanup-1iBIS_o_.mjs +66 -0
- package/dist/story.d.mts +202 -0
- package/dist/story.mjs +330 -0
- package/llms.txt +33 -0
- package/package.json +26 -3
- package/skills/arc-testkit/SKILL.md +112 -0
- package/skills/arc-testkit/references/conformance.md +105 -0
- package/skills/arc-testkit/references/fixtures.md +104 -0
- package/skills/arc-testkit/references/list-filters.md +3 -0
- package/skills/arc-testkit/references/story.md +80 -0
package/dist/mongo.mjs
CHANGED
|
@@ -1,5 +1,286 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { t as registerProcessCleanup } from "./process-cleanup-1iBIS_o_.mjs";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { cpus, freemem, tmpdir, totalmem } from "node:os";
|
|
5
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises";
|
|
6
|
+
// src/mongo-tmp.ts
|
|
7
|
+
/** Prefix mongodb-memory-server gives its data dirs (and that ours use). */
|
|
8
|
+
const MONGO_MEM_DIR_PREFIX = "mongo-mem-";
|
|
9
|
+
/** Replica-set keyfile dirs: no lock file to prove them dead, so never swept. */
|
|
10
|
+
const KEYFILE_DIR_PREFIX = "mongo-mem-keyfile-";
|
|
11
|
+
const HOUR_MS = 36e5;
|
|
12
|
+
const DEFAULT_MAX_AGE_HOURS = 6;
|
|
13
|
+
/** The dedicated base dir from `TEST_MONGO_TMPDIR`, resolved, or undefined. */
|
|
14
|
+
function mongoMemoryTmpBase() {
|
|
15
|
+
const raw = process.env.TEST_MONGO_TMPDIR?.trim();
|
|
16
|
+
return raw ? resolve(raw) : void 0;
|
|
17
|
+
}
|
|
18
|
+
/** Minimum age (ms) of a dir the sweep may remove; `null` when the sweep is off. */
|
|
19
|
+
function staleMongoMemoryAgeMs() {
|
|
20
|
+
const raw = process.env.TEST_MONGO_SWEEP_HOURS?.trim().toLowerCase();
|
|
21
|
+
if (raw === "0" || raw === "off" || raw === "false") return null;
|
|
22
|
+
const hours = Number(raw);
|
|
23
|
+
return raw && Number.isFinite(hours) && hours > 0 ? hours * HOUR_MS : DEFAULT_MAX_AGE_HOURS * HOUR_MS;
|
|
24
|
+
}
|
|
25
|
+
/** Whether a process with this pid exists. EPERM means it exists but is not ours. */
|
|
26
|
+
function isPidAlive(pid) {
|
|
27
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
28
|
+
try {
|
|
29
|
+
process.kill(pid, 0);
|
|
30
|
+
return true;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return err.code === "EPERM";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** The pid in a data dir's `mongod.lock`. mongod empties it on a clean shutdown. */
|
|
36
|
+
async function lockPid(dir) {
|
|
37
|
+
try {
|
|
38
|
+
const pid = Number.parseInt((await readFile(join(dir, "mongod.lock"), "utf8")).trim(), 10);
|
|
39
|
+
return Number.isInteger(pid) && pid > 0 ? pid : void 0;
|
|
40
|
+
} catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Newest mtime of the dir and its direct entries. The dir's own mtime changes
|
|
46
|
+
* only when an entry is added or removed; a live mongod's activity shows in its
|
|
47
|
+
* files (WiredTiger.wt, sizeStorer.wt, the journal), so those count too.
|
|
48
|
+
*/
|
|
49
|
+
async function newestMtimeMs(dir) {
|
|
50
|
+
let newest = (await stat(dir)).mtimeMs;
|
|
51
|
+
let names = [];
|
|
52
|
+
try {
|
|
53
|
+
names = await readdir(dir);
|
|
54
|
+
} catch {}
|
|
55
|
+
for (const name of names) try {
|
|
56
|
+
const { mtimeMs } = await stat(join(dir, name));
|
|
57
|
+
if (mtimeMs > newest) newest = mtimeMs;
|
|
58
|
+
} catch {}
|
|
59
|
+
return newest;
|
|
60
|
+
}
|
|
61
|
+
function defaultSweepDirs() {
|
|
62
|
+
const base = mongoMemoryTmpBase();
|
|
63
|
+
return base ? [tmpdir(), base] : [tmpdir()];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Remove abandoned `mongo-mem-*` data dirs.
|
|
67
|
+
*
|
|
68
|
+
* A dir is removed only when BOTH hold: its `mongod.lock` names no live process
|
|
69
|
+
* (or is empty or missing), and nothing in it changed for `maxAgeMs`. Keyfile
|
|
70
|
+
* dirs and anything without the prefix are never touched. Errors are collected,
|
|
71
|
+
* never thrown: a sweep must not fail a test run.
|
|
72
|
+
*/
|
|
73
|
+
async function sweepStaleMongoMemoryDirs(options = {}) {
|
|
74
|
+
const result = {
|
|
75
|
+
removed: [],
|
|
76
|
+
kept: [],
|
|
77
|
+
failed: []
|
|
78
|
+
};
|
|
79
|
+
const maxAgeMs = options.maxAgeMs === void 0 ? staleMongoMemoryAgeMs() : options.maxAgeMs;
|
|
80
|
+
if (maxAgeMs === null) return result;
|
|
81
|
+
const now = options.now ?? Date.now();
|
|
82
|
+
const alive = options.isAlive ?? isPidAlive;
|
|
83
|
+
const bases = new Set((options.dirs ?? defaultSweepDirs()).map((d) => resolve(d)));
|
|
84
|
+
for (const base of bases) {
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = await readdir(base, { withFileTypes: true });
|
|
88
|
+
} catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (!entry.isDirectory()) continue;
|
|
93
|
+
if (!entry.name.startsWith("mongo-mem-")) continue;
|
|
94
|
+
if (entry.name.startsWith(KEYFILE_DIR_PREFIX)) continue;
|
|
95
|
+
const path = join(base, entry.name);
|
|
96
|
+
try {
|
|
97
|
+
const pid = await lockPid(path);
|
|
98
|
+
if (pid !== void 0 && alive(pid)) {
|
|
99
|
+
result.kept.push({
|
|
100
|
+
path,
|
|
101
|
+
reason: "live-process"
|
|
102
|
+
});
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (now - await newestMtimeMs(path) < maxAgeMs) {
|
|
106
|
+
result.kept.push({
|
|
107
|
+
path,
|
|
108
|
+
reason: "recent"
|
|
109
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
await rm(path, {
|
|
113
|
+
recursive: true,
|
|
114
|
+
force: true,
|
|
115
|
+
maxRetries: 2,
|
|
116
|
+
retryDelay: 100
|
|
117
|
+
});
|
|
118
|
+
result.removed.push(path);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
result.failed.push({
|
|
121
|
+
path,
|
|
122
|
+
error: err.message
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
let processSweep;
|
|
130
|
+
/**
|
|
131
|
+
* Run the sweep at most once per process, in the background. Started before a
|
|
132
|
+
* process boots its first server; never awaited there, because a first sweep
|
|
133
|
+
* after a long leak can have gigabytes to delete. Logs one line when it removed
|
|
134
|
+
* anything, so a machine that keeps leaking is noticed.
|
|
135
|
+
*/
|
|
136
|
+
function sweepStaleMongoMemoryDirsOnce(label = "arc-testkit") {
|
|
137
|
+
processSweep ??= sweepStaleMongoMemoryDirs().then((r) => {
|
|
138
|
+
if (r.removed.length > 0) console.warn(`[${label}] removed ${r.removed.length} abandoned mongo-mem-* temp dir(s) left by killed or crashed test runs`);
|
|
139
|
+
return r;
|
|
140
|
+
}, () => ({
|
|
141
|
+
removed: [],
|
|
142
|
+
kept: [],
|
|
143
|
+
failed: []
|
|
144
|
+
}));
|
|
145
|
+
return processSweep;
|
|
146
|
+
}
|
|
147
|
+
async function newDataDir(base) {
|
|
148
|
+
await mkdir(base, { recursive: true });
|
|
149
|
+
return mkdtemp(join(base, MONGO_MEM_DIR_PREFIX));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Point every member at a fresh dir under `base`, unless the caller set its own
|
|
153
|
+
* `dbPath` / `instanceOpts` (then the caller owns that location).
|
|
154
|
+
*/
|
|
155
|
+
async function withDedicatedDataDirs(kind, opts, base, owned) {
|
|
156
|
+
if (kind === "standalone") {
|
|
157
|
+
const instance = opts.instance ?? {};
|
|
158
|
+
if (instance.dbPath !== void 0) return opts;
|
|
159
|
+
const dbPath = await newDataDir(base);
|
|
160
|
+
owned.push(dbPath);
|
|
161
|
+
return {
|
|
162
|
+
...opts,
|
|
163
|
+
instance: {
|
|
164
|
+
...instance,
|
|
165
|
+
dbPath
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const existing = opts.instanceOpts;
|
|
170
|
+
if (existing && existing.length > 0) return opts;
|
|
171
|
+
const count = Number(opts.replSet?.count ?? 1);
|
|
172
|
+
const instanceOpts = [];
|
|
173
|
+
for (let i = 0; i < Math.max(1, count); i += 1) {
|
|
174
|
+
const dbPath = await newDataDir(base);
|
|
175
|
+
owned.push(dbPath);
|
|
176
|
+
instanceOpts.push({ dbPath });
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
...opts,
|
|
180
|
+
instanceOpts
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function instancesOf(server) {
|
|
184
|
+
const members = server.servers;
|
|
185
|
+
return Array.isArray(members) ? members : [server];
|
|
186
|
+
}
|
|
187
|
+
const sleepCell = new Int32Array(new SharedArrayBuffer(4));
|
|
188
|
+
/** Synchronous sleep, for the `exit` path where no timer fires. */
|
|
189
|
+
function sleepSync(ms) {
|
|
190
|
+
Atomics.wait(sleepCell, 0, 0, ms);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Remove a dir synchronously, retrying for up to `timeoutMs`. A killed mongod
|
|
194
|
+
* is reported gone before Windows releases its file handles, so the first
|
|
195
|
+
* attempts can fail with EPERM / EBUSY.
|
|
196
|
+
*/
|
|
197
|
+
function removeDirSync(dir, timeoutMs = 0) {
|
|
198
|
+
const deadline = Date.now() + timeoutMs;
|
|
199
|
+
for (;;) try {
|
|
200
|
+
rmSync(dir, {
|
|
201
|
+
recursive: true,
|
|
202
|
+
force: true
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
} catch {
|
|
206
|
+
if (Date.now() >= deadline) return;
|
|
207
|
+
sleepSync(50);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** Block until none of `pids` is alive, at most `timeoutMs`. */
|
|
211
|
+
function waitForExitSync(pids, timeoutMs) {
|
|
212
|
+
const deadline = Date.now() + timeoutMs;
|
|
213
|
+
while (pids.some(isPidAlive) && Date.now() < deadline) sleepSync(25);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Create and start a `MongoMemoryServer` (`standalone`) or `MongoMemoryReplSet`
|
|
217
|
+
* (`replset`) with the cleanup guarantees described at the top of this file.
|
|
218
|
+
*
|
|
219
|
+
* `Ctor` is the class itself, so a caller that loads mongodb-memory-server its
|
|
220
|
+
* own way (by resolved path, through an alias) keeps doing so.
|
|
221
|
+
*/
|
|
222
|
+
async function launchMongoMemory(Ctor, kind, createOptions = {}) {
|
|
223
|
+
const base = mongoMemoryTmpBase();
|
|
224
|
+
const ownedDirs = [];
|
|
225
|
+
const opts = base ? await withDedicatedDataDirs(kind, createOptions, base, ownedDirs) : createOptions;
|
|
226
|
+
const cleanup = {
|
|
227
|
+
doCleanup: true,
|
|
228
|
+
force: ownedDirs.length > 0
|
|
229
|
+
};
|
|
230
|
+
const server = new Ctor(opts);
|
|
231
|
+
try {
|
|
232
|
+
await server.start();
|
|
233
|
+
} catch (err) {
|
|
234
|
+
await server.stop(cleanup).catch(() => void 0);
|
|
235
|
+
for (const dir of ownedDirs) removeDirSync(dir, 2e3);
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
let stopped = false;
|
|
239
|
+
return {
|
|
240
|
+
server,
|
|
241
|
+
ownedDirs,
|
|
242
|
+
async stop() {
|
|
243
|
+
if (stopped) return;
|
|
244
|
+
stopped = true;
|
|
245
|
+
if (await server.stop(cleanup) !== false) for (const dir of ownedDirs) removeDirSync(dir);
|
|
246
|
+
},
|
|
247
|
+
stopSync() {
|
|
248
|
+
if (stopped) return;
|
|
249
|
+
stopped = true;
|
|
250
|
+
const dirs = new Set(ownedDirs);
|
|
251
|
+
const pids = [];
|
|
252
|
+
for (const member of instancesOf(server)) {
|
|
253
|
+
const info = member.instanceInfo;
|
|
254
|
+
const pid = info?.instance?.mongodProcess?.pid;
|
|
255
|
+
if (pid) {
|
|
256
|
+
pids.push(pid);
|
|
257
|
+
try {
|
|
258
|
+
process.kill(pid, "SIGKILL");
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
if (info?.tmpDir) dirs.add(info.tmpDir);
|
|
262
|
+
}
|
|
263
|
+
waitForExitSync(pids, 5e3);
|
|
264
|
+
for (const dir of dirs) removeDirSync(dir, 5e3);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Stop a launched server when the process ends without its teardown: Ctrl-C /
|
|
270
|
+
* SIGTERM stop it properly, then re-raise so the exit code stays truthful; an
|
|
271
|
+
* `exit` (including an uncaught exception) kills mongod and removes the dir
|
|
272
|
+
* synchronously. Returns an untrack function for the orderly path.
|
|
273
|
+
*
|
|
274
|
+
* Uses the package's ONE shared process hook, so tracking many servers adds no
|
|
275
|
+
* listeners beyond one per event.
|
|
276
|
+
*/
|
|
277
|
+
function stopOnProcessExit(handle) {
|
|
278
|
+
return registerProcessCleanup({
|
|
279
|
+
stop: () => handle.stop(),
|
|
280
|
+
stopSync: () => handle.stopSync()
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
// src/mongo.ts
|
|
3
284
|
/**
|
|
4
285
|
* `@classytic/arc-testkit/mongo` — the Mongo adapter for the driver-agnostic pool.
|
|
5
286
|
*
|
|
@@ -138,16 +419,22 @@ function mongoServerProvider(options = {}) {
|
|
|
138
419
|
* half-the-machine cache default exactly where nobody is watching.
|
|
139
420
|
*/
|
|
140
421
|
const create = opts?.createOptions;
|
|
141
|
-
|
|
422
|
+
sweepStaleMongoMemoryDirsOnce();
|
|
423
|
+
/**
|
|
424
|
+
* Started through `launchMongoMemory`, not `create()`: it always stops
|
|
425
|
+
* with cleanup, cleans up a failed start (mongodb-memory-server keeps
|
|
426
|
+
* that dir "for investigation"), honours `TEST_MONGO_TMPDIR`, and gives
|
|
427
|
+
* the pool a synchronous `stopSync` for its process `exit` handler.
|
|
428
|
+
*/
|
|
429
|
+
const launched = kind === "transactional" ? await launchMongoMemory(mms.MongoMemoryReplSet, "replset", create ?? { replSet: {
|
|
142
430
|
count: 1,
|
|
143
431
|
storageEngine: "wiredTiger",
|
|
144
432
|
args
|
|
145
|
-
} }) : await mms.MongoMemoryServer
|
|
433
|
+
} }) : await launchMongoMemory(mms.MongoMemoryServer, "standalone", create ?? { instance: { args } });
|
|
146
434
|
return {
|
|
147
|
-
uri: server.getUri(),
|
|
148
|
-
stop:
|
|
149
|
-
|
|
150
|
-
}
|
|
435
|
+
uri: launched.server.getUri(),
|
|
436
|
+
stop: () => launched.stop(),
|
|
437
|
+
stopSync: () => launched.stopSync()
|
|
151
438
|
};
|
|
152
439
|
},
|
|
153
440
|
/**
|
|
@@ -225,5 +512,4 @@ function mongoServerProvider(options = {}) {
|
|
|
225
512
|
}
|
|
226
513
|
};
|
|
227
514
|
}
|
|
228
|
-
|
|
229
|
-
export { awaitIndexBuilds, cacheSizeGB, mongoServerProvider, mongodMemoryArgs, safePoolSize, safeWorkerCount, withDbName };
|
|
515
|
+
export { MONGO_MEM_DIR_PREFIX, awaitIndexBuilds, cacheSizeGB, isPidAlive, launchMongoMemory, mongoMemoryTmpBase, mongoServerProvider, mongodMemoryArgs, safePoolSize, safeWorkerCount, staleMongoMemoryAgeMs, stopOnProcessExit, sweepStaleMongoMemoryDirs, withDbName };
|
package/dist/pool.d.mts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { TestDatabase, TestDatabaseFactory } from "@classytic/arc/testing";
|
|
2
|
-
//#region src/pool.d.ts
|
|
3
2
|
/** Which server shape a suite needs. */
|
|
4
|
-
type ServerKind = "standalone" | "transactional";
|
|
3
|
+
export type ServerKind = "standalone" | "transactional";
|
|
5
4
|
/** A running server the pool may hand out namespaces on. */
|
|
6
|
-
interface TestServerHandle {
|
|
5
|
+
export interface TestServerHandle {
|
|
7
6
|
/** Base connection string, WITHOUT a namespace selected. */
|
|
8
7
|
readonly uri: string;
|
|
9
8
|
stop(): Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* Optional: best-effort SYNCHRONOUS stop, for a process `exit` handler where
|
|
11
|
+
* no async work completes. Without it an `exit` only starts `stop()`, the
|
|
12
|
+
* process ends before it finishes, and the server's data dir is left behind.
|
|
13
|
+
*/
|
|
14
|
+
stopSync?(): void;
|
|
10
15
|
}
|
|
11
16
|
/**
|
|
12
17
|
* What a driver must supply. Two methods, deliberately.
|
|
@@ -15,7 +20,7 @@ interface TestServerHandle {
|
|
|
15
20
|
* is the same in every driver, so putting it here would duplicate it per driver
|
|
16
21
|
* — which is precisely the failure this module exists to end.
|
|
17
22
|
*/
|
|
18
|
-
interface TestServerProvider<TConn> {
|
|
23
|
+
export interface TestServerProvider<TConn> {
|
|
19
24
|
/** Human name, used only in diagnostics. */
|
|
20
25
|
readonly name: string;
|
|
21
26
|
/**
|
|
@@ -67,7 +72,7 @@ interface TestServerProvider<TConn> {
|
|
|
67
72
|
*/
|
|
68
73
|
afterBoot?(): Promise<void>;
|
|
69
74
|
}
|
|
70
|
-
interface TestDatabasePoolOptions {
|
|
75
|
+
export interface TestDatabasePoolOptions {
|
|
71
76
|
/**
|
|
72
77
|
* Env-var prefix for the cross-worker handshake. Two runs with different
|
|
73
78
|
* prefixes (a spine run and a host run) can share a machine without either
|
|
@@ -145,7 +150,7 @@ interface TestDatabasePoolOptions {
|
|
|
145
150
|
/** Escape hatch: force per-suite private servers (the unpooled behaviour). */
|
|
146
151
|
readonly disabled?: boolean;
|
|
147
152
|
}
|
|
148
|
-
interface TestDatabasePool<TConn> {
|
|
153
|
+
export interface TestDatabasePool<TConn> {
|
|
149
154
|
/** Hand to `bootModuleApp({ database })`. */
|
|
150
155
|
readonly factory: TestDatabaseFactory<TConn>;
|
|
151
156
|
/** Wrap a boot function so it defaults to the pool. */
|
|
@@ -178,13 +183,11 @@ interface TestDatabasePool<TConn> {
|
|
|
178
183
|
live: number;
|
|
179
184
|
};
|
|
180
185
|
}
|
|
181
|
-
declare function createTestDatabasePool<TConn>(provider: TestServerProvider<TConn>, options?: TestDatabasePoolOptions): TestDatabasePool<TConn>;
|
|
186
|
+
export declare function createTestDatabasePool<TConn>(provider: TestServerProvider<TConn>, options?: TestDatabasePoolOptions): TestDatabasePool<TConn>;
|
|
182
187
|
/**
|
|
183
188
|
* Publish a started server so other worker PROCESSES reuse it.
|
|
184
189
|
*
|
|
185
190
|
* Only a process that owns servers calls this — typically an eager
|
|
186
191
|
* `globalSetup` in a run that prefers boot-cost-up-front over laziness.
|
|
187
192
|
*/
|
|
188
|
-
declare function publishPoolServers(servers: Partial<Record<ServerKind, readonly string[]>>, envPrefix?: string): void;
|
|
189
|
-
//#endregion
|
|
190
|
-
export { ServerKind, TestDatabasePool, TestDatabasePoolOptions, TestServerHandle, TestServerProvider, createTestDatabasePool, publishPoolServers };
|
|
193
|
+
export declare function publishPoolServers(servers: Partial<Record<ServerKind, readonly string[]>>, envPrefix?: string): void;
|
package/dist/pool.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { n as unregisterProcessCleanup, t as registerProcessCleanup } from "./process-cleanup-1iBIS_o_.mjs";
|
|
2
2
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
5
|
-
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
// src/pool.ts
|
|
6
7
|
/**
|
|
7
8
|
* `@classytic/arc-testkit/pool` — a POOLED `TestDatabaseFactory`, driver-agnostic.
|
|
8
9
|
*
|
|
@@ -207,35 +208,37 @@ function createTestDatabasePool(provider, options = {}) {
|
|
|
207
208
|
* started on a machine with less memory than the one before.
|
|
208
209
|
*
|
|
209
210
|
* `exit` handlers must be SYNCHRONOUS, so the async `stop()` is not awaited
|
|
210
|
-
* there;
|
|
211
|
-
*
|
|
211
|
+
* there; a handle with `stopSync()` (the Mongo adapter's) is killed and its
|
|
212
|
+
* data dir removed synchronously, any other gets a best-effort `stop()`.
|
|
213
|
+
* The signal handlers get to await properly, which is the path that actually
|
|
214
|
+
* matters for Ctrl-C.
|
|
215
|
+
*/
|
|
216
|
+
const processCleanup = {
|
|
217
|
+
stop: () => stopAll(),
|
|
218
|
+
stopSync: () => {
|
|
219
|
+
const handles = [...started.values(), ...privateHandles];
|
|
220
|
+
started.clear();
|
|
221
|
+
starting.clear();
|
|
222
|
+
privateHandles.clear();
|
|
223
|
+
for (const handle of handles) if (handle.stopSync) handle.stopSync();
|
|
224
|
+
else handle.stop();
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* Registered with the package's ONE shared process hook rather than adding
|
|
229
|
+
* listeners per pool: a suite that builds many pools would otherwise pass
|
|
230
|
+
* Node's listener limit (`MaxListenersExceededWarning`). Unregistered by
|
|
231
|
+
* `stopAll()`, re-registered on the next server this pool owns.
|
|
212
232
|
*/
|
|
213
|
-
let exitHooked = false;
|
|
214
233
|
function hookProcessExit() {
|
|
215
|
-
|
|
216
|
-
exitHooked = true;
|
|
217
|
-
const onSignal = (signal) => {
|
|
218
|
-
stopAll().finally(() => {
|
|
219
|
-
process.removeListener(signal, onSignal);
|
|
220
|
-
process.kill(process.pid, signal);
|
|
221
|
-
});
|
|
222
|
-
};
|
|
223
|
-
for (const signal of [
|
|
224
|
-
"SIGINT",
|
|
225
|
-
"SIGTERM",
|
|
226
|
-
"SIGHUP",
|
|
227
|
-
"SIGBREAK"
|
|
228
|
-
]) process.once(signal, onSignal);
|
|
229
|
-
process.once("exit", () => {
|
|
230
|
-
for (const handle of started.values()) handle.stop();
|
|
231
|
-
for (const handle of privateHandles) handle.stop();
|
|
232
|
-
});
|
|
234
|
+
registerProcessCleanup(processCleanup);
|
|
233
235
|
}
|
|
234
236
|
async function stopAll() {
|
|
235
237
|
const handles = [...started.values(), ...privateHandles];
|
|
236
238
|
started.clear();
|
|
237
239
|
starting.clear();
|
|
238
240
|
privateHandles.clear();
|
|
241
|
+
unregisterProcessCleanup(processCleanup);
|
|
239
242
|
await Promise.all(handles.map((h) => h.stop().catch(() => void 0)));
|
|
240
243
|
}
|
|
241
244
|
const factory = async (opts) => {
|
|
@@ -406,5 +409,4 @@ function publishPoolServers(servers, envPrefix = "ARC_TESTKIT") {
|
|
|
406
409
|
}
|
|
407
410
|
writeFileSync(file, JSON.stringify(servers));
|
|
408
411
|
}
|
|
409
|
-
//#endregion
|
|
410
412
|
export { createTestDatabasePool, publishPoolServers };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/process-cleanup.ts
|
|
2
|
+
/** Signals that end a test run; SIGBREAK is Windows Ctrl-Break. */
|
|
3
|
+
const CLEANUP_SIGNALS = [
|
|
4
|
+
"SIGINT",
|
|
5
|
+
"SIGTERM",
|
|
6
|
+
"SIGHUP",
|
|
7
|
+
"SIGBREAK"
|
|
8
|
+
];
|
|
9
|
+
const STATE_KEY = Symbol.for("@classytic/arc-testkit/process-cleanup");
|
|
10
|
+
function state() {
|
|
11
|
+
const g = globalThis;
|
|
12
|
+
const existing = g[STATE_KEY];
|
|
13
|
+
if (existing) return existing;
|
|
14
|
+
const entries = /* @__PURE__ */ new Set();
|
|
15
|
+
const created = {
|
|
16
|
+
entries,
|
|
17
|
+
hooked: false,
|
|
18
|
+
onSignal: (signal) => {
|
|
19
|
+
const all = [...entries];
|
|
20
|
+
entries.clear();
|
|
21
|
+
unhook(created);
|
|
22
|
+
Promise.all(all.map((e) => e.stop().catch(() => void 0))).finally(() => {
|
|
23
|
+
process.kill(process.pid, signal);
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
onExit: () => {
|
|
27
|
+
const all = [...entries];
|
|
28
|
+
entries.clear();
|
|
29
|
+
unhook(created);
|
|
30
|
+
for (const e of all) try {
|
|
31
|
+
e.stopSync();
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
g[STATE_KEY] = created;
|
|
36
|
+
return created;
|
|
37
|
+
}
|
|
38
|
+
function hook(s) {
|
|
39
|
+
if (s.hooked) return;
|
|
40
|
+
s.hooked = true;
|
|
41
|
+
for (const signal of CLEANUP_SIGNALS) process.on(signal, s.onSignal);
|
|
42
|
+
process.on("exit", s.onExit);
|
|
43
|
+
}
|
|
44
|
+
function unhook(s) {
|
|
45
|
+
if (!s.hooked) return;
|
|
46
|
+
s.hooked = false;
|
|
47
|
+
for (const signal of CLEANUP_SIGNALS) process.removeListener(signal, s.onSignal);
|
|
48
|
+
process.removeListener("exit", s.onExit);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Stop `entry` if the process ends before it is unregistered. Registering the
|
|
52
|
+
* same entry twice is a no-op. Returns the unregister function for the
|
|
53
|
+
* orderly path; once nothing is registered the process listeners are removed.
|
|
54
|
+
*/
|
|
55
|
+
function registerProcessCleanup(entry) {
|
|
56
|
+
const s = state();
|
|
57
|
+
s.entries.add(entry);
|
|
58
|
+
hook(s);
|
|
59
|
+
return () => unregisterProcessCleanup(entry);
|
|
60
|
+
}
|
|
61
|
+
function unregisterProcessCleanup(entry) {
|
|
62
|
+
const s = state();
|
|
63
|
+
s.entries.delete(entry);
|
|
64
|
+
if (s.entries.size === 0) unhook(s);
|
|
65
|
+
}
|
|
66
|
+
export { unregisterProcessCleanup as n, registerProcessCleanup as t };
|