MagicCode/packages/core/src/shell.test.ts

131 lines
4.8 KiB
TypeScript
Raw Normal View History

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(), "magiccode-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(), "magiccode-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");
});
});