@elench/testkit 0.1.75 → 0.1.77

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
@@ -147,14 +147,11 @@ Create `testkit.config.ts` at repo root:
147
147
 
148
148
  ```ts
149
149
  import {
150
+ app,
151
+ database,
150
152
  defineConfig,
151
153
  defineFile,
152
- nextApp,
153
- nodeToolchain,
154
- nodeApp,
155
- seedCommand,
156
- templateDatabase,
157
- verifyModule,
154
+ toolchain,
158
155
  } from "@elench/testkit/config";
159
156
 
160
157
  export default defineConfig({
@@ -171,38 +168,41 @@ export default defineConfig({
171
168
  },
172
169
  },
173
170
  toolchains: {
174
- frontendNode: nodeToolchain({
171
+ frontendNode: toolchain.node({
175
172
  cwd: "frontend",
176
173
  detect: "auto",
177
174
  install: "download",
178
175
  }),
179
176
  },
180
177
  services: {
181
- api: nodeApp({
178
+ api: app.node({
182
179
  cwd: ".",
183
180
  entry: "src/index.ts",
184
181
  port: 3004,
185
182
  envFiles: [".env.testkit"],
186
- database: templateDatabase({
187
- inputs: ["db/schema.sql", "scripts/seed.ts"],
188
- schema: "db/schema.sql",
189
- seed: seedCommand("npm run db:seed"),
190
- verify: verifyModule("src/testkit/verify-seed.ts#verifySeed"),
183
+ database: database.postgres({
184
+ template: {
185
+ inputs: ["db/schema.sql", "scripts/seed.ts"],
186
+ schema: "db/schema.sql",
187
+ seed: [{ kind: "command", run: "npm run db:seed" }],
188
+ verify: [{ kind: "module", target: "src/testkit/verify-seed.ts#verifySeed" }],
189
+ },
191
190
  }),
192
191
  runtime: {
193
192
  instances: 1,
194
193
  maxConcurrentTasks: 4,
195
194
  },
196
195
  }),
197
- frontend: nextApp({
196
+ frontend: app.next({
198
197
  cwd: "frontend",
199
198
  mode: "start",
200
199
  port: 3000,
201
200
  dependsOn: ["api"],
202
201
  envFiles: ["frontend/.env.testkit"],
203
202
  env: {
204
- NEXT_DIST_DIR: "{prepareDir}/dist",
205
- NEXT_PUBLIC_API_URL: "{baseUrl:api}",
203
+ values: {
204
+ NEXT_PUBLIC_API_URL: "{baseUrl:api}",
205
+ },
206
206
  },
207
207
  runtime: {
208
208
  instances: 1,
@@ -258,17 +258,13 @@ state. It always executes in three explicit phases:
258
258
  - `seed`
259
259
  - `verify`
260
260
 
261
- For most repos, prefer the intent-focused helpers:
261
+ For most repos, prefer declarative step objects directly inside
262
+ `database.postgres({ template: ... })` and `runtime.prepare.steps`.
263
+ The supported shapes are:
262
264
 
263
- - `schemaSql(...)`
264
- - `seedCommand(...)`
265
- - `seedModule(...)`
266
- - `verifyCommand(...)`
267
- - `verifyModule(...)`
268
- - `templateDatabase(...)`
269
-
270
- Use raw `commandStep(...)`, `sqlFileStep(...)`, and `moduleStep(...)` arrays when
271
- you need lower-level control over the exact stage layout.
265
+ - `{ kind: "command", run: "..." }`
266
+ - `{ kind: "sql-file", path: "..." }`
267
+ - `{ kind: "module", target: "file.ts#exportName" }`
272
268
 
273
269
  `runtime.toolchain` is the first-class way to make those prepare/start commands
274
270
  run under the correct Node toolchain instead of whatever `node`/`npm` happened
@@ -290,14 +286,14 @@ Example:
290
286
 
291
287
  ```ts
292
288
  toolchains: {
293
- frontendNode: nodeToolchain({
289
+ frontendNode: toolchain.node({
294
290
  cwd: "frontend",
295
291
  detect: "auto",
296
292
  install: "download",
297
293
  }),
298
294
  },
299
295
  services: {
300
- frontend: nextApp({
296
+ frontend: app.next({
301
297
  cwd: "frontend",
302
298
  port: 3000,
303
299
  runtime: {
@@ -508,7 +504,7 @@ Git metadata.
508
504
  ## Local Databases
509
505
 
510
506
  `@elench/testkit` provisions Docker-managed local Postgres automatically for
511
- services that define `database: postgresDatabase(...)` or `database: templateDatabase(...)`.
507
+ services that define `database: database.postgres(...)`.
512
508
 
513
509
  - template databases are cached
514
510
  - runtime databases are cloned from templates when binding is `per-runtime`
@@ -19,7 +19,7 @@ describe("config runtime helpers", () => {
19
19
  normalizeRuntimePrepareConfig(
20
20
  {
21
21
  inputs: ["fixtures/users.json"],
22
- steps: [{ kind: "module", specifier: "./scripts/setup.mjs#run", inputs: ["fixtures/users.json"] }],
22
+ steps: [{ kind: "module", target: "./scripts/setup.mjs#run", inputs: ["fixtures/users.json"] }],
23
23
  },
24
24
  "web"
25
25
  )
@@ -73,7 +73,7 @@ describe("config runtime helpers", () => {
73
73
 
74
74
  it("rejects malformed lifecycle steps and empty step inputs", () => {
75
75
  expect(() => normalizeTemplateLifecycleStep({ kind: "module" }, "runtime.prepare.steps[0]")).toThrow(
76
- /specifier must be a non-empty string/
76
+ /target must be a non-empty string/
77
77
  );
78
78
  expect(() => normalizeTemplateStepInputs([""], "runtime.prepare.steps[0]")).toThrow(
79
79
  /must be a non-empty string/
@@ -22,7 +22,7 @@ export interface TemplateStepBaseConfig {
22
22
 
23
23
  export interface TemplateCommandStepConfig extends TemplateStepBaseConfig {
24
24
  kind: "command";
25
- cmd: string;
25
+ run: string;
26
26
  }
27
27
 
28
28
  export interface TemplateSqlFileStepConfig extends TemplateStepBaseConfig {
@@ -32,7 +32,7 @@ export interface TemplateSqlFileStepConfig extends TemplateStepBaseConfig {
32
32
 
33
33
  export interface TemplateModuleStepConfig extends TemplateStepBaseConfig {
34
34
  kind: "module";
35
- specifier: string;
35
+ target: string;
36
36
  }
37
37
 
38
38
  export type TemplateLifecycleStepConfig =
@@ -177,18 +177,28 @@ export interface ServiceConfig {
177
177
  skip?: SkipConfig;
178
178
  }
179
179
 
180
+ export interface DatabaseBindingEnvConfig {
181
+ prefix: string;
182
+ service: string;
183
+ }
184
+
185
+ export interface PresetEnvConfig {
186
+ values?: Record<string, string>;
187
+ databases?: Record<string, DatabaseBindingEnvConfig>;
188
+ }
189
+
180
190
  export interface TestkitFileMetadata {
181
191
  locks?: string[];
182
192
  skip?: string | { reason: string };
183
193
  }
184
194
 
185
- export interface NodeAppOptions extends Omit<ServiceConfig, "local" | "runtime"> {
195
+ export interface NodeAppOptions extends Omit<ServiceConfig, "local" | "runtime" | "env"> {
186
196
  baseUrl?: string;
187
197
  build?: BuildConfig | null;
188
198
  buildInputs?: string[];
189
199
  cwd?: string;
190
200
  entry?: string;
191
- env?: Record<string, string>;
201
+ env?: PresetEnvConfig;
192
202
  outDir?: string;
193
203
  port: number;
194
204
  readyPath?: string;
@@ -200,12 +210,12 @@ export interface NodeAppOptions extends Omit<ServiceConfig, "local" | "runtime">
200
210
  tsconfig?: string;
201
211
  }
202
212
 
203
- export interface NextAppOptions extends Omit<ServiceConfig, "local" | "runtime"> {
213
+ export interface NextAppOptions extends Omit<ServiceConfig, "local" | "runtime" | "env"> {
204
214
  baseUrl?: string;
205
215
  build?: BuildConfig | null;
206
216
  buildInputs?: string[];
207
217
  cwd?: string;
208
- env?: Record<string, string>;
218
+ env?: PresetEnvConfig;
209
219
  mode?: "dev" | "start";
210
220
  port: number;
211
221
  readyTimeoutMs?: number;
@@ -238,62 +248,27 @@ export interface TestkitConfig {
238
248
  export declare function defineConfig<T extends TestkitConfig>(config: T): T;
239
249
  export declare function defineHttpProfile<T extends HttpSuiteConfig>(profile: T): T;
240
250
  export declare function defineFile<T extends TestkitFileMetadata>(metadata: T): T;
241
- export declare function postgresDatabase(options?: Omit<LocalDatabaseConfig, "provider">): LocalDatabaseConfig;
242
- export declare function commandStep(
243
- cmd: string,
244
- options?: Omit<TemplateCommandStepConfig, "kind" | "cmd">
245
- ): TemplateCommandStepConfig;
246
- export declare function sqlFileStep(
247
- filePath: string,
248
- options?: Omit<TemplateSqlFileStepConfig, "kind" | "path">
249
- ): TemplateSqlFileStepConfig;
250
- export declare function moduleStep(
251
- specifier: string,
252
- options?: Omit<TemplateModuleStepConfig, "kind" | "specifier">
253
- ): TemplateModuleStepConfig;
254
- export declare function schemaSql(
255
- filePath: string,
256
- options?: Omit<TemplateSqlFileStepConfig, "kind" | "path">
257
- ): TemplateSqlFileStepConfig;
258
- export declare function seedCommand(
259
- cmd: string,
260
- options?: Omit<TemplateCommandStepConfig, "kind" | "cmd">
261
- ): TemplateCommandStepConfig;
262
- export declare function seedModule(
263
- specifier: string,
264
- options?: Omit<TemplateModuleStepConfig, "kind" | "specifier">
265
- ): TemplateModuleStepConfig;
266
- export declare function verifyCommand(
267
- cmd: string,
268
- options?: Omit<TemplateCommandStepConfig, "kind" | "cmd">
269
- ): TemplateCommandStepConfig;
270
- export declare function verifyModule(
271
- specifier: string,
272
- options?: Omit<TemplateModuleStepConfig, "kind" | "specifier">
273
- ): TemplateModuleStepConfig;
274
- export declare function templateDatabase(
275
- options?: DatabaseTemplateOptions & Omit<LocalDatabaseConfig, "provider" | "template">
276
- ): LocalDatabaseConfig;
277
- export declare function postgresFixture(
278
- options?: Omit<LocalDatabaseConfig, "provider"> & {
279
- discovery?: DiscoveryConfig;
280
- envFiles?: string[];
281
- }
282
- ): ServiceConfig;
283
- export declare function databaseServiceEnv(
284
- prefix: string,
285
- serviceName: string
286
- ): Record<string, string>;
287
- export declare function nodeToolchain(options?: NodeToolchainConfig): NodeToolchainConfig;
288
- export declare function tscBuild(options?: Omit<TscBuildConfig, "kind">): TscBuildConfig;
289
- export declare function scriptBuild(
290
- script: string,
291
- options?: Omit<ScriptBuildConfig, "kind" | "script">
292
- ): ScriptBuildConfig;
293
- export declare function stepsBuild(options?: Omit<StepsBuildConfig, "kind">): StepsBuildConfig;
294
- export declare function nextBuild(options?: Omit<NextBuildConfig, "kind">): NextBuildConfig;
295
- export declare function nodeApp(options: NodeAppOptions): ServiceConfig;
296
- export declare function nextApp(options: NextAppOptions): ServiceConfig;
251
+ export declare const app: {
252
+ node(options: NodeAppOptions): ServiceConfig;
253
+ next(options: NextAppOptions): ServiceConfig;
254
+ };
255
+ export declare const database: {
256
+ postgres(
257
+ options?: Omit<LocalDatabaseConfig, "provider" | "template"> & {
258
+ template?: DatabaseTemplateOptions;
259
+ }
260
+ ): LocalDatabaseConfig;
261
+ fixture(
262
+ options?: Omit<LocalDatabaseConfig, "provider" | "template"> & {
263
+ template?: DatabaseTemplateOptions;
264
+ discovery?: DiscoveryConfig;
265
+ envFiles?: string[];
266
+ }
267
+ ): ServiceConfig;
268
+ };
269
+ export declare const toolchain: {
270
+ node(options?: NodeToolchainConfig): NodeToolchainConfig;
271
+ };
297
272
  export declare function clerkSessionProfile(options?: {
298
273
  apiBase?: string;
299
274
  needsAuth?: boolean;
@@ -22,60 +22,13 @@ export function defineFile(metadata) {
22
22
  return metadata || {};
23
23
  }
24
24
 
25
- export function postgresDatabase(options = {}) {
25
+ function postgresDatabase(options = {}) {
26
26
  return {
27
27
  provider: "local",
28
28
  ...options,
29
29
  };
30
30
  }
31
31
 
32
- export function commandStep(cmd, options = {}) {
33
- return {
34
- kind: "command",
35
- cmd,
36
- cwd: options.cwd,
37
- inputs: Array.isArray(options.inputs) ? [...options.inputs] : undefined,
38
- };
39
- }
40
-
41
- export function sqlFileStep(filePath, options = {}) {
42
- return {
43
- kind: "sql-file",
44
- path: filePath,
45
- cwd: options.cwd,
46
- inputs: Array.isArray(options.inputs) ? [...options.inputs] : undefined,
47
- };
48
- }
49
-
50
- export function moduleStep(specifier, options = {}) {
51
- return {
52
- kind: "module",
53
- specifier,
54
- cwd: options.cwd,
55
- inputs: Array.isArray(options.inputs) ? [...options.inputs] : undefined,
56
- };
57
- }
58
-
59
- export function schemaSql(filePath, options = {}) {
60
- return sqlFileStep(filePath, options);
61
- }
62
-
63
- export function seedCommand(cmd, options = {}) {
64
- return commandStep(cmd, options);
65
- }
66
-
67
- export function seedModule(specifier, options = {}) {
68
- return moduleStep(specifier, options);
69
- }
70
-
71
- export function verifyCommand(cmd, options = {}) {
72
- return commandStep(cmd, options);
73
- }
74
-
75
- export function verifyModule(specifier, options = {}) {
76
- return moduleStep(specifier, options);
77
- }
78
-
79
32
  function buildDatabaseTemplateConfig(options = {}) {
80
33
  const migrate = normalizeTemplateStepList(options.migrate);
81
34
  const seed = normalizeTemplateStepList(options.seed);
@@ -90,60 +43,40 @@ function buildDatabaseTemplateConfig(options = {}) {
90
43
  };
91
44
  }
92
45
 
93
- export function templateDatabase(options = {}) {
94
- const {
95
- inputs,
96
- schema,
97
- migrate,
98
- seed,
99
- verify,
100
- ...databaseOptions
101
- } = options;
102
- return postgresDatabase({
103
- ...databaseOptions,
104
- template: buildDatabaseTemplateConfig(options.template || { inputs, schema, migrate, seed, verify }),
105
- });
106
- }
107
-
108
- export function postgresFixture(options = {}) {
109
- const { discovery, envFiles, ...databaseOptions } = options;
46
+ function postgresFixture(options = {}) {
47
+ const { discovery, envFiles, template, ...databaseOptions } = options;
48
+ for (const legacyKey of ["inputs", "schema", "migrate", "seed", "verify"]) {
49
+ if (Object.prototype.hasOwnProperty.call(options, legacyKey)) {
50
+ throw new Error(
51
+ `database.fixture(...) no longer accepts top-level "${legacyKey}". Move lifecycle config under database.fixture({ template: { ... } }).`
52
+ );
53
+ }
54
+ }
110
55
  return {
111
56
  discovery: discovery || {
112
57
  roots: [".testkit-fixture"],
113
58
  },
114
59
  envFiles,
115
60
  local: false,
116
- database: postgresDatabase(databaseOptions),
117
- };
118
- }
119
-
120
- export function databaseServiceEnv(prefix, serviceName) {
121
- const normalizedPrefix = String(prefix || "").trim().replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
122
- if (!normalizedPrefix) {
123
- throw new Error("databaseServiceEnv prefix must be a non-empty string");
124
- }
125
- if (!serviceName || !String(serviceName).trim()) {
126
- throw new Error("databaseServiceEnv serviceName must be a non-empty string");
127
- }
128
-
129
- return {
130
- [`${normalizedPrefix}_DATABASE_HOST`]: `{dbHost:${serviceName}}`,
131
- [`${normalizedPrefix}_DATABASE_PORT`]: `{dbPort:${serviceName}}`,
132
- [`${normalizedPrefix}_DATABASE_NAME`]: `{dbName:${serviceName}}`,
133
- [`${normalizedPrefix}_DATABASE_USER`]: `{dbUser:${serviceName}}`,
134
- [`${normalizedPrefix}_DATABASE_PASSWORD`]: `{dbPassword:${serviceName}}`,
135
- [`${normalizedPrefix}_DATABASE_SSL`]: "0",
61
+ database: postgresDatabase(
62
+ template
63
+ ? {
64
+ ...databaseOptions,
65
+ template: buildDatabaseTemplateConfig(template),
66
+ }
67
+ : databaseOptions
68
+ ),
136
69
  };
137
70
  }
138
71
 
139
- export function nodeToolchain(options = {}) {
72
+ function nodeToolchain(options = {}) {
140
73
  return {
141
74
  kind: "node",
142
75
  ...options,
143
76
  };
144
77
  }
145
78
 
146
- export function tscBuild(options = {}) {
79
+ function tscBuild(options = {}) {
147
80
  return {
148
81
  kind: "tsc",
149
82
  cwd: options.cwd,
@@ -154,7 +87,7 @@ export function tscBuild(options = {}) {
154
87
  };
155
88
  }
156
89
 
157
- export function scriptBuild(script, options = {}) {
90
+ function scriptBuild(script, options = {}) {
158
91
  return {
159
92
  kind: "script",
160
93
  script,
@@ -163,7 +96,7 @@ export function scriptBuild(script, options = {}) {
163
96
  };
164
97
  }
165
98
 
166
- export function stepsBuild(options = {}) {
99
+ function stepsBuild(options = {}) {
167
100
  return {
168
101
  kind: "steps",
169
102
  inputs: Array.isArray(options.inputs) ? [...options.inputs] : undefined,
@@ -171,7 +104,7 @@ export function stepsBuild(options = {}) {
171
104
  };
172
105
  }
173
106
 
174
- export function nextBuild(options = {}) {
107
+ function nextBuild(options = {}) {
175
108
  return {
176
109
  kind: "next",
177
110
  cwd: options.cwd,
@@ -181,14 +114,14 @@ export function nextBuild(options = {}) {
181
114
  };
182
115
  }
183
116
 
184
- export function nodeApp(options = {}) {
117
+ function nodeApp(options = {}) {
185
118
  const {
186
119
  baseUrl: explicitBaseUrl,
187
120
  build: explicitBuild,
188
121
  buildInputs,
189
122
  cwd = ".",
190
123
  entry = "src/index.ts",
191
- env = {},
124
+ env,
192
125
  envFiles,
193
126
  outDir = "dist",
194
127
  port,
@@ -202,7 +135,8 @@ export function nodeApp(options = {}) {
202
135
  ...serviceConfig
203
136
  } = options;
204
137
 
205
- const normalizedPort = requiredNumber(port, "nodeApp port");
138
+ const normalizedPort = requiredNumber(port, "app.node port");
139
+ const normalizedEnv = normalizePresetEnv(env);
206
140
  const baseUrl = explicitBaseUrl || "http://127.0.0.1:{port}";
207
141
  const build = explicitBuild === undefined ? tscBuild({
208
142
  cwd,
@@ -228,12 +162,12 @@ export function nodeApp(options = {}) {
228
162
  baseUrl,
229
163
  readyUrl: explicitReadyUrl || `${baseUrl}${readyPath}`,
230
164
  readyTimeoutMs,
231
- env,
165
+ env: normalizedEnv,
232
166
  },
233
167
  };
234
168
  }
235
169
 
236
- export function nextApp(options = {}) {
170
+ function nextApp(options = {}) {
237
171
  const {
238
172
  baseUrl: explicitBaseUrl,
239
173
  browser,
@@ -241,7 +175,7 @@ export function nextApp(options = {}) {
241
175
  buildInputs,
242
176
  cwd = ".",
243
177
  dependsOn,
244
- env = {},
178
+ env,
245
179
  envFiles,
246
180
  mode = "dev",
247
181
  port,
@@ -253,7 +187,8 @@ export function nextApp(options = {}) {
253
187
  ...serviceConfig
254
188
  } = options;
255
189
 
256
- const normalizedPort = requiredNumber(port, "nextApp port");
190
+ const normalizedPort = requiredNumber(port, "app.next port");
191
+ const normalizedEnv = normalizePresetEnv(env);
257
192
  const baseUrl = explicitBaseUrl || "http://127.0.0.1:{port}";
258
193
  const build =
259
194
  explicitBuild === undefined
@@ -286,15 +221,47 @@ export function nextApp(options = {}) {
286
221
  readyTimeoutMs,
287
222
  env: mode === "start"
288
223
  ? {
289
- NEXT_DIST_DIR: env.NEXT_DIST_DIR || ".next-testkit/{runtimeId}/dist",
290
- NEXT_TSCONFIG_PATH: env.NEXT_TSCONFIG_PATH || ".next-testkit/{runtimeId}/tsconfig.json",
291
- ...env,
224
+ NEXT_DIST_DIR: normalizedEnv.NEXT_DIST_DIR || ".next-testkit/{runtimeId}/dist",
225
+ NEXT_TSCONFIG_PATH:
226
+ normalizedEnv.NEXT_TSCONFIG_PATH || ".next-testkit/{runtimeId}/tsconfig.json",
227
+ ...normalizedEnv,
292
228
  }
293
- : env,
229
+ : normalizedEnv,
294
230
  },
295
231
  };
296
232
  }
297
233
 
234
+ export const app = {
235
+ node: nodeApp,
236
+ next: nextApp,
237
+ };
238
+
239
+ export const database = {
240
+ postgres(options = {}) {
241
+ const { template, ...databaseOptions } = options;
242
+ for (const legacyKey of ["inputs", "schema", "migrate", "seed", "verify"]) {
243
+ if (Object.prototype.hasOwnProperty.call(options, legacyKey)) {
244
+ throw new Error(
245
+ `database.postgres(...) no longer accepts top-level "${legacyKey}". Move lifecycle config under database.postgres({ template: { ... } }).`
246
+ );
247
+ }
248
+ }
249
+ return postgresDatabase(
250
+ template
251
+ ? {
252
+ ...databaseOptions,
253
+ template: buildDatabaseTemplateConfig(template),
254
+ }
255
+ : databaseOptions
256
+ );
257
+ },
258
+ fixture: postgresFixture,
259
+ };
260
+
261
+ export const toolchain = {
262
+ node: nodeToolchain,
263
+ };
264
+
298
265
  export function clerkSessionProfile(options = {}) {
299
266
  const apiBase = options.apiBase || "https://api.clerk.com/v1";
300
267
  const secretKeyEnv = options.secretKeyEnv || "CLERK_SECRET_KEY";
@@ -381,6 +348,58 @@ function requiredNumber(value, label) {
381
348
  return value;
382
349
  }
383
350
 
351
+ function normalizePresetEnv(env) {
352
+ if (!env) return {};
353
+ if (typeof env !== "object" || Array.isArray(env)) {
354
+ throw new Error("Preset env must be an object");
355
+ }
356
+ const allowedKeys = new Set(["values", "databases"]);
357
+ const unexpectedKeys = Object.keys(env).filter((key) => !allowedKeys.has(key));
358
+ if (unexpectedKeys.length > 0) {
359
+ throw new Error(
360
+ `Preset env only supports "values" and "databases". Received unexpected key(s): ${unexpectedKeys.join(", ")}`
361
+ );
362
+ }
363
+
364
+ const values = env.values && typeof env.values === "object" && !Array.isArray(env.values) ? env.values : {};
365
+ const databases =
366
+ env.databases && typeof env.databases === "object" && !Array.isArray(env.databases) ? env.databases : {};
367
+
368
+ return {
369
+ ...expandDatabaseBindings(databases),
370
+ ...values,
371
+ };
372
+ }
373
+
374
+ function expandDatabaseBindings(bindings) {
375
+ const env = {};
376
+ for (const [name, binding] of Object.entries(bindings || {})) {
377
+ if (!binding || typeof binding !== "object" || Array.isArray(binding)) {
378
+ throw new Error(`env.databases.${name} must be an object`);
379
+ }
380
+ const prefix = normalizeDatabaseEnvToken(binding.prefix, `env.databases.${name}.prefix`);
381
+ const serviceName = normalizeDatabaseEnvToken(binding.service, `env.databases.${name}.service`, false);
382
+ env[`${prefix}_DATABASE_HOST`] = `{dbHost:${serviceName}}`;
383
+ env[`${prefix}_DATABASE_PORT`] = `{dbPort:${serviceName}}`;
384
+ env[`${prefix}_DATABASE_NAME`] = `{dbName:${serviceName}}`;
385
+ env[`${prefix}_DATABASE_USER`] = `{dbUser:${serviceName}}`;
386
+ env[`${prefix}_DATABASE_PASSWORD`] = `{dbPassword:${serviceName}}`;
387
+ env[`${prefix}_DATABASE_SSL`] = "0";
388
+ }
389
+ return env;
390
+ }
391
+
392
+ function normalizeDatabaseEnvToken(value, label, sanitize = true) {
393
+ const raw = String(value || "").trim();
394
+ const normalized = sanitize
395
+ ? raw.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
396
+ : raw;
397
+ if (!normalized) {
398
+ throw new Error(`${label} must be a non-empty string`);
399
+ }
400
+ return normalized;
401
+ }
402
+
384
403
  function resolveNodeAppStart(build, entry) {
385
404
  if (build?.kind === "tsc") {
386
405
  const compiled = compiledEntryFromSource(entry || build.entry || "src/index.ts", build.outDir || "dist");
@@ -397,7 +416,10 @@ function normalizeTemplateStepList(value) {
397
416
  function normalizeSchemaStep(value) {
398
417
  if (value == null) return null;
399
418
  if (typeof value === "string") {
400
- return sqlFileStep(value);
419
+ return {
420
+ kind: "sql-file",
421
+ path: value,
422
+ };
401
423
  }
402
424
  return value;
403
425
  }
@@ -1,26 +1,14 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
- databaseServiceEnv,
3
+ app,
4
+ database,
4
5
  defineConfig,
5
6
  defineFile,
6
7
  defineHttpProfile,
7
- nextApp,
8
- nextBuild,
9
- nodeToolchain,
10
- nodeApp,
11
- postgresDatabase,
12
- postgresFixture,
13
- schemaSql,
14
- seedCommand,
15
- seedModule,
16
- stepsBuild,
17
- templateDatabase,
18
- tscBuild,
19
- verifyCommand,
20
- verifyModule,
8
+ toolchain,
21
9
  } from "./index.mjs";
22
10
 
23
- describe("config helpers", () => {
11
+ describe("config api", () => {
24
12
  it("defines repo config plainly", () => {
25
13
  expect(defineConfig({ execution: { workers: 4 } })).toEqual({
26
14
  execution: { workers: 4 },
@@ -40,13 +28,13 @@ describe("config helpers", () => {
40
28
  });
41
29
 
42
30
  it("builds a Next app preset for dev mode", () => {
43
- const config = nextApp({ port: 3000 });
31
+ const config = app.next({ port: 3000 });
44
32
 
45
33
  expect(config.local.start).toBe("./node_modules/.bin/next dev -p {port}");
46
34
  });
47
35
 
48
36
  it("builds a Next app preset for start mode with managed runtime env defaults", () => {
49
- const config = nextApp({ cwd: "frontend", port: 3000, mode: "start" });
37
+ const config = app.next({ cwd: "frontend", port: 3000, mode: "start" });
50
38
 
51
39
  expect(config.local.start).toBe("./node_modules/.bin/next start --port {port}");
52
40
  expect(config.local.env).toMatchObject({
@@ -63,7 +51,7 @@ describe("config helpers", () => {
63
51
  });
64
52
 
65
53
  it("allows Next start apps to disable managed builds explicitly", () => {
66
- const config = nextApp({ cwd: "frontend", port: 3000, mode: "start", build: null });
54
+ const config = app.next({ cwd: "frontend", port: 3000, mode: "start", build: null });
67
55
 
68
56
  expect(config.runtime.build).toBeNull();
69
57
  expect(config.local.env).toMatchObject({
@@ -73,7 +61,7 @@ describe("config helpers", () => {
73
61
  });
74
62
 
75
63
  it("builds a Node app preset with tsc build defaults", () => {
76
- const config = nodeApp({ port: 3000, entry: "src/server.ts" });
64
+ const config = app.node({ port: 3000, entry: "src/server.ts" });
77
65
 
78
66
  expect(config.local.start).toBe("node {prepareDir}/dist/server.js");
79
67
  expect(config.runtime.build).toEqual({
@@ -87,88 +75,22 @@ describe("config helpers", () => {
87
75
  });
88
76
 
89
77
  it("builds node toolchain profiles with a node kind", () => {
90
- expect(nodeToolchain({ node: "20.19.5", install: "download" })).toEqual({
78
+ expect(toolchain.node({ node: "20.19.5", install: "download" })).toEqual({
91
79
  kind: "node",
92
80
  node: "20.19.5",
93
81
  install: "download",
94
82
  });
95
83
  });
96
84
 
97
- it("builds explicit build presets", () => {
98
- expect(tscBuild({ entry: "src/server.ts", outDir: "build" })).toEqual({
99
- kind: "tsc",
100
- cwd: undefined,
101
- entry: "src/server.ts",
102
- tsconfig: "tsconfig.json",
103
- outDir: "build",
104
- inputs: undefined,
105
- });
106
- expect(nextBuild({ cwd: "frontend" })).toEqual({
107
- kind: "next",
108
- cwd: "frontend",
109
- distDir: "dist",
110
- tsconfig: "tsconfig.json",
111
- inputs: undefined,
112
- });
85
+ it("builds declarative postgres database templates from plain objects", () => {
113
86
  expect(
114
- stepsBuild({
115
- inputs: ["scripts/prepare.mjs"],
116
- steps: [seedCommand("node scripts/prepare.mjs")],
117
- })
118
- ).toEqual({
119
- kind: "steps",
120
- inputs: ["scripts/prepare.mjs"],
121
- steps: [
122
- {
123
- kind: "command",
124
- cmd: "node scripts/prepare.mjs",
125
- cwd: undefined,
126
- inputs: undefined,
87
+ database.postgres({
88
+ template: {
89
+ inputs: ["db/schema.sql", "scripts/seed.ts"],
90
+ schema: "db/schema.sql",
91
+ seed: { kind: "command", run: "npm run db:seed" },
92
+ verify: { kind: "module", target: "scripts/verify.ts#verifySeed" },
127
93
  },
128
- ],
129
- });
130
- });
131
-
132
- it("emits semantic database template steps using the underlying step shapes", () => {
133
- expect(schemaSql("db/schema.sql")).toEqual({
134
- kind: "sql-file",
135
- path: "db/schema.sql",
136
- cwd: undefined,
137
- inputs: undefined,
138
- });
139
- expect(seedCommand("npm run db:seed")).toEqual({
140
- kind: "command",
141
- cmd: "npm run db:seed",
142
- cwd: undefined,
143
- inputs: undefined,
144
- });
145
- expect(seedModule("scripts/seed.ts#seed")).toEqual({
146
- kind: "module",
147
- specifier: "scripts/seed.ts#seed",
148
- cwd: undefined,
149
- inputs: undefined,
150
- });
151
- expect(verifyCommand("npm run db:verify")).toEqual({
152
- kind: "command",
153
- cmd: "npm run db:verify",
154
- cwd: undefined,
155
- inputs: undefined,
156
- });
157
- expect(verifyModule("scripts/verify.ts#verify")).toEqual({
158
- kind: "module",
159
- specifier: "scripts/verify.ts#verify",
160
- cwd: undefined,
161
- inputs: undefined,
162
- });
163
- });
164
-
165
- it("builds declarative template databases from schema, seed, and verify intents", () => {
166
- expect(
167
- templateDatabase({
168
- inputs: ["db/schema.sql", "scripts/seed.ts"],
169
- schema: "db/schema.sql",
170
- seed: seedCommand("npm run db:seed"),
171
- verify: verifyModule("scripts/verify.ts#verifySeed"),
172
94
  })
173
95
  ).toEqual({
174
96
  provider: "local",
@@ -178,24 +100,18 @@ describe("config helpers", () => {
178
100
  {
179
101
  kind: "sql-file",
180
102
  path: "db/schema.sql",
181
- cwd: undefined,
182
- inputs: undefined,
183
103
  },
184
104
  ],
185
105
  seed: [
186
106
  {
187
107
  kind: "command",
188
- cmd: "npm run db:seed",
189
- cwd: undefined,
190
- inputs: undefined,
108
+ run: "npm run db:seed",
191
109
  },
192
110
  ],
193
111
  verify: [
194
112
  {
195
113
  kind: "module",
196
- specifier: "scripts/verify.ts#verifySeed",
197
- cwd: undefined,
198
- inputs: undefined,
114
+ target: "scripts/verify.ts#verifySeed",
199
115
  },
200
116
  ],
201
117
  },
@@ -204,9 +120,11 @@ describe("config helpers", () => {
204
120
 
205
121
  it("prepends schema before explicit migrate steps and normalizes singletons to arrays", () => {
206
122
  expect(
207
- templateDatabase({
208
- schema: schemaSql("db/schema.sql", { cwd: "db" }),
209
- migrate: seedCommand("echo migrate"),
123
+ database.postgres({
124
+ template: {
125
+ schema: { kind: "sql-file", path: "db/schema.sql", cwd: "db" },
126
+ migrate: { kind: "command", run: "echo migrate" },
127
+ },
210
128
  })
211
129
  ).toEqual({
212
130
  provider: "local",
@@ -217,13 +135,10 @@ describe("config helpers", () => {
217
135
  kind: "sql-file",
218
136
  path: "db/schema.sql",
219
137
  cwd: "db",
220
- inputs: undefined,
221
138
  },
222
139
  {
223
140
  kind: "command",
224
- cmd: "echo migrate",
225
- cwd: undefined,
226
- inputs: undefined,
141
+ run: "echo migrate",
227
142
  },
228
143
  ],
229
144
  seed: [],
@@ -232,46 +147,9 @@ describe("config helpers", () => {
232
147
  });
233
148
  });
234
149
 
235
- it("builds declarative postgres database helpers", () => {
236
- expect(postgresDatabase({ reset: false })).toEqual({
237
- provider: "local",
238
- reset: false,
239
- });
150
+ it("builds support database presets and expands database env bindings declaratively", () => {
240
151
  expect(
241
- templateDatabase({
242
- reset: true,
243
- schema: "db/schema.sql",
244
- seed: seedCommand("npm run db:seed"),
245
- })
246
- ).toEqual({
247
- provider: "local",
248
- reset: true,
249
- template: {
250
- inputs: undefined,
251
- migrate: [
252
- {
253
- kind: "sql-file",
254
- path: "db/schema.sql",
255
- cwd: undefined,
256
- inputs: undefined,
257
- },
258
- ],
259
- seed: [
260
- {
261
- kind: "command",
262
- cmd: "npm run db:seed",
263
- cwd: undefined,
264
- inputs: undefined,
265
- },
266
- ],
267
- verify: [],
268
- },
269
- });
270
- });
271
-
272
- it("builds support database presets and env bindings", () => {
273
- expect(
274
- postgresFixture({
152
+ database.fixture({
275
153
  reset: true,
276
154
  })
277
155
  ).toEqual({
@@ -285,7 +163,19 @@ describe("config helpers", () => {
285
163
  reset: true,
286
164
  },
287
165
  });
288
- expect(databaseServiceEnv("ONIX", "catalog")).toEqual({
166
+
167
+ const config = app.node({
168
+ port: 3000,
169
+ env: {
170
+ values: { API_KEY: "test" },
171
+ databases: {
172
+ onix: { service: "catalog", prefix: "ONIX" },
173
+ },
174
+ },
175
+ });
176
+
177
+ expect(config.local.env).toEqual({
178
+ API_KEY: "test",
289
179
  ONIX_DATABASE_HOST: "{dbHost:catalog}",
290
180
  ONIX_DATABASE_PORT: "{dbPort:catalog}",
291
181
  ONIX_DATABASE_NAME: "{dbName:catalog}",
@@ -295,12 +185,25 @@ describe("config helpers", () => {
295
185
  });
296
186
  });
297
187
 
188
+ it("rejects top-level database template lifecycle fields", () => {
189
+ expect(() =>
190
+ database.postgres({
191
+ schema: "db/schema.sql",
192
+ })
193
+ ).toThrow(/no longer accepts top-level "schema"/);
194
+ expect(() =>
195
+ database.fixture({
196
+ seed: { kind: "command", run: "npm run db:seed" },
197
+ })
198
+ ).toThrow(/no longer accepts top-level "seed"/);
199
+ });
200
+
298
201
  it("does not leak preset-only helper fields into node app configs", () => {
299
- const config = nodeApp({
202
+ const config = app.node({
300
203
  port: 3000,
301
204
  entry: "src/server.ts",
302
205
  buildInputs: ["src", "package.json"],
303
- env: { API_KEY: "test" },
206
+ env: { values: { API_KEY: "test" } },
304
207
  readyPath: "/live",
305
208
  });
306
209
 
@@ -310,4 +213,15 @@ describe("config helpers", () => {
310
213
  expect(config.local.env).toEqual({ API_KEY: "test" });
311
214
  expect(config.local.readyUrl).toBe("http://127.0.0.1:{port}/live");
312
215
  });
216
+
217
+ it("rejects flat preset env maps in favor of env.values and env.databases", () => {
218
+ expect(() =>
219
+ app.node({
220
+ port: 3000,
221
+ env: {
222
+ API_KEY: "test",
223
+ },
224
+ })
225
+ ).toThrow(/Preset env only supports "values" and "databases"/);
226
+ });
313
227
  });
@@ -19,11 +19,11 @@ describe("coverage graph builder", () => {
19
19
  productDir,
20
20
  "testkit.config.ts",
21
21
  `
22
- import { defineConfig, nextApp } from "@elench/testkit/config";
22
+ import { app, defineConfig } from "@elench/testkit/config";
23
23
 
24
24
  export default defineConfig({
25
25
  services: {
26
- web: nextApp({
26
+ web: app.next({
27
27
  cwd: ".",
28
28
  start: "node server.js",
29
29
  mode: "start",
@@ -280,6 +280,7 @@ export function resolveTemplateString(value, context) {
280
280
  return value.replace(/\{([a-zA-Z]+)(?::([a-zA-Z0-9_-]+))?\}/g, (_match, token, arg) => {
281
281
  switch (token) {
282
282
  case "runtime":
283
+ case "runtimeId":
283
284
  return String(context.runtimeId);
284
285
  case "service":
285
286
  return context.serviceName;
@@ -192,6 +192,9 @@ describe("runner-template", () => {
192
192
  TESTKIT_LEASE_ID: "lease-1",
193
193
  TESTKIT_LEASE_DIR: "/tmp/lease-1",
194
194
  });
195
+ expect(finalizeString(".next-testkit/{runtimeId}/dist", resolved[1].testkit.templateContext)).toBe(
196
+ ".next-testkit/runtime-2/dist"
197
+ );
195
198
 
196
199
  expect(
197
200
  buildPlaywrightEnv(
@@ -77,7 +77,7 @@ describe("shared build config helpers", () => {
77
77
  {
78
78
  kind: "steps",
79
79
  inputs: ["scripts/prepare.mjs"],
80
- steps: [{ kind: "command", cmd: "node scripts/prepare.mjs" }],
80
+ steps: [{ kind: "command", run: "node scripts/prepare.mjs" }],
81
81
  },
82
82
  "runtime.build"
83
83
  )
@@ -53,8 +53,8 @@ export function normalizeConfiguredStep(step, label) {
53
53
 
54
54
  const kind = normalizeOptionalString(step.kind);
55
55
  if (kind === "command") {
56
- const cmd = normalizeOptionalString(step.cmd);
57
- if (!cmd) throw new Error(`${label}.cmd must be a non-empty string`);
56
+ const cmd = normalizeOptionalString(step.run);
57
+ if (!cmd) throw new Error(`${label}.run must be a non-empty string`);
58
58
  return {
59
59
  kind,
60
60
  cmd,
@@ -73,8 +73,8 @@ export function normalizeConfiguredStep(step, label) {
73
73
  };
74
74
  }
75
75
  if (kind === "module") {
76
- const specifier = normalizeOptionalString(step.specifier);
77
- if (!specifier) throw new Error(`${label}.specifier must be a non-empty string`);
76
+ const specifier = normalizeOptionalString(step.target);
77
+ if (!specifier) throw new Error(`${label}.target must be a non-empty string`);
78
78
  return {
79
79
  kind,
80
80
  specifier,
@@ -118,7 +118,8 @@ export function collectConfiguredInputs(productDir, { inputs = [], steps = [] }
118
118
  collected.add(resolveConfiguredPath(productDir, step.cwd, step.path));
119
119
  }
120
120
  if (step.kind === "module") {
121
- const { modulePath } = parseModuleSpecifier(step.specifier);
121
+ const moduleRef = step.specifier || step.target;
122
+ const { modulePath } = parseModuleSpecifier(moduleRef);
122
123
  const candidatePath = resolveConfiguredPath(productDir, step.cwd, modulePath);
123
124
  if (modulePath.startsWith("@elench/testkit") || fs.existsSync(candidatePath)) {
124
125
  if (!modulePath.startsWith("@elench/testkit")) {
@@ -138,7 +139,7 @@ export function collectConfiguredInputs(productDir, { inputs = [], steps = [] }
138
139
  export function summarizeConfiguredStep(step) {
139
140
  if (step.kind === "command") return `command: ${String(step.cmd).trim()}`;
140
141
  if (step.kind === "sql-file") return `sql: ${step.path}`;
141
- if (step.kind === "module") return `module: ${step.specifier}`;
142
+ if (step.kind === "module") return `module: ${step.specifier || step.target}`;
142
143
  return step.kind;
143
144
  }
144
145
 
@@ -174,7 +175,8 @@ export function validateConfiguredCollection({ productDir, label, inputs = [], s
174
175
  ensureExistingPath(resolveConfiguredPath(productDir, step.cwd, step.path), `${label} sql file`);
175
176
  }
176
177
  if (step.kind === "module") {
177
- const { modulePath } = parseModuleSpecifier(step.specifier);
178
+ const moduleRef = step.specifier || step.target;
179
+ const { modulePath } = parseModuleSpecifier(moduleRef);
178
180
  const candidatePath = resolveConfiguredPath(productDir, step.cwd, modulePath);
179
181
  if (modulePath.startsWith("@elench/testkit")) {
180
182
  // Internal testkit module steps are resolved by the runtime alias map.
@@ -22,7 +22,7 @@ afterEach(() => {
22
22
  describe("shared configured steps", () => {
23
23
  it("normalizes and finalizes configured steps", () => {
24
24
  const normalized = normalizeConfiguredStep(
25
- { kind: "module", specifier: "./seed.mjs#run", cwd: "db", inputs: ["schema.sql"] },
25
+ { kind: "module", target: "./seed.mjs#run", cwd: "db", inputs: ["schema.sql"] },
26
26
  'Service "api" database.template.seed[0]'
27
27
  );
28
28
 
@@ -55,7 +55,7 @@ describe("shared configured steps", () => {
55
55
  inputs: ["db/schema.sql"],
56
56
  steps: [
57
57
  { kind: "sql-file", path: "schema.sql", cwd: "db", inputs: [] },
58
- { kind: "module", specifier: "./seed.mjs#run", cwd: "db", inputs: [] },
58
+ { kind: "module", target: "./seed.mjs#run", cwd: "db", inputs: [] },
59
59
  ],
60
60
  };
61
61
 
@@ -82,7 +82,7 @@ describe("shared configured steps", () => {
82
82
  steps: [
83
83
  {
84
84
  kind: "module",
85
- specifier: "@elench/testkit/config/next-runtime-tsconfig#writeNextRuntimeTsconfig",
85
+ target: "@elench/testkit/config/next-runtime-tsconfig#writeNextRuntimeTsconfig",
86
86
  cwd: "frontend",
87
87
  inputs: [],
88
88
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/next-analysis",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "description": "SWC-backed Next.js source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-bridge",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "description": "Browser bridge helpers for testkit",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc -p tsconfig.json --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@elench/testkit-protocol": "0.1.75"
25
+ "@elench/testkit-protocol": "0.1.77"
26
26
  },
27
27
  "private": false
28
28
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/ts-analysis",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "description": "TypeScript compiler-backed source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -81,10 +81,10 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@babel/code-frame": "^7.29.0",
84
- "@elench/next-analysis": "0.1.75",
85
- "@elench/testkit-bridge": "0.1.75",
86
- "@elench/testkit-protocol": "0.1.75",
87
- "@elench/ts-analysis": "0.1.75",
84
+ "@elench/next-analysis": "0.1.77",
85
+ "@elench/testkit-bridge": "0.1.77",
86
+ "@elench/testkit-protocol": "0.1.77",
87
+ "@elench/ts-analysis": "0.1.77",
88
88
  "@oclif/core": "^4.10.6",
89
89
  "esbuild": "^0.25.11",
90
90
  "execa": "^9.5.0",