@elench/testkit 0.1.81 → 0.1.83
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 +64 -27
- package/lib/cli/agents/index.mjs +64 -0
- package/lib/cli/agents/investigate.mjs +75 -0
- package/lib/cli/agents/investigation-context.mjs +102 -0
- package/lib/cli/agents/investigation-context.test.mjs +144 -0
- package/lib/cli/agents/prompt-builder.mjs +25 -0
- package/lib/cli/agents/providers/claude.mjs +74 -0
- package/lib/cli/agents/providers/claude.test.mjs +95 -0
- package/lib/cli/agents/providers/codex.mjs +83 -0
- package/lib/cli/agents/providers/codex.test.mjs +93 -0
- package/lib/cli/agents/providers/shared.mjs +134 -0
- package/lib/cli/command-helpers.mjs +53 -25
- package/lib/cli/command-helpers.test.mjs +122 -0
- package/lib/cli/commands/investigate.mjs +87 -0
- package/lib/cli/commands/investigate.test.mjs +83 -0
- package/lib/cli/entrypoint.mjs +3 -0
- package/lib/cli/presentation/colors.mjs +12 -0
- package/lib/cli/presentation/events-reporter.mjs +135 -0
- package/lib/cli/presentation/events-reporter.test.mjs +73 -0
- package/lib/cli/presentation/summary-box.mjs +11 -11
- package/lib/cli/presentation/summary-box.test.mjs +17 -0
- package/lib/cli/presentation/tree-reporter.mjs +159 -0
- package/lib/cli/presentation/tree-reporter.test.mjs +166 -0
- package/lib/cli/tui/run-app.mjs +1 -0
- package/lib/cli/tui/run-session-app.mjs +370 -0
- package/lib/cli/tui/run-session-app.test.mjs +50 -0
- package/lib/cli/tui/run-session-state.mjs +481 -0
- package/lib/cli/tui/run-tree-state.mjs +1 -0
- package/lib/cli/tui/run-tree-state.test.mjs +324 -0
- package/lib/config-api/auth-fixtures.mjs +767 -0
- package/lib/config-api/index.d.ts +92 -108
- package/lib/config-api/index.mjs +22 -12
- package/lib/config-api/index.test.mjs +103 -210
- package/lib/discovery/index.mjs +1 -1
- package/lib/index.d.ts +34 -10
- package/lib/runner/orchestrator.mjs +1 -0
- package/lib/runtime/index.d.ts +177 -27
- package/lib/runtime/index.mjs +68 -3
- package/lib/runtime-src/k6/http-assertions.js +31 -1
- package/lib/runtime-src/k6/http-checks.js +120 -0
- package/lib/runtime-src/k6/http-checks.test.mjs +120 -0
- package/lib/runtime-src/k6/http-suite-runtime.js +151 -0
- package/lib/runtime-src/k6/http.js +285 -56
- package/lib/runtime-src/k6/http.test.mjs +205 -0
- package/lib/runtime-src/k6/scenario-suite.js +13 -110
- package/lib/runtime-src/k6/suite.js +13 -107
- package/lib/runtime-src/shared/error-body.mjs +42 -0
- package/lib/runtime-src/shared/http-parsing.mjs +68 -0
- package/lib/runtime-src/shared/http-parsing.test.mjs +69 -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/config-api/profiles.mjs +0 -640
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { PassThrough } from "stream";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const execaMock = vi.fn();
|
|
5
|
+
|
|
6
|
+
vi.mock("execa", () => ({
|
|
7
|
+
execa: execaMock,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
function createChildHandle() {
|
|
11
|
+
const stdout = new PassThrough();
|
|
12
|
+
const stderr = new PassThrough();
|
|
13
|
+
let resolveChild;
|
|
14
|
+
const promise = new Promise((resolve) => {
|
|
15
|
+
resolveChild = resolve;
|
|
16
|
+
});
|
|
17
|
+
promise.stdout = stdout;
|
|
18
|
+
promise.stderr = stderr;
|
|
19
|
+
promise.kill = vi.fn(() => {
|
|
20
|
+
stdout.end();
|
|
21
|
+
stderr.end();
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
child: promise,
|
|
25
|
+
finish(result = {}) {
|
|
26
|
+
stdout.end();
|
|
27
|
+
stderr.end();
|
|
28
|
+
resolveChild({
|
|
29
|
+
exitCode: 0,
|
|
30
|
+
stdout: "",
|
|
31
|
+
stderr: "",
|
|
32
|
+
...result,
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
execaMock.mockReset();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("Claude hosted session", () => {
|
|
43
|
+
it("normalizes streamed tool, delta, status, and final events", async () => {
|
|
44
|
+
const { startClaudeHostedSession } = await import("./claude.mjs");
|
|
45
|
+
const handle = createChildHandle();
|
|
46
|
+
execaMock.mockReturnValue(handle.child);
|
|
47
|
+
const events = [];
|
|
48
|
+
|
|
49
|
+
const session = startClaudeHostedSession({
|
|
50
|
+
cwd: "/tmp/project",
|
|
51
|
+
prompt: "Investigate this failure",
|
|
52
|
+
onEvent(event) {
|
|
53
|
+
events.push(event);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
handle.child.stdout.write(`${JSON.stringify({ type: "tool_use", name: "Bash", detail: "rg failure" })}\n`);
|
|
58
|
+
handle.child.stdout.write(`${JSON.stringify({ type: "message_delta", delta: "Likely root cause." })}\n`);
|
|
59
|
+
handle.child.stderr.write("stderr note\n");
|
|
60
|
+
handle.finish({ exitCode: 0 });
|
|
61
|
+
|
|
62
|
+
const result = await session.completion;
|
|
63
|
+
|
|
64
|
+
expect(execaMock).toHaveBeenCalledWith(
|
|
65
|
+
"claude",
|
|
66
|
+
expect.arrayContaining(["-p", "--output-format", "stream-json", "--include-partial-messages"]),
|
|
67
|
+
expect.objectContaining({ cwd: "/tmp/project", reject: false })
|
|
68
|
+
);
|
|
69
|
+
expect(events[0]).toMatchObject({ provider: "claude", type: "start" });
|
|
70
|
+
expect(events).toContainEqual(expect.objectContaining({ provider: "claude", type: "tool", name: "Bash" }));
|
|
71
|
+
expect(events).toContainEqual(expect.objectContaining({ provider: "claude", type: "delta", text: "Likely root cause." }));
|
|
72
|
+
expect(events).toContainEqual(expect.objectContaining({ provider: "claude", type: "status", message: "stderr note" }));
|
|
73
|
+
expect(events.at(-2)).toMatchObject({ provider: "claude", type: "final", text: "Likely root cause." });
|
|
74
|
+
expect(events.at(-1)).toMatchObject({ provider: "claude", type: "exit", code: 0 });
|
|
75
|
+
expect(result.finalText).toBe("Likely root cause.");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("cancels the child process", async () => {
|
|
79
|
+
const { startClaudeHostedSession } = await import("./claude.mjs");
|
|
80
|
+
const handle = createChildHandle();
|
|
81
|
+
execaMock.mockReturnValue(handle.child);
|
|
82
|
+
|
|
83
|
+
const session = startClaudeHostedSession({
|
|
84
|
+
cwd: "/tmp/project",
|
|
85
|
+
prompt: "Investigate this failure",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
session.cancel();
|
|
89
|
+
handle.finish({ exitCode: 130 });
|
|
90
|
+
const result = await session.completion;
|
|
91
|
+
|
|
92
|
+
expect(handle.child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
93
|
+
expect(result.cancelled).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { execa } from "execa";
|
|
5
|
+
import {
|
|
6
|
+
buildErrorEvent,
|
|
7
|
+
buildStatusEvent,
|
|
8
|
+
buildToolEvent,
|
|
9
|
+
createHostedSessionRunner,
|
|
10
|
+
extractTextFragments,
|
|
11
|
+
readTextFileIfPresent,
|
|
12
|
+
} from "./shared.mjs";
|
|
13
|
+
|
|
14
|
+
export function startCodexHostedSession({ cwd, prompt, onEvent, purpose = "investigate" } = {}) {
|
|
15
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-codex-"));
|
|
16
|
+
const outputFile = path.join(tempDir, "final-message.txt");
|
|
17
|
+
const args = ["exec", "--json", "-o", outputFile];
|
|
18
|
+
|
|
19
|
+
if (purpose === "investigate") {
|
|
20
|
+
args.push("-s", "read-only", "-a", "never");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
args.push(prompt);
|
|
24
|
+
|
|
25
|
+
const child = execa("codex", args, {
|
|
26
|
+
cwd,
|
|
27
|
+
stdout: "pipe",
|
|
28
|
+
stderr: "pipe",
|
|
29
|
+
reject: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const session = createHostedSessionRunner({
|
|
33
|
+
provider: "codex",
|
|
34
|
+
child,
|
|
35
|
+
onEvent,
|
|
36
|
+
parsePayload: parseCodexPayload,
|
|
37
|
+
readFinalText(result) {
|
|
38
|
+
return readTextFileIfPresent(outputFile) || result.stdout || null;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const completion = session.completion.finally(() => {
|
|
43
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
completion,
|
|
48
|
+
cancel: session.cancel,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseCodexPayload(payload) {
|
|
53
|
+
const events = [];
|
|
54
|
+
if (!payload || typeof payload !== "object") return events;
|
|
55
|
+
const type = payload.type || payload.event || payload.kind || null;
|
|
56
|
+
const errorMessage = payload.error?.message || payload.error || null;
|
|
57
|
+
if (errorMessage) {
|
|
58
|
+
const event = buildErrorEvent(errorMessage);
|
|
59
|
+
if (event) events.push(event);
|
|
60
|
+
return events;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (type && /(tool|command|patch|exec)/i.test(type)) {
|
|
64
|
+
const event = buildToolEvent(
|
|
65
|
+
payload.name || payload.tool_name || payload.command || type,
|
|
66
|
+
payload.detail || payload.summary || payload.status || null
|
|
67
|
+
);
|
|
68
|
+
if (event) events.push(event);
|
|
69
|
+
return events;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fragments = [...new Set(extractTextFragments(payload, []))];
|
|
73
|
+
if (fragments.length > 0) {
|
|
74
|
+
for (const fragment of fragments) {
|
|
75
|
+
events.push({ type: "delta", text: fragment });
|
|
76
|
+
}
|
|
77
|
+
return events;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const statusEvent = buildStatusEvent(type ? `Codex event: ${type}` : JSON.stringify(payload));
|
|
81
|
+
if (statusEvent) events.push(statusEvent);
|
|
82
|
+
return events;
|
|
83
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { PassThrough } from "stream";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const execaMock = vi.fn();
|
|
5
|
+
|
|
6
|
+
vi.mock("execa", () => ({
|
|
7
|
+
execa: execaMock,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
function createChildHandle() {
|
|
11
|
+
const stdout = new PassThrough();
|
|
12
|
+
const stderr = new PassThrough();
|
|
13
|
+
let resolveChild;
|
|
14
|
+
const promise = new Promise((resolve) => {
|
|
15
|
+
resolveChild = resolve;
|
|
16
|
+
});
|
|
17
|
+
promise.stdout = stdout;
|
|
18
|
+
promise.stderr = stderr;
|
|
19
|
+
promise.kill = vi.fn(() => {
|
|
20
|
+
stdout.end();
|
|
21
|
+
stderr.end();
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
child: promise,
|
|
25
|
+
finish(result = {}) {
|
|
26
|
+
stdout.end();
|
|
27
|
+
stderr.end();
|
|
28
|
+
resolveChild({
|
|
29
|
+
exitCode: 0,
|
|
30
|
+
stdout: "",
|
|
31
|
+
stderr: "",
|
|
32
|
+
...result,
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
execaMock.mockReset();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("Codex hosted session", () => {
|
|
43
|
+
it("normalizes streamed tool, delta, and final output", async () => {
|
|
44
|
+
const { startCodexHostedSession } = await import("./codex.mjs");
|
|
45
|
+
const handle = createChildHandle();
|
|
46
|
+
execaMock.mockReturnValue(handle.child);
|
|
47
|
+
const events = [];
|
|
48
|
+
|
|
49
|
+
const session = startCodexHostedSession({
|
|
50
|
+
cwd: "/tmp/project",
|
|
51
|
+
prompt: "Investigate this failure",
|
|
52
|
+
onEvent(event) {
|
|
53
|
+
events.push(event);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
handle.child.stdout.write(`${JSON.stringify({ type: "command", command: "rg", status: "done" })}\n`);
|
|
58
|
+
handle.child.stdout.write(`${JSON.stringify({ type: "assistant_delta", text: "Check the migration step." })}\n`);
|
|
59
|
+
handle.finish({ exitCode: 0, stdout: "Check the migration step." });
|
|
60
|
+
|
|
61
|
+
const result = await session.completion;
|
|
62
|
+
|
|
63
|
+
expect(execaMock).toHaveBeenCalledWith(
|
|
64
|
+
"codex",
|
|
65
|
+
expect.arrayContaining(["exec", "--json", "-s", "read-only", "-a", "never"]),
|
|
66
|
+
expect.objectContaining({ cwd: "/tmp/project", reject: false })
|
|
67
|
+
);
|
|
68
|
+
expect(events[0]).toMatchObject({ provider: "codex", type: "start" });
|
|
69
|
+
expect(events).toContainEqual(expect.objectContaining({ provider: "codex", type: "tool", name: "rg" }));
|
|
70
|
+
expect(events).toContainEqual(expect.objectContaining({ provider: "codex", type: "delta", text: "Check the migration step." }));
|
|
71
|
+
expect(events.at(-2)).toMatchObject({ provider: "codex", type: "final", text: "Check the migration step." });
|
|
72
|
+
expect(events.at(-1)).toMatchObject({ provider: "codex", type: "exit", code: 0 });
|
|
73
|
+
expect(result.finalText).toBe("Check the migration step.");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("cancels the child process", async () => {
|
|
77
|
+
const { startCodexHostedSession } = await import("./codex.mjs");
|
|
78
|
+
const handle = createChildHandle();
|
|
79
|
+
execaMock.mockReturnValue(handle.child);
|
|
80
|
+
|
|
81
|
+
const session = startCodexHostedSession({
|
|
82
|
+
cwd: "/tmp/project",
|
|
83
|
+
prompt: "Investigate this failure",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
session.cancel();
|
|
87
|
+
handle.finish({ exitCode: 130 });
|
|
88
|
+
const result = await session.completion;
|
|
89
|
+
|
|
90
|
+
expect(handle.child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
91
|
+
expect(result.cancelled).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import readline from "readline";
|
|
3
|
+
|
|
4
|
+
export function createHostedSessionRunner({ provider, child, onEvent, parsePayload, readFinalText } = {}) {
|
|
5
|
+
let cancelled = false;
|
|
6
|
+
let settled = false;
|
|
7
|
+
let assistantText = "";
|
|
8
|
+
|
|
9
|
+
const emit = (event) => {
|
|
10
|
+
if (event?.type === "delta" || event?.type === "final") {
|
|
11
|
+
assistantText += event.text || "";
|
|
12
|
+
}
|
|
13
|
+
if (typeof onEvent === "function" && event) onEvent({ provider, ...event });
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
emit({ type: "start" });
|
|
17
|
+
|
|
18
|
+
const stdoutReader = readline.createInterface({ input: child.stdout });
|
|
19
|
+
stdoutReader.on("line", (line) => {
|
|
20
|
+
const parsed = tryParseJson(line);
|
|
21
|
+
if (parsed == null) {
|
|
22
|
+
emit({ type: "status", message: line });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const events = parsePayload ? parsePayload(parsed) : [];
|
|
26
|
+
if (!Array.isArray(events) || events.length === 0) return;
|
|
27
|
+
for (const event of events) emit(event);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const stderrReader = readline.createInterface({ input: child.stderr });
|
|
31
|
+
stderrReader.on("line", (line) => {
|
|
32
|
+
emit({ type: "status", message: line });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const completion = (async () => {
|
|
36
|
+
const result = await child;
|
|
37
|
+
const finalText = (readFinalText ? readFinalText(result) : null) || assistantText.trim() || null;
|
|
38
|
+
if (finalText) emit({ type: "final", text: finalText });
|
|
39
|
+
emit({ type: "exit", code: result.exitCode ?? 0 });
|
|
40
|
+
settled = true;
|
|
41
|
+
return {
|
|
42
|
+
provider,
|
|
43
|
+
exitCode: result.exitCode ?? 0,
|
|
44
|
+
stdout: result.stdout || "",
|
|
45
|
+
stderr: result.stderr || "",
|
|
46
|
+
finalText: finalText || result.stdout || "",
|
|
47
|
+
cancelled,
|
|
48
|
+
};
|
|
49
|
+
})();
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
completion,
|
|
53
|
+
cancel() {
|
|
54
|
+
cancelled = true;
|
|
55
|
+
if (settled) return;
|
|
56
|
+
try {
|
|
57
|
+
child.kill("SIGTERM");
|
|
58
|
+
} catch {
|
|
59
|
+
// Ignore cancellation races.
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function tryParseJson(line) {
|
|
66
|
+
const normalized = String(line || "").trim();
|
|
67
|
+
if (!normalized) return null;
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(normalized);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildToolEvent(name, detail = null) {
|
|
76
|
+
if (!name) return null;
|
|
77
|
+
return {
|
|
78
|
+
type: "tool",
|
|
79
|
+
name: String(name),
|
|
80
|
+
...(detail ? { detail: String(detail) } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildStatusEvent(message) {
|
|
85
|
+
if (!message) return null;
|
|
86
|
+
return { type: "status", message: String(message) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function buildErrorEvent(message) {
|
|
90
|
+
if (!message) return null;
|
|
91
|
+
return { type: "error", message: String(message) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function extractTextFragments(payload, fragments = [], depth = 0) {
|
|
95
|
+
if (payload == null || depth > 5 || fragments.length >= 12) return fragments;
|
|
96
|
+
if (typeof payload === "string") {
|
|
97
|
+
const normalized = payload.trim();
|
|
98
|
+
if (normalized) fragments.push(normalized);
|
|
99
|
+
return fragments;
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(payload)) {
|
|
102
|
+
for (const entry of payload) {
|
|
103
|
+
extractTextFragments(entry, fragments, depth + 1);
|
|
104
|
+
if (fragments.length >= 12) break;
|
|
105
|
+
}
|
|
106
|
+
return fragments;
|
|
107
|
+
}
|
|
108
|
+
if (typeof payload !== "object") return fragments;
|
|
109
|
+
|
|
110
|
+
const preferredKeys = ["delta", "text", "content", "message", "output_text", "summary"];
|
|
111
|
+
let matchedPreferredKey = false;
|
|
112
|
+
for (const key of preferredKeys) {
|
|
113
|
+
if (!(key in payload)) continue;
|
|
114
|
+
matchedPreferredKey = true;
|
|
115
|
+
extractTextFragments(payload[key], fragments, depth + 1);
|
|
116
|
+
if (fragments.length >= 12) return fragments;
|
|
117
|
+
}
|
|
118
|
+
if (matchedPreferredKey && fragments.length > 0) return fragments;
|
|
119
|
+
|
|
120
|
+
for (const value of Object.values(payload)) {
|
|
121
|
+
extractTextFragments(value, fragments, depth + 1);
|
|
122
|
+
if (fragments.length >= 12) break;
|
|
123
|
+
}
|
|
124
|
+
return fragments;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function readTextFileIfPresent(filePath) {
|
|
128
|
+
if (!filePath) return null;
|
|
129
|
+
try {
|
|
130
|
+
return fs.readFileSync(filePath, "utf8").trim() || null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
} from "./args.mjs";
|
|
12
12
|
import * as runner from "../runner/index.mjs";
|
|
13
13
|
import { createRunReporter } from "./presentation/run-reporter.mjs";
|
|
14
|
+
import { createTreeReporter } from "./presentation/tree-reporter.mjs";
|
|
15
|
+
import { createRunEventsReporter } from "./presentation/events-reporter.mjs";
|
|
14
16
|
|
|
15
17
|
export const sharedFlags = {
|
|
16
18
|
dir: Flags.string({
|
|
@@ -64,7 +66,7 @@ export const runFlags = {
|
|
|
64
66
|
}),
|
|
65
67
|
"output-mode": Flags.string({
|
|
66
68
|
description: "Reporter mode",
|
|
67
|
-
options: ["compact", "debug"],
|
|
69
|
+
options: ["compact", "debug", "events"],
|
|
68
70
|
}),
|
|
69
71
|
debug: Flags.boolean({
|
|
70
72
|
description: "Alias for --output-mode debug",
|
|
@@ -94,31 +96,57 @@ export async function executeRunCommand(command, flags, positionalType = null) {
|
|
|
94
96
|
: flags.debug
|
|
95
97
|
? "debug"
|
|
96
98
|
: flags["output-mode"] || "compact";
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
|
|
100
|
+
let reporter;
|
|
101
|
+
let finalize = Promise.resolve();
|
|
102
|
+
let close = () => {};
|
|
103
|
+
|
|
104
|
+
if (outputMode === "compact" && process.stdout.isTTY) {
|
|
105
|
+
const tree = createTreeReporter({
|
|
106
|
+
stdout: process.stdout,
|
|
107
|
+
stderr: process.stderr,
|
|
108
|
+
productDir,
|
|
109
|
+
});
|
|
110
|
+
reporter = tree.reporter;
|
|
111
|
+
finalize = tree.finalize;
|
|
112
|
+
close = tree.close;
|
|
113
|
+
} else if (outputMode === "events") {
|
|
114
|
+
reporter = createRunEventsReporter({ stdout: process.stdout, stderr: process.stderr });
|
|
115
|
+
} else {
|
|
116
|
+
reporter = createRunReporter({ outputMode });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const result = await runner.runAll(
|
|
121
|
+
configs,
|
|
104
122
|
typeValues,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
suiteSelectors,
|
|
124
|
+
{
|
|
125
|
+
...flags,
|
|
126
|
+
typeValues,
|
|
127
|
+
fileNames,
|
|
128
|
+
workers,
|
|
129
|
+
fileTimeoutSeconds,
|
|
130
|
+
shard,
|
|
131
|
+
scenarioSeed: flags.seed || null,
|
|
132
|
+
serviceFilter: flags.service || null,
|
|
133
|
+
reporter,
|
|
134
|
+
writeStatus: flags["write-status"],
|
|
135
|
+
allowPartialStatus: flags["allow-partial-status"],
|
|
136
|
+
ignoreSkipRules: flags["ignore-skip-rules"],
|
|
137
|
+
},
|
|
138
|
+
allConfigs
|
|
139
|
+
);
|
|
140
|
+
await finalize;
|
|
141
|
+
return {
|
|
142
|
+
outputMode,
|
|
143
|
+
...result,
|
|
144
|
+
};
|
|
145
|
+
} catch (error) {
|
|
146
|
+
close();
|
|
147
|
+
await finalize.catch(() => {});
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
122
150
|
}
|
|
123
151
|
|
|
124
152
|
export async function runStatusLike(commandName, flags) {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const loadManagedConfigsMock = vi.fn();
|
|
4
|
+
const runAllMock = vi.fn();
|
|
5
|
+
const createRunReporterMock = vi.fn();
|
|
6
|
+
const createTreeReporterMock = vi.fn();
|
|
7
|
+
const createRunEventsReporterMock = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock("../app/configs.mjs", () => ({
|
|
10
|
+
loadManagedConfigs: loadManagedConfigsMock,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("../runner/index.mjs", () => ({
|
|
14
|
+
runAll: runAllMock,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("./presentation/run-reporter.mjs", () => ({
|
|
18
|
+
createRunReporter: createRunReporterMock,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("./presentation/tree-reporter.mjs", () => ({
|
|
22
|
+
createTreeReporter: createTreeReporterMock,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock("./presentation/events-reporter.mjs", () => ({
|
|
26
|
+
createRunEventsReporter: createRunEventsReporterMock,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const originalIsTTY = process.stdout.isTTY;
|
|
30
|
+
const originalStdoutGetWindowSize = process.stdout.getWindowSize;
|
|
31
|
+
const originalStderrGetWindowSize = process.stderr.getWindowSize;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
loadManagedConfigsMock.mockResolvedValue({
|
|
35
|
+
allConfigs: [{ name: "api", productDir: "/tmp/product" }],
|
|
36
|
+
configs: [{ name: "api", productDir: "/tmp/product" }],
|
|
37
|
+
});
|
|
38
|
+
runAllMock.mockResolvedValue({ ok: true });
|
|
39
|
+
createRunReporterMock.mockReturnValue({ outputMode: "compact" });
|
|
40
|
+
createRunEventsReporterMock.mockReturnValue({ outputMode: "events" });
|
|
41
|
+
createTreeReporterMock.mockReturnValue({
|
|
42
|
+
reporter: { outputMode: "compact" },
|
|
43
|
+
finalize: Promise.resolve(),
|
|
44
|
+
close: vi.fn(),
|
|
45
|
+
});
|
|
46
|
+
Object.defineProperty(process.stdout, "isTTY", {
|
|
47
|
+
configurable: true,
|
|
48
|
+
value: true,
|
|
49
|
+
});
|
|
50
|
+
process.stdout.getWindowSize = vi.fn(() => [100, 40]);
|
|
51
|
+
process.stderr.getWindowSize = vi.fn(() => [100, 40]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
loadManagedConfigsMock.mockReset();
|
|
56
|
+
runAllMock.mockReset();
|
|
57
|
+
createRunReporterMock.mockReset();
|
|
58
|
+
createTreeReporterMock.mockReset();
|
|
59
|
+
createRunEventsReporterMock.mockReset();
|
|
60
|
+
Object.defineProperty(process.stdout, "isTTY", {
|
|
61
|
+
configurable: true,
|
|
62
|
+
value: originalIsTTY,
|
|
63
|
+
});
|
|
64
|
+
process.stdout.getWindowSize = originalStdoutGetWindowSize;
|
|
65
|
+
process.stderr.getWindowSize = originalStderrGetWindowSize;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("executeRunCommand", () => {
|
|
69
|
+
it("uses the tree reporter for compact TTY runs and awaits finalization", async () => {
|
|
70
|
+
let finalizeResolved = false;
|
|
71
|
+
createTreeReporterMock.mockReturnValueOnce({
|
|
72
|
+
reporter: { outputMode: "compact" },
|
|
73
|
+
finalize: Promise.resolve().then(() => {
|
|
74
|
+
finalizeResolved = true;
|
|
75
|
+
}),
|
|
76
|
+
close: vi.fn(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const { executeRunCommand } = await import("./command-helpers.mjs");
|
|
80
|
+
const result = await executeRunCommand({ jsonEnabled: () => false }, {}, null);
|
|
81
|
+
|
|
82
|
+
expect(createTreeReporterMock).toHaveBeenCalledWith(
|
|
83
|
+
expect.objectContaining({ productDir: "/tmp/product" })
|
|
84
|
+
);
|
|
85
|
+
expect(runAllMock).toHaveBeenCalledWith(
|
|
86
|
+
expect.any(Array),
|
|
87
|
+
expect.any(Array),
|
|
88
|
+
expect.any(Array),
|
|
89
|
+
expect.objectContaining({ reporter: { outputMode: "compact" } }),
|
|
90
|
+
expect.any(Array)
|
|
91
|
+
);
|
|
92
|
+
expect(finalizeResolved).toBe(true);
|
|
93
|
+
expect(result.outputMode).toBe("compact");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("uses the events reporter when requested", async () => {
|
|
97
|
+
const { executeRunCommand } = await import("./command-helpers.mjs");
|
|
98
|
+
await executeRunCommand(
|
|
99
|
+
{ jsonEnabled: () => false },
|
|
100
|
+
{ "output-mode": "events" },
|
|
101
|
+
null
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(createRunEventsReporterMock).toHaveBeenCalled();
|
|
105
|
+
expect(createRunReporterMock).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("closes the tree reporter when runner.runAll throws", async () => {
|
|
109
|
+
const close = vi.fn();
|
|
110
|
+
createTreeReporterMock.mockReturnValueOnce({
|
|
111
|
+
reporter: { outputMode: "compact" },
|
|
112
|
+
finalize: Promise.resolve(),
|
|
113
|
+
close,
|
|
114
|
+
});
|
|
115
|
+
runAllMock.mockRejectedValueOnce(new Error("boom"));
|
|
116
|
+
|
|
117
|
+
const { executeRunCommand } = await import("./command-helpers.mjs");
|
|
118
|
+
|
|
119
|
+
await expect(executeRunCommand({ jsonEnabled: () => false }, {}, null)).rejects.toThrow("boom");
|
|
120
|
+
expect(close).toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
});
|