@elench/testkit 0.1.48 → 0.1.49

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: "exec ./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
 
@@ -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) {
@@ -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
- }
@@ -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;
@@ -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
+ }
@@ -113,12 +113,14 @@ export function resolveRuntimeConfig(
113
113
  urlMappings
114
114
  ) {
115
115
  const stateDir = resolveServiceStateDir(runtimeDir, config);
116
+ const prepareDir = resolveServicePrepareDir(runtimeDir, config);
116
117
  const context = {
117
118
  runtimeId,
118
119
  runtimeLabel,
119
120
  runtimeDir,
120
121
  serviceName: config.name,
121
122
  serviceStateDir: stateDir,
123
+ prepareDir,
122
124
  portMap,
123
125
  baseUrlByService,
124
126
  readyUrlByService,
@@ -135,6 +137,11 @@ export function resolveRuntimeConfig(
135
137
  }
136
138
  : undefined;
137
139
 
140
+ const runtime = {
141
+ ...config.testkit.runtime,
142
+ prepare: finalizeRuntimePrepare(config.testkit.runtime.prepare, context),
143
+ };
144
+
138
145
  const local = config.testkit.local
139
146
  ? {
140
147
  ...config.testkit.local,
@@ -158,7 +165,9 @@ export function resolveRuntimeConfig(
158
165
  testkit: {
159
166
  ...config.testkit,
160
167
  database,
168
+ prepareDir,
161
169
  templateContext: context,
170
+ runtime,
162
171
  local,
163
172
  },
164
173
  };
@@ -197,6 +206,10 @@ export function resolveServiceStateDir(runtimeDir, config) {
197
206
  return path.join(runtimeDir, "services", config.name);
198
207
  }
199
208
 
209
+ export function resolveServicePrepareDir(runtimeDir, config) {
210
+ return path.join(resolveServiceStateDir(runtimeDir, config), "prepared");
211
+ }
212
+
200
213
  export function buildExecutionEnv(config, extraEnv = {}, processEnv = process.env) {
201
214
  return buildExecutionEnvWithContext(config, null, extraEnv, processEnv);
202
215
  }
@@ -244,6 +257,7 @@ function buildTemplateContext(config, lease) {
244
257
  runtimeLabel: config.runtimeLabel || baseContext.runtimeLabel || null,
245
258
  serviceName: config.name,
246
259
  serviceStateDir: config.stateDir || baseContext.serviceStateDir || null,
260
+ prepareDir: config.testkit?.prepareDir || baseContext.prepareDir || null,
247
261
  leaseId: lease?.leaseId || null,
248
262
  leaseDir: lease?.leaseDir || null,
249
263
  };
@@ -280,6 +294,8 @@ export function resolveTemplateString(value, context) {
280
294
  return context.serviceName;
281
295
  case "stateDir":
282
296
  return context.serviceStateDir;
297
+ case "prepareDir":
298
+ return context.prepareDir || "";
283
299
  case "lease":
284
300
  return context.leaseId ? String(context.leaseId) : "";
285
301
  case "leaseDir":
@@ -332,6 +348,31 @@ function resolveEnvTemplates(values, templateContext) {
332
348
  );
333
349
  }
334
350
 
351
+ function finalizeRuntimePrepare(prepare, context) {
352
+ if (!prepare) {
353
+ return {
354
+ inputs: [],
355
+ steps: [],
356
+ };
357
+ }
358
+
359
+ const finalizeStep = (step) => ({
360
+ ...step,
361
+ ...(typeof step.cmd === "string" ? { cmd: finalizeString(step.cmd, context) } : {}),
362
+ ...(typeof step.cwd === "string" ? { cwd: finalizeString(step.cwd, context) } : {}),
363
+ ...(typeof step.path === "string" ? { path: finalizeString(step.path, context) } : {}),
364
+ ...(typeof step.specifier === "string"
365
+ ? { specifier: finalizeString(step.specifier, context) }
366
+ : {}),
367
+ inputs: (step.inputs || []).map((input) => finalizeString(input, context)),
368
+ });
369
+
370
+ return {
371
+ inputs: (prepare.inputs || []).map((input) => finalizeString(input, context)),
372
+ steps: (prepare.steps || []).map(finalizeStep),
373
+ };
374
+ }
375
+
335
376
  function resolveDatabaseTemplateValue(token, serviceName, context) {
336
377
  const stateDir = context.stateDirByService?.get(serviceName);
337
378
  if (!stateDir) {
@@ -22,8 +22,18 @@ function makeRuntimeConfig(name, local, extras = {}) {
22
22
  name,
23
23
  stateDir: extras.stateDir,
24
24
  runtimeId: extras.runtimeId,
25
+ productDir: extras.productDir || process.cwd(),
25
26
  testkit: {
26
27
  local,
28
+ runtime: extras.runtime || {
29
+ instances: 1,
30
+ maxConcurrentTasks: Infinity,
31
+ prepare: {
32
+ inputs: [],
33
+ steps: [],
34
+ },
35
+ },
36
+ envFiles: extras.envFiles || [],
27
37
  serviceEnv: extras.serviceEnv || {},
28
38
  databaseFrom: extras.databaseFrom,
29
39
  database: extras.database,
@@ -88,6 +98,9 @@ describe("runner-template", () => {
88
98
  expect(resolveTemplateString("{runtime}:{service}:{lease}", context)).toBe(
89
99
  "runtime-2:frontend:lease-1"
90
100
  );
101
+ expect(resolveTemplateString("{prepareDir}", { ...context, prepareDir: "/tmp/prepare-1" })).toBe(
102
+ "/tmp/prepare-1"
103
+ );
91
104
  expect(resolveTemplateString("{baseUrl:api}", context)).toBe("http://127.0.0.1:3100");
92
105
  expect(resolveTemplateString("{dbHost:api}:{dbPort:api}/{dbName:api}", context)).toBe(
93
106
  "127.0.0.1:55432/runtime_db"
@@ -140,6 +153,7 @@ describe("runner-template", () => {
140
153
  expect(resolved[0].testkit.local.port).toBe(3100);
141
154
  expect(resolved[0].runtimeLabel).toBe("api__frontend/runtime-2");
142
155
  expect(resolveServiceStateDir(runtimeDir, api)).toBe(`${runtimeDir}/services/api`);
156
+ expect(resolved[0].testkit.prepareDir).toBe(`${runtimeDir}/services/api/prepared`);
143
157
 
144
158
  fs.mkdirSync(path.join(runtimeDir, "services", "api"), { recursive: true });
145
159
  fs.writeFileSync(
@@ -197,6 +211,56 @@ describe("runner-template", () => {
197
211
  });
198
212
  });
199
213
 
214
+ it("finalizes runtime.prepare templates with prepareDir", () => {
215
+ const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-runtime-prepare-"));
216
+ const api = makeRuntimeConfig(
217
+ "api",
218
+ {
219
+ cwd: ".",
220
+ start: "npm run api",
221
+ port: 3000,
222
+ baseUrl: "http://127.0.0.1:{port}",
223
+ readyUrl: "http://127.0.0.1:{port}/health",
224
+ env: {},
225
+ },
226
+ {
227
+ runtime: {
228
+ instances: 1,
229
+ maxConcurrentTasks: 2,
230
+ prepare: {
231
+ inputs: ["src/{service}.ts"],
232
+ steps: [
233
+ {
234
+ kind: "command",
235
+ cmd: "node scripts/prepare.mjs {prepareDir}",
236
+ cwd: ".",
237
+ inputs: ["src/{service}.ts"],
238
+ },
239
+ ],
240
+ },
241
+ },
242
+ }
243
+ );
244
+
245
+ const [resolved] = resolveRuntimeInstanceConfigs([api], "runtime-1", runtimeDir, {
246
+ graphDirName: "api",
247
+ portNamespaceIndex: 0,
248
+ portNamespaceStride: 1,
249
+ });
250
+
251
+ expect(resolved.testkit.runtime.prepare).toEqual({
252
+ inputs: ["src/api.ts"],
253
+ steps: [
254
+ {
255
+ kind: "command",
256
+ cmd: `node scripts/prepare.mjs ${runtimeDir}/services/api/prepared`,
257
+ cwd: ".",
258
+ inputs: ["src/api.ts"],
259
+ },
260
+ ],
261
+ });
262
+ });
263
+
200
264
  it("parses runtime sockets", () => {
201
265
  expect(numericPortFromUrl("http://localhost:3000")).toBe(3000);
202
266
  expect(socketFromUrl("http://localhost:3000")).toEqual({
@@ -58,6 +58,10 @@ export interface SkipConfig {
58
58
  export interface RuntimeConfig {
59
59
  instances?: number;
60
60
  maxConcurrentTasks?: number;
61
+ prepare?: {
62
+ inputs?: string[];
63
+ steps?: TemplateLifecycleStepConfig[];
64
+ };
61
65
  }
62
66
 
63
67
  export interface SuiteRequirementRule {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.48",
3
+ "version": "0.1.49",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "types": "./lib/index.d.ts",