import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import { createServer, type RequestListener, type Server } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; import { type ElectronApplication, _electron as electron, expect, test } from "@playwright/test"; const APP_MARKER = "Local Rakazo stack ready"; const IMAGE_TAG = "v9.9.9"; const STACK_PROBE_PATH = "/.well-known/rakazo-desktop-stack"; const STACK_TOKEN_HEADER = "x-rakazo-desktop-stack-token"; const COMPOSE_DIR = path.resolve(import.meta.dirname, "..", "..", "..", "infra", "compose"); type FakeDockerMode = | "ok" | "daemon-down" | "pull-fails" | "pool-exhausted" | "port-conflict" | "ports-exhausted"; let server: Server; let serverUrl: string; let userData: string; let app: ElectronApplication | undefined; // The fake docker is a POSIX shell script; Windows desktop e2e does not run in CI. test.skip(process.platform === "win32", "fake docker needs a POSIX shell"); test.beforeAll(async () => { server = createServer(async (request, response) => { if (request.url === STACK_PROBE_PATH && request.method === "GET") { const expected = await readFile(path.join(userData, "stack", ".desktop-stack-token"), "utf8") .then((value) => value.trim()) .catch(() => null); if (expected === null || request.headers[STACK_TOKEN_HEADER] !== expected) { response.writeHead(404).end("Not found"); return; } response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); response.end(JSON.stringify({ ok: true, imageTag: IMAGE_TAG })); return; } if (request.url === "/api/desktop-settings/rpc/integrationSetup/get") { const expected = await readFile(path.join(userData, "stack", ".desktop-stack-token"), "utf8"); if ( request.headers["x-rakazo-local-settings-token"] !== expected.trim() || request.headers.cookie ) { response.writeHead(401).end(); return; } response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ json: { canConfigure: true } })); return; } if (request.url === "/rpc/health" && request.method === "POST") { response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } })); return; } response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.end( `Rakazo
${APP_MARKER}
`, ); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (address === null || typeof address === "string") throw new Error("stub server has no port"); serverUrl = `http://127.0.0.1:${address.port}`; }); test.afterAll(async () => { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); }); test.beforeEach(async () => { // The fake docker logs $PWD, which is the resolved path (macOS /var is a symlink). userData = await realpath(await mkdtemp(path.join(tmpdir(), "rakazo-desktop-stack-"))); }); test.afterEach(async () => { await app?.close(); app = undefined; await rm(userData, { recursive: true, force: true }); }); function fakeDockerLog() { return path.join(userData, "fake-docker.log"); } /** * Stands in for the docker CLI: records `cwd | RAKAZO_IMAGE_TAG | argv` for every call and * answers the handful of commands the app issues. The log path and mode are baked into the * script because the app passes docker an allowlisted environment. */ async function writeFakeDocker(mode: FakeDockerMode) { const script = path.join(userData, "fake-docker.sh"); let up = ' up) echo "Container rakazo-web-1 Started"; exit 0 ;;'; if (mode === "pool-exhausted") { up = ' up) echo "all predefined address pools have been fully subnetted" >&2; exit 1 ;;'; } else if (mode === "ports-exhausted") { up = ' up) echo "port is already allocated" >&2; exit 1 ;;'; } else if (mode === "port-conflict") { up = ` up) if [ ! -f '${path.join(userData, "port-conflict")}' ]; then touch '${path.join(userData, "port-conflict")}'; echo "port is already allocated" >&2; exit 1; fi; echo "Container rakazo-web-1 Started"; exit 0 ;;`; } const lines = [ "#!/bin/sh", `printf '%s | %s | %s\\n' "$PWD" "$RAKAZO_IMAGE_TAG" "$*" >> '${fakeDockerLog()}'`, 'case "$1 $2" in', ' "compose version") echo "2.29.0"; exit 0 ;;', ' "info --format")', mode === "daemon-down" ? ' echo "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?" >&2; exit 1 ;;' : ' echo "27.1.1"; exit 0 ;;', "esac", "# compose --env-file .env -f docker-compose.images.yml --project-name rakazo-desktop ...", 'case "$8" in', " pull)", mode === "pull-fails" ? ' echo "Error response from daemon: manifest unknown" >&2; exit 1 ;;' : ' echo " a235d761c5d1 Downloading 59.47MB"; echo " a235d761c5d1 Downloading 412.3MB"; echo "app Pulled"; sleep 2; echo "computer Pulled"; exit 0 ;;', up, ' logs) echo "web-1 | listening"; exit 0 ;;', "esac", "exit 0", "", ]; await writeFile(script, lines.join("\n"), { encoding: "utf8", mode: 0o755 }); return script; } async function launch(mode: FakeDockerMode | "missing") { const env: NodeJS.ProcessEnv = { ...process.env, RAKAZO_PERFORMANCE_USER_DATA: userData }; // A stale RAKAZO_WEB_URL from the developer's shell would bypass setup entirely. delete env.RAKAZO_WEB_URL; return electron.launch({ args: ["."], cwd: path.resolve(import.meta.dirname, ".."), env: { ...env, RAKAZO_DOCKER_BINARY: mode === "missing" ? "/nonexistent/docker" : await writeFakeDocker(mode), RAKAZO_LOCAL_WEB_URL: serverUrl, RAKAZO_IMAGE_TAG: IMAGE_TAG, }, }); } async function savedSetup() { try { return JSON.parse(await readFile(path.join(userData, "setup.json"), "utf8")); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } } async function readLog() { try { return (await readFile(fakeDockerLog(), "utf8")).trim().split("\n"); } catch { return []; } } test("This computer installs and starts the stack, then opens the app", async () => { app = await launch("ok"); const setup = await app.firstWindow(); await expect(setup.getByRole("radio", { name: /This computer/ })).toBeChecked(); // The panel is empty until a start begins, so check the attribute rather than the box. await expect(setup.locator("#panel-new")).toHaveJSProperty("hidden", false); await expect(setup.locator("#stack")).toBeHidden(); await expect(setup.getByRole("button", { name: "Check connection" })).toBeHidden(); const appWindowPromise = app.waitForEvent("window"); await setup.getByRole("button", { name: "Continue" }).click(); await expect(setup.locator("#stack-phase")).toHaveText("Downloading Rakazo…"); await expect(setup.getByRole("button", { name: "Continue" })).toBeDisabled(); // Docker output stays behind the details toggle; the phase, the bar, and the size show by default. await expect(setup.locator("#stack-detail")).toHaveText("412 MB downloaded"); await expect(setup.locator("#stack-progress")).toBeVisible(); await expect(setup.locator("#stack-output")).toBeHidden(); await setup.screenshot({ path: path.join(import.meta.dirname, "screenshots", "06-setup-installing.png"), // Fast-forward the bar's width transition so the artifact shows the value, not a frame of it. animations: "disabled", }); await setup.getByRole("button", { name: "Technical details" }).click(); await expect(setup.locator("#stack-output")).toContainText("app Pulled"); const appWindow = await appWindowPromise; await expect(appWindow.getByText(APP_MARKER)).toBeVisible(); await expect.poll(savedSetup).toEqual({ mode: "new", serverUrl }); const stackDir = path.join(userData, "stack"); const envFile = path.join(stackDir, ".env"); const tokenFile = path.join(stackDir, ".desktop-stack-token"); expect((await stat(envFile)).mode & 0o777).toBe(0o600); expect((await stat(tokenFile)).mode & 0o777).toBe(0o600); expect(await readFile(tokenFile, "utf8")).toMatch(/^[0-9a-f]{64}\n$/); const env = await readFile(envFile, "utf8"); expect(env).toMatch(/^POSTGRES_PASSWORD=[0-9a-f]{32}$/m); expect(env).toMatch(/^BETTER_AUTH_SECRET=[0-9a-f]{64}$/m); expect(env).not.toContain("RAKAZO_IMAGE_TAG="); expect(env).not.toContain("RAKAZO_COMPUTER_IMAGE_TAG="); await expect(readFile(path.join(stackDir, "docker-compose.images.yml"), "utf8")).resolves.toBe( await readFile(path.join(COMPOSE_DIR, "docker-compose.images.yml"), "utf8"), ); const compose = "compose --env-file .env -f docker-compose.images.yml --project-name rakazo-desktop"; expect(await readLog()).toEqual([ `${stackDir} | ${IMAGE_TAG} | compose version --short`, `${stackDir} | ${IMAGE_TAG} | info --format {{.ServerVersion}}`, `${stackDir} | ${IMAGE_TAG} | ${compose} pull`, `${stackDir} | ${IMAGE_TAG} | ${compose} up -d --wait --wait-timeout 300`, ]); }); test("without Docker the app explains how to get it and offers to check again", async () => { app = await launch("missing"); const setup = await app.firstWindow(); await setup.getByRole("button", { name: "Continue" }).click(); await expect(setup.locator("#stack-phase")).toHaveText( "Docker is not installed on this computer.", ); await expect(setup.locator("#stack-phase")).toHaveAttribute("data-tone", "error"); await expect(setup.getByRole("button", { name: "Docker Desktop" })).toBeVisible(); await expect(setup.getByRole("button", { name: "OrbStack" })).toBeVisible(); await expect(setup.getByRole("button", { name: "Docker Engine" })).toBeVisible(); await expect(setup.getByRole("button", { name: "Check again" })).toBeEnabled(); await setup.screenshot({ path: path.join(import.meta.dirname, "screenshots", "07-setup-docker-missing.png"), }); // Existing instance stays available without Docker. await setup.getByRole("radio", { name: /Existing instance/ }).check(); await expect(setup.locator("#stack")).toBeHidden(); await expect(setup.getByRole("button", { name: "Continue" })).toBeEnabled(); expect(await savedSetup()).toBeNull(); }); test("switching to Existing instance while the stack starts keeps that choice", async () => { app = await launch("ok"); const setup = await app.firstWindow(); await setup.getByRole("button", { name: "Continue" }).click(); await expect(setup.locator("#stack-phase")).toHaveText("Downloading Rakazo…"); // Fake docker sleeps during pull; leave This computer before ready so followStack must not save. await setup.getByRole("radio", { name: /Existing instance/ }).check(); await expect(setup.locator("#panel-existing")).toBeVisible(); await expect(setup.getByRole("button", { name: "Continue" })).toBeEnabled(); await expect(setup.getByRole("button", { name: "Check connection" })).toBeVisible(); // Main process still finishes the install; setup.json must stay untouched. await expect .poll(async () => (await readLog()).some((line) => line.includes(" up -d"))) .toBe(true); await new Promise((resolve) => setTimeout(resolve, 2500)); expect(await savedSetup()).toBeNull(); expect(app.windows()).toHaveLength(1); // Back on This computer, Continue starts (or re-follows) and opens the app. await setup.getByRole("radio", { name: /This computer/ }).check(); const appWindowPromise = app.waitForEvent("window"); await setup.getByRole("button", { name: "Continue" }).click(); await expect((await appWindowPromise).getByText(APP_MARKER)).toBeVisible(); await expect.poll(savedSetup).toEqual({ mode: "new", serverUrl }); }); test("a stopped Docker daemon is reported and Check again asks Docker once more", async () => { app = await launch("daemon-down"); const setup = await app.firstWindow(); await setup.getByRole("button", { name: "Continue" }).click(); await expect(setup.locator("#stack-phase")).toHaveText( "Docker is installed but not running. Start Docker, then check again.", ); await expect(setup.getByRole("button", { name: "Docker Desktop" })).toBeVisible(); expect((await readLog()).filter((line) => line.includes("info --format"))).toHaveLength(1); await setup.getByRole("button", { name: "Check again" }).click(); await expect(setup.getByRole("button", { name: "Check again" })).toBeEnabled(); expect((await readLog()).filter((line) => line.includes("info --format"))).toHaveLength(2); expect((await readLog()).some((line) => line.includes(" pull"))).toBe(false); }); test("unpublished images show the docker output and a Retry button", async () => { app = await launch("pull-fails"); const setup = await app.firstWindow(); await setup.getByRole("button", { name: "Continue" }).click(); await expect(setup.locator("#stack-phase")).toHaveText( `Images for ${IMAGE_TAG} are not published yet. Try again in a few minutes.`, ); await expect(setup.locator("#stack-output")).toContainText("manifest unknown"); await expect(setup.getByRole("button", { name: "Retry" })).toBeEnabled(); await expect(setup.locator("#stack-docker-help")).toBeHidden(); expect((await readLog()).some((line) => line.endsWith(" up -d --wait --wait-timeout 300"))).toBe( false, ); }); test("a saved local stack that is down is started again without asking", async () => { // Nothing listens on the saved address, so the launch probe fails and the stack is restarted. const probe = createServer(); await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve)); const address = probe.address(); if (address === null || typeof address === "string") throw new Error("probe has no port"); await new Promise((resolve, reject) => { probe.close((error) => (error ? reject(error) : resolve())); }); await writeFile( path.join(userData, "setup.json"), `${JSON.stringify({ mode: "new", serverUrl: `http://127.0.0.1:${address.port}` }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }, ); app = await launch("ok"); const setup = await app.firstWindow(); await expect(setup.getByRole("radio", { name: /This computer/ })).toBeChecked(); await expect(setup.locator("#stack")).toBeVisible(); await expect(setup.locator("#status")).toBeEmpty(); const appWindow = await app.waitForEvent("window"); await expect(appWindow.getByText(APP_MARKER)).toBeVisible(); await expect.poll(savedSetup).toEqual({ mode: "new", serverUrl }); expect((await readLog()).some((line) => line.includes(" pull"))).toBe(true); }); test("a saved local stack is reused only after its private identity matches", async () => { const stackDir = path.join(userData, "stack"); await mkdir(stackDir, { recursive: true }); await writeFile(path.join(stackDir, ".desktop-stack-token"), `${"ab".repeat(32)}\n`, { encoding: "utf8", mode: 0o600, }); await writeFile( path.join(userData, "setup.json"), `${JSON.stringify({ mode: "new", serverUrl }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }, ); app = await launch("ok"); const appWindow = await app.firstWindow(); await expect(appWindow.getByText(APP_MARKER)).toBeVisible(); expect(await readLog()).toEqual([]); }); test("a saved local target is the exact origin authenticated before reuse", async () => { const uncheckedServer = createServer((request, response) => { if (request.url === "/rpc/health" && request.method === "POST") { response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); response.end(JSON.stringify({ json: { ok: true, version: "0.1.0" } })); return; } response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.end("Unchecked listener"); }); await new Promise((resolve) => uncheckedServer.listen(0, "127.0.0.1", resolve)); try { const address = uncheckedServer.address(); if (address === null || typeof address === "string") { throw new Error("unchecked server has no port"); } const stackDir = path.join(userData, "stack"); await mkdir(stackDir, { recursive: true }); await writeFile(path.join(stackDir, ".desktop-stack-token"), `${"ab".repeat(32)}\n`, { encoding: "utf8", mode: 0o600, }); await writeFile( path.join(userData, "setup.json"), `${JSON.stringify( { mode: "new", serverUrl: `http://127.0.0.1:${address.port}` }, null, 2, )}\n`, { encoding: "utf8", mode: 0o600 }, ); app = await launch("ok"); const setup = await app.firstWindow(); await expect(setup.getByRole("radio", { name: /This computer/ })).toBeChecked(); await expect(setup.locator("#stack")).toBeVisible(); await expect(setup.getByText("Unchecked listener")).toHaveCount(0); } finally { await new Promise((resolve, reject) => { uncheckedServer.close((error) => (error ? reject(error) : resolve())); }); } }); test("an existing stack .env is never rewritten", async () => { const stackDir = path.join(userData, "stack"); await mkdir(stackDir, { recursive: true }); const sentinel = "POSTGRES_PASSWORD=keep-me\nSENTINEL=1\n"; await writeFile(path.join(stackDir, ".env"), sentinel, { encoding: "utf8", mode: 0o600 }); app = await launch("ok"); const setup = await app.firstWindow(); const appWindowPromise = app.waitForEvent("window"); await setup.getByRole("button", { name: "Continue" }).click(); const appWindow = await appWindowPromise; await expect(appWindow.getByText(APP_MARKER)).toBeVisible(); await expect(readFile(path.join(stackDir, ".env"), "utf8")).resolves.toBe(sentinel); }); test("exhausted address pools explain recovery", async () => { app = await launch("pool-exhausted"); const setup = await app.firstWindow(); await setup.getByRole("button", { name: "Continue" }).click(); await expect(setup.locator("#stack-phase")).toHaveText( "Docker has no free network address pools. Remove unused Docker networks or expand Docker’s address pools, then retry.", ); await expect(setup.getByRole("button", { name: "Retry" })).toBeEnabled(); await setup.screenshot({ path: path.join(import.meta.dirname, "screenshots", "09-setup-address-pool-exhausted.png"), }); }); test("a port conflict opens and saves the replacement managed origin", async () => { app = await launch("port-conflict"); const setup = await app.firstWindow(); const appWindowPromise = app.waitForEvent("window"); await setup.getByRole("button", { name: "Continue" }).click(); const urlFile = path.join(userData, "stack", ".desktop-web-url"); let replacementUrl = ""; await expect .poll(async () => { replacementUrl = await readFile(urlFile, "utf8").catch(() => ""); return replacementUrl !== "" && replacementUrl !== serverUrl; }) .toBe(true); // Stand in for the web container Docker would bind to the selected port. const replacement = createServer(server.listeners("request")[0] as RequestListener); await new Promise((resolve) => replacement.listen(Number(new URL(replacementUrl).port), "127.0.0.1", resolve), ); try { const appWindow = await appWindowPromise; await expect(appWindow.getByText(APP_MARKER)).toBeVisible(); await expect.poll(savedSetup).toEqual({ mode: "new", serverUrl: replacementUrl }); expect((await readLog()).filter((line) => line.includes(" up -d"))).toHaveLength(2); } finally { replacement.closeAllConnections(); await new Promise((resolve, reject) => replacement.close((error) => (error ? reject(error) : resolve())), ); } }); test("repeated port conflicts stop with a retry action", async () => { app = await launch("ports-exhausted"); const setup = await app.firstWindow(); await setup.getByRole("button", { name: "Continue" }).click(); await expect(setup.locator("#stack-phase")).toHaveText( "Could not bind a local port after retrying. Retry to choose another port.", ); await expect(setup.getByRole("button", { name: "Retry" })).toBeEnabled(); expect((await readLog()).filter((line) => line.includes(" up -d"))).toHaveLength(3); await setup.screenshot({ path: path.join(import.meta.dirname, "screenshots", "10-setup-ports-unavailable.png"), }); }); test("the native settings menu opens an isolated logged-out settings capability", async () => { app = await launch("ok"); const setup = await app.firstWindow(); const nextWindow = app.waitForEvent("window"); await setup.getByRole("button", { name: "Continue", exact: true }).click(); const main = await nextWindow; await expect(main.getByText(APP_MARKER)).toBeVisible(); await expect.poll(savedSetup).toEqual({ mode: "new", serverUrl }); const denied = await main.evaluate(async () => { try { await window.rakazoDesktop?.localSettings?.request( "/api/desktop-settings/rpc/integrationSetup/get", "{}", ); return false; } catch { return true; } }); expect(denied).toBe(true); const settingsOpened = app.waitForEvent("window"); await app.evaluate(({ Menu }) => { const item = Menu.getApplicationMenu()?.getMenuItemById("local-server-settings"); if (!item) throw new Error("Missing settings menu"); item.click(); }); const settings = await settingsOpened; await expect(settings).toHaveURL(`${serverUrl}/desktop-settings`); const result = await settings.evaluate(() => window.rakazoDesktop?.localSettings?.request( "/api/desktop-settings/rpc/integrationSetup/get", "{}", ), ); expect(result?.status).toBe(200); expect(JSON.parse(result!.body)).toEqual({ json: { canConfigure: true } }); await app.evaluate(({ Menu }) => Menu.getApplicationMenu()?.getMenuItemById("local-server-settings")?.click(), ); expect(app.windows()).toHaveLength(2); await settings.close(); await expect(main.getByText(APP_MARKER)).toBeVisible(); });