@elench/testkit 0.1.54 → 0.1.55
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/lib/cli/commands/artifacts.mjs +2 -2
- package/lib/cli/commands/logs.mjs +2 -2
- package/lib/cli/commands/show.mjs +2 -2
- package/lib/cli/db.mjs +17 -2
- package/lib/cli/presentation/run-reporter.mjs +25 -0
- package/lib/cli/presentation/run-reporter.test.mjs +80 -0
- package/lib/cli/tui/watch-app.mjs +134 -18
- package/lib/cli/viewer.mjs +37 -0
- package/lib/database/index.mjs +85 -11
- package/lib/database/template-steps.mjs +45 -6
- package/lib/database/template-steps.test.mjs +43 -0
- package/lib/runner/artifacts.mjs +16 -0
- package/lib/runner/logs.mjs +54 -6
- package/lib/runner/orchestrator.mjs +53 -6
- package/lib/runner/reporting.mjs +52 -2
- package/lib/runner/reporting.test.mjs +80 -2
- package/lib/runner/runtime-contexts.mjs +3 -3
- package/lib/runner/runtime-preparation.mjs +31 -0
- package/lib/runner/setup-operations.mjs +115 -0
- package/lib/runner/setup-operations.test.mjs +94 -0
- package/lib/runner/template-steps.mjs +129 -11
- package/lib/toolchains/index.mjs +0 -4
- package/package.json +1 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export function createSetupOperationRegistry({ logRegistry = null, onChange = null } = {}) {
|
|
2
|
+
const operations = [];
|
|
3
|
+
const byId = new Map();
|
|
4
|
+
let nextId = 1;
|
|
5
|
+
|
|
6
|
+
function emitChange() {
|
|
7
|
+
onChange?.(listOperations());
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function start({
|
|
11
|
+
config,
|
|
12
|
+
stage,
|
|
13
|
+
kind = "setup",
|
|
14
|
+
summary = null,
|
|
15
|
+
parentId = null,
|
|
16
|
+
recordLog = true,
|
|
17
|
+
}) {
|
|
18
|
+
const startedAt = new Date().toISOString();
|
|
19
|
+
const logRecord = recordLog ? logRegistry?.ensureSetupLogRecord(config, stage) || null : null;
|
|
20
|
+
const operation = {
|
|
21
|
+
id: `setup-${nextId++}`,
|
|
22
|
+
serviceName: config.name,
|
|
23
|
+
runtimeLabel: config.runtimeLabel || config.name,
|
|
24
|
+
stage,
|
|
25
|
+
kind,
|
|
26
|
+
summary,
|
|
27
|
+
parentId,
|
|
28
|
+
status: "running",
|
|
29
|
+
startedAt,
|
|
30
|
+
finishedAt: null,
|
|
31
|
+
durationMs: null,
|
|
32
|
+
error: null,
|
|
33
|
+
logRef: logRecord
|
|
34
|
+
? {
|
|
35
|
+
path: logRecord.path,
|
|
36
|
+
stage: logRecord.stage,
|
|
37
|
+
}
|
|
38
|
+
: null,
|
|
39
|
+
_logRecord: logRecord,
|
|
40
|
+
};
|
|
41
|
+
operations.push(operation);
|
|
42
|
+
byId.set(operation.id, operation);
|
|
43
|
+
emitChange();
|
|
44
|
+
return operation;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function finish(target, { status = "passed", summary, error = null } = {}) {
|
|
48
|
+
const operation = typeof target === "string" ? byId.get(target) : target;
|
|
49
|
+
if (!operation) return null;
|
|
50
|
+
const finishedAt = new Date().toISOString();
|
|
51
|
+
operation.finishedAt = finishedAt;
|
|
52
|
+
operation.durationMs = Math.max(
|
|
53
|
+
0,
|
|
54
|
+
Date.parse(finishedAt) - Date.parse(operation.startedAt || finishedAt)
|
|
55
|
+
);
|
|
56
|
+
operation.status = status;
|
|
57
|
+
if (summary !== undefined) operation.summary = summary;
|
|
58
|
+
if (error !== null) operation.error = String(error);
|
|
59
|
+
emitChange();
|
|
60
|
+
return operation;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function recordCached({ config, stage, kind = "setup", summary = null, parentId = null }) {
|
|
64
|
+
const now = new Date().toISOString();
|
|
65
|
+
const operation = {
|
|
66
|
+
id: `setup-${nextId++}`,
|
|
67
|
+
serviceName: config.name,
|
|
68
|
+
runtimeLabel: config.runtimeLabel || config.name,
|
|
69
|
+
stage,
|
|
70
|
+
kind,
|
|
71
|
+
summary,
|
|
72
|
+
parentId,
|
|
73
|
+
status: "cached",
|
|
74
|
+
startedAt: now,
|
|
75
|
+
finishedAt: now,
|
|
76
|
+
durationMs: 0,
|
|
77
|
+
error: null,
|
|
78
|
+
logRef: null,
|
|
79
|
+
_logRecord: null,
|
|
80
|
+
};
|
|
81
|
+
operations.push(operation);
|
|
82
|
+
byId.set(operation.id, operation);
|
|
83
|
+
emitChange();
|
|
84
|
+
return operation;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function listOperations() {
|
|
88
|
+
return operations.map(cloneOperation);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
start,
|
|
93
|
+
finish,
|
|
94
|
+
recordCached,
|
|
95
|
+
listOperations,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function cloneOperation(operation) {
|
|
100
|
+
return {
|
|
101
|
+
id: operation.id,
|
|
102
|
+
serviceName: operation.serviceName,
|
|
103
|
+
runtimeLabel: operation.runtimeLabel,
|
|
104
|
+
stage: operation.stage,
|
|
105
|
+
kind: operation.kind,
|
|
106
|
+
summary: operation.summary,
|
|
107
|
+
parentId: operation.parentId,
|
|
108
|
+
status: operation.status,
|
|
109
|
+
startedAt: operation.startedAt,
|
|
110
|
+
finishedAt: operation.finishedAt,
|
|
111
|
+
durationMs: operation.durationMs,
|
|
112
|
+
error: operation.error,
|
|
113
|
+
logRef: operation.logRef,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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 { createRunLogRegistry } from "./logs.mjs";
|
|
6
|
+
import { createSetupOperationRegistry } from "./setup-operations.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 makeConfig(productDir) {
|
|
23
|
+
return {
|
|
24
|
+
name: "api",
|
|
25
|
+
runtimeLabel: "api",
|
|
26
|
+
productDir,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("setup operation registry", () => {
|
|
31
|
+
it("tracks running and finished setup operations with log refs", () => {
|
|
32
|
+
const productDir = makeTempDir("testkit-setup-ops-");
|
|
33
|
+
const logRegistry = createRunLogRegistry(productDir);
|
|
34
|
+
const changes = [];
|
|
35
|
+
const registry = createSetupOperationRegistry({
|
|
36
|
+
logRegistry,
|
|
37
|
+
onChange(operations) {
|
|
38
|
+
changes.push(operations);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const operation = registry.start({
|
|
43
|
+
config: makeConfig(productDir),
|
|
44
|
+
stage: "template:migrate:api:1",
|
|
45
|
+
kind: "setup-step",
|
|
46
|
+
summary: "command: node scripts/migrate.mjs",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(operation.logRef).toMatchObject({
|
|
50
|
+
path: ".testkit/results/setup/api__api__template-migrate-api-1.log",
|
|
51
|
+
stage: "template:migrate:api:1",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const finished = registry.finish(operation, {
|
|
55
|
+
status: "passed",
|
|
56
|
+
summary: "command: node scripts/migrate.mjs",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(finished.status).toBe("passed");
|
|
60
|
+
expect(finished.durationMs).toBeGreaterThanOrEqual(0);
|
|
61
|
+
expect(registry.listOperations()).toEqual([
|
|
62
|
+
expect.objectContaining({
|
|
63
|
+
stage: "template:migrate:api:1",
|
|
64
|
+
kind: "setup-step",
|
|
65
|
+
status: "passed",
|
|
66
|
+
summary: "command: node scripts/migrate.mjs",
|
|
67
|
+
logRef: operation.logRef,
|
|
68
|
+
}),
|
|
69
|
+
]);
|
|
70
|
+
expect(changes).toHaveLength(2);
|
|
71
|
+
|
|
72
|
+
logRegistry.closeAll();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("records cached setup operations without logs", () => {
|
|
76
|
+
const productDir = makeTempDir("testkit-setup-ops-cached-");
|
|
77
|
+
const registry = createSetupOperationRegistry();
|
|
78
|
+
|
|
79
|
+
const operation = registry.recordCached({
|
|
80
|
+
config: makeConfig(productDir),
|
|
81
|
+
stage: "template",
|
|
82
|
+
kind: "database-template",
|
|
83
|
+
summary: "template cache hit",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(operation).toMatchObject({
|
|
87
|
+
stage: "template",
|
|
88
|
+
kind: "database-template",
|
|
89
|
+
status: "cached",
|
|
90
|
+
summary: "template cache hit",
|
|
91
|
+
logRef: null,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -23,16 +23,52 @@ const MODULE_RUNNER_ENTRY = path.join(
|
|
|
23
23
|
"template-step-module-runner.mjs"
|
|
24
24
|
);
|
|
25
25
|
|
|
26
|
-
export async function runConfiguredSteps({
|
|
26
|
+
export async function runConfiguredSteps({
|
|
27
|
+
config,
|
|
28
|
+
steps = [],
|
|
29
|
+
env,
|
|
30
|
+
labelPrefix,
|
|
31
|
+
reporter = null,
|
|
32
|
+
setupRegistry = null,
|
|
33
|
+
parentOperation = null,
|
|
34
|
+
}) {
|
|
27
35
|
if (steps.length === 0) return;
|
|
28
36
|
const resolvedToolchain = await resolveConfiguredToolchain(config);
|
|
29
37
|
await announceResolvedToolchain(config, resolvedToolchain, reporter);
|
|
30
38
|
|
|
31
39
|
for (const [index, step] of steps.entries()) {
|
|
32
40
|
const label = `${labelPrefix}:${config.name}:${index + 1}`;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
const stepOperation = setupRegistry?.start({
|
|
42
|
+
config,
|
|
43
|
+
stage: label,
|
|
44
|
+
kind: "setup-step",
|
|
45
|
+
summary: summarizeConfiguredStep(step),
|
|
46
|
+
parentId: parentOperation?.id || parentOperation || null,
|
|
47
|
+
});
|
|
48
|
+
reporter?.phaseStarted?.(label);
|
|
49
|
+
try {
|
|
50
|
+
await runConfiguredStep(config, step, env, resolvedToolchain, {
|
|
51
|
+
reporter,
|
|
52
|
+
logRecord: stepOperation?._logRecord || null,
|
|
53
|
+
});
|
|
54
|
+
const finished = stepOperation
|
|
55
|
+
? setupRegistry.finish(stepOperation, {
|
|
56
|
+
status: "passed",
|
|
57
|
+
summary: summarizeConfiguredStep(step),
|
|
58
|
+
})
|
|
59
|
+
: null;
|
|
60
|
+
if (finished) reporter?.setupOperationFinished?.(finished);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const finished = stepOperation
|
|
63
|
+
? setupRegistry.finish(stepOperation, {
|
|
64
|
+
status: "failed",
|
|
65
|
+
summary: summarizeConfiguredStep(step),
|
|
66
|
+
error: error?.message || error,
|
|
67
|
+
})
|
|
68
|
+
: null;
|
|
69
|
+
if (finished) reporter?.setupOperationFinished?.(finished);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
36
72
|
}
|
|
37
73
|
}
|
|
38
74
|
|
|
@@ -65,22 +101,33 @@ export function resolveConfiguredPath(productDir, stepCwd, targetPath) {
|
|
|
65
101
|
return path.resolve(resolveConfiguredCwd(productDir, stepCwd), targetPath);
|
|
66
102
|
}
|
|
67
103
|
|
|
68
|
-
async function runConfiguredStep(config, step, env, resolvedToolchain) {
|
|
104
|
+
async function runConfiguredStep(config, step, env, resolvedToolchain, options = {}) {
|
|
69
105
|
const runtimeEnv = applyToolchainEnv(env, resolvedToolchain);
|
|
70
106
|
const cwd = resolveConfiguredCwd(config.productDir, step.cwd);
|
|
107
|
+
const liveWriter =
|
|
108
|
+
options.reporter?.outputMode === "debug"
|
|
109
|
+
? (line) => options.reporter.writeDebugLine?.(line)
|
|
110
|
+
: null;
|
|
71
111
|
|
|
72
112
|
if (step.kind === "command") {
|
|
73
|
-
|
|
113
|
+
const child = execaCommand(step.cmd, {
|
|
74
114
|
cwd,
|
|
75
115
|
env: runtimeEnv,
|
|
76
|
-
stdio: "inherit",
|
|
77
116
|
shell: true,
|
|
117
|
+
stdout: "pipe",
|
|
118
|
+
stderr: "pipe",
|
|
119
|
+
reject: false,
|
|
120
|
+
});
|
|
121
|
+
await awaitCapturedProcess(child, {
|
|
122
|
+
livePrefix: `[${config.runtimeLabel || config.name}:${config.name}]`,
|
|
123
|
+
liveWriter,
|
|
124
|
+
logRecord: options.logRecord || null,
|
|
78
125
|
});
|
|
79
126
|
return;
|
|
80
127
|
}
|
|
81
128
|
|
|
82
129
|
if (step.kind === "sql-file") {
|
|
83
|
-
|
|
130
|
+
const child = execa(
|
|
84
131
|
"psql",
|
|
85
132
|
[
|
|
86
133
|
runtimeEnv.DATABASE_URL,
|
|
@@ -93,9 +140,16 @@ async function runConfiguredStep(config, step, env, resolvedToolchain) {
|
|
|
93
140
|
{
|
|
94
141
|
cwd,
|
|
95
142
|
env: runtimeEnv,
|
|
96
|
-
|
|
143
|
+
stdout: "pipe",
|
|
144
|
+
stderr: "pipe",
|
|
145
|
+
reject: false,
|
|
97
146
|
}
|
|
98
147
|
);
|
|
148
|
+
await awaitCapturedProcess(child, {
|
|
149
|
+
livePrefix: `[${config.runtimeLabel || config.name}:${config.name}]`,
|
|
150
|
+
liveWriter,
|
|
151
|
+
logRecord: options.logRecord || null,
|
|
152
|
+
});
|
|
99
153
|
return;
|
|
100
154
|
}
|
|
101
155
|
|
|
@@ -116,15 +170,22 @@ async function runConfiguredStep(config, step, env, resolvedToolchain) {
|
|
|
116
170
|
fs.writeFileSync(contextPath, JSON.stringify(context));
|
|
117
171
|
|
|
118
172
|
try {
|
|
119
|
-
|
|
173
|
+
const child = execa(
|
|
120
174
|
resolvedToolchain?.nodeExecutable || process.execPath,
|
|
121
175
|
[MODULE_RUNNER_ENTRY, bundledModule.outputFile, exportName, contextPath],
|
|
122
176
|
{
|
|
123
177
|
cwd,
|
|
124
178
|
env: runtimeEnv,
|
|
125
|
-
|
|
179
|
+
stdout: "pipe",
|
|
180
|
+
stderr: "pipe",
|
|
181
|
+
reject: false,
|
|
126
182
|
}
|
|
127
183
|
);
|
|
184
|
+
await awaitCapturedProcess(child, {
|
|
185
|
+
livePrefix: `[${config.runtimeLabel || config.name}:${config.name}]`,
|
|
186
|
+
liveWriter,
|
|
187
|
+
logRecord: options.logRecord || null,
|
|
188
|
+
});
|
|
128
189
|
} finally {
|
|
129
190
|
fs.rmSync(contextPath, { force: true });
|
|
130
191
|
}
|
|
@@ -199,3 +260,60 @@ function parseModuleSpecifier(specifier) {
|
|
|
199
260
|
exportName: exportName || "default",
|
|
200
261
|
};
|
|
201
262
|
}
|
|
263
|
+
|
|
264
|
+
function summarizeConfiguredStep(step) {
|
|
265
|
+
if (step.kind === "command") return `command: ${String(step.cmd).trim()}`;
|
|
266
|
+
if (step.kind === "sql-file") return `sql: ${step.path}`;
|
|
267
|
+
if (step.kind === "module") return `module: ${step.specifier}`;
|
|
268
|
+
return step.kind;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function awaitCapturedProcess(child, { livePrefix = "", liveWriter = null, logRecord = null } = {}) {
|
|
272
|
+
const drains = [
|
|
273
|
+
captureProcessOutput(child.stdout, "stdout", livePrefix, liveWriter, logRecord),
|
|
274
|
+
captureProcessOutput(child.stderr, "stderr", livePrefix, liveWriter, logRecord),
|
|
275
|
+
];
|
|
276
|
+
const result = await child;
|
|
277
|
+
await Promise.all(drains);
|
|
278
|
+
if (result.exitCode !== 0) {
|
|
279
|
+
throw new Error(result.shortMessage || result.stderr || result.stdout || `Step failed with exit code ${result.exitCode}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function captureProcessOutput(stream, streamName, livePrefix, liveWriter, logRecord) {
|
|
284
|
+
if (!stream) return Promise.resolve();
|
|
285
|
+
|
|
286
|
+
let pending = "";
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
let settled = false;
|
|
289
|
+
const settle = () => {
|
|
290
|
+
if (settled) return;
|
|
291
|
+
settled = true;
|
|
292
|
+
resolve();
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
stream.on("data", (chunk) => {
|
|
296
|
+
pending += chunk.toString();
|
|
297
|
+
const lines = pending.split(/\r?\n/);
|
|
298
|
+
pending = lines.pop() || "";
|
|
299
|
+
for (const line of lines) {
|
|
300
|
+
if (line.length === 0) continue;
|
|
301
|
+
if (logRecord) {
|
|
302
|
+
logRecord.stream.write(`${new Date().toISOString()} [${streamName}] ${line}\n`);
|
|
303
|
+
}
|
|
304
|
+
if (liveWriter) liveWriter(livePrefix ? `${livePrefix} ${line}` : line);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
stream.on("end", () => {
|
|
308
|
+
if (pending.length > 0) {
|
|
309
|
+
if (logRecord) {
|
|
310
|
+
logRecord.stream.write(`${new Date().toISOString()} [${streamName}] ${pending}\n`);
|
|
311
|
+
}
|
|
312
|
+
if (liveWriter) liveWriter(livePrefix ? `${livePrefix} ${pending}` : pending);
|
|
313
|
+
}
|
|
314
|
+
settle();
|
|
315
|
+
});
|
|
316
|
+
stream.on("close", settle);
|
|
317
|
+
stream.on("error", settle);
|
|
318
|
+
});
|
|
319
|
+
}
|
package/lib/toolchains/index.mjs
CHANGED
|
@@ -89,11 +89,7 @@ export async function announceResolvedToolchain(config, resolvedToolchain, repor
|
|
|
89
89
|
announcedToolchains.add(config);
|
|
90
90
|
if (reporter?.toolchainResolved) {
|
|
91
91
|
reporter.toolchainResolved(config, resolvedToolchain);
|
|
92
|
-
return;
|
|
93
92
|
}
|
|
94
|
-
console.log(
|
|
95
|
-
`[testkit] ${config.runtimeLabel || config.name}:${config.name} toolchain ${resolvedToolchain.summary}`
|
|
96
|
-
);
|
|
97
93
|
}
|
|
98
94
|
|
|
99
95
|
export function applyToolchainEnv(baseEnv, resolvedToolchain, processEnv = process.env) {
|