@elench/testkit 0.1.39 → 0.1.41

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 (41) hide show
  1. package/README.md +43 -13
  2. package/lib/cli/args.mjs +5 -3
  3. package/lib/cli/args.test.mjs +5 -5
  4. package/lib/cli/index.mjs +9 -15
  5. package/lib/config/index.mjs +72 -24
  6. package/lib/database/index.mjs +19 -7
  7. package/lib/database/naming.mjs +2 -2
  8. package/lib/database/naming.test.mjs +2 -2
  9. package/lib/runner/default-runtime-runner.mjs +63 -43
  10. package/lib/runner/execution-config.mjs +24 -64
  11. package/lib/runner/execution-config.test.mjs +30 -72
  12. package/lib/runner/formatting.mjs +0 -15
  13. package/lib/runner/formatting.test.mjs +0 -18
  14. package/lib/runner/lifecycle.mjs +7 -7
  15. package/lib/runner/orchestrator.mjs +9 -10
  16. package/lib/runner/planning.mjs +42 -136
  17. package/lib/runner/planning.test.mjs +70 -174
  18. package/lib/runner/playwright-config.mjs +8 -2
  19. package/lib/runner/playwright-config.test.mjs +20 -5
  20. package/lib/runner/playwright-runner.mjs +32 -54
  21. package/lib/runner/readiness.mjs +2 -2
  22. package/lib/runner/reporting.mjs +3 -3
  23. package/lib/runner/reporting.test.mjs +4 -5
  24. package/lib/runner/results.mjs +1 -1
  25. package/lib/runner/results.test.mjs +1 -1
  26. package/lib/runner/runtime-contexts.mjs +20 -24
  27. package/lib/runner/runtime-manager.mjs +181 -0
  28. package/lib/runner/runtime-manager.test.mjs +181 -0
  29. package/lib/runner/services.mjs +4 -4
  30. package/lib/runner/state.mjs +1 -2
  31. package/lib/runner/state.test.mjs +2 -4
  32. package/lib/runner/template.mjs +90 -60
  33. package/lib/runner/template.test.mjs +59 -27
  34. package/lib/runner/worker-loop.mjs +29 -32
  35. package/lib/runtime/index.d.ts +11 -0
  36. package/lib/runtime/index.mjs +34 -0
  37. package/lib/setup/index.d.ts +15 -10
  38. package/lib/shared/file-timeout.mjs +107 -0
  39. package/lib/shared/file-timeout.test.mjs +64 -0
  40. package/package.json +1 -1
  41. package/lib/runner/stack-manager.mjs +0 -146
@@ -1,16 +1,15 @@
1
1
  import path from "path";
2
2
  import { execa } from "execa";
3
- import {
4
- parsePlaywrightJsonResults,
5
- } from "../reporters/playwright.mjs";
6
- import { resolveServiceCwd, } from "../config/index.mjs";
7
- import { formatPlaywrightBatchFiles } from "./formatting.mjs";
8
- import { printBufferedOutput } from "./processes.mjs";
3
+ import { parsePlaywrightJsonResults } from "../reporters/playwright.mjs";
4
+ import { resolveServiceCwd } from "../config/index.mjs";
5
+ import { formatFileTimeoutBudgetError } from "../shared/file-timeout.mjs";
6
+ import { settleSubprocess } from "./default-runtime-runner.mjs";
9
7
  import { ensurePlaywrightTestConfig } from "./playwright-config.mjs";
10
- import { buildPlaywrightEnv } from "./template.mjs";
8
+ import { printBufferedOutput } from "./processes.mjs";
11
9
  import { normalizePathSeparators } from "./state.mjs";
10
+ import { buildPlaywrightEnv } from "./template.mjs";
12
11
 
13
- export async function runPlaywrightBatch(targetConfig, batch, lifecycle) {
12
+ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
14
13
  const local = targetConfig.testkit.local;
15
14
  if (!local?.baseUrl) {
16
15
  throw new Error(
@@ -18,69 +17,48 @@ export async function runPlaywrightBatch(targetConfig, batch, lifecycle) {
18
17
  );
19
18
  }
20
19
 
21
- console.log(
22
- `\n── ${targetConfig.stackLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"})${formatPlaywrightBatchFiles(batch)} ──`
23
- );
20
+ console.log(`\n── ${targetConfig.runtimeLabel} playwright:${targetConfig.name} (${task.file}) ──`);
24
21
 
25
22
  const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
26
- const requestedFiles = batch.tasks.map((task) =>
27
- path.relative(cwd, path.join(targetConfig.productDir, task.file))
28
- );
29
- const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles);
23
+ const requestedFile = path.relative(cwd, path.join(targetConfig.productDir, task.file));
24
+ const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, [requestedFile], lease);
30
25
  const startedAt = Date.now();
31
- const result = await execa(
26
+ const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
27
+ const subprocess = execa(
32
28
  "npx",
33
29
  ["playwright", "test", "--config", playwrightConfigPath, "--reporter=json"],
34
30
  {
35
31
  cwd,
36
- env: buildPlaywrightEnv(targetConfig, local.baseUrl, process.env),
32
+ env: buildPlaywrightEnv(targetConfig, local.baseUrl, lease, process.env),
37
33
  reject: false,
38
34
  cancelSignal: lifecycle.signal,
39
35
  forceKillAfterDelay: 5_000,
40
36
  }
41
37
  );
38
+ const { result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds);
42
39
 
43
40
  if (result.stderr) {
44
- printBufferedOutput(result.stderr, `[${targetConfig.stackLabel}:${targetConfig.name}:playwright]`);
41
+ printBufferedOutput(result.stderr, `[${targetConfig.runtimeLabel}:${targetConfig.name}:playwright]`);
45
42
  }
46
43
 
47
- const parsed = parsePlaywrightJsonResults(result.stdout, cwd);
44
+ const parsed = parsePlaywrightJsonResults(result.stdout || "", cwd);
48
45
  const finishedAt = Date.now();
49
- const batchDurationMs = finishedAt - startedAt;
50
- const genericError =
51
- result.exitCode === 0
46
+ const durationMs = finishedAt - startedAt;
47
+ const relativeFile = normalizePathSeparators(requestedFile);
48
+ const fileResult = parsed.fileResults.get(relativeFile);
49
+ const genericError = timedOut
50
+ ? formatFileTimeoutBudgetError(fileTimeoutSeconds)
51
+ : result.exitCode === 0
52
52
  ? parsed.errors[0] || null
53
- : parsed.errors[0] ||
54
- result.stderr.trim() ||
55
- `Playwright exited with code ${result.exitCode}`;
56
-
57
- return batch.tasks.map((task) => {
58
- const relativeFile = normalizePathSeparators(
59
- path.relative(cwd, path.join(targetConfig.productDir, task.file))
60
- );
61
- const fileResult = parsed.fileResults.get(relativeFile);
62
- if (fileResult) {
63
- return {
64
- task,
65
- failed: fileResult.status === "failed",
66
- status: fileResult.status,
67
- error: fileResult.error,
68
- durationMs:
69
- fileResult.durationMs > 0
70
- ? fileResult.durationMs
71
- : Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
72
- startedAt,
73
- finishedAt,
74
- };
75
- }
53
+ : parsed.errors[0] || result.stderr.trim() || `Playwright exited with code ${result.exitCode}`;
76
54
 
77
- return {
78
- task,
79
- failed: result.exitCode !== 0,
80
- error: result.exitCode !== 0 ? genericError : null,
81
- durationMs: Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
82
- startedAt,
83
- finishedAt,
84
- };
85
- });
55
+ return {
56
+ task,
57
+ failed: timedOut ? true : fileResult ? fileResult.status === "failed" : result.exitCode !== 0,
58
+ status: timedOut ? "failed" : fileResult?.status || (result.exitCode === 0 ? "passed" : "failed"),
59
+ error: timedOut ? genericError : fileResult?.error || genericError,
60
+ durationMs: fileResult?.durationMs > 0 ? fileResult.durationMs : durationMs,
61
+ startedAt,
62
+ finishedAt,
63
+ };
86
64
  }
@@ -54,11 +54,11 @@ export async function assertLocalServicePortsAvailable(config, isPortInUse) {
54
54
  const owner = findPortOwner(config.productDir, socket);
55
55
  const ownerDetail = owner
56
56
  ? owner.active
57
- ? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.stackLabel}:${owner.service.serviceName}.`
57
+ ? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.runtimeLabel}:${owner.service.serviceName}.`
58
58
  : ` Stale testkit run ${formatRunSummary(owner.manifest)} owns ${key}.`
59
59
  : "";
60
60
  throw new Error(
61
- `Cannot start "${config.stackLabel}:${config.name}" because ${key} is already in use. ` +
61
+ `Cannot start "${config.runtimeLabel}:${config.name}" because ${key} is already in use. ` +
62
62
  `Stop the existing process and rerun testkit.${ownerDetail}`
63
63
  );
64
64
  }
@@ -96,7 +96,7 @@ export function buildRunArtifact({
96
96
  finishedAt,
97
97
  execution,
98
98
  workerCount,
99
- stackCount,
99
+ runtimeInstanceCount,
100
100
  typeValues,
101
101
  suiteSelectors,
102
102
  fileNames,
@@ -135,9 +135,9 @@ export function buildRunArtifact({
135
135
  finishedAt: new Date(finishedAt).toISOString(),
136
136
  durationMs: finishedAt - startedAt,
137
137
  workers: execution.workers,
138
+ fileTimeoutSeconds: execution.fileTimeoutSeconds,
138
139
  workerCount,
139
- stackMode: execution.stackMode,
140
- stackCount,
140
+ runtimeInstanceCount,
141
141
  dbBackend,
142
142
  types: typeValues,
143
143
  suiteSelectors: suiteSelectors.map((selector) => selector.raw),
@@ -53,11 +53,10 @@ describe("runner reporting", () => {
53
53
  finishedAt: 4000,
54
54
  execution: {
55
55
  workers: 2,
56
- stackMode: "shared",
57
- stackCount: 1,
56
+ fileTimeoutSeconds: 60,
58
57
  },
59
58
  workerCount: 1,
60
- stackCount: 1,
59
+ runtimeInstanceCount: 2,
61
60
  typeValues: ["all"],
62
61
  suiteSelectors: [],
63
62
  fileNames: [],
@@ -82,9 +81,9 @@ describe("runner reporting", () => {
82
81
  expect(artifact.schemaVersion).toBe(3);
83
82
  expect(artifact.run).toMatchObject({
84
83
  workers: 2,
84
+ fileTimeoutSeconds: 60,
85
85
  workerCount: 1,
86
- stackMode: "shared",
87
- stackCount: 1,
86
+ runtimeInstanceCount: 2,
88
87
  });
89
88
  expect(artifact.summary.services).toEqual({
90
89
  total: 1,
@@ -142,7 +142,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
142
142
  }
143
143
 
144
144
  export function recordGraphError(trackers, graph, message, now = Date.now()) {
145
- const targetNames = graph?.assignedTargets || [];
145
+ const targetNames = graph?.targetNames || [];
146
146
  for (const targetName of targetNames) {
147
147
  const tracker = trackers.get(targetName);
148
148
  if (tracker && !tracker.skipped) {
@@ -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,181 @@
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
+ async acquire(task) {
25
+ const pool = getPool(pools, graphByKey, task, productDir, lifecycle);
26
+
27
+ while (true) {
28
+ if (lifecycle.isStopRequested()) {
29
+ throw lifecycle.signal.reason || new Error("testkit run interrupted");
30
+ }
31
+
32
+ const leaseId = `lease-${task.id}-${nextLeaseCounter}`;
33
+ nextLeaseCounter += 1;
34
+
35
+ if (!tryAcquireLocks(locks, task.locks || [], leaseId)) {
36
+ await runtimeHooks.sleep(10);
37
+ continue;
38
+ }
39
+
40
+ const slot = claimRuntimeSlot(pool);
41
+ if (!slot) {
42
+ releaseLocks(locks, task.locks || [], leaseId);
43
+ await runtimeHooks.sleep(10);
44
+ continue;
45
+ }
46
+
47
+ try {
48
+ const context = await getReadyContext(slot, productDir, task, lifecycle, runtimeHooks);
49
+ const leaseDir = path.join(context.runtimeDir, "leases", leaseId);
50
+ fs.mkdirSync(leaseDir, { recursive: true });
51
+ return {
52
+ leaseId,
53
+ leaseDir,
54
+ lockNames: task.locks || [],
55
+ slot,
56
+ context,
57
+ };
58
+ } catch (error) {
59
+ releaseRuntimeSlot(slot);
60
+ releaseLocks(locks, task.locks || [], leaseId);
61
+ cleanupLeaseDir({ leaseDir: path.join(slot.context?.runtimeDir || productDir, "leases", leaseId) });
62
+ await drainRuntimeSlot(slot, lifecycle, runtimeHooks);
63
+ throw error;
64
+ }
65
+ }
66
+ },
67
+ async release(lease, options = {}) {
68
+ if (!lease?.slot) return;
69
+ releaseLocks(locks, lease.lockNames || [], lease.leaseId);
70
+ releaseRuntimeSlot(lease.slot);
71
+ cleanupLeaseDir(lease);
72
+ if (options.invalidate) {
73
+ lease.slot.draining = true;
74
+ }
75
+ if (lease.slot.draining && lease.slot.activeLeaseCount === 0) {
76
+ await drainRuntimeSlot(lease.slot, lifecycle, runtimeHooks);
77
+ }
78
+ },
79
+ async cleanupAll() {
80
+ for (const pool of pools.values()) {
81
+ for (const slot of pool.slots) {
82
+ slot.draining = true;
83
+ await drainRuntimeSlot(slot, lifecycle, runtimeHooks);
84
+ }
85
+ }
86
+ },
87
+ };
88
+ }
89
+
90
+ function getPool(pools, graphByKey, task, productDir, lifecycle) {
91
+ const existing = pools.get(task.graphKey);
92
+ if (existing) return existing;
93
+
94
+ const graph = graphByKey.get(task.graphKey);
95
+ if (!graph) {
96
+ throw new Error(`Unknown graph "${task.graphKey}"`);
97
+ }
98
+
99
+ const pool = {
100
+ productDir,
101
+ lifecycle,
102
+ slots: buildRuntimeIds(graph.instanceCount).map((runtimeId) => ({
103
+ graph,
104
+ runtimeId,
105
+ context: null,
106
+ contextPromise: null,
107
+ activeLeaseCount: 0,
108
+ draining: false,
109
+ })),
110
+ };
111
+ pools.set(task.graphKey, pool);
112
+ return pool;
113
+ }
114
+
115
+ function claimRuntimeSlot(pool) {
116
+ const available = pool.slots.filter((slot) => !slot.draining);
117
+ if (available.length === 0) return null;
118
+
119
+ const slot = [...available].sort(
120
+ (left, right) =>
121
+ left.activeLeaseCount - right.activeLeaseCount ||
122
+ left.runtimeId.localeCompare(right.runtimeId)
123
+ )[0];
124
+ slot.activeLeaseCount += 1;
125
+ return slot;
126
+ }
127
+
128
+ function releaseRuntimeSlot(slot) {
129
+ slot.activeLeaseCount = Math.max(0, slot.activeLeaseCount - 1);
130
+ }
131
+
132
+ async function getReadyContext(slot, productDir, task, lifecycle, runtimeHooks) {
133
+ if (!slot.context) {
134
+ slot.context = runtimeHooks.createRuntimeInstanceContext(slot.runtimeId, slot.graph, productDir);
135
+ lifecycle.trackGraphContext(slot.context);
136
+ }
137
+ if (!slot.contextPromise) {
138
+ slot.contextPromise = Promise.resolve(slot.context);
139
+ }
140
+ await slot.contextPromise;
141
+ await runtimeHooks.ensureRuntimeInstanceReady(slot.context, task, lifecycle);
142
+ return slot.context;
143
+ }
144
+
145
+ async function drainRuntimeSlot(slot, lifecycle, runtimeHooks) {
146
+ if (!slot.context || slot.activeLeaseCount > 0) return;
147
+ await runtimeHooks.cleanupRuntimeInstanceContext(slot.context, lifecycle);
148
+ slot.context = null;
149
+ slot.contextPromise = null;
150
+ slot.draining = false;
151
+ }
152
+
153
+ function tryAcquireLocks(lockMap, lockNames, leaseId) {
154
+ const uniqueLocks = [...new Set(lockNames)].sort();
155
+ for (const lockName of uniqueLocks) {
156
+ if (lockMap.has(lockName)) {
157
+ return false;
158
+ }
159
+ }
160
+ for (const lockName of uniqueLocks) {
161
+ lockMap.set(lockName, leaseId);
162
+ }
163
+ return true;
164
+ }
165
+
166
+ function releaseLocks(lockMap, lockNames, leaseId) {
167
+ for (const lockName of [...new Set(lockNames)].sort()) {
168
+ if (lockMap.get(lockName) === leaseId) {
169
+ lockMap.delete(lockName);
170
+ }
171
+ }
172
+ }
173
+
174
+ function sleep(ms) {
175
+ return new Promise((resolve) => setTimeout(resolve, ms));
176
+ }
177
+
178
+ function cleanupLeaseDir(lease) {
179
+ if (!lease?.leaseDir) return;
180
+ fs.rmSync(lease.leaseDir, { recursive: true, force: true });
181
+ }
@@ -0,0 +1,181 @@
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
+ },
80
+ ],
81
+ hooks: makeHooks(events),
82
+ });
83
+
84
+ const leaseOne = await manager.acquire(makeTask(1));
85
+ const leaseTwo = await manager.acquire(makeTask(2));
86
+
87
+ expect(leaseOne.slot.runtimeId).toBe("runtime-1");
88
+ expect(leaseTwo.slot.runtimeId).toBe("runtime-2");
89
+ expect(events.created).toEqual(["runtime-1", "runtime-2"]);
90
+
91
+ await manager.release(leaseOne);
92
+ await manager.release(leaseTwo);
93
+ await manager.cleanupAll();
94
+ });
95
+
96
+ it("blocks conflicting locks until the first lease releases", async () => {
97
+ const productDir = makeTempDir("testkit-runtime-manager-");
98
+ const manager = createRuntimeManager({
99
+ productDir,
100
+ lifecycle: makeLifecycle(),
101
+ graphs: [
102
+ {
103
+ key: "api",
104
+ dirName: "api",
105
+ targetNames: ["api"],
106
+ instanceCount: 1,
107
+ },
108
+ ],
109
+ hooks: makeHooks({ created: [], ready: [], cleaned: [] }),
110
+ });
111
+
112
+ const firstLease = await manager.acquire(makeTask(1, { locks: ["shared-lock"] }));
113
+ let secondSettled = false;
114
+ const secondPromise = manager.acquire(makeTask(2, { locks: ["shared-lock"] })).then((lease) => {
115
+ secondSettled = true;
116
+ return lease;
117
+ });
118
+
119
+ await new Promise((resolve) => setTimeout(resolve, 20));
120
+ expect(secondSettled).toBe(false);
121
+
122
+ await manager.release(firstLease);
123
+ const secondLease = await secondPromise;
124
+ expect(secondSettled).toBe(true);
125
+
126
+ await manager.release(secondLease);
127
+ await manager.cleanupAll();
128
+ });
129
+
130
+ it("does not drain an invalidated runtime slot until all active leases release", async () => {
131
+ const productDir = makeTempDir("testkit-runtime-manager-");
132
+ const events = { created: [], ready: [], cleaned: [] };
133
+ const manager = createRuntimeManager({
134
+ productDir,
135
+ lifecycle: makeLifecycle(),
136
+ graphs: [
137
+ {
138
+ key: "api",
139
+ dirName: "api",
140
+ targetNames: ["api"],
141
+ instanceCount: 1,
142
+ },
143
+ ],
144
+ hooks: makeHooks(events),
145
+ });
146
+
147
+ const leaseOne = await manager.acquire(makeTask(1));
148
+ const leaseTwo = await manager.acquire(makeTask(2));
149
+
150
+ await manager.release(leaseOne, { invalidate: true });
151
+ expect(events.cleaned).toEqual([]);
152
+
153
+ await manager.release(leaseTwo);
154
+ expect(events.cleaned).toEqual(["runtime-1"]);
155
+ });
156
+
157
+ it("removes lease directories on release", async () => {
158
+ const productDir = makeTempDir("testkit-runtime-manager-");
159
+ const manager = createRuntimeManager({
160
+ productDir,
161
+ lifecycle: makeLifecycle(),
162
+ graphs: [
163
+ {
164
+ key: "api",
165
+ dirName: "api",
166
+ targetNames: ["api"],
167
+ instanceCount: 1,
168
+ },
169
+ ],
170
+ hooks: makeHooks({ created: [], ready: [], cleaned: [] }),
171
+ });
172
+
173
+ const lease = await manager.acquire(makeTask(1));
174
+ expect(fs.existsSync(lease.leaseDir)).toBe(true);
175
+
176
+ await manager.release(lease);
177
+ expect(fs.existsSync(lease.leaseDir)).toBe(false);
178
+
179
+ await manager.cleanupAll();
180
+ });
181
+ });