@elench/testkit 0.1.40 → 0.1.42

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 (38) hide show
  1. package/README.md +27 -13
  2. package/bin/testkit.mjs +6 -1
  3. package/lib/cli/args.mjs +0 -4
  4. package/lib/cli/args.test.mjs +0 -5
  5. package/lib/cli/index.mjs +4 -11
  6. package/lib/config/index.mjs +78 -24
  7. package/lib/database/index.mjs +19 -7
  8. package/lib/database/naming.mjs +2 -2
  9. package/lib/database/naming.test.mjs +2 -2
  10. package/lib/runner/default-runtime-runner.mjs +52 -55
  11. package/lib/runner/execution-config.mjs +31 -70
  12. package/lib/runner/execution-config.test.mjs +30 -74
  13. package/lib/runner/formatting.mjs +0 -15
  14. package/lib/runner/formatting.test.mjs +0 -18
  15. package/lib/runner/lifecycle.mjs +106 -8
  16. package/lib/runner/orchestrator.mjs +16 -10
  17. package/lib/runner/planning.mjs +66 -138
  18. package/lib/runner/planning.test.mjs +101 -167
  19. package/lib/runner/playwright-config.mjs +13 -2
  20. package/lib/runner/playwright-config.test.mjs +26 -6
  21. package/lib/runner/playwright-runner.mjs +50 -56
  22. package/lib/runner/readiness.mjs +2 -2
  23. package/lib/runner/reporting.mjs +4 -3
  24. package/lib/runner/reporting.test.mjs +2 -5
  25. package/lib/runner/results.mjs +1 -1
  26. package/lib/runner/results.test.mjs +1 -1
  27. package/lib/runner/runtime-contexts.mjs +20 -24
  28. package/lib/runner/runtime-manager.mjs +228 -0
  29. package/lib/runner/runtime-manager.test.mjs +206 -0
  30. package/lib/runner/services.mjs +8 -6
  31. package/lib/runner/state.mjs +1 -2
  32. package/lib/runner/state.test.mjs +2 -4
  33. package/lib/runner/template.mjs +90 -60
  34. package/lib/runner/template.test.mjs +59 -27
  35. package/lib/runner/worker-loop.mjs +35 -32
  36. package/lib/setup/index.d.ts +15 -10
  37. package/package.json +1 -1
  38. package/lib/runner/stack-manager.mjs +0 -146
@@ -54,7 +54,7 @@ describe("runner results", () => {
54
54
  const tracker = trackers.get("api");
55
55
  addTrackerError(tracker, "worker failed");
56
56
  addTrackerError(tracker, "worker failed");
57
- recordGraphError(trackers, { assignedTargets: ["api"] }, "graph failed", 1300);
57
+ recordGraphError(trackers, { targetNames: ["api"] }, "graph failed", 1300);
58
58
 
59
59
  const result = finalizeServiceResult(tracker, 1000, 1500);
60
60
  expect(result.failed).toBe(true);
@@ -3,29 +3,29 @@ 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, resolveStackRuntimeConfigs } from "./template.mjs";
6
+ import { taskNeedsLocalRuntime } from "./planning.mjs";
7
7
  import { writeGraphMetadata } from "./state.mjs";
8
8
  import { startLocalServices, stopLocalServices } from "./services.mjs";
9
+ import { buildExecutionEnv, resolveRuntimeInstanceConfigs } from "./template.mjs";
9
10
 
10
- export function createStackContext(stackId, graph, productDir) {
11
+ export function createRuntimeInstanceContext(runtimeId, graph, productDir) {
11
12
  const graphDir = path.join(productDir, ".testkit", "_graphs", graph.dirName);
12
- const stackStateDir = path.join(graphDir, "stacks", stackId);
13
- fs.mkdirSync(stackStateDir, { recursive: true });
13
+ const runtimeDir = path.join(graphDir, "runtimes", runtimeId);
14
+ fs.mkdirSync(runtimeDir, { recursive: true });
14
15
  writeGraphMetadata(graphDir, graph);
15
16
 
16
- const runtimeConfigs = resolveStackRuntimeConfigs(
17
- graph.rootConfig,
18
- graph.runtimeConfigs,
19
- stackId,
20
- stackStateDir
21
- );
17
+ const runtimeConfigs = resolveRuntimeInstanceConfigs(graph.runtimeConfigs, runtimeId, runtimeDir, {
18
+ graphDirName: graph.dirName,
19
+ portNamespaceIndex: graph.portNamespaceIndex,
20
+ portNamespaceStride: graph.portNamespaceStride,
21
+ });
22
22
 
23
23
  return {
24
24
  graphKey: graph.key,
25
- assignedTargets: [...graph.assignedTargets],
25
+ targetNames: [...graph.targetNames],
26
26
  graphDir,
27
- stackId,
28
- stackStateDir,
27
+ runtimeDir,
28
+ runtimeId,
29
29
  runtimeConfigs,
30
30
  configByName: new Map(runtimeConfigs.map((config) => [config.name, config])),
31
31
  prepared: false,
@@ -36,7 +36,7 @@ export function createStackContext(stackId, graph, productDir) {
36
36
  };
37
37
  }
38
38
 
39
- export async function ensureStackContextReady(context, batch, lifecycle) {
39
+ export async function ensureRuntimeInstanceReady(context, task, lifecycle) {
40
40
  if (!context.prepared) {
41
41
  if (!context.preparationPromise) {
42
42
  context.preparationPromise = (async () => {
@@ -49,7 +49,7 @@ export async function ensureStackContextReady(context, batch, lifecycle) {
49
49
  await context.preparationPromise;
50
50
  }
51
51
 
52
- if (batchNeedsLocalRuntime(batch) && !context.started) {
52
+ if (taskNeedsLocalRuntime(task) && !context.started) {
53
53
  if (!context.startupPromise) {
54
54
  context.startupPromise = (async () => {
55
55
  context.startedServices = await startLocalServices(context.runtimeConfigs, lifecycle);
@@ -64,16 +64,16 @@ export async function ensureStackContextReady(context, batch, lifecycle) {
64
64
  return context;
65
65
  }
66
66
 
67
- export async function deactivateStackContext(context, lifecycle) {
67
+ export async function deactivateRuntimeInstanceContext(context, lifecycle) {
68
68
  if (!context?.started) return;
69
69
  await stopLocalServices(context.startedServices, lifecycle);
70
70
  context.started = false;
71
71
  context.startedServices = [];
72
72
  }
73
73
 
74
- export async function cleanupStackContext(context, lifecycle) {
74
+ export async function cleanupRuntimeInstanceContext(context, lifecycle) {
75
75
  if (!context) return;
76
- await deactivateStackContext(context, lifecycle);
76
+ await deactivateRuntimeInstanceContext(context, lifecycle);
77
77
  }
78
78
 
79
79
  export async function prepareDatabases(runtimeConfigs) {
@@ -94,7 +94,7 @@ async function runMigrate(config, databaseUrl) {
94
94
  const env = buildExecutionEnv(config, {}, process.env);
95
95
  if (databaseUrl) env.DATABASE_URL = databaseUrl;
96
96
 
97
- console.log(`\n── migrate:${config.stackLabel}:${config.name} ──`);
97
+ console.log(`\n── migrate:${config.runtimeLabel}:${config.name} ──`);
98
98
  await execaCommand(migrate.cmd, {
99
99
  cwd: resolveServiceCwd(config.productDir, migrate.cwd),
100
100
  env,
@@ -110,7 +110,7 @@ async function runSeed(config, databaseUrl) {
110
110
  const env = buildExecutionEnv(config, {}, process.env);
111
111
  if (databaseUrl) env.DATABASE_URL = databaseUrl;
112
112
 
113
- console.log(`\n── seed:${config.stackLabel}:${config.name} ──`);
113
+ console.log(`\n── seed:${config.runtimeLabel}:${config.name} ──`);
114
114
  await execaCommand(seed.cmd, {
115
115
  cwd: resolveServiceCwd(config.productDir, seed.cwd),
116
116
  env,
@@ -118,7 +118,3 @@ async function runSeed(config, databaseUrl) {
118
118
  shell: true,
119
119
  });
120
120
  }
121
-
122
- function batchNeedsLocalRuntime(batch) {
123
- return batch.type !== "dal";
124
- }
@@ -0,0 +1,228 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { buildRuntimeIds } from "./execution-config.mjs";
4
+ import {
5
+ cleanupRuntimeInstanceContext,
6
+ createRuntimeInstanceContext,
7
+ ensureRuntimeInstanceReady,
8
+ } from "./runtime-contexts.mjs";
9
+
10
+ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {} }) {
11
+ const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
12
+ const pools = new Map();
13
+ const locks = new Map();
14
+ let nextLeaseCounter = 1;
15
+ const runtimeHooks = {
16
+ cleanupRuntimeInstanceContext,
17
+ createRuntimeInstanceContext,
18
+ ensureRuntimeInstanceReady,
19
+ sleep,
20
+ ...hooks,
21
+ };
22
+
23
+ return {
24
+ canAcquire(task) {
25
+ const pool = getPool(pools, graphByKey, task, productDir, lifecycle);
26
+ if (!locksAvailable(locks, task.locks || [])) {
27
+ return false;
28
+ }
29
+ return claimableRuntimeSlot(pool, task) !== null;
30
+ },
31
+ async acquire(task) {
32
+ const pool = getPool(pools, graphByKey, task, productDir, lifecycle);
33
+ if (lifecycle.isStopRequested()) {
34
+ throw lifecycle.signal.reason || new Error("testkit run interrupted");
35
+ }
36
+
37
+ const leaseId = `lease-${task.id}-${nextLeaseCounter}`;
38
+ nextLeaseCounter += 1;
39
+
40
+ if (!tryAcquireLocks(locks, task.locks || [], leaseId)) {
41
+ throw new Error(`Task ${task.id} was claimed before its locks were available`);
42
+ }
43
+
44
+ const slot = claimRuntimeSlot(pool, task);
45
+ if (!slot) {
46
+ releaseLocks(locks, task.locks || [], leaseId);
47
+ throw new Error(`Task ${task.id} was claimed before runtime capacity was available`);
48
+ }
49
+
50
+ try {
51
+ const context = await getReadyContext(slot, productDir, task, lifecycle, runtimeHooks);
52
+ const leaseDir = path.join(context.runtimeDir, "leases", leaseId);
53
+ fs.mkdirSync(leaseDir, { recursive: true });
54
+ return {
55
+ leaseId,
56
+ leaseDir,
57
+ lockNames: task.locks || [],
58
+ resourceCost: task.resourceCost || 1,
59
+ slot,
60
+ context,
61
+ };
62
+ } catch (error) {
63
+ releaseRuntimeSlot(slot, task);
64
+ releaseLocks(locks, task.locks || [], leaseId);
65
+ cleanupLeaseDir({ leaseDir: path.join(slot.context?.runtimeDir || productDir, "leases", leaseId) });
66
+ await drainRuntimeSlot(slot, lifecycle, runtimeHooks);
67
+ throw error;
68
+ }
69
+ },
70
+ async release(lease, options = {}) {
71
+ if (!lease?.slot) return;
72
+ releaseLocks(locks, lease.lockNames || [], lease.leaseId);
73
+ releaseRuntimeSlot(lease.slot, { resourceCost: lease.resourceCost || 1 });
74
+ cleanupLeaseDir(lease);
75
+ if (options.invalidate) {
76
+ lease.slot.draining = true;
77
+ }
78
+ if (lease.slot.draining && lease.slot.activeLeaseCount === 0) {
79
+ await drainRuntimeSlot(lease.slot, lifecycle, runtimeHooks);
80
+ }
81
+ },
82
+ async cleanupAll() {
83
+ for (const pool of pools.values()) {
84
+ for (const slot of pool.slots) {
85
+ slot.draining = true;
86
+ await drainRuntimeSlot(slot, lifecycle, runtimeHooks);
87
+ }
88
+ }
89
+ },
90
+ getStats() {
91
+ return [...pools.values()]
92
+ .map((pool) => ({
93
+ graphKey: pool.slots[0]?.graph.key || null,
94
+ targetNames: [...(pool.slots[0]?.graph.targetNames || [])],
95
+ maxConcurrentTasks: Number.isFinite(pool.slots[0]?.graph.maxConcurrentTasks)
96
+ ? pool.slots[0]?.graph.maxConcurrentTasks
97
+ : null,
98
+ runtimeCount: pool.slots.length,
99
+ runtimes: pool.slots.map((slot) => ({
100
+ runtimeId: slot.runtimeId,
101
+ peakLeaseCount: slot.peakLeaseCount,
102
+ peakResourceUnits: slot.peakResourceUnits,
103
+ })),
104
+ }))
105
+ .sort((left, right) => String(left.graphKey).localeCompare(String(right.graphKey)));
106
+ },
107
+ };
108
+ }
109
+
110
+ function getPool(pools, graphByKey, task, productDir, lifecycle) {
111
+ const existing = pools.get(task.graphKey);
112
+ if (existing) return existing;
113
+
114
+ const graph = graphByKey.get(task.graphKey);
115
+ if (!graph) {
116
+ throw new Error(`Unknown graph "${task.graphKey}"`);
117
+ }
118
+
119
+ const pool = {
120
+ productDir,
121
+ lifecycle,
122
+ slots: buildRuntimeIds(graph.instanceCount).map((runtimeId) => ({
123
+ graph,
124
+ runtimeId,
125
+ context: null,
126
+ contextPromise: null,
127
+ activeLeaseCount: 0,
128
+ activeResourceUnits: 0,
129
+ peakLeaseCount: 0,
130
+ peakResourceUnits: 0,
131
+ draining: false,
132
+ })),
133
+ };
134
+ pools.set(task.graphKey, pool);
135
+ return pool;
136
+ }
137
+
138
+ function claimRuntimeSlot(pool, task) {
139
+ const resourceCost = task.resourceCost || 1;
140
+ const available = pool.slots.filter((slot) => !slot.draining);
141
+ if (available.length === 0) return null;
142
+
143
+ const slot = [...available]
144
+ .filter((candidate) => slotHasCapacity(candidate, resourceCost))
145
+ .sort(
146
+ (left, right) =>
147
+ left.activeResourceUnits - right.activeResourceUnits ||
148
+ left.activeLeaseCount - right.activeLeaseCount ||
149
+ left.runtimeId.localeCompare(right.runtimeId)
150
+ )[0];
151
+ if (!slot) return null;
152
+ slot.activeLeaseCount += 1;
153
+ slot.activeResourceUnits += resourceCost;
154
+ slot.peakLeaseCount = Math.max(slot.peakLeaseCount, slot.activeLeaseCount);
155
+ slot.peakResourceUnits = Math.max(slot.peakResourceUnits, slot.activeResourceUnits);
156
+ return slot;
157
+ }
158
+
159
+ function releaseRuntimeSlot(slot, task = {}) {
160
+ slot.activeLeaseCount = Math.max(0, slot.activeLeaseCount - 1);
161
+ slot.activeResourceUnits = Math.max(0, slot.activeResourceUnits - (task.resourceCost || 1));
162
+ }
163
+
164
+ async function getReadyContext(slot, productDir, task, lifecycle, runtimeHooks) {
165
+ if (!slot.context) {
166
+ slot.context = runtimeHooks.createRuntimeInstanceContext(slot.runtimeId, slot.graph, productDir);
167
+ lifecycle.trackGraphContext(slot.context);
168
+ }
169
+ if (!slot.contextPromise) {
170
+ slot.contextPromise = Promise.resolve(slot.context);
171
+ }
172
+ await slot.contextPromise;
173
+ await runtimeHooks.ensureRuntimeInstanceReady(slot.context, task, lifecycle);
174
+ return slot.context;
175
+ }
176
+
177
+ async function drainRuntimeSlot(slot, lifecycle, runtimeHooks) {
178
+ if (!slot.context || slot.activeLeaseCount > 0) return;
179
+ await runtimeHooks.cleanupRuntimeInstanceContext(slot.context, lifecycle);
180
+ slot.context = null;
181
+ slot.contextPromise = null;
182
+ slot.draining = false;
183
+ }
184
+
185
+ function tryAcquireLocks(lockMap, lockNames, leaseId) {
186
+ const uniqueLocks = [...new Set(lockNames)].sort();
187
+ for (const lockName of uniqueLocks) {
188
+ if (lockMap.has(lockName)) {
189
+ return false;
190
+ }
191
+ }
192
+ for (const lockName of uniqueLocks) {
193
+ lockMap.set(lockName, leaseId);
194
+ }
195
+ return true;
196
+ }
197
+
198
+ function locksAvailable(lockMap, lockNames) {
199
+ return [...new Set(lockNames)].every((lockName) => !lockMap.has(lockName));
200
+ }
201
+
202
+ function claimableRuntimeSlot(pool, task) {
203
+ const resourceCost = task.resourceCost || 1;
204
+ return (
205
+ pool.slots.find((slot) => !slot.draining && slotHasCapacity(slot, resourceCost)) || null
206
+ );
207
+ }
208
+
209
+ function slotHasCapacity(slot, resourceCost) {
210
+ return slot.activeResourceUnits + resourceCost <= slot.graph.maxConcurrentTasks;
211
+ }
212
+
213
+ function releaseLocks(lockMap, lockNames, leaseId) {
214
+ for (const lockName of [...new Set(lockNames)].sort()) {
215
+ if (lockMap.get(lockName) === leaseId) {
216
+ lockMap.delete(lockName);
217
+ }
218
+ }
219
+ }
220
+
221
+ function sleep(ms) {
222
+ return new Promise((resolve) => setTimeout(resolve, ms));
223
+ }
224
+
225
+ function cleanupLeaseDir(lease) {
226
+ if (!lease?.leaseDir) return;
227
+ fs.rmSync(lease.leaseDir, { recursive: true, force: true });
228
+ }
@@ -0,0 +1,206 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { createRuntimeManager } from "./runtime-manager.mjs";
6
+
7
+ const cleanups = [];
8
+
9
+ afterEach(() => {
10
+ while (cleanups.length > 0) {
11
+ cleanups.pop()();
12
+ }
13
+ });
14
+
15
+ function makeTempDir(prefix) {
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
17
+ cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
18
+ return dir;
19
+ }
20
+
21
+ function makeLifecycle() {
22
+ return {
23
+ signal: new AbortController().signal,
24
+ isStopRequested() {
25
+ return false;
26
+ },
27
+ trackGraphContext() {},
28
+ };
29
+ }
30
+
31
+ function makeTask(id, extras = {}) {
32
+ return {
33
+ id,
34
+ graphKey: "api",
35
+ locks: [],
36
+ ...extras,
37
+ };
38
+ }
39
+
40
+ function makeHooks(events) {
41
+ return {
42
+ createRuntimeInstanceContext(runtimeId, graph, productDir) {
43
+ const runtimeDir = path.join(productDir, ".testkit", "_graphs", graph.dirName, "runtimes", runtimeId);
44
+ fs.mkdirSync(runtimeDir, { recursive: true });
45
+ events.created.push(runtimeId);
46
+ return {
47
+ graphKey: graph.key,
48
+ runtimeDir,
49
+ runtimeId,
50
+ targetNames: [...graph.targetNames],
51
+ };
52
+ },
53
+ async ensureRuntimeInstanceReady(context) {
54
+ events.ready.push(context.runtimeId);
55
+ },
56
+ async cleanupRuntimeInstanceContext(context) {
57
+ events.cleaned.push(context.runtimeId);
58
+ fs.rmSync(context.runtimeDir, { recursive: true, force: true });
59
+ },
60
+ async sleep() {
61
+ await new Promise((resolve) => setTimeout(resolve, 0));
62
+ },
63
+ };
64
+ }
65
+
66
+ describe("runtime-manager", () => {
67
+ it("spreads concurrent leases across runtime instances", async () => {
68
+ const productDir = makeTempDir("testkit-runtime-manager-");
69
+ const events = { created: [], ready: [], cleaned: [] };
70
+ const manager = createRuntimeManager({
71
+ productDir,
72
+ lifecycle: makeLifecycle(),
73
+ graphs: [
74
+ {
75
+ key: "api",
76
+ dirName: "api",
77
+ targetNames: ["api"],
78
+ instanceCount: 2,
79
+ maxConcurrentTasks: 1,
80
+ },
81
+ ],
82
+ hooks: makeHooks(events),
83
+ });
84
+
85
+ const leaseOne = await manager.acquire(makeTask(1));
86
+ const leaseTwo = await manager.acquire(makeTask(2));
87
+
88
+ expect(leaseOne.slot.runtimeId).toBe("runtime-1");
89
+ expect(leaseTwo.slot.runtimeId).toBe("runtime-2");
90
+ expect(events.created).toEqual(["runtime-1", "runtime-2"]);
91
+
92
+ await manager.release(leaseOne);
93
+ await manager.release(leaseTwo);
94
+ await manager.cleanupAll();
95
+ });
96
+
97
+ it("marks conflicting locks unavailable until the first lease releases", async () => {
98
+ const productDir = makeTempDir("testkit-runtime-manager-");
99
+ const manager = createRuntimeManager({
100
+ productDir,
101
+ lifecycle: makeLifecycle(),
102
+ graphs: [
103
+ {
104
+ key: "api",
105
+ dirName: "api",
106
+ targetNames: ["api"],
107
+ instanceCount: 1,
108
+ maxConcurrentTasks: 1,
109
+ },
110
+ ],
111
+ hooks: makeHooks({ created: [], ready: [], cleaned: [] }),
112
+ });
113
+
114
+ const firstLease = await manager.acquire(makeTask(1, { locks: ["shared-lock"] }));
115
+ expect(manager.canAcquire(makeTask(2, { locks: ["shared-lock"] }))).toBe(false);
116
+
117
+ await manager.release(firstLease);
118
+ const secondLease = await manager.acquire(makeTask(2, { locks: ["shared-lock"] }));
119
+
120
+ await manager.release(secondLease);
121
+ await manager.cleanupAll();
122
+ });
123
+
124
+ it("does not drain an invalidated runtime slot until all active leases release", async () => {
125
+ const productDir = makeTempDir("testkit-runtime-manager-");
126
+ const events = { created: [], ready: [], cleaned: [] };
127
+ const manager = createRuntimeManager({
128
+ productDir,
129
+ lifecycle: makeLifecycle(),
130
+ graphs: [
131
+ {
132
+ key: "api",
133
+ dirName: "api",
134
+ targetNames: ["api"],
135
+ instanceCount: 1,
136
+ maxConcurrentTasks: 2,
137
+ },
138
+ ],
139
+ hooks: makeHooks(events),
140
+ });
141
+
142
+ const leaseOne = await manager.acquire(makeTask(1));
143
+ const leaseTwo = await manager.acquire(makeTask(2));
144
+
145
+ await manager.release(leaseOne, { invalidate: true });
146
+ expect(events.cleaned).toEqual([]);
147
+
148
+ await manager.release(leaseTwo);
149
+ expect(events.cleaned).toEqual(["runtime-1"]);
150
+ });
151
+
152
+ it("removes lease directories on release", async () => {
153
+ const productDir = makeTempDir("testkit-runtime-manager-");
154
+ const manager = createRuntimeManager({
155
+ productDir,
156
+ lifecycle: makeLifecycle(),
157
+ graphs: [
158
+ {
159
+ key: "api",
160
+ dirName: "api",
161
+ targetNames: ["api"],
162
+ instanceCount: 1,
163
+ maxConcurrentTasks: 1,
164
+ },
165
+ ],
166
+ hooks: makeHooks({ created: [], ready: [], cleaned: [] }),
167
+ });
168
+
169
+ const lease = await manager.acquire(makeTask(1));
170
+ expect(fs.existsSync(lease.leaseDir)).toBe(true);
171
+
172
+ await manager.release(lease);
173
+ expect(fs.existsSync(lease.leaseDir)).toBe(false);
174
+
175
+ await manager.cleanupAll();
176
+ });
177
+
178
+ it("exposes runtime capacity through canAcquire", async () => {
179
+ const productDir = makeTempDir("testkit-runtime-manager-");
180
+ const manager = createRuntimeManager({
181
+ productDir,
182
+ lifecycle: makeLifecycle(),
183
+ graphs: [
184
+ {
185
+ key: "api",
186
+ dirName: "api",
187
+ targetNames: ["api"],
188
+ instanceCount: 1,
189
+ maxConcurrentTasks: 2,
190
+ },
191
+ ],
192
+ hooks: makeHooks({ created: [], ready: [], cleaned: [] }),
193
+ });
194
+
195
+ expect(manager.canAcquire(makeTask(1))).toBe(true);
196
+ const leaseOne = await manager.acquire(makeTask(1));
197
+ expect(manager.canAcquire(makeTask(2))).toBe(true);
198
+ const leaseTwo = await manager.acquire(makeTask(2));
199
+ expect(manager.canAcquire(makeTask(3))).toBe(false);
200
+
201
+ await manager.release(leaseOne);
202
+ expect(manager.canAcquire(makeTask(3))).toBe(true);
203
+ await manager.release(leaseTwo);
204
+ await manager.cleanupAll();
205
+ });
206
+ });
@@ -1,7 +1,7 @@
1
1
  import { resolveServiceCwd } from "../config/index.mjs";
2
2
  import { buildExecutionEnv, numericPortFromUrl } from "./template.mjs";
3
3
  import { DEFAULT_READY_TIMEOUT_MS, assertLocalServicePortsAvailable, isPortInUse, waitForReady } from "./readiness.mjs";
4
- import { pipeOutput, startDetachedCommand, stopChildProcess, sleep } from "./processes.mjs";
4
+ import { killChildProcess, pipeOutput, startDetachedCommand, stopChildProcess, sleep } from "./processes.mjs";
5
5
  import { readDatabaseUrl } from "./state-io.mjs";
6
6
 
7
7
  export async function startLocalServices(runtimeConfigs, lifecycle) {
@@ -36,20 +36,22 @@ export async function startLocalService(config, lifecycle) {
36
36
 
37
37
  await assertLocalServicePortsAvailable(config, isPortInUse);
38
38
 
39
- console.log(`Starting ${config.stackLabel}:${config.name}: ${config.testkit.local.start}`);
39
+ console.log(`Starting ${config.runtimeLabel}:${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.stackLabel}:${config.name}]`),
44
- pipeOutput(child.stderr, `[${config.stackLabel}:${config.name}]`),
43
+ pipeOutput(child.stdout, `[${config.runtimeLabel}:${config.name}]`),
44
+ pipeOutput(child.stderr, `[${config.runtimeLabel}:${config.name}]`),
45
45
  ];
46
- lifecycle.registerService(config, child, cwd);
46
+ lifecycle.registerService(config, child, cwd, () => {
47
+ killChildProcess(child, "SIGTERM");
48
+ });
47
49
 
48
50
  const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
49
51
 
50
52
  try {
51
53
  await waitForReady({
52
- name: `${config.stackLabel}:${config.name}`,
54
+ name: `${config.runtimeLabel}:${config.name}`,
53
55
  url: config.testkit.local.readyUrl,
54
56
  timeoutMs: readyTimeoutMs,
55
57
  process: child,
@@ -46,8 +46,7 @@ export function writeGraphMetadata(graphDir, graph) {
46
46
  fs.mkdirSync(graphDir, { recursive: true });
47
47
  const metadata = {
48
48
  runtimeServices: graph.runtimeNames,
49
- assignedTargets: [...graph.assignedTargets].sort(),
50
- rootService: graph.rootConfig.name,
49
+ targetServices: [...(graph.targetNames || [])].sort(),
51
50
  };
52
51
  fs.writeFileSync(
53
52
  path.join(graphDir, GRAPH_METADATA),
@@ -48,14 +48,12 @@ describe("runner-state", () => {
48
48
  const graphDir = path.join(productDir, ".testkit", "_graphs", "api__frontend");
49
49
  writeGraphMetadata(graphDir, {
50
50
  runtimeNames: ["api", "frontend"],
51
- assignedTargets: ["frontend", "api"],
52
- rootConfig: { name: "api" },
51
+ targetNames: ["frontend", "api"],
53
52
  });
54
53
 
55
54
  expect(readGraphMetadata(graphDir)).toEqual({
56
55
  runtimeServices: ["api", "frontend"],
57
- assignedTargets: ["api", "frontend"],
58
- rootService: "api",
56
+ targetServices: ["api", "frontend"],
59
57
  });
60
58
 
61
59
  expect(findGraphDirsForService(productDir, "frontend")).toEqual([graphDir]);