import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { describe, expect, it } from "vitest"; import { describeShellStatus, formatShellContext, parseShellInvocation, runShell, truncateMiddle, type ShellResult, } from "./shell"; describe("parseShellInvocation", () => { const cmd = (input: string) => parseShellInvocation(input); it("sans guillemets : tout ce qui suit le ! est la commande", () => { expect(cmd("!ls")).toEqual({ command: "ls" }); expect(cmd("!ls -la")).toEqual({ command: "ls -la" }); expect(cmd(" ! git status ")).toEqual({ command: "git status" }); }); it("avec guillemets simples : le texte entre guillemets est la commande", () => { expect(cmd("!'ls -la'")).toEqual({ command: "ls -la" }); expect(cmd("! 'ls -la'")).toEqual({ command: "ls -la" }); expect(cmd("!'git log --oneline -5'")).toEqual({ command: "git log --oneline -5" }); }); it("ce qui suit le guillemet fermant est ajouté comme paramètres", () => { expect(cmd("!'ls' -la src")).toEqual({ command: "ls -la src" }); expect(cmd("!'ls -la' | wc -l")).toEqual({ command: "ls -la | wc -l" }); expect(cmd("!'ls'-la")).toEqual({ command: "ls -la" }); }); it("\\' donne un guillemet littéral dans la commande", () => { expect(cmd("!'echo \"l\\'ami\"'")).toEqual({ command: 'echo "l\'ami"' }); }); it("! seul ou guillemets vides : commande vide (aide)", () => { expect(cmd("!")).toEqual({ command: "" }); expect(cmd("!''")).toEqual({ command: "" }); }); it("guillemet simple non fermé : erreur, rien n'est exécuté", () => { const r = cmd("!'ls -la"); expect(r).toHaveProperty("error"); expect(r).not.toHaveProperty("command"); }); it("ignore les autres saisies", () => { expect(cmd("bonjour !")).toBeNull(); expect(cmd("/help")).toBeNull(); expect(cmd("§skill")).toBeNull(); }); }); describe("runShell", () => { const cwd = process.cwd(); it("capture stdout et stderr, code 0", async () => { const r = await runShell("echo out && echo err 1>&2", { cwd }); expect(r.exitCode).toBe(0); expect(r.output).toContain("out"); expect(r.output).toContain("err"); expect(describeShellStatus(r)).toBeNull(); }); it("code de sortie non nul", async () => { const r = await runShell("exit 3", { cwd }); expect(r.exitCode).toBe(3); expect(describeShellStatus(r)).toBe("code de sortie : 3"); }); it("s'exécute dans le dossier demandé", async () => { const dir = await mkdtemp(join(tmpdir(), "maia-shell-")); const r = await runShell('node -p "process.cwd()"', { cwd: dir }); expect(r.output).toContain(basename(dir)); }); it("transmet les variables d'environnement additionnelles", async () => { const r = await runShell('node -p "process.env.MA_VAR"', { cwd, env: { MA_VAR: "42" } }); expect(r.output).toBe("42"); }); it("interruption par AbortSignal", async () => { const controller = new AbortController(); const started = Date.now(); const promise = runShell('node -e "setTimeout(() => {}, 10000)"', { cwd, signal: controller.signal }); setTimeout(() => controller.abort(), 150); const r = await promise; expect(r.aborted).toBe(true); expect(describeShellStatus(r)).toBe("interrompu (Ctrl+C)"); expect(Date.now() - started).toBeLessThan(5000); }); it("délai dépassé", async () => { const r = await runShell('node -e "setTimeout(() => {}, 10000)"', { cwd, timeoutMs: 200 }); expect(r.timedOut).toBe(true); expect(describeShellStatus(r)).toBe("délai dépassé (0 s)"); }); it("dossier introuvable : pas d'exception, résultat en échec", async () => { const r = await runShell("echo x", { cwd: join(tmpdir(), "maia-n-existe-pas-xyz") }); expect(describeShellStatus(r)).toBe("échec du lancement de la commande"); expect(r.output).not.toBe(""); }); }); describe("formatShellContext", () => { const ok: ShellResult = { output: "a\nb", exitCode: 0, timedOut: false, aborted: false, spawnError: false, timeoutMs: 1000 }; it("contient la commande et la sortie ; pas de statut si succès", () => { const text = formatShellContext("ls", ok); expect(text).toContain("$ ls\na\nb"); expect(text).not.toContain("[code de sortie"); }); it("sortie vide et échec", () => { const text = formatShellContext("false", { ...ok, output: "", exitCode: 1 }); expect(text).toContain("(aucune sortie)"); expect(text).toContain("[code de sortie : 1]"); }); it("tronque le milieu des très longues sorties", () => { const text = formatShellContext("cat gros", { ...ok, output: "x".repeat(100_000) }); expect(text).toContain("caractères omis"); expect(text.length).toBeLessThan(31_000); expect(truncateMiddle("abc", 10)).toBe("abc"); }); });