@elench/testkit 0.1.26 → 0.1.27

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.
@@ -1,84 +1,55 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { fileURLToPath } from "url";
4
- import { discoverSuites } from "./discovery.mjs";
5
- import {
6
- getServiceEnvFiles as getServiceEnvFilesModel,
7
- isObject as isObjectModel,
8
- normalizeTelemetryConfig as normalizeTelemetryConfigModel,
9
- parseDotenvString,
10
- resolveLifecycleConfig as resolveLifecycleConfigModel,
11
- resolveSelectedDatabase as resolveSelectedDatabaseModel,
12
- validateConfigCoverage as validateConfigCoverageModel,
13
- validateServiceConfig as validateServiceConfigModel,
14
- validateTelemetryConfig as validateTelemetryConfigModel,
15
- } from "./model.mjs";
16
-
17
- const TESTKIT_CONFIG = "testkit.config.json";
4
+ import { discoverProject } from "./discovery.mjs";
5
+ import { loadTestkitSetup } from "./setup-loader.mjs";
6
+
18
7
  const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
8
+ const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
9
+ const DEFAULT_LOCAL_USER = "testkit";
10
+ const DEFAULT_LOCAL_PASSWORD = "testkit";
11
+
19
12
  export function parseDotenv(filePath) {
20
13
  if (!fs.existsSync(filePath)) return {};
21
14
  return parseDotenvString(fs.readFileSync(filePath, "utf8"));
22
15
  }
23
16
 
24
- export function getServiceNames(cwd) {
25
- const dir = cwd || process.cwd();
26
- const configPath = path.join(dir, TESTKIT_CONFIG);
27
- if (!fs.existsSync(configPath)) return [];
28
- const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
29
- if (!isObject(config.services)) return [];
30
- return Object.keys(config.services);
31
- }
32
-
33
- export function loadConfigs(opts = {}) {
17
+ export async function loadConfigs(opts = {}) {
34
18
  const productDir = resolveProductDir(process.cwd(), opts.dir);
35
- const config = loadTestkitConfig(productDir);
36
- validateConfigCoverage(config);
37
- const discoveredSuites = discoverSuites(productDir, config.services);
19
+ const { setup, setupFile } = await loadTestkitSetup(productDir);
20
+ const explicitServices = setup.services || {};
21
+ const discovery = discoverProject(productDir, explicitServices);
22
+ const serviceNames = new Set([
23
+ ...Object.keys(explicitServices),
24
+ ...Object.keys(discovery.suitesByService),
25
+ ]);
26
+
27
+ const configs = [...serviceNames]
28
+ .sort((left, right) => left.localeCompare(right))
29
+ .map((name) =>
30
+ normalizeServiceConfig({
31
+ name,
32
+ productDir,
33
+ setup,
34
+ setupFile,
35
+ explicitService: explicitServices[name] || {},
36
+ discoveredService: discovery.services[name] || null,
37
+ suites: discovery.suitesByService[name] || {},
38
+ })
39
+ );
40
+
41
+ validateConfigCoverage(configs);
38
42
 
39
- const serviceEntries = Object.entries(config.services);
40
43
  const filtered = opts.service
41
- ? serviceEntries.filter(([name]) => name === opts.service)
42
- : serviceEntries;
44
+ ? configs.filter((config) => config.name === opts.service)
45
+ : configs;
43
46
 
44
47
  if (opts.service && filtered.length === 0) {
45
- const available = serviceEntries.map(([name]) => name).join(", ");
48
+ const available = configs.map((config) => config.name).join(", ");
46
49
  throw new Error(`Service "${opts.service}" not found. Available: ${available}`);
47
50
  }
48
51
 
49
- return filtered.map(([name, serviceConfig]) => {
50
- const suites = discoveredSuites[name] || {};
51
- const resolvedDatabase = resolveSelectedDatabase(name, serviceConfig);
52
- const serviceEnv = loadServiceEnv(productDir, serviceConfig);
53
- const selectedBackend = resolvedDatabase?.selectedBackend;
54
- const resolvedMigrate = resolveLifecycleConfig(serviceConfig.migrate, selectedBackend);
55
- const resolvedSeed = resolveLifecycleConfig(serviceConfig.seed, selectedBackend);
56
- validateMergedService(
57
- name,
58
- suites,
59
- serviceConfig,
60
- resolvedDatabase,
61
- resolvedMigrate,
62
- resolvedSeed,
63
- productDir
64
- );
65
-
66
- return {
67
- name,
68
- productDir,
69
- stateDir: path.join(productDir, ".testkit", name),
70
- telemetry: normalizeTelemetryConfig(config.telemetry),
71
- suites,
72
- testkit: {
73
- ...serviceConfig,
74
- database: resolvedDatabase,
75
- migrate: resolvedMigrate,
76
- seed: resolvedSeed,
77
- envFiles: getServiceEnvFiles(serviceConfig),
78
- serviceEnv,
79
- },
80
- };
81
- });
52
+ return filtered;
82
53
  }
83
54
 
84
55
  export function resolveK6Binary() {
@@ -114,142 +85,314 @@ export function resolveServiceCwd(productDir, maybeRelative) {
114
85
  return path.resolve(productDir, maybeRelative || ".");
115
86
  }
116
87
 
117
- function loadTestkitConfig(productDir) {
118
- const configPath = path.join(productDir, TESTKIT_CONFIG);
119
- const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
88
+ function normalizeServiceConfig({
89
+ name,
90
+ productDir,
91
+ setup,
92
+ setupFile,
93
+ explicitService,
94
+ discoveredService,
95
+ suites,
96
+ }) {
97
+ const local = normalizeLocalConfig(name, explicitService, discoveredService, productDir);
98
+ const envFiles = inferEnvFiles(productDir, explicitService, local);
99
+ const serviceEnv = loadServiceEnv(productDir, envFiles);
100
+ const database = normalizeDatabaseConfig(explicitService, name);
101
+ const migrate = normalizeLifecycle(explicitService.migrate);
102
+ const seed = normalizeLifecycle(explicitService.seed);
103
+
104
+ if (!explicitService.databaseFrom && !database && (migrate || seed)) {
105
+ throw new Error(
106
+ `Service "${name}" defines migrate/seed hooks but no database. Add localDatabase(...) in testkit.setup.ts.`
107
+ );
108
+ }
109
+
110
+ validateServiceConfig({
111
+ name,
112
+ local,
113
+ database,
114
+ databaseFrom: explicitService.databaseFrom,
115
+ migrate,
116
+ seed,
117
+ dependsOn: explicitService.dependsOn || [],
118
+ suites,
119
+ productDir,
120
+ });
121
+
122
+ return {
123
+ name,
124
+ productDir,
125
+ setupFile,
126
+ stateDir: path.join(productDir, ".testkit", name),
127
+ telemetry: normalizeTelemetryConfig(setup.telemetry),
128
+ suites,
129
+ testkit: {
130
+ dependsOn: explicitService.dependsOn || [],
131
+ database,
132
+ databaseFrom: explicitService.databaseFrom,
133
+ envFiles,
134
+ serviceEnv,
135
+ migrate,
136
+ seed,
137
+ local,
138
+ },
139
+ };
140
+ }
141
+
142
+ function normalizeLocalConfig(name, explicitService, discoveredService, productDir) {
143
+ if (explicitService.local) {
144
+ return {
145
+ ...explicitService.local,
146
+ cwd: explicitService.local.cwd || ".",
147
+ env: explicitService.local.env || {},
148
+ };
149
+ }
150
+
151
+ const inferredCwd = discoveredService?.inferredLocalCwd || ".";
152
+ const detected = inferLocalRuntime(productDir, inferredCwd);
153
+ if (!detected) return undefined;
154
+
155
+ return detected;
156
+ }
157
+
158
+ function inferLocalRuntime(productDir, cwd) {
159
+ const absoluteCwd = resolveServiceCwd(productDir, cwd);
160
+ if (!fs.existsSync(absoluteCwd)) return undefined;
120
161
 
121
- if (!isObject(raw.services)) {
122
- throw new Error(`${TESTKIT_CONFIG} must have a "services" object (${configPath})`);
162
+ if (detectNextApp(absoluteCwd)) {
163
+ return {
164
+ cwd,
165
+ start: "exec ./node_modules/.bin/next dev -p {port}",
166
+ port: 3000,
167
+ baseUrl: "http://127.0.0.1:{port}",
168
+ readyUrl: "http://127.0.0.1:{port}",
169
+ env: {},
170
+ };
123
171
  }
124
172
 
125
- if (raw.telemetry !== undefined) {
126
- validateTelemetryConfigModel(raw.telemetry, configPath, TESTKIT_CONFIG);
173
+ if (fs.existsSync(path.join(absoluteCwd, "cmd", "server"))) {
174
+ return {
175
+ cwd,
176
+ start: "exec go run ./cmd/server",
177
+ port: 3000,
178
+ baseUrl: "http://127.0.0.1:{port}",
179
+ readyUrl: "http://127.0.0.1:{port}/health",
180
+ env: {},
181
+ };
127
182
  }
128
183
 
129
- for (const [serviceName, service] of Object.entries(raw.services)) {
130
- validateServiceConfigModel(serviceName, service, configPath);
184
+ if (fs.existsSync(path.join(absoluteCwd, "package.json")) && fs.existsSync(path.join(absoluteCwd, "src"))) {
185
+ return {
186
+ cwd,
187
+ start: "exec ./node_modules/.bin/tsx watch src/index.ts",
188
+ port: 3000,
189
+ baseUrl: "http://127.0.0.1:{port}",
190
+ readyUrl: "http://127.0.0.1:{port}/health",
191
+ env: {},
192
+ };
131
193
  }
132
194
 
133
- return raw;
195
+ return undefined;
134
196
  }
135
197
 
136
- function validateConfigCoverage(config) {
137
- return validateConfigCoverageModel(config, TESTKIT_CONFIG);
198
+ function normalizeDatabaseConfig(explicitService, serviceName) {
199
+ if (explicitService.databaseFrom) return undefined;
200
+ if (!explicitService.database) return undefined;
201
+
202
+ const database =
203
+ explicitService.database.provider === "local"
204
+ ? explicitService.database
205
+ : {
206
+ provider: "local",
207
+ ...explicitService.database,
208
+ };
209
+
210
+ return {
211
+ ...database,
212
+ provider: "local",
213
+ selectedBackend: "local",
214
+ reset: database.reset !== false,
215
+ image: database.image || DEFAULT_LOCAL_IMAGE,
216
+ user: database.user || DEFAULT_LOCAL_USER,
217
+ password: database.password || DEFAULT_LOCAL_PASSWORD,
218
+ template: {
219
+ inputs: Array.isArray(database.template?.inputs) ? [...database.template.inputs] : [],
220
+ },
221
+ serviceName,
222
+ };
138
223
  }
139
224
 
140
- function resolveProductDir(cwd, explicitDir) {
141
- if (explicitDir) {
142
- const dir = path.resolve(cwd, explicitDir);
143
- ensureProductFiles(dir);
144
- return dir;
225
+ function normalizeLifecycle(value) {
226
+ if (!value) return undefined;
227
+ if (!value.cmd && !value.testkitCmd) {
228
+ throw new Error("Lifecycle config requires cmd or testkitCmd");
145
229
  }
146
230
 
147
- ensureProductFiles(cwd);
148
- return cwd;
231
+ return {
232
+ cmd: value.testkitCmd || value.cmd,
233
+ cwd: value.testkitCwd || value.cwd,
234
+ };
149
235
  }
150
236
 
151
- function ensureProductFiles(dir) {
152
- const missing = [TESTKIT_CONFIG].filter(
153
- (file) => !fs.existsSync(path.join(dir, file))
154
- );
155
- if (missing.length > 0) {
156
- throw new Error(
157
- `Expected ${missing.join(" and ")} in ${dir}. Either cd into a product directory or use --dir.`
158
- );
237
+ function inferEnvFiles(productDir, explicitService, local) {
238
+ if (explicitService.envFile || explicitService.envFiles) {
239
+ const files = [];
240
+ if (explicitService.envFile) files.push(explicitService.envFile);
241
+ if (Array.isArray(explicitService.envFiles)) files.push(...explicitService.envFiles);
242
+ return files;
159
243
  }
244
+
245
+ const candidates = [];
246
+ const serviceCwd = local?.cwd || ".";
247
+ if (serviceCwd !== ".") {
248
+ candidates.push(path.posix.join(serviceCwd, ".env.testkit"));
249
+ candidates.push(path.posix.join(serviceCwd, ".env"));
250
+ }
251
+ candidates.push(".env.testkit");
252
+ candidates.push(".env");
253
+
254
+ return [...new Set(candidates)]
255
+ .map((candidate) => candidate.split(path.sep).join("/"))
256
+ .filter((candidate) => fs.existsSync(resolveServiceCwd(productDir, candidate)));
160
257
  }
161
258
 
162
- function validateMergedService(
259
+ function loadServiceEnv(productDir, envFiles) {
260
+ const env = {};
261
+ for (const envFile of envFiles) {
262
+ Object.assign(env, parseDotenv(resolveServiceCwd(productDir, envFile)));
263
+ }
264
+ return env;
265
+ }
266
+
267
+ function validateConfigCoverage(configs) {
268
+ const names = new Set(configs.map((config) => config.name));
269
+ for (const config of configs) {
270
+ for (const depName of config.testkit.dependsOn || []) {
271
+ if (!names.has(depName)) {
272
+ throw new Error(`Service "${config.name}" depends on "${depName}", but ${depName} is not defined`);
273
+ }
274
+ }
275
+ const databaseFrom = config.testkit.databaseFrom;
276
+ if (databaseFrom && !names.has(databaseFrom)) {
277
+ throw new Error(
278
+ `Service "${config.name}" databaseFrom "${databaseFrom}" is not defined`
279
+ );
280
+ }
281
+ }
282
+ }
283
+
284
+ function validateServiceConfig({
163
285
  name,
286
+ local,
287
+ database,
288
+ databaseFrom,
289
+ migrate,
290
+ seed,
291
+ dependsOn,
164
292
  suites,
165
- serviceConfig,
166
- resolvedDatabase,
167
- resolvedMigrate,
168
- resolvedSeed,
169
- productDir
170
- ) {
293
+ productDir,
294
+ }) {
171
295
  const usesLocalExecution = Object.entries(suites).some(([suiteType, discoveredSuites]) =>
172
296
  discoveredSuites.some(
173
297
  (suite) => (suite.framework && suite.framework !== "k6") || suiteType !== "dal"
174
298
  )
175
299
  );
176
300
 
177
- if (usesLocalExecution && !isObject(serviceConfig.local)) {
301
+ if (usesLocalExecution && !local) {
178
302
  throw new Error(
179
- `Service "${name}" defines non-DAL suites but has no local runtime in ${TESTKIT_CONFIG}`
303
+ `Service "${name}" defines non-DAL suites but no local runtime could be resolved. Add it in testkit.setup.ts.`
180
304
  );
181
305
  }
182
306
 
183
- if (serviceConfig.dependsOn) {
184
- for (const dep of serviceConfig.dependsOn) {
185
- if (dep === name) {
186
- throw new Error(`Service "${name}" cannot depend on itself`);
187
- }
188
- }
307
+ if (database && databaseFrom) {
308
+ throw new Error(`Service "${name}" cannot define both database and databaseFrom`);
189
309
  }
190
310
 
191
- if (
192
- resolvedDatabase?.provider === "local" &&
193
- (serviceConfig.migrate || serviceConfig.seed) &&
194
- resolvedDatabase.template.inputs.length === 0
195
- ) {
196
- throw new Error(
197
- `Service "${name}" uses local database provisioning with migrations or seeds, so database.template.inputs must list the files/directories that define the template cache`
198
- );
199
- }
200
-
201
- if (serviceConfig.local?.cwd) {
202
- const cwdPath = resolveServiceCwd(productDir, serviceConfig.local.cwd);
203
- if (!fs.existsSync(cwdPath)) {
204
- throw new Error(
205
- `Service "${name}" local.cwd does not exist: ${serviceConfig.local.cwd}`
206
- );
311
+ for (const depName of dependsOn || []) {
312
+ if (depName === name) {
313
+ throw new Error(`Service "${name}" cannot depend on itself`);
207
314
  }
208
315
  }
209
316
 
210
- if (resolvedMigrate?.cwd) {
211
- const cwdPath = resolveServiceCwd(productDir, resolvedMigrate.cwd);
212
- if (!fs.existsSync(cwdPath)) {
213
- throw new Error(
214
- `Service "${name}" migrate.cwd does not exist: ${resolvedMigrate.cwd}`
215
- );
216
- }
317
+ if (local?.cwd) {
318
+ ensureExistingPath(productDir, local.cwd, `Service "${name}" local.cwd`);
217
319
  }
218
-
219
- if (resolvedSeed?.cwd) {
220
- const cwdPath = resolveServiceCwd(productDir, resolvedSeed.cwd);
221
- if (!fs.existsSync(cwdPath)) {
222
- throw new Error(
223
- `Service "${name}" seed.cwd does not exist: ${resolvedSeed.cwd}`
224
- );
225
- }
320
+ if (migrate?.cwd) {
321
+ ensureExistingPath(productDir, migrate.cwd, `Service "${name}" migrate.cwd`);
226
322
  }
227
-
228
- }
229
- function loadServiceEnv(productDir, serviceConfig) {
230
- const env = {};
231
- for (const envFile of getServiceEnvFiles(serviceConfig)) {
232
- Object.assign(env, parseDotenv(resolveServiceCwd(productDir, envFile)));
323
+ if (seed?.cwd) {
324
+ ensureExistingPath(productDir, seed.cwd, `Service "${name}" seed.cwd`);
233
325
  }
234
- return env;
235
326
  }
236
327
 
237
- function getServiceEnvFiles(serviceConfig) {
238
- return getServiceEnvFilesModel(serviceConfig);
328
+ function ensureExistingPath(productDir, relativePath, label) {
329
+ const absolute = resolveServiceCwd(productDir, relativePath);
330
+ if (!fs.existsSync(absolute)) {
331
+ throw new Error(`${label} does not exist: ${relativePath}`);
332
+ }
239
333
  }
240
334
 
241
- function resolveSelectedDatabase(name, serviceConfig) {
242
- return resolveSelectedDatabaseModel(name, serviceConfig);
335
+ function normalizeTelemetryConfig(telemetry) {
336
+ if (!telemetry) return null;
337
+ if (telemetry.endpoint) {
338
+ let parsed;
339
+ try {
340
+ parsed = new URL(telemetry.endpoint);
341
+ } catch {
342
+ throw new Error("testkit.setup telemetry.endpoint must be a valid URL");
343
+ }
344
+ if (!["http:", "https:"].includes(parsed.protocol)) {
345
+ throw new Error("testkit.setup telemetry.endpoint must use http or https");
346
+ }
347
+ }
348
+ if (telemetry.enabled === true) {
349
+ if (!telemetry.endpoint) {
350
+ throw new Error("testkit.setup telemetry.endpoint is required when telemetry.enabled is true");
351
+ }
352
+ if (!telemetry.tokenEnv) {
353
+ throw new Error("testkit.setup telemetry.tokenEnv is required when telemetry.enabled is true");
354
+ }
355
+ }
356
+ return {
357
+ enabled: telemetry.enabled === true,
358
+ endpoint: telemetry.endpoint,
359
+ tokenEnv: telemetry.tokenEnv,
360
+ timeoutMs: telemetry.timeoutMs || 3000,
361
+ };
243
362
  }
244
363
 
245
- function resolveLifecycleConfig(value, selectedBackend) {
246
- return resolveLifecycleConfigModel(value, selectedBackend);
364
+ function resolveProductDir(cwd, explicitDir) {
365
+ const dir = explicitDir ? path.resolve(cwd, explicitDir) : cwd;
366
+ if (!fs.existsSync(dir)) {
367
+ throw new Error(`Product directory does not exist: ${dir}`);
368
+ }
369
+ return dir;
247
370
  }
248
371
 
249
- function isObject(value) {
250
- return isObjectModel(value);
372
+ function parseDotenvString(source) {
373
+ const env = {};
374
+ for (const line of String(source).split("\n")) {
375
+ const trimmed = line.trim();
376
+ if (!trimmed || trimmed.startsWith("#")) continue;
377
+ const eq = trimmed.indexOf("=");
378
+ if (eq === -1) continue;
379
+ const key = trimmed.slice(0, eq).trim();
380
+ let val = trimmed.slice(eq + 1).trim();
381
+ if (
382
+ (val.startsWith("'") && val.endsWith("'")) ||
383
+ (val.startsWith("\"") && val.endsWith("\""))
384
+ ) {
385
+ val = val.slice(1, -1);
386
+ }
387
+ env[key] = val;
388
+ }
389
+ return env;
251
390
  }
252
391
 
253
- function normalizeTelemetryConfig(telemetry) {
254
- return normalizeTelemetryConfigModel(telemetry);
392
+ function detectNextApp(cwd) {
393
+ return (
394
+ fs.existsSync(path.join(cwd, "next.config.js")) ||
395
+ fs.existsSync(path.join(cwd, "next.config.mjs")) ||
396
+ fs.existsSync(path.join(cwd, "next.config.ts"))
397
+ );
255
398
  }
@@ -0,0 +1,98 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { build } from "esbuild";
5
+ import { fileURLToPath, pathToFileURL } from "url";
6
+
7
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
8
+ const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
9
+ const SETUP_ENTRY = path.join(PACKAGE_ROOT, "lib", "setup", "index.mjs");
10
+ const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
11
+ const SETUP_FILES = [
12
+ "testkit.setup.ts",
13
+ "testkit.setup.mts",
14
+ "testkit.setup.mjs",
15
+ "testkit.setup.js",
16
+ ];
17
+
18
+ export function findSetupFile(productDir) {
19
+ for (const candidate of SETUP_FILES) {
20
+ const absolute = path.join(productDir, candidate);
21
+ if (fs.existsSync(absolute)) {
22
+ return absolute;
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+
28
+ export async function loadTestkitSetup(productDir) {
29
+ const setupFile = findSetupFile(productDir);
30
+ if (!setupFile) {
31
+ return {
32
+ setup: {},
33
+ setupFile: null,
34
+ };
35
+ }
36
+
37
+ const bundleDir = path.join(productDir, ".testkit", "_setup");
38
+ fs.mkdirSync(bundleDir, { recursive: true });
39
+
40
+ const cacheKey = buildSetupCacheKey(setupFile);
41
+ const outputFile = path.join(bundleDir, `setup-${cacheKey.slice(0, 12)}.mjs`);
42
+
43
+ await build({
44
+ absWorkingDir: productDir,
45
+ bundle: true,
46
+ entryPoints: [setupFile],
47
+ format: "esm",
48
+ legalComments: "none",
49
+ outfile: outputFile,
50
+ platform: "node",
51
+ sourcemap: "inline",
52
+ target: "es2020",
53
+ plugins: [testkitSetupAliasPlugin()],
54
+ });
55
+
56
+ const imported = await import(`${pathToFileURL(outputFile).href}?v=${cacheKey}`);
57
+ const setup = imported.default || imported.setup || {};
58
+ if (!setup || typeof setup !== "object") {
59
+ throw new Error(`testkit setup file must export an object: ${path.basename(setupFile)}`);
60
+ }
61
+
62
+ return {
63
+ setup,
64
+ setupFile,
65
+ };
66
+ }
67
+
68
+ function buildSetupCacheKey(setupFile) {
69
+ const content = fs.readFileSync(setupFile, "utf8");
70
+ return crypto.createHash("sha256").update(setupFile).update("\0").update(content).digest("hex");
71
+ }
72
+
73
+ function testkitSetupAliasPlugin() {
74
+ return {
75
+ name: "testkit-setup-alias",
76
+ setup(buildApi) {
77
+ buildApi.onResolve({ filter: /^@elench\/testkit(?:\/.*)?$/ }, (args) => ({
78
+ namespace: "file",
79
+ path: resolvePackageSubpath(args.path),
80
+ }));
81
+ },
82
+ };
83
+ }
84
+
85
+ function resolvePackageSubpath(specifier) {
86
+ const subpath = specifier.slice("@elench/testkit".length);
87
+ if (!subpath) return ROOT_ENTRY;
88
+ if (subpath === "/setup") return SETUP_ENTRY;
89
+ if (subpath === "/runtime") {
90
+ throw new Error(
91
+ "testkit.setup.ts may not import @elench/testkit/runtime. " +
92
+ "Use @elench/testkit/setup runtime helpers instead."
93
+ );
94
+ }
95
+ if (subpath === "/runtime/index.mjs") return RUNTIME_ENTRY;
96
+
97
+ throw new Error(`Unsupported @elench/testkit import "${specifier}" while loading testkit setup`);
98
+ }
package/lib/index.d.ts CHANGED
@@ -42,6 +42,7 @@ export interface HttpSuiteConfig<TSetup = unknown> {
42
42
  auth?: AuthAdapter<TSetup> | null;
43
43
  env?: RuntimeEnv;
44
44
  headers?: HeaderBuilder<TSetup>;
45
+ profile?: string;
45
46
  rawHeaders?: HeaderBuilder<never>;
46
47
  options?: RuntimeOptions;
47
48
  }
@@ -14,11 +14,16 @@ describe("package metadata", () => {
14
14
  types: "./lib/index.d.ts",
15
15
  default: "./lib/index.mjs",
16
16
  });
17
+ expect(packageJson.exports["./setup"]).toEqual({
18
+ types: "./lib/setup/index.d.ts",
19
+ default: "./lib/setup/index.mjs",
20
+ });
17
21
  expect(packageJson.exports["./runtime"]).toEqual({
18
22
  types: "./lib/runtime/index.d.ts",
19
23
  default: "./lib/runtime/index.mjs",
20
24
  });
21
25
  expect(fs.existsSync(path.join(rootDir, "lib", "index.d.ts"))).toBe(true);
26
+ expect(fs.existsSync(path.join(rootDir, "lib", "setup", "index.d.ts"))).toBe(true);
22
27
  expect(fs.existsSync(path.join(rootDir, "lib", "runtime", "index.d.ts"))).toBe(true);
23
28
  });
24
29
  });
@@ -72,7 +72,7 @@ export function buildPortMap(runtimeConfigs, workerId) {
72
72
  if (existing) {
73
73
  throw new Error(
74
74
  `Worker port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
75
- `Assign distinct local.port/baseUrl ports in testkit.config.json.`
75
+ `Assign distinct local.port/baseUrl ports in testkit.setup.ts.`
76
76
  );
77
77
  }
78
78
  seen.set(actualPort, config.name);
@@ -56,6 +56,7 @@ export function createHttpClient(config) {
56
56
  }
57
57
 
58
58
  return {
59
+ rawHttp: http,
59
60
  request,
60
61
  raw,
61
62
  get(path, setupData, extraHeaders = {}) {