@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.
@@ -0,0 +1,565 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { execa } from "execa";
6
+ import { resolveServiceCwd } from "../config/index.mjs";
7
+ import {
8
+ deriveSemverMinimum,
9
+ formatSemver,
10
+ isExactSemver,
11
+ normalizeSemver,
12
+ satisfiesSemver,
13
+ } from "./semver.mjs";
14
+
15
+ const DEFAULT_TOOLCHAIN_CWD = ".";
16
+ const DEFAULT_TOOLCHAIN_DETECT = "auto";
17
+ const DEFAULT_TOOLCHAIN_INSTALL = "require-host";
18
+
19
+ const resolvedToolchainPromises = new WeakMap();
20
+ const announcedToolchains = new WeakSet();
21
+
22
+ export function nodeToolchain(options = {}) {
23
+ return {
24
+ kind: "node",
25
+ ...options,
26
+ };
27
+ }
28
+
29
+ export function normalizeToolchainRegistry(value) {
30
+ if (value == null) return {};
31
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
32
+ throw new Error("testkit.setup.ts toolchains must be an object");
33
+ }
34
+
35
+ const normalized = {};
36
+ for (const [name, config] of Object.entries(value)) {
37
+ normalized[name] = normalizeToolchainConfig(config, `testkit.setup.ts toolchains.${name}`);
38
+ }
39
+ return normalized;
40
+ }
41
+
42
+ export function normalizeRuntimeToolchain(value, label, registry = {}) {
43
+ if (value == null) return null;
44
+
45
+ if (typeof value === "string") {
46
+ const name = value.trim();
47
+ if (!name) {
48
+ throw new Error(`${label} must reference a non-empty toolchain name`);
49
+ }
50
+ const referenced = registry[name];
51
+ if (!referenced) {
52
+ throw new Error(`${label} references unknown toolchain "${name}"`);
53
+ }
54
+ return {
55
+ ...referenced,
56
+ refName: name,
57
+ sourceLabel: `${label} -> ${name}`,
58
+ };
59
+ }
60
+
61
+ const normalized = normalizeToolchainConfig(value, label);
62
+ return {
63
+ ...normalized,
64
+ refName: null,
65
+ sourceLabel: label,
66
+ };
67
+ }
68
+
69
+ export async function resolveConfiguredToolchain(config, options = {}) {
70
+ const toolchain = config?.testkit?.runtime?.toolchain;
71
+ if (!toolchain) return null;
72
+
73
+ const existing = resolvedToolchainPromises.get(config);
74
+ if (existing) return existing;
75
+
76
+ const promise = resolveToolchain(config, toolchain, options);
77
+ resolvedToolchainPromises.set(config, promise);
78
+
79
+ try {
80
+ return await promise;
81
+ } catch (error) {
82
+ resolvedToolchainPromises.delete(config);
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ export async function announceResolvedToolchain(config, resolvedToolchain) {
88
+ if (!resolvedToolchain || announcedToolchains.has(config)) return;
89
+ announcedToolchains.add(config);
90
+ console.log(
91
+ `[testkit] ${config.runtimeLabel || config.name}:${config.name} toolchain ` +
92
+ `${resolvedToolchain.summary}`
93
+ );
94
+ }
95
+
96
+ export function applyToolchainEnv(baseEnv, resolvedToolchain, processEnv = process.env) {
97
+ if (!resolvedToolchain) return { ...baseEnv };
98
+
99
+ const env = {
100
+ ...baseEnv,
101
+ PATH: prependPathEntry(
102
+ resolvedToolchain.binDir,
103
+ baseEnv.PATH || processEnv.PATH || ""
104
+ ),
105
+ TESTKIT_TOOLCHAIN_KIND: resolvedToolchain.kind,
106
+ TESTKIT_TOOLCHAIN_NODE_VERSION: resolvedToolchain.nodeVersion,
107
+ TESTKIT_TOOLCHAIN_NODE_SOURCE: resolvedToolchain.nodeSource,
108
+ TESTKIT_TOOLCHAIN_INSTALL_MODE: resolvedToolchain.install,
109
+ };
110
+
111
+ if (resolvedToolchain.npmVersion) {
112
+ env.TESTKIT_TOOLCHAIN_NPM_VERSION = resolvedToolchain.npmVersion;
113
+ env.TESTKIT_TOOLCHAIN_NPM_SOURCE = resolvedToolchain.npmSource;
114
+ }
115
+
116
+ return env;
117
+ }
118
+
119
+ function normalizeToolchainConfig(value, label) {
120
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
121
+ throw new Error(`${label} must be an object`);
122
+ }
123
+
124
+ const kind = normalizeOptionalString(value.kind) || "node";
125
+ if (kind !== "node") {
126
+ throw new Error(`${label}.kind must be "node"`);
127
+ }
128
+
129
+ const cwd = normalizeOptionalString(value.cwd) || DEFAULT_TOOLCHAIN_CWD;
130
+ const detect = normalizeOptionalString(value.detect) || DEFAULT_TOOLCHAIN_DETECT;
131
+ const install = normalizeOptionalString(value.install) || DEFAULT_TOOLCHAIN_INSTALL;
132
+
133
+ if (!["auto", "off"].includes(detect)) {
134
+ throw new Error(`${label}.detect must be "auto" or "off"`);
135
+ }
136
+ if (!["require-host", "download"].includes(install)) {
137
+ throw new Error(`${label}.install must be "require-host" or "download"`);
138
+ }
139
+
140
+ return {
141
+ kind: "node",
142
+ cwd,
143
+ detect,
144
+ install,
145
+ node: normalizeOptionalString(value.node),
146
+ npm: normalizeOptionalString(value.npm),
147
+ };
148
+ }
149
+
150
+ async function resolveToolchain(config, toolchain, options) {
151
+ if (toolchain.kind !== "node") {
152
+ throw new Error(`Unsupported toolchain kind "${toolchain.kind}"`);
153
+ }
154
+
155
+ const productDir = config.productDir;
156
+ const cwd = resolveServiceCwd(productDir, toolchain.cwd || DEFAULT_TOOLCHAIN_CWD);
157
+ const detected = detectNodeToolchainMetadata(cwd);
158
+ const nodeSpec = toolchain.node || (toolchain.detect === "auto" ? detected.nodeSpec : null);
159
+ const nodeSource =
160
+ (toolchain.node && "toolchain.node") ||
161
+ (toolchain.detect === "auto" ? detected.nodeSource : null) ||
162
+ "none";
163
+ const npmSpec = toolchain.npm || (toolchain.detect === "auto" ? detected.npmSpec : null);
164
+ const npmSource =
165
+ (toolchain.npm && "toolchain.npm") ||
166
+ (toolchain.detect === "auto" ? detected.npmSource : null) ||
167
+ "none";
168
+
169
+ if (!nodeSpec && !npmSpec) {
170
+ throw new Error(
171
+ `Service "${config.name}" enables runtime.toolchain but no Node/npm version could be resolved in ${toolchain.cwd}`
172
+ );
173
+ }
174
+
175
+ if (toolchain.install === "require-host") {
176
+ return resolveHostNodeToolchain({
177
+ config,
178
+ cwd,
179
+ nodeSpec,
180
+ nodeSource,
181
+ npmSpec,
182
+ npmSource,
183
+ toolchain,
184
+ options,
185
+ });
186
+ }
187
+
188
+ return resolveDownloadedNodeToolchain({
189
+ config,
190
+ cwd,
191
+ nodeSpec,
192
+ nodeSource,
193
+ npmSpec,
194
+ npmSource,
195
+ toolchain,
196
+ options,
197
+ });
198
+ }
199
+
200
+ function detectNodeToolchainMetadata(cwd) {
201
+ const packageJsonPath = path.join(cwd, "package.json");
202
+ const packageJson = readJsonFile(packageJsonPath);
203
+ const toolVersions = readToolVersions(path.join(cwd, ".tool-versions"));
204
+ const nvmrc = readFirstLine(path.join(cwd, ".nvmrc"));
205
+ const nodeVersionFile = readFirstLine(path.join(cwd, ".node-version"));
206
+
207
+ const nodeSpec =
208
+ normalizeOptionalString(packageJson?.volta?.node) ||
209
+ normalizeOptionalString(nvmrc) ||
210
+ normalizeOptionalString(nodeVersionFile) ||
211
+ normalizeOptionalString(toolVersions.nodejs) ||
212
+ normalizeOptionalString(packageJson?.engines?.node);
213
+
214
+ const nodeSource =
215
+ (normalizeOptionalString(packageJson?.volta?.node) && "package.json#volta.node") ||
216
+ (normalizeOptionalString(nvmrc) && ".nvmrc") ||
217
+ (normalizeOptionalString(nodeVersionFile) && ".node-version") ||
218
+ (normalizeOptionalString(toolVersions.nodejs) && ".tool-versions#nodejs") ||
219
+ (normalizeOptionalString(packageJson?.engines?.node) && "package.json#engines.node") ||
220
+ null;
221
+
222
+ const packageManager = parsePackageManager(packageJson?.packageManager);
223
+ const npmSpec =
224
+ normalizeOptionalString(packageJson?.volta?.npm) ||
225
+ (packageManager?.name === "npm" ? packageManager.version : null) ||
226
+ normalizeOptionalString(packageJson?.engines?.npm);
227
+
228
+ const npmSource =
229
+ (normalizeOptionalString(packageJson?.volta?.npm) && "package.json#volta.npm") ||
230
+ ((packageManager?.name === "npm" && packageManager.version) && "package.json#packageManager") ||
231
+ (normalizeOptionalString(packageJson?.engines?.npm) && "package.json#engines.npm") ||
232
+ null;
233
+
234
+ return {
235
+ nodeSpec,
236
+ nodeSource,
237
+ npmSpec,
238
+ npmSource,
239
+ };
240
+ }
241
+
242
+ async function resolveHostNodeToolchain({
243
+ config,
244
+ cwd,
245
+ nodeSpec,
246
+ nodeSource,
247
+ npmSpec,
248
+ npmSource,
249
+ toolchain,
250
+ options,
251
+ }) {
252
+ const hostNodeVersion = normalizeSemver(options.hostNodeVersion || process.versions.node);
253
+ if (nodeSpec && !satisfiesSemver(hostNodeVersion, nodeSpec)) {
254
+ throw new Error(
255
+ `Service "${config.name}" requires Node ${nodeSpec} (${nodeSource}), but host Node is ${hostNodeVersion}`
256
+ );
257
+ }
258
+
259
+ let hostNpmVersion = null;
260
+ if (npmSpec) {
261
+ hostNpmVersion = normalizeSemver(
262
+ options.hostNpmVersion ||
263
+ (await readCommandVersion({
264
+ command: "npm",
265
+ args: ["--version"],
266
+ cwd,
267
+ env: process.env,
268
+ }))
269
+ );
270
+ if (!satisfiesSemver(hostNpmVersion, npmSpec)) {
271
+ throw new Error(
272
+ `Service "${config.name}" requires npm ${npmSpec} (${npmSource}), but host npm is ${hostNpmVersion}`
273
+ );
274
+ }
275
+ }
276
+
277
+ return {
278
+ kind: "node",
279
+ install: toolchain.install,
280
+ cwd: toolchain.cwd,
281
+ nodeVersion: hostNodeVersion,
282
+ nodeSource,
283
+ nodeSpec,
284
+ npmVersion: hostNpmVersion,
285
+ npmSource,
286
+ npmSpec,
287
+ nodeExecutable: process.execPath,
288
+ binDir: path.dirname(process.execPath),
289
+ toolchainDir: path.dirname(process.execPath),
290
+ fingerprint: buildToolchainFingerprint({
291
+ kind: "node",
292
+ install: toolchain.install,
293
+ nodeVersion: hostNodeVersion,
294
+ npmVersion: hostNpmVersion,
295
+ nodeSource,
296
+ npmSource,
297
+ }),
298
+ summary: `${toolchain.install} node=${hostNodeVersion}${hostNpmVersion ? ` npm=${hostNpmVersion}` : ""} (${nodeSource})`,
299
+ };
300
+ }
301
+
302
+ async function resolveDownloadedNodeToolchain({
303
+ config,
304
+ cwd,
305
+ nodeSpec,
306
+ nodeSource,
307
+ npmSpec,
308
+ npmSource,
309
+ toolchain,
310
+ options,
311
+ }) {
312
+ if (!nodeSpec) {
313
+ throw new Error(
314
+ `Service "${config.name}" download toolchain mode requires a resolvable Node version`
315
+ );
316
+ }
317
+
318
+ const requestedNodeVersion = isExactSemver(nodeSpec)
319
+ ? normalizeSemver(nodeSpec)
320
+ : deriveSemverMinimum(nodeSpec);
321
+ if (!requestedNodeVersion) {
322
+ throw new Error(
323
+ `Service "${config.name}" cannot deterministically provision Node from "${nodeSpec}". ` +
324
+ `Provide an exact version in runtime.toolchain.node, .nvmrc, .node-version, .tool-versions, or package.json#volta.node.`
325
+ );
326
+ }
327
+
328
+ const provisioned = await provisionNodeToolchain({
329
+ productDir: config.productDir,
330
+ version: requestedNodeVersion,
331
+ fetchImpl: options.fetchImpl || fetch,
332
+ execaImpl: options.execaImpl || execa,
333
+ });
334
+
335
+ if (!satisfiesSemver(requestedNodeVersion, nodeSpec)) {
336
+ throw new Error(
337
+ `Service "${config.name}" resolved Node ${requestedNodeVersion}, which does not satisfy ${nodeSpec}`
338
+ );
339
+ }
340
+
341
+ const npmVersion = provisioned.npmVersion ? normalizeSemver(provisioned.npmVersion) : null;
342
+ if (npmSpec && (!npmVersion || !satisfiesSemver(npmVersion, npmSpec))) {
343
+ throw new Error(
344
+ `Service "${config.name}" requires npm ${npmSpec} (${npmSource}), but Node ${requestedNodeVersion} bundles npm ${npmVersion || "unknown"}`
345
+ );
346
+ }
347
+
348
+ return {
349
+ kind: "node",
350
+ install: toolchain.install,
351
+ cwd: toolchain.cwd,
352
+ nodeVersion: requestedNodeVersion,
353
+ nodeSource,
354
+ nodeSpec,
355
+ npmVersion,
356
+ npmSource,
357
+ npmSpec,
358
+ nodeExecutable: provisioned.nodeExecutable,
359
+ binDir: provisioned.binDir,
360
+ toolchainDir: provisioned.toolchainDir,
361
+ fingerprint: buildToolchainFingerprint({
362
+ kind: "node",
363
+ install: toolchain.install,
364
+ nodeVersion: requestedNodeVersion,
365
+ npmVersion,
366
+ nodeSource,
367
+ npmSource,
368
+ }),
369
+ summary:
370
+ `download node=${requestedNodeVersion}${npmVersion ? ` npm=${npmVersion}` : ""} ` +
371
+ `(${nodeSource})`,
372
+ };
373
+ }
374
+
375
+ async function provisionNodeToolchain({ productDir, version, fetchImpl, execaImpl }) {
376
+ const rootDir = path.join(productDir, ".testkit", "_toolchains", "node");
377
+ const installDir = path.join(rootDir, `${version}-${platformArchiveLabel()}`);
378
+ const manifestPath = path.join(installDir, "toolchain.json");
379
+
380
+ if (fs.existsSync(manifestPath)) {
381
+ return readProvisionedNodeToolchain(installDir);
382
+ }
383
+
384
+ fs.mkdirSync(rootDir, { recursive: true });
385
+ const lockPath = `${installDir}.lock`;
386
+
387
+ return withFileLock(lockPath, async () => {
388
+ if (fs.existsSync(manifestPath)) {
389
+ return readProvisionedNodeToolchain(installDir);
390
+ }
391
+
392
+ const tempDir = path.join(rootDir, `.tmp-${version}-${crypto.randomUUID()}`);
393
+ const archivePath = `${tempDir}.tar.gz`;
394
+
395
+ fs.mkdirSync(tempDir, { recursive: true });
396
+ try {
397
+ const downloadUrl = buildNodeDownloadUrl(version);
398
+ const response = await fetchImpl(downloadUrl);
399
+ if (!response.ok) {
400
+ throw new Error(`Failed to download ${downloadUrl}: HTTP ${response.status}`);
401
+ }
402
+
403
+ const body = Buffer.from(await response.arrayBuffer());
404
+ fs.writeFileSync(archivePath, body);
405
+
406
+ await execaImpl("tar", ["-xzf", archivePath, "-C", tempDir, "--strip-components=1"]);
407
+ fs.rmSync(archivePath, { force: true });
408
+ fs.rmSync(installDir, { recursive: true, force: true });
409
+ fs.renameSync(tempDir, installDir);
410
+
411
+ const npmVersion = await readCommandVersion({
412
+ command: path.join(nodeBinDir(installDir), "npm"),
413
+ args: ["--version"],
414
+ cwd: installDir,
415
+ env: process.env,
416
+ });
417
+
418
+ const manifest = {
419
+ kind: "node",
420
+ version,
421
+ npmVersion: normalizeSemver(npmVersion),
422
+ platform: process.platform,
423
+ arch: process.arch,
424
+ };
425
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
426
+ return readProvisionedNodeToolchain(installDir);
427
+ } catch (error) {
428
+ fs.rmSync(tempDir, { recursive: true, force: true });
429
+ fs.rmSync(archivePath, { force: true });
430
+ throw error;
431
+ }
432
+ });
433
+ }
434
+
435
+ function readProvisionedNodeToolchain(installDir) {
436
+ const manifest = readJsonFile(path.join(installDir, "toolchain.json"));
437
+ return {
438
+ toolchainDir: installDir,
439
+ binDir: nodeBinDir(installDir),
440
+ nodeExecutable: path.join(nodeBinDir(installDir), process.platform === "win32" ? "node.exe" : "node"),
441
+ npmVersion: manifest?.npmVersion || null,
442
+ };
443
+ }
444
+
445
+ function nodeBinDir(installDir) {
446
+ if (process.platform === "win32") return installDir;
447
+ return path.join(installDir, "bin");
448
+ }
449
+
450
+ function buildNodeDownloadUrl(version) {
451
+ if (!["linux", "darwin"].includes(process.platform)) {
452
+ throw new Error(`Node toolchain download is not supported on ${process.platform}`);
453
+ }
454
+
455
+ const archivePlatform =
456
+ process.platform === "darwin" ? "darwin" : process.platform;
457
+ const archiveArch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : null;
458
+ if (!archiveArch) {
459
+ throw new Error(`Node toolchain download is not supported on ${process.platform}/${process.arch}`);
460
+ }
461
+
462
+ return `https://nodejs.org/dist/v${version}/node-v${version}-${archivePlatform}-${archiveArch}.tar.gz`;
463
+ }
464
+
465
+ function platformArchiveLabel() {
466
+ return `${process.platform}-${process.arch}`;
467
+ }
468
+
469
+ function buildToolchainFingerprint(value) {
470
+ return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
471
+ }
472
+
473
+ async function readCommandVersion({ command, args, cwd, env }) {
474
+ const result = await execa(command, args, {
475
+ cwd,
476
+ env,
477
+ stdio: "pipe",
478
+ });
479
+ return String(result.stdout || "").trim();
480
+ }
481
+
482
+ function parsePackageManager(value) {
483
+ const normalized = normalizeOptionalString(value);
484
+ if (!normalized) return null;
485
+ const atIndex = normalized.lastIndexOf("@");
486
+ if (atIndex <= 0 || atIndex === normalized.length - 1) return null;
487
+ return {
488
+ name: normalized.slice(0, atIndex),
489
+ version: normalized.slice(atIndex + 1),
490
+ };
491
+ }
492
+
493
+ function readJsonFile(filePath) {
494
+ if (!fs.existsSync(filePath)) return null;
495
+ try {
496
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
497
+ } catch {
498
+ return null;
499
+ }
500
+ }
501
+
502
+ function readFirstLine(filePath) {
503
+ if (!fs.existsSync(filePath)) return null;
504
+ const content = fs.readFileSync(filePath, "utf8");
505
+ const line = content.split(/\r?\n/).find((entry) => entry.trim().length > 0);
506
+ return line ? line.trim() : null;
507
+ }
508
+
509
+ function readToolVersions(filePath) {
510
+ if (!fs.existsSync(filePath)) return {};
511
+ const values = {};
512
+ for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
513
+ const trimmed = line.trim();
514
+ if (!trimmed || trimmed.startsWith("#")) continue;
515
+ const [tool, version] = trimmed.split(/\s+/, 2);
516
+ if (tool && version) {
517
+ values[tool] = version.trim();
518
+ }
519
+ }
520
+ return values;
521
+ }
522
+
523
+ function prependPathEntry(entry, currentPath) {
524
+ const normalized = normalizeOptionalString(entry);
525
+ if (!normalized) return currentPath;
526
+ const parts = String(currentPath || "")
527
+ .split(path.delimiter)
528
+ .filter(Boolean)
529
+ .filter((value) => value !== normalized);
530
+ return [normalized, ...parts].join(path.delimiter);
531
+ }
532
+
533
+ function normalizeOptionalString(value) {
534
+ if (typeof value !== "string") return null;
535
+ const normalized = value.trim();
536
+ return normalized.length > 0 ? normalized : null;
537
+ }
538
+
539
+ async function withFileLock(lockPath, fn) {
540
+ const startedAt = Date.now();
541
+ while (true) {
542
+ try {
543
+ const fd = fs.openSync(lockPath, "wx");
544
+ fs.writeFileSync(fd, JSON.stringify({
545
+ pid: process.pid,
546
+ host: os.hostname(),
547
+ createdAt: new Date().toISOString(),
548
+ }));
549
+ fs.closeSync(fd);
550
+
551
+ try {
552
+ return await fn();
553
+ } finally {
554
+ fs.rmSync(lockPath, { force: true });
555
+ }
556
+ } catch (error) {
557
+ if (error?.code !== "EEXIST") throw error;
558
+ }
559
+
560
+ if (Date.now() - startedAt > 60_000) {
561
+ throw new Error(`Timed out waiting for toolchain lock ${lockPath}`);
562
+ }
563
+ await new Promise((resolve) => setTimeout(resolve, 100));
564
+ }
565
+ }