@elench/testkit 0.1.85 → 0.1.87
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/README.md +43 -10
- package/lib/cli/commands/inspect.mjs +124 -0
- package/lib/cli/entrypoint.mjs +2 -4
- package/lib/cli/presentation/tree-reporter.mjs +34 -28
- package/lib/cli/tui/detail-pane.mjs +161 -0
- package/lib/cli/tui/filter-bar.mjs +12 -0
- package/lib/cli/tui/fuzzy-match.mjs +106 -0
- package/lib/cli/tui/inspect-app.mjs +306 -0
- package/lib/cli/tui/inspect-artifact-adapter.mjs +3 -0
- package/lib/cli/tui/inspect-live-adapter.mjs +15 -0
- package/lib/cli/tui/inspect-model.mjs +817 -0
- package/lib/cli/tui/inspect-state.mjs +321 -0
- package/lib/index.d.ts +30 -8
- package/lib/index.mjs +3 -0
- package/lib/runtime/index.d.ts +77 -0
- package/lib/runtime-src/k6/dal-fixtures.js +66 -0
- package/lib/runtime-src/k6/dal-suite.js +21 -1
- package/lib/runtime-src/shared/fixture-engine.mjs +320 -0
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +5 -5
- package/lib/cli/commands/artifacts.mjs +0 -45
- package/lib/cli/commands/logs.mjs +0 -47
- package/lib/cli/commands/show.mjs +0 -47
- package/lib/cli/commands/watch.mjs +0 -23
- package/lib/cli/tui/run-app.mjs +0 -1
- package/lib/cli/tui/run-session-app.mjs +0 -432
- package/lib/cli/tui/run-session-state.mjs +0 -505
- package/lib/cli/tui/run-tree-state.mjs +0 -1
- package/lib/cli/tui/watch-app.mjs +0 -220
package/README.md
CHANGED
|
@@ -56,11 +56,11 @@ npx @elench/testkit destroy
|
|
|
56
56
|
npx @elench/testkit cleanup
|
|
57
57
|
|
|
58
58
|
# Inspect the latest run artifact
|
|
59
|
-
npx @elench/testkit
|
|
60
|
-
npx @elench/testkit
|
|
61
|
-
npx @elench/testkit artifacts __testkit__/health/health.int.testkit.ts
|
|
62
|
-
npx @elench/testkit logs __testkit__/health/health.int.testkit.ts
|
|
63
|
-
npx @elench/testkit
|
|
59
|
+
npx @elench/testkit inspect
|
|
60
|
+
npx @elench/testkit inspect --file __testkit__/health/health.int.testkit.ts
|
|
61
|
+
npx @elench/testkit inspect --pane artifacts --file __testkit__/health/health.int.testkit.ts
|
|
62
|
+
npx @elench/testkit inspect --pane logs --file __testkit__/health/health.int.testkit.ts
|
|
63
|
+
npx @elench/testkit inspect --live
|
|
64
64
|
|
|
65
65
|
# Automatic regression intelligence
|
|
66
66
|
# Configure testkit.regressions.json and testkit classifies new vs known regressions automatically during runs
|
|
@@ -72,8 +72,7 @@ npx @elench/testkit db snapshot capture --service api --output scripts/testkit/s
|
|
|
72
72
|
`testkit` now keeps the default terminal output intentionally short: one line
|
|
73
73
|
per completed file, a concise failure block, and a final summary. Service logs,
|
|
74
74
|
captured runtime output, emitted artifacts, and user-visible LLM responses are
|
|
75
|
-
persisted under `.testkit/results/` and inspected on demand with `
|
|
76
|
-
`artifacts`, `logs`, or `watch`.
|
|
75
|
+
persisted under `.testkit/results/` and inspected on demand with `inspect`.
|
|
77
76
|
|
|
78
77
|
`testkit discover` also maintains a small durable per-test history index at
|
|
79
78
|
`.testkit/history/tests.json`. The index tracks first/last seen timestamps,
|
|
@@ -423,15 +422,49 @@ const suite = defineHttpSuite({ profile: "defaultAuth" }, ({ actor, actors, req
|
|
|
423
422
|
DAL suites:
|
|
424
423
|
|
|
425
424
|
```ts
|
|
426
|
-
import { defineDalSuite } from "@elench/testkit";
|
|
425
|
+
import { defineDalFixtures, defineDalSuite } from "@elench/testkit";
|
|
426
|
+
|
|
427
|
+
const fixtures = defineDalFixtures(({ db, fixtureScope }) => ({
|
|
428
|
+
widget() {
|
|
429
|
+
return fixtureScope.seed("widget", "primary", { name: "Primary Widget" }, () => {
|
|
430
|
+
const widgetId = fixtureScope.id("widget");
|
|
431
|
+
db.exec(`
|
|
432
|
+
INSERT INTO widgets (id, name)
|
|
433
|
+
VALUES ('${widgetId}', 'Primary Widget')
|
|
434
|
+
`);
|
|
435
|
+
return { widgetId };
|
|
436
|
+
});
|
|
437
|
+
},
|
|
438
|
+
}));
|
|
427
439
|
|
|
428
|
-
const suite = defineDalSuite(({ db }) => {
|
|
429
|
-
|
|
440
|
+
const suite = defineDalSuite({ fixtures }, ({ db, fixtureScope, fixtures }) => {
|
|
441
|
+
const widget = fixtures.widget();
|
|
442
|
+
db.query(`select id from widgets where id = '${widget.widgetId}'`);
|
|
443
|
+
fixtureScope.records();
|
|
430
444
|
});
|
|
431
445
|
|
|
432
446
|
export default suite;
|
|
433
447
|
```
|
|
434
448
|
|
|
449
|
+
`defineDalFixtures(...)` is the package-owned DAL seeding model. It gives every
|
|
450
|
+
suite a deterministic `fixtureScope` with:
|
|
451
|
+
|
|
452
|
+
- `fixtureScope.id(label)` / `uuid(label)`
|
|
453
|
+
- `fixtureScope.slug(label)`
|
|
454
|
+
- `fixtureScope.email(label)`
|
|
455
|
+
- `fixtureScope.string(label, options)`
|
|
456
|
+
- `fixtureScope.token(label)`
|
|
457
|
+
- `fixtureScope.seed(kind, key, signature, create)`
|
|
458
|
+
- `fixtureScope.records()`
|
|
459
|
+
|
|
460
|
+
`testkit` enforces strict fixture behavior:
|
|
461
|
+
|
|
462
|
+
- one logical fixture per `kind + key`
|
|
463
|
+
- identical reseeds reuse the same seeded value
|
|
464
|
+
- conflicting reseeds fail immediately
|
|
465
|
+
- dependency cycles fail immediately
|
|
466
|
+
- seeded fixture records are persisted as a `testkit.dal-fixtures` artifact
|
|
467
|
+
|
|
435
468
|
Scenario suites:
|
|
436
469
|
|
|
437
470
|
```ts
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React, { createElement } from "react";
|
|
2
|
+
import { Command, Flags } from "@oclif/core";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { sharedFlags } from "../command-helpers.mjs";
|
|
5
|
+
import { loadCurrentRunArtifact, loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
|
|
6
|
+
import { createInspectState } from "../tui/inspect-state.mjs";
|
|
7
|
+
import { InspectApp } from "../tui/inspect-app.mjs";
|
|
8
|
+
import { buildInspectPaneContent } from "../tui/detail-pane.mjs";
|
|
9
|
+
import { hydrateInspectStateFromArtifact } from "../tui/inspect-artifact-adapter.mjs";
|
|
10
|
+
|
|
11
|
+
export default class InspectCommand extends Command {
|
|
12
|
+
static summary = "Inspect the latest run or a live run in one unified interface";
|
|
13
|
+
|
|
14
|
+
static enableJsonFlag = true;
|
|
15
|
+
|
|
16
|
+
static flags = {
|
|
17
|
+
...sharedFlags,
|
|
18
|
+
file: Flags.string({
|
|
19
|
+
description: "File path to inspect; defaults to the first failed file",
|
|
20
|
+
}),
|
|
21
|
+
live: Flags.boolean({
|
|
22
|
+
description: "Inspect the live run artifact instead of the latest completed run",
|
|
23
|
+
default: false,
|
|
24
|
+
}),
|
|
25
|
+
pane: Flags.string({
|
|
26
|
+
description: "Initial pane",
|
|
27
|
+
options: ["detail", "artifacts", "logs", "setup"],
|
|
28
|
+
default: "detail",
|
|
29
|
+
}),
|
|
30
|
+
"log-tail": Flags.integer({
|
|
31
|
+
description: "Number of backend log lines to include in detail/log panes",
|
|
32
|
+
default: 12,
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
async run() {
|
|
37
|
+
const { flags } = await this.parse(InspectCommand);
|
|
38
|
+
const productDir = flags.dir || process.cwd();
|
|
39
|
+
const interactive = process.stdout.isTTY && !this.jsonEnabled();
|
|
40
|
+
|
|
41
|
+
if (!interactive) {
|
|
42
|
+
const artifact = loadArtifact(productDir, flags.live);
|
|
43
|
+
const inspectState = createInspectState({ dataSource: flags.live ? "live" : "artifact" });
|
|
44
|
+
hydrateInspectStateFromArtifact(inspectState, artifact);
|
|
45
|
+
inspectState.setPaneMode(flags.pane);
|
|
46
|
+
applyInitialSelection(inspectState, artifact, flags.file || null, flags.service || null);
|
|
47
|
+
const snapshot = inspectState.getSnapshot();
|
|
48
|
+
const pane = buildInspectPaneContent({
|
|
49
|
+
productDir,
|
|
50
|
+
snapshot,
|
|
51
|
+
paneMode: flags.pane,
|
|
52
|
+
logTail: flags["log-tail"],
|
|
53
|
+
});
|
|
54
|
+
const payload = {
|
|
55
|
+
source: flags.live ? "live" : "latest",
|
|
56
|
+
pane: flags.pane,
|
|
57
|
+
selection: snapshot.selectedEntry,
|
|
58
|
+
lines: pane.lines,
|
|
59
|
+
data: pane.data,
|
|
60
|
+
};
|
|
61
|
+
if (!this.jsonEnabled()) {
|
|
62
|
+
for (const line of pane.lines) {
|
|
63
|
+
this.log(line);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return payload;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const artifact = loadArtifact(productDir, flags.live);
|
|
70
|
+
const inspectState = createInspectState({ dataSource: flags.live ? "live" : "artifact" });
|
|
71
|
+
hydrateInspectStateFromArtifact(inspectState, artifact);
|
|
72
|
+
inspectState.setPaneMode(flags.pane);
|
|
73
|
+
applyInitialSelection(inspectState, artifact, flags.file || null, flags.service || null);
|
|
74
|
+
|
|
75
|
+
const app = render(
|
|
76
|
+
createElement(InspectApp, {
|
|
77
|
+
inspectState,
|
|
78
|
+
stdout: process.stdout,
|
|
79
|
+
productDir,
|
|
80
|
+
}),
|
|
81
|
+
{ stdout: process.stdout, exitOnCtrlC: false }
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
let interval = null;
|
|
85
|
+
if (flags.live) {
|
|
86
|
+
interval = setInterval(() => {
|
|
87
|
+
try {
|
|
88
|
+
const liveArtifact = loadCurrentRunArtifact(productDir);
|
|
89
|
+
hydrateInspectStateFromArtifact(inspectState, liveArtifact);
|
|
90
|
+
inspectState.setPaneMode(flags.pane);
|
|
91
|
+
applyInitialSelection(inspectState, liveArtifact, flags.file || null, flags.service || null);
|
|
92
|
+
} catch {
|
|
93
|
+
// Ignore polling misses while the live artifact is being rewritten.
|
|
94
|
+
}
|
|
95
|
+
}, 1000);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await app.waitUntilExit();
|
|
100
|
+
} finally {
|
|
101
|
+
if (interval) clearInterval(interval);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
source: flags.live ? "live" : "latest",
|
|
106
|
+
pane: flags.pane,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function loadArtifact(productDir, live) {
|
|
112
|
+
return live ? loadCurrentRunArtifact(productDir) : loadLatestRunArtifact(productDir);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function applyInitialSelection(inspectState, artifact, fileSelector, serviceFilter) {
|
|
116
|
+
if (fileSelector) {
|
|
117
|
+
const subject = resolveFileSubject(artifact, fileSelector, serviceFilter || null);
|
|
118
|
+
inspectState.revealFile(subject.service.name, subject.file.path);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (serviceFilter) {
|
|
122
|
+
inspectState.revealService(serviceFilter);
|
|
123
|
+
}
|
|
124
|
+
}
|
package/lib/cli/entrypoint.mjs
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
export function normalizeCliArgs(argv) {
|
|
2
2
|
const topLevelCommands = new Set([
|
|
3
3
|
"run",
|
|
4
|
+
"inspect",
|
|
4
5
|
"status",
|
|
5
6
|
"destroy",
|
|
6
7
|
"cleanup",
|
|
7
|
-
"show",
|
|
8
|
-
"logs",
|
|
9
|
-
"artifacts",
|
|
10
|
-
"watch",
|
|
11
8
|
"discover",
|
|
12
9
|
"typecheck",
|
|
13
10
|
"doctor",
|
|
@@ -40,6 +37,7 @@ export function normalizeCliArgs(argv) {
|
|
|
40
37
|
"--log-tail",
|
|
41
38
|
"--provider",
|
|
42
39
|
"--message",
|
|
40
|
+
"--pane",
|
|
43
41
|
]);
|
|
44
42
|
const positionals = findPositionals(argv, valueFlags);
|
|
45
43
|
const firstPositional = positionals[0] || null;
|
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
import React, { createElement } from "react";
|
|
2
2
|
import { render } from "ink";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { createInspectState } from "../tui/inspect-state.mjs";
|
|
4
|
+
import { InspectApp } from "../tui/inspect-app.mjs";
|
|
5
|
+
import {
|
|
6
|
+
applyReporterPlans,
|
|
7
|
+
applyReporterRunSummary,
|
|
8
|
+
applyReporterTaskFinished,
|
|
9
|
+
applyReporterTaskStarted,
|
|
10
|
+
} from "../tui/inspect-live-adapter.mjs";
|
|
5
11
|
import { suiteSelectionType } from "../../runner/suite-selection.mjs";
|
|
6
12
|
import { startHostedInvestigation } from "../agents/investigate.mjs";
|
|
7
13
|
import { createInvestigationInterpreter } from "../agents/investigation-interpreter.mjs";
|
|
8
14
|
import { writeInvestigationLog } from "../agents/investigation-log.mjs";
|
|
9
15
|
|
|
10
16
|
export function createTreeReporter({ stdout = process.stdout, stderr = process.stderr, productDir } = {}) {
|
|
11
|
-
const
|
|
17
|
+
const inspectState = createInspectState({ dataSource: "live" });
|
|
12
18
|
let activeAgentSession = null;
|
|
13
19
|
let activeInterpreter = null;
|
|
14
20
|
let investigationToken = 0;
|
|
15
21
|
|
|
16
22
|
const app = render(
|
|
17
|
-
createElement(
|
|
18
|
-
|
|
23
|
+
createElement(InspectApp, {
|
|
24
|
+
inspectState,
|
|
19
25
|
stdout,
|
|
20
26
|
productDir,
|
|
21
27
|
onInvestigate: startInvestigation,
|
|
@@ -31,36 +37,36 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
31
37
|
outputMode: "compact",
|
|
32
38
|
|
|
33
39
|
setServicePlans(plans) {
|
|
34
|
-
|
|
40
|
+
applyReporterPlans(inspectState, plans);
|
|
35
41
|
},
|
|
36
42
|
|
|
37
43
|
setTotalFileCount(count) {
|
|
38
|
-
|
|
44
|
+
inspectState.setTotalFileCount(count);
|
|
39
45
|
},
|
|
40
46
|
|
|
41
47
|
setRegressionCatalog(document) {
|
|
42
|
-
|
|
48
|
+
inspectState.setRegressionCatalog(document);
|
|
43
49
|
},
|
|
44
50
|
|
|
45
51
|
serviceSkipped(config, reason) {
|
|
46
|
-
|
|
52
|
+
inspectState.markServiceSkipped(config.name, reason);
|
|
47
53
|
},
|
|
48
54
|
|
|
49
55
|
plannedSkip(entry) {
|
|
50
|
-
|
|
56
|
+
inspectState.markPlannedSkip(entry);
|
|
51
57
|
},
|
|
52
58
|
|
|
53
59
|
taskStarted(task, _config) {
|
|
54
60
|
const suiteKey = `${task.displayType || suiteSelectionType(task.type, task.framework)}:${task.suiteName}`;
|
|
55
|
-
|
|
61
|
+
applyReporterTaskStarted(inspectState, task, suiteKey);
|
|
56
62
|
},
|
|
57
63
|
|
|
58
64
|
taskFinished(task, outcome) {
|
|
59
|
-
|
|
65
|
+
applyReporterTaskFinished(inspectState, task, outcome);
|
|
60
66
|
},
|
|
61
67
|
|
|
62
68
|
runtimeError(task, message) {
|
|
63
|
-
|
|
69
|
+
inspectState.markRuntimeError(task, message);
|
|
64
70
|
},
|
|
65
71
|
|
|
66
72
|
setupOperationFinished(_operation) {
|
|
@@ -68,7 +74,7 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
68
74
|
},
|
|
69
75
|
|
|
70
76
|
phaseStarted(label) {
|
|
71
|
-
|
|
77
|
+
inspectState.setPhase(label);
|
|
72
78
|
},
|
|
73
79
|
|
|
74
80
|
toolchainResolved() {},
|
|
@@ -78,7 +84,7 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
78
84
|
telemetry() {},
|
|
79
85
|
|
|
80
86
|
runSummary(results, durationMs, regressionReport) {
|
|
81
|
-
|
|
87
|
+
applyReporterRunSummary(inspectState, results, durationMs, regressionReport);
|
|
82
88
|
},
|
|
83
89
|
|
|
84
90
|
error(message) {
|
|
@@ -93,19 +99,19 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
93
99
|
};
|
|
94
100
|
|
|
95
101
|
async function startInvestigation({ provider = "auto", userMessage } = {}) {
|
|
96
|
-
const snapshot =
|
|
102
|
+
const snapshot = inspectState.getSnapshot();
|
|
97
103
|
if (!snapshot.selectedFailure) {
|
|
98
|
-
|
|
104
|
+
inspectState.setNotice("No failed file is selected for investigation.");
|
|
99
105
|
return;
|
|
100
106
|
}
|
|
101
107
|
if (activeAgentSession) {
|
|
102
|
-
|
|
108
|
+
inspectState.setNotice("An investigation is already running.");
|
|
103
109
|
return;
|
|
104
110
|
}
|
|
105
111
|
|
|
106
112
|
const token = ++investigationToken;
|
|
107
113
|
let finalDelivered = false;
|
|
108
|
-
|
|
114
|
+
inspectState.beginInvestigation({ provider, userMessage });
|
|
109
115
|
|
|
110
116
|
try {
|
|
111
117
|
activeInterpreter = createInvestigationInterpreter();
|
|
@@ -119,7 +125,7 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
119
125
|
if (token !== investigationToken) return;
|
|
120
126
|
if (event.type === "final") finalDelivered = true;
|
|
121
127
|
const presentation = activeInterpreter?.consumeProviderEvent(event) || null;
|
|
122
|
-
|
|
128
|
+
inspectState.recordInvestigationProgress(event, presentation);
|
|
123
129
|
},
|
|
124
130
|
});
|
|
125
131
|
const result = await activeAgentSession.completion;
|
|
@@ -128,21 +134,21 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
128
134
|
if (result.finalText && !finalDelivered) {
|
|
129
135
|
const finalEvent = { type: "final", text: result.finalText };
|
|
130
136
|
const presentation = activeInterpreter?.consumeProviderEvent(finalEvent) || null;
|
|
131
|
-
|
|
137
|
+
inspectState.recordInvestigationProgress(finalEvent, presentation);
|
|
132
138
|
}
|
|
133
139
|
if (result.cancelled) {
|
|
134
|
-
|
|
140
|
+
inspectState.cancelAgentSession("Cancelled investigation.");
|
|
135
141
|
persistInvestigationLog();
|
|
136
142
|
activeInterpreter = null;
|
|
137
143
|
return;
|
|
138
144
|
}
|
|
139
145
|
if (result.exitCode !== 0 && !result.finalText) {
|
|
140
|
-
|
|
146
|
+
inspectState.failAgentSession(result.stderr || `Agent exited with code ${result.exitCode}`);
|
|
141
147
|
persistInvestigationLog();
|
|
142
148
|
activeInterpreter = null;
|
|
143
149
|
return;
|
|
144
150
|
}
|
|
145
|
-
|
|
151
|
+
inspectState.completeAgentSession({
|
|
146
152
|
finalText: result.finalText,
|
|
147
153
|
exitCode: result.exitCode,
|
|
148
154
|
});
|
|
@@ -151,7 +157,7 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
151
157
|
} catch (error) {
|
|
152
158
|
if (token !== investigationToken) return;
|
|
153
159
|
activeAgentSession = null;
|
|
154
|
-
|
|
160
|
+
inspectState.failAgentSession(error);
|
|
155
161
|
persistInvestigationLog();
|
|
156
162
|
activeInterpreter = null;
|
|
157
163
|
}
|
|
@@ -159,13 +165,13 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
159
165
|
|
|
160
166
|
function cancelInvestigation() {
|
|
161
167
|
if (!activeAgentSession) {
|
|
162
|
-
|
|
168
|
+
inspectState.returnToSummary();
|
|
163
169
|
return;
|
|
164
170
|
}
|
|
165
171
|
investigationToken += 1;
|
|
166
172
|
activeAgentSession.cancel();
|
|
167
173
|
activeAgentSession = null;
|
|
168
|
-
|
|
174
|
+
inspectState.cancelAgentSession("Cancelled investigation.");
|
|
169
175
|
persistInvestigationLog();
|
|
170
176
|
activeInterpreter = null;
|
|
171
177
|
}
|
|
@@ -180,7 +186,7 @@ export function createTreeReporter({ stdout = process.stdout, stderr = process.s
|
|
|
180
186
|
}
|
|
181
187
|
|
|
182
188
|
function persistInvestigationLog() {
|
|
183
|
-
const snapshot =
|
|
189
|
+
const snapshot = inspectState.getSnapshot();
|
|
184
190
|
if (!snapshot.selectedFailure || !snapshot.agentSession) return;
|
|
185
191
|
writeInvestigationLog({
|
|
186
192
|
productDir,
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import {
|
|
3
|
+
collectArtifactEntries,
|
|
4
|
+
formatArtifactPreview,
|
|
5
|
+
formatFileDetail,
|
|
6
|
+
getServiceLogRefs,
|
|
7
|
+
getSetupOperationsForService,
|
|
8
|
+
loadCurrentRunArtifact,
|
|
9
|
+
resolveFileSubject,
|
|
10
|
+
} from "../viewer.mjs";
|
|
11
|
+
import { formatDuration } from "../../runner/formatting.mjs";
|
|
12
|
+
import { readLogTail } from "../../runner/logs.mjs";
|
|
13
|
+
|
|
14
|
+
export function buildInspectPaneContent({ productDir, snapshot, paneMode = "detail", logTail = 12 } = {}) {
|
|
15
|
+
const selectedEntry = snapshot.selectedEntry || null;
|
|
16
|
+
if (!selectedEntry) {
|
|
17
|
+
return { title: "Selection", lines: ["No entry selected."], data: null };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const runArtifact = resolveArtifact(productDir, snapshot);
|
|
21
|
+
const subject = resolveSubject(runArtifact, selectedEntry);
|
|
22
|
+
|
|
23
|
+
if (paneMode === "artifacts") {
|
|
24
|
+
return buildArtifactsPane(productDir, runArtifact, selectedEntry, subject);
|
|
25
|
+
}
|
|
26
|
+
if (paneMode === "logs") {
|
|
27
|
+
return buildLogsPane(productDir, runArtifact, selectedEntry, logTail);
|
|
28
|
+
}
|
|
29
|
+
if (paneMode === "setup") {
|
|
30
|
+
return buildSetupPane(productDir, runArtifact, selectedEntry);
|
|
31
|
+
}
|
|
32
|
+
return buildDetailPane(productDir, runArtifact, selectedEntry, subject, logTail);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildDetailPane(productDir, runArtifact, entry, subject, logTail) {
|
|
36
|
+
if (subject && runArtifact) {
|
|
37
|
+
return {
|
|
38
|
+
title: "Detail",
|
|
39
|
+
lines: formatFileDetail(productDir, runArtifact, subject, { logTail }),
|
|
40
|
+
data: subject,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
title: "Detail",
|
|
46
|
+
lines: formatAggregateDetail(entry),
|
|
47
|
+
data: entry,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildArtifactsPane(productDir, runArtifact, entry, subject) {
|
|
52
|
+
if (!runArtifact || !subject) {
|
|
53
|
+
return {
|
|
54
|
+
title: "Artifacts",
|
|
55
|
+
lines: ["Artifacts are available for file selections from a persisted run artifact."],
|
|
56
|
+
data: [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const entries = collectArtifactEntries(productDir, runArtifact, subject.file.path, subject.service.name).map((item) => ({
|
|
61
|
+
service: item.service.name,
|
|
62
|
+
suite: `${item.suite.type}:${item.suite.name}`,
|
|
63
|
+
file: item.file.path,
|
|
64
|
+
name: item.artifactRef.name,
|
|
65
|
+
kind: item.artifactRef.kind,
|
|
66
|
+
summary: item.artifactRef.summary,
|
|
67
|
+
path: item.artifactRef.path,
|
|
68
|
+
preview: formatArtifactPreview(item.payload, 6),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
if (entries.length === 0) {
|
|
72
|
+
return { title: "Artifacts", lines: ["No artifacts were persisted for the selected file."], data: [] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lines = [];
|
|
76
|
+
for (const item of entries) {
|
|
77
|
+
lines.push(`${item.name}${item.kind ? ` [${item.kind}]` : ""}`);
|
|
78
|
+
if (item.summary) lines.push(` ${item.summary}`);
|
|
79
|
+
for (const line of item.preview) lines.push(` ${line}`);
|
|
80
|
+
lines.push(` ${item.path}`);
|
|
81
|
+
}
|
|
82
|
+
return { title: "Artifacts", lines, data: entries };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildLogsPane(productDir, runArtifact, entry, tail) {
|
|
86
|
+
if (!runArtifact) {
|
|
87
|
+
return { title: "Logs", lines: ["Backend logs are available only from persisted run artifacts."], data: [] };
|
|
88
|
+
}
|
|
89
|
+
const serviceName = entry.serviceName;
|
|
90
|
+
const logs = getServiceLogRefs(runArtifact, serviceName).map((item) => ({
|
|
91
|
+
...item,
|
|
92
|
+
lines: readLogTail(path.join(productDir, item.path), tail),
|
|
93
|
+
}));
|
|
94
|
+
if (logs.length === 0) {
|
|
95
|
+
return { title: "Logs", lines: ["No backend logs were recorded for the selected service."], data: [] };
|
|
96
|
+
}
|
|
97
|
+
const lines = [];
|
|
98
|
+
for (const item of logs) {
|
|
99
|
+
lines.push(item.runtimeLabel);
|
|
100
|
+
lines.push(` ${item.path}`);
|
|
101
|
+
for (const line of item.lines) lines.push(` ${line}`);
|
|
102
|
+
}
|
|
103
|
+
return { title: "Logs", lines, data: logs };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildSetupPane(productDir, runArtifact, entry) {
|
|
107
|
+
if (!runArtifact) {
|
|
108
|
+
return { title: "Setup", lines: ["Setup operations are available only from persisted run artifacts."], data: [] };
|
|
109
|
+
}
|
|
110
|
+
const operations = getSetupOperationsForService(runArtifact, entry.serviceName);
|
|
111
|
+
if (operations.length === 0) {
|
|
112
|
+
return { title: "Setup", lines: ["No setup operations were recorded for the selected service."], data: [] };
|
|
113
|
+
}
|
|
114
|
+
const lines = [];
|
|
115
|
+
for (const operation of operations) {
|
|
116
|
+
const duration = operation.durationMs == null ? "" : ` ${formatDuration(operation.durationMs)}`;
|
|
117
|
+
const summary = operation.summary ? ` ${operation.summary}` : "";
|
|
118
|
+
lines.push(`${operation.status} ${operation.stage}${duration}${summary}`);
|
|
119
|
+
if (operation.error) lines.push(` ${operation.error}`);
|
|
120
|
+
if (operation.logRef?.path) lines.push(` ${operation.logRef.path}`);
|
|
121
|
+
}
|
|
122
|
+
return { title: "Setup", lines, data: operations };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveArtifact(productDir, snapshot) {
|
|
126
|
+
if (snapshot.runArtifact) return snapshot.runArtifact;
|
|
127
|
+
try {
|
|
128
|
+
return loadCurrentRunArtifact(productDir);
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveSubject(runArtifact, entry) {
|
|
135
|
+
if (!runArtifact || !entry || entry.kind !== "file") return null;
|
|
136
|
+
try {
|
|
137
|
+
return resolveFileSubject(runArtifact, entry.filePath, entry.serviceName);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatAggregateDetail(entry) {
|
|
144
|
+
const lines = [`Kind: ${entry.kind}`];
|
|
145
|
+
if (entry.serviceName) lines.push(`Service: ${entry.serviceName}`);
|
|
146
|
+
if (entry.type) lines.push(`Type: ${entry.type}`);
|
|
147
|
+
if (entry.suiteName) lines.push(`Suite: ${entry.suiteName}`);
|
|
148
|
+
if (entry.filePath) lines.push(`File: ${entry.filePath}`);
|
|
149
|
+
if (entry.status) lines.push(`Status: ${entry.status}`);
|
|
150
|
+
if (entry.summary) {
|
|
151
|
+
lines.push(
|
|
152
|
+
`Files: ${entry.summary.total} total · ${entry.summary.passed} passed · ${entry.summary.failed} failed · ${entry.summary.skipped} skipped · ${entry.summary.notRun} not run`
|
|
153
|
+
);
|
|
154
|
+
if (entry.summary.durationMs > 0) {
|
|
155
|
+
lines.push(`Duration: ${formatDuration(entry.summary.durationMs)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (entry.skipReason) lines.push(`Skip reason: ${entry.skipReason}`);
|
|
159
|
+
if (entry.error) lines.push(`Error: ${entry.error}`);
|
|
160
|
+
return lines;
|
|
161
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React, { createElement } from "react";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { bold, dim } from "../presentation/colors.mjs";
|
|
4
|
+
|
|
5
|
+
export function FilterBar({ filter } = {}) {
|
|
6
|
+
if (!filter?.active) return null;
|
|
7
|
+
return createElement(
|
|
8
|
+
Text,
|
|
9
|
+
null,
|
|
10
|
+
`${bold("/")}${filter.query}${dim(` ${filter.count} ${filter.count === 1 ? "match" : "matches"}`)}`
|
|
11
|
+
);
|
|
12
|
+
}
|