@elench/testkit 0.1.46 → 0.1.47
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 +24 -6
- package/lib/cli/args.mjs +25 -4
- package/lib/cli/args.test.mjs +32 -0
- package/lib/cli/db.mjs +115 -0
- package/lib/cli/index.mjs +23 -1
- package/lib/cli/known-failures.mjs +164 -0
- package/lib/config/index.mjs +155 -28
- package/lib/database/fingerprint.mjs +9 -5
- package/lib/database/fingerprint.test.mjs +10 -4
- package/lib/database/index.mjs +34 -11
- package/lib/database/template-steps.mjs +232 -0
- package/lib/runner/runtime-contexts.mjs +2 -41
- package/lib/runner/template.mjs +30 -24
- package/lib/runner/template.test.mjs +1 -2
- package/lib/setup/index.d.ts +37 -7
- package/lib/setup/index.mjs +21 -3
- package/package.json +1 -1
package/lib/config/index.mjs
CHANGED
|
@@ -119,9 +119,12 @@ function normalizeServiceConfig({
|
|
|
119
119
|
...loadServiceEnv(productDir, envFiles),
|
|
120
120
|
...(explicitService.env || {}),
|
|
121
121
|
};
|
|
122
|
+
if (explicitService.migrate || explicitService.seed) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Service "${name}" uses removed migrate/seed hooks. Move template lifecycle to database.template.{migrate,seed,verify}.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
122
127
|
const database = normalizeDatabaseConfig(explicitService, name);
|
|
123
|
-
const migrate = normalizeLifecycle(explicitService.migrate);
|
|
124
|
-
const seed = normalizeLifecycle(explicitService.seed);
|
|
125
128
|
const runtime = normalizeRuntimeConfig(explicitService.runtime, name);
|
|
126
129
|
const skip = normalizeSkipConfig(explicitService.skip, {
|
|
127
130
|
name,
|
|
@@ -133,20 +136,12 @@ function normalizeServiceConfig({
|
|
|
133
136
|
suites,
|
|
134
137
|
});
|
|
135
138
|
|
|
136
|
-
if (!explicitService.databaseFrom && !database && (migrate || seed)) {
|
|
137
|
-
throw new Error(
|
|
138
|
-
`Service "${name}" defines migrate/seed hooks but no database. Add localDatabase(...) in testkit.setup.ts.`
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
139
|
validateServiceConfig({
|
|
143
140
|
name,
|
|
144
141
|
local,
|
|
145
142
|
database,
|
|
146
143
|
databaseFrom: explicitService.databaseFrom,
|
|
147
144
|
runtime,
|
|
148
|
-
migrate,
|
|
149
|
-
seed,
|
|
150
145
|
dependsOn: explicitService.dependsOn || [],
|
|
151
146
|
suites,
|
|
152
147
|
productDir,
|
|
@@ -168,8 +163,6 @@ function normalizeServiceConfig({
|
|
|
168
163
|
envFiles,
|
|
169
164
|
requirements,
|
|
170
165
|
serviceEnv,
|
|
171
|
-
migrate,
|
|
172
|
-
seed,
|
|
173
166
|
skip,
|
|
174
167
|
runtime,
|
|
175
168
|
local,
|
|
@@ -276,9 +269,7 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
|
|
|
276
269
|
image: database.image || DEFAULT_LOCAL_IMAGE,
|
|
277
270
|
user: database.user || DEFAULT_LOCAL_USER,
|
|
278
271
|
password: database.password || DEFAULT_LOCAL_PASSWORD,
|
|
279
|
-
template:
|
|
280
|
-
inputs: Array.isArray(database.template?.inputs) ? [...database.template.inputs] : [],
|
|
281
|
-
},
|
|
272
|
+
template: normalizeDatabaseTemplateConfig(database.template, serviceName),
|
|
282
273
|
serviceName,
|
|
283
274
|
};
|
|
284
275
|
}
|
|
@@ -309,18 +300,117 @@ function normalizeOptionalString(value) {
|
|
|
309
300
|
return normalized.length > 0 ? normalized : null;
|
|
310
301
|
}
|
|
311
302
|
|
|
312
|
-
function
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
303
|
+
function normalizeDatabaseTemplateConfig(value, serviceName) {
|
|
304
|
+
if (value == null) {
|
|
305
|
+
return {
|
|
306
|
+
inputs: [],
|
|
307
|
+
migrate: [],
|
|
308
|
+
seed: [],
|
|
309
|
+
verify: [],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (!value || typeof value !== "object") {
|
|
313
|
+
throw new Error(`Service "${serviceName}" database.template must be an object`);
|
|
316
314
|
}
|
|
317
315
|
|
|
318
316
|
return {
|
|
319
|
-
|
|
320
|
-
|
|
317
|
+
inputs: normalizeTemplateInputs(value.inputs, serviceName),
|
|
318
|
+
migrate: normalizeTemplateLifecycleSteps(
|
|
319
|
+
value.migrate,
|
|
320
|
+
`Service "${serviceName}" database.template.migrate`
|
|
321
|
+
),
|
|
322
|
+
seed: normalizeTemplateLifecycleSteps(
|
|
323
|
+
value.seed,
|
|
324
|
+
`Service "${serviceName}" database.template.seed`
|
|
325
|
+
),
|
|
326
|
+
verify: normalizeTemplateLifecycleSteps(
|
|
327
|
+
value.verify,
|
|
328
|
+
`Service "${serviceName}" database.template.verify`
|
|
329
|
+
),
|
|
321
330
|
};
|
|
322
331
|
}
|
|
323
332
|
|
|
333
|
+
function normalizeTemplateInputs(value, serviceName) {
|
|
334
|
+
if (value == null) return [];
|
|
335
|
+
if (!Array.isArray(value)) {
|
|
336
|
+
throw new Error(`Service "${serviceName}" database.template.inputs must be an array`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return value.map((entry, index) => {
|
|
340
|
+
const normalized = normalizeOptionalString(entry);
|
|
341
|
+
if (!normalized) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`Service "${serviceName}" database.template.inputs[${index}] must be a non-empty string`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return normalized;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function normalizeTemplateLifecycleSteps(value, label) {
|
|
351
|
+
if (value == null) return [];
|
|
352
|
+
if (!Array.isArray(value)) {
|
|
353
|
+
throw new Error(`${label} must be an array`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return value.map((step, index) => normalizeTemplateLifecycleStep(step, `${label}[${index}]`));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function normalizeTemplateLifecycleStep(step, label) {
|
|
360
|
+
if (!step || typeof step !== "object") {
|
|
361
|
+
throw new Error(`${label} must be an object`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const kind = normalizeOptionalString(step.kind);
|
|
365
|
+
if (kind === "command") {
|
|
366
|
+
const cmd = normalizeOptionalString(step.cmd);
|
|
367
|
+
if (!cmd) throw new Error(`${label}.cmd must be a non-empty string`);
|
|
368
|
+
return {
|
|
369
|
+
kind,
|
|
370
|
+
cmd,
|
|
371
|
+
cwd: normalizeOptionalString(step.cwd),
|
|
372
|
+
inputs: normalizeTemplateStepInputs(step.inputs, label),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
if (kind === "sql-file") {
|
|
376
|
+
const filePath = normalizeOptionalString(step.path);
|
|
377
|
+
if (!filePath) throw new Error(`${label}.path must be a non-empty string`);
|
|
378
|
+
return {
|
|
379
|
+
kind,
|
|
380
|
+
path: filePath,
|
|
381
|
+
cwd: normalizeOptionalString(step.cwd),
|
|
382
|
+
inputs: normalizeTemplateStepInputs(step.inputs, label),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
if (kind === "module") {
|
|
386
|
+
const specifier = normalizeOptionalString(step.specifier);
|
|
387
|
+
if (!specifier) throw new Error(`${label}.specifier must be a non-empty string`);
|
|
388
|
+
return {
|
|
389
|
+
kind,
|
|
390
|
+
specifier,
|
|
391
|
+
cwd: normalizeOptionalString(step.cwd),
|
|
392
|
+
inputs: normalizeTemplateStepInputs(step.inputs, label),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
throw new Error(`${label}.kind must be one of: command, sql-file, module`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function normalizeTemplateStepInputs(value, label) {
|
|
400
|
+
if (value == null) return [];
|
|
401
|
+
if (!Array.isArray(value)) {
|
|
402
|
+
throw new Error(`${label}.inputs must be an array`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return value.map((entry, index) => {
|
|
406
|
+
const normalized = normalizeOptionalString(entry);
|
|
407
|
+
if (!normalized) {
|
|
408
|
+
throw new Error(`${label}.inputs[${index}] must be a non-empty string`);
|
|
409
|
+
}
|
|
410
|
+
return normalized;
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
324
414
|
function normalizeSkipConfig(value, { name, productDir, suites }) {
|
|
325
415
|
if (!value) return undefined;
|
|
326
416
|
|
|
@@ -587,8 +677,6 @@ function validateServiceConfig({
|
|
|
587
677
|
database,
|
|
588
678
|
databaseFrom,
|
|
589
679
|
runtime,
|
|
590
|
-
migrate,
|
|
591
|
-
seed,
|
|
592
680
|
dependsOn,
|
|
593
681
|
suites,
|
|
594
682
|
productDir,
|
|
@@ -626,11 +714,42 @@ function validateServiceConfig({
|
|
|
626
714
|
if (local?.cwd) {
|
|
627
715
|
ensureExistingPath(productDir, local.cwd, `Service "${name}" local.cwd`);
|
|
628
716
|
}
|
|
629
|
-
|
|
630
|
-
|
|
717
|
+
for (const [stageName, steps] of Object.entries(database?.template || {})) {
|
|
718
|
+
if (stageName === "inputs") continue;
|
|
719
|
+
for (const step of steps || []) {
|
|
720
|
+
if (step.cwd) {
|
|
721
|
+
ensureExistingPath(
|
|
722
|
+
productDir,
|
|
723
|
+
step.cwd,
|
|
724
|
+
`Service "${name}" database.template.${stageName} step cwd`
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
if (step.kind === "sql-file") {
|
|
728
|
+
ensureExistingPath(
|
|
729
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
730
|
+
step.path,
|
|
731
|
+
`Service "${name}" database.template.${stageName} sql file`
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
if (step.kind === "module") {
|
|
735
|
+
const { modulePath } = parseModuleSpecifier(step.specifier);
|
|
736
|
+
ensureExistingPath(
|
|
737
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
738
|
+
modulePath,
|
|
739
|
+
`Service "${name}" database.template.${stageName} module`
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
for (const input of step.inputs || []) {
|
|
743
|
+
ensureExistingPath(
|
|
744
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
745
|
+
input,
|
|
746
|
+
`Service "${name}" database.template.${stageName} step input`
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
631
750
|
}
|
|
632
|
-
|
|
633
|
-
ensureExistingPath(productDir,
|
|
751
|
+
for (const input of database?.template?.inputs || []) {
|
|
752
|
+
ensureExistingPath(productDir, input, `Service "${name}" database.template input`);
|
|
634
753
|
}
|
|
635
754
|
}
|
|
636
755
|
|
|
@@ -687,7 +806,7 @@ function normalizePath(value) {
|
|
|
687
806
|
.replace(/^\.\/+/, "");
|
|
688
807
|
}
|
|
689
808
|
|
|
690
|
-
function resolveProductDir(cwd, explicitDir) {
|
|
809
|
+
export function resolveProductDir(cwd, explicitDir) {
|
|
691
810
|
const dir = explicitDir ? path.resolve(cwd, explicitDir) : cwd;
|
|
692
811
|
if (!fs.existsSync(dir)) {
|
|
693
812
|
throw new Error(`Product directory does not exist: ${dir}`);
|
|
@@ -722,3 +841,11 @@ function detectNextApp(cwd) {
|
|
|
722
841
|
fs.existsSync(path.join(cwd, "next.config.ts"))
|
|
723
842
|
);
|
|
724
843
|
}
|
|
844
|
+
|
|
845
|
+
function parseModuleSpecifier(specifier) {
|
|
846
|
+
const [modulePath, exportName] = String(specifier).split("#", 2);
|
|
847
|
+
return {
|
|
848
|
+
modulePath,
|
|
849
|
+
exportName: exportName || "default",
|
|
850
|
+
};
|
|
851
|
+
}
|
|
@@ -2,6 +2,7 @@ import crypto from "crypto";
|
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { resolveServiceCwd } from "../config/index.mjs";
|
|
5
|
+
import { collectTemplateInputs } from "./template-steps.mjs";
|
|
5
6
|
|
|
6
7
|
const LOCAL_IMAGE = "pgvector/pgvector:pg16";
|
|
7
8
|
const LOCAL_USER = "testkit";
|
|
@@ -14,15 +15,14 @@ export async function computeTemplateFingerprint(config) {
|
|
|
14
15
|
selectedBackend: db.selectedBackend,
|
|
15
16
|
image: db.image || LOCAL_IMAGE,
|
|
16
17
|
user: db.user || LOCAL_USER,
|
|
17
|
-
|
|
18
|
-
seed: config.testkit.seed || null,
|
|
18
|
+
template: db.template || null,
|
|
19
19
|
}));
|
|
20
20
|
|
|
21
21
|
for (const envFile of config.testkit.envFiles || []) {
|
|
22
22
|
appendFileToHash(hash, config.productDir, resolveServiceCwd(config.productDir, envFile));
|
|
23
23
|
}
|
|
24
|
-
for (const input of db.template
|
|
25
|
-
|
|
24
|
+
for (const input of collectTemplateInputs(config.productDir, db.template || {})) {
|
|
25
|
+
appendResolvedInputToHash(hash, config.productDir, input);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
return hash.digest("hex");
|
|
@@ -30,6 +30,10 @@ export async function computeTemplateFingerprint(config) {
|
|
|
30
30
|
|
|
31
31
|
export function appendInputToHash(hash, productDir, input) {
|
|
32
32
|
const absPath = resolveServiceCwd(productDir, input);
|
|
33
|
+
appendResolvedInputToHash(hash, productDir, absPath);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function appendResolvedInputToHash(hash, productDir, absPath) {
|
|
33
37
|
if (!fs.existsSync(absPath)) {
|
|
34
38
|
hash.update(`missing:${path.relative(productDir, absPath)}`);
|
|
35
39
|
return;
|
|
@@ -40,7 +44,7 @@ export function appendInputToHash(hash, productDir, input) {
|
|
|
40
44
|
hash.update(`dir:${path.relative(productDir, absPath)}`);
|
|
41
45
|
for (const entry of fs.readdirSync(absPath).sort()) {
|
|
42
46
|
if (entry === ".git" || entry === "node_modules" || entry === ".testkit") continue;
|
|
43
|
-
|
|
47
|
+
appendResolvedInputToHash(hash, productDir, path.join(absPath, entry));
|
|
44
48
|
}
|
|
45
49
|
return;
|
|
46
50
|
}
|
|
@@ -74,13 +74,19 @@ describe("database-fingerprint", () => {
|
|
|
74
74
|
selectedBackend: "local",
|
|
75
75
|
template: {
|
|
76
76
|
inputs: ["schema"],
|
|
77
|
+
migrate: [
|
|
78
|
+
{
|
|
79
|
+
kind: "command",
|
|
80
|
+
cmd: "npm run migrate",
|
|
81
|
+
cwd: ".",
|
|
82
|
+
inputs: [],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
seed: [],
|
|
86
|
+
verify: [],
|
|
77
87
|
},
|
|
78
88
|
},
|
|
79
89
|
envFiles: [".env.testkit"],
|
|
80
|
-
migrate: {
|
|
81
|
-
cmd: "npm run migrate",
|
|
82
|
-
},
|
|
83
|
-
seed: null,
|
|
84
90
|
},
|
|
85
91
|
};
|
|
86
92
|
|
package/lib/database/index.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
readStateValue as readStateValueModel,
|
|
26
26
|
visitDirs as visitDirsModel,
|
|
27
27
|
} from "./state.mjs";
|
|
28
|
+
import { captureTemplateSnapshot, runTemplateStage } from "./template-steps.mjs";
|
|
28
29
|
|
|
29
30
|
const LOCAL_IMAGE = "pgvector/pgvector:pg16";
|
|
30
31
|
const LOCAL_USER = "testkit";
|
|
@@ -33,19 +34,39 @@ const LOCAL_ADMIN_DB = "postgres";
|
|
|
33
34
|
const LOCAL_READY_TIMEOUT_MS = 60_000;
|
|
34
35
|
const LOCAL_POLL_INTERVAL_MS = 1_000;
|
|
35
36
|
|
|
36
|
-
export async function prepareDatabaseRuntime(config
|
|
37
|
+
export async function prepareDatabaseRuntime(config) {
|
|
37
38
|
const db = config.testkit.database;
|
|
38
39
|
if (!db) return;
|
|
39
40
|
|
|
40
41
|
fs.mkdirSync(config.stateDir, { recursive: true });
|
|
41
42
|
if (db.provider === "local") {
|
|
42
|
-
await prepareLocalDatabase(config
|
|
43
|
+
await prepareLocalDatabase(config);
|
|
43
44
|
return;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
throw new Error(`Unsupported database provider "${db.provider}"`);
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
export async function captureDatabaseTemplateSnapshot(config, outputPath) {
|
|
51
|
+
if (!config.testkit.database || config.testkit.database.provider !== "local") {
|
|
52
|
+
throw new Error(`Service "${config.name}" does not use a local testkit database`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await prepareDatabaseRuntime(config);
|
|
56
|
+
const cacheDir = getLocalServiceCacheDir(config.productDir, config.name);
|
|
57
|
+
const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
|
|
58
|
+
if (!templateDbName) {
|
|
59
|
+
throw new Error(`Missing template database for service "${config.name}"`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const infra = await loadExistingLocalContainer(config.productDir);
|
|
63
|
+
if (!infra) {
|
|
64
|
+
throw new Error(`Missing local database container for service "${config.name}"`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return captureTemplateSnapshot(config, outputPath, buildDatabaseUrl(infra, templateDbName));
|
|
68
|
+
}
|
|
69
|
+
|
|
49
70
|
export async function destroyRuntimeDatabase({ productDir, stateDir }) {
|
|
50
71
|
const backend = readStateValue(path.join(stateDir, "database_backend"));
|
|
51
72
|
if (backend === "local") {
|
|
@@ -115,7 +136,7 @@ export function showServiceDatabaseStatus(productDir, serviceName) {
|
|
|
115
136
|
return true;
|
|
116
137
|
}
|
|
117
138
|
|
|
118
|
-
async function prepareLocalDatabase(config
|
|
139
|
+
async function prepareLocalDatabase(config) {
|
|
119
140
|
const db = config.testkit.database;
|
|
120
141
|
const productDir = config.productDir;
|
|
121
142
|
const serviceName = config.name;
|
|
@@ -131,7 +152,7 @@ async function prepareLocalDatabase(config, hooks) {
|
|
|
131
152
|
);
|
|
132
153
|
|
|
133
154
|
await withLock(path.join(lockDir, `template-${serviceName}.lock`), async () => {
|
|
134
|
-
await ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint
|
|
155
|
+
await ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint);
|
|
135
156
|
});
|
|
136
157
|
|
|
137
158
|
await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(bindingKey, 10)}.lock`), async () => {
|
|
@@ -139,7 +160,7 @@ async function prepareLocalDatabase(config, hooks) {
|
|
|
139
160
|
});
|
|
140
161
|
}
|
|
141
162
|
|
|
142
|
-
async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint
|
|
163
|
+
async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint) {
|
|
143
164
|
const serviceName = config.name;
|
|
144
165
|
const existingFingerprint = readStateValue(path.join(cacheDir, "template_fingerprint"));
|
|
145
166
|
const existingDbName = readStateValue(path.join(cacheDir, "template_database_name"));
|
|
@@ -161,13 +182,15 @@ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerpri
|
|
|
161
182
|
await dropDatabaseIfExists(infra, desiredDbName);
|
|
162
183
|
}
|
|
163
184
|
|
|
164
|
-
await createEmptyDatabase(infra, desiredDbName);
|
|
165
185
|
const templateUrl = buildDatabaseUrl(infra, desiredDbName);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
await
|
|
186
|
+
await createEmptyDatabase(infra, desiredDbName);
|
|
187
|
+
try {
|
|
188
|
+
await runTemplateStage(config, "migrate", templateUrl);
|
|
189
|
+
await runTemplateStage(config, "seed", templateUrl);
|
|
190
|
+
await runTemplateStage(config, "verify", templateUrl);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
await dropDatabaseIfExists(infra, desiredDbName);
|
|
193
|
+
throw error;
|
|
171
194
|
}
|
|
172
195
|
|
|
173
196
|
writeLocalCacheState(cacheDir, infra, desiredDbName, templateFingerprint);
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { build } from "esbuild";
|
|
5
|
+
import { execa, execaCommand } from "execa";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
7
|
+
import { resolveServiceCwd } from "../config/index.mjs";
|
|
8
|
+
import { buildExecutionEnv } from "../runner/template.mjs";
|
|
9
|
+
|
|
10
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
11
|
+
const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
|
|
12
|
+
const SETUP_ENTRY = path.join(PACKAGE_ROOT, "lib", "setup", "index.mjs");
|
|
13
|
+
const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
|
|
14
|
+
const KNOWN_FAILURES_ENTRY = path.join(PACKAGE_ROOT, "lib", "known-failures", "index.mjs");
|
|
15
|
+
|
|
16
|
+
export async function runTemplateStage(config, stageName, databaseUrl) {
|
|
17
|
+
const steps = config.testkit.database?.template?.[stageName] || [];
|
|
18
|
+
if (steps.length === 0) return;
|
|
19
|
+
|
|
20
|
+
const env = {
|
|
21
|
+
...buildExecutionEnv(config, {}, process.env),
|
|
22
|
+
DATABASE_URL: databaseUrl,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
for (const [index, step] of steps.entries()) {
|
|
26
|
+
const label = `template:${stageName}:${config.name}:${index + 1}`;
|
|
27
|
+
console.log(`\n── ${label} ──`);
|
|
28
|
+
await runTemplateStep(config, stageName, step, env);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function collectTemplateInputs(productDir, template = {}) {
|
|
33
|
+
const inputs = new Set();
|
|
34
|
+
for (const input of template.inputs || []) {
|
|
35
|
+
inputs.add(resolveTemplatePath(productDir, null, input));
|
|
36
|
+
}
|
|
37
|
+
for (const stageName of ["migrate", "seed", "verify"]) {
|
|
38
|
+
for (const step of template[stageName] || []) {
|
|
39
|
+
if (step.kind === "sql-file") {
|
|
40
|
+
inputs.add(resolveTemplatePath(productDir, step.cwd, step.path));
|
|
41
|
+
}
|
|
42
|
+
if (step.kind === "module") {
|
|
43
|
+
inputs.add(resolveTemplatePath(productDir, step.cwd, parseModuleSpecifier(step.specifier).modulePath));
|
|
44
|
+
}
|
|
45
|
+
for (const input of step.inputs || []) {
|
|
46
|
+
inputs.add(resolveTemplatePath(productDir, step.cwd, input));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [...inputs].sort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function captureTemplateSnapshot(config, outputPath, databaseUrl) {
|
|
54
|
+
const templateDbUrl = databaseUrl;
|
|
55
|
+
const absoluteOutputPath = path.resolve(config.productDir, outputPath);
|
|
56
|
+
fs.mkdirSync(path.dirname(absoluteOutputPath), { recursive: true });
|
|
57
|
+
|
|
58
|
+
await execa("pg_dump", [
|
|
59
|
+
"--schema-only",
|
|
60
|
+
"--no-owner",
|
|
61
|
+
"--no-privileges",
|
|
62
|
+
"--file",
|
|
63
|
+
absoluteOutputPath,
|
|
64
|
+
templateDbUrl,
|
|
65
|
+
], {
|
|
66
|
+
cwd: config.productDir,
|
|
67
|
+
env: {
|
|
68
|
+
...buildExecutionEnv(config, {}, process.env),
|
|
69
|
+
DATABASE_URL: templateDbUrl,
|
|
70
|
+
},
|
|
71
|
+
stdio: "inherit",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
sanitizeSnapshotFile(absoluteOutputPath);
|
|
75
|
+
return absoluteOutputPath;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sanitizeSnapshotFile(filePath) {
|
|
79
|
+
const dump = fs.readFileSync(filePath, "utf8");
|
|
80
|
+
const sanitized = dump
|
|
81
|
+
.split("\n")
|
|
82
|
+
.filter((line) => line.trim() !== "SET transaction_timeout = 0;")
|
|
83
|
+
.join("\n");
|
|
84
|
+
|
|
85
|
+
if (sanitized !== dump) {
|
|
86
|
+
fs.writeFileSync(filePath, sanitized);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function runTemplateStep(config, stageName, step, env) {
|
|
91
|
+
if (step.kind === "command") {
|
|
92
|
+
await execaCommand(step.cmd, {
|
|
93
|
+
cwd: resolveTemplateCwd(config.productDir, step.cwd),
|
|
94
|
+
env,
|
|
95
|
+
stdio: "inherit",
|
|
96
|
+
shell: true,
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (step.kind === "sql-file") {
|
|
102
|
+
await execa("psql", [
|
|
103
|
+
env.DATABASE_URL,
|
|
104
|
+
"-v",
|
|
105
|
+
"ON_ERROR_STOP=1",
|
|
106
|
+
"-X",
|
|
107
|
+
"-f",
|
|
108
|
+
resolveTemplatePath(config.productDir, step.cwd, step.path),
|
|
109
|
+
], {
|
|
110
|
+
cwd: resolveTemplateCwd(config.productDir, step.cwd),
|
|
111
|
+
env,
|
|
112
|
+
stdio: "inherit",
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (step.kind === "module") {
|
|
118
|
+
const moduleRef = await loadTemplateModule(config.productDir, step);
|
|
119
|
+
const { exportName } = parseModuleSpecifier(step.specifier);
|
|
120
|
+
const fn = moduleRef[exportName];
|
|
121
|
+
if (typeof fn !== "function") {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Template module step "${step.specifier}" did not export a function named "${exportName}"`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await withProcessContext(
|
|
128
|
+
resolveTemplateCwd(config.productDir, step.cwd),
|
|
129
|
+
env,
|
|
130
|
+
async () => {
|
|
131
|
+
await fn({
|
|
132
|
+
productDir: config.productDir,
|
|
133
|
+
cwd: resolveTemplateCwd(config.productDir, step.cwd),
|
|
134
|
+
serviceName: config.name,
|
|
135
|
+
stage: stageName,
|
|
136
|
+
databaseUrl: env.DATABASE_URL,
|
|
137
|
+
env: { ...env },
|
|
138
|
+
runtimeId: config.runtimeId || null,
|
|
139
|
+
stateDir: config.stateDir,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
throw new Error(`Unsupported template step kind "${step.kind}"`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function resolveTemplateCwd(productDir, stepCwd) {
|
|
150
|
+
return resolveServiceCwd(productDir, stepCwd || ".");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveTemplatePath(productDir, stepCwd, targetPath) {
|
|
154
|
+
return path.resolve(resolveTemplateCwd(productDir, stepCwd), targetPath);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function loadTemplateModule(productDir, step) {
|
|
158
|
+
const { modulePath } = parseModuleSpecifier(step.specifier);
|
|
159
|
+
const absoluteModulePath = resolveTemplatePath(productDir, step.cwd, modulePath);
|
|
160
|
+
const bundleDir = path.join(productDir, ".testkit", "_template-steps");
|
|
161
|
+
fs.mkdirSync(bundleDir, { recursive: true });
|
|
162
|
+
|
|
163
|
+
const cacheKey = buildModuleCacheKey(absoluteModulePath);
|
|
164
|
+
const outputFile = path.join(bundleDir, `${path.basename(modulePath).replace(/\W+/g, "-")}-${cacheKey.slice(0, 12)}.mjs`);
|
|
165
|
+
|
|
166
|
+
await build({
|
|
167
|
+
absWorkingDir: productDir,
|
|
168
|
+
bundle: true,
|
|
169
|
+
entryPoints: [absoluteModulePath],
|
|
170
|
+
format: "esm",
|
|
171
|
+
legalComments: "none",
|
|
172
|
+
outfile: outputFile,
|
|
173
|
+
platform: "node",
|
|
174
|
+
sourcemap: "inline",
|
|
175
|
+
target: "es2020",
|
|
176
|
+
plugins: [testkitAliasPlugin()],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return import(`${pathToFileURL(outputFile).href}?v=${cacheKey}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function buildModuleCacheKey(modulePath) {
|
|
183
|
+
const content = fs.readFileSync(modulePath, "utf8");
|
|
184
|
+
return crypto.createHash("sha256").update(modulePath).update("\0").update(content).digest("hex");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function testkitAliasPlugin() {
|
|
188
|
+
return {
|
|
189
|
+
name: "testkit-template-step-alias",
|
|
190
|
+
setup(buildApi) {
|
|
191
|
+
buildApi.onResolve({ filter: /^@elench\/testkit(?:\/.*)?$/ }, (args) => ({
|
|
192
|
+
namespace: "file",
|
|
193
|
+
path: resolvePackageSubpath(args.path),
|
|
194
|
+
}));
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function resolvePackageSubpath(specifier) {
|
|
200
|
+
const subpath = specifier.slice("@elench/testkit".length);
|
|
201
|
+
if (!subpath) return ROOT_ENTRY;
|
|
202
|
+
if (subpath === "/setup") return SETUP_ENTRY;
|
|
203
|
+
if (subpath === "/runtime") return RUNTIME_ENTRY;
|
|
204
|
+
if (subpath === "/known-failures") return KNOWN_FAILURES_ENTRY;
|
|
205
|
+
|
|
206
|
+
throw new Error(`Unsupported @elench/testkit import "${specifier}" while loading template step`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseModuleSpecifier(specifier) {
|
|
210
|
+
const [modulePath, exportName] = String(specifier).split("#", 2);
|
|
211
|
+
return {
|
|
212
|
+
modulePath,
|
|
213
|
+
exportName: exportName || "default",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function withProcessContext(cwd, env, fn) {
|
|
218
|
+
const previousCwd = process.cwd();
|
|
219
|
+
const previousEnv = process.env;
|
|
220
|
+
process.chdir(cwd);
|
|
221
|
+
process.env = {
|
|
222
|
+
...previousEnv,
|
|
223
|
+
...env,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
return await fn();
|
|
228
|
+
} finally {
|
|
229
|
+
process.chdir(previousCwd);
|
|
230
|
+
process.env = previousEnv;
|
|
231
|
+
}
|
|
232
|
+
}
|