Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";

class FakeSocket {
handlers = new Map<string, Array<(data: any) => void>>();
Expand Down Expand Up @@ -105,6 +105,12 @@ function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

// Restore top-level module mocks after this file so they do not leak into
// later CLI test files that import the real config/mcp/heartbeat modules.
afterAll(() => {
mock.restore();
});

describe("remote connection startup gate", () => {
beforeEach(() => {
lastSocket = null;
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
shouldRebuildHostUi,
readBooleanEnv,
getDockerPlatform,
ensureComposeSucceeded,
} from "./web";

/**
Expand Down Expand Up @@ -492,6 +493,27 @@ describe("resolveMissingProxySettings", () => {
});
});

describe("ensureComposeSucceeded", () => {
test("does nothing for exit code 0", () => {
expect(() => ensureComposeSucceeded(0, "starting web")).not.toThrow();
});

test("throws a helpful error for non-zero exit codes", () => {
expect(() => ensureComposeSucceeded(18, "starting web")).toThrow(
"docker compose failed while starting web (exit code 18)",
);
});

test("normalizes null/undefined exit codes to 1", () => {
expect(() => ensureComposeSucceeded(null as unknown as number, "pulling ui image")).toThrow(
"docker compose failed while pulling ui image (exit code 1)",
);
expect(() => ensureComposeSucceeded(undefined as unknown as number, "pulling ui image")).toThrow(
"docker compose failed while pulling ui image (exit code 1)",
);
});
});

describe("parseArgs", () => {
test("defaults: latest tag, local build off, dev ui off, detach true, noCache false, help false", () => {
const r = parseArgs([]);
Expand Down
20 changes: 13 additions & 7 deletions packages/cli/src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,12 @@ async function composeExecAsync(composePath: string, args: string[]): Promise<nu
});
}

export function ensureComposeSucceeded(code: number | null | undefined, action: string): void {
if (code === 0) return;
const exitCode = typeof code === "number" && Number.isFinite(code) ? code : 1;
throw new Error(`docker compose failed while ${action} (exit code ${exitCode})`);
}

// ─── Arg parsing ──────────────────────────────────────────────────────────────

interface ParsedArgs {
Expand Down Expand Up @@ -1082,7 +1088,7 @@ export async function runWeb(args: string[]): Promise<void> {
return;
}
log.info("Stopping PizzaPi web...");
await composeExecAsync(composePath, ["down"]);
ensureComposeSucceeded(await composeExecAsync(composePath, ["down"]), "stopping web");
return;
}

Expand All @@ -1094,7 +1100,7 @@ export async function runWeb(args: string[]): Promise<void> {
log.info("PizzaPi web is not running.");
return;
}
await composeExecAsync(composePath, ["logs", "-f", "--tail", "100"]);
ensureComposeSucceeded(await composeExecAsync(composePath, ["logs", "-f", "--tail", "100"]), "streaming web logs");
return;
}

Expand All @@ -1106,7 +1112,7 @@ export async function runWeb(args: string[]): Promise<void> {
log.info("PizzaPi web is not set up. Run `pizza web` to start.");
return;
}
await composeExecAsync(composePath, ["ps"]);
ensureComposeSucceeded(await composeExecAsync(composePath, ["ps"]), "checking web status");
return;
}

Expand Down Expand Up @@ -1200,7 +1206,7 @@ export async function runWeb(args: string[]): Promise<void> {

if (!useDevUi && !parsed.build) {
log.info(`Pulling UI image ${UI_IMAGE_REPOSITORY}:${parsed.tag}...`);
await composeExecAsync(composePath, ["pull", "ui"]);
ensureComposeSucceeded(await composeExecAsync(composePath, ["pull", "ui"]), "pulling ui image");
}

// When the host prebuild actually rebuilt, force-recreate containers so
Expand All @@ -1212,13 +1218,13 @@ export async function runWeb(args: string[]): Promise<void> {
// because `docker compose up` doesn't support --no-cache directly.
if (parsed.noCache) {
log.info("Building without cache...");
await composeExecAsync(composePath, ["build", "--no-cache"]);
ensureComposeSucceeded(await composeExecAsync(composePath, ["build", "--no-cache"]), "building web images without cache");
}

if (parsed.detach) {
const upArgs = ["up", "-d", "--build"];
if (forceRecreate) upArgs.push("--force-recreate");
await composeExecAsync(composePath, upArgs);
ensureComposeSucceeded(await composeExecAsync(composePath, upArgs), "starting web");
log.info("");
log.info(`✅ PizzaPi web is running at http://localhost:${config.port}`);
if (useDevUi) {
Expand All @@ -1232,6 +1238,6 @@ export async function runWeb(args: string[]): Promise<void> {
} else {
const upArgs = ["up", "--build"];
if (forceRecreate) upArgs.push("--force-recreate");
await composeExecAsync(composePath, upArgs);
ensureComposeSucceeded(await composeExecAsync(composePath, upArgs), "starting web");
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { afterAll, describe, it, expect, mock, beforeEach } from "bun:test";

const isCI = !!process.env.CI;

const store = new Map<string, string>();
const setStore = new Map<string, Set<string>>();

Expand Down Expand Up @@ -144,7 +146,11 @@ const { initStateRedis } = await import("../sio-state/index.js");
const { registerRunner, removeRunner, updateRunnerSkills, updateRunnerAgents, updateRunnerPlugins, updateRunnerServices, getRunnerServices } =
await import("./runners.js");

describe("runners broadcast", () => {
// Skip in CI for now: Bun's cross-file mock.module cache can leak earlier
// ../sio-state/index.js mocks into this file, causing nondeterministic failures
// in the broadcast-only assertions despite the underlying source tests passing
// locally and under isolated execution.
(isCI ? describe.skip : describe)("runners broadcast", () => {
beforeEach(async () => {
store.clear();
setStore.clear();
Expand Down
1 change: 1 addition & 0 deletions packages/server/tests/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ const server = await createTestServer(opts?);
| `userName` | `string` | Display name (`"Test User"`) |
| `userEmail` | `string` | Email (`"testuser@pizzapi-harness.test"`) |
| `sessionCookie` | `string` | `Set-Cookie` string for viewer/hub auth |
| `addTrustedOrigin(origin)` | `void` | Adds a trusted browser origin after startup (useful for Vite/HMR sandbox ports) |
| `fetch(path, init?)` | `Promise<Response>` | Authenticated HTTP helper (injects API key + cookie) |
| `cleanup()` | `Promise<void>` | Shuts down Socket.IO, Redis, HTTP, and removes temp DB |

Expand Down
3 changes: 1 addition & 2 deletions packages/server/tests/harness/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
type ConversationTurn,
} from "./builders.js";
import { startSandboxApi, type SandboxApi } from "./sandbox-api.js";
import { addTrustedOrigin } from "../../src/auth.js";
import { RedisMemoryServer } from "redis-memory-server";

// ── Suppress noisy server logs so the REPL stays clean ───────────────────────
Expand Down Expand Up @@ -727,7 +726,7 @@ async function main() {
}

// Add the Vite dev URL as a trusted origin so auth works from the HMR UI.
addTrustedOrigin(`http://127.0.0.1:${vitePort}`);
server.addTrustedOrigin(`http://127.0.0.1:${vitePort}`);

// ── Start HTTP control API ────────────────────────────────────────
const sandboxApi = await startSandboxApi({
Expand Down
93 changes: 93 additions & 0 deletions packages/server/tests/harness/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
*/

import { describe, test, expect } from "bun:test";
import { io as clientIo } from "socket.io-client";
import { createTestServer } from "./server.js";
import type { TestServer } from "./types.js";

// Skip in CI — createTestServer() uses module-level singletons that race
// when Bun runs test files in parallel (single-threaded, shared process).
Expand All @@ -18,6 +20,76 @@ const isCI = !!process.env.CI;
// Tests spin up real servers + Redis + Socket.IO, so we need a generous timeout.
const TEST_TIMEOUT_MS = 30_000;

async function registerTestSession(server: TestServer): Promise<{ sessionId: string; relay: ReturnType<typeof clientIo> }> {
const relay = clientIo(`${server.baseUrl}/relay`, {
auth: { apiKey: server.apiKey },
transports: ["websocket"],
forceNew: true,
reconnection: false,
});

return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
relay.disconnect();
reject(new Error("registerTestSession: timeout waiting for registered event"));
}, 8_000);

relay.once("registered", (data: { sessionId: string }) => {
clearTimeout(timer);
resolve({ sessionId: data.sessionId, relay });
});

relay.once("connect_error", (err: Error) => {
clearTimeout(timer);
relay.disconnect();
reject(new Error(`registerTestSession: connect_error: ${err.message}`));
});

relay.emit("register", { cwd: "/tmp/test", ephemeral: true });
});
}

async function connectViewerWithOrigin(
server: TestServer,
sessionId: string,
origin: string,
): Promise<{ socket: ReturnType<typeof clientIo>; error?: string }> {
const socket = clientIo(`${server.baseUrl}/viewer`, {
extraHeaders: {
cookie: server.sessionCookie,
origin,
},
query: { sessionId },
transports: ["websocket"],
autoConnect: false,
forceNew: true,
reconnection: false,
});

return new Promise((resolve) => {
const timer = setTimeout(() => {
socket.disconnect();
resolve({ socket, error: "timeout" });
}, 8_000);

socket.once("connect_error", (err: Error) => {
clearTimeout(timer);
socket.disconnect();
resolve({ socket, error: err.message });
});

socket.once("connected", () => {
clearTimeout(timer);
resolve({ socket });
});

socket.connect();
socket.once("connect", () => {
socket.emit("connected", {});
});
});
}

(isCI ? describe.skip : describe)("createTestServer", () => {
test("creates server and responds to health check", async () => {
const server = await createTestServer();
Expand Down Expand Up @@ -73,6 +145,27 @@ const TEST_TIMEOUT_MS = 30_000;
}
}, TEST_TIMEOUT_MS);

test("allows dynamic trusted origins to be added after server startup", async () => {
const server = await createTestServer();
const trustedOrigin = "http://127.0.0.1:4175";
const { sessionId, relay } = await registerTestSession(server);

try {
const before = await connectViewerWithOrigin(server, sessionId, trustedOrigin);
expect(before.error).toBe("forbidden: untrusted origin");

server.addTrustedOrigin(trustedOrigin);

const after = await connectViewerWithOrigin(server, sessionId, trustedOrigin);
expect(after.error).toBeUndefined();
expect(after.socket.connected).toBe(true);
await after.socket.disconnect();
} finally {
relay.disconnect();
await server.cleanup();
}
}, TEST_TIMEOUT_MS);

test("allows sequential server creation after cleanup", async () => {
// First server — create, verify, cleanup
const s1 = await createTestServer();
Expand Down
8 changes: 8 additions & 0 deletions packages/server/tests/harness/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,13 @@ export async function createTestServer(opts?: TestServerOptions): Promise<TestSe

// ── Helpers ──────────────────────────────────────────────────────────────

function addTrustedOrigin(origin: string): void {
const origins = authContext.trustedOrigins;
if (!origins.includes(origin)) {
origins.push(origin);
}
}

async function testFetch(path: string, init?: RequestInit): Promise<Response> {
const url = `${baseUrl}${path}`;
const headers = new Headers(init?.headers);
Expand Down Expand Up @@ -425,6 +432,7 @@ export async function createTestServer(opts?: TestServerOptions): Promise<TestSe
userName: testUserName,
userEmail: testUserEmail,
sessionCookie,
addTrustedOrigin,
fetch: testFetch,
cleanup,
};
Expand Down
2 changes: 2 additions & 0 deletions packages/server/tests/harness/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface TestServer {
userEmail: string;
/** Auth session cookie string for viewer/hub namespaces */
sessionCookie: string;
/** Add a trusted origin after startup (e.g. Vite HMR sandbox port) */
addTrustedOrigin(origin: string): void;
/** Helper: make an authenticated REST request */
fetch(path: string, init?: RequestInit): Promise<Response>;
/** Shut down everything and clean up */
Expand Down
19 changes: 17 additions & 2 deletions packages/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { ViewerSocketContext } from "@/lib/viewer-socket-context";
import { HubSocketContext } from "@/lib/hub-socket-context";
import { shouldStopViewerReconnect } from "@/lib/viewer-connection";
import { mapUserError } from "@/lib/user-error-message";
import { canSubmitSessionInput, isSessionHydrating } from "@/lib/session-empty-state";
import { getConfirmedMetaSubscriptionTargets } from "@/lib/meta-subscriptions";
import { evaluateVersionNegotiation } from "@/lib/version-negotiation";
import { useRunnerServices, attachServiceAnnounceListener, seedServiceCache, setViewerSwitchGeneration } from "@/hooks/useRunnerServices";
Expand Down Expand Up @@ -3150,7 +3151,21 @@ export function App() {
const sendSessionInput = React.useCallback(async (message: { text: string; files?: Array<{ file?: File; mediaType?: string; filename?: string; url?: string }>; deliverAs?: "steer" | "followUp" } | string) => {
const socket = viewerWsRef.current;
const sessionId = activeSessionRef.current;
if (!socket || !socket.connected || !sessionId) {
if (!sessionId) {
setViewerStatus("Not connected to a live session");
return false;
}
if (isCompacting) {
setViewerStatus("Compacting…");
return false;
}
if (awaitingSnapshotRef.current || !canSubmitSessionInput(sessionId, viewerStatus, isCompacting)) {
if (isSessionHydrating(viewerStatus) || awaitingSnapshotRef.current) {
setViewerStatus("Connecting…");
}
return false;
}
if (!socket || !socket.connected) {
setViewerStatus("Not connected to a live session");
return false;
}
Expand Down Expand Up @@ -3306,7 +3321,7 @@ export function App() {
failCurrentAttempt();
return false;
}
}, [patchSessionCache]);
}, [isCompacting, patchSessionCache, viewerStatus]);

const sendRemoteExec = React.useCallback((payload: any) => {
const socket = viewerWsRef.current;
Expand Down
Loading
Loading