@elench/testkit 0.1.46 → 0.1.48

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.
@@ -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 normalizeLifecycle(value) {
313
- if (!value) return undefined;
314
- if (!value.cmd && !value.testkitCmd) {
315
- throw new Error("Lifecycle config requires cmd or testkitCmd");
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
- cmd: value.testkitCmd || value.cmd,
320
- cwd: value.testkitCwd || value.cwd,
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
- if (migrate?.cwd) {
630
- ensureExistingPath(productDir, migrate.cwd, `Service "${name}" migrate.cwd`);
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
- if (seed?.cwd) {
633
- ensureExistingPath(productDir, seed.cwd, `Service "${name}" seed.cwd`);
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
- migrate: config.testkit.migrate || null,
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.inputs || []) {
25
- appendInputToHash(hash, config.productDir, input);
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
- appendInputToHash(hash, productDir, path.join(input, entry));
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
 
@@ -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, hooks = {}) {
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, hooks);
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, hooks) {
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, hooks);
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, hooks) {
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
- if (hooks.runMigrate) {
167
- await hooks.runMigrate(templateUrl);
168
- }
169
- if (hooks.runSeed) {
170
- await hooks.runSeed(templateUrl);
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
+ }