@elench/testkit 0.1.50 → 0.1.51

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
@@ -67,6 +67,7 @@ import {
67
67
  localDatabase,
68
68
  moduleStep,
69
69
  nextService,
70
+ nodeToolchain,
70
71
  service,
71
72
  sqlFileStep,
72
73
  tsxService,
@@ -85,6 +86,13 @@ export default defineTestkitSetup({
85
86
  cacheTtlSeconds: 900,
86
87
  },
87
88
  },
89
+ toolchains: {
90
+ frontendNode: nodeToolchain({
91
+ cwd: "frontend",
92
+ detect: "auto",
93
+ install: "download",
94
+ }),
95
+ },
88
96
  services: {
89
97
  api: service({
90
98
  ...tsxService({
@@ -130,6 +138,7 @@ export default defineTestkitSetup({
130
138
  runtime: {
131
139
  instances: 1,
132
140
  maxConcurrentTasks: 2,
141
+ toolchain: "frontendNode",
133
142
  prepare: {
134
143
  inputs: ["frontend/src", "frontend/public", "frontend/package.json"],
135
144
  steps: [commandStep("npm run build", { cwd: "frontend" })],
@@ -164,6 +173,7 @@ for:
164
173
  - multi-service graphs
165
174
  - local runtime instance counts
166
175
  - per-runtime concurrent task caps
176
+ - repo-managed Node toolchains for prepare/start commands
167
177
  - one-time runtime preparation steps for stable shared servers
168
178
  - local DB binding configuration
169
179
  - template database migrate / seed / verify stages
@@ -181,6 +191,41 @@ inputs, and writes cache state under the service runtime directory. This is the
181
191
  right way to move expensive browser targets from `next dev` / watch mode to
182
192
  stable build-and-start flows.
183
193
 
194
+ `runtime.toolchain` is the first-class way to make those prepare/start commands
195
+ run under the correct Node toolchain instead of whatever `node`/`npm` happened
196
+ to launch `testkit`. Node toolchains support:
197
+
198
+ - host verification mode: `install: "require-host"`
199
+ - cached repo-local provisioning mode: `install: "download"`
200
+ - auto-detection from:
201
+ - `package.json#volta.node`
202
+ - `.nvmrc`
203
+ - `.node-version`
204
+ - `.tool-versions` (`nodejs`)
205
+ - `package.json#engines.node`
206
+ - `package.json#volta.npm`
207
+ - `package.json#packageManager`
208
+ - `package.json#engines.npm`
209
+
210
+ Example:
211
+
212
+ ```ts
213
+ toolchains: {
214
+ frontendNode: nodeToolchain({
215
+ cwd: "frontend",
216
+ detect: "auto",
217
+ install: "download",
218
+ }),
219
+ },
220
+ services: {
221
+ frontend: service({
222
+ runtime: {
223
+ toolchain: "frontendNode",
224
+ },
225
+ }),
226
+ }
227
+ ```
228
+
184
229
  If `reporting.knownFailuresFile` is configured, `testkit` enriches
185
230
  `.testkit/results/latest.json` and `testkit.status.json` with:
186
231
 
@@ -16,6 +16,10 @@ import {
16
16
  normalizeRuntimeInstances,
17
17
  } from "../runner/execution-config.mjs";
18
18
  import { normalizeKnownFailureIssueValidationConfig } from "../known-failures/github.mjs";
19
+ import {
20
+ normalizeRuntimeToolchain,
21
+ normalizeToolchainRegistry,
22
+ } from "../toolchains/index.mjs";
19
23
 
20
24
  const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
21
25
  const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
@@ -32,6 +36,7 @@ export async function loadConfigs(opts = {}) {
32
36
  const { setup, setupFile } = await loadTestkitSetup(productDir);
33
37
  const execution = normalizeRepoExecution(setup.execution);
34
38
  const reporting = normalizeReportingConfig(setup.reporting);
39
+ const toolchains = normalizeToolchainRegistry(setup.toolchains);
35
40
  const explicitServices = setup.services || {};
36
41
  const discovery = discoverProject(productDir, explicitServices);
37
42
  const serviceNames = new Set([
@@ -49,6 +54,7 @@ export async function loadConfigs(opts = {}) {
49
54
  setupFile,
50
55
  execution,
51
56
  reporting,
57
+ toolchains,
52
58
  explicitService: explicitServices[name] || {},
53
59
  discoveredService: discovery.services[name] || null,
54
60
  suites: discovery.suitesByService[name] || {},
@@ -109,6 +115,7 @@ function normalizeServiceConfig({
109
115
  setupFile,
110
116
  execution,
111
117
  reporting,
118
+ toolchains,
112
119
  explicitService,
113
120
  discoveredService,
114
121
  suites,
@@ -125,7 +132,7 @@ function normalizeServiceConfig({
125
132
  );
126
133
  }
127
134
  const database = normalizeDatabaseConfig(explicitService, name);
128
- const runtime = normalizeRuntimeConfig(explicitService.runtime, name);
135
+ const runtime = normalizeRuntimeConfig(explicitService.runtime, name, toolchains);
129
136
  const skip = normalizeSkipConfig(explicitService.skip, {
130
137
  name,
131
138
  productDir,
@@ -274,7 +281,7 @@ function normalizeDatabaseConfig(explicitService, serviceName) {
274
281
  };
275
282
  }
276
283
 
277
- function normalizeRuntimeConfig(value, serviceName) {
284
+ function normalizeRuntimeConfig(value, serviceName, toolchains) {
278
285
  if (!value) {
279
286
  return {
280
287
  instances: 1,
@@ -283,6 +290,7 @@ function normalizeRuntimeConfig(value, serviceName) {
283
290
  inputs: [],
284
291
  steps: [],
285
292
  },
293
+ toolchain: null,
286
294
  };
287
295
  }
288
296
 
@@ -296,6 +304,11 @@ function normalizeRuntimeConfig(value, serviceName) {
296
304
  `Service "${serviceName}" runtime.maxConcurrentTasks`
297
305
  ),
298
306
  prepare: normalizeRuntimePrepareConfig(value.prepare, serviceName),
307
+ toolchain: normalizeRuntimeToolchain(
308
+ value.toolchain,
309
+ `Service "${serviceName}" runtime.toolchain`,
310
+ toolchains || {}
311
+ ),
299
312
  };
300
313
  }
301
314
 
@@ -739,6 +752,9 @@ function validateServiceConfig({
739
752
  if (local?.cwd) {
740
753
  ensureExistingPath(productDir, local.cwd, `Service "${name}" local.cwd`);
741
754
  }
755
+ if (runtime.toolchain?.cwd) {
756
+ ensureExistingPath(productDir, runtime.toolchain.cwd, `Service "${name}" runtime.toolchain.cwd`);
757
+ }
742
758
  for (const [stageName, steps] of Object.entries(database?.template || {})) {
743
759
  if (stageName === "inputs") continue;
744
760
  for (const step of steps || []) {
@@ -3,6 +3,7 @@ import fs from "fs";
3
3
  import path from "path";
4
4
  import { resolveServiceCwd } from "../config/index.mjs";
5
5
  import { appendFileToHash, appendInputToHash } from "../database/fingerprint.mjs";
6
+ import { announceResolvedToolchain, resolveConfiguredToolchain } from "../toolchains/index.mjs";
6
7
  import { readDatabaseUrl } from "./state-io.mjs";
7
8
  import { buildExecutionEnv } from "./template.mjs";
8
9
  import {
@@ -42,6 +43,7 @@ export async function prepareRuntimeService(config) {
42
43
  }
43
44
 
44
45
  try {
46
+ await announceResolvedToolchain(config, await resolveConfiguredToolchain(config));
45
47
  await runConfiguredSteps({
46
48
  config,
47
49
  steps: prepare.steps,
@@ -61,10 +63,22 @@ export async function prepareRuntimeService(config) {
61
63
  }
62
64
 
63
65
  export async function computeRuntimePrepareFingerprint(config) {
66
+ const resolvedToolchain = await resolveConfiguredToolchain(config);
64
67
  const hash = crypto.createHash("sha256");
65
68
  hash.update(
66
69
  JSON.stringify({
67
70
  prepare: config.testkit.runtime.prepare || null,
71
+ toolchain: resolvedToolchain
72
+ ? {
73
+ kind: resolvedToolchain.kind,
74
+ install: resolvedToolchain.install,
75
+ nodeVersion: resolvedToolchain.nodeVersion,
76
+ npmVersion: resolvedToolchain.npmVersion,
77
+ nodeSource: resolvedToolchain.nodeSource,
78
+ npmSource: resolvedToolchain.npmSource,
79
+ fingerprint: resolvedToolchain.fingerprint,
80
+ }
81
+ : null,
68
82
  serviceEnv: config.testkit.serviceEnv || {},
69
83
  local: config.testkit.local
70
84
  ? {
@@ -1,4 +1,9 @@
1
1
  import { resolveServiceCwd } from "../config/index.mjs";
2
+ import {
3
+ announceResolvedToolchain,
4
+ applyToolchainEnv,
5
+ resolveConfiguredToolchain,
6
+ } from "../toolchains/index.mjs";
2
7
  import { buildExecutionEnv, numericPortFromUrl } from "./template.mjs";
3
8
  import { DEFAULT_READY_TIMEOUT_MS, assertLocalServicePortsAvailable, isPortInUse, waitForReady } from "./readiness.mjs";
4
9
  import { killChildProcess, pipeOutput, startDetachedCommand, stopChildProcess, sleep } from "./processes.mjs";
@@ -23,7 +28,12 @@ export async function startLocalServices(runtimeConfigs, lifecycle) {
23
28
 
24
29
  export async function startLocalService(config, lifecycle) {
25
30
  const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
26
- const env = buildExecutionEnv(config, config.testkit.local.env, process.env);
31
+ const resolvedToolchain = await resolveConfiguredToolchain(config);
32
+ await announceResolvedToolchain(config, resolvedToolchain);
33
+ const env = applyToolchainEnv(
34
+ buildExecutionEnv(config, config.testkit.local.env, process.env),
35
+ resolvedToolchain
36
+ );
27
37
  const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
28
38
  if (port) {
29
39
  env.PORT = String(port);
@@ -0,0 +1,25 @@
1
+ import fs from "fs";
2
+ import { pathToFileURL } from "url";
3
+
4
+ const [, , moduleFile, exportName, contextFile] = process.argv;
5
+
6
+ if (!moduleFile || !exportName || !contextFile) {
7
+ console.error("Usage: node template-step-module-runner.mjs <module-file> <export-name> <context-file>");
8
+ process.exit(1);
9
+ }
10
+
11
+ try {
12
+ const context = JSON.parse(fs.readFileSync(contextFile, "utf8"));
13
+ const moduleRef = await import(pathToFileURL(moduleFile).href);
14
+ const fn = moduleRef[exportName];
15
+ if (typeof fn !== "function") {
16
+ throw new Error(
17
+ `Template module step "${moduleFile}#${exportName}" did not export a function named "${exportName}"`
18
+ );
19
+ }
20
+
21
+ await fn(context);
22
+ } catch (error) {
23
+ console.error(error?.stack || error?.message || String(error));
24
+ process.exit(1);
25
+ }
@@ -3,22 +3,35 @@ import fs from "fs";
3
3
  import path from "path";
4
4
  import { build } from "esbuild";
5
5
  import { execa, execaCommand } from "execa";
6
- import { fileURLToPath, pathToFileURL } from "url";
6
+ import { fileURLToPath } from "url";
7
7
  import { resolveServiceCwd } from "../config/index.mjs";
8
+ import {
9
+ announceResolvedToolchain,
10
+ applyToolchainEnv,
11
+ resolveConfiguredToolchain,
12
+ } from "../toolchains/index.mjs";
8
13
 
9
14
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
10
15
  const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
11
16
  const SETUP_ENTRY = path.join(PACKAGE_ROOT, "lib", "setup", "index.mjs");
12
17
  const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
13
18
  const KNOWN_FAILURES_ENTRY = path.join(PACKAGE_ROOT, "lib", "known-failures", "index.mjs");
19
+ const MODULE_RUNNER_ENTRY = path.join(
20
+ PACKAGE_ROOT,
21
+ "lib",
22
+ "runner",
23
+ "template-step-module-runner.mjs"
24
+ );
14
25
 
15
26
  export async function runConfiguredSteps({ config, steps = [], env, labelPrefix }) {
16
27
  if (steps.length === 0) return;
28
+ const resolvedToolchain = await resolveConfiguredToolchain(config);
29
+ await announceResolvedToolchain(config, resolvedToolchain);
17
30
 
18
31
  for (const [index, step] of steps.entries()) {
19
32
  const label = `${labelPrefix}:${config.name}:${index + 1}`;
20
33
  console.log(`\n── ${label} ──`);
21
- await runConfiguredStep(config, step, env);
34
+ await runConfiguredStep(config, step, env, resolvedToolchain);
22
35
  }
23
36
  }
24
37
 
@@ -51,11 +64,14 @@ export function resolveConfiguredPath(productDir, stepCwd, targetPath) {
51
64
  return path.resolve(resolveConfiguredCwd(productDir, stepCwd), targetPath);
52
65
  }
53
66
 
54
- async function runConfiguredStep(config, step, env) {
67
+ async function runConfiguredStep(config, step, env, resolvedToolchain) {
68
+ const runtimeEnv = applyToolchainEnv(env, resolvedToolchain);
69
+ const cwd = resolveConfiguredCwd(config.productDir, step.cwd);
70
+
55
71
  if (step.kind === "command") {
56
72
  await execaCommand(step.cmd, {
57
- cwd: resolveConfiguredCwd(config.productDir, step.cwd),
58
- env,
73
+ cwd,
74
+ env: runtimeEnv,
59
75
  stdio: "inherit",
60
76
  shell: true,
61
77
  });
@@ -66,7 +82,7 @@ async function runConfiguredStep(config, step, env) {
66
82
  await execa(
67
83
  "psql",
68
84
  [
69
- env.DATABASE_URL,
85
+ runtimeEnv.DATABASE_URL,
70
86
  "-v",
71
87
  "ON_ERROR_STOP=1",
72
88
  "-X",
@@ -74,8 +90,8 @@ async function runConfiguredStep(config, step, env) {
74
90
  resolveConfiguredPath(config.productDir, step.cwd, step.path),
75
91
  ],
76
92
  {
77
- cwd: resolveConfiguredCwd(config.productDir, step.cwd),
78
- env,
93
+ cwd,
94
+ env: runtimeEnv,
79
95
  stdio: "inherit",
80
96
  }
81
97
  );
@@ -83,34 +99,41 @@ async function runConfiguredStep(config, step, env) {
83
99
  }
84
100
 
85
101
  if (step.kind === "module") {
86
- const moduleRef = await loadConfiguredModule(config.productDir, step);
102
+ const bundledModule = await bundleConfiguredModule(config.productDir, step);
87
103
  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}"`
104
+ const context = {
105
+ databaseUrl: runtimeEnv.DATABASE_URL || null,
106
+ productDir: config.productDir,
107
+ cwd,
108
+ serviceName: config.name,
109
+ env: { ...runtimeEnv },
110
+ runtimeId: config.runtimeId || null,
111
+ stateDir: config.stateDir,
112
+ prepareDir: config.testkit.prepareDir || null,
113
+ };
114
+ const contextPath = `${bundledModule.outputFile}.context.json`;
115
+ fs.writeFileSync(contextPath, JSON.stringify(context));
116
+
117
+ try {
118
+ await execa(
119
+ resolvedToolchain?.nodeExecutable || process.execPath,
120
+ [MODULE_RUNNER_ENTRY, bundledModule.outputFile, exportName, contextPath],
121
+ {
122
+ cwd,
123
+ env: runtimeEnv,
124
+ stdio: "inherit",
125
+ }
92
126
  );
127
+ } finally {
128
+ fs.rmSync(contextPath, { force: true });
93
129
  }
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
130
  return;
108
131
  }
109
132
 
110
133
  throw new Error(`Unsupported template step kind "${step.kind}"`);
111
134
  }
112
135
 
113
- async function loadConfiguredModule(productDir, step) {
136
+ async function bundleConfiguredModule(productDir, step) {
114
137
  const { modulePath } = parseModuleSpecifier(step.specifier);
115
138
  const absoluteModulePath = resolveConfiguredPath(productDir, step.cwd, modulePath);
116
139
  const bundleDir = path.join(productDir, ".testkit", "_template-steps");
@@ -135,7 +158,10 @@ async function loadConfiguredModule(productDir, step) {
135
158
  plugins: [testkitAliasPlugin()],
136
159
  });
137
160
 
138
- return import(`${pathToFileURL(outputFile).href}?v=${cacheKey}`);
161
+ return {
162
+ outputFile,
163
+ cacheKey,
164
+ };
139
165
  }
140
166
 
141
167
  function buildModuleCacheKey(modulePath) {
@@ -172,20 +198,3 @@ function parseModuleSpecifier(specifier) {
172
198
  exportName: exportName || "default",
173
199
  };
174
200
  }
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
- }
@@ -62,8 +62,20 @@ export interface RuntimeConfig {
62
62
  inputs?: string[];
63
63
  steps?: TemplateLifecycleStepConfig[];
64
64
  };
65
+ toolchain?: string | NodeToolchainConfig;
65
66
  }
66
67
 
68
+ export interface NodeToolchainConfig {
69
+ kind?: "node";
70
+ cwd?: string;
71
+ detect?: "auto" | "off";
72
+ install?: "require-host" | "download";
73
+ node?: string;
74
+ npm?: string;
75
+ }
76
+
77
+ export type ToolchainConfig = NodeToolchainConfig;
78
+
67
79
  export interface SuiteRequirementRule {
68
80
  selector: string;
69
81
  locks?: string[];
@@ -124,6 +136,7 @@ export interface TestkitSetup {
124
136
  issueValidation?: KnownFailureIssueValidationConfig;
125
137
  };
126
138
  services?: Record<string, ServiceConfig>;
139
+ toolchains?: Record<string, ToolchainConfig>;
127
140
  telemetry?: {
128
141
  enabled?: boolean;
129
142
  endpoint?: string;
@@ -148,6 +161,7 @@ export declare function moduleStep(
148
161
  specifier: string,
149
162
  options?: Omit<TemplateModuleStepConfig, "kind" | "specifier">
150
163
  ): TemplateModuleStepConfig;
164
+ export declare function nodeToolchain(options?: NodeToolchainConfig): NodeToolchainConfig;
151
165
  export declare function goService(options: ServiceConfig["local"] & {
152
166
  command?: string;
153
167
  entrypoint?: string;
@@ -56,6 +56,13 @@ export function moduleStep(specifier, options = {}) {
56
56
  };
57
57
  }
58
58
 
59
+ export function nodeToolchain(options = {}) {
60
+ return {
61
+ kind: "node",
62
+ ...options,
63
+ };
64
+ }
65
+
59
66
  export function goService(options = {}) {
60
67
  const cwd = options.cwd || ".";
61
68
  const port = requiredNumber(options.port, "goService port");
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { goService, nextService, tsxService } from "./index.mjs";
2
+ import { goService, nextService, nodeToolchain, tsxService } from "./index.mjs";
3
3
 
4
4
  describe("setup helpers", () => {
5
5
  it("emits plain next start commands without an exec prefix", () => {
@@ -23,4 +23,12 @@ describe("setup helpers", () => {
23
23
  "go run ./cmd/worker"
24
24
  );
25
25
  });
26
+
27
+ it("builds node toolchain profiles with a node kind", () => {
28
+ expect(nodeToolchain({ node: "20.19.5", install: "download" })).toEqual({
29
+ kind: "node",
30
+ node: "20.19.5",
31
+ install: "download",
32
+ });
33
+ });
26
34
  });