diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 2c87399d9..b6ddc465c 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -107,9 +107,15 @@ import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx"; import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx"; import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx"; import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx"; +import { ProjectScreen, ProjectCommandNotImplementedScreen } from "../handlers/project/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; +// PROJECT_COMMANDS are the `agentcore project` subcommands that are listed in +// the menu but have no screen of their own yet. Each is routed explicitly so +// selecting it reports "not implemented" error +const PROJECT_COMMANDS = ["create", "add", "remove", "dev", "deploy", "status", "build"] as const; + export interface RootProps { // path is the command path to the executing node (e.g. "/agentcore"). path: string; @@ -736,6 +742,16 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/identity/oauth2-credential-provider/get/:name/json" element={} /> + } /> + {PROJECT_COMMANDS.map((command) => ( + + } + /> + ))} } /> diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 87def85c6..188f61224 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -50,7 +50,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createGatewayHandler(core, io)); root.handler(createEvalHandler(core, io)); root.handler(createConfigHandler()); - root.handler(createProjectHandler({ projectManager: core.projectManager, io })); + root.handler(createProjectHandler({ core, io })); // Invoking with no subcommand launches the interactive TUI. root.default(renderTui(core, io)); diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 774a2ca39..5ae74e1be 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -5,6 +5,8 @@ import { ContainerDevRunner } from "../../core/dev/container"; import { InspectorAssets } from "../../core/dev/inspectorAssets"; import { startOtelCollector } from "../../core/dev/otel/collector"; import { withProject } from "../../middleware"; +import { renderTui } from "../../tui"; +import type { Core } from "../types"; import { createCreateProjectHandler } from "./create"; import { createRemoveProjectHandler } from "./remove"; import { createDevProjectHandler } from "./dev"; @@ -16,16 +18,20 @@ import type { ProjectManager } from "./types"; import { createAddProjectResourceHandler } from "./add"; type ProjectHandlerConfig = { - projectManager: ProjectManager; + core: Core; io: AppIO; }; -export function createProjectHandler(config: ProjectHandlerConfig): Router { +export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router { + const projectManager: ProjectManager = core.projectManager; + const config = { projectManager, io }; const project = new Router("project", "manage an AgentCore project"); - project.handler( - createCreateProjectHandler({ projectManager: config.projectManager, io: config.io }), - ); + // Without a default, a bare `agentcore project` falls back to Commander's help + // and a usage exit code instead of the menu every sibling router opens. + project.default(renderTui(core, io)); + + project.handler(createCreateProjectHandler({ projectManager, io })); project.handler(createAddProjectResourceHandler(config)); project.handler( withProject({ projectManager: config.projectManager })( diff --git a/src/handlers/project/project.screen.test.tsx b/src/handlers/project/project.screen.test.tsx new file mode 100644 index 000000000..cadb9461f --- /dev/null +++ b/src/handlers/project/project.screen.test.tsx @@ -0,0 +1,158 @@ +import { test, expect, describe, afterEach } from "bun:test"; +import { + renderScreen, + waitForText, + cleanupScreens, + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, + ttyTestIO, +} from "../../testing"; +import { renderTuiAt } from "../../tui"; +import { InvalidEnvironmentError, NotImplementedError } from "../../errors"; +import { compile, ValueContext } from "../../router"; +import { ExitCode } from "../../runnable"; +import { createRootHandler } from "../index"; + +afterEach(cleanupScreens); + +// projectSubcommands reads the project group's children off the compiled +// Commander tree, so tests driven by it cover any subcommand added later. +function projectSubcommands(): string[] { + const root = compile( + createRootHandler(new TestCoreClient(), { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }), + ValueContext.EmptyContext(), + ); + const project = root.commands.find((command) => command.name() === "project")!; + // `help` is Commander's own, not one of ours. + return project.commands.map((command) => command.name()).filter((name) => name !== "help"); +} + +describe("project menu", () => { + test("lists every project subcommand", async () => { + const r = renderScreen("/agentcore/project"); + + await waitForText(r.lastFrame, "manage an AgentCore project"); + const frame = r.lastFrame()!; + for (const command of projectSubcommands()) { + expect(frame).toContain(command); + } + r.unmount(); + }); + + test("is reachable from the root menu", async () => { + const r = renderScreen("/agentcore"); + + await waitForText(r.lastFrame, "manage an AgentCore project"); + await r.write("project"); + await waitForText(r.lastFrame, "❯ project"); + await r.press("return"); + + await waitForText(r.lastFrame, "agentcore → project"); + expect(r.lastFrame()).toContain("create"); + r.unmount(); + }); + + test("esc returns to the root menu", async () => { + const r = renderScreen("/agentcore/project"); + + await waitForText(r.lastFrame, "agentcore → project"); + await r.press("escape"); + + await waitForText(r.lastFrame, "the platform for production AI agents"); + r.unmount(); + }); +}); + +describe("project subcommands without a screen", () => { + // renderTuiAt rather than renderScreen: ink-testing-library exposes no + // waitUntilExit, so it cannot observe the rejection under test. + // + // Reading the cases off the router also guards Root's hand-written + // PROJECT_COMMANDS: an unrouted subcommand hits the catch-all, which resolves + // instead of rejecting. Frames can't detect that — the catch-all exits before + // painting, so it and this screen both render empty. + test.each(projectSubcommands())( + "%s tears down the TUI with NotImplementedError", + async (command) => { + const { streams } = ttyTestIO(); + + const rendering = renderTuiAt( + `/agentcore/project/${command}`, + ValueContext.EmptyContext(), + new TestCoreClient(), + streams.io, + ); + + await expect(rendering).rejects.toThrow(NotImplementedError); + await expect(rendering).rejects.toThrow(`'agentcore project ${command}'`); + }, + ); + + test("the error names the command to run instead", async () => { + const { streams } = ttyTestIO(); + + const caught: unknown = await renderTuiAt( + "/agentcore/project/deploy", + ValueContext.EmptyContext(), + new TestCoreClient(), + streams.io, + ).then( + () => undefined, + (error: unknown) => error, + ); + + expect(caught).toBeInstanceOf(NotImplementedError); + const error = caught as NotImplementedError; + expect(error.message).toContain("agentcore project deploy --help"); + // Surfaces as a plain CLI failure, not a crash. + expect(error.exitCode).toBe(1); + }); +}); + +describe("agentcore project (no subcommand)", () => { + // Exercises the real CLI entrypoint; the screen tests mount a path directly + // and so never caught the missing default handler. + // + // Asserts renderTui's TTY guard rather than a rendered frame: Ink only writes + // frames incrementally when interactive (`!isInCi && isTTY`), so asserting on + // frames here would pass locally and time out under CI. Reaching the guard at + // all proves the group routed to the TUI — Commander help neither throws nor + // touches stderr. + test("routes to the TUI rather than printing Commander help", async () => { + const io = testIO(); + const root = createRootHandler(new TestCoreClient(), { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + const caught: unknown = await root.route(["node", "agentcore", "project"]).then( + () => undefined, + (error: unknown) => error, + ); + + expect(caught).toBeInstanceOf(InvalidEnvironmentError); + expect((caught as InvalidEnvironmentError).exitCode).toBe(ExitCode.USAGE); + expect(io.stdout()).toBe(""); + }); + + test("prints help instead of the TUI under --json", async () => { + const io = testIO(); + const root = createRootHandler(new TestCoreClient(), { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + await root.route(["node", "agentcore", "project", "--json"]); + + expect(io.stdout()).toContain("Usage:"); + expect(io.stdout()).toContain("create"); + }); +}); diff --git a/src/handlers/project/screen.tsx b/src/handlers/project/screen.tsx new file mode 100644 index 000000000..9b56c61e2 --- /dev/null +++ b/src/handlers/project/screen.tsx @@ -0,0 +1,37 @@ +import { useEffect } from "react"; +import { useApp } from "ink"; +import { RouterScreen } from "../../components/RouterScreen"; +import { NotImplementedError } from "../../errors"; +import type { ScreenProps } from "../types"; + +export function ProjectScreen(props: ScreenProps) { + return ; +} + +export interface ProjectCommandNotImplementedScreenProps extends ScreenProps { + // command is the project subcommand the user selected, e.g. "deploy". + command: string; +} + +// ProjectCommandNotImplementedScreen is the landing screen for a project +// subcommand that is listed in the menu but has no screen yet. +// +// exit(error) rejects the waitUntilExit() that renderTuiAt awaits, so the TUI +// tears down and the error takes the normal CLI path. Throwing during render +// would surface a React stack trace instead. +export function ProjectCommandNotImplementedScreen({ + command, +}: ProjectCommandNotImplementedScreenProps) { + const { exit } = useApp(); + + useEffect(() => { + exit( + new NotImplementedError( + `'agentcore project ${command}' has no interactive screen yet; ` + + `run 'agentcore project ${command} --help' to use it from the command line`, + ), + ); + }, [exit, command]); + + return null; +} diff --git a/src/testing/index.tsx b/src/testing/index.tsx index 548d3fd16..44de6961e 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -1,6 +1,6 @@ export { parse, stringify } from "./serialization"; export { fixtureFactories, fixtureFetch, isRecording, matchGolden, settle } from "./fixtures"; -export { testIO, type TestIO } from "./testIO"; +export { testIO, ttyTestIO, type TestIO, type TtyInput } from "./testIO"; export { tick, waitFor, WaitForTimeoutError } from "./timing"; export { TestCoreClient, diff --git a/src/testing/testIO.tsx b/src/testing/testIO.tsx index 527c32a57..ade5e0114 100644 --- a/src/testing/testIO.tsx +++ b/src/testing/testIO.tsx @@ -53,3 +53,32 @@ export function testIO({ isTTY = false, stdin: stdinContent }: TestIOOptions = { stderr: err.read, }; } + +// TtyInput is the writable stdin of a TTY TestIO: tests push raw key sequences +// into it to drive a mounted TUI. +export interface TtyInput extends NodeJS.ReadStream { + write(chunk: string): boolean; +} + +// ttyTestIO builds a TestIO that Ink accepts as a real terminal: isTTY, the +// no-op stdin methods Ink calls when it takes over, and a window size (Ink needs +// columns/rows to lay out frames). For tests that mount the TUI through +// renderTui/renderTuiAt rather than ink-testing-library. +export function ttyTestIO(columns = 100, rows = 40): { streams: TestIO; stdin: TtyInput } { + const streams = testIO({ isTTY: true }); + const stdin = streams.io.stdin as TtyInput; + stdin.setRawMode = function () { + return this; + }; + stdin.ref = function () { + return this; + }; + stdin.unref = function () { + return this; + }; + Object.defineProperties(streams.io.stdout, { + columns: { configurable: true, value: columns }, + rows: { configurable: true, value: rows }, + }); + return { streams, stdin }; +} diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index 6238ac6cc..97388848a 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -7,34 +7,12 @@ import { TestCoreClient, TestGlobalConfigAccessor, testIO, + ttyTestIO, tick, waitFor, } from "../testing"; import { ExitCode } from "../runnable"; -interface TtyInput extends NodeJS.ReadStream { - write(chunk: string): boolean; -} - -function ttyTestIO(): { streams: ReturnType; stdin: TtyInput } { - const streams = testIO({ isTTY: true }); - const stdin = streams.io.stdin as TtyInput; - stdin.setRawMode = function () { - return this; - }; - stdin.ref = function () { - return this; - }; - stdin.unref = function () { - return this; - }; - Object.defineProperties(streams.io.stdout, { - columns: { configurable: true, value: 100 }, - rows: { configurable: true, value: 40 }, - }); - return { streams, stdin }; -} - describe("renderJson", () => { test("pretty-prints a value as indented JSON to the given writer", () => { const lines: string[] = [];