@elench/testkit 0.1.48 → 0.1.50
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 +14 -0
- package/lib/config/index.mjs +58 -3
- package/lib/database/index.mjs +105 -12
- package/lib/database/index.test.mjs +95 -0
- package/lib/database/template-steps.mjs +35 -193
- package/lib/runner/processes.mjs +16 -2
- package/lib/runner/processes.test.mjs +21 -0
- package/lib/runner/results.mjs +2 -1
- package/lib/runner/results.test.mjs +61 -0
- package/lib/runner/runtime-contexts.mjs +2 -0
- package/lib/runner/runtime-manager.mjs +34 -0
- package/lib/runner/runtime-manager.test.mjs +46 -0
- package/lib/runner/runtime-preparation.mjs +107 -0
- package/lib/runner/runtime-preparation.test.mjs +141 -0
- package/lib/runner/template-steps.mjs +191 -0
- package/lib/runner/template.mjs +41 -0
- package/lib/runner/template.test.mjs +64 -0
- package/lib/runner/worker-loop.mjs +21 -0
- package/lib/setup/index.d.ts +4 -0
- package/lib/setup/index.mjs +5 -5
- package/lib/setup/index.test.mjs +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -119,7 +119,9 @@ export default defineTestkitSetup({
|
|
|
119
119
|
...nextService({
|
|
120
120
|
cwd: "frontend",
|
|
121
121
|
port: 3000,
|
|
122
|
+
start: "./node_modules/.bin/next start --port {port}",
|
|
122
123
|
env: {
|
|
124
|
+
NEXT_DIST_DIR: "{prepareDir}/dist",
|
|
123
125
|
NEXT_PUBLIC_API_URL: "{baseUrl:api}",
|
|
124
126
|
},
|
|
125
127
|
}),
|
|
@@ -127,6 +129,11 @@ export default defineTestkitSetup({
|
|
|
127
129
|
envFiles: ["frontend/.env.testkit"],
|
|
128
130
|
runtime: {
|
|
129
131
|
instances: 1,
|
|
132
|
+
maxConcurrentTasks: 2,
|
|
133
|
+
prepare: {
|
|
134
|
+
inputs: ["frontend/src", "frontend/public", "frontend/package.json"],
|
|
135
|
+
steps: [commandStep("npm run build", { cwd: "frontend" })],
|
|
136
|
+
},
|
|
130
137
|
},
|
|
131
138
|
}),
|
|
132
139
|
billing: service({
|
|
@@ -157,6 +164,7 @@ for:
|
|
|
157
164
|
- multi-service graphs
|
|
158
165
|
- local runtime instance counts
|
|
159
166
|
- per-runtime concurrent task caps
|
|
167
|
+
- one-time runtime preparation steps for stable shared servers
|
|
160
168
|
- local DB binding configuration
|
|
161
169
|
- template database migrate / seed / verify stages
|
|
162
170
|
- template schema snapshot capture
|
|
@@ -167,6 +175,12 @@ for:
|
|
|
167
175
|
- repo-declared suite/file skip policies with explicit reasons
|
|
168
176
|
- telemetry upload configuration
|
|
169
177
|
|
|
178
|
+
`runtime.prepare` is the generic build-once hook for shared runtimes. It runs
|
|
179
|
+
once per runtime generation before local services start, fingerprints declared
|
|
180
|
+
inputs, and writes cache state under the service runtime directory. This is the
|
|
181
|
+
right way to move expensive browser targets from `next dev` / watch mode to
|
|
182
|
+
stable build-and-start flows.
|
|
183
|
+
|
|
170
184
|
If `reporting.knownFailuresFile` is configured, `testkit` enriches
|
|
171
185
|
`.testkit/results/latest.json` and `testkit.status.json` with:
|
|
172
186
|
|
package/lib/config/index.mjs
CHANGED
|
@@ -212,7 +212,7 @@ function inferLocalRuntime(productDir, cwd) {
|
|
|
212
212
|
if (detectNextApp(absoluteCwd)) {
|
|
213
213
|
return {
|
|
214
214
|
cwd,
|
|
215
|
-
start: "
|
|
215
|
+
start: "./node_modules/.bin/next dev -p {port}",
|
|
216
216
|
port: 3000,
|
|
217
217
|
baseUrl: "http://127.0.0.1:{port}",
|
|
218
218
|
readyUrl: "http://127.0.0.1:{port}",
|
|
@@ -223,7 +223,7 @@ function inferLocalRuntime(productDir, cwd) {
|
|
|
223
223
|
if (fs.existsSync(path.join(absoluteCwd, "cmd", "server"))) {
|
|
224
224
|
return {
|
|
225
225
|
cwd,
|
|
226
|
-
start: "
|
|
226
|
+
start: "go run ./cmd/server",
|
|
227
227
|
port: 3000,
|
|
228
228
|
baseUrl: "http://127.0.0.1:{port}",
|
|
229
229
|
readyUrl: "http://127.0.0.1:{port}/health",
|
|
@@ -234,7 +234,7 @@ function inferLocalRuntime(productDir, cwd) {
|
|
|
234
234
|
if (fs.existsSync(path.join(absoluteCwd, "package.json")) && fs.existsSync(path.join(absoluteCwd, "src"))) {
|
|
235
235
|
return {
|
|
236
236
|
cwd,
|
|
237
|
-
start: "
|
|
237
|
+
start: "./node_modules/.bin/tsx watch src/index.ts",
|
|
238
238
|
port: 3000,
|
|
239
239
|
baseUrl: "http://127.0.0.1:{port}",
|
|
240
240
|
readyUrl: "http://127.0.0.1:{port}/health",
|
|
@@ -279,6 +279,10 @@ function normalizeRuntimeConfig(value, serviceName) {
|
|
|
279
279
|
return {
|
|
280
280
|
instances: 1,
|
|
281
281
|
maxConcurrentTasks: Number.POSITIVE_INFINITY,
|
|
282
|
+
prepare: {
|
|
283
|
+
inputs: [],
|
|
284
|
+
steps: [],
|
|
285
|
+
},
|
|
282
286
|
};
|
|
283
287
|
}
|
|
284
288
|
|
|
@@ -291,6 +295,27 @@ function normalizeRuntimeConfig(value, serviceName) {
|
|
|
291
295
|
value.maxConcurrentTasks,
|
|
292
296
|
`Service "${serviceName}" runtime.maxConcurrentTasks`
|
|
293
297
|
),
|
|
298
|
+
prepare: normalizeRuntimePrepareConfig(value.prepare, serviceName),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function normalizeRuntimePrepareConfig(value, serviceName) {
|
|
303
|
+
if (value == null) {
|
|
304
|
+
return {
|
|
305
|
+
inputs: [],
|
|
306
|
+
steps: [],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
if (!value || typeof value !== "object") {
|
|
310
|
+
throw new Error(`Service "${serviceName}" runtime.prepare must be an object`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
inputs: normalizeTemplateInputs(value.inputs, `Service "${serviceName}" runtime.prepare`),
|
|
315
|
+
steps: normalizeTemplateLifecycleSteps(
|
|
316
|
+
value.steps,
|
|
317
|
+
`Service "${serviceName}" runtime.prepare.steps`
|
|
318
|
+
),
|
|
294
319
|
};
|
|
295
320
|
}
|
|
296
321
|
|
|
@@ -751,6 +776,36 @@ function validateServiceConfig({
|
|
|
751
776
|
for (const input of database?.template?.inputs || []) {
|
|
752
777
|
ensureExistingPath(productDir, input, `Service "${name}" database.template input`);
|
|
753
778
|
}
|
|
779
|
+
for (const step of runtime.prepare?.steps || []) {
|
|
780
|
+
if (step.cwd) {
|
|
781
|
+
ensureExistingPath(productDir, step.cwd, `Service "${name}" runtime.prepare step cwd`);
|
|
782
|
+
}
|
|
783
|
+
if (step.kind === "sql-file") {
|
|
784
|
+
ensureExistingPath(
|
|
785
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
786
|
+
step.path,
|
|
787
|
+
`Service "${name}" runtime.prepare sql file`
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
if (step.kind === "module") {
|
|
791
|
+
const { modulePath } = parseModuleSpecifier(step.specifier);
|
|
792
|
+
ensureExistingPath(
|
|
793
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
794
|
+
modulePath,
|
|
795
|
+
`Service "${name}" runtime.prepare module`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
for (const input of step.inputs || []) {
|
|
799
|
+
ensureExistingPath(
|
|
800
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
801
|
+
input,
|
|
802
|
+
`Service "${name}" runtime.prepare step input`
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
for (const input of runtime.prepare?.inputs || []) {
|
|
807
|
+
ensureExistingPath(productDir, input, `Service "${name}" runtime.prepare input`);
|
|
808
|
+
}
|
|
754
809
|
}
|
|
755
810
|
|
|
756
811
|
function ensureExistingPath(productDir, relativePath, label) {
|
package/lib/database/index.mjs
CHANGED
|
@@ -33,6 +33,8 @@ const LOCAL_PASSWORD = "testkit";
|
|
|
33
33
|
const LOCAL_ADMIN_DB = "postgres";
|
|
34
34
|
const LOCAL_READY_TIMEOUT_MS = 60_000;
|
|
35
35
|
const LOCAL_POLL_INTERVAL_MS = 1_000;
|
|
36
|
+
const LOCAL_DROP_DATABASE_TIMEOUT_MS = 15_000;
|
|
37
|
+
const LOCAL_DROP_DATABASE_POLL_INTERVAL_MS = 250;
|
|
36
38
|
|
|
37
39
|
export async function prepareDatabaseRuntime(config) {
|
|
38
40
|
const db = config.testkit.database;
|
|
@@ -398,18 +400,78 @@ async function cloneDatabaseFromTemplate(infra, dbName, templateDbName) {
|
|
|
398
400
|
}
|
|
399
401
|
|
|
400
402
|
async function dropDatabaseIfExists(infra, dbName) {
|
|
401
|
-
await
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
403
|
+
await dropDatabaseWithForceOrDrain(infra, dbName);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function dropDatabaseWithForceOrDrain(infra, dbName, hooks = {}) {
|
|
407
|
+
const runAdminQueryFn = hooks.runAdminQuery || runAdminQuery;
|
|
408
|
+
const databaseExistsFn = hooks.databaseExists || databaseExists;
|
|
409
|
+
const sleepFn = hooks.sleep || sleep;
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
await runAdminQueryFn(infra, [
|
|
413
|
+
"-c",
|
|
414
|
+
`DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}" WITH (FORCE)`,
|
|
415
|
+
]);
|
|
416
|
+
return;
|
|
417
|
+
} catch (error) {
|
|
418
|
+
if (!isUnsupportedForceDropError(error)) {
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!(await databaseExistsFn(infra, dbName))) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let restoreConnections = false;
|
|
428
|
+
try {
|
|
429
|
+
await runAdminQueryFn(infra, [
|
|
430
|
+
"-c",
|
|
431
|
+
`ALTER DATABASE "${escapeIdentifier(dbName)}" WITH ALLOW_CONNECTIONS false`,
|
|
432
|
+
]);
|
|
433
|
+
restoreConnections = true;
|
|
434
|
+
await waitForDatabaseConnectionsToDrain(infra, dbName, {
|
|
435
|
+
runAdminQuery: runAdminQueryFn,
|
|
436
|
+
sleep: sleepFn,
|
|
437
|
+
});
|
|
438
|
+
await runAdminQueryFn(infra, [
|
|
439
|
+
"-c",
|
|
440
|
+
`DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}"`,
|
|
441
|
+
]);
|
|
442
|
+
restoreConnections = false;
|
|
443
|
+
} finally {
|
|
444
|
+
if (restoreConnections && (await databaseExistsFn(infra, dbName))) {
|
|
445
|
+
await runAdminQueryFn(infra, [
|
|
446
|
+
"-c",
|
|
447
|
+
`ALTER DATABASE "${escapeIdentifier(dbName)}" WITH ALLOW_CONNECTIONS true`,
|
|
448
|
+
]).catch(() => {
|
|
449
|
+
// Best-effort restoration for failed fallback drops.
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export async function waitForDatabaseConnectionsToDrain(infra, dbName, hooks = {}) {
|
|
456
|
+
const runAdminQueryFn = hooks.runAdminQuery || runAdminQuery;
|
|
457
|
+
const sleepFn = hooks.sleep || sleep;
|
|
458
|
+
const now = hooks.now || Date.now;
|
|
459
|
+
const timeoutMs = hooks.timeoutMs ?? LOCAL_DROP_DATABASE_TIMEOUT_MS;
|
|
460
|
+
const deadline = now() + timeoutMs;
|
|
461
|
+
|
|
462
|
+
while (true) {
|
|
463
|
+
await terminateDatabaseConnections(infra, dbName, runAdminQueryFn);
|
|
464
|
+
const remainingConnections = await countDatabaseConnections(infra, dbName, runAdminQueryFn);
|
|
465
|
+
if (remainingConnections === 0) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (now() >= deadline) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
`Timed out waiting for database "${dbName}" connections to close (${remainingConnections} remaining)`
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
await sleepFn(LOCAL_DROP_DATABASE_POLL_INTERVAL_MS);
|
|
474
|
+
}
|
|
413
475
|
}
|
|
414
476
|
|
|
415
477
|
async function runAdminQuery(infra, args) {
|
|
@@ -431,6 +493,37 @@ async function runAdminQuery(infra, args) {
|
|
|
431
493
|
return stdout;
|
|
432
494
|
}
|
|
433
495
|
|
|
496
|
+
async function terminateDatabaseConnections(infra, dbName, runAdminQueryFn) {
|
|
497
|
+
await runAdminQueryFn(infra, [
|
|
498
|
+
"-c",
|
|
499
|
+
[
|
|
500
|
+
"SELECT pg_terminate_backend(pid)",
|
|
501
|
+
"FROM pg_stat_activity",
|
|
502
|
+
`WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
|
|
503
|
+
].join(" "),
|
|
504
|
+
]);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function countDatabaseConnections(infra, dbName, runAdminQueryFn) {
|
|
508
|
+
const result = await runAdminQueryFn(infra, [
|
|
509
|
+
"-tAc",
|
|
510
|
+
[
|
|
511
|
+
"SELECT COUNT(*)",
|
|
512
|
+
"FROM pg_stat_activity",
|
|
513
|
+
`WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
|
|
514
|
+
].join(" "),
|
|
515
|
+
]);
|
|
516
|
+
return Number.parseInt(result.trim(), 10) || 0;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function isUnsupportedForceDropError(error) {
|
|
520
|
+
const text = `${error?.shortMessage || ""}\n${error?.stderr || ""}\n${error?.stdout || ""}\n${error?.message || ""}`;
|
|
521
|
+
return (
|
|
522
|
+
text.includes('syntax error at or near "WITH"') ||
|
|
523
|
+
text.includes('option "force"')
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
434
527
|
async function computeTemplateFingerprint(config) {
|
|
435
528
|
return computeTemplateFingerprintModel(config);
|
|
436
529
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
dropDatabaseWithForceOrDrain,
|
|
4
|
+
waitForDatabaseConnectionsToDrain,
|
|
5
|
+
} from "./index.mjs";
|
|
6
|
+
|
|
7
|
+
describe("database lifecycle helpers", () => {
|
|
8
|
+
it("uses DROP DATABASE ... WITH (FORCE) when supported", async () => {
|
|
9
|
+
const calls = [];
|
|
10
|
+
|
|
11
|
+
await dropDatabaseWithForceOrDrain(
|
|
12
|
+
{ containerName: "pg", password: "pw", user: "user" },
|
|
13
|
+
"demo",
|
|
14
|
+
{
|
|
15
|
+
async runAdminQuery(_infra, args) {
|
|
16
|
+
calls.push(args);
|
|
17
|
+
return "";
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(calls).toEqual([["-c", 'DROP DATABASE IF EXISTS "demo" WITH (FORCE)']]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("falls back to draining connections when forced drop is unsupported", async () => {
|
|
26
|
+
const calls = [];
|
|
27
|
+
const counts = ["2", "0"];
|
|
28
|
+
|
|
29
|
+
await dropDatabaseWithForceOrDrain(
|
|
30
|
+
{ containerName: "pg", password: "pw", user: "user" },
|
|
31
|
+
"demo",
|
|
32
|
+
{
|
|
33
|
+
async runAdminQuery(_infra, args) {
|
|
34
|
+
calls.push(args);
|
|
35
|
+
if (args[1] === 'DROP DATABASE IF EXISTS "demo" WITH (FORCE)') {
|
|
36
|
+
const error = new Error('syntax error at or near "WITH"');
|
|
37
|
+
error.stderr = 'ERROR: syntax error at or near "WITH"';
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
if (args[0] === "-tAc") {
|
|
41
|
+
return counts.shift() || "0";
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
},
|
|
45
|
+
async databaseExists() {
|
|
46
|
+
return true;
|
|
47
|
+
},
|
|
48
|
+
async sleep() {},
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(calls).toEqual([
|
|
53
|
+
["-c", 'DROP DATABASE IF EXISTS "demo" WITH (FORCE)'],
|
|
54
|
+
["-c", 'ALTER DATABASE "demo" WITH ALLOW_CONNECTIONS false'],
|
|
55
|
+
[
|
|
56
|
+
"-c",
|
|
57
|
+
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'demo' AND pid <> pg_backend_pid();",
|
|
58
|
+
],
|
|
59
|
+
[
|
|
60
|
+
"-tAc",
|
|
61
|
+
"SELECT COUNT(*) FROM pg_stat_activity WHERE datname = 'demo' AND pid <> pg_backend_pid();",
|
|
62
|
+
],
|
|
63
|
+
[
|
|
64
|
+
"-c",
|
|
65
|
+
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'demo' AND pid <> pg_backend_pid();",
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
"-tAc",
|
|
69
|
+
"SELECT COUNT(*) FROM pg_stat_activity WHERE datname = 'demo' AND pid <> pg_backend_pid();",
|
|
70
|
+
],
|
|
71
|
+
["-c", 'DROP DATABASE IF EXISTS "demo"'],
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("times out clearly while waiting for lingering connections to drain", async () => {
|
|
76
|
+
let now = 0;
|
|
77
|
+
await expect(() =>
|
|
78
|
+
waitForDatabaseConnectionsToDrain(
|
|
79
|
+
{ containerName: "pg", password: "pw", user: "user" },
|
|
80
|
+
"demo",
|
|
81
|
+
{
|
|
82
|
+
async runAdminQuery(_infra, args) {
|
|
83
|
+
if (args[0] === "-tAc") return "1";
|
|
84
|
+
return "";
|
|
85
|
+
},
|
|
86
|
+
async sleep() {
|
|
87
|
+
now += 10;
|
|
88
|
+
},
|
|
89
|
+
now: () => now,
|
|
90
|
+
timeoutMs: 20,
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
).rejects.toThrow(/Timed out waiting for database "demo" connections to close/);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -1,17 +1,11 @@
|
|
|
1
|
-
import crypto from "crypto";
|
|
2
1
|
import fs from "fs";
|
|
3
2
|
import path from "path";
|
|
4
|
-
import {
|
|
5
|
-
import { execa, execaCommand } from "execa";
|
|
6
|
-
import { fileURLToPath, pathToFileURL } from "url";
|
|
7
|
-
import { resolveServiceCwd } from "../config/index.mjs";
|
|
3
|
+
import { execa } from "execa";
|
|
8
4
|
import { buildExecutionEnv } from "../runner/template.mjs";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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");
|
|
5
|
+
import {
|
|
6
|
+
collectConfiguredInputs,
|
|
7
|
+
runConfiguredSteps,
|
|
8
|
+
} from "../runner/template-steps.mjs";
|
|
15
9
|
|
|
16
10
|
export async function runTemplateStage(config, stageName, databaseUrl) {
|
|
17
11
|
const steps = config.testkit.database?.template?.[stageName] || [];
|
|
@@ -22,32 +16,20 @@ export async function runTemplateStage(config, stageName, databaseUrl) {
|
|
|
22
16
|
DATABASE_URL: databaseUrl,
|
|
23
17
|
};
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
await runConfiguredSteps({
|
|
20
|
+
config,
|
|
21
|
+
steps,
|
|
22
|
+
env,
|
|
23
|
+
labelPrefix: `template:${stageName}`,
|
|
24
|
+
});
|
|
30
25
|
}
|
|
31
26
|
|
|
32
27
|
export function collectTemplateInputs(productDir, template = {}) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
inputs.
|
|
36
|
-
|
|
37
|
-
|
|
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();
|
|
28
|
+
const steps = ["migrate", "seed", "verify"].flatMap((stageName) => template[stageName] || []);
|
|
29
|
+
return collectConfiguredInputs(productDir, {
|
|
30
|
+
inputs: template.inputs || [],
|
|
31
|
+
steps,
|
|
32
|
+
});
|
|
51
33
|
}
|
|
52
34
|
|
|
53
35
|
export async function captureTemplateSnapshot(config, outputPath, databaseUrl) {
|
|
@@ -55,21 +37,25 @@ export async function captureTemplateSnapshot(config, outputPath, databaseUrl) {
|
|
|
55
37
|
const absoluteOutputPath = path.resolve(config.productDir, outputPath);
|
|
56
38
|
fs.mkdirSync(path.dirname(absoluteOutputPath), { recursive: true });
|
|
57
39
|
|
|
58
|
-
await execa(
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
40
|
+
await execa(
|
|
41
|
+
"pg_dump",
|
|
42
|
+
[
|
|
43
|
+
"--schema-only",
|
|
44
|
+
"--no-owner",
|
|
45
|
+
"--no-privileges",
|
|
46
|
+
"--file",
|
|
47
|
+
absoluteOutputPath,
|
|
48
|
+
templateDbUrl,
|
|
49
|
+
],
|
|
50
|
+
{
|
|
51
|
+
cwd: config.productDir,
|
|
52
|
+
env: {
|
|
53
|
+
...buildExecutionEnv(config, {}, process.env),
|
|
54
|
+
DATABASE_URL: templateDbUrl,
|
|
55
|
+
},
|
|
56
|
+
stdio: "inherit",
|
|
57
|
+
}
|
|
58
|
+
);
|
|
73
59
|
|
|
74
60
|
sanitizeSnapshotFile(absoluteOutputPath);
|
|
75
61
|
return absoluteOutputPath;
|
|
@@ -86,147 +72,3 @@ function sanitizeSnapshotFile(filePath) {
|
|
|
86
72
|
fs.writeFileSync(filePath, sanitized);
|
|
87
73
|
}
|
|
88
74
|
}
|
|
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
|
-
}
|
package/lib/runner/processes.mjs
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
|
|
3
|
+
export function normalizeServiceStartCommand(command) {
|
|
4
|
+
if (typeof command !== "string") {
|
|
5
|
+
throw new Error("Service start command must be a string");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const trimmed = command.trim();
|
|
9
|
+
if (trimmed.length === 0) {
|
|
10
|
+
throw new Error("Service start command must not be empty");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return trimmed.replace(/^exec\s+/u, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
3
16
|
export function startDetachedCommand(command, cwd, env) {
|
|
17
|
+
const normalizedCommand = normalizeServiceStartCommand(command);
|
|
4
18
|
if (process.platform === "win32") {
|
|
5
|
-
return spawn(
|
|
19
|
+
return spawn(normalizedCommand, {
|
|
6
20
|
cwd,
|
|
7
21
|
env,
|
|
8
22
|
detached: true,
|
|
@@ -12,7 +26,7 @@ export function startDetachedCommand(command, cwd, env) {
|
|
|
12
26
|
}
|
|
13
27
|
|
|
14
28
|
const shell = process.env.SHELL || "/bin/sh";
|
|
15
|
-
return spawn(shell, ["-lc", `exec ${
|
|
29
|
+
return spawn(shell, ["-lc", `exec ${normalizedCommand}`], {
|
|
16
30
|
cwd,
|
|
17
31
|
env,
|
|
18
32
|
detached: true,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeServiceStartCommand } from "./processes.mjs";
|
|
3
|
+
|
|
4
|
+
describe("runner processes", () => {
|
|
5
|
+
it("strips a single leading exec prefix for backward compatibility", () => {
|
|
6
|
+
expect(normalizeServiceStartCommand("exec node server.mjs")).toBe("node server.mjs");
|
|
7
|
+
expect(normalizeServiceStartCommand(" exec ./node_modules/.bin/next dev -p {port} ")).toBe(
|
|
8
|
+
"./node_modules/.bin/next dev -p {port}"
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("leaves plain commands unchanged", () => {
|
|
13
|
+
expect(normalizeServiceStartCommand("node server.mjs")).toBe("node server.mjs");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("rejects empty commands", () => {
|
|
17
|
+
expect(() => normalizeServiceStartCommand(" ")).toThrow(
|
|
18
|
+
/Service start command must not be empty/
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
});
|