@elench/testkit 0.1.37 → 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 +126 -2
- 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/state-io.mjs +23 -0
- package/lib/runner/template.mjs +107 -39
- package/lib/runner/template.test.mjs +68 -41
- package/lib/runner/worker-loop.mjs +17 -14
- package/lib/setup/index.d.ts +25 -1
- 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/state-io.mjs
CHANGED
|
@@ -5,6 +5,29 @@ export function readDatabaseUrl(stateDir) {
|
|
|
5
5
|
return readStateValue(path.join(stateDir, "database_url"));
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
export function readDatabaseInfo(stateDir) {
|
|
9
|
+
return parseDatabaseUrl(readDatabaseUrl(stateDir));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseDatabaseUrl(databaseUrl) {
|
|
13
|
+
if (!databaseUrl) return null;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const parsed = new URL(databaseUrl);
|
|
17
|
+
const port = Number(parsed.port || "5432");
|
|
18
|
+
return {
|
|
19
|
+
url: databaseUrl,
|
|
20
|
+
host: parsed.hostname,
|
|
21
|
+
port: Number.isInteger(port) && port > 0 ? port : 5432,
|
|
22
|
+
database: parsed.pathname.replace(/^\//, ""),
|
|
23
|
+
user: decodeURIComponent(parsed.username || ""),
|
|
24
|
+
password: decodeURIComponent(parsed.password || ""),
|
|
25
|
+
};
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
8
31
|
export function readStateValue(filePath) {
|
|
9
32
|
if (!fs.existsSync(filePath)) return null;
|
|
10
33
|
return fs.readFileSync(filePath, "utf8").trim();
|
package/lib/runner/template.mjs
CHANGED
|
@@ -1,30 +1,43 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
+
import { readDatabaseInfo } from "./state-io.mjs";
|
|
2
3
|
|
|
3
4
|
const PORT_STRIDE = 100;
|
|
4
5
|
|
|
5
|
-
export function
|
|
6
|
-
const portMap = buildPortMap(runtimeConfigs,
|
|
6
|
+
export function resolveStackRuntimeConfigs(targetConfig, runtimeConfigs, stackId, stackStateDir) {
|
|
7
|
+
const portMap = buildPortMap(runtimeConfigs, stackId);
|
|
7
8
|
const baseUrlByService = new Map();
|
|
8
9
|
const readyUrlByService = new Map();
|
|
10
|
+
const stateDirByService = new Map();
|
|
11
|
+
|
|
12
|
+
for (const config of runtimeConfigs) {
|
|
13
|
+
stateDirByService.set(
|
|
14
|
+
config.name,
|
|
15
|
+
resolveServiceStateDir(stackStateDir, targetConfig.name, config)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
9
18
|
|
|
10
19
|
for (const config of runtimeConfigs) {
|
|
11
20
|
if (!config.testkit.local) continue;
|
|
12
21
|
baseUrlByService.set(
|
|
13
22
|
config.name,
|
|
14
|
-
resolveRuntimeUrl(config.testkit.local.baseUrl, config.name, targetConfig,
|
|
15
|
-
|
|
23
|
+
resolveRuntimeUrl(config.testkit.local.baseUrl, config.name, targetConfig, stackId, {
|
|
24
|
+
stackId,
|
|
25
|
+
stackStateDir,
|
|
16
26
|
portMap,
|
|
17
27
|
baseUrlByService,
|
|
18
28
|
readyUrlByService,
|
|
29
|
+
stateDirByService,
|
|
19
30
|
})
|
|
20
31
|
);
|
|
21
32
|
readyUrlByService.set(
|
|
22
33
|
config.name,
|
|
23
|
-
resolveRuntimeUrl(config.testkit.local.readyUrl, config.name, targetConfig,
|
|
24
|
-
|
|
34
|
+
resolveRuntimeUrl(config.testkit.local.readyUrl, config.name, targetConfig, stackId, {
|
|
35
|
+
stackId,
|
|
36
|
+
stackStateDir,
|
|
25
37
|
portMap,
|
|
26
38
|
baseUrlByService,
|
|
27
39
|
readyUrlByService,
|
|
40
|
+
stateDirByService,
|
|
28
41
|
})
|
|
29
42
|
);
|
|
30
43
|
}
|
|
@@ -43,23 +56,24 @@ export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, worker
|
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
return runtimeConfigs.map((config) =>
|
|
46
|
-
|
|
59
|
+
resolveStackConfig(
|
|
47
60
|
config,
|
|
48
61
|
targetConfig,
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
stackId,
|
|
63
|
+
stackStateDir,
|
|
51
64
|
portMap,
|
|
52
65
|
baseUrlByService,
|
|
53
66
|
readyUrlByService,
|
|
67
|
+
stateDirByService,
|
|
54
68
|
urlMappings
|
|
55
69
|
)
|
|
56
70
|
);
|
|
57
71
|
}
|
|
58
72
|
|
|
59
|
-
export function buildPortMap(runtimeConfigs,
|
|
73
|
+
export function buildPortMap(runtimeConfigs, stackId) {
|
|
60
74
|
const portMap = new Map();
|
|
61
75
|
const seen = new Map();
|
|
62
|
-
const offset =
|
|
76
|
+
const offset = stackPortOffset(stackId);
|
|
63
77
|
|
|
64
78
|
for (const config of runtimeConfigs) {
|
|
65
79
|
if (!config.testkit.local) continue;
|
|
@@ -71,7 +85,7 @@ export function buildPortMap(runtimeConfigs, workerId) {
|
|
|
71
85
|
const existing = seen.get(actualPort);
|
|
72
86
|
if (existing) {
|
|
73
87
|
throw new Error(
|
|
74
|
-
`
|
|
88
|
+
`Stack port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
|
|
75
89
|
`Assign distinct local.port/baseUrl ports in testkit.setup.ts.`
|
|
76
90
|
);
|
|
77
91
|
}
|
|
@@ -82,25 +96,27 @@ export function buildPortMap(runtimeConfigs, workerId) {
|
|
|
82
96
|
return portMap;
|
|
83
97
|
}
|
|
84
98
|
|
|
85
|
-
export function
|
|
99
|
+
export function resolveStackConfig(
|
|
86
100
|
config,
|
|
87
101
|
targetConfig,
|
|
88
|
-
|
|
89
|
-
|
|
102
|
+
stackId,
|
|
103
|
+
stackStateDir,
|
|
90
104
|
portMap,
|
|
91
105
|
baseUrlByService,
|
|
92
106
|
readyUrlByService,
|
|
107
|
+
stateDirByService,
|
|
93
108
|
urlMappings
|
|
94
109
|
) {
|
|
95
|
-
const stateDir = resolveServiceStateDir(
|
|
110
|
+
const stateDir = resolveServiceStateDir(stackStateDir, targetConfig.name, config);
|
|
96
111
|
const context = {
|
|
97
|
-
|
|
98
|
-
serviceName: config.name,
|
|
112
|
+
stackId,
|
|
99
113
|
targetName: targetConfig.name,
|
|
114
|
+
serviceName: config.name,
|
|
100
115
|
serviceStateDir: stateDir,
|
|
101
116
|
portMap,
|
|
102
117
|
baseUrlByService,
|
|
103
118
|
readyUrlByService,
|
|
119
|
+
stateDirByService,
|
|
104
120
|
urlMappings,
|
|
105
121
|
};
|
|
106
122
|
|
|
@@ -143,51 +159,48 @@ export function resolveWorkerConfig(
|
|
|
143
159
|
port: portMap.get(config.name) || config.testkit.local.port,
|
|
144
160
|
baseUrl: baseUrlByService.get(config.name),
|
|
145
161
|
readyUrl: readyUrlByService.get(config.name),
|
|
146
|
-
env:
|
|
147
|
-
Object.entries(config.testkit.local.env || {}).map(([key, value]) => [
|
|
148
|
-
key,
|
|
149
|
-
finalizeString(String(value), context),
|
|
150
|
-
])
|
|
151
|
-
),
|
|
162
|
+
env: { ...(config.testkit.local.env || {}) },
|
|
152
163
|
}
|
|
153
164
|
: undefined;
|
|
154
165
|
|
|
155
166
|
return {
|
|
156
167
|
...config,
|
|
157
168
|
stateDir,
|
|
158
|
-
|
|
159
|
-
|
|
169
|
+
stackId,
|
|
170
|
+
stackLabel: stackId,
|
|
160
171
|
targetName: targetConfig.name,
|
|
161
172
|
testkit: {
|
|
162
173
|
...config.testkit,
|
|
163
174
|
database,
|
|
164
175
|
migrate,
|
|
165
176
|
seed,
|
|
177
|
+
templateContext: context,
|
|
166
178
|
local,
|
|
167
179
|
},
|
|
168
180
|
};
|
|
169
181
|
}
|
|
170
182
|
|
|
171
|
-
export function resolveServiceStateDir(
|
|
183
|
+
export function resolveServiceStateDir(stackStateDir, targetName, config) {
|
|
172
184
|
const dbSource = config.testkit.databaseFrom || config.name;
|
|
173
|
-
return
|
|
185
|
+
return getStackServiceStateDir(stackStateDir, targetName, dbSource);
|
|
174
186
|
}
|
|
175
187
|
|
|
176
|
-
export function
|
|
188
|
+
export function getStackServiceStateDir(stackStateDir, targetName, serviceName) {
|
|
177
189
|
if (targetName === serviceName) {
|
|
178
|
-
return
|
|
190
|
+
return stackStateDir;
|
|
179
191
|
}
|
|
180
|
-
return path.join(
|
|
192
|
+
return path.join(stackStateDir, "deps", serviceName);
|
|
181
193
|
}
|
|
182
194
|
|
|
183
195
|
export function buildExecutionEnv(config, extraEnv = {}, processEnv = process.env) {
|
|
184
196
|
const inheritedEnv = { ...processEnv };
|
|
197
|
+
const templateContext = config.testkit?.templateContext;
|
|
185
198
|
const env = {
|
|
186
199
|
...inheritedEnv,
|
|
187
|
-
...(config.testkit.serviceEnv || {}),
|
|
188
|
-
...extraEnv,
|
|
200
|
+
...resolveEnvTemplates(config.testkit.serviceEnv || {}, templateContext),
|
|
201
|
+
...resolveEnvTemplates(extraEnv, templateContext),
|
|
189
202
|
TESTKIT_ACTIVE: "1",
|
|
190
|
-
...(config.
|
|
203
|
+
...(config.stackId ? { TESTKIT_STACK_ID: String(config.stackId) } : {}),
|
|
191
204
|
};
|
|
192
205
|
delete env.DATABASE_URL;
|
|
193
206
|
return env;
|
|
@@ -202,17 +215,17 @@ export function buildPlaywrightEnv(config, baseUrl, processEnv = process.env) {
|
|
|
202
215
|
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS:
|
|
203
216
|
processEnv.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS || "1",
|
|
204
217
|
TESTKIT_MANAGED_SERVERS: "1",
|
|
205
|
-
|
|
218
|
+
TESTKIT_STACK_ID: String(config.stackId),
|
|
206
219
|
},
|
|
207
220
|
processEnv
|
|
208
221
|
);
|
|
209
222
|
}
|
|
210
223
|
|
|
211
|
-
export function resolveRuntimeUrl(rawUrl, serviceName, targetConfig,
|
|
224
|
+
export function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, stackId, context) {
|
|
212
225
|
const resolved = resolveTemplateString(rawUrl, {
|
|
213
226
|
...context,
|
|
214
227
|
targetName: targetConfig.name,
|
|
215
|
-
|
|
228
|
+
stackId,
|
|
216
229
|
serviceName,
|
|
217
230
|
});
|
|
218
231
|
const actualPort = context.portMap.get(serviceName);
|
|
@@ -234,8 +247,8 @@ export function resolveTemplateString(value, context) {
|
|
|
234
247
|
|
|
235
248
|
return value.replace(/\{([a-zA-Z]+)(?::([a-zA-Z0-9_-]+))?\}/g, (_match, token, arg) => {
|
|
236
249
|
switch (token) {
|
|
237
|
-
case "
|
|
238
|
-
return String(context.
|
|
250
|
+
case "stack":
|
|
251
|
+
return String(context.stackId);
|
|
239
252
|
case "target":
|
|
240
253
|
return context.targetName;
|
|
241
254
|
case "service":
|
|
@@ -266,12 +279,61 @@ export function resolveTemplateString(value, context) {
|
|
|
266
279
|
}
|
|
267
280
|
return readyUrl;
|
|
268
281
|
}
|
|
282
|
+
case "dbUrl":
|
|
283
|
+
case "dbHost":
|
|
284
|
+
case "dbPort":
|
|
285
|
+
case "dbName":
|
|
286
|
+
case "dbUser":
|
|
287
|
+
case "dbPassword": {
|
|
288
|
+
const serviceName = arg || context.serviceName;
|
|
289
|
+
return resolveDatabaseTemplateValue(token, serviceName, context);
|
|
290
|
+
}
|
|
269
291
|
default:
|
|
270
292
|
throw new Error(`Unsupported template token "{${token}${arg ? `:${arg}` : ""}}"`);
|
|
271
293
|
}
|
|
272
294
|
});
|
|
273
295
|
}
|
|
274
296
|
|
|
297
|
+
function resolveEnvTemplates(values, templateContext) {
|
|
298
|
+
return Object.fromEntries(
|
|
299
|
+
Object.entries(values || {}).map(([key, value]) => [
|
|
300
|
+
key,
|
|
301
|
+
typeof value === "string" && templateContext ? finalizeString(value, templateContext) : value,
|
|
302
|
+
])
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function resolveDatabaseTemplateValue(token, serviceName, context) {
|
|
307
|
+
const stateDir = context.stateDirByService?.get(serviceName);
|
|
308
|
+
if (!stateDir) {
|
|
309
|
+
throw new Error(`Unknown database placeholder for service "${serviceName}"`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const info = readDatabaseInfo(stateDir);
|
|
313
|
+
if (!info) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`Database placeholder "{${token}:${serviceName}}" is unavailable before "${serviceName}" database preparation`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
switch (token) {
|
|
320
|
+
case "dbUrl":
|
|
321
|
+
return info.url;
|
|
322
|
+
case "dbHost":
|
|
323
|
+
return info.host;
|
|
324
|
+
case "dbPort":
|
|
325
|
+
return String(info.port);
|
|
326
|
+
case "dbName":
|
|
327
|
+
return info.database;
|
|
328
|
+
case "dbUser":
|
|
329
|
+
return info.user;
|
|
330
|
+
case "dbPassword":
|
|
331
|
+
return info.password;
|
|
332
|
+
default:
|
|
333
|
+
throw new Error(`Unsupported database placeholder "{${token}:${serviceName}}"`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
275
337
|
export function rewriteUrlPort(rawUrl, port) {
|
|
276
338
|
try {
|
|
277
339
|
const original = new URL(rawUrl);
|
|
@@ -318,3 +380,9 @@ export function normalizeSocketHost(hostname) {
|
|
|
318
380
|
if (hostname === "[::1]") return "::1";
|
|
319
381
|
return hostname;
|
|
320
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
|
+
}
|