@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 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
 
@@ -212,7 +212,7 @@ function inferLocalRuntime(productDir, cwd) {
212
212
  if (detectNextApp(absoluteCwd)) {
213
213
  return {
214
214
  cwd,
215
- start: "exec ./node_modules/.bin/next dev -p {port}",
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: "exec go run ./cmd/server",
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: "exec ./node_modules/.bin/tsx watch src/index.ts",
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) {
@@ -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 runAdminQuery(infra, [
402
- "-c",
403
- [
404
- `SELECT pg_terminate_backend(pid)`,
405
- `FROM pg_stat_activity`,
406
- `WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
407
- ].join(" "),
408
- ]);
409
- await runAdminQuery(infra, [
410
- "-c",
411
- `DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}"`,
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 { build } from "esbuild";
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
- 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");
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
- 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
- }
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 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();
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("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
- });
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
- }
@@ -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(command, {
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 ${command}`], {
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
+ });