Profile placeholders were reaching spawned processes because each menu decided whether to resolve them. Keep UI callers profile-only and perform empty-prompt resolution once when the workspace terminal is created.
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
DEFAULT_TERMINAL_PROFILES,
|
|
PROMPT_SENTINEL,
|
|
formatResolvedCommand,
|
|
getTerminalProfileIcon,
|
|
guessTerminalProfileIcon,
|
|
profileTakesPrompt,
|
|
resolveTerminalProfileLaunch,
|
|
resolveTerminalProfiles,
|
|
substitutePrompt,
|
|
} from "./terminal-profiles.js";
|
|
|
|
describe("resolveTerminalProfiles", () => {
|
|
it("returns defaults when undefined", () => {
|
|
const result = resolveTerminalProfiles(undefined);
|
|
expect(result).toBe(DEFAULT_TERMINAL_PROFILES);
|
|
});
|
|
|
|
it("returns an empty array as-is when defined as empty", () => {
|
|
const result = resolveTerminalProfiles([]);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
// A profile that predates the sentinel got materialized into user config the
|
|
// first time anyone touched the profile list, so it would otherwise be stuck
|
|
// launch-only while a fresh install gets prompt support.
|
|
it("appends the sentinel to a bare shipped default", () => {
|
|
const persisted = [{ id: "claude", name: "Claude Code", command: "claude", icon: "claude" }];
|
|
const result = resolveTerminalProfiles(persisted);
|
|
expect(result[0]).toEqual({
|
|
id: "claude",
|
|
name: "Claude Code",
|
|
command: "claude",
|
|
icon: "claude",
|
|
args: [PROMPT_SENTINEL],
|
|
});
|
|
});
|
|
|
|
// Profiles created through the settings UI get generated ids, so keying the
|
|
// adoption on the id would never match a real user's config.
|
|
it("appends the sentinel for a generated id, keying on the command instead", () => {
|
|
const persisted = [
|
|
{ id: "profile_1739308800000_a1b2c3", name: "Codex", command: "codex", args: ["--yolo"] },
|
|
];
|
|
const result = resolveTerminalProfiles(persisted);
|
|
expect(result[0]?.args).toEqual(["--yolo", PROMPT_SENTINEL]);
|
|
});
|
|
|
|
it("appends the sentinel after the user's own args, preserving them", () => {
|
|
const persisted = [{ id: "codex", name: "Codex", command: "codex", args: ["--yolo"] }];
|
|
const result = resolveTerminalProfiles(persisted);
|
|
expect(result[0]?.args).toEqual(["--yolo", PROMPT_SENTINEL]);
|
|
});
|
|
|
|
it("appends the sentinel for an absolute path to a known agent", () => {
|
|
const persisted = [
|
|
{ id: "profile_1739308800001_d4e5f6", name: "Codex", command: "/usr/local/bin/codex" },
|
|
];
|
|
const result = resolveTerminalProfiles(persisted);
|
|
expect(result[0]?.args).toEqual([PROMPT_SENTINEL]);
|
|
});
|
|
|
|
it("appends the sentinel for a Windows-shaped command with an extension", () => {
|
|
const persisted = [
|
|
{
|
|
id: "profile_1739308800002_g7h8i9",
|
|
name: "Claude Code",
|
|
command: "C:\\Program Files\\nodejs\\claude.cmd",
|
|
},
|
|
];
|
|
const result = resolveTerminalProfiles(persisted);
|
|
expect(result[0]?.args).toEqual([PROMPT_SENTINEL]);
|
|
});
|
|
|
|
it("does not append when the profile already carries a sentinel", () => {
|
|
const persisted = [
|
|
{ id: "codex", name: "Codex", command: "codex", args: [PROMPT_SENTINEL, "--yolo"] },
|
|
];
|
|
const result = resolveTerminalProfiles(persisted);
|
|
expect(result[0]).toBe(persisted[0]);
|
|
});
|
|
|
|
it("leaves a command that is not a known agent untouched, whatever its id", () => {
|
|
const persisted = [
|
|
{ id: "zsh-custom", name: "Zsh", command: "zsh" },
|
|
{ id: "claude", name: "Gemini", command: "gemini" },
|
|
];
|
|
const result = resolveTerminalProfiles(persisted);
|
|
expect(result[0]).toBe(persisted[0]);
|
|
expect(result[1]).toBe(persisted[1]);
|
|
});
|
|
|
|
it("migrates only the matching entries in a mixed list, preserving the rest by reference", () => {
|
|
const otherAgent = { id: "profile_1739308800003_j1k2l3", name: "Gemini", command: "gemini" };
|
|
const persisted = [{ id: "claude", name: "Claude Code", command: "claude" }, otherAgent];
|
|
const result = resolveTerminalProfiles(persisted);
|
|
expect(result[0]?.args).toEqual([PROMPT_SENTINEL]);
|
|
expect(result[1]).toBe(otherAgent);
|
|
});
|
|
|
|
it("default profiles include claude, codex, opencode", () => {
|
|
const ids = DEFAULT_TERMINAL_PROFILES.map((p) => p.id);
|
|
expect(ids).toContain("claude");
|
|
expect(ids).toContain("codex");
|
|
expect(ids).toContain("opencode");
|
|
});
|
|
|
|
it("claude profile has the correct icon", () => {
|
|
const claude = DEFAULT_TERMINAL_PROFILES.find((p) => p.id === "claude");
|
|
expect(claude?.icon).toBe("claude");
|
|
});
|
|
|
|
it("every default profile takes a prompt, and drops the arg entirely without one", () => {
|
|
for (const profile of DEFAULT_TERMINAL_PROFILES) {
|
|
expect(profileTakesPrompt(profile)).toBe(true);
|
|
// No prompt has to mean no argv entry at all: a bare positional would be
|
|
// an empty argument, and a bare `--prompt=` is not the same as omitting it.
|
|
expect(substitutePrompt(profile, "")).toEqual({ command: profile.command, args: [] });
|
|
}
|
|
});
|
|
|
|
it("ships the prompt form each CLI actually accepts", () => {
|
|
const byId = new Map(DEFAULT_TERMINAL_PROFILES.map((profile) => [profile.id, profile]));
|
|
// claude, codex, and pi take the prompt as a trailing positional.
|
|
for (const id of ["claude", "codex", "pi"]) {
|
|
expect(byId.get(id)?.args).toEqual(["{{{prompt}}}"]);
|
|
}
|
|
// opencode's positional is the project directory, so the prompt is a flag.
|
|
expect(byId.get("opencode")?.args).toEqual(["--prompt={{{prompt}}}"]);
|
|
});
|
|
|
|
it("adopts the right prompt form per command, not a blanket trailing positional", () => {
|
|
const [adoptedOpencode] = resolveTerminalProfiles([
|
|
{ id: "profile_generated", name: "OpenCode", command: "opencode" },
|
|
]);
|
|
expect(adoptedOpencode?.args).toEqual(["--prompt={{{prompt}}}"]);
|
|
|
|
const [adoptedPi] = resolveTerminalProfiles([
|
|
{ id: "profile_generated", name: "Pi", command: "pi", args: ["--thinking", "high"] },
|
|
]);
|
|
expect(adoptedPi?.args).toEqual(["--thinking", "high", "{{{prompt}}}"]);
|
|
});
|
|
|
|
it.each([
|
|
["codex", ["--profile", "work", "login"]],
|
|
["opencode", ["--model", "anthropic/claude", "run"]],
|
|
])("does not adopt %s when a subcommand follows global options", (command, args) => {
|
|
const persisted = { id: "profile_generated", name: command, command, args };
|
|
const [resolved] = resolveTerminalProfiles([persisted]);
|
|
|
|
expect(resolved).toBe(persisted);
|
|
});
|
|
|
|
it.each([
|
|
["codex", ["--profile", "work"]],
|
|
["opencode", ["--model", "anthropic/claude"]],
|
|
["pi", ["--thinking", "high"]],
|
|
])("adopts %s when global options are the only existing args", (command, args) => {
|
|
const [resolved] = resolveTerminalProfiles([
|
|
{ id: "profile_generated", name: command, command, args },
|
|
]);
|
|
|
|
expect(resolved?.args).toEqual([...args, expect.stringContaining("{{{prompt}}}")]);
|
|
});
|
|
|
|
it("codex profile has the correct icon", () => {
|
|
const codex = DEFAULT_TERMINAL_PROFILES.find((p) => p.id === "codex");
|
|
expect(codex?.icon).toBe("codex");
|
|
});
|
|
|
|
it("opencode profile has the correct icon", () => {
|
|
const opencode = DEFAULT_TERMINAL_PROFILES.find((p) => p.id === "opencode");
|
|
expect(opencode?.icon).toBe("opencode");
|
|
});
|
|
});
|
|
|
|
describe("guessTerminalProfileIcon", () => {
|
|
it.each([
|
|
["claude", "claude"],
|
|
["codex", "codex"],
|
|
["opencode", "opencode"],
|
|
["copilot", "copilot"],
|
|
["kiro", "kiro"],
|
|
["omp", "omp"],
|
|
["pi", "pi"],
|
|
["gemini", "gemini"],
|
|
["cursor", "cursor"],
|
|
["goose", "goose"],
|
|
["grok", "grok"],
|
|
["kimi", "kimi"],
|
|
["Claude", "claude"],
|
|
["CODEX", "codex"],
|
|
["/usr/local/bin/claude", "claude"],
|
|
["C:\\\\Program Files\\\\Codex\\\\codex.exe", "codex"],
|
|
["/usr/local/bin/gemini", "gemini"],
|
|
["agy", "agy"],
|
|
["/usr/local/bin/agy", "agy"],
|
|
])("guesses %s -> %s", (command, expected) => {
|
|
expect(guessTerminalProfileIcon(command)).toBe(expected);
|
|
});
|
|
|
|
it.each([["zsh"], ["bash"], ["fish"], [""], ["/usr/bin/foo"], ["cursor-agent"]])(
|
|
"returns undefined for unknown command %s",
|
|
(command) => {
|
|
expect(guessTerminalProfileIcon(command)).toBeUndefined();
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("getTerminalProfileIcon", () => {
|
|
it("returns the explicit icon when set", () => {
|
|
const profile = { id: "1", name: "Foo", command: "zsh", icon: "claude" };
|
|
expect(getTerminalProfileIcon(profile)).toBe("claude");
|
|
});
|
|
|
|
it("guesses the icon when none is set", () => {
|
|
const profile = { id: "1", name: "Foo", command: "claude" };
|
|
expect(getTerminalProfileIcon(profile)).toBe("claude");
|
|
});
|
|
|
|
it("returns undefined when no icon is set and command is unknown", () => {
|
|
const profile = { id: "1", name: "Foo", command: "zsh" };
|
|
expect(getTerminalProfileIcon(profile)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("profileTakesPrompt", () => {
|
|
it("is true when the sentinel is a standalone arg", () => {
|
|
expect(profileTakesPrompt({ command: "claude", args: [PROMPT_SENTINEL] })).toBe(true);
|
|
});
|
|
|
|
it("is true when the sentinel is embedded inside a larger arg", () => {
|
|
expect(profileTakesPrompt({ command: "sh", args: ["-c", `echo ${PROMPT_SENTINEL}`] })).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("is true when the sentinel is in the command", () => {
|
|
expect(profileTakesPrompt({ command: `run ${PROMPT_SENTINEL}` })).toBe(true);
|
|
});
|
|
|
|
it("is false with no sentinel anywhere, including with args omitted", () => {
|
|
expect(profileTakesPrompt({ command: "claude", args: ["--continue"] })).toBe(false);
|
|
expect(profileTakesPrompt({ command: "zsh" })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("substitutePrompt", () => {
|
|
it("replaces a standalone sentinel arg with the prompt", () => {
|
|
expect(substitutePrompt({ command: "claude", args: [PROMPT_SENTINEL] }, "fix the bug")).toEqual(
|
|
{ command: "claude", args: ["fix the bug"] },
|
|
);
|
|
});
|
|
|
|
it("drops a sentinel-only arg when the prompt is empty, so a bare command still launches", () => {
|
|
expect(substitutePrompt({ command: "claude", args: [PROMPT_SENTINEL] }, "")).toEqual({
|
|
command: "claude",
|
|
args: [],
|
|
});
|
|
});
|
|
|
|
it("substitutes into an embedded sentinel rather than dropping the arg, even when empty", () => {
|
|
expect(
|
|
substitutePrompt({ command: "sh", args: ["-c", `echo ${PROMPT_SENTINEL}`] }, ""),
|
|
).toEqual({ command: "sh", args: ["-c", "echo "] });
|
|
});
|
|
|
|
it("replaces every occurrence, in the command and in the args", () => {
|
|
expect(
|
|
substitutePrompt(
|
|
{ command: `${PROMPT_SENTINEL}-run`, args: [`${PROMPT_SENTINEL}/${PROMPT_SENTINEL}`] },
|
|
"x",
|
|
),
|
|
).toEqual({ command: "x-run", args: ["x/x"] });
|
|
});
|
|
|
|
it("passes the prompt through verbatim, with no quoting or escaping", () => {
|
|
expect(
|
|
substitutePrompt({ command: "sh", args: [PROMPT_SENTINEL] }, 'a "quoted" $var; rm -rf'),
|
|
).toEqual({ command: "sh", args: ['a "quoted" $var; rm -rf'] });
|
|
});
|
|
|
|
it("normalizes absent args to an empty list", () => {
|
|
expect(substitutePrompt({ command: "zsh" }, "ignored")).toEqual({ command: "zsh", args: [] });
|
|
});
|
|
});
|
|
|
|
describe("resolveTerminalProfileLaunch", () => {
|
|
it("turns an empty prompt profile into a spawnable command with no prompt argument", () => {
|
|
expect(
|
|
resolveTerminalProfileLaunch(
|
|
{
|
|
id: "agent",
|
|
name: "Agent",
|
|
command: "agent-cli",
|
|
args: [PROMPT_SENTINEL],
|
|
},
|
|
"",
|
|
),
|
|
).toEqual({ name: "Agent", command: "agent-cli", args: [] });
|
|
});
|
|
});
|
|
|
|
describe("formatResolvedCommand", () => {
|
|
it("joins the command and its args with spaces", () => {
|
|
expect(formatResolvedCommand({ command: "/bin/sh", args: ["-c", "echo hi"] })).toBe(
|
|
"/bin/sh -c echo hi",
|
|
);
|
|
});
|
|
|
|
it("is just the command when there are no args", () => {
|
|
expect(formatResolvedCommand({ command: "zsh", args: [] })).toBe("zsh");
|
|
});
|
|
});
|