@elench/testkit 0.1.53 → 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/code-frames.mjs +57 -0
- package/lib/cli/presentation/code-frames.test.mjs +71 -0
- package/lib/cli/presentation/colors.mjs +29 -0
- package/lib/cli/presentation/run-reporter.mjs +41 -7
- package/lib/cli/presentation/run-reporter.test.mjs +80 -0
- package/lib/cli/tui/watch-app.mjs +134 -18
- package/lib/cli/viewer.mjs +146 -4
- 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/known-failures/index.mjs +1 -1
- package/lib/known-failures/index.test.mjs +46 -0
- package/lib/runner/artifacts.mjs +16 -0
- package/lib/runner/default-runtime-errors.mjs +66 -0
- package/lib/runner/default-runtime-runner.mjs +8 -1
- package/lib/runner/failure-details.mjs +31 -0
- package/lib/runner/failure-details.test.mjs +51 -0
- package/lib/runner/formatting.mjs +114 -4
- package/lib/runner/formatting.test.mjs +77 -0
- package/lib/runner/logs.mjs +71 -6
- package/lib/runner/orchestrator.mjs +63 -7
- 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/runner/triage.mjs +67 -0
- package/lib/runtime/index.d.ts +60 -0
- package/lib/runtime/index.mjs +12 -0
- package/lib/runtime-src/k6/checks.js +45 -12
- package/lib/runtime-src/k6/http-assertions.js +214 -0
- package/lib/runtime-src/k6/http.js +261 -13
- package/lib/runtime-src/k6/suite.js +46 -1
- package/lib/toolchains/index.mjs +0 -4
- package/package.json +3 -1
package/lib/runner/reporting.mjs
CHANGED
|
@@ -112,6 +112,9 @@ export function buildRunArtifact({
|
|
|
112
112
|
metadata,
|
|
113
113
|
summarizeDbBackend,
|
|
114
114
|
serviceLogs = [],
|
|
115
|
+
setupLogs = [],
|
|
116
|
+
setupOperations = [],
|
|
117
|
+
runStatus = null,
|
|
115
118
|
}) {
|
|
116
119
|
const executed = results.filter((result) => !result.skipped);
|
|
117
120
|
const effectivelySkippedServices = executed.filter(isEffectivelySkippedService);
|
|
@@ -128,7 +131,7 @@ export function buildRunArtifact({
|
|
|
128
131
|
const dbBackend = summarizeDbBackend(results);
|
|
129
132
|
|
|
130
133
|
return {
|
|
131
|
-
schemaVersion:
|
|
134
|
+
schemaVersion: 8,
|
|
132
135
|
source: "testkit",
|
|
133
136
|
generatedAt: new Date(finishedAt).toISOString(),
|
|
134
137
|
product: {
|
|
@@ -138,7 +141,7 @@ export function buildRunArtifact({
|
|
|
138
141
|
git: metadata.git,
|
|
139
142
|
host: metadata.host,
|
|
140
143
|
run: {
|
|
141
|
-
status: failedServices.length > 0 ? "failed" : "passed",
|
|
144
|
+
status: runStatus || (failedServices.length > 0 ? "failed" : "passed"),
|
|
142
145
|
startedAt: new Date(startedAt).toISOString(),
|
|
143
146
|
finishedAt: new Date(finishedAt).toISOString(),
|
|
144
147
|
durationMs: finishedAt - startedAt,
|
|
@@ -179,6 +182,10 @@ export function buildRunArtifact({
|
|
|
179
182
|
},
|
|
180
183
|
logs: {
|
|
181
184
|
services: serviceLogs,
|
|
185
|
+
setup: setupLogs,
|
|
186
|
+
},
|
|
187
|
+
setup: {
|
|
188
|
+
operations: setupOperations,
|
|
182
189
|
},
|
|
183
190
|
services: results.map((result) => ({
|
|
184
191
|
name: result.name,
|
|
@@ -203,6 +210,49 @@ export function buildRunArtifact({
|
|
|
203
210
|
};
|
|
204
211
|
}
|
|
205
212
|
|
|
213
|
+
export function buildLiveRunArtifact({
|
|
214
|
+
productDir,
|
|
215
|
+
results,
|
|
216
|
+
startedAt,
|
|
217
|
+
updatedAt,
|
|
218
|
+
execution,
|
|
219
|
+
workerCount,
|
|
220
|
+
runtimeInstanceCount,
|
|
221
|
+
runtimeStats,
|
|
222
|
+
typeValues,
|
|
223
|
+
suiteSelectors,
|
|
224
|
+
fileNames,
|
|
225
|
+
shard,
|
|
226
|
+
serviceFilter,
|
|
227
|
+
metadata,
|
|
228
|
+
summarizeDbBackend,
|
|
229
|
+
serviceLogs = [],
|
|
230
|
+
setupLogs = [],
|
|
231
|
+
setupOperations = [],
|
|
232
|
+
}) {
|
|
233
|
+
return buildRunArtifact({
|
|
234
|
+
productDir,
|
|
235
|
+
results,
|
|
236
|
+
startedAt,
|
|
237
|
+
finishedAt: updatedAt,
|
|
238
|
+
execution,
|
|
239
|
+
workerCount,
|
|
240
|
+
runtimeInstanceCount,
|
|
241
|
+
runtimeStats,
|
|
242
|
+
typeValues,
|
|
243
|
+
suiteSelectors,
|
|
244
|
+
fileNames,
|
|
245
|
+
shard,
|
|
246
|
+
serviceFilter,
|
|
247
|
+
metadata,
|
|
248
|
+
summarizeDbBackend,
|
|
249
|
+
serviceLogs,
|
|
250
|
+
setupLogs,
|
|
251
|
+
setupOperations,
|
|
252
|
+
runStatus: "running",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
206
256
|
function isEffectivelySkippedService(result) {
|
|
207
257
|
return (
|
|
208
258
|
!result.skipped &&
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
|
|
2
|
+
import { buildLiveRunArtifact, buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
|
|
3
3
|
|
|
4
4
|
describe("runner reporting", () => {
|
|
5
5
|
it("builds run artifacts", () => {
|
|
@@ -78,7 +78,7 @@ describe("runner reporting", () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
expect(artifact.product.name).toBe("my-product");
|
|
81
|
-
expect(artifact.schemaVersion).toBe(
|
|
81
|
+
expect(artifact.schemaVersion).toBe(8);
|
|
82
82
|
expect(artifact.run).toMatchObject({
|
|
83
83
|
workers: 2,
|
|
84
84
|
fileTimeoutSeconds: 60,
|
|
@@ -109,7 +109,85 @@ describe("runner reporting", () => {
|
|
|
109
109
|
expect(artifact.services[0].totalTaskDurationMs).toBe(2400);
|
|
110
110
|
expect(artifact.logs).toEqual({
|
|
111
111
|
services: [],
|
|
112
|
+
setup: [],
|
|
112
113
|
});
|
|
114
|
+
expect(artifact.setup).toEqual({
|
|
115
|
+
operations: [],
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("builds live run artifacts with running status and setup details", () => {
|
|
120
|
+
const artifact = buildLiveRunArtifact({
|
|
121
|
+
productDir: "/tmp/my-product",
|
|
122
|
+
results: [],
|
|
123
|
+
startedAt: 1_000,
|
|
124
|
+
updatedAt: 2_000,
|
|
125
|
+
execution: {
|
|
126
|
+
workers: 2,
|
|
127
|
+
fileTimeoutSeconds: 60,
|
|
128
|
+
},
|
|
129
|
+
workerCount: 1,
|
|
130
|
+
runtimeInstanceCount: 1,
|
|
131
|
+
runtimeStats: [],
|
|
132
|
+
typeValues: ["int"],
|
|
133
|
+
suiteSelectors: [],
|
|
134
|
+
fileNames: [],
|
|
135
|
+
shard: null,
|
|
136
|
+
serviceFilter: null,
|
|
137
|
+
metadata: {
|
|
138
|
+
git: {
|
|
139
|
+
branch: "main",
|
|
140
|
+
commitSha: "abc",
|
|
141
|
+
repoRoot: "/tmp",
|
|
142
|
+
},
|
|
143
|
+
host: {
|
|
144
|
+
hostname: "local",
|
|
145
|
+
username: "dev",
|
|
146
|
+
},
|
|
147
|
+
testkitVersion: "0.1.54",
|
|
148
|
+
},
|
|
149
|
+
summarizeDbBackend: () => "local",
|
|
150
|
+
serviceLogs: [],
|
|
151
|
+
setupLogs: [
|
|
152
|
+
{
|
|
153
|
+
serviceName: "api",
|
|
154
|
+
runtimeLabel: "api",
|
|
155
|
+
stage: "runtime:prepare",
|
|
156
|
+
path: ".testkit/results/setup/api__api__runtime-prepare.log",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
setupOperations: [
|
|
160
|
+
{
|
|
161
|
+
id: "setup-1",
|
|
162
|
+
serviceName: "api",
|
|
163
|
+
runtimeLabel: "api",
|
|
164
|
+
stage: "runtime:prepare",
|
|
165
|
+
kind: "runtime-prepare",
|
|
166
|
+
summary: "runtime prepare",
|
|
167
|
+
parentId: null,
|
|
168
|
+
status: "running",
|
|
169
|
+
startedAt: "1970-01-01T00:00:01.000Z",
|
|
170
|
+
finishedAt: null,
|
|
171
|
+
durationMs: null,
|
|
172
|
+
error: null,
|
|
173
|
+
logRef: {
|
|
174
|
+
path: ".testkit/results/setup/api__api__runtime-prepare.log",
|
|
175
|
+
stage: "runtime:prepare",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(artifact.run.status).toBe("running");
|
|
182
|
+
expect(artifact.logs.setup).toEqual([
|
|
183
|
+
{
|
|
184
|
+
serviceName: "api",
|
|
185
|
+
runtimeLabel: "api",
|
|
186
|
+
stage: "runtime:prepare",
|
|
187
|
+
path: ".testkit/results/setup/api__api__runtime-prepare.log",
|
|
188
|
+
},
|
|
189
|
+
]);
|
|
190
|
+
expect(artifact.setup.operations).toHaveLength(1);
|
|
113
191
|
});
|
|
114
192
|
|
|
115
193
|
it("builds deterministic status artifacts", () => {
|
|
@@ -39,7 +39,7 @@ export async function ensureRuntimeInstanceReady(context, task, lifecycle, optio
|
|
|
39
39
|
if (!context.prepared) {
|
|
40
40
|
if (!context.preparationPromise) {
|
|
41
41
|
context.preparationPromise = (async () => {
|
|
42
|
-
await prepareDatabases(context.runtimeConfigs);
|
|
42
|
+
await prepareDatabases(context.runtimeConfigs, options);
|
|
43
43
|
await prepareRuntimeServices(context.runtimeConfigs, options);
|
|
44
44
|
context.prepared = true;
|
|
45
45
|
})().finally(() => {
|
|
@@ -80,8 +80,8 @@ export async function cleanupRuntimeInstanceContext(context, lifecycle) {
|
|
|
80
80
|
await deactivateRuntimeInstanceContext(context, lifecycle);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export async function prepareDatabases(runtimeConfigs) {
|
|
83
|
+
export async function prepareDatabases(runtimeConfigs, options = {}) {
|
|
84
84
|
for (const config of runtimeConfigs) {
|
|
85
|
-
await prepareDatabaseRuntime(config);
|
|
85
|
+
await prepareDatabaseRuntime(config, options);
|
|
86
86
|
}
|
|
87
87
|
}
|
|
@@ -28,6 +28,12 @@ export async function prepareRuntimeService(config, options = {}) {
|
|
|
28
28
|
const manifestPath = path.join(prepareDir, MANIFEST_FILE);
|
|
29
29
|
const existingManifest = readPrepareManifest(manifestPath);
|
|
30
30
|
if (existingManifest?.fingerprint === fingerprint) {
|
|
31
|
+
options.setupRegistry?.recordCached({
|
|
32
|
+
config,
|
|
33
|
+
stage: "runtime:prepare",
|
|
34
|
+
kind: "runtime-prepare",
|
|
35
|
+
summary: "runtime prepare cache hit",
|
|
36
|
+
});
|
|
31
37
|
return;
|
|
32
38
|
}
|
|
33
39
|
|
|
@@ -42,6 +48,14 @@ export async function prepareRuntimeService(config, options = {}) {
|
|
|
42
48
|
env.DATABASE_URL = databaseUrl;
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
const prepareOperation = options.setupRegistry?.start({
|
|
52
|
+
config,
|
|
53
|
+
stage: "runtime:prepare",
|
|
54
|
+
kind: "runtime-prepare",
|
|
55
|
+
summary: "runtime prepare",
|
|
56
|
+
recordLog: false,
|
|
57
|
+
});
|
|
58
|
+
|
|
45
59
|
try {
|
|
46
60
|
await announceResolvedToolchain(
|
|
47
61
|
config,
|
|
@@ -54,7 +68,16 @@ export async function prepareRuntimeService(config, options = {}) {
|
|
|
54
68
|
env,
|
|
55
69
|
labelPrefix: "runtime:prepare",
|
|
56
70
|
reporter: options.reporter,
|
|
71
|
+
setupRegistry: options.setupRegistry || null,
|
|
72
|
+
parentOperation: prepareOperation,
|
|
57
73
|
});
|
|
74
|
+
const finished = prepareOperation
|
|
75
|
+
? options.setupRegistry.finish(prepareOperation, {
|
|
76
|
+
status: "passed",
|
|
77
|
+
summary: "runtime prepare",
|
|
78
|
+
})
|
|
79
|
+
: null;
|
|
80
|
+
if (finished) options.reporter?.setupOperationFinished?.(finished);
|
|
58
81
|
writePrepareManifest(manifestPath, {
|
|
59
82
|
fingerprint,
|
|
60
83
|
preparedAt: new Date().toISOString(),
|
|
@@ -62,6 +85,14 @@ export async function prepareRuntimeService(config, options = {}) {
|
|
|
62
85
|
serviceName: config.name,
|
|
63
86
|
});
|
|
64
87
|
} catch (error) {
|
|
88
|
+
const finished = prepareOperation
|
|
89
|
+
? options.setupRegistry.finish(prepareOperation, {
|
|
90
|
+
status: "failed",
|
|
91
|
+
summary: "runtime prepare",
|
|
92
|
+
error: error?.message || error,
|
|
93
|
+
})
|
|
94
|
+
: null;
|
|
95
|
+
if (finished) options.reporter?.setupOperationFinished?.(finished);
|
|
65
96
|
fs.rmSync(prepareDir, { recursive: true, force: true });
|
|
66
97
|
throw error;
|
|
67
98
|
}
|
|
@@ -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
|
+
}
|