@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.
@@ -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
+ }
@@ -89,11 +89,7 @@ export async function announceResolvedToolchain(config, resolvedToolchain, repor
89
89
  announcedToolchains.add(config);
90
90
  if (reporter?.toolchainResolved) {
91
91
  reporter.toolchainResolved(config, resolvedToolchain);
92
- return;
93
92
  }
94
- console.log(
95
- `[testkit] ${config.runtimeLabel || config.name}:${config.name} toolchain ${resolvedToolchain.summary}`
96
- );
97
93
  }
98
94
 
99
95
  export function applyToolchainEnv(baseEnv, resolvedToolchain, processEnv = process.env) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.54",
3
+ "version": "0.1.55",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "types": "./lib/index.d.ts",