Skip to content

Commit 4ae1831

Browse files
authored
Refactor startup planning for direct tests (#24)
1 parent d005bbd commit 4ae1831

3 files changed

Lines changed: 251 additions & 33 deletions

File tree

src/core/startup.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { resolveConfiguredCliInput } from "./config";
2+
import { loadAppBootstrap } from "./loaders";
3+
import { looksLikePatchInput } from "./pager";
4+
import { openControllingTerminal, resolveRuntimeCliInput, usesPipedPatchInput, type ControllingTerminal } from "./terminal";
5+
import type { AppBootstrap, CliInput, ParsedCliInput } from "./types";
6+
import { parseCli } from "./cli";
7+
8+
export type StartupPlan =
9+
| {
10+
kind: "help";
11+
text: string;
12+
}
13+
| {
14+
kind: "mcp-serve";
15+
}
16+
| {
17+
kind: "plain-text-pager";
18+
text: string;
19+
}
20+
| {
21+
kind: "app";
22+
bootstrap: AppBootstrap;
23+
cliInput: CliInput;
24+
controllingTerminal: ControllingTerminal | null;
25+
};
26+
27+
export interface StartupDeps {
28+
parseCliImpl?: (argv: string[]) => Promise<ParsedCliInput>;
29+
readStdinText?: () => Promise<string>;
30+
looksLikePatchInputImpl?: (text: string) => boolean;
31+
resolveRuntimeCliInputImpl?: typeof resolveRuntimeCliInput;
32+
resolveConfiguredCliInputImpl?: typeof resolveConfiguredCliInput;
33+
loadAppBootstrapImpl?: typeof loadAppBootstrap;
34+
usesPipedPatchInputImpl?: typeof usesPipedPatchInput;
35+
openControllingTerminalImpl?: typeof openControllingTerminal;
36+
}
37+
38+
/** Normalize startup work so help, pager, and app-bootstrap paths can be tested directly. */
39+
export async function prepareStartupPlan(
40+
argv: string[] = process.argv,
41+
deps: StartupDeps = {},
42+
): Promise<StartupPlan> {
43+
const parseCliImpl = deps.parseCliImpl ?? parseCli;
44+
const readStdinText = deps.readStdinText ?? (() => new Response(Bun.stdin.stream()).text());
45+
const looksLikePatchInputImpl = deps.looksLikePatchInputImpl ?? looksLikePatchInput;
46+
const resolveRuntimeCliInputImpl = deps.resolveRuntimeCliInputImpl ?? resolveRuntimeCliInput;
47+
const resolveConfiguredCliInputImpl = deps.resolveConfiguredCliInputImpl ?? resolveConfiguredCliInput;
48+
const loadAppBootstrapImpl = deps.loadAppBootstrapImpl ?? loadAppBootstrap;
49+
const usesPipedPatchInputImpl = deps.usesPipedPatchInputImpl ?? usesPipedPatchInput;
50+
const openControllingTerminalImpl = deps.openControllingTerminalImpl ?? openControllingTerminal;
51+
52+
let parsedCliInput = await parseCliImpl(argv);
53+
54+
if (parsedCliInput.kind === "help") {
55+
return {
56+
kind: "help",
57+
text: parsedCliInput.text,
58+
};
59+
}
60+
61+
if (parsedCliInput.kind === "mcp-serve") {
62+
return {
63+
kind: "mcp-serve",
64+
};
65+
}
66+
67+
if (parsedCliInput.kind === "pager") {
68+
const stdinText = await readStdinText();
69+
70+
if (!looksLikePatchInputImpl(stdinText)) {
71+
return {
72+
kind: "plain-text-pager",
73+
text: stdinText,
74+
};
75+
}
76+
77+
parsedCliInput = {
78+
kind: "patch",
79+
file: "-",
80+
text: stdinText,
81+
options: {
82+
...parsedCliInput.options,
83+
pager: true,
84+
},
85+
};
86+
}
87+
88+
const runtimeCliInput = resolveRuntimeCliInputImpl(parsedCliInput);
89+
const configured = resolveConfiguredCliInputImpl(runtimeCliInput);
90+
const cliInput = configured.input;
91+
const bootstrap = await loadAppBootstrapImpl(cliInput);
92+
const controllingTerminal = usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null;
93+
94+
return {
95+
kind: "app",
96+
bootstrap,
97+
cliInput,
98+
controllingTerminal,
99+
};
100+
}

src/main.tsx

Lines changed: 12 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,57 +2,36 @@
22

33
import { createCliRenderer } from "@opentui/core";
44
import { createRoot } from "@opentui/react";
5-
import { parseCli } from "./core/cli";
6-
import { resolveConfiguredCliInput } from "./core/config";
7-
import { loadAppBootstrap } from "./core/loaders";
8-
import { looksLikePatchInput, pagePlainText } from "./core/pager";
5+
import { pagePlainText } from "./core/pager";
96
import { shutdownSession } from "./core/shutdown";
10-
import { openControllingTerminal, resolveRuntimeCliInput, usesPipedPatchInput } from "./core/terminal";
7+
import { prepareStartupPlan } from "./core/startup";
118
import { App } from "./ui/App";
129
import { HunkHostClient } from "./mcp/client";
1310
import { serveHunkMcpServer } from "./mcp/server";
1411
import { createInitialSessionSnapshot, createSessionRegistration } from "./mcp/sessionRegistration";
1512

16-
let parsedCliInput = await parseCli(process.argv);
13+
const startupPlan = await prepareStartupPlan();
1714

18-
if (parsedCliInput.kind === "help") {
19-
process.stdout.write(parsedCliInput.text);
15+
if (startupPlan.kind === "help") {
16+
process.stdout.write(startupPlan.text);
2017
process.exit(0);
2118
}
2219

23-
if (parsedCliInput.kind === "mcp-serve") {
20+
if (startupPlan.kind === "mcp-serve") {
2421
serveHunkMcpServer();
2522
await new Promise<never>(() => {});
2623
}
2724

28-
if (parsedCliInput.kind === "mcp-serve") {
29-
throw new Error("Unreachable MCP daemon branch.");
25+
if (startupPlan.kind === "plain-text-pager") {
26+
await pagePlainText(startupPlan.text);
27+
process.exit(0);
3028
}
3129

32-
if (parsedCliInput.kind === "pager") {
33-
const stdinText = await new Response(Bun.stdin.stream()).text();
34-
35-
if (!looksLikePatchInput(stdinText)) {
36-
await pagePlainText(stdinText);
37-
process.exit(0);
38-
}
39-
40-
parsedCliInput = {
41-
kind: "patch",
42-
file: "-",
43-
text: stdinText,
44-
options: {
45-
...parsedCliInput.options,
46-
pager: true,
47-
},
48-
};
30+
if (startupPlan.kind !== "app") {
31+
throw new Error("Unreachable startup plan.");
4932
}
5033

51-
const runtimeCliInput = resolveRuntimeCliInput(parsedCliInput);
52-
const configured = resolveConfiguredCliInput(runtimeCliInput);
53-
const cliInput = configured.input;
54-
const bootstrap = await loadAppBootstrap(cliInput);
55-
const controllingTerminal = usesPipedPatchInput(cliInput) ? openControllingTerminal() : null;
34+
const { bootstrap, cliInput, controllingTerminal } = startupPlan;
5635
const hostClient = new HunkHostClient(createSessionRegistration(bootstrap), createInitialSessionSnapshot(bootstrap));
5736
hostClient.start();
5837

test/startup.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { prepareStartupPlan } from "../src/core/startup";
3+
import type { AppBootstrap, CliInput, ParsedCliInput } from "../src/core/types";
4+
5+
function createBootstrap(input: CliInput): AppBootstrap {
6+
return {
7+
input,
8+
changeset: {
9+
id: "changeset:startup",
10+
sourceLabel: "repo",
11+
title: "repo working tree",
12+
files: [],
13+
},
14+
initialMode: input.options.mode ?? "auto",
15+
};
16+
}
17+
18+
describe("startup planning", () => {
19+
test("returns help output without entering app startup", async () => {
20+
let loaded = false;
21+
22+
const plan = await prepareStartupPlan(["bun", "hunk"], {
23+
parseCliImpl: async () => ({ kind: "help", text: "Usage: hunk\n" }),
24+
loadAppBootstrapImpl: async () => {
25+
loaded = true;
26+
throw new Error("unreachable");
27+
},
28+
});
29+
30+
expect(plan).toEqual({ kind: "help", text: "Usage: hunk\n" });
31+
expect(loaded).toBe(false);
32+
});
33+
34+
test("passes the MCP serve command through without app bootstrap work", async () => {
35+
let loaded = false;
36+
37+
const plan = await prepareStartupPlan(["bun", "hunk", "mcp", "serve"], {
38+
parseCliImpl: async () => ({ kind: "mcp-serve" }),
39+
loadAppBootstrapImpl: async () => {
40+
loaded = true;
41+
throw new Error("unreachable");
42+
},
43+
});
44+
45+
expect(plan).toEqual({ kind: "mcp-serve" });
46+
expect(loaded).toBe(false);
47+
});
48+
49+
test("routes non-diff pager stdin to the plain-text pager path", async () => {
50+
let loaded = false;
51+
52+
const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
53+
parseCliImpl: async () => ({ kind: "pager", options: { theme: "paper" } }),
54+
readStdinText: async () => "* main\n feature/demo\n",
55+
looksLikePatchInputImpl: () => false,
56+
loadAppBootstrapImpl: async () => {
57+
loaded = true;
58+
throw new Error("unreachable");
59+
},
60+
});
61+
62+
expect(plan).toEqual({ kind: "plain-text-pager", text: "* main\n feature/demo\n" });
63+
expect(loaded).toBe(false);
64+
});
65+
66+
test("normalizes diff-like pager stdin into patch app startup", async () => {
67+
const seenInputs: CliInput[] = [];
68+
69+
const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
70+
parseCliImpl: async () => ({ kind: "pager", options: { theme: "paper" } }),
71+
readStdinText: async () => "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
72+
looksLikePatchInputImpl: () => true,
73+
resolveRuntimeCliInputImpl(input) {
74+
seenInputs.push(input);
75+
return input;
76+
},
77+
resolveConfiguredCliInputImpl(input) {
78+
seenInputs.push(input);
79+
return { input } as never;
80+
},
81+
loadAppBootstrapImpl: async (input) => {
82+
seenInputs.push(input);
83+
return createBootstrap(input);
84+
},
85+
usesPipedPatchInputImpl: () => false,
86+
});
87+
88+
expect(plan.kind).toBe("app");
89+
if (plan.kind !== "app") {
90+
throw new Error("Expected app startup plan.");
91+
}
92+
93+
expect(plan.cliInput).toMatchObject({
94+
kind: "patch",
95+
file: "-",
96+
text: "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
97+
options: {
98+
theme: "paper",
99+
pager: true,
100+
},
101+
});
102+
expect(seenInputs).toHaveLength(3);
103+
});
104+
105+
test("opens the controlling terminal for piped patch startup", async () => {
106+
const cliInput: CliInput = {
107+
kind: "patch",
108+
file: "-",
109+
options: {
110+
mode: "auto",
111+
pager: true,
112+
},
113+
};
114+
const controllingTerminal = { stdin: {} as never, stdout: {} as never, close: () => {} };
115+
let opened = 0;
116+
117+
const plan = await prepareStartupPlan(["bun", "hunk", "patch", "-"], {
118+
parseCliImpl: async () => cliInput as ParsedCliInput,
119+
resolveRuntimeCliInputImpl: (input) => input,
120+
resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
121+
loadAppBootstrapImpl: async (input) => createBootstrap(input),
122+
usesPipedPatchInputImpl: (input) => {
123+
expect(input).toBe(cliInput);
124+
return true;
125+
},
126+
openControllingTerminalImpl: () => {
127+
opened += 1;
128+
return controllingTerminal;
129+
},
130+
});
131+
132+
expect(plan).toMatchObject({
133+
kind: "app",
134+
cliInput,
135+
controllingTerminal,
136+
});
137+
expect(opened).toBe(1);
138+
});
139+
});

0 commit comments

Comments
 (0)