import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { RunDocker, RunDockerResult } from "./docker-cli.js"; import { COMPOSE_WAIT_TIMEOUT_S, ensureStackEnv, ensureStackToken, initialStackState, LocalStackController, type LocalStackDeps, readStackToken, reduceStackState, renderStackEnv, resolveImageTag, STACK_COMPOSE_FILE, STACK_ENV_FILE, STACK_ENV_TEMPLATE, STACK_OUTPUT_LINES, STACK_PROJECT_NAME, STACK_TOKEN_FILE, stackDir, stackFailureMessage, stackResourceDir, } from "./local-stack.js"; const COMPOSE_DIR = path.resolve(import.meta.dirname, "..", "..", "..", "infra", "compose"); const fakeHex = (bytes: number) => "ab".repeat(bytes); describe("stack locations", () => { it("keeps the compose project under user data", () => { expect(stackDir("/data/rakazo")).toBe(path.join("/data/rakazo", "stack")); }); it("reads resources from the bundle when packaged and from the repo otherwise", () => { expect( stackResourceDir({ packaged: true, resourcesPath: "/App/Resources", appPath: "/x" }), ).toBe(path.join("/App/Resources", "stack")); expect( stackResourceDir({ packaged: false, resourcesPath: "/x", appPath: "/repo/apps/desktop" }), ).toBe(path.resolve("/repo/infra/compose")); }); }); describe("resolveImageTag", () => { it("pins installed builds to their own version", () => { expect(resolveImageTag({ version: "0.2.0", packaged: true })).toBe("v0.2.0"); }); it("follows edge for development builds and prereleases", () => { expect(resolveImageTag({ version: "0.2.0", packaged: false })).toBe("edge"); expect(resolveImageTag({ version: "0.2.0-beta.1", packaged: true })).toBe("edge"); }); it("lets RAKAZO_IMAGE_TAG override everything", () => { expect(resolveImageTag({ version: "0.2.0", packaged: true, override: " v9.9.9 " })).toBe( "v9.9.9", ); expect(resolveImageTag({ version: "0.2.0", packaged: true, override: "" })).toBe("v0.2.0"); }); }); describe("renderStackEnv", () => { it("fills the five secrets and drops the image tag lines of the shipped template", async () => { const template = await readFile(path.join(COMPOSE_DIR, STACK_ENV_TEMPLATE), "utf8"); const rendered = renderStackEnv(template, fakeHex); const lines = rendered.split("\n"); expect(lines).toContain(`POSTGRES_PASSWORD=${"ab".repeat(16)}`); for (const name of [ "BETTER_AUTH_SECRET", "ENCRYPTION_KEY", "SCREEN_PROXY_SECRET", "SANDBOX_SUPERVISOR_TOKEN", ]) { expect(lines).toContain(`${name}=${"ab".repeat(32)}`); } expect(lines.some((line) => line.startsWith("RAKAZO_IMAGE_TAG="))).toBe(false); expect(lines.some((line) => line.startsWith("RAKAZO_COMPUTER_IMAGE_TAG="))).toBe(false); // Everything else, including the image names and empty optional keys, stays verbatim. expect(lines).toContain("RAKAZO_IMAGE=ghcr.io/elie222/rakazo/app"); expect(lines).toContain("SANDBOX_PROVIDER=docker"); expect(lines).toContain("OPENROUTER_API_KEY="); expect(rendered.endsWith("\n")).toBe(template.endsWith("\n")); }); it("leaves a secret that already has a value alone", () => { expect(renderStackEnv("POSTGRES_PASSWORD=keep\n# note=\n", fakeHex)).toBe( "POSTGRES_PASSWORD=keep\n# note=\n", ); }); }); describe("ensureStackEnv", () => { let dir: string; beforeEach(async () => { dir = await mkdtemp(path.join(tmpdir(), "rakazo-stack-env-")); }); afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); it("creates a private .env from the template", async () => { await expect(ensureStackEnv(dir, "POSTGRES_PASSWORD=\n", fakeHex)).resolves.toBe("created"); const file = path.join(dir, STACK_ENV_FILE); await expect(readFile(file, "utf8")).resolves.toBe(`POSTGRES_PASSWORD=${"ab".repeat(16)}\n`); if (process.platform !== "win32") expect((await stat(file)).mode & 0o777).toBe(0o600); }); it("never touches an existing .env", async () => { await writeFile(path.join(dir, STACK_ENV_FILE), "sentinel\n", "utf8"); await expect(ensureStackEnv(dir, "POSTGRES_PASSWORD=\n", fakeHex)).resolves.toBe("kept"); await expect(readFile(path.join(dir, STACK_ENV_FILE), "utf8")).resolves.toBe("sentinel\n"); }); it.runIf(process.platform !== "win32")( "replaces a symlinked .env without touching its target", async () => { const target = path.join(dir, "outside.env"); await writeFile(target, "sentinel\n", "utf8"); await symlink(target, path.join(dir, STACK_ENV_FILE)); await expect(ensureStackEnv(dir, "POSTGRES_PASSWORD=\n", fakeHex)).resolves.toBe("created"); await expect(readFile(path.join(dir, STACK_ENV_FILE), "utf8")).resolves.toBe( `POSTGRES_PASSWORD=${"ab".repeat(16)}\n`, ); await expect(readFile(target, "utf8")).resolves.toBe("sentinel\n"); }, ); }); describe("stack identity", () => { let dir: string; beforeEach(async () => { dir = await mkdtemp(path.join(tmpdir(), "rakazo-stack-token-")); }); afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); it("creates one private token and reuses it", async () => { const token = await ensureStackToken(dir, fakeHex); await expect(ensureStackToken(dir, () => "cd".repeat(32))).resolves.toBe(token); await expect(readStackToken(dir)).resolves.toBe(token); if (process.platform !== "win32") { expect((await stat(path.join(dir, STACK_TOKEN_FILE))).mode & 0o777).toBe(0o600); } }); it("replaces an invalid legacy token", async () => { await writeFile(path.join(dir, STACK_TOKEN_FILE), "not-a-token\n", "utf8"); await expect(ensureStackToken(dir, fakeHex)).resolves.toBe("ab".repeat(32)); }); it("does not buffer an oversized stack token", async () => { await writeFile(path.join(dir, STACK_TOKEN_FILE), "a".repeat(1025), "utf8"); await expect(readStackToken(dir)).resolves.toBeNull(); }); it.runIf(process.platform !== "win32")( "does not reuse a stack token through a final symlink", async () => { const target = path.join(dir, "outside-token"); await writeFile(target, `${"cd".repeat(32)}\n`, "utf8"); await symlink(target, path.join(dir, STACK_TOKEN_FILE)); await expect(readStackToken(dir)).resolves.toBeNull(); await expect(ensureStackToken(dir, fakeHex)).resolves.toBe("ab".repeat(32)); await expect(readFile(target, "utf8")).resolves.toBe(`${"cd".repeat(32)}\n`); }, ); }); describe("reduceStackState", () => { const start = initialStackState("edge"); it("walks the happy path and only records output while docker is working", () => { let state = reduceStackState(start, { type: "check-start" }); expect(state.phase).toBe("checking-docker"); state = reduceStackState(state, { type: "output", line: "ignored" }); expect(state.output).toEqual([]); state = reduceStackState(state, { type: "prepare" }); state = reduceStackState(state, { type: "pull-start" }); state = reduceStackState(state, { type: "output", line: "app Pulled" }); state = reduceStackState(state, { type: "up-start" }); state = reduceStackState(state, { type: "output", line: "web Started" }); state = reduceStackState(state, { type: "wait-start" }); expect(state).toMatchObject({ phase: "waiting-healthy", message: null, output: ["app Pulled", "web Started"], imageTag: "edge", }); state = reduceStackState(state, { type: "ready" }); expect(state.phase).toBe("ready"); }); it("sums the largest size each layer reports and clears it for the next attempt", () => { let state = reduceStackState(start, { type: "check-start" }); state = reduceStackState(state, { type: "pull-start" }); for (const line of [ " a235d761c5d1 Downloading 59.47MB", " a235d761c5d1 Downloading 1.02GB", // Out-of-order and repeated lines must never walk the total backwards. " a235d761c5d1 Downloading 900MB", " 37d39b5cad9d Downloading 491.5kB", " 37d39b5cad9d Extracting 491.5kB", " app Pulled", ]) { state = reduceStackState(state, { type: "output", line }); } expect(state.layerBytes).toEqual({ a235d761c5d1: 1.02e9, "37d39b5cad9d": 491_500 }); expect(reduceStackState(state, { type: "check-start" }).layerBytes).toEqual({}); }); it("keeps only the last lines of output", () => { let state = reduceStackState(start, { type: "check-start" }); state = reduceStackState(state, { type: "pull-start" }); for (let index = 0; index < STACK_OUTPUT_LINES + 5; index += 1) { state = reduceStackState(state, { type: "output", line: `line ${index}` }); } expect(state.output).toHaveLength(STACK_OUTPUT_LINES); expect(state.output[0]).toBe("line 5"); }); it("holds terminal phases until the next check-start clears them", () => { let state = reduceStackState(start, { type: "check-start" }); state = reduceStackState(state, { type: "pull-start" }); state = reduceStackState(state, { type: "output", line: "manifest unknown" }); state = reduceStackState(state, { type: "failed", message: "Pull failed." }); expect(state).toMatchObject({ phase: "failed", message: "Pull failed." }); expect(reduceStackState(state, { type: "ready" })).toBe(state); expect(reduceStackState(state, { type: "output", line: "late" })).toBe(state); state = reduceStackState(state, { type: "check-start" }); expect(state).toMatchObject({ phase: "checking-docker", message: null, output: [] }); }); it("records why Docker is unavailable", () => { const state = reduceStackState(reduceStackState(start, { type: "check-start" }), { type: "docker-missing", message: "Docker is not installed on this computer.", }); expect(state).toMatchObject({ phase: "docker-missing", message: "Docker is not installed on this computer.", }); }); }); describe("stackFailureMessage", () => { it("names the tag when images are not published yet", () => { expect(stackFailureMessage("image-not-found", "pulling", "v0.2.0")).toBe( "Images for v0.2.0 are not published yet. Try again in a few minutes.", ); }); it("points at the ports for a port clash", () => { expect(stackFailureMessage("port-in-use", "starting", "edge")).toContain("local port"); }); it("explains the docker group for socket permission errors", () => { expect(stackFailureMessage("socket-permission", "pulling", "edge")).toContain("docker group"); }); it("falls back to phase-specific wording", () => { expect(stackFailureMessage("other", "pulling", "edge")).toContain("Downloading"); expect(stackFailureMessage("other", "starting", "edge")).toContain("did not start"); }); }); interface RecordedCall { binary: string; args: string[]; cwd: string; env: Record; } type Script = ( args: string[], ) => Partial & { lines?: string[]; wait?: Promise }; function fakeRun(calls: RecordedCall[], script: Script): RunDocker { return async (binary, args, options) => { calls.push({ binary, args, cwd: options.cwd, env: options.env }); const { lines = [], wait, ...reply } = script(args); for (const line of lines) options.onLine?.(line); await wait; return { code: 0, stdout: "", stderr: "", ...reply }; }; } const ok: Script = (args) => { if (args[0] === "compose" && args[1] === "version") return { stdout: "2.29.0\n" }; if (args[0] === "info") return { stdout: "27.1.1\n" }; const subcommand = args[7]; if (subcommand === "pull") return { lines: ["app Pulled", "computer Pulled"] }; if (subcommand === "up") return { lines: ["Container rakazo-web-1 Started"] }; return {}; }; describe("LocalStackController", () => { let root: string; let calls: RecordedCall[]; let phases: string[]; beforeEach(async () => { root = await mkdtemp(path.join(tmpdir(), "rakazo-stack-")); calls = []; phases = []; }); afterEach(async () => { await rm(root, { recursive: true, force: true }); }); function controller(overrides: Partial = {}, script: Script = ok) { const run = fakeRun(calls, script); const probe = overrides.probe ?? (async () => "v1.2.3"); const deps: LocalStackDeps = { platform: "linux", env: { PATH: "/usr/bin", HOME: "/home/me", OPENROUTER_API_KEY: "sk-secret" }, exists: (file) => file === "/usr/bin/docker", allocatePort: async () => 45174, stackDir: path.join(root, "stack"), resourceDir: COMPOSE_DIR, localWebUrl: "http://127.0.0.1:5173", imageTag: "v1.2.3", randomHex: fakeHex, sleep: async () => undefined, healthTimeoutMs: 50, ...overrides, // Record the phase a poller would see while each docker command and probe runs. run: (binary, args, options) => { phases.push(stack.state().phase); return run(binary, args, options); }, probe: (url, signal, token) => { phases.push(stack.state().phase); return probe(url, signal, token); }, }; const stack = new LocalStackController(deps); return stack; } it("pushes every state change so the setup window is never left waiting on a poll", async () => { const pushed: string[] = []; const stack = controller({ onState: (state) => pushed.push(state.phase) }); await stack.start(); expect(pushed).toEqual([ "checking-docker", "preparing", "pulling", "pulling", "pulling", "starting", "starting", "waiting-healthy", "ready", ]); }); it("installs the compose project and walks every phase to ready", async () => { const stack = controller(); expect(stack.state()).toEqual(initialStackState("v1.2.3")); const state = await stack.start(); expect(state).toMatchObject({ phase: "ready", message: null, imageTag: "v1.2.3" }); expect(state.output).toEqual([ "app Pulled", "computer Pulled", "Container rakazo-web-1 Started", ]); expect(phases).toEqual([ "checking-docker", "checking-docker", "pulling", "starting", "waiting-healthy", ]); const stackPath = path.join(root, "stack"); const compose = [ "compose", "--env-file", ".env", "-f", STACK_COMPOSE_FILE, "--project-name", STACK_PROJECT_NAME, ]; expect(calls.map((call) => call.args)).toEqual([ ["compose", "version", "--short"], ["info", "--format", "{{.ServerVersion}}"], [...compose, "pull"], [...compose, "up", "-d", "--wait", "--wait-timeout", String(COMPOSE_WAIT_TIMEOUT_S)], ]); for (const call of calls) { expect(call.binary).toBe("/usr/bin/docker"); expect(call.cwd).toBe(stackPath); expect(call.env).toMatchObject({ RAKAZO_IMAGE_TAG: "v1.2.3", RAKAZO_COMPUTER_IMAGE_TAG: "v1.2.3", COMPOSE_PROGRESS: "plain", HOME: "/home/me", }); expect(call.env).not.toHaveProperty("OPENROUTER_API_KEY"); } expect(calls.at(-1)?.env.RAKAZO_DESKTOP_STACK_TOKEN).toBe("ab".repeat(32)); await expect(readFile(path.join(stackPath, STACK_COMPOSE_FILE), "utf8")).resolves.toBe( await readFile(path.join(COMPOSE_DIR, STACK_COMPOSE_FILE), "utf8"), ); const env = await readFile(path.join(stackPath, STACK_ENV_FILE), "utf8"); expect(env).toContain(`POSTGRES_PASSWORD=${"ab".repeat(16)}`); expect(env).not.toContain("RAKAZO_IMAGE_TAG="); if (process.platform !== "win32") { expect((await stat(path.join(stackPath, STACK_ENV_FILE))).mode & 0o777).toBe(0o600); expect((await stat(stackPath)).mode & 0o777).toBe(0o700); } }); it("keeps lifecycle commands off the standalone project despite environment overrides", async () => { const dir = path.join(root, "stack"); await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, STACK_ENV_FILE), "COMPOSE_PROJECT_NAME=rakazo\n"); const stack = controller({ env: { COMPOSE_PROJECT_NAME: "rakazo" } }, (args) => args[7] === "up" ? { code: 1, stderr: "port is already allocated" } : ok(args), ); expect((await stack.start()).phase).toBe("failed"); // A fresh process must stop the same desktop project without discovering or adopting rakazo. expect((await controller().stop()).phase).toBe("idle"); const commands = calls.filter( (call) => call.args[0] === "compose" && call.args[1] !== "version", ); expect(commands.map((call) => call.args[7])).toEqual([ "pull", "up", "up", "up", "logs", "stop", ]); for (const call of commands) { expect(call.args.slice(5, 7)).toEqual(["--project-name", "rakazo-desktop"]); expect(call.env).not.toHaveProperty("COMPOSE_PROJECT_NAME"); } }); it("reuses only the privately identified stack on the desired image", async () => { const stackPath = path.join(root, "stack"); await mkdir(stackPath, { recursive: true }); const token = await ensureStackToken(stackPath, fakeHex); const probedUrls: string[] = []; const stack = controller({ probe: async (url, _signal, supplied) => { probedUrls.push(url); return supplied === token ? "v1.2.3" : null; }, }); await expect(stack.matchesDesiredStack()).resolves.toBe(true); expect(probedUrls).toEqual(["http://127.0.0.1:5173"]); await expect(stack.matchesDesiredStack("http://127.0.0.1:5199")).resolves.toBe(true); expect(probedUrls).toEqual(["http://127.0.0.1:5173", "http://127.0.0.1:5199"]); expect(calls).toEqual([]); }); it("rejects another listener and a managed stack from an older release", async () => { const stackPath = path.join(root, "stack"); await mkdir(stackPath, { recursive: true }); await ensureStackToken(stackPath, fakeHex); await expect(controller({ probe: async () => null }).matchesDesiredStack()).resolves.toBe( false, ); await expect(controller({ probe: async () => "v1.2.2" }).matchesDesiredStack()).resolves.toBe( false, ); }); it("uses a plain up -d for Compose plugins older than 2.17", async () => { const stack = controller({}, (args) => args[1] === "version" ? { stdout: "2.12.2\n" } : ok(args), ); await stack.start(); expect(calls.at(-1)?.args.slice(7)).toEqual(["up", "-d"]); }); it("stops at docker-missing when no binary exists and touches nothing", async () => { const stack = controller({ exists: () => false }); const state = await stack.start(); expect(state).toMatchObject({ phase: "docker-missing", message: "Docker is not installed on this computer.", }); expect(calls).toEqual([]); await expect(stat(path.join(root, "stack", STACK_ENV_FILE))).rejects.toThrow(); }); it("reports a missing Compose plugin as docker-missing", async () => { const stack = controller({}, (args) => args[1] === "version" ? { code: 1, stderr: "docker: 'compose' is not a docker command" } : ok(args), ); const state = await stack.start(); expect(state.phase).toBe("docker-missing"); expect(state.message).toContain("Docker Compose is missing"); expect(calls).toHaveLength(1); }); it("waits for the daemon when docker info fails", async () => { const stack = controller({}, (args) => args[0] === "info" ? { code: 1, stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock" } : ok(args), ); const state = await stack.start(); expect(state).toMatchObject({ phase: "docker-not-running", message: "Docker is installed but not running. Start Docker, then check again.", }); expect(calls).toHaveLength(2); }); it("explains the docker group when the socket is not accessible", async () => { const stack = controller({}, (args) => args[0] === "info" ? { code: 1, stderr: "permission denied while trying to connect to the Docker daemon" } : ok(args), ); const state = await stack.start(); expect(state.phase).toBe("docker-not-running"); expect(state.message).toContain("docker group"); }); it("fails with a tag-specific message when the images are not published", async () => { const stack = controller({}, (args) => args[7] === "pull" ? { code: 1, stderr: "Error response from daemon: manifest unknown", lines: ["Error response from daemon: manifest unknown"], } : ok(args), ); const state = await stack.start(); expect(state).toMatchObject({ phase: "failed", message: "Images for v1.2.3 are not published yet. Try again in a few minutes.", output: ["Error response from daemon: manifest unknown"], }); expect(calls.map((call) => call.args[7] ?? call.args[0])).toEqual(["compose", "info", "pull"]); }); it("reports interruption instead of a pull failure when docker returns 130", async () => { const stack = controller({}, (args) => args[7] === "pull" ? { code: 130, stderr: "got 3 SIGTERM" } : ok(args), ); const state = await stack.start(); expect(state).toMatchObject({ phase: "failed", message: "The start was interrupted. Retry to continue.", }); }); it("retries port conflicts on a new origin and uses it for auth, health, and saved launches", async () => { let starts = 0; const probed: string[] = []; const stack = controller( { probe: async (url) => { probed.push(url); return "v1.2.3"; }, }, (args) => { if (args[7] === "up" && starts++ === 0) return { code: 1, stderr: "port is already allocated" }; return ok(args); }, ); expect((await stack.start()).phase).toBe("ready"); expect(starts).toBe(2); expect(probed).toEqual(["http://127.0.0.1:45174"]); expect(stack.webUrl()).toBe("http://127.0.0.1:45174"); expect(calls.filter((call) => call.args[7] === "pull")).toHaveLength(1); expect( calls.filter((call) => call.args[7] === "up").map((call) => call.env.RAKAZO_WEB_PORT), ).toEqual(["5173", "45174"]); expect(calls.at(-1)?.env).toMatchObject({ RAKAZO_API_PORT: "0", WEB_ORIGIN: stack.webUrl(), BETTER_AUTH_URL: stack.webUrl(), API_URL: stack.webUrl(), }); expect(await readFile(path.join(root, "stack", ".desktop-web-url"), "utf8")).toBe( stack.webUrl(), ); expect(await stack.matchesDesiredStack()).toBe(true); expect(probed.at(-1)).toBe(stack.webUrl()); }); it("bounds retries when another process repeatedly takes the selected port", async () => { const stack = controller({}, (args) => args[7] === "up" ? { code: 1, stderr: "port is already allocated" } : ok(args), ); expect(await stack.start()).toMatchObject({ phase: "failed", message: expect.stringContaining("local port"), }); expect(calls.filter((call) => call.args[7] === "up")).toHaveLength(3); expect(phases).not.toContain("waiting-healthy"); }); it("collects service logs when up fails", async () => { const stack = controller({}, (args) => { if (args[7] === "up") { return { code: 1, stderr: "service exited", lines: ["web Error"] }; } if (args[7] === "logs") return { lines: ["web-1 | EADDRINUSE"] }; return ok(args); }); const state = await stack.start(); expect(state.phase).toBe("failed"); expect(state.message).toContain("did not start"); expect(state.output).toEqual([ "app Pulled", "computer Pulled", "web Error", "web-1 | EADDRINUSE", ]); expect(calls.at(-1)?.args.slice(7)).toEqual(["logs", "--tail", "30", "--no-color"]); }); it.each(["stdout", "stderr"] as const)( "explains exhausted address pools from %s and can retry after recovery", async (stream) => { let exhausted = true; const stack = controller({}, (args) => { if (args[7] === "up" && exhausted) { return { code: 1, [stream]: "failed to create network rakazo-desktop_data: Error response from daemon: all predefined address pools have been fully subnetted", }; } return ok(args); }); expect(await stack.start()).toMatchObject({ phase: "failed", message: "Docker has no free network address pools. Remove unused Docker networks or expand Docker’s address pools, then retry.", }); expect(phases).not.toContain("waiting-healthy"); expect(calls.every((call) => call.args[0] === "compose" || call.args[0] === "info")).toBe( true, ); exhausted = false; expect(await stack.start()).toMatchObject({ phase: "ready", message: null }); }, ); it("fails when the web app never answers after up", async () => { let probes = 0; const stack = controller({ probe: async () => { probes += 1; return null; }, }); const state = await stack.start(); expect(state.phase).toBe("failed"); expect(state.message).toContain("did not answer"); expect(probes).toBeGreaterThan(1); }); it("returns the same attempt while one is in flight and restarts after failure", async () => { let attempts = 0; const stack = controller({}, (args) => { if (args[7] === "pull") { attempts += 1; return attempts === 1 ? { code: 1, stderr: "no such host" } : ok(args); } return ok(args); }); const first = stack.start(); expect(stack.start()).toBe(first); const failed = await first; expect(failed).toMatchObject({ phase: "failed" }); expect(failed.message).toContain("registry"); const second = await stack.start(); expect(second.phase).toBe("ready"); expect(attempts).toBe(2); }); it("does not leak randomness or docker output into the message", async () => { const stack = controller({}, (args) => args[7] === "pull" ? { code: 1, stderr: "/Users/me/secret-path: boom" } : ok(args), ); const state = await stack.start(); expect(state.message).not.toContain("/Users/me"); }); it("stops the containers with compose stop and returns to idle", async () => { const stack = controller(); await stack.start(); const state = await stack.stop(); expect(state).toEqual(initialStackState("v1.2.3")); expect(calls.at(-1)?.args.slice(7)).toEqual(["stop"]); }); it("reports a stop when docker has gone missing since the start", async () => { let installed = true; const stack = controller({ exists: (file) => installed && file === "/usr/bin/docker" }); await stack.start(); installed = false; const state = await stack.stop(); expect(state).toMatchObject({ phase: "failed", message: expect.stringContaining("Could not stop"), }); expect(calls.at(-1)?.args.slice(7)).not.toEqual(["stop"]); }); it("reports a stop that docker refused instead of pretending the stack is down", async () => { const stack = controller({}, (args) => args[7] === "stop" ? { code: 1, stderr: "/Users/me/secret-path: Cannot connect to the Docker daemon" } : ok(args), ); await stack.start(); expect(stack.state().phase).toBe("ready"); const state = await stack.stop(); expect(state).toMatchObject({ phase: "failed", message: "Could not stop the local stack. Check that Docker is running, then try again.", imageTag: "v1.2.3", }); expect(state.message).not.toContain("/Users/me"); expect(state).not.toEqual(initialStackState("v1.2.3")); expect(calls.at(-1)?.args.slice(7)).toEqual(["stop"]); }); it("queues a start behind a stop so up and stop never overlap", async () => { let releaseStop: () => void = () => undefined; const stopGate = new Promise((resolve) => { releaseStop = resolve; }); const stack = controller({}, (args) => { if (args[7] === "stop") return { wait: stopGate }; return ok(args); }); await stack.start(); const stopping = stack.stop(); const restarted = stack.start(); await new Promise((resolve) => setTimeout(resolve, 10)); expect(calls.at(-1)?.args.slice(7)).toEqual(["stop"]); releaseStop(); await stopping; expect((await restarted).phase).toBe("ready"); const order = calls.map((call) => call.args[7] ?? call.args[0]); expect(order.indexOf("stop")).toBeLessThan(order.lastIndexOf("compose")); }); it("queues a fresh start behind a stop instead of joining the aborted attempt", async () => { let releaseStop: () => void = () => undefined; const stopGate = new Promise((resolve) => { releaseStop = resolve; }); let probes = 0; let markProbeStarted: () => void = () => undefined; const probeStarted = new Promise((resolve) => { markProbeStarted = resolve; }); const stack = controller( { probe: (_url, signal) => { probes += 1; if (probes > 1) return Promise.resolve("v1.2.3"); markProbeStarted(); if (signal.aborted) return Promise.resolve(null); return new Promise((resolve) => signal.addEventListener("abort", () => resolve(null))); }, }, (args) => (args[7] === "stop" ? { wait: stopGate } : ok(args)), ); const first = stack.start(); await probeStarted; const stopping = stack.stop(); const second = stack.start(); expect(second).not.toBe(first); expect(stack.start()).toBe(second); releaseStop(); await stopping; expect((await first).message).toBe("The start was interrupted. Retry to continue."); expect((await second).phase).toBe("ready"); const order = calls.map((call) => call.args[7] ?? call.args[0]); expect(order.indexOf("stop")).toBeLessThan(order.lastIndexOf("up")); }); it("honours a second stop over a start queued behind a slow first stop", async () => { let releaseStop: () => void = () => undefined; const stopGate = new Promise((resolve) => { releaseStop = resolve; }); let stops = 0; const stack = controller({}, (args) => { if (args[7] !== "stop") return ok(args); stops += 1; return stops === 1 ? { wait: stopGate } : {}; }); await stack.start(); const firstStop = stack.stop(); const queuedStart = stack.start(); const secondStop = stack.stop(); expect(secondStop).not.toBe(firstStop); releaseStop(); await Promise.all([firstStop, queuedStart, secondStop]); expect(stack.state()).toEqual(initialStackState("v1.2.3")); const order = calls.map((call) => call.args[7] ?? call.args[0]); expect(order.lastIndexOf("up")).toBeLessThan(order.indexOf("stop")); }); it("does not wait out a slow health probe when stopped", async () => { const stack = controller({ probe: (_url, signal) => signal.aborted ? Promise.resolve(null) : new Promise((resolve) => signal.addEventListener("abort", () => resolve(null))), }); const started = stack.start(); await new Promise((resolve) => setTimeout(resolve, 10)); const stopped = await stack.stop(); expect(stopped).toEqual(initialStackState("v1.2.3")); expect((await started).message).toBe("The start was interrupted. Retry to continue."); }); it("surfaces a broken resource bundle instead of hanging", async () => { const resourceDir = path.join(root, "empty"); await mkdir(resourceDir); const stack = controller({ resourceDir }); const state = await stack.start(); expect(state).toMatchObject({ phase: "failed", message: "Could not prepare the local stack. Retry.", }); // copyFile/readFile ENOENT must not leak absolute paths into user-facing state. expect(state.message).not.toContain(resourceDir); expect(state.message).not.toContain(root); }); });