Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
214 lines
7.4 KiB
TypeScript
214 lines
7.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
classifyDockerFailure,
|
|
composeSupportsWaitTimeout,
|
|
DOCKER_INSTALL_LINKS,
|
|
dockerBinaryCandidates,
|
|
dockerSpawnEnv,
|
|
isDesktopSetupLink,
|
|
resolveDockerBinary,
|
|
runDocker,
|
|
} from "./docker-cli.js";
|
|
|
|
describe("docker install links", () => {
|
|
it("only opens the three known pages over https", () => {
|
|
for (const url of Object.values(DOCKER_INSTALL_LINKS)) expect(url).toMatch(/^https:\/\//);
|
|
expect(isDesktopSetupLink("orbstack")).toBe(true);
|
|
expect(isDesktopSetupLink("toString")).toBe(false);
|
|
expect(isDesktopSetupLink("https://evil.example")).toBe(false);
|
|
expect(isDesktopSetupLink(42)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("dockerBinaryCandidates", () => {
|
|
it("checks the macOS install locations before PATH", () => {
|
|
const candidates = dockerBinaryCandidates("darwin", {
|
|
HOME: "/Users/me",
|
|
PATH: "/usr/bin:/usr/local/bin",
|
|
});
|
|
expect(candidates).toEqual([
|
|
"/usr/local/bin/docker",
|
|
"/opt/homebrew/bin/docker",
|
|
"/Applications/Docker.app/Contents/Resources/bin/docker",
|
|
"/Users/me/.orbstack/bin/docker",
|
|
"/usr/bin/docker",
|
|
]);
|
|
});
|
|
|
|
it("uses Program Files and PATH entries on Windows", () => {
|
|
const candidates = dockerBinaryCandidates("win32", {
|
|
ProgramFiles: "C:\\Program Files",
|
|
PATH: "C:\\Windows\\system32;C:\\tools",
|
|
});
|
|
expect(candidates).toEqual([
|
|
"C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe",
|
|
"C:\\Windows\\system32\\docker.exe",
|
|
"C:\\tools\\docker.exe",
|
|
]);
|
|
});
|
|
|
|
it("falls back to the usual Linux paths and skips empty PATH entries", () => {
|
|
expect(dockerBinaryCandidates("linux", { PATH: "/snap/bin::/usr/bin" })).toEqual([
|
|
"/usr/bin/docker",
|
|
"/usr/local/bin/docker",
|
|
"/snap/bin/docker",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("resolveDockerBinary", () => {
|
|
it("returns the first existing candidate", () => {
|
|
const binary = resolveDockerBinary("linux", { PATH: "/opt/bin" }, (file) =>
|
|
file.startsWith("/opt/"),
|
|
);
|
|
expect(binary).toBe("/opt/bin/docker");
|
|
});
|
|
|
|
it("returns null when nothing exists", () => {
|
|
expect(resolveDockerBinary("darwin", { HOME: "/Users/me" }, () => false)).toBeNull();
|
|
});
|
|
|
|
it("treats RAKAZO_DOCKER_BINARY as the only candidate", () => {
|
|
const env = { RAKAZO_DOCKER_BINARY: "/fake/docker", PATH: "/usr/bin" };
|
|
expect(resolveDockerBinary("linux", env, (file) => file === "/fake/docker")).toBe(
|
|
"/fake/docker",
|
|
);
|
|
// A real docker on PATH must not be used when the override points nowhere.
|
|
expect(resolveDockerBinary("linux", env, (file) => file === "/usr/bin/docker")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("dockerSpawnEnv", () => {
|
|
it("passes only what docker needs and prepends the docker directory to PATH", () => {
|
|
const env = dockerSpawnEnv(
|
|
"darwin",
|
|
{
|
|
HOME: "/Users/me",
|
|
PATH: "/usr/bin:/bin",
|
|
OPENROUTER_API_KEY: "sk-secret",
|
|
POSTGRES_PASSWORD: "leak",
|
|
DOCKER_HOST: "unix:///Users/me/.orbstack/run/docker.sock",
|
|
LANG: "en_US.UTF-8",
|
|
},
|
|
"/Users/me/.orbstack/bin/docker",
|
|
{ RAKAZO_IMAGE_TAG: "v1.2.3" },
|
|
);
|
|
expect(env).toEqual({
|
|
HOME: "/Users/me",
|
|
PATH: "/Users/me/.orbstack/bin:/usr/bin:/bin",
|
|
DOCKER_HOST: "unix:///Users/me/.orbstack/run/docker.sock",
|
|
LANG: "en_US.UTF-8",
|
|
RAKAZO_IMAGE_TAG: "v1.2.3",
|
|
});
|
|
expect(env).not.toHaveProperty("OPENROUTER_API_KEY");
|
|
expect(env).not.toHaveProperty("POSTGRES_PASSWORD");
|
|
});
|
|
|
|
it("keeps the Windows system variables docker.exe depends on", () => {
|
|
const env = dockerSpawnEnv(
|
|
"win32",
|
|
{ SystemRoot: "C:\\Windows", APPDATA: "C:\\Users\\me\\AppData\\Roaming", PATH: "" },
|
|
"C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe",
|
|
);
|
|
expect(env).toEqual({
|
|
SystemRoot: "C:\\Windows",
|
|
APPDATA: "C:\\Users\\me\\AppData\\Roaming",
|
|
PATH: "C:\\Program Files\\Docker\\Docker\\resources\\bin",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("classifyDockerFailure", () => {
|
|
it.each([
|
|
["Cannot connect to the Docker daemon at unix:///var/run/docker.sock", "daemon-down"],
|
|
[
|
|
"error during connect: Head http://%2F%2F.%2Fpipe: open //./pipe: file not found",
|
|
"daemon-down",
|
|
],
|
|
["permission denied while trying to connect to the Docker daemon socket", "socket-permission"],
|
|
["docker: 'compose' is not a docker command.", "compose-missing"],
|
|
["Error response from daemon: manifest unknown", "image-not-found"],
|
|
["pull access denied for ghcr.io/x/y, repository does not exist", "image-not-found"],
|
|
["Get https://ghcr.io/v2/: dial tcp: lookup ghcr.io: no such host", "network"],
|
|
["net/http: TLS handshake timeout", "network"],
|
|
["Bind for 127.0.0.1:5173 failed: port is already allocated", "port-in-use"],
|
|
["listen tcp 127.0.0.1:3100: bind: address already in use", "port-in-use"],
|
|
[
|
|
"Error response from daemon: all predefined address pools have been fully subnetted",
|
|
"address-pool-exhausted",
|
|
],
|
|
[
|
|
"could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network",
|
|
"address-pool-exhausted",
|
|
],
|
|
["something else entirely", "other"],
|
|
])("classifies %j as %s", (output, kind) => {
|
|
expect(classifyDockerFailure(output)).toBe(kind);
|
|
});
|
|
});
|
|
|
|
describe("composeSupportsWaitTimeout", () => {
|
|
it.each([
|
|
["2.17.0", true],
|
|
["v2.29.7", true],
|
|
["3.0.0", true],
|
|
["2.16.9", false],
|
|
["1.29.2", false],
|
|
["", false],
|
|
])("%s → %s", (version, supported) => {
|
|
expect(composeSupportsWaitTimeout(version)).toBe(supported);
|
|
});
|
|
});
|
|
|
|
describe("runDocker", () => {
|
|
const node = process.execPath;
|
|
const options = { cwd: process.cwd(), env: { PATH: process.env.PATH ?? "" }, timeoutMs: 10_000 };
|
|
|
|
it("captures stdout, stderr, the exit code, and complete lines in order", async () => {
|
|
const lines: string[] = [];
|
|
const result = await runDocker(
|
|
node,
|
|
["-e", 'process.stdout.write("a\\nb"); process.stderr.write("err\\n"); process.exit(3)'],
|
|
{ ...options, onLine: (line) => lines.push(line) },
|
|
);
|
|
expect(result).toEqual({ code: 3, stdout: "a\nb", stderr: "err\n" });
|
|
expect(lines.sort()).toEqual(["a", "b", "err"]);
|
|
});
|
|
|
|
it("keeps the last rendering of carriage-return progress lines", async () => {
|
|
const lines: string[] = [];
|
|
await runDocker(node, ["-e", 'process.stdout.write("10%\\r50%\\r100%\\n")'], {
|
|
...options,
|
|
onLine: (line) => lines.push(line),
|
|
});
|
|
expect(lines).toEqual(["100%"]);
|
|
});
|
|
|
|
it("kills a command that exceeds its timeout", async () => {
|
|
const started = Date.now();
|
|
const result = await runDocker(node, ["-e", "setInterval(() => {}, 1000)"], {
|
|
...options,
|
|
timeoutMs: 300,
|
|
});
|
|
expect(result.code).toBe(124);
|
|
expect(Date.now() - started).toBeLessThan(5_000);
|
|
});
|
|
|
|
it("honours an abort signal", async () => {
|
|
const controller = new AbortController();
|
|
const pending = runDocker(node, ["-e", "setInterval(() => {}, 1000)"], {
|
|
...options,
|
|
signal: controller.signal,
|
|
});
|
|
controller.abort();
|
|
const result = await pending;
|
|
expect(result.code).toBe(130);
|
|
});
|
|
|
|
it("reports a missing binary as a failure instead of throwing", async () => {
|
|
const result = await runDocker("/nonexistent/docker", ["info"], options);
|
|
expect(result.code).toBe(1);
|
|
expect(result.stderr).toContain("ENOENT");
|
|
});
|
|
});
|