@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.
@@ -114,7 +114,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
114
114
  const normalizedPath = normalizePathSeparators(task.file);
115
115
  const existingFileResult = suite.fileResultsByPath.get(normalizedPath);
116
116
  const status = normalizeOutcomeStatus(outcome);
117
- if (status !== "skipped") {
117
+ if (status !== "skipped" && status !== "not_run") {
118
118
  suite.completedFileCount += 1;
119
119
  }
120
120
  if (existingFileResult) {
@@ -291,6 +291,7 @@ function formatFrameworkForArtifact(framework) {
291
291
  }
292
292
 
293
293
  function normalizeOutcomeStatus(outcome) {
294
+ if (outcome?.status === "not_run") return "not_run";
294
295
  if (outcome?.status === "skipped") return "skipped";
295
296
  return outcome?.failed ? "failed" : "passed";
296
297
  }
@@ -307,6 +307,67 @@ describe("runner results", () => {
307
307
  ]);
308
308
  });
309
309
 
310
+ it("preserves not-run task outcomes in suite and service summaries", () => {
311
+ const trackers = buildServiceTrackers(
312
+ [
313
+ {
314
+ skipped: false,
315
+ config: {
316
+ name: "api",
317
+ testkit: {
318
+ database: {
319
+ selectedBackend: null,
320
+ },
321
+ },
322
+ },
323
+ suites: [
324
+ {
325
+ name: "health",
326
+ type: "integration",
327
+ framework: "k6",
328
+ files: ["tests/a.int.testkit.ts", "tests/b.int.testkit.ts"],
329
+ orderIndex: 0,
330
+ },
331
+ ],
332
+ },
333
+ ],
334
+ 1000
335
+ );
336
+
337
+ recordTaskOutcome(
338
+ trackers,
339
+ {
340
+ serviceName: "api",
341
+ suiteKey: "integration:health",
342
+ file: "tests/a.int.testkit.ts",
343
+ },
344
+ {
345
+ failed: false,
346
+ status: "not_run",
347
+ reason: "Graph initialization failed",
348
+ durationMs: 0,
349
+ error: null,
350
+ },
351
+ 1000
352
+ );
353
+
354
+ const result = finalizeServiceResult(trackers.get("api"), 1000, 1200);
355
+
356
+ expect(result.completedFileCount).toBe(0);
357
+ expect(result.passedFileCount).toBe(0);
358
+ expect(result.failedFileCount).toBe(0);
359
+ expect(result.skippedFileCount).toBe(0);
360
+ expect(result.notRunFileCount).toBe(2);
361
+ expect(result.suites[0].files).toContainEqual({
362
+ path: "tests/a.int.testkit.ts",
363
+ failed: false,
364
+ status: "not_run",
365
+ durationMs: 0,
366
+ error: null,
367
+ reason: "Graph initialization failed",
368
+ });
369
+ });
370
+
310
371
  it("summarizes mixed db backends", () => {
311
372
  expect(
312
373
  summarizeDbBackend([{ dbBackend: "local" }, { dbBackend: "neon" }])
@@ -2,6 +2,7 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { prepareDatabaseRuntime } from "../database/index.mjs";
4
4
  import { taskNeedsLocalRuntime } from "./planning.mjs";
5
+ import { prepareRuntimeServices } from "./runtime-preparation.mjs";
5
6
  import { writeGraphMetadata } from "./state.mjs";
6
7
  import { startLocalServices, stopLocalServices } from "./services.mjs";
7
8
  import { resolveRuntimeInstanceConfigs } from "./template.mjs";
@@ -39,6 +40,7 @@ export async function ensureRuntimeInstanceReady(context, task, lifecycle) {
39
40
  if (!context.preparationPromise) {
40
41
  context.preparationPromise = (async () => {
41
42
  await prepareDatabases(context.runtimeConfigs);
43
+ await prepareRuntimeServices(context.runtimeConfigs);
42
44
  context.prepared = true;
43
45
  })().finally(() => {
44
46
  context.preparationPromise = null;
@@ -1,6 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { buildRuntimeIds } from "./execution-config.mjs";
4
+ import { formatError } from "./formatting.mjs";
4
5
  import {
5
6
  cleanupRuntimeInstanceContext,
6
7
  createRuntimeInstanceContext,
@@ -11,6 +12,7 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
11
12
  const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
12
13
  const pools = new Map();
13
14
  const locks = new Map();
15
+ const failedGraphs = new Map();
14
16
  let nextLeaseCounter = 1;
15
17
  const runtimeHooks = {
16
18
  cleanupRuntimeInstanceContext,
@@ -22,6 +24,9 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
22
24
 
23
25
  return {
24
26
  canAcquire(task) {
27
+ if (failedGraphs.has(task.graphKey)) {
28
+ return false;
29
+ }
25
30
  const pool = getPool(pools, graphByKey, task, productDir, lifecycle);
26
31
  if (!locksAvailable(locks, task.locks || [])) {
27
32
  return false;
@@ -64,6 +69,9 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
64
69
  releaseLocks(locks, task.locks || [], leaseId);
65
70
  cleanupLeaseDir({ leaseDir: path.join(slot.context?.runtimeDir || productDir, "leases", leaseId) });
66
71
  await drainRuntimeSlot(slot, lifecycle, runtimeHooks);
72
+ if (!lifecycle.isStopRequested()) {
73
+ markGraphFailed(failedGraphs, task.graphKey, formatError(error));
74
+ }
67
75
  throw error;
68
76
  }
69
77
  },
@@ -104,6 +112,27 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
104
112
  }))
105
113
  .sort((left, right) => String(left.graphKey).localeCompare(String(right.graphKey)));
106
114
  },
115
+ drainFailedTasks(queue) {
116
+ const drained = [];
117
+ for (let index = queue.length - 1; index >= 0; index -= 1) {
118
+ const task = queue[index];
119
+ const message = failedGraphs.get(task.graphKey);
120
+ if (!message) continue;
121
+
122
+ queue.splice(index, 1);
123
+ drained.push({
124
+ task,
125
+ reason: `Not run because runtime graph failed to initialize: ${message}`,
126
+ message,
127
+ graph: graphByKey.get(task.graphKey) || {
128
+ key: task.graphKey,
129
+ targetNames: [task.targetName],
130
+ },
131
+ });
132
+ }
133
+
134
+ return drained.reverse();
135
+ },
107
136
  };
108
137
  }
109
138
 
@@ -218,6 +247,11 @@ function releaseLocks(lockMap, lockNames, leaseId) {
218
247
  }
219
248
  }
220
249
 
250
+ function markGraphFailed(failedGraphs, graphKey, message) {
251
+ if (!graphKey || failedGraphs.has(graphKey)) return;
252
+ failedGraphs.set(graphKey, message);
253
+ }
254
+
221
255
  function sleep(ms) {
222
256
  return new Promise((resolve) => setTimeout(resolve, ms));
223
257
  }
@@ -203,4 +203,50 @@ describe("runtime-manager", () => {
203
203
  await manager.release(leaseTwo);
204
204
  await manager.cleanupAll();
205
205
  });
206
+
207
+ it("blocks a graph after fatal runtime initialization failure and drains remaining tasks", async () => {
208
+ const productDir = makeTempDir("testkit-runtime-manager-");
209
+ const manager = createRuntimeManager({
210
+ productDir,
211
+ lifecycle: makeLifecycle(),
212
+ graphs: [
213
+ {
214
+ key: "api",
215
+ dirName: "api",
216
+ targetNames: ["api"],
217
+ instanceCount: 1,
218
+ maxConcurrentTasks: 1,
219
+ },
220
+ ],
221
+ hooks: {
222
+ ...makeHooks({ created: [], ready: [], cleaned: [] }),
223
+ async ensureRuntimeInstanceReady() {
224
+ throw new Error("prepare failed");
225
+ },
226
+ },
227
+ });
228
+
229
+ const firstTask = makeTask(1, { file: "tests/a.int.testkit.ts", targetName: "api" });
230
+ const secondTask = makeTask(2, { file: "tests/b.int.testkit.ts", targetName: "api" });
231
+
232
+ await expect(manager.acquire(firstTask)).rejects.toThrow(/prepare failed/);
233
+ expect(manager.canAcquire(secondTask)).toBe(false);
234
+
235
+ const queue = [secondTask];
236
+ expect(manager.drainFailedTasks(queue)).toEqual([
237
+ {
238
+ task: secondTask,
239
+ reason: "Not run because runtime graph failed to initialize: prepare failed",
240
+ message: "prepare failed",
241
+ graph: {
242
+ key: "api",
243
+ dirName: "api",
244
+ targetNames: ["api"],
245
+ instanceCount: 1,
246
+ maxConcurrentTasks: 1,
247
+ },
248
+ },
249
+ ]);
250
+ expect(queue).toEqual([]);
251
+ });
206
252
  });
@@ -0,0 +1,107 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { resolveServiceCwd } from "../config/index.mjs";
5
+ import { appendFileToHash, appendInputToHash } from "../database/fingerprint.mjs";
6
+ import { readDatabaseUrl } from "./state-io.mjs";
7
+ import { buildExecutionEnv } from "./template.mjs";
8
+ import {
9
+ collectConfiguredInputs,
10
+ runConfiguredSteps,
11
+ } from "./template-steps.mjs";
12
+
13
+ const MANIFEST_FILE = "prepare-manifest.json";
14
+
15
+ export async function prepareRuntimeServices(runtimeConfigs) {
16
+ for (const config of runtimeConfigs) {
17
+ await prepareRuntimeService(config);
18
+ }
19
+ }
20
+
21
+ export async function prepareRuntimeService(config) {
22
+ const prepare = config.testkit.runtime.prepare;
23
+ if (!prepare || prepare.steps.length === 0) return;
24
+
25
+ const prepareDir = config.testkit.prepareDir;
26
+ const fingerprint = await computeRuntimePrepareFingerprint(config);
27
+ const manifestPath = path.join(prepareDir, MANIFEST_FILE);
28
+ const existingManifest = readPrepareManifest(manifestPath);
29
+ if (existingManifest?.fingerprint === fingerprint) {
30
+ return;
31
+ }
32
+
33
+ fs.rmSync(prepareDir, { recursive: true, force: true });
34
+ fs.mkdirSync(prepareDir, { recursive: true });
35
+
36
+ const env = {
37
+ ...buildExecutionEnv(config, config.testkit.local?.env || {}, process.env),
38
+ };
39
+ const databaseUrl = readDatabaseUrl(config.stateDir);
40
+ if (databaseUrl) {
41
+ env.DATABASE_URL = databaseUrl;
42
+ }
43
+
44
+ try {
45
+ await runConfiguredSteps({
46
+ config,
47
+ steps: prepare.steps,
48
+ env,
49
+ labelPrefix: "runtime:prepare",
50
+ });
51
+ writePrepareManifest(manifestPath, {
52
+ fingerprint,
53
+ preparedAt: new Date().toISOString(),
54
+ runtimeId: config.runtimeId || null,
55
+ serviceName: config.name,
56
+ });
57
+ } catch (error) {
58
+ fs.rmSync(prepareDir, { recursive: true, force: true });
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ export async function computeRuntimePrepareFingerprint(config) {
64
+ const hash = crypto.createHash("sha256");
65
+ hash.update(
66
+ JSON.stringify({
67
+ prepare: config.testkit.runtime.prepare || null,
68
+ serviceEnv: config.testkit.serviceEnv || {},
69
+ local: config.testkit.local
70
+ ? {
71
+ baseUrl: config.testkit.local.baseUrl,
72
+ cwd: config.testkit.local.cwd,
73
+ env: config.testkit.local.env || {},
74
+ readyUrl: config.testkit.local.readyUrl,
75
+ start: config.testkit.local.start,
76
+ }
77
+ : null,
78
+ })
79
+ );
80
+
81
+ for (const envFile of config.testkit.envFiles || []) {
82
+ appendFileToHash(hash, config.productDir, resolveServiceCwd(config.productDir, envFile));
83
+ }
84
+ for (const input of collectConfiguredInputs(config.productDir, config.testkit.runtime.prepare)) {
85
+ appendResolvedInputToHash(hash, config.productDir, input);
86
+ }
87
+
88
+ return hash.digest("hex");
89
+ }
90
+
91
+ function appendResolvedInputToHash(hash, productDir, absPath) {
92
+ const relative = path.relative(productDir, absPath);
93
+ appendInputToHash(hash, productDir, relative);
94
+ }
95
+
96
+ function readPrepareManifest(manifestPath) {
97
+ if (!fs.existsSync(manifestPath)) return null;
98
+ try {
99
+ return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ function writePrepareManifest(manifestPath, manifest) {
106
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
107
+ }
@@ -0,0 +1,141 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { prepareRuntimeService } from "./runtime-preparation.mjs";
6
+ import { resolveRuntimeInstanceConfigs } from "./template.mjs";
7
+
8
+ const tempDirs = [];
9
+
10
+ afterEach(() => {
11
+ while (tempDirs.length > 0) {
12
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
13
+ }
14
+ });
15
+
16
+ function makeTempDir(prefix) {
17
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
18
+ tempDirs.push(dir);
19
+ return dir;
20
+ }
21
+
22
+ function writeFile(filePath, contents) {
23
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
24
+ fs.writeFileSync(filePath, contents);
25
+ }
26
+
27
+ function makeResolvedRuntimeConfig(productDir, prepareConfig) {
28
+ const rawConfig = {
29
+ name: "api",
30
+ productDir,
31
+ testkit: {
32
+ envFiles: [],
33
+ serviceEnv: {},
34
+ runtime: {
35
+ instances: 1,
36
+ maxConcurrentTasks: 1,
37
+ prepare: prepareConfig,
38
+ },
39
+ local: {
40
+ cwd: ".",
41
+ start: "node {prepareDir}/server.mjs",
42
+ port: 3010,
43
+ baseUrl: "http://127.0.0.1:{port}",
44
+ readyUrl: "http://127.0.0.1:{port}/health",
45
+ env: {},
46
+ },
47
+ },
48
+ };
49
+
50
+ const runtimeDir = path.join(productDir, ".testkit", "_graphs", "api", "runtimes", "runtime-1");
51
+ fs.mkdirSync(runtimeDir, { recursive: true });
52
+ return resolveRuntimeInstanceConfigs([rawConfig], "runtime-1", runtimeDir, {
53
+ graphDirName: "api",
54
+ portNamespaceIndex: 0,
55
+ portNamespaceStride: 1,
56
+ })[0];
57
+ }
58
+
59
+ describe("runtime preparation", () => {
60
+ it("runs prepare steps once and reuses the manifest until inputs change", async () => {
61
+ const productDir = makeTempDir("testkit-runtime-prepare-");
62
+ writeFile(path.join(productDir, "src", "message.txt"), "hello one\n");
63
+ writeFile(
64
+ path.join(productDir, "scripts", "prepare.mjs"),
65
+ [
66
+ 'import fs from "fs";',
67
+ 'import path from "path";',
68
+ 'const prepareDir = process.argv[2];',
69
+ 'const productDir = process.cwd();',
70
+ 'const message = fs.readFileSync(path.join(productDir, "src", "message.txt"), "utf8").trim();',
71
+ 'const counterDir = path.join(productDir, ".runtime", "prepared");',
72
+ 'const counterFile = path.join(counterDir, "build-count.txt");',
73
+ 'const current = fs.existsSync(counterFile) ? Number.parseInt(fs.readFileSync(counterFile, "utf8"), 10) || 0 : 0;',
74
+ 'fs.mkdirSync(counterDir, { recursive: true });',
75
+ 'fs.mkdirSync(prepareDir, { recursive: true });',
76
+ 'fs.writeFileSync(counterFile, `${current + 1}\\n`);',
77
+ 'fs.writeFileSync(path.join(prepareDir, "message.txt"), `${message}\\n`);',
78
+ ].join("\n")
79
+ );
80
+
81
+ const config = makeResolvedRuntimeConfig(productDir, {
82
+ inputs: ["src/message.txt"],
83
+ steps: [
84
+ {
85
+ kind: "command",
86
+ cmd: "node scripts/prepare.mjs {prepareDir}",
87
+ },
88
+ ],
89
+ });
90
+
91
+ await prepareRuntimeService(config);
92
+ expect(
93
+ fs.readFileSync(path.join(productDir, ".runtime", "prepared", "build-count.txt"), "utf8").trim()
94
+ ).toBe("1");
95
+ expect(fs.readFileSync(path.join(config.testkit.prepareDir, "message.txt"), "utf8").trim()).toBe(
96
+ "hello one"
97
+ );
98
+
99
+ await prepareRuntimeService(config);
100
+ expect(
101
+ fs.readFileSync(path.join(productDir, ".runtime", "prepared", "build-count.txt"), "utf8").trim()
102
+ ).toBe("1");
103
+
104
+ fs.writeFileSync(path.join(productDir, "src", "message.txt"), "hello two\n");
105
+ await prepareRuntimeService(config);
106
+ expect(
107
+ fs.readFileSync(path.join(productDir, ".runtime", "prepared", "build-count.txt"), "utf8").trim()
108
+ ).toBe("2");
109
+ expect(fs.readFileSync(path.join(config.testkit.prepareDir, "message.txt"), "utf8").trim()).toBe(
110
+ "hello two"
111
+ );
112
+ });
113
+
114
+ it("cleans incomplete prepared output when a prepare step fails", async () => {
115
+ const productDir = makeTempDir("testkit-runtime-prepare-fail-");
116
+ writeFile(
117
+ path.join(productDir, "scripts", "prepare-fail.mjs"),
118
+ [
119
+ 'import fs from "fs";',
120
+ 'import path from "path";',
121
+ 'const prepareDir = process.argv[2];',
122
+ 'fs.mkdirSync(prepareDir, { recursive: true });',
123
+ 'fs.writeFileSync(path.join(prepareDir, "partial.txt"), "partial\\n");',
124
+ 'throw new Error("prepare failed");',
125
+ ].join("\n")
126
+ );
127
+
128
+ const config = makeResolvedRuntimeConfig(productDir, {
129
+ steps: [
130
+ {
131
+ kind: "command",
132
+ cmd: "node scripts/prepare-fail.mjs {prepareDir}",
133
+ },
134
+ ],
135
+ });
136
+
137
+ await expect(prepareRuntimeService(config)).rejects.toThrow("Command failed with exit code 1");
138
+ expect(fs.existsSync(config.testkit.prepareDir)).toBe(false);
139
+ expect(fs.existsSync(path.join(productDir, ".testkit", "_graphs", "api", "runtimes", "runtime-1", "services", "api", "prepared", "prepare-manifest.json"))).toBe(false);
140
+ });
141
+ });
@@ -0,0 +1,191 @@
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
+
9
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
10
+ const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
11
+ const SETUP_ENTRY = path.join(PACKAGE_ROOT, "lib", "setup", "index.mjs");
12
+ const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
13
+ const KNOWN_FAILURES_ENTRY = path.join(PACKAGE_ROOT, "lib", "known-failures", "index.mjs");
14
+
15
+ export async function runConfiguredSteps({ config, steps = [], env, labelPrefix }) {
16
+ if (steps.length === 0) return;
17
+
18
+ for (const [index, step] of steps.entries()) {
19
+ const label = `${labelPrefix}:${config.name}:${index + 1}`;
20
+ console.log(`\n── ${label} ──`);
21
+ await runConfiguredStep(config, step, env);
22
+ }
23
+ }
24
+
25
+ export function collectConfiguredInputs(productDir, { inputs = [], steps = [] } = {}) {
26
+ const collected = new Set();
27
+ for (const input of inputs) {
28
+ collected.add(resolveConfiguredPath(productDir, null, input));
29
+ }
30
+ for (const step of steps) {
31
+ if (step.kind === "sql-file") {
32
+ collected.add(resolveConfiguredPath(productDir, step.cwd, step.path));
33
+ }
34
+ if (step.kind === "module") {
35
+ collected.add(
36
+ resolveConfiguredPath(productDir, step.cwd, parseModuleSpecifier(step.specifier).modulePath)
37
+ );
38
+ }
39
+ for (const input of step.inputs || []) {
40
+ collected.add(resolveConfiguredPath(productDir, step.cwd, input));
41
+ }
42
+ }
43
+ return [...collected].sort();
44
+ }
45
+
46
+ export function resolveConfiguredCwd(productDir, stepCwd) {
47
+ return resolveServiceCwd(productDir, stepCwd || ".");
48
+ }
49
+
50
+ export function resolveConfiguredPath(productDir, stepCwd, targetPath) {
51
+ return path.resolve(resolveConfiguredCwd(productDir, stepCwd), targetPath);
52
+ }
53
+
54
+ async function runConfiguredStep(config, step, env) {
55
+ if (step.kind === "command") {
56
+ await execaCommand(step.cmd, {
57
+ cwd: resolveConfiguredCwd(config.productDir, step.cwd),
58
+ env,
59
+ stdio: "inherit",
60
+ shell: true,
61
+ });
62
+ return;
63
+ }
64
+
65
+ if (step.kind === "sql-file") {
66
+ await execa(
67
+ "psql",
68
+ [
69
+ env.DATABASE_URL,
70
+ "-v",
71
+ "ON_ERROR_STOP=1",
72
+ "-X",
73
+ "-f",
74
+ resolveConfiguredPath(config.productDir, step.cwd, step.path),
75
+ ],
76
+ {
77
+ cwd: resolveConfiguredCwd(config.productDir, step.cwd),
78
+ env,
79
+ stdio: "inherit",
80
+ }
81
+ );
82
+ return;
83
+ }
84
+
85
+ if (step.kind === "module") {
86
+ const moduleRef = await loadConfiguredModule(config.productDir, step);
87
+ const { exportName } = parseModuleSpecifier(step.specifier);
88
+ const fn = moduleRef[exportName];
89
+ if (typeof fn !== "function") {
90
+ throw new Error(
91
+ `Template module step "${step.specifier}" did not export a function named "${exportName}"`
92
+ );
93
+ }
94
+
95
+ await withProcessContext(resolveConfiguredCwd(config.productDir, step.cwd), env, async () => {
96
+ await fn({
97
+ databaseUrl: env.DATABASE_URL || null,
98
+ productDir: config.productDir,
99
+ cwd: resolveConfiguredCwd(config.productDir, step.cwd),
100
+ serviceName: config.name,
101
+ env: { ...env },
102
+ runtimeId: config.runtimeId || null,
103
+ stateDir: config.stateDir,
104
+ prepareDir: config.testkit.prepareDir || null,
105
+ });
106
+ });
107
+ return;
108
+ }
109
+
110
+ throw new Error(`Unsupported template step kind "${step.kind}"`);
111
+ }
112
+
113
+ async function loadConfiguredModule(productDir, step) {
114
+ const { modulePath } = parseModuleSpecifier(step.specifier);
115
+ const absoluteModulePath = resolveConfiguredPath(productDir, step.cwd, modulePath);
116
+ const bundleDir = path.join(productDir, ".testkit", "_template-steps");
117
+ fs.mkdirSync(bundleDir, { recursive: true });
118
+
119
+ const cacheKey = buildModuleCacheKey(absoluteModulePath);
120
+ const outputFile = path.join(
121
+ bundleDir,
122
+ `${path.basename(modulePath).replace(/\W+/g, "-")}-${cacheKey.slice(0, 12)}.mjs`
123
+ );
124
+
125
+ await build({
126
+ absWorkingDir: productDir,
127
+ bundle: true,
128
+ entryPoints: [absoluteModulePath],
129
+ format: "esm",
130
+ legalComments: "none",
131
+ outfile: outputFile,
132
+ platform: "node",
133
+ sourcemap: "inline",
134
+ target: "es2020",
135
+ plugins: [testkitAliasPlugin()],
136
+ });
137
+
138
+ return import(`${pathToFileURL(outputFile).href}?v=${cacheKey}`);
139
+ }
140
+
141
+ function buildModuleCacheKey(modulePath) {
142
+ const content = fs.readFileSync(modulePath, "utf8");
143
+ return crypto.createHash("sha256").update(modulePath).update("\0").update(content).digest("hex");
144
+ }
145
+
146
+ function testkitAliasPlugin() {
147
+ return {
148
+ name: "testkit-template-step-alias",
149
+ setup(buildApi) {
150
+ buildApi.onResolve({ filter: /^@elench\/testkit(?:\/.*)?$/ }, (args) => ({
151
+ namespace: "file",
152
+ path: resolvePackageSubpath(args.path),
153
+ }));
154
+ },
155
+ };
156
+ }
157
+
158
+ function resolvePackageSubpath(specifier) {
159
+ const subpath = specifier.slice("@elench/testkit".length);
160
+ if (!subpath) return ROOT_ENTRY;
161
+ if (subpath === "/setup") return SETUP_ENTRY;
162
+ if (subpath === "/runtime") return RUNTIME_ENTRY;
163
+ if (subpath === "/known-failures") return KNOWN_FAILURES_ENTRY;
164
+
165
+ throw new Error(`Unsupported @elench/testkit import "${specifier}" while loading template step`);
166
+ }
167
+
168
+ function parseModuleSpecifier(specifier) {
169
+ const [modulePath, exportName] = String(specifier).split("#", 2);
170
+ return {
171
+ modulePath,
172
+ exportName: exportName || "default",
173
+ };
174
+ }
175
+
176
+ async function withProcessContext(cwd, env, fn) {
177
+ const previousCwd = process.cwd();
178
+ const previousEnv = process.env;
179
+ process.chdir(cwd);
180
+ process.env = {
181
+ ...previousEnv,
182
+ ...env,
183
+ };
184
+
185
+ try {
186
+ return await fn();
187
+ } finally {
188
+ process.chdir(previousCwd);
189
+ process.env = previousEnv;
190
+ }
191
+ }