@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Args, Command } from "@oclif/core";
|
|
2
2
|
import { sharedFlags } from "../command-helpers.mjs";
|
|
3
|
-
import { collectArtifactEntries,
|
|
3
|
+
import { collectArtifactEntries, loadCurrentRunArtifact } from "../viewer.mjs";
|
|
4
4
|
|
|
5
5
|
export default class ArtifactsCommand extends Command {
|
|
6
6
|
static summary = "List persisted artifacts from the latest run";
|
|
@@ -19,7 +19,7 @@ export default class ArtifactsCommand extends Command {
|
|
|
19
19
|
async run() {
|
|
20
20
|
const { args, flags } = await this.parse(ArtifactsCommand);
|
|
21
21
|
const productDir = flags.dir || process.cwd();
|
|
22
|
-
const runArtifact =
|
|
22
|
+
const runArtifact = loadCurrentRunArtifact(productDir);
|
|
23
23
|
const entries = collectArtifactEntries(productDir, runArtifact, args.file || null, flags.service || null)
|
|
24
24
|
.map((entry) => ({
|
|
25
25
|
service: entry.service.name,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Args, Command, Flags } from "@oclif/core";
|
|
2
2
|
import { sharedFlags } from "../command-helpers.mjs";
|
|
3
3
|
import { readLogTail } from "../../runner/logs.mjs";
|
|
4
|
-
import { getServiceLogRefs,
|
|
4
|
+
import { getServiceLogRefs, loadCurrentRunArtifact, resolveFileSubject } from "../viewer.mjs";
|
|
5
5
|
import path from "path";
|
|
6
6
|
|
|
7
7
|
export default class LogsCommand extends Command {
|
|
@@ -27,7 +27,7 @@ export default class LogsCommand extends Command {
|
|
|
27
27
|
async run() {
|
|
28
28
|
const { args, flags } = await this.parse(LogsCommand);
|
|
29
29
|
const productDir = flags.dir || process.cwd();
|
|
30
|
-
const runArtifact =
|
|
30
|
+
const runArtifact = loadCurrentRunArtifact(productDir);
|
|
31
31
|
const subject = resolveFileSubject(runArtifact, args.file || null, flags.service || null);
|
|
32
32
|
const logs = getServiceLogRefs(runArtifact, subject.service.name).map((entry) => ({
|
|
33
33
|
...entry,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Args, Command, Flags } from "@oclif/core";
|
|
2
2
|
import { sharedFlags } from "../command-helpers.mjs";
|
|
3
|
-
import { formatFileDetail,
|
|
3
|
+
import { formatFileDetail, loadCurrentRunArtifact, resolveFileSubject } from "../viewer.mjs";
|
|
4
4
|
|
|
5
5
|
export default class ShowCommand extends Command {
|
|
6
6
|
static summary = "Show the most useful details for one file from the latest run";
|
|
@@ -25,7 +25,7 @@ export default class ShowCommand extends Command {
|
|
|
25
25
|
async run() {
|
|
26
26
|
const { args, flags } = await this.parse(ShowCommand);
|
|
27
27
|
const productDir = flags.dir || process.cwd();
|
|
28
|
-
const runArtifact =
|
|
28
|
+
const runArtifact = loadCurrentRunArtifact(productDir);
|
|
29
29
|
const subject = resolveFileSubject(runArtifact, args.file || null, flags.service || null);
|
|
30
30
|
const result = {
|
|
31
31
|
file: subject.file,
|
package/lib/cli/db.mjs
CHANGED
|
@@ -3,6 +3,9 @@ import os from "os";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { loadConfigs, resolveProductDir } from "../config/index.mjs";
|
|
5
5
|
import { captureDatabaseTemplateSnapshot, prepareDatabaseRuntime } from "../database/index.mjs";
|
|
6
|
+
import { createRunReporter } from "./presentation/run-reporter.mjs";
|
|
7
|
+
import { createRunLogRegistry } from "../runner/logs.mjs";
|
|
8
|
+
import { createSetupOperationRegistry } from "../runner/setup-operations.mjs";
|
|
6
9
|
import { resolveRuntimeInstanceConfigs } from "../runner/template.mjs";
|
|
7
10
|
|
|
8
11
|
export async function runDatabaseSnapshotCaptureCommand(options = {}) {
|
|
@@ -28,16 +31,28 @@ export async function runDatabaseSnapshotCaptureCommand(options = {}) {
|
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
const absoluteOutputPath = path.resolve(productDir, outputPath);
|
|
34
|
+
const reporter = createRunReporter({ outputMode: options.debug ? "debug" : "compact" });
|
|
35
|
+
const logRegistry = createRunLogRegistry(productDir);
|
|
36
|
+
const setupRegistry = createSetupOperationRegistry({ logRegistry });
|
|
31
37
|
try {
|
|
32
38
|
for (const config of topologicallySortConfigs(resolvedConfigs)) {
|
|
33
39
|
if (config.name === resolvedTarget.name) continue;
|
|
34
40
|
if (config.testkit.database?.provider === "local") {
|
|
35
|
-
await prepareDatabaseRuntime(config
|
|
41
|
+
await prepareDatabaseRuntime(config, {
|
|
42
|
+
reporter,
|
|
43
|
+
logRegistry,
|
|
44
|
+
setupRegistry,
|
|
45
|
+
});
|
|
36
46
|
}
|
|
37
47
|
}
|
|
38
|
-
await captureDatabaseTemplateSnapshot(resolvedTarget, absoluteOutputPath
|
|
48
|
+
await captureDatabaseTemplateSnapshot(resolvedTarget, absoluteOutputPath, {
|
|
49
|
+
reporter,
|
|
50
|
+
logRegistry,
|
|
51
|
+
setupRegistry,
|
|
52
|
+
});
|
|
39
53
|
console.log(`Wrote ${path.relative(productDir, absoluteOutputPath)}`);
|
|
40
54
|
} finally {
|
|
55
|
+
logRegistry.closeAll();
|
|
41
56
|
fs.rmSync(runtimeRoot, { recursive: true, force: true });
|
|
42
57
|
}
|
|
43
58
|
}
|
|
@@ -26,6 +26,27 @@ export function createRunReporter({ outputMode = "compact", stdout = process.std
|
|
|
26
26
|
`[testkit] ${config.runtimeLabel || config.name}:${config.name} toolchain ${resolvedToolchain.summary}\n`
|
|
27
27
|
);
|
|
28
28
|
},
|
|
29
|
+
setupOperationFinished(operation) {
|
|
30
|
+
if (!operation) return;
|
|
31
|
+
if (mode === "json") return;
|
|
32
|
+
if (operation.status === "cached") return;
|
|
33
|
+
const duration = formatDuration(operation.durationMs || 0);
|
|
34
|
+
if (operation.status === "failed") {
|
|
35
|
+
const detail = shortenMessage(operation.error || operation.summary || operation.stage);
|
|
36
|
+
stdout.write(
|
|
37
|
+
`${colorStatus("FAIL")} ${"SETUP"} ${operation.serviceName} ${operation.stage} ${dim(duration)} ${detail}\n`
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (
|
|
42
|
+
mode === "compact" &&
|
|
43
|
+
isHighLevelSetupOperation(operation) &&
|
|
44
|
+
(operation.durationMs || 0) >= 5_000
|
|
45
|
+
) {
|
|
46
|
+
const summary = shortenMessage(operation.summary || operation.stage);
|
|
47
|
+
stdout.write(`${colorStatus("RUN")} ${"SETUP"} ${operation.serviceName} ${summary} ${dim(duration)}\n`);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
29
50
|
localServiceStarting(config, command) {
|
|
30
51
|
if (mode !== "debug") return;
|
|
31
52
|
stdout.write(`Starting ${config.runtimeLabel}:${config.name}: ${command}\n`);
|
|
@@ -98,3 +119,7 @@ function isThresholdWrapperMessage(message) {
|
|
|
98
119
|
function normalizePath(filePath) {
|
|
99
120
|
return String(filePath).split(path.sep).join("/");
|
|
100
121
|
}
|
|
122
|
+
|
|
123
|
+
function isHighLevelSetupOperation(operation) {
|
|
124
|
+
return operation.kind === "database-template" || operation.kind === "runtime-prepare";
|
|
125
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Writable } from "stream";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createRunReporter } from "./run-reporter.mjs";
|
|
4
|
+
|
|
5
|
+
describe("run reporter setup output", () => {
|
|
6
|
+
it("prints concise high-level setup summaries in compact mode", () => {
|
|
7
|
+
let stdout = "";
|
|
8
|
+
const reporter = createRunReporter({
|
|
9
|
+
outputMode: "compact",
|
|
10
|
+
stdout: new Writable({
|
|
11
|
+
write(chunk, _encoding, callback) {
|
|
12
|
+
stdout += chunk.toString();
|
|
13
|
+
callback();
|
|
14
|
+
},
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
reporter.setupOperationFinished({
|
|
19
|
+
serviceName: "api",
|
|
20
|
+
stage: "template",
|
|
21
|
+
kind: "database-template",
|
|
22
|
+
summary: "template rebuild",
|
|
23
|
+
status: "passed",
|
|
24
|
+
durationMs: 8_000,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(stdout).toContain("RUN SETUP api template rebuild");
|
|
28
|
+
expect(stdout).toContain("8s");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does not print low-level setup steps in compact mode", () => {
|
|
32
|
+
let stdout = "";
|
|
33
|
+
const reporter = createRunReporter({
|
|
34
|
+
outputMode: "compact",
|
|
35
|
+
stdout: new Writable({
|
|
36
|
+
write(chunk, _encoding, callback) {
|
|
37
|
+
stdout += chunk.toString();
|
|
38
|
+
callback();
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
reporter.setupOperationFinished({
|
|
44
|
+
serviceName: "api",
|
|
45
|
+
stage: "template:migrate:api:1",
|
|
46
|
+
kind: "setup-step",
|
|
47
|
+
summary: "sql-file: db/schema.sql",
|
|
48
|
+
status: "passed",
|
|
49
|
+
durationMs: 8_000,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(stdout).toBe("");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("prints concise setup failures", () => {
|
|
56
|
+
let stdout = "";
|
|
57
|
+
const reporter = createRunReporter({
|
|
58
|
+
outputMode: "compact",
|
|
59
|
+
stdout: new Writable({
|
|
60
|
+
write(chunk, _encoding, callback) {
|
|
61
|
+
stdout += chunk.toString();
|
|
62
|
+
callback();
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
reporter.setupOperationFinished({
|
|
68
|
+
serviceName: "api",
|
|
69
|
+
stage: "runtime:prepare",
|
|
70
|
+
kind: "runtime-prepare",
|
|
71
|
+
summary: "runtime prepare",
|
|
72
|
+
status: "failed",
|
|
73
|
+
durationMs: 1_200,
|
|
74
|
+
error: "Command failed with exit code 1: node scripts/fail-prepare.mjs",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(stdout).toContain("FAIL SETUP api runtime:prepare");
|
|
78
|
+
expect(stdout).toContain("Command failed with exit code 1");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
1
3
|
import React, { createElement, useEffect, useMemo, useState } from "react";
|
|
2
4
|
import { Box, Text, useApp, useInput } from "ink";
|
|
3
5
|
import { formatDuration } from "../../runner/formatting.mjs";
|
|
4
|
-
import { formatFileDetail,
|
|
6
|
+
import { formatFileDetail, loadCurrentRunArtifact } from "../viewer.mjs";
|
|
5
7
|
|
|
6
8
|
export function WatchApp({ productDir, serviceFilter = null }) {
|
|
7
9
|
const { exit } = useApp();
|
|
8
10
|
const [artifact, setArtifact] = useState(() => safeLoadArtifact(productDir));
|
|
9
|
-
const [
|
|
11
|
+
const [view, setView] = useState("files");
|
|
12
|
+
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
|
|
13
|
+
const [selectedSetupIndex, setSelectedSetupIndex] = useState(0);
|
|
10
14
|
|
|
11
15
|
const files = useMemo(() => collectFiles(artifact, serviceFilter), [artifact, serviceFilter]);
|
|
12
|
-
const
|
|
16
|
+
const setupOperations = useMemo(
|
|
17
|
+
() => collectSetupOperations(artifact, serviceFilter),
|
|
18
|
+
[artifact, serviceFilter]
|
|
19
|
+
);
|
|
20
|
+
const selectedFile = files[Math.min(selectedFileIndex, Math.max(0, files.length - 1))] || null;
|
|
21
|
+
const selectedSetup =
|
|
22
|
+
setupOperations[Math.min(selectedSetupIndex, Math.max(0, setupOperations.length - 1))] || null;
|
|
13
23
|
|
|
14
24
|
useEffect(() => {
|
|
15
25
|
const timer = setInterval(() => {
|
|
@@ -18,6 +28,14 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
18
28
|
return () => clearInterval(timer);
|
|
19
29
|
}, [productDir]);
|
|
20
30
|
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
setSelectedFileIndex((current) => Math.min(current, Math.max(0, files.length - 1)));
|
|
33
|
+
}, [files.length]);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setSelectedSetupIndex((current) => Math.min(current, Math.max(0, setupOperations.length - 1)));
|
|
37
|
+
}, [setupOperations.length]);
|
|
38
|
+
|
|
21
39
|
useInput((input, key) => {
|
|
22
40
|
if (input === "q") {
|
|
23
41
|
exit();
|
|
@@ -27,12 +45,27 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
27
45
|
setArtifact(safeLoadArtifact(productDir));
|
|
28
46
|
return;
|
|
29
47
|
}
|
|
48
|
+
if (key.tab || input === "s") {
|
|
49
|
+
setView((current) => {
|
|
50
|
+
if (current === "files" && setupOperations.length > 0) return "setup";
|
|
51
|
+
return "files";
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
30
55
|
if (key.downArrow) {
|
|
31
|
-
|
|
56
|
+
if (view === "setup") {
|
|
57
|
+
setSelectedSetupIndex((current) => Math.min(current + 1, Math.max(0, setupOperations.length - 1)));
|
|
58
|
+
} else {
|
|
59
|
+
setSelectedFileIndex((current) => Math.min(current + 1, Math.max(0, files.length - 1)));
|
|
60
|
+
}
|
|
32
61
|
return;
|
|
33
62
|
}
|
|
34
63
|
if (key.upArrow) {
|
|
35
|
-
|
|
64
|
+
if (view === "setup") {
|
|
65
|
+
setSelectedSetupIndex((current) => Math.max(0, current - 1));
|
|
66
|
+
} else {
|
|
67
|
+
setSelectedFileIndex((current) => Math.max(0, current - 1));
|
|
68
|
+
}
|
|
36
69
|
}
|
|
37
70
|
});
|
|
38
71
|
|
|
@@ -46,7 +79,7 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
46
79
|
createElement(
|
|
47
80
|
Text,
|
|
48
81
|
null,
|
|
49
|
-
`testkit watch · q quit · r reload · ${artifact.run.status} · ${formatDuration(artifact.run.durationMs)}`
|
|
82
|
+
`testkit watch · q quit · r reload · tab toggle · ${artifact.run.status} · ${formatDuration(artifact.run.durationMs)}`
|
|
50
83
|
),
|
|
51
84
|
createElement(
|
|
52
85
|
Box,
|
|
@@ -54,22 +87,21 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
54
87
|
createElement(
|
|
55
88
|
Box,
|
|
56
89
|
{ width: "40%", flexDirection: "column", marginRight: 2 },
|
|
57
|
-
createElement(Text, null, "Files"),
|
|
58
|
-
...
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
Text,
|
|
62
|
-
{ key: `${entry.service.name}:${entry.file.path}` },
|
|
63
|
-
`${prefix} ${entry.file.status.toUpperCase().padEnd(7)} ${entry.file.path}`
|
|
64
|
-
);
|
|
65
|
-
})
|
|
90
|
+
createElement(Text, null, view === "setup" ? "Setup" : "Files"),
|
|
91
|
+
...(view === "setup"
|
|
92
|
+
? renderSetupEntries(setupOperations, selectedSetupIndex)
|
|
93
|
+
: renderFileEntries(files, selectedFileIndex))
|
|
66
94
|
),
|
|
67
95
|
createElement(
|
|
68
96
|
Box,
|
|
69
97
|
{ width: "60%", flexDirection: "column" },
|
|
70
98
|
createElement(Text, null, "Details"),
|
|
71
|
-
...(
|
|
72
|
-
?
|
|
99
|
+
...(view === "setup"
|
|
100
|
+
? formatSetupDetail(productDir, selectedSetup)
|
|
101
|
+
.slice(0, 28)
|
|
102
|
+
.map((line, index) => createElement(Text, { key: `${index}:${line}` }, line))
|
|
103
|
+
: selectedFile
|
|
104
|
+
? formatFileDetail(productDir, artifact, selectedFile, { logTail: 8 })
|
|
73
105
|
.slice(0, 28)
|
|
74
106
|
.map((line, index) => createElement(Text, { key: `${index}:${line}` }, line))
|
|
75
107
|
: [createElement(Text, { key: "empty" }, "No file results")])
|
|
@@ -80,7 +112,7 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
80
112
|
|
|
81
113
|
function safeLoadArtifact(productDir) {
|
|
82
114
|
try {
|
|
83
|
-
return
|
|
115
|
+
return loadCurrentRunArtifact(productDir);
|
|
84
116
|
} catch {
|
|
85
117
|
return null;
|
|
86
118
|
}
|
|
@@ -102,3 +134,87 @@ function collectFiles(runArtifact, serviceFilter) {
|
|
|
102
134
|
left.file.path.localeCompare(right.file.path)
|
|
103
135
|
);
|
|
104
136
|
}
|
|
137
|
+
|
|
138
|
+
function renderFileEntries(files, selectedIndex) {
|
|
139
|
+
return files.map((entry, index) => {
|
|
140
|
+
const prefix = index === selectedIndex ? ">" : " ";
|
|
141
|
+
return createElement(
|
|
142
|
+
Text,
|
|
143
|
+
{ key: `${entry.service.name}:${entry.file.path}` },
|
|
144
|
+
`${prefix} ${entry.file.status.toUpperCase().padEnd(7)} ${entry.file.path}`
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function collectSetupOperations(runArtifact, serviceFilter) {
|
|
150
|
+
if (!runArtifact) return [];
|
|
151
|
+
return [...(runArtifact.setup?.operations || [])]
|
|
152
|
+
.filter((entry) => !serviceFilter || entry.serviceName === serviceFilter)
|
|
153
|
+
.sort((left, right) => {
|
|
154
|
+
return (
|
|
155
|
+
setupStatusRank(left.status) - setupStatusRank(right.status) ||
|
|
156
|
+
String(left.serviceName || "").localeCompare(String(right.serviceName || "")) ||
|
|
157
|
+
String(left.startedAt || "").localeCompare(String(right.startedAt || "")) ||
|
|
158
|
+
String(left.stage || "").localeCompare(String(right.stage || ""))
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderSetupEntries(operations, selectedIndex) {
|
|
164
|
+
return operations.map((entry, index) => {
|
|
165
|
+
const prefix = index === selectedIndex ? ">" : " ";
|
|
166
|
+
const duration = entry.durationMs == null ? "" : ` ${formatDuration(entry.durationMs)}`;
|
|
167
|
+
return createElement(
|
|
168
|
+
Text,
|
|
169
|
+
{ key: entry.id },
|
|
170
|
+
`${prefix} ${String(entry.status || "").toUpperCase().padEnd(7)} ${entry.serviceName} ${entry.stage}${duration}`
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatSetupDetail(productDir, operation) {
|
|
176
|
+
if (!operation) return ["No setup operations"];
|
|
177
|
+
const lines = [
|
|
178
|
+
`Service: ${operation.serviceName}`,
|
|
179
|
+
`Stage: ${operation.stage}`,
|
|
180
|
+
`Status: ${operation.status}`,
|
|
181
|
+
];
|
|
182
|
+
if (operation.durationMs != null) {
|
|
183
|
+
lines.push(`Duration: ${formatDuration(operation.durationMs)}`);
|
|
184
|
+
}
|
|
185
|
+
if (operation.summary) {
|
|
186
|
+
lines.push(`Summary: ${operation.summary}`);
|
|
187
|
+
}
|
|
188
|
+
if (operation.error) {
|
|
189
|
+
lines.push(`Error: ${operation.error}`);
|
|
190
|
+
}
|
|
191
|
+
if (operation.logRef?.path) {
|
|
192
|
+
lines.push("");
|
|
193
|
+
lines.push("Log:");
|
|
194
|
+
lines.push(` ${operation.logRef.path}`);
|
|
195
|
+
const absolutePath = path.join(productDir, operation.logRef.path);
|
|
196
|
+
for (const line of readTailSafe(absolutePath, 12)) {
|
|
197
|
+
lines.push(` ${line}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readTailSafe(absolutePath, maxLines) {
|
|
204
|
+
try {
|
|
205
|
+
return fs.readFileSync(absolutePath, "utf8")
|
|
206
|
+
.split(/\r?\n/)
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.slice(-maxLines);
|
|
209
|
+
} catch {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function setupStatusRank(status) {
|
|
215
|
+
if (status === "failed") return 1;
|
|
216
|
+
if (status === "running") return 2;
|
|
217
|
+
if (status === "passed") return 3;
|
|
218
|
+
if (status === "cached") return 4;
|
|
219
|
+
return 5;
|
|
220
|
+
}
|
package/lib/cli/viewer.mjs
CHANGED
|
@@ -16,6 +16,14 @@ export function loadLatestRunArtifact(productDir) {
|
|
|
16
16
|
return JSON.parse(fs.readFileSync(artifactPath, "utf8"));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function loadCurrentRunArtifact(productDir) {
|
|
20
|
+
const livePath = path.join(productDir, ".testkit", "results", "live.json");
|
|
21
|
+
if (fs.existsSync(livePath)) {
|
|
22
|
+
return JSON.parse(fs.readFileSync(livePath, "utf8"));
|
|
23
|
+
}
|
|
24
|
+
return loadLatestRunArtifact(productDir);
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
export function resolveFileSubject(runArtifact, selector = null, serviceFilter = null) {
|
|
20
28
|
const files = collectFiles(runArtifact, serviceFilter);
|
|
21
29
|
if (files.length === 0) {
|
|
@@ -108,6 +116,25 @@ export function formatFileDetail(productDir, runArtifact, subject, options = {})
|
|
|
108
116
|
for (const line of triageLines) lines.push(` ${line}`);
|
|
109
117
|
}
|
|
110
118
|
|
|
119
|
+
const setupOperations = getSetupOperationsForService(runArtifact, subject.service.name);
|
|
120
|
+
if (setupOperations.length > 0) {
|
|
121
|
+
lines.push("");
|
|
122
|
+
lines.push("Setup:");
|
|
123
|
+
for (const operation of setupOperations.slice(0, 8)) {
|
|
124
|
+
const duration = operation.durationMs == null ? "" : ` ${formatDuration(operation.durationMs)}`;
|
|
125
|
+
const suffix = operation.summary ? ` ${operation.summary}` : "";
|
|
126
|
+
lines.push(` ${operation.status} ${operation.stage}${duration}${suffix}`);
|
|
127
|
+
if (operation.logRef?.path) lines.push(` ${operation.logRef.path}`);
|
|
128
|
+
if (operation.error) lines.push(` ${operation.error}`);
|
|
129
|
+
if (operation.logRef?.path) {
|
|
130
|
+
const setupLogPath = path.join(productDir, operation.logRef.path);
|
|
131
|
+
for (const line of readLogTail(setupLogPath, 4).slice(-4)) {
|
|
132
|
+
lines.push(` ${line}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
111
138
|
const artifacts = collectArtifactEntries(productDir, runArtifact, subject.file.path, subject.service.name);
|
|
112
139
|
if (artifacts.length > 0) {
|
|
113
140
|
lines.push("");
|
|
@@ -148,6 +175,16 @@ export function getServiceLogRefs(runArtifact, serviceName) {
|
|
|
148
175
|
return (runArtifact.logs?.services || []).filter((entry) => entry.serviceName === serviceName);
|
|
149
176
|
}
|
|
150
177
|
|
|
178
|
+
export function getSetupOperationsForService(runArtifact, serviceName) {
|
|
179
|
+
return (runArtifact.setup?.operations || [])
|
|
180
|
+
.filter((entry) => entry.serviceName === serviceName)
|
|
181
|
+
.sort(
|
|
182
|
+
(left, right) =>
|
|
183
|
+
String(left.startedAt || "").localeCompare(String(right.startedAt || "")) ||
|
|
184
|
+
String(left.stage || "").localeCompare(String(right.stage || ""))
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
151
188
|
export function formatArtifactPreview(payload, maxLines = 6) {
|
|
152
189
|
if (!payload) return ["artifact payload missing"];
|
|
153
190
|
if (payload.kind === "agentic-query") {
|
package/lib/database/index.mjs
CHANGED
|
@@ -36,25 +36,25 @@ const LOCAL_POLL_INTERVAL_MS = 1_000;
|
|
|
36
36
|
const LOCAL_DROP_DATABASE_TIMEOUT_MS = 15_000;
|
|
37
37
|
const LOCAL_DROP_DATABASE_POLL_INTERVAL_MS = 250;
|
|
38
38
|
|
|
39
|
-
export async function prepareDatabaseRuntime(config) {
|
|
39
|
+
export async function prepareDatabaseRuntime(config, options = {}) {
|
|
40
40
|
const db = config.testkit.database;
|
|
41
41
|
if (!db) return;
|
|
42
42
|
|
|
43
43
|
fs.mkdirSync(config.stateDir, { recursive: true });
|
|
44
44
|
if (db.provider === "local") {
|
|
45
|
-
await prepareLocalDatabase(config);
|
|
45
|
+
await prepareLocalDatabase(config, options);
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
throw new Error(`Unsupported database provider "${db.provider}"`);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export async function captureDatabaseTemplateSnapshot(config, outputPath) {
|
|
52
|
+
export async function captureDatabaseTemplateSnapshot(config, outputPath, options = {}) {
|
|
53
53
|
if (!config.testkit.database || config.testkit.database.provider !== "local") {
|
|
54
54
|
throw new Error(`Service "${config.name}" does not use a local testkit database`);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
await prepareDatabaseRuntime(config);
|
|
57
|
+
await prepareDatabaseRuntime(config, options);
|
|
58
58
|
const cacheDir = getLocalServiceCacheDir(config.productDir, config.name);
|
|
59
59
|
const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
|
|
60
60
|
if (!templateDbName) {
|
|
@@ -66,7 +66,41 @@ export async function captureDatabaseTemplateSnapshot(config, outputPath) {
|
|
|
66
66
|
throw new Error(`Missing local database container for service "${config.name}"`);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
const snapshotOperation = options.setupRegistry?.start({
|
|
70
|
+
config,
|
|
71
|
+
stage: "template:snapshot",
|
|
72
|
+
kind: "database-snapshot",
|
|
73
|
+
summary: `snapshot: ${path.relative(config.productDir, outputPath)}`,
|
|
74
|
+
});
|
|
75
|
+
try {
|
|
76
|
+
const output = await captureTemplateSnapshot(
|
|
77
|
+
config,
|
|
78
|
+
outputPath,
|
|
79
|
+
buildDatabaseUrl(infra, templateDbName),
|
|
80
|
+
{
|
|
81
|
+
reporter: options.reporter || null,
|
|
82
|
+
logRecord: snapshotOperation?._logRecord || null,
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
const finished = snapshotOperation
|
|
86
|
+
? options.setupRegistry.finish(snapshotOperation, {
|
|
87
|
+
status: "passed",
|
|
88
|
+
summary: `snapshot: ${path.relative(config.productDir, outputPath)}`,
|
|
89
|
+
})
|
|
90
|
+
: null;
|
|
91
|
+
if (finished) options.reporter?.setupOperationFinished?.(finished);
|
|
92
|
+
return output;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
const finished = snapshotOperation
|
|
95
|
+
? options.setupRegistry.finish(snapshotOperation, {
|
|
96
|
+
status: "failed",
|
|
97
|
+
summary: `snapshot: ${path.relative(config.productDir, outputPath)}`,
|
|
98
|
+
error: error?.message || error,
|
|
99
|
+
})
|
|
100
|
+
: null;
|
|
101
|
+
if (finished) options.reporter?.setupOperationFinished?.(finished);
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
70
104
|
}
|
|
71
105
|
|
|
72
106
|
export async function destroyRuntimeDatabase({ productDir, stateDir }) {
|
|
@@ -138,7 +172,7 @@ export function showServiceDatabaseStatus(productDir, serviceName) {
|
|
|
138
172
|
return true;
|
|
139
173
|
}
|
|
140
174
|
|
|
141
|
-
async function prepareLocalDatabase(config) {
|
|
175
|
+
async function prepareLocalDatabase(config, options = {}) {
|
|
142
176
|
const db = config.testkit.database;
|
|
143
177
|
const productDir = config.productDir;
|
|
144
178
|
const serviceName = config.name;
|
|
@@ -154,7 +188,7 @@ async function prepareLocalDatabase(config) {
|
|
|
154
188
|
);
|
|
155
189
|
|
|
156
190
|
await withLock(path.join(lockDir, `template-${serviceName}.lock`), async () => {
|
|
157
|
-
await ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint);
|
|
191
|
+
await ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, options);
|
|
158
192
|
});
|
|
159
193
|
|
|
160
194
|
await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(bindingKey, 10)}.lock`), async () => {
|
|
@@ -162,7 +196,7 @@ async function prepareLocalDatabase(config) {
|
|
|
162
196
|
});
|
|
163
197
|
}
|
|
164
198
|
|
|
165
|
-
async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint) {
|
|
199
|
+
async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, options = {}) {
|
|
166
200
|
const serviceName = config.name;
|
|
167
201
|
const existingFingerprint = readStateValue(path.join(cacheDir, "template_fingerprint"));
|
|
168
202
|
const existingDbName = readStateValue(path.join(cacheDir, "template_database_name"));
|
|
@@ -173,6 +207,12 @@ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerpri
|
|
|
173
207
|
existingDbName &&
|
|
174
208
|
(await databaseExists(infra, existingDbName))
|
|
175
209
|
) {
|
|
210
|
+
options.setupRegistry?.recordCached({
|
|
211
|
+
config,
|
|
212
|
+
stage: "template",
|
|
213
|
+
kind: "database-template",
|
|
214
|
+
summary: "template cache hit",
|
|
215
|
+
});
|
|
176
216
|
writeLocalCacheState(cacheDir, infra, existingDbName, templateFingerprint);
|
|
177
217
|
return;
|
|
178
218
|
}
|
|
@@ -186,11 +226,45 @@ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerpri
|
|
|
186
226
|
|
|
187
227
|
const templateUrl = buildDatabaseUrl(infra, desiredDbName);
|
|
188
228
|
await createEmptyDatabase(infra, desiredDbName);
|
|
229
|
+
const templateOperation = options.setupRegistry?.start({
|
|
230
|
+
config,
|
|
231
|
+
stage: "template",
|
|
232
|
+
kind: "database-template",
|
|
233
|
+
summary: "template rebuild",
|
|
234
|
+
recordLog: false,
|
|
235
|
+
});
|
|
189
236
|
try {
|
|
190
|
-
await runTemplateStage(config, "migrate", templateUrl
|
|
191
|
-
|
|
192
|
-
|
|
237
|
+
await runTemplateStage(config, "migrate", templateUrl, {
|
|
238
|
+
reporter: options.reporter || null,
|
|
239
|
+
setupRegistry: options.setupRegistry || null,
|
|
240
|
+
parentOperation: templateOperation,
|
|
241
|
+
});
|
|
242
|
+
await runTemplateStage(config, "seed", templateUrl, {
|
|
243
|
+
reporter: options.reporter || null,
|
|
244
|
+
setupRegistry: options.setupRegistry || null,
|
|
245
|
+
parentOperation: templateOperation,
|
|
246
|
+
});
|
|
247
|
+
await runTemplateStage(config, "verify", templateUrl, {
|
|
248
|
+
reporter: options.reporter || null,
|
|
249
|
+
setupRegistry: options.setupRegistry || null,
|
|
250
|
+
parentOperation: templateOperation,
|
|
251
|
+
});
|
|
252
|
+
const finished = templateOperation
|
|
253
|
+
? options.setupRegistry.finish(templateOperation, {
|
|
254
|
+
status: "passed",
|
|
255
|
+
summary: "template rebuild",
|
|
256
|
+
})
|
|
257
|
+
: null;
|
|
258
|
+
if (finished) options.reporter?.setupOperationFinished?.(finished);
|
|
193
259
|
} catch (error) {
|
|
260
|
+
const finished = templateOperation
|
|
261
|
+
? options.setupRegistry.finish(templateOperation, {
|
|
262
|
+
status: "failed",
|
|
263
|
+
summary: "template rebuild",
|
|
264
|
+
error: error?.message || error,
|
|
265
|
+
})
|
|
266
|
+
: null;
|
|
267
|
+
if (finished) options.reporter?.setupOperationFinished?.(finished);
|
|
194
268
|
await dropDatabaseIfExists(infra, desiredDbName);
|
|
195
269
|
throw error;
|
|
196
270
|
}
|