@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.
- package/README.md +12 -3
- package/lib/cli/args.mjs +6 -7
- package/lib/cli/args.test.mjs +9 -4
- package/lib/cli/index.mjs +15 -4
- package/lib/config/index.mjs +118 -0
- package/lib/runner/default-runtime-runner.mjs +4 -4
- package/lib/runner/execution-config.mjs +108 -0
- package/lib/runner/execution-config.test.mjs +101 -0
- package/lib/runner/lifecycle.mjs +7 -7
- package/lib/runner/orchestrator.mjs +51 -24
- package/lib/runner/planning.mjs +42 -2
- package/lib/runner/planning.test.mjs +175 -3
- package/lib/runner/playwright-config.test.mjs +1 -1
- package/lib/runner/playwright-runner.mjs +2 -2
- package/lib/runner/readiness.mjs +2 -2
- package/lib/runner/reporting.mjs +5 -2
- package/lib/runner/reporting.test.mjs +12 -1
- package/lib/runner/runtime-contexts.mjs +38 -47
- package/lib/runner/services.mjs +4 -4
- package/lib/runner/stack-manager.mjs +146 -0
- package/lib/runner/template.mjs +40 -32
- package/lib/runner/template.test.mjs +42 -35
- package/lib/runner/worker-loop.mjs +17 -14
- package/lib/setup/index.d.ts +23 -0
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
|
11
|
-
const graphDir = path.join(
|
|
12
|
-
const
|
|
13
|
-
fs.mkdirSync(
|
|
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 =
|
|
16
|
+
const runtimeConfigs = resolveStackRuntimeConfigs(
|
|
17
17
|
graph.rootConfig,
|
|
18
18
|
graph.runtimeConfigs,
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
stackId,
|
|
20
|
+
stackStateDir
|
|
21
21
|
);
|
|
22
22
|
|
|
23
23
|
return {
|
|
24
24
|
graphKey: graph.key,
|
|
25
|
+
assignedTargets: [...graph.assignedTargets],
|
|
25
26
|
graphDir,
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
76
|
-
if (!
|
|
77
|
-
await
|
|
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.
|
|
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.
|
|
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,
|
package/lib/runner/services.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
44
|
-
pipeOutput(child.stderr, `[${config.
|
|
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.
|
|
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
|
+
}
|
package/lib/runner/template.mjs
CHANGED
|
@@ -3,8 +3,8 @@ import { readDatabaseInfo } from "./state-io.mjs";
|
|
|
3
3
|
|
|
4
4
|
const PORT_STRIDE = 100;
|
|
5
5
|
|
|
6
|
-
export function
|
|
7
|
-
const portMap = buildPortMap(runtimeConfigs,
|
|
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(
|
|
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,
|
|
24
|
-
|
|
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,
|
|
34
|
-
|
|
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
|
-
|
|
59
|
+
resolveStackConfig(
|
|
58
60
|
config,
|
|
59
61
|
targetConfig,
|
|
60
|
-
|
|
61
|
-
|
|
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,
|
|
73
|
+
export function buildPortMap(runtimeConfigs, stackId) {
|
|
72
74
|
const portMap = new Map();
|
|
73
75
|
const seen = new Map();
|
|
74
|
-
const offset =
|
|
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
|
-
`
|
|
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
|
|
99
|
+
export function resolveStackConfig(
|
|
98
100
|
config,
|
|
99
101
|
targetConfig,
|
|
100
|
-
|
|
101
|
-
|
|
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(
|
|
110
|
+
const stateDir = resolveServiceStateDir(stackStateDir, targetConfig.name, config);
|
|
109
111
|
const context = {
|
|
110
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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(
|
|
183
|
+
export function resolveServiceStateDir(stackStateDir, targetName, config) {
|
|
182
184
|
const dbSource = config.testkit.databaseFrom || config.name;
|
|
183
|
-
return
|
|
185
|
+
return getStackServiceStateDir(stackStateDir, targetName, dbSource);
|
|
184
186
|
}
|
|
185
187
|
|
|
186
|
-
export function
|
|
188
|
+
export function getStackServiceStateDir(stackStateDir, targetName, serviceName) {
|
|
187
189
|
if (targetName === serviceName) {
|
|
188
|
-
return
|
|
190
|
+
return stackStateDir;
|
|
189
191
|
}
|
|
190
|
-
return path.join(
|
|
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.
|
|
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
|
-
|
|
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,
|
|
224
|
+
export function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, stackId, context) {
|
|
223
225
|
const resolved = resolveTemplateString(rawUrl, {
|
|
224
226
|
...context,
|
|
225
227
|
targetName: targetConfig.name,
|
|
226
|
-
|
|
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 "
|
|
249
|
-
return String(context.
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
"shared"
|
|
52
52
|
)
|
|
53
|
-
).toThrow("
|
|
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
|
-
|
|
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("{
|
|
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
|
|
94
|
-
const
|
|
95
|
-
const api = makeRuntimeConfig(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 =
|
|
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(
|
|
125
|
-
`${
|
|
130
|
+
expect(resolveServiceStateDir(stackStateDir, "frontend", api)).toBe(
|
|
131
|
+
`${stackStateDir}/deps/api`
|
|
126
132
|
);
|
|
127
|
-
expect(
|
|
133
|
+
expect(getStackServiceStateDir(stackStateDir, "frontend", "frontend")).toBe(stackStateDir);
|
|
128
134
|
|
|
129
|
-
fs.mkdirSync(path.join(
|
|
135
|
+
fs.mkdirSync(path.join(stackStateDir, "deps", "api"), { recursive: true });
|
|
130
136
|
fs.writeFileSync(
|
|
131
|
-
path.join(
|
|
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
|
-
|
|
157
|
+
TESTKIT_STACK_ID: "stack-2",
|
|
152
158
|
});
|
|
153
159
|
|
|
154
|
-
expect(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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", () => {
|