@elench/testkit 0.1.48 → 0.1.50
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 +58 -3
- package/lib/database/index.mjs +105 -12
- package/lib/database/index.test.mjs +95 -0
- package/lib/database/template-steps.mjs +35 -193
- package/lib/runner/processes.mjs +16 -2
- package/lib/runner/processes.test.mjs +21 -0
- package/lib/runner/results.mjs +2 -1
- package/lib/runner/results.test.mjs +61 -0
- package/lib/runner/runtime-contexts.mjs +2 -0
- package/lib/runner/runtime-manager.mjs +34 -0
- package/lib/runner/runtime-manager.test.mjs +46 -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/runner/worker-loop.mjs +21 -0
- package/lib/setup/index.d.ts +4 -0
- package/lib/setup/index.mjs +5 -5
- package/lib/setup/index.test.mjs +26 -0
- package/package.json +1 -1
package/lib/runner/results.mjs
CHANGED
|
@@ -114,7 +114,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
|
|
|
114
114
|
const normalizedPath = normalizePathSeparators(task.file);
|
|
115
115
|
const existingFileResult = suite.fileResultsByPath.get(normalizedPath);
|
|
116
116
|
const status = normalizeOutcomeStatus(outcome);
|
|
117
|
-
if (status !== "skipped") {
|
|
117
|
+
if (status !== "skipped" && status !== "not_run") {
|
|
118
118
|
suite.completedFileCount += 1;
|
|
119
119
|
}
|
|
120
120
|
if (existingFileResult) {
|
|
@@ -291,6 +291,7 @@ function formatFrameworkForArtifact(framework) {
|
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
function normalizeOutcomeStatus(outcome) {
|
|
294
|
+
if (outcome?.status === "not_run") return "not_run";
|
|
294
295
|
if (outcome?.status === "skipped") return "skipped";
|
|
295
296
|
return outcome?.failed ? "failed" : "passed";
|
|
296
297
|
}
|
|
@@ -307,6 +307,67 @@ describe("runner results", () => {
|
|
|
307
307
|
]);
|
|
308
308
|
});
|
|
309
309
|
|
|
310
|
+
it("preserves not-run task outcomes in suite and service summaries", () => {
|
|
311
|
+
const trackers = buildServiceTrackers(
|
|
312
|
+
[
|
|
313
|
+
{
|
|
314
|
+
skipped: false,
|
|
315
|
+
config: {
|
|
316
|
+
name: "api",
|
|
317
|
+
testkit: {
|
|
318
|
+
database: {
|
|
319
|
+
selectedBackend: null,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
suites: [
|
|
324
|
+
{
|
|
325
|
+
name: "health",
|
|
326
|
+
type: "integration",
|
|
327
|
+
framework: "k6",
|
|
328
|
+
files: ["tests/a.int.testkit.ts", "tests/b.int.testkit.ts"],
|
|
329
|
+
orderIndex: 0,
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
1000
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
recordTaskOutcome(
|
|
338
|
+
trackers,
|
|
339
|
+
{
|
|
340
|
+
serviceName: "api",
|
|
341
|
+
suiteKey: "integration:health",
|
|
342
|
+
file: "tests/a.int.testkit.ts",
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
failed: false,
|
|
346
|
+
status: "not_run",
|
|
347
|
+
reason: "Graph initialization failed",
|
|
348
|
+
durationMs: 0,
|
|
349
|
+
error: null,
|
|
350
|
+
},
|
|
351
|
+
1000
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const result = finalizeServiceResult(trackers.get("api"), 1000, 1200);
|
|
355
|
+
|
|
356
|
+
expect(result.completedFileCount).toBe(0);
|
|
357
|
+
expect(result.passedFileCount).toBe(0);
|
|
358
|
+
expect(result.failedFileCount).toBe(0);
|
|
359
|
+
expect(result.skippedFileCount).toBe(0);
|
|
360
|
+
expect(result.notRunFileCount).toBe(2);
|
|
361
|
+
expect(result.suites[0].files).toContainEqual({
|
|
362
|
+
path: "tests/a.int.testkit.ts",
|
|
363
|
+
failed: false,
|
|
364
|
+
status: "not_run",
|
|
365
|
+
durationMs: 0,
|
|
366
|
+
error: null,
|
|
367
|
+
reason: "Graph initialization failed",
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
310
371
|
it("summarizes mixed db backends", () => {
|
|
311
372
|
expect(
|
|
312
373
|
summarizeDbBackend([{ dbBackend: "local" }, { dbBackend: "neon" }])
|
|
@@ -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;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { buildRuntimeIds } from "./execution-config.mjs";
|
|
4
|
+
import { formatError } from "./formatting.mjs";
|
|
4
5
|
import {
|
|
5
6
|
cleanupRuntimeInstanceContext,
|
|
6
7
|
createRuntimeInstanceContext,
|
|
@@ -11,6 +12,7 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
|
|
|
11
12
|
const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
|
|
12
13
|
const pools = new Map();
|
|
13
14
|
const locks = new Map();
|
|
15
|
+
const failedGraphs = new Map();
|
|
14
16
|
let nextLeaseCounter = 1;
|
|
15
17
|
const runtimeHooks = {
|
|
16
18
|
cleanupRuntimeInstanceContext,
|
|
@@ -22,6 +24,9 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
|
|
|
22
24
|
|
|
23
25
|
return {
|
|
24
26
|
canAcquire(task) {
|
|
27
|
+
if (failedGraphs.has(task.graphKey)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
25
30
|
const pool = getPool(pools, graphByKey, task, productDir, lifecycle);
|
|
26
31
|
if (!locksAvailable(locks, task.locks || [])) {
|
|
27
32
|
return false;
|
|
@@ -64,6 +69,9 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
|
|
|
64
69
|
releaseLocks(locks, task.locks || [], leaseId);
|
|
65
70
|
cleanupLeaseDir({ leaseDir: path.join(slot.context?.runtimeDir || productDir, "leases", leaseId) });
|
|
66
71
|
await drainRuntimeSlot(slot, lifecycle, runtimeHooks);
|
|
72
|
+
if (!lifecycle.isStopRequested()) {
|
|
73
|
+
markGraphFailed(failedGraphs, task.graphKey, formatError(error));
|
|
74
|
+
}
|
|
67
75
|
throw error;
|
|
68
76
|
}
|
|
69
77
|
},
|
|
@@ -104,6 +112,27 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
|
|
|
104
112
|
}))
|
|
105
113
|
.sort((left, right) => String(left.graphKey).localeCompare(String(right.graphKey)));
|
|
106
114
|
},
|
|
115
|
+
drainFailedTasks(queue) {
|
|
116
|
+
const drained = [];
|
|
117
|
+
for (let index = queue.length - 1; index >= 0; index -= 1) {
|
|
118
|
+
const task = queue[index];
|
|
119
|
+
const message = failedGraphs.get(task.graphKey);
|
|
120
|
+
if (!message) continue;
|
|
121
|
+
|
|
122
|
+
queue.splice(index, 1);
|
|
123
|
+
drained.push({
|
|
124
|
+
task,
|
|
125
|
+
reason: `Not run because runtime graph failed to initialize: ${message}`,
|
|
126
|
+
message,
|
|
127
|
+
graph: graphByKey.get(task.graphKey) || {
|
|
128
|
+
key: task.graphKey,
|
|
129
|
+
targetNames: [task.targetName],
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return drained.reverse();
|
|
135
|
+
},
|
|
107
136
|
};
|
|
108
137
|
}
|
|
109
138
|
|
|
@@ -218,6 +247,11 @@ function releaseLocks(lockMap, lockNames, leaseId) {
|
|
|
218
247
|
}
|
|
219
248
|
}
|
|
220
249
|
|
|
250
|
+
function markGraphFailed(failedGraphs, graphKey, message) {
|
|
251
|
+
if (!graphKey || failedGraphs.has(graphKey)) return;
|
|
252
|
+
failedGraphs.set(graphKey, message);
|
|
253
|
+
}
|
|
254
|
+
|
|
221
255
|
function sleep(ms) {
|
|
222
256
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
223
257
|
}
|
|
@@ -203,4 +203,50 @@ describe("runtime-manager", () => {
|
|
|
203
203
|
await manager.release(leaseTwo);
|
|
204
204
|
await manager.cleanupAll();
|
|
205
205
|
});
|
|
206
|
+
|
|
207
|
+
it("blocks a graph after fatal runtime initialization failure and drains remaining tasks", async () => {
|
|
208
|
+
const productDir = makeTempDir("testkit-runtime-manager-");
|
|
209
|
+
const manager = createRuntimeManager({
|
|
210
|
+
productDir,
|
|
211
|
+
lifecycle: makeLifecycle(),
|
|
212
|
+
graphs: [
|
|
213
|
+
{
|
|
214
|
+
key: "api",
|
|
215
|
+
dirName: "api",
|
|
216
|
+
targetNames: ["api"],
|
|
217
|
+
instanceCount: 1,
|
|
218
|
+
maxConcurrentTasks: 1,
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
hooks: {
|
|
222
|
+
...makeHooks({ created: [], ready: [], cleaned: [] }),
|
|
223
|
+
async ensureRuntimeInstanceReady() {
|
|
224
|
+
throw new Error("prepare failed");
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const firstTask = makeTask(1, { file: "tests/a.int.testkit.ts", targetName: "api" });
|
|
230
|
+
const secondTask = makeTask(2, { file: "tests/b.int.testkit.ts", targetName: "api" });
|
|
231
|
+
|
|
232
|
+
await expect(manager.acquire(firstTask)).rejects.toThrow(/prepare failed/);
|
|
233
|
+
expect(manager.canAcquire(secondTask)).toBe(false);
|
|
234
|
+
|
|
235
|
+
const queue = [secondTask];
|
|
236
|
+
expect(manager.drainFailedTasks(queue)).toEqual([
|
|
237
|
+
{
|
|
238
|
+
task: secondTask,
|
|
239
|
+
reason: "Not run because runtime graph failed to initialize: prepare failed",
|
|
240
|
+
message: "prepare failed",
|
|
241
|
+
graph: {
|
|
242
|
+
key: "api",
|
|
243
|
+
dirName: "api",
|
|
244
|
+
targetNames: ["api"],
|
|
245
|
+
instanceCount: 1,
|
|
246
|
+
maxConcurrentTasks: 1,
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
]);
|
|
250
|
+
expect(queue).toEqual([]);
|
|
251
|
+
});
|
|
206
252
|
});
|
|
@@ -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
|
+
}
|