Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
507 lines
16 KiB
TypeScript
507 lines
16 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
|
|
|
|
type LocalStorageMock = {
|
|
clear(): void;
|
|
getItem(key: string): string | null;
|
|
setItem(key: string, value: string): void;
|
|
removeItem(key: string): void;
|
|
};
|
|
|
|
type MockDirEntry = {
|
|
name: string;
|
|
type: "file" | "directory" | "symlink";
|
|
};
|
|
|
|
function installLocalStorage(): LocalStorageMock {
|
|
const store = new Map<string, string>();
|
|
const localStorage: LocalStorageMock = {
|
|
clear() {
|
|
store.clear();
|
|
},
|
|
getItem(key: string) {
|
|
return store.has(key) ? store.get(key)! : null;
|
|
},
|
|
setItem(key: string, value: string) {
|
|
store.set(key, String(value));
|
|
},
|
|
removeItem(key: string) {
|
|
store.delete(key);
|
|
},
|
|
};
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
value: localStorage,
|
|
configurable: true,
|
|
});
|
|
return localStorage;
|
|
}
|
|
|
|
const localStorage = installLocalStorage();
|
|
const storySpec: FigSpec = {
|
|
name: "story",
|
|
subcommands: [
|
|
{
|
|
name: "open",
|
|
args: { template: "filepaths" },
|
|
},
|
|
{
|
|
name: "pick",
|
|
args: { name: "item", generators: {} },
|
|
},
|
|
],
|
|
};
|
|
const bridgeState: {
|
|
localEntries: MockDirEntry[];
|
|
remoteEntriesByPath: Map<string, MockDirEntry[]>;
|
|
remoteCalls: string[];
|
|
remoteDelayMs: number;
|
|
} = {
|
|
localEntries: [],
|
|
remoteEntriesByPath: new Map(),
|
|
remoteCalls: [],
|
|
remoteDelayMs: 0,
|
|
};
|
|
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: {
|
|
netcatty: {
|
|
listFigSpecs: async () => ["story"],
|
|
loadFigSpec: async (commandName: string) => commandName === "story" ? storySpec : null,
|
|
listAutocompleteLocalDir: async (
|
|
_path: string,
|
|
foldersOnly: boolean,
|
|
filterPrefix?: string,
|
|
limit?: number,
|
|
) => {
|
|
const prefix = (filterPrefix ?? "").toLowerCase();
|
|
const entries = bridgeState.localEntries
|
|
.filter((entry) => !foldersOnly || entry.type === "directory")
|
|
.filter((entry) => !prefix || entry.name.toLowerCase().startsWith(prefix))
|
|
.slice(0, limit ?? bridgeState.localEntries.length);
|
|
return { success: true, entries };
|
|
},
|
|
listAutocompleteRemoteDir: async (
|
|
_sessionId: string,
|
|
path: string,
|
|
foldersOnly: boolean,
|
|
filterPrefix?: string,
|
|
limit?: number,
|
|
) => {
|
|
bridgeState.remoteCalls.push(path);
|
|
if (bridgeState.remoteDelayMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, bridgeState.remoteDelayMs));
|
|
}
|
|
const prefix = (filterPrefix ?? "").toLowerCase();
|
|
const remoteEntries = bridgeState.remoteEntriesByPath.get(path) ?? [];
|
|
const entries = remoteEntries
|
|
.filter((entry) => !foldersOnly || entry.type === "directory")
|
|
.filter((entry) => !prefix || entry.name.toLowerCase().startsWith(prefix))
|
|
.slice(0, limit ?? remoteEntries.length);
|
|
return { success: true, entries };
|
|
},
|
|
},
|
|
},
|
|
configurable: true,
|
|
});
|
|
|
|
const {
|
|
getCompletions,
|
|
getPathSuggestionsWithinBudget,
|
|
} = await import("./autocomplete/completionEngine.ts");
|
|
const {
|
|
clearHistory,
|
|
recordCommand,
|
|
removeCommandHistoryEntry,
|
|
} = await import("./autocomplete/commandHistoryStore.ts");
|
|
const {
|
|
normalizePathTokenForLookup,
|
|
shouldPreferRemoteShellCwd,
|
|
} = await import("./autocomplete/remotePathCompleter.ts");
|
|
|
|
test.beforeEach(() => {
|
|
localStorage.clear();
|
|
clearHistory();
|
|
bridgeState.localEntries = [{ name: "package.json", type: "file" }];
|
|
bridgeState.remoteEntriesByPath = new Map();
|
|
bridgeState.remoteCalls = [];
|
|
bridgeState.remoteDelayMs = 0;
|
|
});
|
|
|
|
test("getCompletions prioritizes spec-driven path suggestions over history", async () => {
|
|
recordCommand("story open package-lock.json", "host-1");
|
|
|
|
const completions = await getCompletions("story open pa", {
|
|
hostId: "host-1",
|
|
protocol: "local",
|
|
cwd: "/repo",
|
|
});
|
|
|
|
assert.ok(completions.length > 0);
|
|
assert.equal(completions[0]?.source, "path");
|
|
assert.equal(completions[0]?.text, "story open package.json");
|
|
|
|
const historyIndex = completions.findIndex((entry) =>
|
|
entry.source === "history" && entry.text === "story open package-lock.json"
|
|
);
|
|
assert.ok(historyIndex > 0);
|
|
assert.equal(completions[historyIndex]?.historyMatch, "path-argument");
|
|
});
|
|
|
|
test("path completion marks a matching history replacement even when its full line is shorter", async () => {
|
|
const historyCommand = "story package.json";
|
|
const input = "story open --number p";
|
|
recordCommand(historyCommand, "host-1");
|
|
|
|
const completions = await getCompletions(input, {
|
|
hostId: "host-1",
|
|
protocol: "local",
|
|
cwd: "/repo",
|
|
});
|
|
const history = completions.find((entry) => entry.text === historyCommand);
|
|
|
|
assert.ok(history);
|
|
assert.ok(history.text.length < input.length);
|
|
assert.equal(history.historyMatch, "path-argument");
|
|
});
|
|
|
|
test("getCompletions does not treat generator-only spec args as path contexts", async () => {
|
|
recordCommand("story pick package-choice", "host-1");
|
|
|
|
const completions = await getCompletions("story pick pa", {
|
|
hostId: "host-1",
|
|
protocol: "local",
|
|
cwd: "/repo",
|
|
});
|
|
|
|
assert.ok(completions.length > 0);
|
|
assert.equal(completions[0]?.source, "history");
|
|
assert.equal(completions[0]?.text, "story pick package-choice");
|
|
assert.equal(completions.some((entry) => entry.source === "path"), false);
|
|
});
|
|
|
|
test("history suggestions stop when an edited argument no longer matches the command prefix", async () => {
|
|
const historyCommand = "python3.14 -m robot -d /home/wx0043/Desktop/suite9";
|
|
recordCommand(historyCommand, "host-1");
|
|
|
|
const matching = await getCompletions("python3.14 -m r", {
|
|
hostId: "host-1",
|
|
historyScope: "host",
|
|
protocol: "ssh",
|
|
sessionId: "session-1",
|
|
});
|
|
assert.equal(
|
|
matching.some((entry) => entry.source === "history" && entry.text === historyCommand),
|
|
true,
|
|
);
|
|
|
|
const changedArgument = await getCompletions("python3.14 -m p", {
|
|
hostId: "host-1",
|
|
historyScope: "host",
|
|
protocol: "ssh",
|
|
sessionId: "session-1",
|
|
});
|
|
assert.equal(
|
|
changedArgument.some((entry) => entry.source === "history" && entry.text === historyCommand),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("single-token history queries retain fuzzy command-name matching", async () => {
|
|
const historyCommand = "docker compose up";
|
|
recordCommand(historyCommand, "host-1");
|
|
|
|
const completions = await getCompletions("dcu", {
|
|
hostId: "host-1",
|
|
historyScope: "host",
|
|
protocol: "ssh",
|
|
sessionId: "session-1",
|
|
});
|
|
|
|
assert.equal(
|
|
completions.some((entry) => entry.source === "history" && entry.text === historyCommand),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("removeCommandHistoryEntry removes only the matching host's autocomplete record", async () => {
|
|
recordCommand("bad-command --flag", "host-1");
|
|
recordCommand("bad-command --flag", "host-2");
|
|
|
|
removeCommandHistoryEntry("bad-command --flag", "host-1");
|
|
|
|
const completions = await getCompletions("bad-command", {
|
|
hostId: "host-1",
|
|
historyScope: "global",
|
|
protocol: "local",
|
|
cwd: "/repo",
|
|
});
|
|
assert.equal(
|
|
completions.some((entry) => entry.source === "history" && entry.text === "bad-command --flag"),
|
|
true,
|
|
);
|
|
|
|
const hostOnlyCompletions = await getCompletions("bad-command", {
|
|
hostId: "host-1",
|
|
historyScope: "host",
|
|
protocol: "local",
|
|
cwd: "/repo",
|
|
});
|
|
assert.equal(
|
|
hostOnlyCompletions.some((entry) => entry.source === "history" && entry.text === "bad-command --flag"),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("removeCommandHistoryEntry trims command text like recordCommand", async () => {
|
|
recordCommand("padded-cmd", "host-trim");
|
|
assert.equal(removeCommandHistoryEntry(" padded-cmd ", "host-trim"), true);
|
|
|
|
const hostOnlyCompletions = await getCompletions("padded", {
|
|
hostId: "host-trim",
|
|
historyScope: "host",
|
|
protocol: "local",
|
|
cwd: "/repo",
|
|
});
|
|
assert.equal(
|
|
hostOnlyCompletions.some((entry) => entry.source === "history" && entry.text === "padded-cmd"),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("getCompletions uses the remote shell cwd for relative path arguments instead of stale home", async () => {
|
|
bridgeState.remoteEntriesByPath.set("~", [{ name: "home-only.txt", type: "file" }]);
|
|
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
|
|
|
const completions = await getCompletions("cat wo", {
|
|
hostId: "host-1",
|
|
os: "linux",
|
|
protocol: "ssh",
|
|
sessionId: "session-1",
|
|
cwd: "~",
|
|
});
|
|
|
|
assert.deepEqual(bridgeState.remoteCalls, ["."]);
|
|
assert.equal(completions[0]?.source, "path");
|
|
assert.equal(completions[0]?.text, "cat worktree.txt");
|
|
assert.equal(completions.some((entry) => entry.text.includes("~")), false);
|
|
});
|
|
|
|
test("getCompletions uses absolute prompt cwd for remote relative path arguments", async () => {
|
|
bridgeState.remoteEntriesByPath.set(".", [{ name: "old-user-file.txt", type: "file" }]);
|
|
bridgeState.remoteEntriesByPath.set("/etc", [{ name: "passwd", type: "file" }]);
|
|
|
|
const completions = await getCompletions("cat pa", {
|
|
hostId: "host-1",
|
|
os: "linux",
|
|
protocol: "ssh",
|
|
sessionId: "session-1",
|
|
cwd: "/etc",
|
|
cwdSource: "prompt",
|
|
});
|
|
|
|
assert.deepEqual(bridgeState.remoteCalls, ["/etc"]);
|
|
assert.equal(completions[0]?.source, "path");
|
|
assert.equal(completions[0]?.text, "cat passwd");
|
|
assert.equal(completions.some((entry) => entry.text === "cat old-user-file.txt"), false);
|
|
});
|
|
|
|
test("remote subdirectory lookups keep absolute prompt cwd", () => {
|
|
const preferRelativeCwd = shouldPreferRemoteShellCwd("ssh", "session-1", "linux", "/etc", "prompt");
|
|
|
|
assert.equal(preferRelativeCwd, false);
|
|
assert.equal(
|
|
normalizePathTokenForLookup("pam.d/", "/etc", { preferRelativeCwd }),
|
|
"/etc/pam.d/",
|
|
);
|
|
});
|
|
|
|
test("getCompletions keeps remote shell cwd when absolute cwd is only a fallback", async () => {
|
|
bridgeState.remoteEntriesByPath.set("/old", [{ name: "old-user-file.txt", type: "file" }]);
|
|
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
|
|
|
const completions = await getCompletions("cat wo", {
|
|
hostId: "host-1",
|
|
os: "linux",
|
|
protocol: "ssh",
|
|
sessionId: "session-1",
|
|
cwd: "/old",
|
|
cwdSource: "fallback",
|
|
});
|
|
|
|
assert.deepEqual(bridgeState.remoteCalls, ["."]);
|
|
assert.equal(completions[0]?.source, "path");
|
|
assert.equal(completions[0]?.text, "cat worktree.txt");
|
|
assert.equal(completions.some((entry) => entry.text === "cat old-user-file.txt"), false);
|
|
});
|
|
|
|
test("getCompletions does not reuse cached remote relative listings after cwd changes", async () => {
|
|
bridgeState.remoteEntriesByPath.set(".", [{ name: "home-only.txt", type: "file" }]);
|
|
|
|
await getCompletions("cat ", {
|
|
hostId: "host-1",
|
|
os: "linux",
|
|
protocol: "ssh",
|
|
sessionId: "session-1",
|
|
});
|
|
|
|
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
|
|
|
const completions = await getCompletions("cat wo", {
|
|
hostId: "host-1",
|
|
os: "linux",
|
|
protocol: "ssh",
|
|
sessionId: "session-1",
|
|
});
|
|
|
|
assert.equal(bridgeState.remoteCalls.length, 2);
|
|
assert.equal(completions[0]?.text, "cat worktree.txt");
|
|
});
|
|
|
|
test("getCompletions does not reuse in-flight remote relative listings after cwd changes", async () => {
|
|
bridgeState.remoteDelayMs = 150;
|
|
bridgeState.remoteEntriesByPath.set(".", [{ name: "home-only.txt", type: "file" }]);
|
|
|
|
const first = getCompletions("cat ", {
|
|
hostId: "host-1",
|
|
os: "linux",
|
|
protocol: "ssh",
|
|
sessionId: "session-inflight-cwd",
|
|
pathBudgetMs: Infinity,
|
|
});
|
|
|
|
// First listing must be in flight before the shell cwd listing changes.
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
|
|
|
const second = await getCompletions("cat wo", {
|
|
hostId: "host-1",
|
|
os: "linux",
|
|
protocol: "ssh",
|
|
sessionId: "session-inflight-cwd",
|
|
pathBudgetMs: Infinity,
|
|
});
|
|
await first;
|
|
|
|
assert.equal(bridgeState.remoteCalls.length, 2);
|
|
assert.equal(second[0]?.text, "cat worktree.txt");
|
|
assert.equal(second.some((entry) => entry.text === "cat home-only.txt"), false);
|
|
});
|
|
|
|
test("getCompletions returns local history before a slow remote path listing finishes", async () => {
|
|
recordCommand("cat worktree.txt", "host-1");
|
|
bridgeState.remoteDelayMs = 250;
|
|
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
|
|
|
const started = Date.now();
|
|
const completions = await getCompletions("cat wo", {
|
|
hostId: "host-1",
|
|
os: "linux",
|
|
protocol: "ssh",
|
|
sessionId: "session-slow-path",
|
|
cwd: "~",
|
|
pathBudgetMs: 40,
|
|
});
|
|
const elapsed = Date.now() - started;
|
|
|
|
assert.ok(elapsed < 200, `expected local suggestions within budget, took ${elapsed}ms`);
|
|
assert.ok(
|
|
completions.some((entry) => entry.source === "history" && entry.text === "cat worktree.txt"),
|
|
);
|
|
assert.equal(completions.some((entry) => entry.source === "path"), false);
|
|
});
|
|
|
|
test("getCompletions surfaces late path suggestions for cache-bypassed relative SSH cwd", async () => {
|
|
recordCommand("cat worktree.txt", "host-1");
|
|
bridgeState.remoteDelayMs = 250;
|
|
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
|
|
|
let latePathSuggestions: Awaited<ReturnType<typeof getCompletions>> | null = null;
|
|
const latePathPromise = new Promise<void>((resolve) => {
|
|
void getCompletions("cat wo", {
|
|
hostId: "host-1",
|
|
os: "linux",
|
|
protocol: "ssh",
|
|
sessionId: "session-late-path",
|
|
cwd: "/stale-fallback",
|
|
cwdSource: "fallback",
|
|
pathBudgetMs: 40,
|
|
onLatePathSuggestions: (suggestions) => {
|
|
latePathSuggestions = suggestions;
|
|
resolve();
|
|
},
|
|
}).then((completions) => {
|
|
assert.equal(completions.some((entry) => entry.source === "path"), false);
|
|
assert.ok(
|
|
completions.some((entry) => entry.source === "history" && entry.text === "cat worktree.txt"),
|
|
);
|
|
});
|
|
});
|
|
|
|
await latePathPromise;
|
|
assert.ok(latePathSuggestions);
|
|
assert.equal(latePathSuggestions![0]?.source, "path");
|
|
assert.equal(latePathSuggestions![0]?.text, "cat worktree.txt");
|
|
});
|
|
|
|
test("getPathSuggestionsWithinBudget ignores late rejections after the soft timeout", async () => {
|
|
const unhandled: unknown[] = [];
|
|
const onUnhandled = (reason: unknown) => {
|
|
unhandled.push(reason);
|
|
};
|
|
process.on("unhandledRejection", onUnhandled);
|
|
try {
|
|
const pathPromise = new Promise<{ name: string; type: "file" }[]>((_resolve, reject) => {
|
|
setTimeout(() => reject(new Error("late path failure")), 80);
|
|
});
|
|
const entries = await getPathSuggestionsWithinBudget(pathPromise, 20);
|
|
assert.deepEqual(entries, []);
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
assert.equal(unhandled.length, 0);
|
|
} finally {
|
|
process.off("unhandledRejection", onUnhandled);
|
|
}
|
|
});
|
|
|
|
test("getPathSuggestionsWithinBudget delivers late entries after the soft timeout", async () => {
|
|
const pathPromise = new Promise<{ name: string; type: "file" }[]>((resolve) => {
|
|
setTimeout(() => resolve([{ name: "late.txt", type: "file" }]), 80);
|
|
});
|
|
let lateEntries: { name: string; type: "file" }[] | null = null;
|
|
const entries = await getPathSuggestionsWithinBudget(pathPromise, 20, (late) => {
|
|
lateEntries = late as { name: string; type: "file" }[];
|
|
});
|
|
assert.deepEqual(entries, []);
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
assert.deepEqual(lateEntries, [{ name: "late.txt", type: "file" }]);
|
|
});
|
|
|
|
test("getCompletions includes other hosts' history when historyScope is global", async () => {
|
|
recordCommand("systemctl restart nginx", "host-a");
|
|
recordCommand("systemctl status nginx", "host-b");
|
|
|
|
const hostScoped = await getCompletions("systemctl", {
|
|
hostId: "host-a",
|
|
historyScope: "host",
|
|
});
|
|
assert.ok(
|
|
hostScoped.some((entry) => entry.source === "history" && entry.text === "systemctl restart nginx"),
|
|
);
|
|
assert.equal(
|
|
hostScoped.some((entry) => entry.source === "history" && entry.text === "systemctl status nginx"),
|
|
false,
|
|
);
|
|
|
|
const globalScoped = await getCompletions("systemctl", {
|
|
hostId: "host-a",
|
|
historyScope: "global",
|
|
});
|
|
assert.ok(
|
|
globalScoped.some((entry) => entry.source === "history" && entry.text === "systemctl restart nginx"),
|
|
);
|
|
assert.ok(
|
|
globalScoped.some((entry) => entry.source === "history" && entry.text === "systemctl status nginx"),
|
|
);
|
|
});
|