@elench/testkit 0.1.38 → 0.1.39

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.
@@ -3,86 +3,77 @@ import path from "path";
3
3
  import { execaCommand } from "execa";
4
4
  import { resolveServiceCwd } from "../config/index.mjs";
5
5
  import { prepareDatabaseRuntime } from "../database/index.mjs";
6
- import { buildExecutionEnv, resolveWorkerRuntimeConfigs } from "./template.mjs";
6
+ import { buildExecutionEnv, resolveStackRuntimeConfigs } from "./template.mjs";
7
7
  import { writeGraphMetadata } from "./state.mjs";
8
8
  import { startLocalServices, stopLocalServices } from "./services.mjs";
9
9
 
10
- export function createGraphContext(worker, graph) {
11
- const graphDir = path.join(worker.productDir, ".testkit", "_graphs", graph.dirName);
12
- const workerStateDir = path.join(graphDir, "workers", `worker-${worker.workerId}`);
13
- fs.mkdirSync(workerStateDir, { recursive: true });
10
+ export function createStackContext(stackId, graph, productDir) {
11
+ const graphDir = path.join(productDir, ".testkit", "_graphs", graph.dirName);
12
+ const stackStateDir = path.join(graphDir, "stacks", stackId);
13
+ fs.mkdirSync(stackStateDir, { recursive: true });
14
14
  writeGraphMetadata(graphDir, graph);
15
15
 
16
- const runtimeConfigs = resolveWorkerRuntimeConfigs(
16
+ const runtimeConfigs = resolveStackRuntimeConfigs(
17
17
  graph.rootConfig,
18
18
  graph.runtimeConfigs,
19
- worker.workerId,
20
- workerStateDir
19
+ stackId,
20
+ stackStateDir
21
21
  );
22
22
 
23
23
  return {
24
24
  graphKey: graph.key,
25
+ assignedTargets: [...graph.assignedTargets],
25
26
  graphDir,
26
- workerStateDir,
27
+ stackId,
28
+ stackStateDir,
27
29
  runtimeConfigs,
28
30
  configByName: new Map(runtimeConfigs.map((config) => [config.name, config])),
29
31
  prepared: false,
32
+ preparationPromise: null,
30
33
  started: false,
34
+ startupPromise: null,
31
35
  startedServices: [],
32
36
  };
33
37
  }
34
38
 
35
- export async function ensureWorkerGraph(worker, batch, graphByKey, lifecycle) {
36
- const graph = graphByKey.get(batch.graphKey);
37
- if (!graph) {
38
- throw new Error(`Unknown graph "${batch.graphKey}"`);
39
- }
40
-
41
- if (worker.currentGraphKey && worker.currentGraphKey !== batch.graphKey) {
42
- await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey), lifecycle);
43
- worker.graphSwitches += 1;
44
- worker.currentGraphKey = null;
45
- }
46
-
47
- let context = worker.graphContexts.get(batch.graphKey);
48
- if (!context) {
49
- context = createGraphContext(worker, graph);
50
- worker.graphContexts.set(batch.graphKey, context);
51
- lifecycle.trackGraphContext(context);
52
- }
53
-
39
+ export async function ensureStackContextReady(context, batch, lifecycle) {
54
40
  if (!context.prepared) {
55
- await prepareDatabases(context.runtimeConfigs);
56
- context.prepared = true;
41
+ if (!context.preparationPromise) {
42
+ context.preparationPromise = (async () => {
43
+ await prepareDatabases(context.runtimeConfigs);
44
+ context.prepared = true;
45
+ })().finally(() => {
46
+ context.preparationPromise = null;
47
+ });
48
+ }
49
+ await context.preparationPromise;
57
50
  }
58
51
 
59
52
  if (batchNeedsLocalRuntime(batch) && !context.started) {
60
- context.startedServices = await startLocalServices(context.runtimeConfigs, lifecycle);
61
- context.started = true;
53
+ if (!context.startupPromise) {
54
+ context.startupPromise = (async () => {
55
+ context.startedServices = await startLocalServices(context.runtimeConfigs, lifecycle);
56
+ context.started = true;
57
+ })().finally(() => {
58
+ context.startupPromise = null;
59
+ });
60
+ }
61
+ await context.startupPromise;
62
62
  }
63
63
 
64
- worker.currentGraphKey = batch.graphKey;
65
64
  return context;
66
65
  }
67
66
 
68
- export async function deactivateGraphContext(context, lifecycle) {
67
+ export async function deactivateStackContext(context, lifecycle) {
69
68
  if (!context?.started) return;
70
69
  await stopLocalServices(context.startedServices, lifecycle);
71
70
  context.started = false;
72
71
  context.startedServices = [];
73
72
  }
74
73
 
75
- export async function resetCurrentGraph(worker, lifecycle) {
76
- if (!worker.currentGraphKey) return;
77
- await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey), lifecycle);
78
- worker.currentGraphKey = null;
79
- }
80
-
81
- export async function cleanupWorker(worker, lifecycle) {
82
- for (const context of worker.graphContexts.values()) {
83
- await deactivateGraphContext(context, lifecycle);
84
- }
85
- worker.currentGraphKey = null;
74
+ export async function cleanupStackContext(context, lifecycle) {
75
+ if (!context) return;
76
+ await deactivateStackContext(context, lifecycle);
86
77
  }
87
78
 
88
79
  export async function prepareDatabases(runtimeConfigs) {
@@ -103,7 +94,7 @@ async function runMigrate(config, databaseUrl) {
103
94
  const env = buildExecutionEnv(config, {}, process.env);
104
95
  if (databaseUrl) env.DATABASE_URL = databaseUrl;
105
96
 
106
- console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
97
+ console.log(`\n── migrate:${config.stackLabel}:${config.name} ──`);
107
98
  await execaCommand(migrate.cmd, {
108
99
  cwd: resolveServiceCwd(config.productDir, migrate.cwd),
109
100
  env,
@@ -119,7 +110,7 @@ async function runSeed(config, databaseUrl) {
119
110
  const env = buildExecutionEnv(config, {}, process.env);
120
111
  if (databaseUrl) env.DATABASE_URL = databaseUrl;
121
112
 
122
- console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
113
+ console.log(`\n── seed:${config.stackLabel}:${config.name} ──`);
123
114
  await execaCommand(seed.cmd, {
124
115
  cwd: resolveServiceCwd(config.productDir, seed.cwd),
125
116
  env,
@@ -36,12 +36,12 @@ export async function startLocalService(config, lifecycle) {
36
36
 
37
37
  await assertLocalServicePortsAvailable(config, isPortInUse);
38
38
 
39
- console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
39
+ console.log(`Starting ${config.stackLabel}:${config.name}: ${config.testkit.local.start}`);
40
40
  const child = startDetachedCommand(config.testkit.local.start, cwd, env);
41
41
 
42
42
  const outputDrains = [
43
- pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`),
44
- pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`),
43
+ pipeOutput(child.stdout, `[${config.stackLabel}:${config.name}]`),
44
+ pipeOutput(child.stderr, `[${config.stackLabel}:${config.name}]`),
45
45
  ];
46
46
  lifecycle.registerService(config, child, cwd);
47
47
 
@@ -49,7 +49,7 @@ export async function startLocalService(config, lifecycle) {
49
49
 
50
50
  try {
51
51
  await waitForReady({
52
- name: `${config.workerLabel}:${config.name}`,
52
+ name: `${config.stackLabel}:${config.name}`,
53
53
  url: config.testkit.local.readyUrl,
54
54
  timeoutMs: readyTimeoutMs,
55
55
  process: child,
@@ -0,0 +1,146 @@
1
+ import { buildStackIds } from "./execution-config.mjs";
2
+ import {
3
+ cleanupStackContext,
4
+ createStackContext,
5
+ ensureStackContextReady,
6
+ } from "./runtime-contexts.mjs";
7
+
8
+ export function createStackManager({ productDir, graphs, execution, lifecycle }) {
9
+ const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
10
+ const pools = new Map();
11
+
12
+ return {
13
+ async acquire(batch) {
14
+ const pool = getPool(pools, graphByKey, batch, execution, productDir, lifecycle);
15
+ while (true) {
16
+ if (lifecycle.isStopRequested()) {
17
+ throw lifecycle.signal.reason || new Error("testkit run interrupted");
18
+ }
19
+
20
+ const slot = claimSlot(pool, batch.accessMode);
21
+ if (!slot) {
22
+ await sleep(25);
23
+ continue;
24
+ }
25
+
26
+ try {
27
+ const context = await getReadyContext(slot, graphByKey, productDir, batch, lifecycle);
28
+ return {
29
+ slot,
30
+ context,
31
+ };
32
+ } catch (error) {
33
+ releaseSlot(slot, batch.accessMode);
34
+ await invalidateSlot(slot, lifecycle);
35
+ throw error;
36
+ }
37
+ }
38
+ },
39
+ async release(lease, options = {}) {
40
+ if (!lease?.slot) return;
41
+ releaseSlot(lease.slot, options.accessMode || lease.slot.lastAccessMode);
42
+ if (options.invalidate) {
43
+ await invalidateSlot(lease.slot, lifecycle);
44
+ }
45
+ },
46
+ async cleanupAll() {
47
+ for (const pool of pools.values()) {
48
+ for (const slot of pool.slots) {
49
+ await invalidateSlot(slot, lifecycle);
50
+ }
51
+ }
52
+ },
53
+ };
54
+ }
55
+
56
+ function getPool(pools, graphByKey, batch, execution, productDir, lifecycle) {
57
+ const key = `${batch.graphKey}:${batch.stackMode}`;
58
+ const existing = pools.get(key);
59
+ if (existing) return existing;
60
+
61
+ const graph = graphByKey.get(batch.graphKey);
62
+ if (!graph) {
63
+ throw new Error(`Unknown graph "${batch.graphKey}"`);
64
+ }
65
+
66
+ const slots = buildPoolStackIds(batch.stackMode, execution).map((stackId) => ({
67
+ graph,
68
+ stackId,
69
+ context: null,
70
+ activeSharedCount: 0,
71
+ exclusiveActive: false,
72
+ lastAccessMode: null,
73
+ contextPromise: null,
74
+ }));
75
+ const pool = {
76
+ productDir,
77
+ lifecycle,
78
+ slots,
79
+ };
80
+ pools.set(key, pool);
81
+ return pool;
82
+ }
83
+
84
+ function buildPoolStackIds(stackMode, execution) {
85
+ if (stackMode === "isolated") {
86
+ return Array.from({ length: execution.workers }, (_unused, index) => `isolated-${index + 1}`);
87
+ }
88
+ if (stackMode === "shared") {
89
+ return ["shared"];
90
+ }
91
+ return buildStackIds({
92
+ stackMode: "pooled",
93
+ stackCount: execution.stackCount,
94
+ });
95
+ }
96
+
97
+ function claimSlot(pool, accessMode) {
98
+ if (accessMode === "shared") {
99
+ const candidates = pool.slots.filter((slot) => !slot.exclusiveActive);
100
+ if (candidates.length === 0) return null;
101
+ const slot = [...candidates].sort((left, right) => left.activeSharedCount - right.activeSharedCount)[0];
102
+ slot.activeSharedCount += 1;
103
+ slot.lastAccessMode = "shared";
104
+ return slot;
105
+ }
106
+
107
+ const slot = pool.slots.find((candidate) => !candidate.exclusiveActive && candidate.activeSharedCount === 0);
108
+ if (!slot) return null;
109
+ slot.exclusiveActive = true;
110
+ slot.lastAccessMode = "exclusive";
111
+ return slot;
112
+ }
113
+
114
+ async function getReadyContext(slot, graphByKey, productDir, batch, lifecycle) {
115
+ if (!slot.context) {
116
+ slot.context = createStackContext(slot.stackId, slot.graph, productDir);
117
+ lifecycle.trackGraphContext(slot.context);
118
+ }
119
+ if (!slot.contextPromise) {
120
+ slot.contextPromise = Promise.resolve(slot.context);
121
+ }
122
+ await slot.contextPromise;
123
+ await ensureStackContextReady(slot.context, batch, lifecycle);
124
+ return slot.context;
125
+ }
126
+
127
+ function releaseSlot(slot, accessMode) {
128
+ if (accessMode === "shared") {
129
+ slot.activeSharedCount = Math.max(0, slot.activeSharedCount - 1);
130
+ return;
131
+ }
132
+ slot.exclusiveActive = false;
133
+ }
134
+
135
+ async function invalidateSlot(slot, lifecycle) {
136
+ if (!slot.context) return;
137
+ await cleanupStackContext(slot.context, lifecycle);
138
+ slot.context = null;
139
+ slot.contextPromise = null;
140
+ slot.activeSharedCount = 0;
141
+ slot.exclusiveActive = false;
142
+ }
143
+
144
+ function sleep(ms) {
145
+ return new Promise((resolve) => setTimeout(resolve, ms));
146
+ }
@@ -3,8 +3,8 @@ import { readDatabaseInfo } from "./state-io.mjs";
3
3
 
4
4
  const PORT_STRIDE = 100;
5
5
 
6
- export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, workerId, workerStateDir) {
7
- const portMap = buildPortMap(runtimeConfigs, workerId);
6
+ export function resolveStackRuntimeConfigs(targetConfig, runtimeConfigs, stackId, stackStateDir) {
7
+ const portMap = buildPortMap(runtimeConfigs, stackId);
8
8
  const baseUrlByService = new Map();
9
9
  const readyUrlByService = new Map();
10
10
  const stateDirByService = new Map();
@@ -12,7 +12,7 @@ export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, worker
12
12
  for (const config of runtimeConfigs) {
13
13
  stateDirByService.set(
14
14
  config.name,
15
- resolveServiceStateDir(workerStateDir, targetConfig.name, config)
15
+ resolveServiceStateDir(stackStateDir, targetConfig.name, config)
16
16
  );
17
17
  }
18
18
 
@@ -20,8 +20,9 @@ export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, worker
20
20
  if (!config.testkit.local) continue;
21
21
  baseUrlByService.set(
22
22
  config.name,
23
- resolveRuntimeUrl(config.testkit.local.baseUrl, config.name, targetConfig, workerId, {
24
- workerStateDir,
23
+ resolveRuntimeUrl(config.testkit.local.baseUrl, config.name, targetConfig, stackId, {
24
+ stackId,
25
+ stackStateDir,
25
26
  portMap,
26
27
  baseUrlByService,
27
28
  readyUrlByService,
@@ -30,8 +31,9 @@ export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, worker
30
31
  );
31
32
  readyUrlByService.set(
32
33
  config.name,
33
- resolveRuntimeUrl(config.testkit.local.readyUrl, config.name, targetConfig, workerId, {
34
- workerStateDir,
34
+ resolveRuntimeUrl(config.testkit.local.readyUrl, config.name, targetConfig, stackId, {
35
+ stackId,
36
+ stackStateDir,
35
37
  portMap,
36
38
  baseUrlByService,
37
39
  readyUrlByService,
@@ -54,11 +56,11 @@ export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, worker
54
56
  }
55
57
 
56
58
  return runtimeConfigs.map((config) =>
57
- resolveWorkerConfig(
59
+ resolveStackConfig(
58
60
  config,
59
61
  targetConfig,
60
- workerId,
61
- workerStateDir,
62
+ stackId,
63
+ stackStateDir,
62
64
  portMap,
63
65
  baseUrlByService,
64
66
  readyUrlByService,
@@ -68,10 +70,10 @@ export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, worker
68
70
  );
69
71
  }
70
72
 
71
- export function buildPortMap(runtimeConfigs, workerId) {
73
+ export function buildPortMap(runtimeConfigs, stackId) {
72
74
  const portMap = new Map();
73
75
  const seen = new Map();
74
- const offset = PORT_STRIDE * (workerId - 1);
76
+ const offset = stackPortOffset(stackId);
75
77
 
76
78
  for (const config of runtimeConfigs) {
77
79
  if (!config.testkit.local) continue;
@@ -83,7 +85,7 @@ export function buildPortMap(runtimeConfigs, workerId) {
83
85
  const existing = seen.get(actualPort);
84
86
  if (existing) {
85
87
  throw new Error(
86
- `Worker port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
88
+ `Stack port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
87
89
  `Assign distinct local.port/baseUrl ports in testkit.setup.ts.`
88
90
  );
89
91
  }
@@ -94,22 +96,22 @@ export function buildPortMap(runtimeConfigs, workerId) {
94
96
  return portMap;
95
97
  }
96
98
 
97
- export function resolveWorkerConfig(
99
+ export function resolveStackConfig(
98
100
  config,
99
101
  targetConfig,
100
- workerId,
101
- workerStateDir,
102
+ stackId,
103
+ stackStateDir,
102
104
  portMap,
103
105
  baseUrlByService,
104
106
  readyUrlByService,
105
107
  stateDirByService,
106
108
  urlMappings
107
109
  ) {
108
- const stateDir = resolveServiceStateDir(workerStateDir, targetConfig.name, config);
110
+ const stateDir = resolveServiceStateDir(stackStateDir, targetConfig.name, config);
109
111
  const context = {
110
- workerId,
111
- serviceName: config.name,
112
+ stackId,
112
113
  targetName: targetConfig.name,
114
+ serviceName: config.name,
113
115
  serviceStateDir: stateDir,
114
116
  portMap,
115
117
  baseUrlByService,
@@ -164,8 +166,8 @@ export function resolveWorkerConfig(
164
166
  return {
165
167
  ...config,
166
168
  stateDir,
167
- workerId,
168
- workerLabel: `w${workerId}`,
169
+ stackId,
170
+ stackLabel: stackId,
169
171
  targetName: targetConfig.name,
170
172
  testkit: {
171
173
  ...config.testkit,
@@ -178,16 +180,16 @@ export function resolveWorkerConfig(
178
180
  };
179
181
  }
180
182
 
181
- export function resolveServiceStateDir(workerStateDir, targetName, config) {
183
+ export function resolveServiceStateDir(stackStateDir, targetName, config) {
182
184
  const dbSource = config.testkit.databaseFrom || config.name;
183
- return getWorkerServiceStateDir(workerStateDir, targetName, dbSource);
185
+ return getStackServiceStateDir(stackStateDir, targetName, dbSource);
184
186
  }
185
187
 
186
- export function getWorkerServiceStateDir(workerStateDir, targetName, serviceName) {
188
+ export function getStackServiceStateDir(stackStateDir, targetName, serviceName) {
187
189
  if (targetName === serviceName) {
188
- return workerStateDir;
190
+ return stackStateDir;
189
191
  }
190
- return path.join(workerStateDir, "deps", serviceName);
192
+ return path.join(stackStateDir, "deps", serviceName);
191
193
  }
192
194
 
193
195
  export function buildExecutionEnv(config, extraEnv = {}, processEnv = process.env) {
@@ -198,7 +200,7 @@ export function buildExecutionEnv(config, extraEnv = {}, processEnv = process.en
198
200
  ...resolveEnvTemplates(config.testkit.serviceEnv || {}, templateContext),
199
201
  ...resolveEnvTemplates(extraEnv, templateContext),
200
202
  TESTKIT_ACTIVE: "1",
201
- ...(config.workerId ? { TESTKIT_WORKER_ID: String(config.workerId) } : {}),
203
+ ...(config.stackId ? { TESTKIT_STACK_ID: String(config.stackId) } : {}),
202
204
  };
203
205
  delete env.DATABASE_URL;
204
206
  return env;
@@ -213,17 +215,17 @@ export function buildPlaywrightEnv(config, baseUrl, processEnv = process.env) {
213
215
  PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS:
214
216
  processEnv.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS || "1",
215
217
  TESTKIT_MANAGED_SERVERS: "1",
216
- TESTKIT_WORKER_ID: String(config.workerId),
218
+ TESTKIT_STACK_ID: String(config.stackId),
217
219
  },
218
220
  processEnv
219
221
  );
220
222
  }
221
223
 
222
- export function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
224
+ export function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, stackId, context) {
223
225
  const resolved = resolveTemplateString(rawUrl, {
224
226
  ...context,
225
227
  targetName: targetConfig.name,
226
- workerId,
228
+ stackId,
227
229
  serviceName,
228
230
  });
229
231
  const actualPort = context.portMap.get(serviceName);
@@ -245,8 +247,8 @@ export function resolveTemplateString(value, context) {
245
247
 
246
248
  return value.replace(/\{([a-zA-Z]+)(?::([a-zA-Z0-9_-]+))?\}/g, (_match, token, arg) => {
247
249
  switch (token) {
248
- case "worker":
249
- return String(context.workerId);
250
+ case "stack":
251
+ return String(context.stackId);
250
252
  case "target":
251
253
  return context.targetName;
252
254
  case "service":
@@ -378,3 +380,9 @@ export function normalizeSocketHost(hostname) {
378
380
  if (hostname === "[::1]") return "::1";
379
381
  return hostname;
380
382
  }
383
+
384
+ function stackPortOffset(stackId) {
385
+ const match = String(stackId).match(/(\d+)$/);
386
+ if (!match) return 0;
387
+ return PORT_STRIDE * (Number.parseInt(match[1], 10) - 1);
388
+ }
@@ -7,12 +7,12 @@ import {
7
7
  buildPlaywrightEnv,
8
8
  buildPortMap,
9
9
  finalizeString,
10
- getWorkerServiceStateDir,
10
+ getStackServiceStateDir,
11
11
  normalizeSocketHost,
12
12
  numericPortFromUrl,
13
13
  resolveServiceStateDir,
14
+ resolveStackRuntimeConfigs,
14
15
  resolveTemplateString,
15
- resolveWorkerRuntimeConfigs,
16
16
  rewriteUrlPort,
17
17
  socketFromUrl,
18
18
  } from "./template.mjs";
@@ -37,7 +37,7 @@ describe("runner-template", () => {
37
37
  makeRuntimeConfig("frontend", { port: 3001, baseUrl: "http://127.0.0.1:{port}" }),
38
38
  ];
39
39
 
40
- expect([...buildPortMap(configs, 2).entries()]).toEqual([
40
+ expect([...buildPortMap(configs, "stack-2").entries()]).toEqual([
41
41
  ["api", 3100],
42
42
  ["frontend", 3101],
43
43
  ]);
@@ -48,9 +48,9 @@ describe("runner-template", () => {
48
48
  makeRuntimeConfig("api", { port: 3000, baseUrl: "http://127.0.0.1:{port}" }),
49
49
  makeRuntimeConfig("other", { port: 3000, baseUrl: "http://127.0.0.1:{port}" }),
50
50
  ],
51
- 1
51
+ "shared"
52
52
  )
53
- ).toThrow("Worker port collision");
53
+ ).toThrow("Stack port collision");
54
54
  });
55
55
 
56
56
  it("resolves template strings and URL rewrites", () => {
@@ -63,7 +63,7 @@ describe("runner-template", () => {
63
63
  );
64
64
 
65
65
  const context = {
66
- workerId: 2,
66
+ stackId: "stack-2",
67
67
  targetName: "frontend",
68
68
  serviceName: "frontend",
69
69
  serviceStateDir: "/tmp/state",
@@ -77,7 +77,9 @@ describe("runner-template", () => {
77
77
  urlMappings: [["http://api:3000", "http://127.0.0.1:3100"]],
78
78
  };
79
79
 
80
- expect(resolveTemplateString("{worker}:{target}:{service}", context)).toBe("2:frontend:frontend");
80
+ expect(resolveTemplateString("{stack}:{target}:{service}", context)).toBe(
81
+ "stack-2:frontend:frontend"
82
+ );
81
83
  expect(resolveTemplateString("{baseUrl:api}", context)).toBe("http://127.0.0.1:3100");
82
84
  expect(resolveTemplateString("{dbHost:api}:{dbPort:api}/{dbName:api}", context)).toBe(
83
85
  "127.0.0.1:55432/onix_db"
@@ -90,22 +92,26 @@ describe("runner-template", () => {
90
92
  );
91
93
  });
92
94
 
93
- it("builds worker runtime configs and execution env", () => {
94
- const workerStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-worker-"));
95
- const api = makeRuntimeConfig("api", {
96
- cwd: ".",
97
- start: "npm run api",
98
- port: 3000,
99
- baseUrl: "http://127.0.0.1:{port}",
100
- readyUrl: "http://127.0.0.1:{port}/health",
101
- env: {
102
- PORT: "{port}",
103
- },
104
- }, {
105
- serviceEnv: {
106
- API_KEY: "secret",
95
+ it("builds stack runtime configs and execution env", () => {
96
+ const stackStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-stack-"));
97
+ const api = makeRuntimeConfig(
98
+ "api",
99
+ {
100
+ cwd: ".",
101
+ start: "npm run api",
102
+ port: 3000,
103
+ baseUrl: "http://127.0.0.1:{port}",
104
+ readyUrl: "http://127.0.0.1:{port}/health",
105
+ env: {
106
+ PORT: "{port}",
107
+ },
107
108
  },
108
- });
109
+ {
110
+ serviceEnv: {
111
+ API_KEY: "secret",
112
+ },
113
+ }
114
+ );
109
115
  const frontend = makeRuntimeConfig("frontend", {
110
116
  cwd: "frontend",
111
117
  start: "npm run web",
@@ -118,17 +124,17 @@ describe("runner-template", () => {
118
124
  },
119
125
  });
120
126
 
121
- const resolved = resolveWorkerRuntimeConfigs(frontend, [api, frontend], 2, workerStateDir);
127
+ const resolved = resolveStackRuntimeConfigs(frontend, [api, frontend], "stack-2", stackStateDir);
122
128
  expect(resolved[0].testkit.local.port).toBe(3100);
123
129
  expect(resolved[1].testkit.local.env.NEXT_PUBLIC_API_URL).toBe("{baseUrl:api}");
124
- expect(resolveServiceStateDir(workerStateDir, "frontend", api)).toBe(
125
- `${workerStateDir}/deps/api`
130
+ expect(resolveServiceStateDir(stackStateDir, "frontend", api)).toBe(
131
+ `${stackStateDir}/deps/api`
126
132
  );
127
- expect(getWorkerServiceStateDir(workerStateDir, "frontend", "frontend")).toBe(workerStateDir);
133
+ expect(getStackServiceStateDir(stackStateDir, "frontend", "frontend")).toBe(stackStateDir);
128
134
 
129
- fs.mkdirSync(path.join(workerStateDir, "deps", "api"), { recursive: true });
135
+ fs.mkdirSync(path.join(stackStateDir, "deps", "api"), { recursive: true });
130
136
  fs.writeFileSync(
131
- path.join(workerStateDir, "deps", "api", "database_url"),
137
+ path.join(stackStateDir, "deps", "api", "database_url"),
132
138
  "postgres://testkit:testkit@127.0.0.1:55432/onix_db"
133
139
  );
134
140
 
@@ -148,15 +154,16 @@ describe("runner-template", () => {
148
154
  NEXT_PUBLIC_API_URL: "http://127.0.0.1:3100",
149
155
  ONIX_DB_HOST: "127.0.0.1",
150
156
  TESTKIT_ACTIVE: "1",
151
- TESTKIT_WORKER_ID: "2",
157
+ TESTKIT_STACK_ID: "stack-2",
152
158
  });
153
159
 
154
- expect(buildPlaywrightEnv({ workerId: 3, testkit: { serviceEnv: {} } }, "http://localhost:3000", {}))
155
- .toMatchObject({
156
- BASE_URL: "http://localhost:3000",
157
- TESTKIT_WORKER_ID: "3",
158
- PLAYWRIGHT_HTML_OPEN: "never",
159
- });
160
+ expect(
161
+ buildPlaywrightEnv({ stackId: "shared", testkit: { serviceEnv: {} } }, "http://localhost:3000", {})
162
+ ).toMatchObject({
163
+ BASE_URL: "http://localhost:3000",
164
+ TESTKIT_STACK_ID: "shared",
165
+ PLAYWRIGHT_HTML_OPEN: "never",
166
+ });
160
167
  });
161
168
 
162
169
  it("parses runtime sockets", () => {