@elench/testkit 0.1.48 → 0.1.49
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 +14 -0
- package/lib/config/index.mjs +55 -0
- package/lib/database/template-steps.mjs +35 -193
- package/lib/runner/runtime-contexts.mjs +2 -0
- package/lib/runner/runtime-preparation.mjs +107 -0
- package/lib/runner/runtime-preparation.test.mjs +141 -0
- package/lib/runner/template-steps.mjs +191 -0
- package/lib/runner/template.mjs +41 -0
- package/lib/runner/template.test.mjs +64 -0
- package/lib/setup/index.d.ts +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -119,7 +119,9 @@ export default defineTestkitSetup({
|
|
|
119
119
|
...nextService({
|
|
120
120
|
cwd: "frontend",
|
|
121
121
|
port: 3000,
|
|
122
|
+
start: "exec ./node_modules/.bin/next start --port {port}",
|
|
122
123
|
env: {
|
|
124
|
+
NEXT_DIST_DIR: "{prepareDir}/dist",
|
|
123
125
|
NEXT_PUBLIC_API_URL: "{baseUrl:api}",
|
|
124
126
|
},
|
|
125
127
|
}),
|
|
@@ -127,6 +129,11 @@ export default defineTestkitSetup({
|
|
|
127
129
|
envFiles: ["frontend/.env.testkit"],
|
|
128
130
|
runtime: {
|
|
129
131
|
instances: 1,
|
|
132
|
+
maxConcurrentTasks: 2,
|
|
133
|
+
prepare: {
|
|
134
|
+
inputs: ["frontend/src", "frontend/public", "frontend/package.json"],
|
|
135
|
+
steps: [commandStep("npm run build", { cwd: "frontend" })],
|
|
136
|
+
},
|
|
130
137
|
},
|
|
131
138
|
}),
|
|
132
139
|
billing: service({
|
|
@@ -157,6 +164,7 @@ for:
|
|
|
157
164
|
- multi-service graphs
|
|
158
165
|
- local runtime instance counts
|
|
159
166
|
- per-runtime concurrent task caps
|
|
167
|
+
- one-time runtime preparation steps for stable shared servers
|
|
160
168
|
- local DB binding configuration
|
|
161
169
|
- template database migrate / seed / verify stages
|
|
162
170
|
- template schema snapshot capture
|
|
@@ -167,6 +175,12 @@ for:
|
|
|
167
175
|
- repo-declared suite/file skip policies with explicit reasons
|
|
168
176
|
- telemetry upload configuration
|
|
169
177
|
|
|
178
|
+
`runtime.prepare` is the generic build-once hook for shared runtimes. It runs
|
|
179
|
+
once per runtime generation before local services start, fingerprints declared
|
|
180
|
+
inputs, and writes cache state under the service runtime directory. This is the
|
|
181
|
+
right way to move expensive browser targets from `next dev` / watch mode to
|
|
182
|
+
stable build-and-start flows.
|
|
183
|
+
|
|
170
184
|
If `reporting.knownFailuresFile` is configured, `testkit` enriches
|
|
171
185
|
`.testkit/results/latest.json` and `testkit.status.json` with:
|
|
172
186
|
|
package/lib/config/index.mjs
CHANGED
|
@@ -279,6 +279,10 @@ function normalizeRuntimeConfig(value, serviceName) {
|
|
|
279
279
|
return {
|
|
280
280
|
instances: 1,
|
|
281
281
|
maxConcurrentTasks: Number.POSITIVE_INFINITY,
|
|
282
|
+
prepare: {
|
|
283
|
+
inputs: [],
|
|
284
|
+
steps: [],
|
|
285
|
+
},
|
|
282
286
|
};
|
|
283
287
|
}
|
|
284
288
|
|
|
@@ -291,6 +295,27 @@ function normalizeRuntimeConfig(value, serviceName) {
|
|
|
291
295
|
value.maxConcurrentTasks,
|
|
292
296
|
`Service "${serviceName}" runtime.maxConcurrentTasks`
|
|
293
297
|
),
|
|
298
|
+
prepare: normalizeRuntimePrepareConfig(value.prepare, serviceName),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function normalizeRuntimePrepareConfig(value, serviceName) {
|
|
303
|
+
if (value == null) {
|
|
304
|
+
return {
|
|
305
|
+
inputs: [],
|
|
306
|
+
steps: [],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
if (!value || typeof value !== "object") {
|
|
310
|
+
throw new Error(`Service "${serviceName}" runtime.prepare must be an object`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
inputs: normalizeTemplateInputs(value.inputs, `Service "${serviceName}" runtime.prepare`),
|
|
315
|
+
steps: normalizeTemplateLifecycleSteps(
|
|
316
|
+
value.steps,
|
|
317
|
+
`Service "${serviceName}" runtime.prepare.steps`
|
|
318
|
+
),
|
|
294
319
|
};
|
|
295
320
|
}
|
|
296
321
|
|
|
@@ -751,6 +776,36 @@ function validateServiceConfig({
|
|
|
751
776
|
for (const input of database?.template?.inputs || []) {
|
|
752
777
|
ensureExistingPath(productDir, input, `Service "${name}" database.template input`);
|
|
753
778
|
}
|
|
779
|
+
for (const step of runtime.prepare?.steps || []) {
|
|
780
|
+
if (step.cwd) {
|
|
781
|
+
ensureExistingPath(productDir, step.cwd, `Service "${name}" runtime.prepare step cwd`);
|
|
782
|
+
}
|
|
783
|
+
if (step.kind === "sql-file") {
|
|
784
|
+
ensureExistingPath(
|
|
785
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
786
|
+
step.path,
|
|
787
|
+
`Service "${name}" runtime.prepare sql file`
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
if (step.kind === "module") {
|
|
791
|
+
const { modulePath } = parseModuleSpecifier(step.specifier);
|
|
792
|
+
ensureExistingPath(
|
|
793
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
794
|
+
modulePath,
|
|
795
|
+
`Service "${name}" runtime.prepare module`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
for (const input of step.inputs || []) {
|
|
799
|
+
ensureExistingPath(
|
|
800
|
+
resolveServiceCwd(productDir, step.cwd || "."),
|
|
801
|
+
input,
|
|
802
|
+
`Service "${name}" runtime.prepare step input`
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
for (const input of runtime.prepare?.inputs || []) {
|
|
807
|
+
ensureExistingPath(productDir, input, `Service "${name}" runtime.prepare input`);
|
|
808
|
+
}
|
|
754
809
|
}
|
|
755
810
|
|
|
756
811
|
function ensureExistingPath(productDir, relativePath, label) {
|
|
@@ -1,17 +1,11 @@
|
|
|
1
|
-
import crypto from "crypto";
|
|
2
1
|
import fs from "fs";
|
|
3
2
|
import path from "path";
|
|
4
|
-
import {
|
|
5
|
-
import { execa, execaCommand } from "execa";
|
|
6
|
-
import { fileURLToPath, pathToFileURL } from "url";
|
|
7
|
-
import { resolveServiceCwd } from "../config/index.mjs";
|
|
3
|
+
import { execa } from "execa";
|
|
8
4
|
import { buildExecutionEnv } from "../runner/template.mjs";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
|
|
14
|
-
const KNOWN_FAILURES_ENTRY = path.join(PACKAGE_ROOT, "lib", "known-failures", "index.mjs");
|
|
5
|
+
import {
|
|
6
|
+
collectConfiguredInputs,
|
|
7
|
+
runConfiguredSteps,
|
|
8
|
+
} from "../runner/template-steps.mjs";
|
|
15
9
|
|
|
16
10
|
export async function runTemplateStage(config, stageName, databaseUrl) {
|
|
17
11
|
const steps = config.testkit.database?.template?.[stageName] || [];
|
|
@@ -22,32 +16,20 @@ export async function runTemplateStage(config, stageName, databaseUrl) {
|
|
|
22
16
|
DATABASE_URL: databaseUrl,
|
|
23
17
|
};
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
await runConfiguredSteps({
|
|
20
|
+
config,
|
|
21
|
+
steps,
|
|
22
|
+
env,
|
|
23
|
+
labelPrefix: `template:${stageName}`,
|
|
24
|
+
});
|
|
30
25
|
}
|
|
31
26
|
|
|
32
27
|
export function collectTemplateInputs(productDir, template = {}) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
inputs.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
for (const step of template[stageName] || []) {
|
|
39
|
-
if (step.kind === "sql-file") {
|
|
40
|
-
inputs.add(resolveTemplatePath(productDir, step.cwd, step.path));
|
|
41
|
-
}
|
|
42
|
-
if (step.kind === "module") {
|
|
43
|
-
inputs.add(resolveTemplatePath(productDir, step.cwd, parseModuleSpecifier(step.specifier).modulePath));
|
|
44
|
-
}
|
|
45
|
-
for (const input of step.inputs || []) {
|
|
46
|
-
inputs.add(resolveTemplatePath(productDir, step.cwd, input));
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return [...inputs].sort();
|
|
28
|
+
const steps = ["migrate", "seed", "verify"].flatMap((stageName) => template[stageName] || []);
|
|
29
|
+
return collectConfiguredInputs(productDir, {
|
|
30
|
+
inputs: template.inputs || [],
|
|
31
|
+
steps,
|
|
32
|
+
});
|
|
51
33
|
}
|
|
52
34
|
|
|
53
35
|
export async function captureTemplateSnapshot(config, outputPath, databaseUrl) {
|
|
@@ -55,21 +37,25 @@ export async function captureTemplateSnapshot(config, outputPath, databaseUrl) {
|
|
|
55
37
|
const absoluteOutputPath = path.resolve(config.productDir, outputPath);
|
|
56
38
|
fs.mkdirSync(path.dirname(absoluteOutputPath), { recursive: true });
|
|
57
39
|
|
|
58
|
-
await execa(
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
40
|
+
await execa(
|
|
41
|
+
"pg_dump",
|
|
42
|
+
[
|
|
43
|
+
"--schema-only",
|
|
44
|
+
"--no-owner",
|
|
45
|
+
"--no-privileges",
|
|
46
|
+
"--file",
|
|
47
|
+
absoluteOutputPath,
|
|
48
|
+
templateDbUrl,
|
|
49
|
+
],
|
|
50
|
+
{
|
|
51
|
+
cwd: config.productDir,
|
|
52
|
+
env: {
|
|
53
|
+
...buildExecutionEnv(config, {}, process.env),
|
|
54
|
+
DATABASE_URL: templateDbUrl,
|
|
55
|
+
},
|
|
56
|
+
stdio: "inherit",
|
|
57
|
+
}
|
|
58
|
+
);
|
|
73
59
|
|
|
74
60
|
sanitizeSnapshotFile(absoluteOutputPath);
|
|
75
61
|
return absoluteOutputPath;
|
|
@@ -86,147 +72,3 @@ function sanitizeSnapshotFile(filePath) {
|
|
|
86
72
|
fs.writeFileSync(filePath, sanitized);
|
|
87
73
|
}
|
|
88
74
|
}
|
|
89
|
-
|
|
90
|
-
async function runTemplateStep(config, stageName, step, env) {
|
|
91
|
-
if (step.kind === "command") {
|
|
92
|
-
await execaCommand(step.cmd, {
|
|
93
|
-
cwd: resolveTemplateCwd(config.productDir, step.cwd),
|
|
94
|
-
env,
|
|
95
|
-
stdio: "inherit",
|
|
96
|
-
shell: true,
|
|
97
|
-
});
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (step.kind === "sql-file") {
|
|
102
|
-
await execa("psql", [
|
|
103
|
-
env.DATABASE_URL,
|
|
104
|
-
"-v",
|
|
105
|
-
"ON_ERROR_STOP=1",
|
|
106
|
-
"-X",
|
|
107
|
-
"-f",
|
|
108
|
-
resolveTemplatePath(config.productDir, step.cwd, step.path),
|
|
109
|
-
], {
|
|
110
|
-
cwd: resolveTemplateCwd(config.productDir, step.cwd),
|
|
111
|
-
env,
|
|
112
|
-
stdio: "inherit",
|
|
113
|
-
});
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (step.kind === "module") {
|
|
118
|
-
const moduleRef = await loadTemplateModule(config.productDir, step);
|
|
119
|
-
const { exportName } = parseModuleSpecifier(step.specifier);
|
|
120
|
-
const fn = moduleRef[exportName];
|
|
121
|
-
if (typeof fn !== "function") {
|
|
122
|
-
throw new Error(
|
|
123
|
-
`Template module step "${step.specifier}" did not export a function named "${exportName}"`
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
await withProcessContext(
|
|
128
|
-
resolveTemplateCwd(config.productDir, step.cwd),
|
|
129
|
-
env,
|
|
130
|
-
async () => {
|
|
131
|
-
await fn({
|
|
132
|
-
productDir: config.productDir,
|
|
133
|
-
cwd: resolveTemplateCwd(config.productDir, step.cwd),
|
|
134
|
-
serviceName: config.name,
|
|
135
|
-
stage: stageName,
|
|
136
|
-
databaseUrl: env.DATABASE_URL,
|
|
137
|
-
env: { ...env },
|
|
138
|
-
runtimeId: config.runtimeId || null,
|
|
139
|
-
stateDir: config.stateDir,
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
);
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
throw new Error(`Unsupported template step kind "${step.kind}"`);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function resolveTemplateCwd(productDir, stepCwd) {
|
|
150
|
-
return resolveServiceCwd(productDir, stepCwd || ".");
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function resolveTemplatePath(productDir, stepCwd, targetPath) {
|
|
154
|
-
return path.resolve(resolveTemplateCwd(productDir, stepCwd), targetPath);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function loadTemplateModule(productDir, step) {
|
|
158
|
-
const { modulePath } = parseModuleSpecifier(step.specifier);
|
|
159
|
-
const absoluteModulePath = resolveTemplatePath(productDir, step.cwd, modulePath);
|
|
160
|
-
const bundleDir = path.join(productDir, ".testkit", "_template-steps");
|
|
161
|
-
fs.mkdirSync(bundleDir, { recursive: true });
|
|
162
|
-
|
|
163
|
-
const cacheKey = buildModuleCacheKey(absoluteModulePath);
|
|
164
|
-
const outputFile = path.join(bundleDir, `${path.basename(modulePath).replace(/\W+/g, "-")}-${cacheKey.slice(0, 12)}.mjs`);
|
|
165
|
-
|
|
166
|
-
await build({
|
|
167
|
-
absWorkingDir: productDir,
|
|
168
|
-
bundle: true,
|
|
169
|
-
entryPoints: [absoluteModulePath],
|
|
170
|
-
format: "esm",
|
|
171
|
-
legalComments: "none",
|
|
172
|
-
outfile: outputFile,
|
|
173
|
-
platform: "node",
|
|
174
|
-
sourcemap: "inline",
|
|
175
|
-
target: "es2020",
|
|
176
|
-
plugins: [testkitAliasPlugin()],
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
return import(`${pathToFileURL(outputFile).href}?v=${cacheKey}`);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function buildModuleCacheKey(modulePath) {
|
|
183
|
-
const content = fs.readFileSync(modulePath, "utf8");
|
|
184
|
-
return crypto.createHash("sha256").update(modulePath).update("\0").update(content).digest("hex");
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function testkitAliasPlugin() {
|
|
188
|
-
return {
|
|
189
|
-
name: "testkit-template-step-alias",
|
|
190
|
-
setup(buildApi) {
|
|
191
|
-
buildApi.onResolve({ filter: /^@elench\/testkit(?:\/.*)?$/ }, (args) => ({
|
|
192
|
-
namespace: "file",
|
|
193
|
-
path: resolvePackageSubpath(args.path),
|
|
194
|
-
}));
|
|
195
|
-
},
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function resolvePackageSubpath(specifier) {
|
|
200
|
-
const subpath = specifier.slice("@elench/testkit".length);
|
|
201
|
-
if (!subpath) return ROOT_ENTRY;
|
|
202
|
-
if (subpath === "/setup") return SETUP_ENTRY;
|
|
203
|
-
if (subpath === "/runtime") return RUNTIME_ENTRY;
|
|
204
|
-
if (subpath === "/known-failures") return KNOWN_FAILURES_ENTRY;
|
|
205
|
-
|
|
206
|
-
throw new Error(`Unsupported @elench/testkit import "${specifier}" while loading template step`);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function parseModuleSpecifier(specifier) {
|
|
210
|
-
const [modulePath, exportName] = String(specifier).split("#", 2);
|
|
211
|
-
return {
|
|
212
|
-
modulePath,
|
|
213
|
-
exportName: exportName || "default",
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
async function withProcessContext(cwd, env, fn) {
|
|
218
|
-
const previousCwd = process.cwd();
|
|
219
|
-
const previousEnv = process.env;
|
|
220
|
-
process.chdir(cwd);
|
|
221
|
-
process.env = {
|
|
222
|
-
...previousEnv,
|
|
223
|
-
...env,
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
try {
|
|
227
|
-
return await fn();
|
|
228
|
-
} finally {
|
|
229
|
-
process.chdir(previousCwd);
|
|
230
|
-
process.env = previousEnv;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
@@ -2,6 +2,7 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { prepareDatabaseRuntime } from "../database/index.mjs";
|
|
4
4
|
import { taskNeedsLocalRuntime } from "./planning.mjs";
|
|
5
|
+
import { prepareRuntimeServices } from "./runtime-preparation.mjs";
|
|
5
6
|
import { writeGraphMetadata } from "./state.mjs";
|
|
6
7
|
import { startLocalServices, stopLocalServices } from "./services.mjs";
|
|
7
8
|
import { resolveRuntimeInstanceConfigs } from "./template.mjs";
|
|
@@ -39,6 +40,7 @@ export async function ensureRuntimeInstanceReady(context, task, lifecycle) {
|
|
|
39
40
|
if (!context.preparationPromise) {
|
|
40
41
|
context.preparationPromise = (async () => {
|
|
41
42
|
await prepareDatabases(context.runtimeConfigs);
|
|
43
|
+
await prepareRuntimeServices(context.runtimeConfigs);
|
|
42
44
|
context.prepared = true;
|
|
43
45
|
})().finally(() => {
|
|
44
46
|
context.preparationPromise = null;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { resolveServiceCwd } from "../config/index.mjs";
|
|
5
|
+
import { appendFileToHash, appendInputToHash } from "../database/fingerprint.mjs";
|
|
6
|
+
import { readDatabaseUrl } from "./state-io.mjs";
|
|
7
|
+
import { buildExecutionEnv } from "./template.mjs";
|
|
8
|
+
import {
|
|
9
|
+
collectConfiguredInputs,
|
|
10
|
+
runConfiguredSteps,
|
|
11
|
+
} from "./template-steps.mjs";
|
|
12
|
+
|
|
13
|
+
const MANIFEST_FILE = "prepare-manifest.json";
|
|
14
|
+
|
|
15
|
+
export async function prepareRuntimeServices(runtimeConfigs) {
|
|
16
|
+
for (const config of runtimeConfigs) {
|
|
17
|
+
await prepareRuntimeService(config);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function prepareRuntimeService(config) {
|
|
22
|
+
const prepare = config.testkit.runtime.prepare;
|
|
23
|
+
if (!prepare || prepare.steps.length === 0) return;
|
|
24
|
+
|
|
25
|
+
const prepareDir = config.testkit.prepareDir;
|
|
26
|
+
const fingerprint = await computeRuntimePrepareFingerprint(config);
|
|
27
|
+
const manifestPath = path.join(prepareDir, MANIFEST_FILE);
|
|
28
|
+
const existingManifest = readPrepareManifest(manifestPath);
|
|
29
|
+
if (existingManifest?.fingerprint === fingerprint) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fs.rmSync(prepareDir, { recursive: true, force: true });
|
|
34
|
+
fs.mkdirSync(prepareDir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
const env = {
|
|
37
|
+
...buildExecutionEnv(config, config.testkit.local?.env || {}, process.env),
|
|
38
|
+
};
|
|
39
|
+
const databaseUrl = readDatabaseUrl(config.stateDir);
|
|
40
|
+
if (databaseUrl) {
|
|
41
|
+
env.DATABASE_URL = databaseUrl;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await runConfiguredSteps({
|
|
46
|
+
config,
|
|
47
|
+
steps: prepare.steps,
|
|
48
|
+
env,
|
|
49
|
+
labelPrefix: "runtime:prepare",
|
|
50
|
+
});
|
|
51
|
+
writePrepareManifest(manifestPath, {
|
|
52
|
+
fingerprint,
|
|
53
|
+
preparedAt: new Date().toISOString(),
|
|
54
|
+
runtimeId: config.runtimeId || null,
|
|
55
|
+
serviceName: config.name,
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
fs.rmSync(prepareDir, { recursive: true, force: true });
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function computeRuntimePrepareFingerprint(config) {
|
|
64
|
+
const hash = crypto.createHash("sha256");
|
|
65
|
+
hash.update(
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
prepare: config.testkit.runtime.prepare || null,
|
|
68
|
+
serviceEnv: config.testkit.serviceEnv || {},
|
|
69
|
+
local: config.testkit.local
|
|
70
|
+
? {
|
|
71
|
+
baseUrl: config.testkit.local.baseUrl,
|
|
72
|
+
cwd: config.testkit.local.cwd,
|
|
73
|
+
env: config.testkit.local.env || {},
|
|
74
|
+
readyUrl: config.testkit.local.readyUrl,
|
|
75
|
+
start: config.testkit.local.start,
|
|
76
|
+
}
|
|
77
|
+
: null,
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
for (const envFile of config.testkit.envFiles || []) {
|
|
82
|
+
appendFileToHash(hash, config.productDir, resolveServiceCwd(config.productDir, envFile));
|
|
83
|
+
}
|
|
84
|
+
for (const input of collectConfiguredInputs(config.productDir, config.testkit.runtime.prepare)) {
|
|
85
|
+
appendResolvedInputToHash(hash, config.productDir, input);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return hash.digest("hex");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function appendResolvedInputToHash(hash, productDir, absPath) {
|
|
92
|
+
const relative = path.relative(productDir, absPath);
|
|
93
|
+
appendInputToHash(hash, productDir, relative);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readPrepareManifest(manifestPath) {
|
|
97
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writePrepareManifest(manifestPath, manifest) {
|
|
106
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
107
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
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 { prepareRuntimeService } from "./runtime-preparation.mjs";
|
|
6
|
+
import { resolveRuntimeInstanceConfigs } from "./template.mjs";
|
|
7
|
+
|
|
8
|
+
const tempDirs = [];
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
while (tempDirs.length > 0) {
|
|
12
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function makeTempDir(prefix) {
|
|
17
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
18
|
+
tempDirs.push(dir);
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeFile(filePath, contents) {
|
|
23
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
24
|
+
fs.writeFileSync(filePath, contents);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeResolvedRuntimeConfig(productDir, prepareConfig) {
|
|
28
|
+
const rawConfig = {
|
|
29
|
+
name: "api",
|
|
30
|
+
productDir,
|
|
31
|
+
testkit: {
|
|
32
|
+
envFiles: [],
|
|
33
|
+
serviceEnv: {},
|
|
34
|
+
runtime: {
|
|
35
|
+
instances: 1,
|
|
36
|
+
maxConcurrentTasks: 1,
|
|
37
|
+
prepare: prepareConfig,
|
|
38
|
+
},
|
|
39
|
+
local: {
|
|
40
|
+
cwd: ".",
|
|
41
|
+
start: "node {prepareDir}/server.mjs",
|
|
42
|
+
port: 3010,
|
|
43
|
+
baseUrl: "http://127.0.0.1:{port}",
|
|
44
|
+
readyUrl: "http://127.0.0.1:{port}/health",
|
|
45
|
+
env: {},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const runtimeDir = path.join(productDir, ".testkit", "_graphs", "api", "runtimes", "runtime-1");
|
|
51
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
52
|
+
return resolveRuntimeInstanceConfigs([rawConfig], "runtime-1", runtimeDir, {
|
|
53
|
+
graphDirName: "api",
|
|
54
|
+
portNamespaceIndex: 0,
|
|
55
|
+
portNamespaceStride: 1,
|
|
56
|
+
})[0];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("runtime preparation", () => {
|
|
60
|
+
it("runs prepare steps once and reuses the manifest until inputs change", async () => {
|
|
61
|
+
const productDir = makeTempDir("testkit-runtime-prepare-");
|
|
62
|
+
writeFile(path.join(productDir, "src", "message.txt"), "hello one\n");
|
|
63
|
+
writeFile(
|
|
64
|
+
path.join(productDir, "scripts", "prepare.mjs"),
|
|
65
|
+
[
|
|
66
|
+
'import fs from "fs";',
|
|
67
|
+
'import path from "path";',
|
|
68
|
+
'const prepareDir = process.argv[2];',
|
|
69
|
+
'const productDir = process.cwd();',
|
|
70
|
+
'const message = fs.readFileSync(path.join(productDir, "src", "message.txt"), "utf8").trim();',
|
|
71
|
+
'const counterDir = path.join(productDir, ".runtime", "prepared");',
|
|
72
|
+
'const counterFile = path.join(counterDir, "build-count.txt");',
|
|
73
|
+
'const current = fs.existsSync(counterFile) ? Number.parseInt(fs.readFileSync(counterFile, "utf8"), 10) || 0 : 0;',
|
|
74
|
+
'fs.mkdirSync(counterDir, { recursive: true });',
|
|
75
|
+
'fs.mkdirSync(prepareDir, { recursive: true });',
|
|
76
|
+
'fs.writeFileSync(counterFile, `${current + 1}\\n`);',
|
|
77
|
+
'fs.writeFileSync(path.join(prepareDir, "message.txt"), `${message}\\n`);',
|
|
78
|
+
].join("\n")
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const config = makeResolvedRuntimeConfig(productDir, {
|
|
82
|
+
inputs: ["src/message.txt"],
|
|
83
|
+
steps: [
|
|
84
|
+
{
|
|
85
|
+
kind: "command",
|
|
86
|
+
cmd: "node scripts/prepare.mjs {prepareDir}",
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await prepareRuntimeService(config);
|
|
92
|
+
expect(
|
|
93
|
+
fs.readFileSync(path.join(productDir, ".runtime", "prepared", "build-count.txt"), "utf8").trim()
|
|
94
|
+
).toBe("1");
|
|
95
|
+
expect(fs.readFileSync(path.join(config.testkit.prepareDir, "message.txt"), "utf8").trim()).toBe(
|
|
96
|
+
"hello one"
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await prepareRuntimeService(config);
|
|
100
|
+
expect(
|
|
101
|
+
fs.readFileSync(path.join(productDir, ".runtime", "prepared", "build-count.txt"), "utf8").trim()
|
|
102
|
+
).toBe("1");
|
|
103
|
+
|
|
104
|
+
fs.writeFileSync(path.join(productDir, "src", "message.txt"), "hello two\n");
|
|
105
|
+
await prepareRuntimeService(config);
|
|
106
|
+
expect(
|
|
107
|
+
fs.readFileSync(path.join(productDir, ".runtime", "prepared", "build-count.txt"), "utf8").trim()
|
|
108
|
+
).toBe("2");
|
|
109
|
+
expect(fs.readFileSync(path.join(config.testkit.prepareDir, "message.txt"), "utf8").trim()).toBe(
|
|
110
|
+
"hello two"
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("cleans incomplete prepared output when a prepare step fails", async () => {
|
|
115
|
+
const productDir = makeTempDir("testkit-runtime-prepare-fail-");
|
|
116
|
+
writeFile(
|
|
117
|
+
path.join(productDir, "scripts", "prepare-fail.mjs"),
|
|
118
|
+
[
|
|
119
|
+
'import fs from "fs";',
|
|
120
|
+
'import path from "path";',
|
|
121
|
+
'const prepareDir = process.argv[2];',
|
|
122
|
+
'fs.mkdirSync(prepareDir, { recursive: true });',
|
|
123
|
+
'fs.writeFileSync(path.join(prepareDir, "partial.txt"), "partial\\n");',
|
|
124
|
+
'throw new Error("prepare failed");',
|
|
125
|
+
].join("\n")
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const config = makeResolvedRuntimeConfig(productDir, {
|
|
129
|
+
steps: [
|
|
130
|
+
{
|
|
131
|
+
kind: "command",
|
|
132
|
+
cmd: "node scripts/prepare-fail.mjs {prepareDir}",
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await expect(prepareRuntimeService(config)).rejects.toThrow("Command failed with exit code 1");
|
|
138
|
+
expect(fs.existsSync(config.testkit.prepareDir)).toBe(false);
|
|
139
|
+
expect(fs.existsSync(path.join(productDir, ".testkit", "_graphs", "api", "runtimes", "runtime-1", "services", "api", "prepared", "prepare-manifest.json"))).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { build } from "esbuild";
|
|
5
|
+
import { execa, execaCommand } from "execa";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
7
|
+
import { resolveServiceCwd } from "../config/index.mjs";
|
|
8
|
+
|
|
9
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
10
|
+
const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
|
|
11
|
+
const SETUP_ENTRY = path.join(PACKAGE_ROOT, "lib", "setup", "index.mjs");
|
|
12
|
+
const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
|
|
13
|
+
const KNOWN_FAILURES_ENTRY = path.join(PACKAGE_ROOT, "lib", "known-failures", "index.mjs");
|
|
14
|
+
|
|
15
|
+
export async function runConfiguredSteps({ config, steps = [], env, labelPrefix }) {
|
|
16
|
+
if (steps.length === 0) return;
|
|
17
|
+
|
|
18
|
+
for (const [index, step] of steps.entries()) {
|
|
19
|
+
const label = `${labelPrefix}:${config.name}:${index + 1}`;
|
|
20
|
+
console.log(`\n── ${label} ──`);
|
|
21
|
+
await runConfiguredStep(config, step, env);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function collectConfiguredInputs(productDir, { inputs = [], steps = [] } = {}) {
|
|
26
|
+
const collected = new Set();
|
|
27
|
+
for (const input of inputs) {
|
|
28
|
+
collected.add(resolveConfiguredPath(productDir, null, input));
|
|
29
|
+
}
|
|
30
|
+
for (const step of steps) {
|
|
31
|
+
if (step.kind === "sql-file") {
|
|
32
|
+
collected.add(resolveConfiguredPath(productDir, step.cwd, step.path));
|
|
33
|
+
}
|
|
34
|
+
if (step.kind === "module") {
|
|
35
|
+
collected.add(
|
|
36
|
+
resolveConfiguredPath(productDir, step.cwd, parseModuleSpecifier(step.specifier).modulePath)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
for (const input of step.inputs || []) {
|
|
40
|
+
collected.add(resolveConfiguredPath(productDir, step.cwd, input));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return [...collected].sort();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveConfiguredCwd(productDir, stepCwd) {
|
|
47
|
+
return resolveServiceCwd(productDir, stepCwd || ".");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveConfiguredPath(productDir, stepCwd, targetPath) {
|
|
51
|
+
return path.resolve(resolveConfiguredCwd(productDir, stepCwd), targetPath);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function runConfiguredStep(config, step, env) {
|
|
55
|
+
if (step.kind === "command") {
|
|
56
|
+
await execaCommand(step.cmd, {
|
|
57
|
+
cwd: resolveConfiguredCwd(config.productDir, step.cwd),
|
|
58
|
+
env,
|
|
59
|
+
stdio: "inherit",
|
|
60
|
+
shell: true,
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (step.kind === "sql-file") {
|
|
66
|
+
await execa(
|
|
67
|
+
"psql",
|
|
68
|
+
[
|
|
69
|
+
env.DATABASE_URL,
|
|
70
|
+
"-v",
|
|
71
|
+
"ON_ERROR_STOP=1",
|
|
72
|
+
"-X",
|
|
73
|
+
"-f",
|
|
74
|
+
resolveConfiguredPath(config.productDir, step.cwd, step.path),
|
|
75
|
+
],
|
|
76
|
+
{
|
|
77
|
+
cwd: resolveConfiguredCwd(config.productDir, step.cwd),
|
|
78
|
+
env,
|
|
79
|
+
stdio: "inherit",
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (step.kind === "module") {
|
|
86
|
+
const moduleRef = await loadConfiguredModule(config.productDir, step);
|
|
87
|
+
const { exportName } = parseModuleSpecifier(step.specifier);
|
|
88
|
+
const fn = moduleRef[exportName];
|
|
89
|
+
if (typeof fn !== "function") {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Template module step "${step.specifier}" did not export a function named "${exportName}"`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await withProcessContext(resolveConfiguredCwd(config.productDir, step.cwd), env, async () => {
|
|
96
|
+
await fn({
|
|
97
|
+
databaseUrl: env.DATABASE_URL || null,
|
|
98
|
+
productDir: config.productDir,
|
|
99
|
+
cwd: resolveConfiguredCwd(config.productDir, step.cwd),
|
|
100
|
+
serviceName: config.name,
|
|
101
|
+
env: { ...env },
|
|
102
|
+
runtimeId: config.runtimeId || null,
|
|
103
|
+
stateDir: config.stateDir,
|
|
104
|
+
prepareDir: config.testkit.prepareDir || null,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
throw new Error(`Unsupported template step kind "${step.kind}"`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function loadConfiguredModule(productDir, step) {
|
|
114
|
+
const { modulePath } = parseModuleSpecifier(step.specifier);
|
|
115
|
+
const absoluteModulePath = resolveConfiguredPath(productDir, step.cwd, modulePath);
|
|
116
|
+
const bundleDir = path.join(productDir, ".testkit", "_template-steps");
|
|
117
|
+
fs.mkdirSync(bundleDir, { recursive: true });
|
|
118
|
+
|
|
119
|
+
const cacheKey = buildModuleCacheKey(absoluteModulePath);
|
|
120
|
+
const outputFile = path.join(
|
|
121
|
+
bundleDir,
|
|
122
|
+
`${path.basename(modulePath).replace(/\W+/g, "-")}-${cacheKey.slice(0, 12)}.mjs`
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await build({
|
|
126
|
+
absWorkingDir: productDir,
|
|
127
|
+
bundle: true,
|
|
128
|
+
entryPoints: [absoluteModulePath],
|
|
129
|
+
format: "esm",
|
|
130
|
+
legalComments: "none",
|
|
131
|
+
outfile: outputFile,
|
|
132
|
+
platform: "node",
|
|
133
|
+
sourcemap: "inline",
|
|
134
|
+
target: "es2020",
|
|
135
|
+
plugins: [testkitAliasPlugin()],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return import(`${pathToFileURL(outputFile).href}?v=${cacheKey}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildModuleCacheKey(modulePath) {
|
|
142
|
+
const content = fs.readFileSync(modulePath, "utf8");
|
|
143
|
+
return crypto.createHash("sha256").update(modulePath).update("\0").update(content).digest("hex");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function testkitAliasPlugin() {
|
|
147
|
+
return {
|
|
148
|
+
name: "testkit-template-step-alias",
|
|
149
|
+
setup(buildApi) {
|
|
150
|
+
buildApi.onResolve({ filter: /^@elench\/testkit(?:\/.*)?$/ }, (args) => ({
|
|
151
|
+
namespace: "file",
|
|
152
|
+
path: resolvePackageSubpath(args.path),
|
|
153
|
+
}));
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolvePackageSubpath(specifier) {
|
|
159
|
+
const subpath = specifier.slice("@elench/testkit".length);
|
|
160
|
+
if (!subpath) return ROOT_ENTRY;
|
|
161
|
+
if (subpath === "/setup") return SETUP_ENTRY;
|
|
162
|
+
if (subpath === "/runtime") return RUNTIME_ENTRY;
|
|
163
|
+
if (subpath === "/known-failures") return KNOWN_FAILURES_ENTRY;
|
|
164
|
+
|
|
165
|
+
throw new Error(`Unsupported @elench/testkit import "${specifier}" while loading template step`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseModuleSpecifier(specifier) {
|
|
169
|
+
const [modulePath, exportName] = String(specifier).split("#", 2);
|
|
170
|
+
return {
|
|
171
|
+
modulePath,
|
|
172
|
+
exportName: exportName || "default",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function withProcessContext(cwd, env, fn) {
|
|
177
|
+
const previousCwd = process.cwd();
|
|
178
|
+
const previousEnv = process.env;
|
|
179
|
+
process.chdir(cwd);
|
|
180
|
+
process.env = {
|
|
181
|
+
...previousEnv,
|
|
182
|
+
...env,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
return await fn();
|
|
187
|
+
} finally {
|
|
188
|
+
process.chdir(previousCwd);
|
|
189
|
+
process.env = previousEnv;
|
|
190
|
+
}
|
|
191
|
+
}
|
package/lib/runner/template.mjs
CHANGED
|
@@ -113,12 +113,14 @@ export function resolveRuntimeConfig(
|
|
|
113
113
|
urlMappings
|
|
114
114
|
) {
|
|
115
115
|
const stateDir = resolveServiceStateDir(runtimeDir, config);
|
|
116
|
+
const prepareDir = resolveServicePrepareDir(runtimeDir, config);
|
|
116
117
|
const context = {
|
|
117
118
|
runtimeId,
|
|
118
119
|
runtimeLabel,
|
|
119
120
|
runtimeDir,
|
|
120
121
|
serviceName: config.name,
|
|
121
122
|
serviceStateDir: stateDir,
|
|
123
|
+
prepareDir,
|
|
122
124
|
portMap,
|
|
123
125
|
baseUrlByService,
|
|
124
126
|
readyUrlByService,
|
|
@@ -135,6 +137,11 @@ export function resolveRuntimeConfig(
|
|
|
135
137
|
}
|
|
136
138
|
: undefined;
|
|
137
139
|
|
|
140
|
+
const runtime = {
|
|
141
|
+
...config.testkit.runtime,
|
|
142
|
+
prepare: finalizeRuntimePrepare(config.testkit.runtime.prepare, context),
|
|
143
|
+
};
|
|
144
|
+
|
|
138
145
|
const local = config.testkit.local
|
|
139
146
|
? {
|
|
140
147
|
...config.testkit.local,
|
|
@@ -158,7 +165,9 @@ export function resolveRuntimeConfig(
|
|
|
158
165
|
testkit: {
|
|
159
166
|
...config.testkit,
|
|
160
167
|
database,
|
|
168
|
+
prepareDir,
|
|
161
169
|
templateContext: context,
|
|
170
|
+
runtime,
|
|
162
171
|
local,
|
|
163
172
|
},
|
|
164
173
|
};
|
|
@@ -197,6 +206,10 @@ export function resolveServiceStateDir(runtimeDir, config) {
|
|
|
197
206
|
return path.join(runtimeDir, "services", config.name);
|
|
198
207
|
}
|
|
199
208
|
|
|
209
|
+
export function resolveServicePrepareDir(runtimeDir, config) {
|
|
210
|
+
return path.join(resolveServiceStateDir(runtimeDir, config), "prepared");
|
|
211
|
+
}
|
|
212
|
+
|
|
200
213
|
export function buildExecutionEnv(config, extraEnv = {}, processEnv = process.env) {
|
|
201
214
|
return buildExecutionEnvWithContext(config, null, extraEnv, processEnv);
|
|
202
215
|
}
|
|
@@ -244,6 +257,7 @@ function buildTemplateContext(config, lease) {
|
|
|
244
257
|
runtimeLabel: config.runtimeLabel || baseContext.runtimeLabel || null,
|
|
245
258
|
serviceName: config.name,
|
|
246
259
|
serviceStateDir: config.stateDir || baseContext.serviceStateDir || null,
|
|
260
|
+
prepareDir: config.testkit?.prepareDir || baseContext.prepareDir || null,
|
|
247
261
|
leaseId: lease?.leaseId || null,
|
|
248
262
|
leaseDir: lease?.leaseDir || null,
|
|
249
263
|
};
|
|
@@ -280,6 +294,8 @@ export function resolveTemplateString(value, context) {
|
|
|
280
294
|
return context.serviceName;
|
|
281
295
|
case "stateDir":
|
|
282
296
|
return context.serviceStateDir;
|
|
297
|
+
case "prepareDir":
|
|
298
|
+
return context.prepareDir || "";
|
|
283
299
|
case "lease":
|
|
284
300
|
return context.leaseId ? String(context.leaseId) : "";
|
|
285
301
|
case "leaseDir":
|
|
@@ -332,6 +348,31 @@ function resolveEnvTemplates(values, templateContext) {
|
|
|
332
348
|
);
|
|
333
349
|
}
|
|
334
350
|
|
|
351
|
+
function finalizeRuntimePrepare(prepare, context) {
|
|
352
|
+
if (!prepare) {
|
|
353
|
+
return {
|
|
354
|
+
inputs: [],
|
|
355
|
+
steps: [],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const finalizeStep = (step) => ({
|
|
360
|
+
...step,
|
|
361
|
+
...(typeof step.cmd === "string" ? { cmd: finalizeString(step.cmd, context) } : {}),
|
|
362
|
+
...(typeof step.cwd === "string" ? { cwd: finalizeString(step.cwd, context) } : {}),
|
|
363
|
+
...(typeof step.path === "string" ? { path: finalizeString(step.path, context) } : {}),
|
|
364
|
+
...(typeof step.specifier === "string"
|
|
365
|
+
? { specifier: finalizeString(step.specifier, context) }
|
|
366
|
+
: {}),
|
|
367
|
+
inputs: (step.inputs || []).map((input) => finalizeString(input, context)),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
inputs: (prepare.inputs || []).map((input) => finalizeString(input, context)),
|
|
372
|
+
steps: (prepare.steps || []).map(finalizeStep),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
335
376
|
function resolveDatabaseTemplateValue(token, serviceName, context) {
|
|
336
377
|
const stateDir = context.stateDirByService?.get(serviceName);
|
|
337
378
|
if (!stateDir) {
|
|
@@ -22,8 +22,18 @@ function makeRuntimeConfig(name, local, extras = {}) {
|
|
|
22
22
|
name,
|
|
23
23
|
stateDir: extras.stateDir,
|
|
24
24
|
runtimeId: extras.runtimeId,
|
|
25
|
+
productDir: extras.productDir || process.cwd(),
|
|
25
26
|
testkit: {
|
|
26
27
|
local,
|
|
28
|
+
runtime: extras.runtime || {
|
|
29
|
+
instances: 1,
|
|
30
|
+
maxConcurrentTasks: Infinity,
|
|
31
|
+
prepare: {
|
|
32
|
+
inputs: [],
|
|
33
|
+
steps: [],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
envFiles: extras.envFiles || [],
|
|
27
37
|
serviceEnv: extras.serviceEnv || {},
|
|
28
38
|
databaseFrom: extras.databaseFrom,
|
|
29
39
|
database: extras.database,
|
|
@@ -88,6 +98,9 @@ describe("runner-template", () => {
|
|
|
88
98
|
expect(resolveTemplateString("{runtime}:{service}:{lease}", context)).toBe(
|
|
89
99
|
"runtime-2:frontend:lease-1"
|
|
90
100
|
);
|
|
101
|
+
expect(resolveTemplateString("{prepareDir}", { ...context, prepareDir: "/tmp/prepare-1" })).toBe(
|
|
102
|
+
"/tmp/prepare-1"
|
|
103
|
+
);
|
|
91
104
|
expect(resolveTemplateString("{baseUrl:api}", context)).toBe("http://127.0.0.1:3100");
|
|
92
105
|
expect(resolveTemplateString("{dbHost:api}:{dbPort:api}/{dbName:api}", context)).toBe(
|
|
93
106
|
"127.0.0.1:55432/runtime_db"
|
|
@@ -140,6 +153,7 @@ describe("runner-template", () => {
|
|
|
140
153
|
expect(resolved[0].testkit.local.port).toBe(3100);
|
|
141
154
|
expect(resolved[0].runtimeLabel).toBe("api__frontend/runtime-2");
|
|
142
155
|
expect(resolveServiceStateDir(runtimeDir, api)).toBe(`${runtimeDir}/services/api`);
|
|
156
|
+
expect(resolved[0].testkit.prepareDir).toBe(`${runtimeDir}/services/api/prepared`);
|
|
143
157
|
|
|
144
158
|
fs.mkdirSync(path.join(runtimeDir, "services", "api"), { recursive: true });
|
|
145
159
|
fs.writeFileSync(
|
|
@@ -197,6 +211,56 @@ describe("runner-template", () => {
|
|
|
197
211
|
});
|
|
198
212
|
});
|
|
199
213
|
|
|
214
|
+
it("finalizes runtime.prepare templates with prepareDir", () => {
|
|
215
|
+
const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-runtime-prepare-"));
|
|
216
|
+
const api = makeRuntimeConfig(
|
|
217
|
+
"api",
|
|
218
|
+
{
|
|
219
|
+
cwd: ".",
|
|
220
|
+
start: "npm run api",
|
|
221
|
+
port: 3000,
|
|
222
|
+
baseUrl: "http://127.0.0.1:{port}",
|
|
223
|
+
readyUrl: "http://127.0.0.1:{port}/health",
|
|
224
|
+
env: {},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
runtime: {
|
|
228
|
+
instances: 1,
|
|
229
|
+
maxConcurrentTasks: 2,
|
|
230
|
+
prepare: {
|
|
231
|
+
inputs: ["src/{service}.ts"],
|
|
232
|
+
steps: [
|
|
233
|
+
{
|
|
234
|
+
kind: "command",
|
|
235
|
+
cmd: "node scripts/prepare.mjs {prepareDir}",
|
|
236
|
+
cwd: ".",
|
|
237
|
+
inputs: ["src/{service}.ts"],
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const [resolved] = resolveRuntimeInstanceConfigs([api], "runtime-1", runtimeDir, {
|
|
246
|
+
graphDirName: "api",
|
|
247
|
+
portNamespaceIndex: 0,
|
|
248
|
+
portNamespaceStride: 1,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(resolved.testkit.runtime.prepare).toEqual({
|
|
252
|
+
inputs: ["src/api.ts"],
|
|
253
|
+
steps: [
|
|
254
|
+
{
|
|
255
|
+
kind: "command",
|
|
256
|
+
cmd: `node scripts/prepare.mjs ${runtimeDir}/services/api/prepared`,
|
|
257
|
+
cwd: ".",
|
|
258
|
+
inputs: ["src/api.ts"],
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
200
264
|
it("parses runtime sockets", () => {
|
|
201
265
|
expect(numericPortFromUrl("http://localhost:3000")).toBe(3000);
|
|
202
266
|
expect(socketFromUrl("http://localhost:3000")).toEqual({
|
package/lib/setup/index.d.ts
CHANGED
|
@@ -58,6 +58,10 @@ export interface SkipConfig {
|
|
|
58
58
|
export interface RuntimeConfig {
|
|
59
59
|
instances?: number;
|
|
60
60
|
maxConcurrentTasks?: number;
|
|
61
|
+
prepare?: {
|
|
62
|
+
inputs?: string[];
|
|
63
|
+
steps?: TemplateLifecycleStepConfig[];
|
|
64
|
+
};
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
export interface SuiteRequirementRule {
|