@elench/testkit 0.1.137 → 0.1.138

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.
@@ -236,11 +236,33 @@ function normalizeEnvironmentConfig(name, environment) {
236
236
  data,
237
237
  portOffset,
238
238
  env: normalizeEnvironmentEnv(environment.env),
239
+ resources: normalizeEnvironmentResources(name, environment.resources),
240
+ productionLike: Boolean(environment.productionLike),
239
241
  ...(environment.publicHost ? { publicHost: String(environment.publicHost) } : {}),
240
242
  ...(kiln ? { kiln } : {}),
241
243
  };
242
244
  }
243
245
 
246
+ function normalizeEnvironmentResources(environmentName, resources = {}) {
247
+ if (resources == null) return {};
248
+ if (typeof resources !== "object" || Array.isArray(resources)) {
249
+ throw new Error(`Environment "${environmentName}" resources must be an object`);
250
+ }
251
+ return Object.fromEntries(
252
+ Object.entries(resources).map(([name, resource]) => {
253
+ const normalizedName = normalizeDatabaseEnvToken(name, `Environment "${environmentName}" resource name`, false);
254
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) {
255
+ throw new Error(`Environment "${environmentName}" resource "${name}" must be an object`);
256
+ }
257
+ const kind = String(resource.kind || "").trim();
258
+ if (!["postgres", "server"].includes(kind)) {
259
+ throw new Error(`Environment "${environmentName}" resource "${name}" kind must be "postgres" or "server"`);
260
+ }
261
+ return [normalizedName, { ...resource, kind }];
262
+ })
263
+ );
264
+ }
265
+
244
266
  function normalizeKilnEnvironmentConfig(name, kiln) {
245
267
  if (!kiln) return null;
246
268
  if (typeof kiln !== "object" || Array.isArray(kiln)) {
@@ -277,13 +299,14 @@ function normalizeEnvironmentEnv(env) {
277
299
  }
278
300
  const hasPresetShape =
279
301
  Object.prototype.hasOwnProperty.call(env, "values") ||
280
- Object.prototype.hasOwnProperty.call(env, "databases");
302
+ Object.prototype.hasOwnProperty.call(env, "databases") ||
303
+ Object.prototype.hasOwnProperty.call(env, "resources");
281
304
  if (!hasPresetShape) {
282
305
  return Object.fromEntries(
283
306
  Object.entries(env).map(([key, value]) => [key, String(value)])
284
307
  );
285
308
  }
286
- const allowedKeys = new Set(["values", "databases"]);
309
+ const allowedKeys = new Set(["values", "databases", "resources"]);
287
310
  const unexpectedKeys = Object.keys(env).filter((key) => !allowedKeys.has(key));
288
311
  if (unexpectedKeys.length > 0) {
289
312
  throw new Error(
@@ -293,8 +316,11 @@ function normalizeEnvironmentEnv(env) {
293
316
  const values = env.values && typeof env.values === "object" && !Array.isArray(env.values) ? env.values : {};
294
317
  const databases =
295
318
  env.databases && typeof env.databases === "object" && !Array.isArray(env.databases) ? env.databases : {};
319
+ const resources =
320
+ env.resources && typeof env.resources === "object" && !Array.isArray(env.resources) ? env.resources : {};
296
321
  return {
297
322
  ...expandDatabaseBindings(databases),
323
+ ...expandResourceBindings(resources),
298
324
  ...Object.fromEntries(Object.entries(values).map(([key, value]) => [key, String(value)])),
299
325
  };
300
326
  }
@@ -317,6 +343,22 @@ function expandDatabaseBindings(bindings) {
317
343
  return env;
318
344
  }
319
345
 
346
+ function expandResourceBindings(bindings) {
347
+ const env = {};
348
+ for (const [envName, binding] of Object.entries(bindings || {})) {
349
+ if (typeof binding === "string") {
350
+ const [resourceName, field = "url"] = binding.split(".");
351
+ env[envName] = `{resource:${normalizeDatabaseEnvToken(resourceName, `env.resources.${envName}`, false)}:${normalizeDatabaseEnvToken(field, `env.resources.${envName} field`, false)}}`;
352
+ continue;
353
+ }
354
+ if (!binding || typeof binding !== "object" || Array.isArray(binding)) {
355
+ throw new Error(`env.resources.${envName} must be a string or object`);
356
+ }
357
+ env[envName] = `{resource:${normalizeDatabaseEnvToken(binding.resource, `env.resources.${envName}.resource`, false)}:${normalizeDatabaseEnvToken(binding.field || "url", `env.resources.${envName}.field`, false)}}`;
358
+ }
359
+ return env;
360
+ }
361
+
320
362
  function normalizeDatabaseEnvToken(value, label, sanitize = true) {
321
363
  const raw = String(value || "").trim();
322
364
  const normalized = sanitize
@@ -280,8 +280,40 @@ export interface DatabaseBindingEnvConfig {
280
280
  export interface PresetEnvConfig {
281
281
  values?: Record<string, string>;
282
282
  databases?: Record<string, DatabaseBindingEnvConfig>;
283
+ resources?: Record<string, string | { resource: string; field?: string }>;
283
284
  }
284
285
 
286
+ export interface KilnVMResourceConfig {
287
+ autostart?: boolean;
288
+ disk?: string;
289
+ diskSize?: string | number;
290
+ diskSizeMB?: number;
291
+ memoryMB?: number;
292
+ name?: string;
293
+ networkId?: string;
294
+ profile?: string;
295
+ vcpus?: number;
296
+ }
297
+
298
+ export interface PostgresResourceConfig {
299
+ kind: "postgres";
300
+ data?: "reuse" | "reset" | "rebuild";
301
+ database?: string;
302
+ extensions?: string[];
303
+ password?: string;
304
+ port?: number;
305
+ user?: string;
306
+ version?: string;
307
+ vm?: KilnVMResourceConfig;
308
+ }
309
+
310
+ export interface ServerResourceConfig {
311
+ kind: "server";
312
+ vm?: KilnVMResourceConfig;
313
+ }
314
+
315
+ export type ResourceConfig = PostgresResourceConfig | ServerResourceConfig;
316
+
285
317
  export interface LocalEnvironmentConfig {
286
318
  kind: "local";
287
319
  data?: "reuse" | "reset" | "rebuild";
@@ -289,7 +321,9 @@ export interface LocalEnvironmentConfig {
289
321
  env?: Record<string, string>;
290
322
  kiln?: KilnLocalEnvironmentConfig;
291
323
  portOffset?: number;
324
+ productionLike?: boolean;
292
325
  publicHost?: string;
326
+ resources?: Record<string, ResourceConfig>;
293
327
  target: string;
294
328
  }
295
329
 
@@ -599,6 +633,17 @@ export declare const database: {
599
633
  }
600
634
  ): ServiceConfig;
601
635
  };
636
+ export declare const resource: {
637
+ postgres(options?: Omit<PostgresResourceConfig, "kind">): PostgresResourceConfig;
638
+ server(options?: Omit<ServerResourceConfig, "kind">): ServerResourceConfig;
639
+ field(resourceName: string, field?: string): string;
640
+ url(resourceName: string): string;
641
+ host(resourceName: string): string;
642
+ port(resourceName: string): string;
643
+ database(resourceName: string): string;
644
+ user(resourceName: string): string;
645
+ password(resourceName: string): string;
646
+ };
602
647
  export declare const step: {
603
648
  command(run: string, options?: TemplateLifecycleStepOptions): TemplateCommandStepConfig;
604
649
  module(target: string, options?: TemplateLifecycleStepOptions): TemplateModuleStepConfig;
@@ -615,6 +660,7 @@ export declare const ui: {
615
660
  };
616
661
  export declare const environment: {
617
662
  local(options: LocalEnvironmentOptions): LocalEnvironmentConfig;
663
+ productionLike(options: LocalEnvironmentOptions): LocalEnvironmentConfig;
618
664
  };
619
665
  export declare const auth: {
620
666
  fixture(options: { contract: JsonSessionContract; topology: AuthTopology }): AuthFixture;
@@ -33,6 +33,33 @@ function postgresDatabase(options = {}) {
33
33
  };
34
34
  }
35
35
 
36
+ function postgresResource(options = {}) {
37
+ return {
38
+ kind: "postgres",
39
+ version: options.version || "16",
40
+ database: options.database || options.name || "app",
41
+ user: options.user || "app",
42
+ ...(options.password ? { password: options.password } : {}),
43
+ ...(options.port ? { port: options.port } : {}),
44
+ ...(options.vm ? { vm: { ...options.vm } } : {}),
45
+ ...(options.data ? { data: options.data } : {}),
46
+ ...(options.extensions ? { extensions: [...options.extensions] } : {}),
47
+ };
48
+ }
49
+
50
+ function serverResource(options = {}) {
51
+ return {
52
+ kind: "server",
53
+ ...(options.vm ? { vm: { ...options.vm } } : {}),
54
+ };
55
+ }
56
+
57
+ function resourceField(resourceName, field = "url") {
58
+ const normalizedResourceName = normalizeDatabaseEnvToken(resourceName, "resource name", false);
59
+ const normalizedField = normalizeDatabaseEnvToken(field, "resource field", false);
60
+ return `{resource:${normalizedResourceName}:${normalizedField}}`;
61
+ }
62
+
36
63
  function buildDatabaseTemplateConfig(options = {}) {
37
64
  for (const legacyKey of ["schema"]) {
38
65
  if (Object.prototype.hasOwnProperty.call(options, legacyKey)) {
@@ -412,6 +439,30 @@ export const database = {
412
439
  fixture: postgresFixture,
413
440
  };
414
441
 
442
+ export const resource = {
443
+ postgres: postgresResource,
444
+ server: serverResource,
445
+ field: resourceField,
446
+ url(resourceName) {
447
+ return resourceField(resourceName, "url");
448
+ },
449
+ host(resourceName) {
450
+ return resourceField(resourceName, "host");
451
+ },
452
+ port(resourceName) {
453
+ return resourceField(resourceName, "port");
454
+ },
455
+ database(resourceName) {
456
+ return resourceField(resourceName, "database");
457
+ },
458
+ user(resourceName) {
459
+ return resourceField(resourceName, "user");
460
+ },
461
+ password(resourceName) {
462
+ return resourceField(resourceName, "password");
463
+ },
464
+ };
465
+
415
466
  export const step = {
416
467
  command: commandStep,
417
468
  module: moduleStep,
@@ -456,6 +507,13 @@ function localEnvironment(options = {}) {
456
507
 
457
508
  export const environment = {
458
509
  local: localEnvironment,
510
+ productionLike(options = {}) {
511
+ return localEnvironment({
512
+ driver: "kiln",
513
+ ...options,
514
+ productionLike: true,
515
+ });
516
+ },
459
517
  };
460
518
 
461
519
  export const auth = {
@@ -497,7 +555,7 @@ function normalizePresetEnv(env) {
497
555
  if (typeof env !== "object" || Array.isArray(env)) {
498
556
  throw new Error("Preset env must be an object");
499
557
  }
500
- const allowedKeys = new Set(["values", "databases"]);
558
+ const allowedKeys = new Set(["values", "databases", "resources"]);
501
559
  const unexpectedKeys = Object.keys(env).filter((key) => !allowedKeys.has(key));
502
560
  if (unexpectedKeys.length > 0) {
503
561
  throw new Error(
@@ -508,9 +566,12 @@ function normalizePresetEnv(env) {
508
566
  const values = env.values && typeof env.values === "object" && !Array.isArray(env.values) ? env.values : {};
509
567
  const databases =
510
568
  env.databases && typeof env.databases === "object" && !Array.isArray(env.databases) ? env.databases : {};
569
+ const resources =
570
+ env.resources && typeof env.resources === "object" && !Array.isArray(env.resources) ? env.resources : {};
511
571
 
512
572
  return {
513
573
  ...expandDatabaseBindings(databases),
574
+ ...expandResourceBindings(resources),
514
575
  ...values,
515
576
  };
516
577
  }
@@ -533,6 +594,22 @@ function expandDatabaseBindings(bindings) {
533
594
  return env;
534
595
  }
535
596
 
597
+ function expandResourceBindings(bindings) {
598
+ const env = {};
599
+ for (const [envName, binding] of Object.entries(bindings || {})) {
600
+ if (typeof binding === "string") {
601
+ const [resourceName, field = "url"] = binding.split(".");
602
+ env[envName] = resourceField(resourceName, field);
603
+ continue;
604
+ }
605
+ if (!binding || typeof binding !== "object" || Array.isArray(binding)) {
606
+ throw new Error(`env.resources.${envName} must be a string or object`);
607
+ }
608
+ env[envName] = resourceField(binding.resource, binding.field || "url");
609
+ }
610
+ return env;
611
+ }
612
+
536
613
  function normalizeDatabaseEnvToken(value, label, sanitize = true) {
537
614
  const raw = String(value || "").trim();
538
615
  const normalized = sanitize
@@ -72,6 +72,26 @@ export class KilnClient {
72
72
  return this.request("POST", `/vms/${encodeURIComponent(ref)}/exec`, { command });
73
73
  }
74
74
 
75
+ ensureAppliance(req) {
76
+ return this.request("POST", "/appliances/ensure", req);
77
+ }
78
+
79
+ getAppliance(ref) {
80
+ return this.request("GET", `/appliances/${encodeURIComponent(ref)}`);
81
+ }
82
+
83
+ listAppliances() {
84
+ return this.request("GET", "/appliances");
85
+ }
86
+
87
+ stopAppliance(ref) {
88
+ return this.request("POST", `/appliances/${encodeURIComponent(ref)}/stop`);
89
+ }
90
+
91
+ deleteAppliance(ref) {
92
+ return this.request("DELETE", `/appliances/${encodeURIComponent(ref)}`);
93
+ }
94
+
75
95
  sshKey(machineId) {
76
96
  return this.requestRaw("GET", `/machines/${encodeURIComponent(machineId)}/ssh-key`);
77
97
  }
@@ -45,6 +45,7 @@ export async function kilnLocalUp(context, environment, options = {}) {
45
45
 
46
46
  try {
47
47
  const network = await ensureNetwork(client, environment);
48
+ const resources = await ensureResources(client, environment, network);
48
49
  let vm = await ensureVM(client, vmConfig, network?.id || "");
49
50
  vm = await ensureStarted(client, vm.name);
50
51
  vm = await waitForVMReady(client, vm.name);
@@ -74,7 +75,12 @@ export async function kilnLocalUp(context, environment, options = {}) {
74
75
  ...(environment.data === "reset" ? ["--reset"] : []),
75
76
  ...(environment.data === "rebuild" ? ["--rebuild"] : []),
76
77
  ...(environment.portOffset ? ["--port-offset", String(environment.portOffset)] : []),
77
- ], { TESTKIT_PUBLIC_HOST: targetHost });
78
+ ], {
79
+ TESTKIT_PUBLIC_HOST: targetHost,
80
+ ...(Object.keys(resources.connections).length > 0
81
+ ? { TESTKIT_RESOURCE_CONNECTIONS_JSON: JSON.stringify(resources.connections) }
82
+ : {}),
83
+ });
78
84
  if (remote.exitCode !== 0) {
79
85
  throw new Error(`remote testkit local up failed\n${remote.stdout}${remote.stderr}`);
80
86
  }
@@ -90,6 +96,7 @@ export async function kilnLocalUp(context, environment, options = {}) {
90
96
  network: network ? { id: network.id, name: network.name, cidr: network.cidr } : null,
91
97
  },
92
98
  endpoints: rewriteEndpoints(status?.manifest?.endpoints || {}, targetHost),
99
+ resources: resources.manifest,
93
100
  remoteStatus: status?.manifest || null,
94
101
  });
95
102
  return buildKilnLocalStatus(context.productDir, environment.name);
@@ -126,6 +133,9 @@ export async function kilnLocalDown(context, name, options = {}) {
126
133
  stoppedAt: new Date().toISOString(),
127
134
  services: [],
128
135
  });
136
+ if (options.destroyState) {
137
+ await deleteManifestResources(manifest);
138
+ }
129
139
  return manifest;
130
140
  } finally {
131
141
  ssh.cleanup?.();
@@ -193,6 +203,14 @@ export function buildKilnLocalStatus(productDir, name) {
193
203
  lines.push(" endpoints:");
194
204
  for (const [service, url] of Object.entries(endpoints)) lines.push(` ${service}: ${url}`);
195
205
  }
206
+ const resources = manifest.resources || {};
207
+ if (Object.keys(resources).length > 0) {
208
+ lines.push(" resources:");
209
+ for (const [name, resource] of Object.entries(resources)) {
210
+ const address = resource.private_ip || resource.ip || "";
211
+ lines.push(` ${name}: ${resource.kind} ${resource.name}${address ? ` ${address}` : ""}`);
212
+ }
213
+ }
196
214
  return { name, exists: true, active: manifest.status === "running", manifest, services: [], lines };
197
215
  }
198
216
 
@@ -226,6 +244,70 @@ async function attachExistingVMs(client, network, vmRefs) {
226
244
  }
227
245
  }
228
246
 
247
+ async function ensureResources(client, environment, network) {
248
+ const configured = environment.resources || {};
249
+ const manifest = {};
250
+ const connections = {};
251
+ for (const [name, resource] of Object.entries(configured)) {
252
+ if (resource.kind === "postgres") {
253
+ const appliance = await client.ensureAppliance({
254
+ name: resource.vm?.name || `${environment.name}-${name}`,
255
+ kind: "postgres",
256
+ network_id: resource.vm?.networkId || network?.id || "",
257
+ profile: resource.vm?.profile || "ubuntu-docker",
258
+ disk_size_mb: parseDiskMB(resource.vm?.disk || resource.vm?.diskSize || resource.vm?.diskSizeMB || resource.disk || "24G"),
259
+ memory_mb: Number(resource.vm?.memoryMB || resource.memoryMB || 1536),
260
+ vcpus: Number(resource.vm?.vcpus || resource.vcpus || 1),
261
+ autostart: Boolean(resource.vm?.autostart || resource.autostart),
262
+ postgres: {
263
+ version: resource.version || "16",
264
+ database: resource.database || name,
265
+ user: resource.user || "app",
266
+ ...(resource.password ? { password: resource.password } : {}),
267
+ ...(resource.port ? { port: Number(resource.port) } : {}),
268
+ },
269
+ metadata: {
270
+ "testkit.environment": environment.name,
271
+ "testkit.resource": name,
272
+ },
273
+ });
274
+ manifest[name] = pickAppliance(appliance);
275
+ connections[name] = appliance.connection || {};
276
+ continue;
277
+ }
278
+ if (resource.kind === "server") {
279
+ const appliance = await client.ensureAppliance({
280
+ name: resource.vm?.name || `${environment.name}-${name}`,
281
+ kind: "server",
282
+ network_id: resource.vm?.networkId || network?.id || "",
283
+ profile: resource.vm?.profile || "ubuntu-docker",
284
+ disk_size_mb: parseDiskMB(resource.vm?.disk || resource.vm?.diskSize || resource.vm?.diskSizeMB || resource.disk || "16G"),
285
+ memory_mb: Number(resource.vm?.memoryMB || resource.memoryMB || 1024),
286
+ vcpus: Number(resource.vm?.vcpus || resource.vcpus || 1),
287
+ autostart: Boolean(resource.vm?.autostart || resource.autostart),
288
+ metadata: {
289
+ "testkit.environment": environment.name,
290
+ "testkit.resource": name,
291
+ },
292
+ });
293
+ manifest[name] = pickAppliance(appliance);
294
+ connections[name] = appliance.connection || {};
295
+ }
296
+ }
297
+ return { manifest, connections };
298
+ }
299
+
300
+ async function deleteManifestResources(manifest) {
301
+ const resources = manifest.resources || {};
302
+ if (Object.keys(resources).length === 0) return;
303
+ const client = new KilnClient(manifest.kiln?.api || {});
304
+ for (const resource of Object.values(resources)) {
305
+ if (resource?.name) {
306
+ await client.deleteAppliance(resource.name).catch(() => {});
307
+ }
308
+ }
309
+ }
310
+
229
311
  async function ensureVM(client, vmConfig, networkId) {
230
312
  try {
231
313
  const existing = await client.getVM(vmConfig.name);
@@ -525,6 +607,20 @@ function pickVM(vm) {
525
607
  };
526
608
  }
527
609
 
610
+ function pickAppliance(appliance) {
611
+ return {
612
+ id: appliance.id,
613
+ name: appliance.name,
614
+ kind: appliance.kind,
615
+ vm_id: appliance.vm_id,
616
+ vm_name: appliance.vm_name,
617
+ state: appliance.state,
618
+ ip: appliance.ip,
619
+ private_ip: appliance.private_ip,
620
+ connection: appliance.connection || null,
621
+ };
622
+ }
623
+
528
624
  function pickAPIConfig(api) {
529
625
  return {
530
626
  ...(api.apiUrl ? { apiUrl: api.apiUrl } : {}),
@@ -242,6 +242,8 @@ export function resolveEnvironment(context, options = {}) {
242
242
  env: { ...(configured?.env || {}) },
243
243
  kiln: configured?.kiln || null,
244
244
  publicHost: process.env.TESTKIT_PUBLIC_HOST || configured?.publicHost || null,
245
+ resources: configured?.resources || {},
246
+ productionLike: Boolean(configured?.productionLike),
245
247
  };
246
248
  }
247
249
 
@@ -240,7 +240,9 @@ export function buildTaskExecutionEnv(config, lease, extraEnv = {}, processEnv =
240
240
 
241
241
  function buildExecutionEnvWithContext(config, lease, extraEnv, processEnv, options = {}) {
242
242
  const inheritedEnv = { ...processEnv };
243
- const templateContext = buildTemplateContext(config, lease);
243
+ delete inheritedEnv.DATABASE_URL;
244
+ delete inheritedEnv.TESTKIT_RESOURCE_CONNECTIONS_JSON;
245
+ const templateContext = buildTemplateContext(config, lease, processEnv);
244
246
  const serviceEnv = options.omitRuntimeDatabaseBindings
245
247
  ? omitRuntimeDatabaseBindings(config.testkit.serviceEnv || {})
246
248
  : config.testkit.serviceEnv || {};
@@ -261,7 +263,6 @@ function buildExecutionEnvWithContext(config, lease, extraEnv, processEnv, optio
261
263
  ...(lease?.leaseId ? { TESTKIT_LEASE_ID: String(lease.leaseId) } : {}),
262
264
  ...(lease?.leaseDir ? { TESTKIT_LEASE_DIR: lease.leaseDir } : {}),
263
265
  };
264
- delete env.DATABASE_URL;
265
266
  return env;
266
267
  }
267
268
 
@@ -284,7 +285,7 @@ export function buildPlaywrightEnv(config, baseUrl, lease, processEnv = process.
284
285
  );
285
286
  }
286
287
 
287
- function buildTemplateContext(config, lease) {
288
+ function buildTemplateContext(config, lease, processEnv = process.env) {
288
289
  const baseContext = config.testkit?.templateContext || {};
289
290
  return {
290
291
  ...baseContext,
@@ -296,6 +297,7 @@ function buildTemplateContext(config, lease) {
296
297
  prepareDir: config.testkit?.prepareDir || baseContext.prepareDir || null,
297
298
  leaseId: lease?.leaseId || null,
298
299
  leaseDir: lease?.leaseDir || null,
300
+ resourceConnectionByName: parseResourceConnections(processEnv.TESTKIT_RESOURCE_CONNECTIONS_JSON),
299
301
  };
300
302
  }
301
303
 
@@ -331,7 +333,7 @@ function finalizeSerializable(value, context) {
331
333
  export function resolveTemplateString(value, context) {
332
334
  if (typeof value !== "string") return value;
333
335
 
334
- return value.replace(/\{([a-zA-Z]+)(?::([a-zA-Z0-9_-]+))?\}/g, (_match, token, arg) => {
336
+ return value.replace(/\{([a-zA-Z]+)(?::([^}]+))?\}/g, (_match, token, arg) => {
335
337
  switch (token) {
336
338
  case "runtime":
337
339
  case "runtimeId":
@@ -391,6 +393,10 @@ export function resolveTemplateString(value, context) {
391
393
  const serviceName = arg || context.serviceName;
392
394
  return resolveDatabaseTemplateValue(token, serviceName, context);
393
395
  }
396
+ case "resource": {
397
+ const [resourceName, field = "url"] = String(arg || "").split(":");
398
+ return resolveResourceTemplateValue(resourceName, field, context);
399
+ }
394
400
  default:
395
401
  throw new Error(`Unsupported template token "{${token}${arg ? `:${arg}` : ""}}"`);
396
402
  }
@@ -423,7 +429,7 @@ function omitRuntimeDatabaseBindings(values = {}) {
423
429
  return Object.fromEntries(
424
430
  Object.entries(values).filter(([_key, value]) => {
425
431
  if (typeof value !== "string") return true;
426
- return !/\{db(?:Url|Host|Port|Name|User|Password)(?::[a-zA-Z0-9_-]+)?\}/.test(value);
432
+ return !/\{(?:db(?:Url|Host|Port|Name|User|Password)(?::[^}]+)?|resource:[^}]+)\}/.test(value);
427
433
  })
428
434
  );
429
435
  }
@@ -473,6 +479,32 @@ function resolveDatabaseTemplateValue(token, serviceName, context) {
473
479
  }
474
480
  }
475
481
 
482
+ function resolveResourceTemplateValue(resourceName, field, context) {
483
+ if (!resourceName) {
484
+ throw new Error("Resource placeholder requires a resource name");
485
+ }
486
+ const connections = context.resourceConnectionByName || {};
487
+ const resource = connections[resourceName];
488
+ if (!resource) {
489
+ throw new Error(`Unknown resource placeholder for resource "${resourceName}"`);
490
+ }
491
+ const value = resource[field];
492
+ if (value == null) {
493
+ throw new Error(`Unknown resource field "${field}" for resource "${resourceName}"`);
494
+ }
495
+ return String(value);
496
+ }
497
+
498
+ function parseResourceConnections(raw) {
499
+ if (!raw) return {};
500
+ try {
501
+ const parsed = JSON.parse(raw);
502
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
503
+ } catch {
504
+ return {};
505
+ }
506
+ }
507
+
476
508
  export function rewriteUrlPort(rawUrl, port) {
477
509
  try {
478
510
  const original = new URL(rawUrl);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/next-analysis",
3
- "version": "0.1.137",
3
+ "version": "0.1.138",
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.137",
3
+ "version": "0.1.138",
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.137"
25
+ "@elench/testkit-protocol": "0.1.138"
26
26
  },
27
27
  "private": false
28
28
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.137",
3
+ "version": "0.1.138",
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.137",
3
+ "version": "0.1.138",
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.137",
3
+ "version": "0.1.138",
4
4
  "description": "Assistant-first CLI for running, inspecting, and debugging local testkit suites",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -98,10 +98,10 @@
98
98
  },
99
99
  "dependencies": {
100
100
  "@babel/code-frame": "^7.29.0",
101
- "@elench/next-analysis": "0.1.137",
102
- "@elench/testkit-bridge": "0.1.137",
103
- "@elench/testkit-protocol": "0.1.137",
104
- "@elench/ts-analysis": "0.1.137",
101
+ "@elench/next-analysis": "0.1.138",
102
+ "@elench/testkit-bridge": "0.1.138",
103
+ "@elench/testkit-protocol": "0.1.138",
104
+ "@elench/ts-analysis": "0.1.138",
105
105
  "@oclif/core": "^4.10.6",
106
106
  "@playwright/test": "^1.52.0",
107
107
  "esbuild": "^0.25.11",