@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.
Files changed (41) hide show
  1. package/lib/cli/commands/artifacts.mjs +2 -2
  2. package/lib/cli/commands/logs.mjs +2 -2
  3. package/lib/cli/commands/show.mjs +2 -2
  4. package/lib/cli/db.mjs +17 -2
  5. package/lib/cli/presentation/code-frames.mjs +57 -0
  6. package/lib/cli/presentation/code-frames.test.mjs +71 -0
  7. package/lib/cli/presentation/colors.mjs +29 -0
  8. package/lib/cli/presentation/run-reporter.mjs +41 -7
  9. package/lib/cli/presentation/run-reporter.test.mjs +80 -0
  10. package/lib/cli/tui/watch-app.mjs +134 -18
  11. package/lib/cli/viewer.mjs +146 -4
  12. package/lib/database/index.mjs +85 -11
  13. package/lib/database/template-steps.mjs +45 -6
  14. package/lib/database/template-steps.test.mjs +43 -0
  15. package/lib/known-failures/index.mjs +1 -1
  16. package/lib/known-failures/index.test.mjs +46 -0
  17. package/lib/runner/artifacts.mjs +16 -0
  18. package/lib/runner/default-runtime-errors.mjs +66 -0
  19. package/lib/runner/default-runtime-runner.mjs +8 -1
  20. package/lib/runner/failure-details.mjs +31 -0
  21. package/lib/runner/failure-details.test.mjs +51 -0
  22. package/lib/runner/formatting.mjs +114 -4
  23. package/lib/runner/formatting.test.mjs +77 -0
  24. package/lib/runner/logs.mjs +71 -6
  25. package/lib/runner/orchestrator.mjs +63 -7
  26. package/lib/runner/reporting.mjs +52 -2
  27. package/lib/runner/reporting.test.mjs +80 -2
  28. package/lib/runner/runtime-contexts.mjs +3 -3
  29. package/lib/runner/runtime-preparation.mjs +31 -0
  30. package/lib/runner/setup-operations.mjs +115 -0
  31. package/lib/runner/setup-operations.test.mjs +94 -0
  32. package/lib/runner/template-steps.mjs +129 -11
  33. package/lib/runner/triage.mjs +67 -0
  34. package/lib/runtime/index.d.ts +60 -0
  35. package/lib/runtime/index.mjs +12 -0
  36. package/lib/runtime-src/k6/checks.js +45 -12
  37. package/lib/runtime-src/k6/http-assertions.js +214 -0
  38. package/lib/runtime-src/k6/http.js +261 -13
  39. package/lib/runtime-src/k6/suite.js +46 -1
  40. package/lib/toolchains/index.mjs +0 -4
  41. package/package.json +3 -1
@@ -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: 7,
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(7);
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({ config, steps = [], env, labelPrefix, reporter = null }) {
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
- if (reporter?.phaseStarted) reporter.phaseStarted(label);
34
- else console.log(`\n── ${label} ──`);
35
- await runConfiguredStep(config, step, env, resolvedToolchain);
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
- await execaCommand(step.cmd, {
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
- await execa(
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
- stdio: "inherit",
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
- await execa(
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
- stdio: "inherit",
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
+ }