@elench/testkit 0.1.49 → 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.
@@ -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
- }
@@ -36,6 +36,27 @@ export async function runWorker(
36
36
  runtimeManager.canAcquire(candidate)
37
37
  );
38
38
  if (!task) {
39
+ const blockedTasks = runtimeManager.drainFailedTasks(queue);
40
+ if (blockedTasks.length > 0) {
41
+ const now = Date.now();
42
+ const recordedGraphs = new Set();
43
+ for (const blocked of blockedTasks) {
44
+ recordTaskOutcome(trackers, blocked.task, {
45
+ failed: false,
46
+ status: "not_run",
47
+ reason: blocked.reason,
48
+ durationMs: 0,
49
+ startedAt: now,
50
+ finishedAt: now,
51
+ error: null,
52
+ });
53
+ if (!recordedGraphs.has(blocked.graph.key)) {
54
+ recordGraphError(trackers, blocked.graph, blocked.message, now);
55
+ recordedGraphs.add(blocked.graph.key);
56
+ }
57
+ }
58
+ continue;
59
+ }
39
60
  if (queue.length === 0) break;
40
61
  await new Promise((resolve) => setTimeout(resolve, 10));
41
62
  continue;
@@ -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");
@@ -85,7 +92,7 @@ export function nextService(options = {}) {
85
92
  ...service(options),
86
93
  local: {
87
94
  cwd,
88
- start: options.start || "exec ./node_modules/.bin/next dev -p {port}",
95
+ start: options.start || "./node_modules/.bin/next dev -p {port}",
89
96
  port,
90
97
  baseUrl,
91
98
  readyUrl: options.readyUrl || baseUrl,
@@ -105,7 +112,7 @@ export function tsxService(options = {}) {
105
112
  ...service(options),
106
113
  local: {
107
114
  cwd,
108
- start: options.start || `exec ./node_modules/.bin/tsx watch ${entry}`,
115
+ start: options.start || `./node_modules/.bin/tsx watch ${entry}`,
109
116
  port,
110
117
  baseUrl,
111
118
  readyUrl: options.readyUrl || `${baseUrl}${options.readyPath || "/health"}`,
@@ -196,12 +203,12 @@ export {
196
203
 
197
204
  function defaultGoStartCommand(options) {
198
205
  if (options.command) {
199
- return `exec go run ${options.command}`;
206
+ return `go run ${options.command}`;
200
207
  }
201
208
  if (options.entrypoint) {
202
- return `exec go run ${options.entrypoint}`;
209
+ return `go run ${options.entrypoint}`;
203
210
  }
204
- return "exec go run ./cmd/server";
211
+ return "go run ./cmd/server";
205
212
  }
206
213
 
207
214
  function requiredNumber(value, label) {
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { goService, nextService, nodeToolchain, tsxService } from "./index.mjs";
3
+
4
+ describe("setup helpers", () => {
5
+ it("emits plain next start commands without an exec prefix", () => {
6
+ const config = nextService({ port: 3000 });
7
+
8
+ expect(config.local.start).toBe("./node_modules/.bin/next dev -p {port}");
9
+ });
10
+
11
+ it("emits plain tsx start commands without an exec prefix", () => {
12
+ const config = tsxService({ port: 3000, entry: "src/server.ts" });
13
+
14
+ expect(config.local.start).toBe("./node_modules/.bin/tsx watch src/server.ts");
15
+ });
16
+
17
+ it("emits plain go start commands without an exec prefix", () => {
18
+ expect(goService({ port: 3000 }).local.start).toBe("go run ./cmd/server");
19
+ expect(goService({ port: 3000, entrypoint: "./cmd/api" }).local.start).toBe(
20
+ "go run ./cmd/api"
21
+ );
22
+ expect(goService({ port: 3000, command: "./cmd/worker" }).local.start).toBe(
23
+ "go run ./cmd/worker"
24
+ );
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
+ });
34
+ });