The mobile composer/footer slid over the message list when the user swiped with the keyboard up. Cause: --vv-height / --vv-offset-top were routed through React state via useMobileKeyboard, so on iOS — which fires visualViewport scroll/resize on the same frame as its keyboard animation — the .chat-thread translation lagged by one paint, visible as the composer momentarily floating over messages. Now those two vars are written imperatively in a useLayoutEffect directly to the .chat-thread DOM node on every visualViewport event, mirroring the working pattern at QuickChatFAB.tsx:1032-1052 (which already works correctly on mobile). Only --keyboard-overlap (a structural open/close signal, not per-frame) still flows through React state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
353 lines
16 KiB
JavaScript
353 lines
16 KiB
JavaScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { EventEmitter } from "node:events";
|
|
// ── Mock node:child_process before imports that use it ─────────────────────
|
|
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
|
|
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
|
|
import { buildHermesArgs, invokeHermesCli, listHermesProfiles, parseHermesOutput, resolveCliSettings, } from "../cli-spawn.js";
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
function defaultSettings(overrides = {}) {
|
|
return {
|
|
binaryPath: "hermes",
|
|
maxTurns: 12,
|
|
yolo: false,
|
|
cliTimeoutMs: 5_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
/**
|
|
* Create a fake ChildProcess-like EventEmitter with controllable
|
|
* stdout/stderr streams and a kill spy.
|
|
*/
|
|
function makeFakeChild() {
|
|
const main = new EventEmitter();
|
|
const stdoutEmitter = new EventEmitter();
|
|
const stderrEmitter = new EventEmitter();
|
|
main.stdout = stdoutEmitter;
|
|
main.stderr = stderrEmitter;
|
|
const kill = vi.fn().mockReturnValue(true);
|
|
main.kill = kill;
|
|
return {
|
|
child: main,
|
|
emitStdout: (data) => stdoutEmitter.emit("data", Buffer.from(data)),
|
|
emitStderr: (data) => stderrEmitter.emit("data", Buffer.from(data)),
|
|
emitError: (err) => main.emit("error", err),
|
|
emitClose: (code) => main.emit("close", code),
|
|
kill,
|
|
};
|
|
}
|
|
/** Build a stdout string that hermes would produce for a given body + session id. */
|
|
function fakeHermesOutput(body, sessionId = "20260427_120000_abcd12") {
|
|
return `${body}\nsession_id: ${sessionId}\n`;
|
|
}
|
|
// ── resolveCliSettings ──────────────────────────────────────────────────────
|
|
describe("resolveCliSettings", () => {
|
|
const origEnv = { ...process.env };
|
|
afterEach(() => {
|
|
process.env = { ...origEnv };
|
|
});
|
|
it("returns defaults when settings and env are empty", () => {
|
|
delete process.env.HERMES_BIN;
|
|
delete process.env.HERMES_MODEL_ID;
|
|
delete process.env.HERMES_PROVIDER;
|
|
delete process.env.HERMES_MAX_TURNS;
|
|
delete process.env.HERMES_YOLO;
|
|
delete process.env.HERMES_CLI_TIMEOUT_MS;
|
|
expect(resolveCliSettings()).toEqual({
|
|
binaryPath: "hermes",
|
|
model: undefined,
|
|
provider: undefined,
|
|
maxTurns: 12,
|
|
yolo: false,
|
|
cliTimeoutMs: 300_000,
|
|
});
|
|
});
|
|
it("prefers settings over env vars", () => {
|
|
process.env.HERMES_BIN = "/usr/bin/hermes";
|
|
process.env.HERMES_MODEL_ID = "env-model";
|
|
process.env.HERMES_PROVIDER = "openrouter";
|
|
expect(resolveCliSettings({
|
|
binaryPath: "/custom/hermes",
|
|
model: "gpt-4o",
|
|
provider: "openai-codex",
|
|
maxTurns: 5,
|
|
yolo: true,
|
|
cliTimeoutMs: 10_000,
|
|
})).toEqual({
|
|
binaryPath: "/custom/hermes",
|
|
model: "gpt-4o",
|
|
provider: "openai-codex",
|
|
maxTurns: 5,
|
|
yolo: true,
|
|
cliTimeoutMs: 10_000,
|
|
});
|
|
});
|
|
it("falls back to env vars when settings omit values", () => {
|
|
process.env.HERMES_BIN = "/env/hermes";
|
|
process.env.HERMES_MODEL_ID = "env-model";
|
|
process.env.HERMES_PROVIDER = "gemini";
|
|
process.env.HERMES_MAX_TURNS = "7";
|
|
process.env.HERMES_YOLO = "true";
|
|
process.env.HERMES_CLI_TIMEOUT_MS = "60000";
|
|
expect(resolveCliSettings({})).toEqual({
|
|
binaryPath: "/env/hermes",
|
|
model: "env-model",
|
|
provider: "gemini",
|
|
maxTurns: 7,
|
|
yolo: true,
|
|
cliTimeoutMs: 60_000,
|
|
});
|
|
});
|
|
});
|
|
// ── buildHermesArgs ────────────────────────────────────────────────────────
|
|
describe("buildHermesArgs", () => {
|
|
it("builds minimal args without resume or optional flags", () => {
|
|
const args = buildHermesArgs("hello world", defaultSettings());
|
|
expect(args).toEqual(["chat", "-q", "hello world", "-Q", "--source", "tool", "--max-turns", "12"]);
|
|
});
|
|
it("adds --resume when sessionId provided", () => {
|
|
const args = buildHermesArgs("hello", defaultSettings(), "20260427_120000_abcd12");
|
|
expect(args).toContain("--resume");
|
|
expect(args[args.indexOf("--resume") + 1]).toBe("20260427_120000_abcd12");
|
|
});
|
|
it("adds -m and --provider when configured", () => {
|
|
const args = buildHermesArgs("hello", defaultSettings({ model: "claude-sonnet-4-5", provider: "anthropic" }));
|
|
expect(args).toContain("-m");
|
|
expect(args[args.indexOf("-m") + 1]).toBe("claude-sonnet-4-5");
|
|
expect(args).toContain("--provider");
|
|
expect(args[args.indexOf("--provider") + 1]).toBe("anthropic");
|
|
});
|
|
it("adds --yolo when yolo is true", () => {
|
|
const args = buildHermesArgs("hello", defaultSettings({ yolo: true }));
|
|
expect(args).toContain("--yolo");
|
|
});
|
|
it("does not add --yolo when yolo is false", () => {
|
|
const args = buildHermesArgs("hello", defaultSettings({ yolo: false }));
|
|
expect(args).not.toContain("--yolo");
|
|
});
|
|
it("uses configured maxTurns", () => {
|
|
const args = buildHermesArgs("hello", defaultSettings({ maxTurns: 5 }));
|
|
expect(args[args.indexOf("--max-turns") + 1]).toBe("5");
|
|
});
|
|
it("first call has no --resume arg", () => {
|
|
const args = buildHermesArgs("hi", defaultSettings());
|
|
expect(args).not.toContain("--resume");
|
|
});
|
|
});
|
|
// ── parseHermesOutput ──────────────────────────────────────────────────────
|
|
describe("parseHermesOutput", () => {
|
|
it("extracts session_id and body from clean output", () => {
|
|
const stdout = "This is the response\nsession_id: 20260427_120000_abcd12\n";
|
|
const result = parseHermesOutput(stdout, "");
|
|
expect(result.sessionId).toBe("20260427_120000_abcd12");
|
|
expect(result.body).toBe("This is the response");
|
|
});
|
|
it("strips ANSI escape codes from body", () => {
|
|
const stdout = "\x1b[32mGreen text\x1b[0m\nsession_id: 20260427_120000_abcd12\n";
|
|
const result = parseHermesOutput(stdout, "");
|
|
expect(result.body).toBe("Green text");
|
|
expect(result.body).not.toContain("\x1b");
|
|
});
|
|
it("strips preamble chrome lines", () => {
|
|
const stdout = [
|
|
"╭─ Hermes ─╮",
|
|
"↻ Resumed session foo",
|
|
" ┊ preparing memory…",
|
|
"Query: what is 2+2?",
|
|
"4",
|
|
"╰─────────╯",
|
|
"session_id: 20260427_120000_abcd12",
|
|
].join("\n") + "\n";
|
|
const result = parseHermesOutput(stdout, "");
|
|
expect(result.body).toBe("4");
|
|
expect(result.body).not.toContain("Hermes");
|
|
expect(result.body).not.toContain("Resumed");
|
|
expect(result.body).not.toContain("preparing");
|
|
expect(result.body).not.toContain("Query:");
|
|
expect(result.body).not.toContain("╭");
|
|
expect(result.body).not.toContain("╰");
|
|
});
|
|
it("normalizes CRLF to LF", () => {
|
|
const stdout = "line1\r\nline2\r\nsession_id: 20260427_120000_abcd12\r\n";
|
|
const result = parseHermesOutput(stdout, "");
|
|
expect(result.body).toBe("line1\nline2");
|
|
});
|
|
it("throws when session_id line is missing", () => {
|
|
expect(() => parseHermesOutput("some output without id", "stderr text")).toThrow(/missing session_id/);
|
|
});
|
|
});
|
|
// ── invokeHermesCli ────────────────────────────────────────────────────────
|
|
describe("invokeHermesCli", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
it("first call passes correct args and no --resume", async () => {
|
|
const { child, emitStdout, emitClose } = makeFakeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const PROMPT = "what is typescript?";
|
|
const promise = invokeHermesCli(PROMPT, defaultSettings());
|
|
emitStdout(fakeHermesOutput("TypeScript is a language."));
|
|
emitClose(0);
|
|
const result = await promise;
|
|
expect(mockSpawn).toHaveBeenCalledOnce();
|
|
const [bin, args] = mockSpawn.mock.calls[0];
|
|
expect(bin).toBe("hermes");
|
|
expect(args).toEqual(["chat", "-q", PROMPT, "-Q", "--source", "tool", "--max-turns", "12"]);
|
|
expect(result.body).toBe("TypeScript is a language.");
|
|
expect(result.sessionId).toBe("20260427_120000_abcd12");
|
|
});
|
|
it("subsequent call with sessionId passes --resume", async () => {
|
|
const { child, emitStdout, emitClose } = makeFakeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const promise = invokeHermesCli("hello again", defaultSettings(), "20260427_120000_abcd12");
|
|
emitStdout(fakeHermesOutput("Hi there!"));
|
|
emitClose(0);
|
|
await promise;
|
|
const args = mockSpawn.mock.calls[0][1];
|
|
expect(args).toContain("--resume");
|
|
expect(args[args.indexOf("--resume") + 1]).toBe("20260427_120000_abcd12");
|
|
});
|
|
it("captures session id from stdout", async () => {
|
|
const { child, emitStdout, emitClose } = makeFakeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const promise = invokeHermesCli("hi", defaultSettings());
|
|
emitStdout("Hello!\nsession_id: 20260427_120000_abcd12\n");
|
|
emitClose(0);
|
|
const result = await promise;
|
|
expect(result.sessionId).toBe("20260427_120000_abcd12");
|
|
});
|
|
it("rejects on non-zero exit code and surfaces stderr", async () => {
|
|
const { child, emitStdout, emitStderr, emitClose } = makeFakeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const promise = invokeHermesCli("hi", defaultSettings());
|
|
emitStdout("partial output");
|
|
emitStderr("fatal error from hermes");
|
|
emitClose(1);
|
|
await expect(promise).rejects.toThrow(/exited with code 1/);
|
|
});
|
|
it("rejects when session_id is missing from stdout on exit 0", async () => {
|
|
const { child, emitStdout, emitClose } = makeFakeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const promise = invokeHermesCli("hi", defaultSettings());
|
|
emitStdout("Some output without session id line");
|
|
emitClose(0);
|
|
await expect(promise).rejects.toThrow(/missing session_id/);
|
|
});
|
|
it("includes -m, --provider, --max-turns, --yolo when settings configured", async () => {
|
|
const { child, emitStdout, emitClose } = makeFakeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const settings = defaultSettings({
|
|
model: "claude-sonnet-4-5",
|
|
provider: "anthropic",
|
|
maxTurns: 20,
|
|
yolo: true,
|
|
});
|
|
const promise = invokeHermesCli("test", settings);
|
|
emitStdout(fakeHermesOutput("ok"));
|
|
emitClose(0);
|
|
await promise;
|
|
const args = mockSpawn.mock.calls[0][1];
|
|
expect(args).toContain("-m");
|
|
expect(args[args.indexOf("-m") + 1]).toBe("claude-sonnet-4-5");
|
|
expect(args).toContain("--provider");
|
|
expect(args[args.indexOf("--provider") + 1]).toBe("anthropic");
|
|
expect(args[args.indexOf("--max-turns") + 1]).toBe("20");
|
|
expect(args).toContain("--yolo");
|
|
});
|
|
it("rejects on ENOENT with not-found message", async () => {
|
|
const { child, emitError } = makeFakeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const promise = invokeHermesCli("hi", defaultSettings());
|
|
const err = Object.assign(new Error("not found"), { code: "ENOENT" });
|
|
emitError(err);
|
|
await expect(promise).rejects.toThrow(/binary not found/);
|
|
});
|
|
it("AbortSignal triggers child.kill and rejects", async () => {
|
|
const { child, kill, emitStdout } = makeFakeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const ac = new AbortController();
|
|
const promise = invokeHermesCli("hi", defaultSettings(), undefined, ac.signal);
|
|
// Abort before any output arrives.
|
|
ac.abort();
|
|
emitStdout("irrelevant");
|
|
await expect(promise).rejects.toThrow(/aborted/);
|
|
expect(kill).toHaveBeenCalledWith("SIGTERM");
|
|
});
|
|
});
|
|
// ── listHermesProfiles ─────────────────────────────────────────────────────
|
|
describe("listHermesProfiles", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
const SAMPLE_OUTPUT = [
|
|
" Profile Model Gateway Alias",
|
|
" ─────────────── ───────────────────────── ─────────── ────────────",
|
|
" ◆default MiniMax-M2.7 stopped —",
|
|
].join("\n") + "\n";
|
|
it("happy path: parses single default profile", async () => {
|
|
const { child, emitStdout, emitClose } = makeFakeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const promise = listHermesProfiles();
|
|
emitStdout(SAMPLE_OUTPUT);
|
|
emitClose(0);
|
|
const profiles = await promise;
|
|
expect(profiles).toHaveLength(1);
|
|
expect(profiles[0]).toMatchObject({
|
|
name: "default",
|
|
model: "MiniMax-M2.7",
|
|
gateway: "stopped",
|
|
alias: undefined,
|
|
isDefault: true,
|
|
});
|
|
});
|
|
it("happy path: multi-profile ordered correctly", async () => {
|
|
const { child, emitStdout, emitClose } = makeFakeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const multiOutput = [
|
|
" Profile Model Gateway Alias",
|
|
" ─────────────── ───────────────────────── ─────────── ────────────",
|
|
" ◆default MiniMax-M2.7 stopped —",
|
|
" work claude-sonnet-4-5 running work-hermes",
|
|
].join("\n") + "\n";
|
|
const promise = listHermesProfiles();
|
|
emitStdout(multiOutput);
|
|
emitClose(0);
|
|
const profiles = await promise;
|
|
expect(profiles).toHaveLength(2);
|
|
expect(profiles[0].name).toBe("default");
|
|
expect(profiles[0].isDefault).toBe(true);
|
|
expect(profiles[1].name).toBe("work");
|
|
expect(profiles[1].isDefault).toBe(false);
|
|
expect(profiles[1].model).toBe("claude-sonnet-4-5");
|
|
expect(profiles[1].alias).toBe("work-hermes");
|
|
});
|
|
it("ENOENT rejects with binary-not-found error", async () => {
|
|
const { child, emitError } = makeFakeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const promise = listHermesProfiles({ binaryPath: "/no/such/hermes" });
|
|
const err = Object.assign(new Error("not found"), { code: "ENOENT" });
|
|
emitError(err);
|
|
await expect(promise).rejects.toThrow(/hermes profile list failed.*binary not found/);
|
|
});
|
|
it("non-zero exit rejects with exit code error", async () => {
|
|
const { child, emitStdout, emitStderr, emitClose } = makeFakeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const promise = listHermesProfiles();
|
|
emitStdout("");
|
|
emitStderr("unknown subcommand");
|
|
emitClose(2);
|
|
await expect(promise).rejects.toThrow(/hermes profile list failed.*exited with code 2/);
|
|
});
|
|
it("spawns with 'profile list' args", async () => {
|
|
const { child, emitStdout, emitClose } = makeFakeChild();
|
|
mockSpawn.mockReturnValue(child);
|
|
const promise = listHermesProfiles({ binaryPath: "/custom/hermes" });
|
|
emitStdout(SAMPLE_OUTPUT);
|
|
emitClose(0);
|
|
await promise;
|
|
expect(mockSpawn).toHaveBeenCalledOnce();
|
|
const [bin, args] = mockSpawn.mock.calls[0];
|
|
expect(bin).toBe("/custom/hermes");
|
|
expect(args).toEqual(["profile", "list"]);
|
|
});
|
|
});
|
|
//# sourceMappingURL=cli-spawn.test.js.map
|