@elench/testkit 0.1.135 → 0.1.137

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.
Files changed (43) hide show
  1. package/README.md +38 -0
  2. package/lib/cli/commands/local/down.mjs +37 -0
  3. package/lib/cli/commands/local/env.mjs +31 -0
  4. package/lib/cli/commands/local/logs.mjs +35 -0
  5. package/lib/cli/commands/local/shell.mjs +49 -0
  6. package/lib/cli/commands/local/status.mjs +34 -0
  7. package/lib/cli/commands/local/up.mjs +39 -0
  8. package/lib/cli/entrypoint.mjs +6 -0
  9. package/lib/cli/renderers/status/text.mjs +14 -0
  10. package/lib/config/index.mjs +154 -0
  11. package/lib/config/validation.mjs +9 -0
  12. package/lib/config-api/index.d.ts +53 -0
  13. package/lib/config-api/index.mjs +14 -0
  14. package/lib/database/fingerprint.mjs +13 -33
  15. package/lib/database/index.mjs +27 -12
  16. package/lib/database/schema-source.mjs +3 -1
  17. package/lib/docker-compat/matrix.mjs +135 -0
  18. package/lib/env/index.d.ts +1 -0
  19. package/lib/env/index.mjs +5 -1
  20. package/lib/kiln/client.mjs +100 -0
  21. package/lib/local/kiln-driver.mjs +544 -0
  22. package/lib/local/lifecycle.mjs +289 -0
  23. package/lib/local/orchestrator.mjs +343 -0
  24. package/lib/repo/fingerprint-policy.mjs +145 -0
  25. package/lib/repo/state.mjs +46 -44
  26. package/lib/runner/maintenance.mjs +23 -0
  27. package/lib/runner/processes.mjs +45 -6
  28. package/lib/runner/readiness.mjs +12 -1
  29. package/lib/runner/runtime-preparation.mjs +10 -5
  30. package/lib/runner/services.mjs +24 -18
  31. package/lib/runner/status-model.mjs +27 -0
  32. package/lib/runner/template.mjs +39 -1
  33. package/node_modules/@elench/next-analysis/package.json +1 -1
  34. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  35. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  36. package/node_modules/@elench/ts-analysis/package.json +1 -1
  37. package/node_modules/es-toolkit/CHANGELOG.md +801 -0
  38. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
  39. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
  40. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
  41. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
  42. package/node_modules/esprima/ChangeLog +235 -0
  43. package/package.json +8 -5
package/README.md CHANGED
@@ -62,6 +62,13 @@ npx @elench/testkit status
62
62
  npx @elench/testkit destroy
63
63
  npx @elench/testkit cleanup
64
64
 
65
+ # Local production environment
66
+ npx @elench/testkit local up
67
+ npx @elench/testkit local status
68
+ npx @elench/testkit local env --service frontend
69
+ npx @elench/testkit local logs --service frontend
70
+ npx @elench/testkit local down
71
+
65
72
  # Inspect the latest run artifact through the assistant
66
73
  npx @elench/testkit assistant --message '/inspect "__testkit__/health/health.int.testkit.ts"'
67
74
  npx @elench/testkit assistant --message '/artifacts "__testkit__/health/health.int.testkit.ts"'
@@ -222,6 +229,7 @@ import {
222
229
  database,
223
230
  defineConfig,
224
231
  defineFile,
232
+ environment,
225
233
  toolchain,
226
234
  } from "@elench/testkit/config";
227
235
 
@@ -238,6 +246,9 @@ export default defineConfig({
238
246
  cacheTtlSeconds: 900,
239
247
  },
240
248
  },
249
+ fingerprints: {
250
+ exclude: ["next-env.d.ts"],
251
+ },
241
252
  toolchains: {
242
253
  frontendNode: toolchain.node({
243
254
  cwd: "frontend",
@@ -245,6 +256,12 @@ export default defineConfig({
245
256
  install: "download",
246
257
  }),
247
258
  },
259
+ environments: {
260
+ local: environment.local({
261
+ target: "frontend",
262
+ data: "reuse",
263
+ }),
264
+ },
248
265
  services: {
249
266
  api: app.node({
250
267
  cwd: ".",
@@ -314,6 +331,7 @@ for:
314
331
  - named HTTP suite profiles
315
332
  - automatic regression classification for new vs known failures
316
333
  - optional GitHub-backed regression issue sync
334
+ - repo-level fingerprint include/exclude policy
317
335
  - repo-declared suite/file skip policies with explicit reasons
318
336
  - telemetry upload configuration
319
337
 
@@ -323,6 +341,18 @@ inputs, and writes cache state under the service runtime directory. This is the
323
341
  right way to move expensive browser targets from `next dev` / watch mode to
324
342
  stable build-and-start flows.
325
343
 
344
+ `testkit local` starts the same service graph as a persistent local production
345
+ environment instead of a test run. It provisions local databases, runs template
346
+ setup, runs `runtime.prepare`, starts dependent services, and records state
347
+ under `.testkit/environments/<name>/` rather than `.testkit/_runs`. Local
348
+ environment processes receive `TESTKIT_ACTIVE=1`, `TESTKIT_MODE=local`, and
349
+ `TESTKIT_LOCAL_ENV=<name>`. Use `data: "reuse"` for fast restarts against the
350
+ existing local runtime database, `data: "reset"` to refresh runtime databases
351
+ from their templates on each launch, or `--rebuild` to destroy and recreate the
352
+ environment state. Testkit only supports local environments here; it does not
353
+ copy production data and it refuses managed runtime database URLs that are not
354
+ loopback PostgreSQL URLs.
355
+
326
356
  `database.template` is the database-side equivalent for reusable template DB
327
357
  state. When `database.sourceSchema` is configured, Testkit treats the configured
328
358
  source database as the schema source of truth. A normal `testkit run` resolves a
@@ -339,6 +369,14 @@ Source schema cache keys are derived automatically from repo state:
339
369
  - dirty git worktrees use `dirty/<sha>-<fingerprint>`
340
370
  - non-git directories use `nogit/<fingerprint>`
341
371
 
372
+ Dirty worktree fingerprints use Git's own ignore engine for untracked files, so
373
+ normal `.gitignore` rules are respected. Testkit-owned outputs are always
374
+ excluded from fingerprints: `.testkit/`, `.next-testkit/`, and
375
+ `testkit.status.json`. `testkit.config.ts` is normal repo configuration and is
376
+ not excluded automatically. For user-managed generated files, add repo-level
377
+ `fingerprints.exclude` patterns; `fingerprints.include` can opt specific paths
378
+ back in below an excluded directory.
379
+
342
380
  Branch names and worktree paths are recorded as metadata but do not affect clean
343
381
  commit cache keys, so branch renames and clean worktrees at the same commit
344
382
  reuse the same source schema. Dirty worktrees are isolated by content
@@ -0,0 +1,37 @@
1
+ import { Args, Command, Flags } from "@oclif/core";
2
+ import { sharedFlags } from "../../command-flags.mjs";
3
+ import { localDown } from "../../../local/orchestrator.mjs";
4
+ import { withAssistantCommandResult } from "../../assistant/command-results.mjs";
5
+
6
+ export default class LocalDownCommand extends Command {
7
+ static summary = "Stop a local production environment";
8
+
9
+ static enableJsonFlag = true;
10
+
11
+ static args = {
12
+ name: Args.string({ required: false, description: "Environment name" }),
13
+ };
14
+
15
+ static flags = {
16
+ ...sharedFlags,
17
+ "destroy-state": Flags.boolean({
18
+ description: "Remove environment runtime state and databases after stopping",
19
+ default: false,
20
+ }),
21
+ };
22
+
23
+ async run() {
24
+ return withAssistantCommandResult("local down", async () => {
25
+ const { args, flags } = await this.parse(LocalDownCommand);
26
+ const result = await localDown({
27
+ dir: flags.dir,
28
+ name: args.name || "local",
29
+ destroyState: flags["destroy-state"],
30
+ });
31
+ if (!this.jsonEnabled()) {
32
+ for (const line of result.lines || []) this.log(line);
33
+ }
34
+ return { ok: true, result };
35
+ });
36
+ }
37
+ }
@@ -0,0 +1,31 @@
1
+ import { Args, Command } from "@oclif/core";
2
+ import { sharedFlags } from "../../command-flags.mjs";
3
+ import { localEnv } from "../../../local/orchestrator.mjs";
4
+ import { withAssistantCommandResult } from "../../assistant/command-results.mjs";
5
+
6
+ export default class LocalEnvCommand extends Command {
7
+ static summary = "Print resolved environment variables for a local service";
8
+
9
+ static enableJsonFlag = true;
10
+
11
+ static args = {
12
+ name: Args.string({ required: false, description: "Environment name" }),
13
+ };
14
+
15
+ static flags = sharedFlags;
16
+
17
+ async run() {
18
+ return withAssistantCommandResult("local env", async () => {
19
+ const { args, flags } = await this.parse(LocalEnvCommand);
20
+ const result = await localEnv({
21
+ dir: flags.dir,
22
+ name: args.name || "local",
23
+ service: flags.service,
24
+ });
25
+ if (!this.jsonEnabled()) {
26
+ for (const line of result.lines || []) this.log(line);
27
+ }
28
+ return { ok: true, result };
29
+ });
30
+ }
31
+ }
@@ -0,0 +1,35 @@
1
+ import { Args, Command, Flags } from "@oclif/core";
2
+ import { sharedFlags } from "../../command-flags.mjs";
3
+ import { localLogs } from "../../../local/orchestrator.mjs";
4
+ import { withAssistantCommandResult } from "../../assistant/command-results.mjs";
5
+
6
+ export default class LocalLogsCommand extends Command {
7
+ static summary = "Show local production environment logs";
8
+
9
+ static enableJsonFlag = true;
10
+
11
+ static args = {
12
+ name: Args.string({ required: false, description: "Environment name" }),
13
+ };
14
+
15
+ static flags = {
16
+ ...sharedFlags,
17
+ lines: Flags.integer({ description: "Number of lines to show", default: 200 }),
18
+ };
19
+
20
+ async run() {
21
+ return withAssistantCommandResult("local logs", async () => {
22
+ const { args, flags } = await this.parse(LocalLogsCommand);
23
+ const result = await localLogs({
24
+ dir: flags.dir,
25
+ name: args.name || "local",
26
+ service: flags.service,
27
+ lines: flags.lines,
28
+ });
29
+ if (!this.jsonEnabled()) {
30
+ for (const line of result.lines || []) this.log(line);
31
+ }
32
+ return { ok: true, result };
33
+ });
34
+ }
35
+ }
@@ -0,0 +1,49 @@
1
+ import { Command } from "@oclif/core";
2
+ import { sharedFlags } from "../../command-flags.mjs";
3
+ import { localShell } from "../../../local/orchestrator.mjs";
4
+ import { withAssistantCommandResult } from "../../assistant/command-results.mjs";
5
+
6
+ export default class LocalShellCommand extends Command {
7
+ static summary = "Run a command with a local service environment";
8
+
9
+ static strict = false;
10
+
11
+ static enableJsonFlag = true;
12
+
13
+ static flags = sharedFlags;
14
+
15
+ async run() {
16
+ return withAssistantCommandResult("local shell", async () => {
17
+ const { flags } = await this.parse(LocalShellCommand);
18
+ const separatorIndex = this.argv.indexOf("--");
19
+ const beforeSeparator = separatorIndex >= 0 ? this.argv.slice(0, separatorIndex) : this.argv;
20
+ const command = separatorIndex >= 0 ? this.argv.slice(separatorIndex + 1) : [];
21
+ const result = await localShell({
22
+ dir: flags.dir,
23
+ name: extractEnvironmentName(beforeSeparator) || "local",
24
+ service: flags.service,
25
+ command,
26
+ });
27
+ if (!this.jsonEnabled()) {
28
+ if (result.stdout) this.log(result.stdout.trimEnd());
29
+ if (result.stderr) this.error(result.stderr.trimEnd(), { exit: false });
30
+ }
31
+ this.exit(result.code);
32
+ return { ok: result.code === 0, result };
33
+ });
34
+ }
35
+ }
36
+
37
+ function extractEnvironmentName(args) {
38
+ const valueFlags = new Set(["--dir", "--service"]);
39
+ for (let index = 0; index < args.length; index += 1) {
40
+ const value = args[index];
41
+ if (valueFlags.has(value)) {
42
+ index += 1;
43
+ continue;
44
+ }
45
+ if (value.startsWith("-")) continue;
46
+ return value;
47
+ }
48
+ return null;
49
+ }
@@ -0,0 +1,34 @@
1
+ import { Args, Command } from "@oclif/core";
2
+ import { sharedFlags } from "../../command-flags.mjs";
3
+ import { localStatus } from "../../../local/orchestrator.mjs";
4
+ import { withAssistantCommandResult } from "../../assistant/command-results.mjs";
5
+
6
+ export default class LocalStatusCommand extends Command {
7
+ static summary = "Show local production environments";
8
+
9
+ static enableJsonFlag = true;
10
+
11
+ static args = {
12
+ name: Args.string({ required: false, description: "Environment name" }),
13
+ };
14
+
15
+ static flags = sharedFlags;
16
+
17
+ async run() {
18
+ return withAssistantCommandResult("local status", async () => {
19
+ const { args, flags } = await this.parse(LocalStatusCommand);
20
+ const result = await localStatus({ dir: flags.dir, name: args.name || null });
21
+ if (!this.jsonEnabled()) {
22
+ if (result.environments) {
23
+ if (result.environments.length === 0) this.log("Local Environments: none");
24
+ for (const environment of result.environments) {
25
+ for (const line of environment.lines || []) this.log(line);
26
+ }
27
+ } else {
28
+ for (const line of result.lines || []) this.log(line);
29
+ }
30
+ }
31
+ return { ok: true, result };
32
+ });
33
+ }
34
+ }
@@ -0,0 +1,39 @@
1
+ import { Args, Command, Flags } from "@oclif/core";
2
+ import { sharedFlags } from "../../command-flags.mjs";
3
+ import { localUp } from "../../../local/orchestrator.mjs";
4
+ import { withAssistantCommandResult } from "../../assistant/command-results.mjs";
5
+
6
+ export default class LocalUpCommand extends Command {
7
+ static summary = "Start a local production environment";
8
+
9
+ static enableJsonFlag = true;
10
+
11
+ static args = {
12
+ name: Args.string({ required: false, description: "Environment name" }),
13
+ };
14
+
15
+ static flags = {
16
+ ...sharedFlags,
17
+ reset: Flags.boolean({ description: "Reset runtime databases from their templates", default: false }),
18
+ rebuild: Flags.boolean({ description: "Destroy and rebuild environment runtime state", default: false }),
19
+ "port-offset": Flags.integer({ description: "Add an offset to configured local ports" }),
20
+ };
21
+
22
+ async run() {
23
+ return withAssistantCommandResult("local up", async () => {
24
+ const { args, flags } = await this.parse(LocalUpCommand);
25
+ const result = await localUp({
26
+ dir: flags.dir,
27
+ name: args.name || "local",
28
+ service: flags.service,
29
+ reset: flags.reset,
30
+ rebuild: flags.rebuild,
31
+ portOffset: flags["port-offset"],
32
+ });
33
+ if (!this.jsonEnabled()) {
34
+ for (const line of result.lines || []) this.log(line);
35
+ }
36
+ return { ok: true, result };
37
+ });
38
+ }
39
+ }
@@ -14,6 +14,7 @@ export function normalizeCliArgs(argv) {
14
14
  "typecheck",
15
15
  "doctor",
16
16
  "lint",
17
+ "local",
17
18
  "browser",
18
19
  "db",
19
20
  "guard",
@@ -43,6 +44,8 @@ export function normalizeCliArgs(argv) {
43
44
  "--prompt",
44
45
  "--command",
45
46
  "--use",
47
+ "--port-offset",
48
+ "--lines",
46
49
  ]);
47
50
  const positionals = findPositionals(argv, valueFlags);
48
51
  const firstPositional = positionals[0] || null;
@@ -134,6 +137,9 @@ function reorderCommandArgs(args, positionals) {
134
137
  if (positionals[0]?.value === "browser" && positionals[1]) {
135
138
  commandTokens.push(positionals[1]);
136
139
  }
140
+ if (positionals[0]?.value === "local" && positionals[1]) {
141
+ commandTokens.push(positionals[1]);
142
+ }
137
143
  const commandIndexes = new Set(commandTokens.map((token) => token.index));
138
144
  return [
139
145
  ...commandTokens.map((token) => token.value),
@@ -24,6 +24,20 @@ export function renderStatusResult(result) {
24
24
  lines.push(` ... ${(result.runs.runs.length - 5)} more run${result.runs.runs.length - 5 === 1 ? "" : "s"}`);
25
25
  }
26
26
 
27
+ lines.push("", "Local Environments");
28
+ if ((result.localEnvironments?.environments || []).length === 0) {
29
+ lines.push(" none");
30
+ } else {
31
+ lines.push(` active: ${result.localEnvironments?.active || 0}`);
32
+ for (const environment of result.localEnvironments.environments.slice(0, 5)) {
33
+ const ports = environment.ports?.length ? ` ports=${environment.ports.join(",")}` : "";
34
+ lines.push(` ${environment.name}: ${environment.status} target=${environment.target || "unknown"}${ports}`);
35
+ }
36
+ if (result.localEnvironments.environments.length > 5) {
37
+ lines.push(` ... ${result.localEnvironments.environments.length - 5} more environment${result.localEnvironments.environments.length - 5 === 1 ? "" : "s"}`);
38
+ }
39
+ }
40
+
27
41
  lines.push("", "Runtime Graphs");
28
42
  if ((result.runtimeGraphs || []).length === 0) {
29
43
  lines.push(" none");
@@ -5,6 +5,7 @@ import { loadTestkitConfig } from "./config-loader.mjs";
5
5
  import { normalizeToolchainRegistry } from "../toolchains/index.mjs";
6
6
  import { loadTestFileMetadataMap } from "../discovery/file-metadata.mjs";
7
7
  import { mergeDiscoveryConfigs } from "../discovery/path-policy.mjs";
8
+ import { normalizeFingerprintPolicy } from "../repo/fingerprint-policy.mjs";
8
9
  import { normalizeDatabaseConfig } from "./database.mjs";
9
10
  import { normalizeRepoDiscoveryConfig, normalizeServiceDiscoveryConfig } from "./discovery-config.mjs";
10
11
  import { inferEnvFiles, loadServiceEnv, parseDotenv } from "./env.mjs";
@@ -32,6 +33,8 @@ export async function loadConfigContext(opts = {}) {
32
33
  const execution = normalizeRepoExecution(config.execution);
33
34
  const regressions = normalizeRegressionsConfig(config.regressions);
34
35
  const toolchains = normalizeToolchainRegistry(config.toolchains);
36
+ const environments = normalizeEnvironmentRegistry(config.environments);
37
+ const fingerprints = normalizeFingerprintPolicy(config.fingerprints);
35
38
  const discoveryConfig = normalizeRepoDiscoveryConfig(config.discovery);
36
39
  const explicitServices = config.services || {};
37
40
  const discovery = discoverProject(productDir, explicitServices, {
@@ -56,6 +59,8 @@ export async function loadConfigContext(opts = {}) {
56
59
  discovery: discoveryConfig,
57
60
  regressions,
58
61
  toolchains,
62
+ environments,
63
+ fingerprints,
59
64
  explicitService: explicitServices[name] || {},
60
65
  discoveredService: discovery.services[name] || null,
61
66
  suites: discovery.suitesByService[name] || {},
@@ -70,9 +75,11 @@ export async function loadConfigContext(opts = {}) {
70
75
  config,
71
76
  configFile,
72
77
  execution,
78
+ environments,
73
79
  discovery: discoveryConfig,
74
80
  regressions,
75
81
  toolchains,
82
+ fingerprints,
76
83
  explicitServices,
77
84
  discovery,
78
85
  configs,
@@ -104,6 +111,8 @@ function normalizeServiceConfig({
104
111
  discovery,
105
112
  regressions,
106
113
  toolchains,
114
+ environments,
115
+ fingerprints,
107
116
  explicitService,
108
117
  discoveredService,
109
118
  suites,
@@ -168,12 +177,157 @@ function normalizeServiceConfig({
168
177
  runtime,
169
178
  browser,
170
179
  ui: config.ui || null,
180
+ environments,
181
+ fingerprints,
171
182
  fileMetadataByPath: serviceFileMetadata,
172
183
  local,
173
184
  },
174
185
  };
175
186
  }
176
187
 
188
+ function normalizeEnvironmentRegistry(environments = {}) {
189
+ if (!environments || typeof environments !== "object" || Array.isArray(environments)) {
190
+ throw new Error("testkit.config.ts environments must be an object");
191
+ }
192
+ return Object.fromEntries(
193
+ Object.entries(environments).map(([name, environment]) => [
194
+ normalizeEnvironmentName(name),
195
+ normalizeEnvironmentConfig(name, environment),
196
+ ])
197
+ );
198
+ }
199
+
200
+ function normalizeEnvironmentName(name) {
201
+ const normalized = String(name || "").trim();
202
+ if (!/^[A-Za-z0-9_-]+$/.test(normalized)) {
203
+ throw new Error(`Environment name "${name}" must contain only letters, numbers, underscores, or dashes`);
204
+ }
205
+ return normalized;
206
+ }
207
+
208
+ function normalizeEnvironmentConfig(name, environment) {
209
+ if (!environment || typeof environment !== "object" || Array.isArray(environment)) {
210
+ throw new Error(`Environment "${name}" must be an object`);
211
+ }
212
+ if ((environment.kind || "local") !== "local") {
213
+ throw new Error(`Environment "${name}" uses unsupported kind "${environment.kind}"`);
214
+ }
215
+ const target = String(environment.target || "").trim();
216
+ if (!target) {
217
+ throw new Error(`Environment "${name}" requires target`);
218
+ }
219
+ const data = environment.data || "reuse";
220
+ if (!["reuse", "reset", "rebuild"].includes(data)) {
221
+ throw new Error(`Environment "${name}" data must be one of: reuse, reset, rebuild`);
222
+ }
223
+ const portOffset = environment.portOffset == null ? 0 : Number(environment.portOffset);
224
+ if (!Number.isInteger(portOffset) || portOffset < 0) {
225
+ throw new Error(`Environment "${name}" portOffset must be a non-negative integer`);
226
+ }
227
+ const driver = environment.driver || "host";
228
+ if (!["host", "kiln"].includes(driver)) {
229
+ throw new Error(`Environment "${name}" driver must be one of: host, kiln`);
230
+ }
231
+ const kiln = normalizeKilnEnvironmentConfig(name, environment.kiln);
232
+ return {
233
+ kind: "local",
234
+ driver,
235
+ target,
236
+ data,
237
+ portOffset,
238
+ env: normalizeEnvironmentEnv(environment.env),
239
+ ...(environment.publicHost ? { publicHost: String(environment.publicHost) } : {}),
240
+ ...(kiln ? { kiln } : {}),
241
+ };
242
+ }
243
+
244
+ function normalizeKilnEnvironmentConfig(name, kiln) {
245
+ if (!kiln) return null;
246
+ if (typeof kiln !== "object" || Array.isArray(kiln)) {
247
+ throw new Error(`Environment "${name}" kiln must be an object`);
248
+ }
249
+ const vm = kiln.vm || {};
250
+ if (vm && (typeof vm !== "object" || Array.isArray(vm))) {
251
+ throw new Error(`Environment "${name}" kiln.vm must be an object`);
252
+ }
253
+ const network = kiln.network || null;
254
+ if (network && (typeof network !== "object" || Array.isArray(network))) {
255
+ throw new Error(`Environment "${name}" kiln.network must be an object`);
256
+ }
257
+ const workspace = kiln.workspace || null;
258
+ if (workspace && (typeof workspace !== "object" || Array.isArray(workspace))) {
259
+ throw new Error(`Environment "${name}" kiln.workspace must be an object`);
260
+ }
261
+ return {
262
+ ...kiln,
263
+ vm: {
264
+ ...(vm || {}),
265
+ ...(vm.name ? { name: String(vm.name) } : {}),
266
+ ...(vm.profile ? { profile: String(vm.profile) } : {}),
267
+ },
268
+ ...(network ? { network: { ...network } } : {}),
269
+ ...(workspace ? { workspace: { ...workspace } } : {}),
270
+ };
271
+ }
272
+
273
+ function normalizeEnvironmentEnv(env) {
274
+ if (!env) return {};
275
+ if (typeof env !== "object" || Array.isArray(env)) {
276
+ throw new Error("Environment env must be an object");
277
+ }
278
+ const hasPresetShape =
279
+ Object.prototype.hasOwnProperty.call(env, "values") ||
280
+ Object.prototype.hasOwnProperty.call(env, "databases");
281
+ if (!hasPresetShape) {
282
+ return Object.fromEntries(
283
+ Object.entries(env).map(([key, value]) => [key, String(value)])
284
+ );
285
+ }
286
+ const allowedKeys = new Set(["values", "databases"]);
287
+ const unexpectedKeys = Object.keys(env).filter((key) => !allowedKeys.has(key));
288
+ if (unexpectedKeys.length > 0) {
289
+ throw new Error(
290
+ `Environment env only supports "values" and "databases". Received unexpected key(s): ${unexpectedKeys.join(", ")}`
291
+ );
292
+ }
293
+ const values = env.values && typeof env.values === "object" && !Array.isArray(env.values) ? env.values : {};
294
+ const databases =
295
+ env.databases && typeof env.databases === "object" && !Array.isArray(env.databases) ? env.databases : {};
296
+ return {
297
+ ...expandDatabaseBindings(databases),
298
+ ...Object.fromEntries(Object.entries(values).map(([key, value]) => [key, String(value)])),
299
+ };
300
+ }
301
+
302
+ function expandDatabaseBindings(bindings) {
303
+ const env = {};
304
+ for (const [name, binding] of Object.entries(bindings || {})) {
305
+ if (!binding || typeof binding !== "object" || Array.isArray(binding)) {
306
+ throw new Error(`env.databases.${name} must be an object`);
307
+ }
308
+ const prefix = normalizeDatabaseEnvToken(binding.prefix, `env.databases.${name}.prefix`);
309
+ const serviceName = normalizeDatabaseEnvToken(binding.service, `env.databases.${name}.service`, false);
310
+ env[`${prefix}_DATABASE_HOST`] = `{dbHost:${serviceName}}`;
311
+ env[`${prefix}_DATABASE_PORT`] = `{dbPort:${serviceName}}`;
312
+ env[`${prefix}_DATABASE_NAME`] = `{dbName:${serviceName}}`;
313
+ env[`${prefix}_DATABASE_USER`] = `{dbUser:${serviceName}}`;
314
+ env[`${prefix}_DATABASE_PASSWORD`] = `{dbPassword:${serviceName}}`;
315
+ env[`${prefix}_DATABASE_SSL`] = "0";
316
+ }
317
+ return env;
318
+ }
319
+
320
+ function normalizeDatabaseEnvToken(value, label, sanitize = true) {
321
+ const raw = String(value || "").trim();
322
+ const normalized = sanitize
323
+ ? raw.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
324
+ : raw;
325
+ if (!normalized) {
326
+ throw new Error(`${label} must be a non-empty string`);
327
+ }
328
+ return normalized;
329
+ }
330
+
177
331
  function normalizeLocalConfig(name, explicitService, discoveredService, productDir) {
178
332
  if (Object.prototype.hasOwnProperty.call(explicitService, "local")) {
179
333
  if (explicitService.local === false) {
@@ -3,6 +3,15 @@ import { validateConfiguredCollection } from "../shared/configured-steps.mjs";
3
3
 
4
4
  export function validateConfigCoverage(configs) {
5
5
  const names = new Set(configs.map((config) => config.name));
6
+ const environments = configs[0]?.testkit?.environments || {};
7
+ for (const [environmentName, environment] of Object.entries(environments)) {
8
+ if (!names.has(environment.target)) {
9
+ const available = [...names].sort().join(", ");
10
+ throw new Error(
11
+ `Environment "${environmentName}" targets unknown service "${environment.target}". Available: ${available}`
12
+ );
13
+ }
14
+ }
6
15
  for (const config of configs) {
7
16
  for (const depName of config.testkit.dependsOn || []) {
8
17
  if (!names.has(depName)) {
@@ -153,6 +153,11 @@ export interface TestkitExecutionConfig {
153
153
  fileTimeoutSeconds?: number;
154
154
  }
155
155
 
156
+ export interface FingerprintConfig {
157
+ exclude?: string[];
158
+ include?: string[];
159
+ }
160
+
156
161
  export interface BrowserServiceConfig {
157
162
  origins?: string[];
158
163
  }
@@ -277,6 +282,49 @@ export interface PresetEnvConfig {
277
282
  databases?: Record<string, DatabaseBindingEnvConfig>;
278
283
  }
279
284
 
285
+ export interface LocalEnvironmentConfig {
286
+ kind: "local";
287
+ data?: "reuse" | "reset" | "rebuild";
288
+ driver?: "host" | "kiln";
289
+ env?: Record<string, string>;
290
+ kiln?: KilnLocalEnvironmentConfig;
291
+ portOffset?: number;
292
+ publicHost?: string;
293
+ target: string;
294
+ }
295
+
296
+ export interface LocalEnvironmentOptions extends Omit<LocalEnvironmentConfig, "kind" | "env"> {
297
+ env?: PresetEnvConfig;
298
+ }
299
+
300
+ export interface KilnLocalEnvironmentConfig {
301
+ api?: {
302
+ apiUrl?: string;
303
+ token?: string;
304
+ };
305
+ network?: {
306
+ attachExistingVMs?: string[];
307
+ cidr?: string;
308
+ id?: string;
309
+ name?: string;
310
+ };
311
+ vm?: {
312
+ autostart?: boolean;
313
+ disk?: string;
314
+ diskSize?: string | number;
315
+ diskSizeMB?: number;
316
+ memoryMB?: number;
317
+ name?: string;
318
+ networkId?: string;
319
+ profile?: string;
320
+ vcpus?: number;
321
+ };
322
+ workspace?: {
323
+ exclude?: string[];
324
+ remotePath?: string;
325
+ };
326
+ }
327
+
280
328
  export interface TestkitFileMetadata {
281
329
  locks?: string[];
282
330
  skip?: string | { reason: string };
@@ -477,6 +525,8 @@ export interface NextAppOptions extends Omit<ServiceConfig, "local" | "runtime"
477
525
  export interface TestkitConfig {
478
526
  discovery?: DiscoveryConfig;
479
527
  execution?: TestkitExecutionConfig;
528
+ environments?: Record<string, LocalEnvironmentConfig>;
529
+ fingerprints?: FingerprintConfig;
480
530
  lint?: TestkitLintConfig;
481
531
  profiles?: {
482
532
  http?: Record<string, HttpSuiteConfig<any>>;
@@ -563,6 +613,9 @@ export declare const toolchain: {
563
613
  export declare const ui: {
564
614
  auth(options?: UiAuthConfig): UiConfig;
565
615
  };
616
+ export declare const environment: {
617
+ local(options: LocalEnvironmentOptions): LocalEnvironmentConfig;
618
+ };
566
619
  export declare const auth: {
567
620
  fixture(options: { contract: JsonSessionContract; topology: AuthTopology }): AuthFixture;
568
621
  contracts: {
@@ -444,6 +444,20 @@ export const ui = {
444
444
  auth: uiAuth,
445
445
  };
446
446
 
447
+ function localEnvironment(options = {}) {
448
+ const { env, ...rest } = options;
449
+ return {
450
+ kind: "local",
451
+ data: "reuse",
452
+ ...rest,
453
+ ...(env !== undefined ? { env: normalizePresetEnv(env) } : {}),
454
+ };
455
+ }
456
+
457
+ export const environment = {
458
+ local: localEnvironment,
459
+ };
460
+
447
461
  export const auth = {
448
462
  fixture: createAuthFixture,
449
463
  contracts: {