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
90 changes: 64 additions & 26 deletions packages/cli/__tests__/commands/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,45 +89,83 @@ describe("findRunningDashboardPid", () => {
});
});

describe("findProcessWebDir", () => {
it("extracts cwd from lsof output", async () => {
const webDir = join(tmpDir, "web");
mkdirSync(webDir, { recursive: true });
writeFileSync(join(webDir, "package.json"), "{}");
describe("isInstalledUnderNodeModules", () => {
it("returns true for a Unix node_modules path segment", async () => {
const { isInstalledUnderNodeModules } = await import("../../src/lib/dashboard-rebuild.js");

// Simulate lsof -p <pid> -Fn output
mockExecSilent.mockResolvedValue(
`p12345\nfcwd\nn${webDir}\nftxt\nn/usr/bin/node`,
);
expect(isInstalledUnderNodeModules("/usr/local/lib/node_modules/@composio/ao-web")).toBe(true);
});

const { findProcessWebDir } = await import("../../src/lib/dashboard-rebuild.js");
it("returns true for a Windows node_modules path segment", async () => {
const { isInstalledUnderNodeModules } = await import("../../src/lib/dashboard-rebuild.js");

const result = await findProcessWebDir("12345");
expect(result).toBe(webDir);
expect(isInstalledUnderNodeModules("C:\\Users\\me\\node_modules\\@composio\\ao-web")).toBe(true);
});

it("returns null when cwd has no package.json", async () => {
const webDir = join(tmpDir, "web");
it("returns false for source paths containing node_modules as plain text", async () => {
const { isInstalledUnderNodeModules } = await import("../../src/lib/dashboard-rebuild.js");

expect(
isInstalledUnderNodeModules("/home/user/node_modules_backup/agent-orchestrator/packages/web"),
).toBe(false);
});
});

describe("assertDashboardRebuildSupported", () => {
it("passes for a source checkout", async () => {
const { assertDashboardRebuildSupported } = await import("../../src/lib/dashboard-rebuild.js");

expect(() =>
assertDashboardRebuildSupported("/home/user/agent-orchestrator/packages/web"),
).not.toThrow();
});

it("throws for an npm-installed package path", async () => {
const { assertDashboardRebuildSupported } = await import("../../src/lib/dashboard-rebuild.js");

expect(() =>
assertDashboardRebuildSupported("/usr/local/lib/node_modules/@composio/ao-web"),
).toThrow("Dashboard rebuild is only available from a source checkout");
});
});

describe("rebuildDashboardProductionArtifacts", () => {
it("cleans .next and runs pnpm build on success", async () => {
const webDir = join(tmpDir, "packages", "web");
mkdirSync(webDir, { recursive: true });
// No package.json
mkdirSync(join(webDir, ".next"), { recursive: true });

mockExecSilent.mockResolvedValue(
`p12345\nfcwd\nn${webDir}\nftxt\nn/usr/bin/node`,
);
mockExec.mockResolvedValue({ stdout: "", stderr: "" });

const { findProcessWebDir } = await import("../../src/lib/dashboard-rebuild.js");
const { rebuildDashboardProductionArtifacts } = await import("../../src/lib/dashboard-rebuild.js");

const result = await findProcessWebDir("12345");
expect(result).toBeNull();
await rebuildDashboardProductionArtifacts(webDir);

// .next should be cleaned
expect(existsSync(join(webDir, ".next"))).toBe(false);
// pnpm build should be called from workspace root (../../ relative to webDir)
expect(mockExec).toHaveBeenCalledWith("pnpm", ["build"], { cwd: tmpDir });
});

it("returns null when lsof fails", async () => {
mockExecSilent.mockResolvedValue(null);
it("throws when pnpm build fails", async () => {
const webDir = join(tmpDir, "packages", "web");
mkdirSync(webDir, { recursive: true });

mockExec.mockRejectedValue(new Error("build failed"));

const { rebuildDashboardProductionArtifacts } = await import("../../src/lib/dashboard-rebuild.js");

await expect(rebuildDashboardProductionArtifacts(webDir)).rejects.toThrow(
"Failed to rebuild dashboard production artifacts",
);
});

const { findProcessWebDir } = await import("../../src/lib/dashboard-rebuild.js");
it("throws when called from an npm-installed path", async () => {
const { rebuildDashboardProductionArtifacts } = await import("../../src/lib/dashboard-rebuild.js");

const result = await findProcessWebDir("12345");
expect(result).toBeNull();
await expect(
rebuildDashboardProductionArtifacts("/usr/local/lib/node_modules/@composio/ao-web"),
).rejects.toThrow("Dashboard rebuild is only available from a source checkout");
});
});

Expand Down
3 changes: 1 addition & 2 deletions packages/cli/__tests__/commands/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,8 @@ vi.mock("../../src/lib/web-dir.js", () => ({
}));

vi.mock("../../src/lib/dashboard-rebuild.js", () => ({
cleanNextCache: vi.fn(),
findRunningDashboardPid: vi.fn().mockResolvedValue(null),
findProcessWebDir: vi.fn().mockResolvedValue(null),
rebuildDashboardProductionArtifacts: vi.fn().mockResolvedValue(undefined),
waitForPortFree: vi.fn(),
}));

Expand Down
40 changes: 40 additions & 0 deletions packages/cli/__tests__/lib/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ vi.mock("node:fs", () => ({
existsSync: mockExistsSync,
}));

vi.mock("../../src/lib/dashboard-rebuild.js", () => ({
isInstalledUnderNodeModules: (path: string) =>
path.includes("/node_modules/") || path.includes("\\node_modules\\"),
}));

import { preflight } from "../../src/lib/preflight.js";

beforeEach(() => {
Expand Down Expand Up @@ -58,9 +63,12 @@ describe("preflight.checkBuilt", () => {
// /web/node_modules/@composio/ao-core — miss
// /node_modules/@composio/ao-core — hit
// /node_modules/@composio/ao-core/dist/index.js — exists
// /web/.next/BUILD_ID and /web/dist-server/start-all.js — exist
mockExistsSync
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce(true);
await expect(preflight.checkBuilt("/web")).resolves.toBeUndefined();
});
Expand Down Expand Up @@ -88,6 +96,38 @@ describe("preflight.checkBuilt", () => {
"Packages not built",
);
});

it("throws when web production artifacts are missing", async () => {
// findPackageUp finds ao-core, dist/index.js exists, but .next/BUILD_ID missing
mockExistsSync
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
await expect(preflight.checkBuilt("/web")).rejects.toThrow(
"Packages not built",
);
});

it("throws npm hint when web artifacts missing in global install", async () => {
// ao-core found at first check, dist exists, but .next/BUILD_ID missing
mockExistsSync
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
await expect(
preflight.checkBuilt("/usr/local/lib/node_modules/@composio/ao-web"),
).rejects.toThrow("npm install -g @composio/ao@latest");
});

it("throws npm hint when ao-core dist is missing in global install", async () => {
// ao-core found, but dist/index.js missing
mockExistsSync
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
await expect(
preflight.checkBuilt("/usr/local/lib/node_modules/@composio/ao-web"),
).rejects.toThrow("npm install -g @composio/ao@latest");
});
});

describe("preflight.checkTmux", () => {
Expand Down
45 changes: 23 additions & 22 deletions packages/cli/src/commands/dashboard.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import chalk from "chalk";
import type { Command } from "commander";
import { loadConfig } from "@composio/ao-core";
import { findWebDir, buildDashboardEnv, waitForPortAndOpen } from "../lib/web-dir.js";
import { cleanNextCache, findRunningDashboardPid, findProcessWebDir, waitForPortFree } from "../lib/dashboard-rebuild.js";
import {
findRunningDashboardPid,
isInstalledUnderNodeModules,
rebuildDashboardProductionArtifacts,
waitForPortFree,
} from "../lib/dashboard-rebuild.js";
import { preflight } from "../lib/preflight.js";

export function registerDashboard(program: Command): void {
program
Expand All @@ -14,6 +19,7 @@ export function registerDashboard(program: Command): void {
.option("-p, --port <port>", "Port to listen on")
.option("--no-open", "Don't open browser automatically")
.option("--rebuild", "Clean stale build artifacts and rebuild before starting")
/* c8 ignore start -- process-spawning startup code, tested via integration/onboarding */
.action(async (opts: { port?: string; open?: boolean; rebuild?: boolean }) => {
const config = loadConfig();
const port = opts.port ? parseInt(opts.port, 10) : (config.port ?? 3000);
Expand All @@ -28,11 +34,9 @@ export function registerDashboard(program: Command): void {
if (opts.rebuild) {
// Check if a dashboard is already running on this port.
const runningPid = await findRunningDashboardPid(port);
const runningWebDir = runningPid ? await findProcessWebDir(runningPid) : null;
const targetWebDir = runningWebDir ?? localWebDir;

if (runningPid) {
// Kill the running server, clean .next, then start fresh below.
// Stop the running server before rebuilding or restarting below.
console.log(
chalk.dim(`Stopping dashboard (PID ${runningPid}) on port ${port}...`),
);
Expand All @@ -45,8 +49,10 @@ export function registerDashboard(program: Command): void {
await waitForPortFree(port, 5000);
}

await cleanNextCache(targetWebDir);
await rebuildDashboardProductionArtifacts(localWebDir);
// Fall through to start the dashboard on this port.
} else {
await preflight.checkBuilt(localWebDir);
}

const webDir = localWebDir;
Expand All @@ -60,21 +66,12 @@ export function registerDashboard(program: Command): void {
config.directTerminalPort,
);

// In dev mode (monorepo), use `pnpm run dev` which starts Next.js AND
// the terminal WebSocket servers via concurrently. Without the WS servers,
// the live terminal in the dashboard won't work.
const isDevMode = existsSync(resolve(webDir, "server"));
const child = isDevMode
? spawn("pnpm", ["run", "dev"], {
cwd: webDir,
stdio: ["inherit", "inherit", "pipe"],
env,
})
: spawn("npx", ["next", "dev", "-p", String(port)], {
cwd: webDir,
stdio: ["inherit", "inherit", "pipe"],
env,
});
const startScript = resolve(webDir, "dist-server", "start-all.js");
const child = spawn("node", [startScript], {
cwd: webDir,
stdio: ["inherit", "inherit", "pipe"],
env,
});

const stderrChunks: string[] = [];

Expand Down Expand Up @@ -108,10 +105,13 @@ export function registerDashboard(program: Command): void {
if (code !== 0 && code !== null && !opts.rebuild) {
const stderr = stderrChunks.join("");
if (looksLikeStaleBuild(stderr)) {
const recoveryCommand = isInstalledUnderNodeModules(webDir)
? "npm install -g @composio/ao@latest"
: "ao dashboard --rebuild";
console.error(
chalk.yellow(
"\nThis looks like a stale build cache issue. Try:\n\n" +
` ${chalk.cyan("ao dashboard --rebuild")}\n`,
` ${chalk.cyan(recoveryCommand)}\n`,
),
);
}
Expand All @@ -120,6 +120,7 @@ export function registerDashboard(program: Command): void {
process.exit(code ?? 0);
});
});
/* c8 ignore stop */
}

/**
Expand Down
Loading
Loading