@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.
@@ -1,6 +1,6 @@
1
1
  import { Args, Command } from "@oclif/core";
2
2
  import { sharedFlags } from "../command-helpers.mjs";
3
- import { collectArtifactEntries, loadLatestRunArtifact } from "../viewer.mjs";
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 = loadLatestRunArtifact(productDir);
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, loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
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 = loadLatestRunArtifact(productDir);
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, loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
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 = loadLatestRunArtifact(productDir);
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, loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
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 [selectedIndex, setSelectedIndex] = useState(0);
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 selected = files[Math.min(selectedIndex, Math.max(0, files.length - 1))] || null;
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
- setSelectedIndex((current) => Math.min(current + 1, Math.max(0, files.length - 1)));
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
- setSelectedIndex((current) => Math.max(0, current - 1));
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
- ...files.map((entry, index) => {
59
- const prefix = index === selectedIndex ? ">" : " ";
60
- return createElement(
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
- ...(selected
72
- ? formatFileDetail(productDir, artifact, selected, { logTail: 8 })
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 loadLatestRunArtifact(productDir);
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
+ }
@@ -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") {
@@ -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
- return captureTemplateSnapshot(config, outputPath, buildDatabaseUrl(infra, templateDbName));
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
- await runTemplateStage(config, "seed", templateUrl);
192
- await runTemplateStage(config, "verify", templateUrl);
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
  }