|
| 1 | +import assert from "node:assert/strict"; |
| 2 | +import { spawnSync } from "node:child_process"; |
| 3 | +import { readFileSync } from "node:fs"; |
| 4 | +import test from "node:test"; |
| 5 | +import { fileURLToPath } from "node:url"; |
| 6 | + |
| 7 | +import { completionModel, completionScript } from "../src/completion.mjs"; |
| 8 | +import { ENGINE_ALIASES, ENGINES } from "../src/engines.mjs"; |
| 9 | +import { TOOLS } from "../src/tools.mjs"; |
| 10 | + |
| 11 | +const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url)); |
| 12 | + |
| 13 | +function names(entries) { |
| 14 | + return entries.map(({ name }) => name); |
| 15 | +} |
| 16 | + |
| 17 | +function bashQuote(value) { |
| 18 | + return `'${String(value).replaceAll("'", "'\\''")}'`; |
| 19 | +} |
| 20 | + |
| 21 | +// Source the real generated script in a real bash and call the real completion |
| 22 | +// function, rather than asserting against the script text. A script can mention |
| 23 | +// a word and still never offer it. |
| 24 | +function bashCompletions(tokens) { |
| 25 | + const script = `${completionScript("bash")} |
| 26 | +COMP_WORDS=(${tokens.map(bashQuote).join(" ")}) |
| 27 | +COMP_CWORD=${tokens.length - 1} |
| 28 | +_moshcode_completion |
| 29 | +printf '%s\\n' "\${COMPREPLY[@]}" |
| 30 | +`; |
| 31 | + const result = spawnSync("bash", ["-c", script], { encoding: "utf8" }); |
| 32 | + assert.equal(result.status, 0, result.stderr); |
| 33 | + return result.stdout.split("\n").filter(Boolean); |
| 34 | +} |
| 35 | + |
| 36 | +// The roster `uninstall` actually resolves its target against: bin/moshcode.mjs |
| 37 | +// checks `Object.hasOwn(ENGINES, target) || Object.hasOwn(TOOLS, target)`. |
| 38 | +const REMOVABLE = [...new Set([...Object.keys(ENGINES), ...Object.keys(TOOLS)])]; |
| 39 | + |
| 40 | +// --- the bug: `uninstall` shipped with no completion at all ----------------- |
| 41 | + |
| 42 | +test("uninstall and its remove alias are offered as top-level commands", () => { |
| 43 | + const top = new Set(names(completionModel().top)); |
| 44 | + assert.ok(top.has("uninstall"), "uninstall missing from top-level completion"); |
| 45 | + assert.ok(top.has("remove"), "remove missing from top-level completion"); |
| 46 | +}); |
| 47 | + |
| 48 | +test("typing `moshcode un` completes to uninstall in bash", () => { |
| 49 | + assert.deepEqual(bashCompletions(["moshcode", "un"]), ["uninstall"]); |
| 50 | +}); |
| 51 | + |
| 52 | +test("`moshcode uninstall <TAB>` offers every engine and tool it can remove", () => { |
| 53 | + const offered = new Set(bashCompletions(["moshcode", "uninstall", ""])); |
| 54 | + for (const name of REMOVABLE) { |
| 55 | + assert.ok(offered.has(name), `${name} can be uninstalled but is not offered`); |
| 56 | + } |
| 57 | +}); |
| 58 | + |
| 59 | +test("the remove alias completes its targets too", () => { |
| 60 | + assert.deepEqual( |
| 61 | + new Set(bashCompletions(["moshcode", "remove", ""])), |
| 62 | + new Set(bashCompletions(["moshcode", "uninstall", ""])), |
| 63 | + ); |
| 64 | +}); |
| 65 | + |
| 66 | +test("uninstall flags complete after a target", () => { |
| 67 | + const offered = new Set(bashCompletions(["moshcode", "uninstall", "claude", "--"])); |
| 68 | + assert.ok(offered.has("--yes"), "--yes is required to delete a binary but is not offered"); |
| 69 | + assert.ok(offered.has("--dry-run")); |
| 70 | +}); |
| 71 | + |
| 72 | +test("zsh and fish completions cover uninstall as well as bash", () => { |
| 73 | + for (const shell of ["zsh", "fish"]) { |
| 74 | + const script = completionScript(shell); |
| 75 | + assert.match(script, /\buninstall\b/, `${shell} completion never mentions uninstall`); |
| 76 | + assert.match(script, /\bremove\b/, `${shell} completion never mentions remove`); |
| 77 | + } |
| 78 | +}); |
| 79 | + |
| 80 | +test("every command bin/moshcode.mjs dispatches on is completable", () => { |
| 81 | + // The same guarantee completion.test.mjs asserts, kept here so a new command |
| 82 | + // added without a completion entry fails next to the uninstall regression. |
| 83 | + const source = readFileSync(BIN, "utf8"); |
| 84 | + const dispatched = [...source.matchAll(/cmd === "([^"]+)"/g)].map((m) => m[1]); |
| 85 | + const top = new Set(names(completionModel().top)); |
| 86 | + assert.ok(dispatched.includes("uninstall"), "guard is stale: uninstall is no longer dispatched"); |
| 87 | + for (const command of dispatched) { |
| 88 | + assert.ok(top.has(command), `${command} is dispatched but missing from completion`); |
| 89 | + } |
| 90 | +}); |
| 91 | + |
| 92 | +// --- controls: these pass before and after, in the opposite direction ------- |
| 93 | +// They stop the fix buying a passing suite by over-offering or by disturbing |
| 94 | +// the completions that already worked. |
| 95 | + |
| 96 | +test("uninstall offers exactly the removable roster and nothing more", () => { |
| 97 | + assert.deepEqual(new Set(names(completionModel().uninstall)), new Set(REMOVABLE)); |
| 98 | +}); |
| 99 | + |
| 100 | +test("uninstall does not offer engine aliases, which its dispatch cannot resolve", () => { |
| 101 | + // `uninstall` looks the target up with Object.hasOwn(ENGINES, target), so an |
| 102 | + // alias would be offered and then rejected. install behaves the same way. |
| 103 | + const offered = new Set(names(completionModel().uninstall)); |
| 104 | + for (const alias of Object.keys(ENGINE_ALIASES)) { |
| 105 | + assert.ok(!offered.has(alias), `${alias} is an alias and would not resolve`); |
| 106 | + } |
| 107 | +}); |
| 108 | + |
| 109 | +test("install completion is unchanged by the uninstall wiring", () => { |
| 110 | + assert.deepEqual(new Set(names(completionModel().install)), new Set(REMOVABLE)); |
| 111 | + assert.deepEqual(new Set(bashCompletions(["moshcode", "install", ""])), new Set(REMOVABLE)); |
| 112 | +}); |
| 113 | + |
| 114 | +test("an unrelated command still completes nothing", () => { |
| 115 | + assert.deepEqual(bashCompletions(["moshcode", "whoami", ""]), []); |
| 116 | +}); |
| 117 | + |
| 118 | +test("uninstall completes targets only in the target position", () => { |
| 119 | + // COMP_CWORD 3 without a flag prefix must not re-offer the roster. |
| 120 | + const offered = bashCompletions(["moshcode", "uninstall", "claude", ""]); |
| 121 | + assert.deepEqual(offered, []); |
| 122 | +}); |
| 123 | + |
| 124 | +test("the generated scripts still parse in their own shell where available", () => { |
| 125 | + const bash = spawnSync("bash", ["-n", "-c", completionScript("bash")], { encoding: "utf8" }); |
| 126 | + assert.equal(bash.status, 0, bash.stderr); |
| 127 | +}); |
0 commit comments