178 lines
6.5 KiB
TypeScript
178 lines
6.5 KiB
TypeScript
|
|
import assert from "node:assert/strict";
|
||
|
|
import test from "node:test";
|
||
|
|
|
||
|
|
type LocalStorageMock = {
|
||
|
|
clear(): void;
|
||
|
|
getItem(key: string): string | null;
|
||
|
|
setItem(key: string, value: string): void;
|
||
|
|
removeItem(key: string): void;
|
||
|
|
};
|
||
|
|
|
||
|
|
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 files = new Map<string, string>();
|
||
|
|
let bridgeEnabled = true;
|
||
|
|
const bridge = {
|
||
|
|
getHomeDir: async () => (bridgeEnabled ? "/Users/demo" : Promise.reject(new Error("no bridge"))),
|
||
|
|
readLocalFile: async (path: string, options?: { maxBytes?: number }) => {
|
||
|
|
if (!bridgeEnabled) throw new Error("no bridge");
|
||
|
|
const text = files.get(path);
|
||
|
|
if (text === undefined) throw new Error(`ENOENT: ${path}`);
|
||
|
|
let bytes = new TextEncoder().encode(text);
|
||
|
|
if (options?.maxBytes && bytes.byteLength > options.maxBytes) {
|
||
|
|
bytes = bytes.subarray(bytes.byteLength - options.maxBytes);
|
||
|
|
}
|
||
|
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
Object.defineProperty(globalThis, "window", {
|
||
|
|
value: { electron: bridge, netcatty: bridge },
|
||
|
|
configurable: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
const { clearHistory, queryHistory } = await import("./commandHistoryStore.ts");
|
||
|
|
const { seedLocalShellHistoryFromHistfiles } = await import("./localShellHistorySeed.ts");
|
||
|
|
const { getCompletions } = await import("./completionEngine.ts");
|
||
|
|
|
||
|
|
test.beforeEach(() => {
|
||
|
|
localStorage.clear();
|
||
|
|
clearHistory();
|
||
|
|
files.clear();
|
||
|
|
bridgeEnabled = true;
|
||
|
|
(window as Window & { netcatty?: unknown }).netcatty = bridge;
|
||
|
|
});
|
||
|
|
|
||
|
|
test("seedLocalShellHistoryFromHistfiles imports zsh history for autocomplete prefix match", async () => {
|
||
|
|
const hostId = "local-terminal";
|
||
|
|
files.set(
|
||
|
|
"/Users/demo/.zsh_history",
|
||
|
|
": 1700000000:0;sudo xattr -rd com.apple.quarantine /Applications/ClashX\\ Meta.app\n",
|
||
|
|
);
|
||
|
|
|
||
|
|
const seeded = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||
|
|
assert.ok(seeded > 0);
|
||
|
|
|
||
|
|
const matches = queryHistory("sudo xattr", { hostId, limit: 5 });
|
||
|
|
assert.equal(matches.length, 1);
|
||
|
|
assert.match(matches[0].command, /ClashX/);
|
||
|
|
|
||
|
|
const completions = await getCompletions("sudo xattr", {
|
||
|
|
hostId,
|
||
|
|
os: "macos",
|
||
|
|
protocol: "local",
|
||
|
|
});
|
||
|
|
assert.ok(
|
||
|
|
completions.some((c) => c.source === "history" && c.text.includes("ClashX")),
|
||
|
|
`expected history completion, got ${JSON.stringify(completions.map((c) => ({ s: c.source, t: c.text })))}`,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("seedLocalShellHistoryFromHistfiles is idempotent for the same host after a successful import", async () => {
|
||
|
|
const hostId = "local-terminal";
|
||
|
|
files.set("/Users/demo/.zsh_history", ": 1700000000:0;pwd\n: 1700000001:0;ls\n");
|
||
|
|
|
||
|
|
const first = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||
|
|
const second = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||
|
|
assert.equal(first, 2);
|
||
|
|
assert.equal(second, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("seedLocalShellHistoryFromHistfiles retries when histfiles were empty", async () => {
|
||
|
|
const hostId = "local-terminal";
|
||
|
|
|
||
|
|
const first = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||
|
|
assert.equal(first, 0);
|
||
|
|
|
||
|
|
files.set("/Users/demo/.zsh_history", ": 1700000000:0;echo later\n");
|
||
|
|
const second = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||
|
|
assert.equal(second, 1);
|
||
|
|
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("seedLocalShellHistoryFromHistfiles no-ops without a bridge and stays retryable", async () => {
|
||
|
|
const hostId = "local-terminal";
|
||
|
|
(window as Window & { netcatty?: unknown }).netcatty = undefined;
|
||
|
|
|
||
|
|
const first = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||
|
|
assert.equal(first, 0);
|
||
|
|
|
||
|
|
(window as Window & { netcatty?: unknown }).netcatty = bridge;
|
||
|
|
files.set("/Users/demo/.zsh_history", ": 1700000000:0;pwd\n");
|
||
|
|
const second = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||
|
|
assert.equal(second, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("seedLocalShellHistoryFromHistfiles dedupes concurrent calls for the same host", async () => {
|
||
|
|
const hostId = "local-terminal";
|
||
|
|
files.set("/Users/demo/.zsh_history", ": 1700000000:0;pwd\n");
|
||
|
|
|
||
|
|
// Start the first seed without awaiting so the second call overlaps in-flight.
|
||
|
|
const firstPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||
|
|
const secondPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||
|
|
const [a, b] = await Promise.all([firstPromise, secondPromise]);
|
||
|
|
assert.equal(a, 1);
|
||
|
|
assert.equal(b, 1);
|
||
|
|
assert.equal(queryHistory("pw", { hostId, limit: 5 }).length, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("seedLocalShellHistoryFromHistfiles drops a partial first line from a full-budget histfile tail", async () => {
|
||
|
|
const hostId = "local-terminal";
|
||
|
|
// Simulate a main-process maxBytes tail: exactly 512KiB ending mid-command,
|
||
|
|
// then a complete command on the next line.
|
||
|
|
const maxBytes = 512 * 1024;
|
||
|
|
const complete = ": 1700000001:0;echo complete\n";
|
||
|
|
const partialPrefix = "PARTIAL_TRUNCATED_COMMAND_WITHOUT_NEWLINE";
|
||
|
|
const overhead = Buffer.byteLength(`${partialPrefix}\n\n${complete}`, "utf8");
|
||
|
|
const filler = "x".repeat(maxBytes - overhead);
|
||
|
|
const tail = `${partialPrefix}\n${filler}\n${complete}`;
|
||
|
|
assert.equal(Buffer.byteLength(tail, "utf8"), maxBytes);
|
||
|
|
|
||
|
|
files.set("/Users/demo/.zsh_history", tail);
|
||
|
|
const seeded = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||
|
|
assert.ok(seeded >= 1);
|
||
|
|
assert.equal(queryHistory("echo", { hostId, limit: 5 })[0]?.command, "echo complete");
|
||
|
|
assert.equal(queryHistory("PARTIAL", { hostId, limit: 5 }).length, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("seedLocalShellHistoryFromHistfiles joins Windows home paths for fish history", async () => {
|
||
|
|
const hostId = "local-terminal";
|
||
|
|
const previousHome = bridge.getHomeDir;
|
||
|
|
bridge.getHomeDir = async () => "C:\\Users\\demo";
|
||
|
|
try {
|
||
|
|
files.set(
|
||
|
|
"C:\\Users\\demo\\.config\\fish\\fish_history",
|
||
|
|
"- cmd: echo fish\n when: 1700000000\n",
|
||
|
|
);
|
||
|
|
|
||
|
|
const seeded = await seedLocalShellHistoryFromHistfiles(hostId, "windows");
|
||
|
|
assert.equal(seeded, 1);
|
||
|
|
assert.equal(queryHistory("echo", { hostId, limit: 5 })[0]?.command, "echo fish");
|
||
|
|
} finally {
|
||
|
|
bridge.getHomeDir = previousHome;
|
||
|
|
}
|
||
|
|
});
|