diff --git a/README.md b/README.md index fd94c2170..b2a3be481 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,8 @@ It gives you two ways to work, from the same binary: - **A scriptable CLI** — every operation is a flag-driven subcommand that emits JSON (`--json`), so it can be used by codeing agents and can drop cleanly into scripts, CI, and automation. -- **An interactive TUI** — run a command with no arguments and it opens a - full-screen terminal app for browsing resources, filling in wizards, and - chatting with a live agent. +- **An interactive TUI** — bare Harness and Runtime branches and leaves open + their corresponding menus and selection flows. ```bash agentcore # launch the interactive TUI @@ -27,8 +26,8 @@ responses. `agentcore` wraps all of that behind one ergonomic tool. ## Command surface -Every leaf command runs headless with flags, or opens the matching TUI screen -when invoked bare. +Commands with operation flags run headlessly. Bare Harness and Runtime branches +and leaves open their interactive flows. ``` agentcore # interactive TUI @@ -117,6 +116,18 @@ agentcore identity api-key-credential-provider update --name my-provider --api-k agentcore identity api-key-credential-provider delete --name my-provider ``` +Bare Runtime branches and leaves require a TTY on stdin and stdout. Supplying +operation flags runs the command headlessly, and `--json` always suppresses TUI +rendering. + +```bash +agentcore runtime +agentcore runtime list +agentcore runtime get +agentcore runtime version list +agentcore runtime endpoint list +``` + --- # Architecture & patterns diff --git a/src/components/EndpointPicker.tsx b/src/components/EndpointPicker.tsx deleted file mode 100644 index 9231093ef..000000000 --- a/src/components/EndpointPicker.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { Text, useWindowSize } from "ink"; -import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; -import type { HarnessEndpoint } from "@aws-sdk/client-bedrock-agentcore-control"; -import { DataTable } from "./ui/data-table"; -import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; -import { usePagedList } from "./usePagedList"; -import { Spinner } from "./ui/spinner"; -import { Layout } from "./Layout"; -import { darkTheme } from "./ui/_core.js"; - -// EndpointRow is the flat, display-ready shape the table renders. -interface EndpointRow extends Record { - endpointName: string; - liveVersion: string; - targetVersion: string; - status: string; - updatedAt: string; -} - -function toRow(e: HarnessEndpoint): EndpointRow { - return { - endpointName: e.endpointName!, - liveVersion: e.liveVersion ?? "-", - targetVersion: e.targetVersion ?? "-", - status: e.status!, - updatedAt: e.updatedAt!.toISOString(), - }; -} - -export interface EndpointPickerProps extends ScreenProps { - // harnessId scopes the listing to one harness's endpoints. - harnessId: string; - // breadcrumb labels the screen the picker is serving. - breadcrumb: string[]; - // description tells the user what selecting an endpoint will do. - description?: string; - // onSelect receives the chosen endpoint's name. - onSelect: (endpointName: string) => void; - // onEscape overrides what esc does (default: pop back in history). Hosts - // that embed the picker as an overlay (e.g. the chat's ctrl+t endpoint - // switch) pass a closer instead. - onEscape?: () => void; -} - -// EndpointPicker fetches a harness's endpoints and renders them as a navigable -// table — the endpoint counterpart of HarnessPicker, shared by every "pick an -// endpoint" screen (list, update, delete). Esc pops back. -export function EndpointPicker({ - ctx, - core, - harnessId, - breadcrumb, - description, - onSelect, - onEscape, -}: EndpointPickerProps) { - const opts = coreOptsFromCtx(ctx); - const { columns } = useWindowSize(); - const navigate = useNavigate(); - const paging = usePagedList(); - - const list = useQuery({ - queryKey: ["harness-endpoints", opts.region, harnessId, paging.pageSize, paging.token], - queryFn: () => - core.harness.listHarnessEndpoints(harnessId, paging.token, paging.pageSize, opts), - placeholderData: keepPreviousData, - }); - - const nextToken = list.data?.nextToken; - // Pagination surfaces only once a response reports more pages (nextToken). - const paginated = paging.pageIndex > 0 || nextToken !== undefined; - - return ( - - {list.isPending ? ( - - ) : list.isError ? ( - Error: {(list.error as Error).message} - ) : !paginated && (list.data.endpoints ?? []).length === 0 ? ( - This harness has no endpoints yet. - ) : ( - <> - { - if (row.endpointName) onSelect(row.endpointName); - }} - onEscape={onEscape ?? (() => navigate(-1))} - onPrevPage={paging.pageIndex > 0 ? paging.prev : undefined} - onNextPage={nextToken ? () => paging.next(nextToken) : undefined} - /> - {paginated && ( - - page {paging.pageIndex + 1} - {nextToken ? " · more →" : ""} - - )} - - )} - - ); -} diff --git a/src/components/HarnessEndpointPicker.tsx b/src/components/HarnessEndpointPicker.tsx new file mode 100644 index 000000000..4ccd78d0d --- /dev/null +++ b/src/components/HarnessEndpointPicker.tsx @@ -0,0 +1,89 @@ +import { useNavigate } from "react-router"; +import type { HarnessEndpoint } from "@aws-sdk/client-bedrock-agentcore-control"; +import type { ScreenProps } from "../handlers/types"; +import { coreOptsFromCtx } from "../handlers/utils"; +import { PaginatedTablePicker } from "./PaginatedTablePicker"; + +// EndpointRow is the flat, display-ready shape the table renders. +interface EndpointRow extends Record { + endpointName: string; + liveVersion: string; + targetVersion: string; + status: string; + updatedAt: string; +} + +function toRow(e: HarnessEndpoint): EndpointRow { + return { + endpointName: e.endpointName!, + liveVersion: e.liveVersion ?? "-", + targetVersion: e.targetVersion ?? "-", + status: e.status!, + updatedAt: e.updatedAt!.toISOString(), + }; +} + +export interface HarnessEndpointPickerProps extends ScreenProps { + // harnessId scopes the listing to one harness's endpoints. + harnessId: string; + // breadcrumb labels the screen the picker is serving. + breadcrumb: string[]; + // description tells the user what selecting an endpoint will do. + description?: string; + // onSelect receives the chosen endpoint's name. + onSelect: (endpointName: string) => void; + // onEscape overrides what esc does (default: pop back in history). Hosts + // that embed the picker as an overlay (e.g. the chat's ctrl+t endpoint + // switch) pass a closer instead. + onEscape?: () => void; +} + +/** + * Fetches a harness's endpoints and renders them as a navigable table. + * + * This is the endpoint counterpart of HarnessPicker, shared by every "pick an + * endpoint" screen (list, update, delete). Esc pops back. + */ +export function HarnessEndpointPicker({ + ctx, + core, + harnessId, + breadcrumb, + description, + onSelect, + onEscape, +}: HarnessEndpointPickerProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const goBack = onEscape ?? (() => navigate(-1)); + + return ( + { + const response = await core.harness.listHarnessEndpoints(harnessId, token, pageSize, opts); + return { + items: response.endpoints ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={[ + { key: "endpointName", header: "name" }, + { key: "liveVersion", header: "live" }, + { key: "targetVersion", header: "target" }, + { key: "status", header: "status" }, + { key: "updatedAt", header: "updatedAt" }, + ]} + getValue={(row) => row.endpointName} + onSelect={onSelect} + onBack={goBack} + loadingMessage="Loading endpoints…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="This harness has no endpoints yet." + emptyPageMessage={`No endpoints on this page for harness ${harnessId}.`} + /> + ); +} diff --git a/src/components/HarnessPicker.tsx b/src/components/HarnessPicker.tsx index f5042b8e7..3b4d5f3dd 100644 --- a/src/components/HarnessPicker.tsx +++ b/src/components/HarnessPicker.tsx @@ -1,14 +1,8 @@ -import { Text, useWindowSize } from "ink"; -import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { useNavigate } from "react-router"; import type { HarnessSummary } from "@aws-sdk/client-bedrock-agentcore-control"; -import { DataTable } from "./ui/data-table"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; -import { usePagedList } from "./usePagedList"; -import { Spinner } from "./ui/spinner"; -import { Layout } from "./Layout"; -import { darkTheme } from "./ui/_core.js"; +import { PaginatedTablePicker } from "./PaginatedTablePicker"; // HarnessRow is the flat, display-ready shape the table renders. It also satisfies // DataTable's `T extends Record` constraint, which the SDK's @@ -43,11 +37,13 @@ export interface HarnessPickerProps extends ScreenProps { onSelect: (harnessId: string) => void; } -// HarnessPicker fetches the caller's harnesses and renders them as a navigable -// table. It is the shared body of every "pick a harness" screen (list, invoke); -// hosts differ only in breadcrumb, subtitle, and what selection does. Esc -// returns to the parent menu, derived from the breadcrumb (e.g. the endpoint -// menu for [..., "endpoint", "list"]). +/** + * Fetches the caller's harnesses and renders them as a navigable table. + * + * This is the shared body of every "pick a harness" screen (list, invoke); + * hosts differ only in breadcrumb, subtitle, and what selection does. Esc + * returns to the parent menu derived from the breadcrumb. + */ export function HarnessPicker({ ctx, core, @@ -56,69 +52,35 @@ export function HarnessPicker({ onSelect, }: HarnessPickerProps) { const opts = coreOptsFromCtx(ctx); - const { columns } = useWindowSize(); const navigate = useNavigate(); - const paging = usePagedList(); - - const list = useQuery({ - queryKey: ["harnesses", opts.region, paging.pageSize, paging.token], - queryFn: () => core.harness.listHarnesses(paging.token, paging.pageSize, opts), - placeholderData: keepPreviousData, - }); - - const nextToken = list.data?.nextToken; - // Pagination surfaces only once a response reports more pages (nextToken). - const paginated = paging.pageIndex > 0 || nextToken !== undefined; + const goBack = () => navigate("/" + breadcrumb.slice(0, -1).join("/")); return ( - { + const response = await core.harness.listHarnesses(token, pageSize, opts); + return { + items: response.harnesses ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={[ + { key: "harnessName", header: "name" }, + { key: "harnessVersion", header: "version" }, + { key: "status", header: "status" }, + { key: "updatedAt", header: "updatedAt" }, ]} - > - {list.isPending ? ( - - ) : list.isError ? ( - Error: {(list.error as Error).message} - ) : ( - <> - { - if (row.harnessId) onSelect(row.harnessId); - }} - onEscape={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))} - onPrevPage={paging.pageIndex > 0 ? paging.prev : undefined} - onNextPage={nextToken ? () => paging.next(nextToken) : undefined} - /> - {paginated && ( - - page {paging.pageIndex + 1} - {nextToken ? " · more →" : ""} - - )} - - )} - + getValue={(row) => row.harnessId} + onSelect={onSelect} + onBack={goBack} + loadingMessage="Loading harnesses…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No harnesses found." + emptyPageMessage="No harnesses on this page." + /> ); } diff --git a/src/components/HarnessVersionPicker.tsx b/src/components/HarnessVersionPicker.tsx new file mode 100644 index 000000000..f4e6a2a8f --- /dev/null +++ b/src/components/HarnessVersionPicker.tsx @@ -0,0 +1,82 @@ +import { useNavigate } from "react-router"; +import type { HarnessVersionSummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import type { ScreenProps } from "../handlers/types"; +import { coreOptsFromCtx } from "../handlers/utils"; +import { PaginatedTablePicker } from "./PaginatedTablePicker"; + +// VersionRow is the flat, display-ready shape the table renders. +interface VersionRow extends Record { + harnessVersion: string; + status: string; + createdAt: string; + updatedAt: string; +} + +function toRow(v: HarnessVersionSummary): VersionRow { + return { + harnessVersion: v.harnessVersion!, + status: v.status!, + createdAt: v.createdAt!.toISOString(), + updatedAt: v.updatedAt!.toISOString(), + }; +} + +export interface HarnessVersionPickerProps extends ScreenProps { + // harnessId scopes the listing to one harness's versions. + harnessId: string; + // breadcrumb labels the screen the picker is serving. + breadcrumb: string[]; + // description tells the user what selecting a version will do. + description?: string; + // onSelect receives the chosen version (e.g. "2"). + onSelect: (version: string) => void; +} + +/** + * Fetches a harness's versions and renders them as a navigable table, newest first. + * + * Esc pops back. + */ +export function HarnessVersionPicker({ + ctx, + core, + harnessId, + breadcrumb, + description, + onSelect, +}: HarnessVersionPickerProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const goBack = () => navigate(-1); + + return ( + { + const response = await core.harness.listHarnessVersions(harnessId, token, pageSize, opts); + return { + items: response.harnessVersions ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={[ + { key: "harnessVersion", header: "version" }, + { key: "status", header: "status" }, + { key: "createdAt", header: "createdAt" }, + ]} + sortRows={(rows) => + [...rows].sort((left, right) => Number(right.harnessVersion) - Number(left.harnessVersion)) + } + getValue={(row) => row.harnessVersion} + onSelect={onSelect} + onBack={goBack} + loadingMessage="Loading versions…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No versions found." + emptyPageMessage={`No versions on this page for harness ${harnessId}.`} + /> + ); +} diff --git a/src/components/JsonDetail.tsx b/src/components/JsonDetail.tsx index fbfe98854..84e61582e 100644 --- a/src/components/JsonDetail.tsx +++ b/src/components/JsonDetail.tsx @@ -16,19 +16,32 @@ export interface JsonDetailProps { data: unknown; // loadingLabel names what's loading (e.g. "Loading endpoint…"). loadingLabel: string; + onRetry?: () => void; } // JsonDetail is the shared "show me the raw resource" screen body: a scrollable // pretty-printed JSON block framed by the standard Layout. Every harness // resource (harness, version, endpoint) gets the same detail treatment. Esc pops // back to wherever the user came from; jk/arrows scroll. -export function JsonDetail({ breadcrumb, isPending, error, data, loadingLabel }: JsonDetailProps) { +export function JsonDetail({ + breadcrumb, + isPending, + error, + data, + loadingLabel, + onRetry, +}: JsonDetailProps) { const navigate = useNavigate(); const scrollRef = useRef(null); useInput((input, key) => { if (key.escape) { navigate(-1); + return; + } + if (input === "r" && error && onRetry) { + onRetry(); + return; } if (key.upArrow || input === "k") { scrollRef.current?.scrollBy(-1); @@ -43,6 +56,7 @@ export function JsonDetail({ breadcrumb, isPending, error, data, loadingLabel }: breadcrumb={breadcrumb} keyHints={[ { key: "↑↓/kj", label: "navigate" }, + ...(error && onRetry ? [{ key: "r", label: "retry" }] : []), { key: "esc", label: "back" }, { key: "ctl+c", label: "quit" }, ]} diff --git a/src/components/PaginatedTablePicker.test.tsx b/src/components/PaginatedTablePicker.test.tsx new file mode 100644 index 000000000..d14df1fe0 --- /dev/null +++ b/src/components/PaginatedTablePicker.test.tsx @@ -0,0 +1,290 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + HarnessSummary, + ListHarnessesResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { QueryClient } from "@tanstack/react-query"; +import { cleanupScreens, renderScreen, TestCoreClient, waitFor, waitForText } from "../testing"; + +afterEach(cleanupScreens); + +function harness(overrides: Partial = {}): HarnessSummary { + return { + harnessId: "MyHarness-abc123", + harnessName: "MyHarness", + arn: "arn:aws:bedrock-agentcore:us-east-1:123:harness/MyHarness-abc123", + createdAt: new Date("2026-04-22T21:53:06.235Z"), + updatedAt: new Date("2026-04-22T21:53:27.062Z"), + harnessVersion: "1", + status: "READY", + ...overrides, + }; +} + +function coreWith(harnesses: HarnessSummary[]): TestCoreClient { + const core = new TestCoreClient(); + core.harness.setListResponse({ harnesses }); + return core; +} + +function getResponse(summary: HarnessSummary) { + return { harness: summary } as Parameters[0]; +} + +describe("paginated table picker contract", () => { + test("retries a failed query", async () => { + const core = new TestCoreClient(); + core.harness.setError(new Error("access denied")); + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "access denied"); + expect(r.lastFrame()).toContain("[r] retry"); + + core.harness.setError(undefined); + core.harness.setListResponse({ + harnesses: [harness({ harnessName: "recovered" })], + }); + await r.write("r"); + await waitForText(r.lastFrame, "recovered"); + }); + + test("returns to the previous page after a later-page query fails", async () => { + const core = new TestCoreClient(); + core.harness.setListResponse({ + harnesses: [harness({ harnessName: "page-one" })], + nextToken: "t2", + }); + const listHarnesses = core.harness.listHarnesses.bind(core.harness); + core.harness.listHarnesses = async (...args) => { + if (args[0] === "t2") throw new Error("page unavailable"); + return listHarnesses(...args); + }; + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "page unavailable"); + expect(r.lastFrame()).toContain("[←/h] previous page"); + + await r.write("h"); + await waitForText(r.lastFrame, "page-one"); + expect(core.harness.calls.at(-1)?.args[0]).toBeUndefined(); + }); + + test("keeps Escape active while loading", async () => { + const core = new TestCoreClient(); + const pending = Promise.withResolvers(); + core.harness.listHarnesses = async () => pending.promise; + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "Loading harnesses"); + await r.press("escape"); + await waitForText(r.lastFrame, "manage agentcore harnesses"); + }); + + test("keeps Escape active after a query fails", async () => { + const core = new TestCoreClient(); + core.harness.setError(new Error("access denied")); + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "access denied"); + await r.press("escape"); + await waitForText(r.lastFrame, "manage agentcore harnesses"); + }); + + test("distinguishes first-page and later-page empty states", async () => { + const firstPage = renderScreen("/agentcore/harness/list"); + await waitForText(firstPage.lastFrame, "No harnesses found."); + expect(firstPage.lastFrame()).not.toContain("page 1"); + await firstPage.press("escape"); + await waitForText(firstPage.lastFrame, "manage agentcore harnesses"); + firstPage.unmount(); + + const core = new TestCoreClient(); + core.harness.setListResponse({ + harnesses: [harness({ harnessName: "page-one" })], + nextToken: "t2", + }); + core.harness.setListResponse({ harnesses: [] }, "t2"); + const laterPage = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(laterPage.lastFrame, "page 1 · more →"); + await laterPage.write("l"); + await waitForText(laterPage.lastFrame, "No harnesses on this page."); + expect(laterPage.lastFrame()).toContain("page 2"); + expect(laterPage.lastFrame()).not.toContain("No harnesses found."); + }); + + test("pages forward and backward using token history", async () => { + const core = new TestCoreClient(); + const first = harness({ harnessName: "page-one-first", harnessId: "page-one-first" }); + core.harness.setListResponse({ + harnesses: [first, harness({ harnessName: "page-one-second", harnessId: "page-one-second" })], + nextToken: "t2", + }); + core.harness.setListResponse( + { + harnesses: [ + harness({ harnessName: "page-two-first", harnessId: "page-two-first" }), + harness({ harnessName: "page-two-second", harnessId: "page-two-second" }), + ], + }, + "t2", + ); + core.harness.setGetResponse(getResponse(first)); + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + expect(r.lastFrame()).toContain("[←→/hl] page"); + expect(core.harness.calls.filter((call) => call.method === "listHarnesses")).toEqual([ + { + method: "listHarnesses", + args: [undefined, 32, { region: "us-east-1", endpointUrl: undefined }], + }, + ]); + await r.press("down"); + await waitForText(r.lastFrame, "❯ page-one-second"); + await r.write("l"); + await waitForText(r.lastFrame, "❯ page-two-first"); + expect(core.harness.calls.at(-1)).toEqual({ + method: "listHarnesses", + args: ["t2", 32, { region: "us-east-1", endpointUrl: undefined }], + }); + + await r.press("down"); + await waitForText(r.lastFrame, "❯ page-two-second"); + await r.write("h"); + await waitForText(r.lastFrame, "❯ page-one-first"); + expect(r.lastFrame()).toContain("page 1"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → harness → get → page-one-first"); + expect( + core.harness.calls.some( + (call) => call.method === "getHarness" && call.args[0] === "page-one-second", + ), + ).toBe(false); + }); + + test("retains rows and disables selection and paging during a transition", async () => { + const core = new TestCoreClient(); + core.harness.setListResponse({ + harnesses: [harness({ harnessName: "stable-page-one", harnessId: "stable-1" })], + nextToken: "t2", + }); + core.harness.setListResponse( + { harnesses: [harness({ harnessName: "settled-page-two", harnessId: "settled-2" })] }, + "t2", + ); + const nextPage = Promise.withResolvers(); + const previousPage = Promise.withResolvers(); + let holdFirstPage = false; + const listHarnesses = core.harness.listHarnesses.bind(core.harness); + core.harness.listHarnesses = async (...args) => { + const response = await listHarnesses(...args); + if (args[0] === "t2") await nextPage.promise; + if (args[0] === undefined && holdFirstPage) await previousPage.promise; + return response; + }; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity, staleTime: 0 }, + }, + }); + const r = renderScreen("/agentcore/harness/list", { core, queryClient }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "loading page 2…"); + expect(r.lastFrame()).toContain("stable-page-one"); + + const callsDuringTransition = core.harness.calls.length; + await r.press("return"); + await r.write("l"); + await r.write("h"); + expect(core.harness.calls).toHaveLength(callsDuringTransition); + expect(core.harness.calls.some((call) => call.method === "getHarness")).toBe(false); + + nextPage.resolve(); + await waitForText(r.lastFrame, "settled-page-two"); + + holdFirstPage = true; + await r.write("h"); + await waitForText(r.lastFrame, "loading page 1…"); + expect(r.lastFrame()).toContain("stable-page-one"); + + const callsDuringRefetch = core.harness.calls.length; + await r.press("return"); + await r.write("l"); + expect(core.harness.calls).toHaveLength(callsDuringRefetch); + expect(core.harness.calls.some((call) => call.method === "getHarness")).toBe(false); + + await r.press("escape"); + await waitForText(r.lastFrame, "manage agentcore harnesses"); + previousPage.resolve(); + }); + + test("resizing resets pagination and selection", async () => { + const first = harness({ harnessName: "page-one-first", harnessId: "page-one-first" }); + const core = new TestCoreClient(); + core.harness.setListResponse({ + harnesses: [first, harness({ harnessName: "page-one-second", harnessId: "page-one-second" })], + nextToken: "t2", + }); + core.harness.setListResponse( + { + harnesses: [ + harness({ harnessName: "page-two-first", harnessId: "page-two-first" }), + harness({ harnessName: "page-two-second", harnessId: "page-two-second" }), + ], + }, + "t2", + ); + core.harness.setGetResponse(getResponse(first)); + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "page-two-second"); + await r.press("down"); + await waitForText(r.lastFrame, "❯ page-two-second"); + + await r.resize(100, 20); + await waitForText(r.lastFrame, "page-one-first"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → harness → get → page-one-first"); + expect( + core.harness.calls.some( + (call) => call.method === "getHarness" && call.args[0] === "page-one-second", + ), + ).toBe(false); + }); + + test("pasted filter input does not page and resets selection to its first match", async () => { + const alpha = harness({ harnessName: "alpha", harnessId: "alpha-1" }); + const beta = harness({ harnessName: "beta", harnessId: "beta-2" }); + const core = coreWith([alpha, beta]); + core.harness.setListResponse({ harnesses: [alpha, beta], nextToken: "t2" }); + core.harness.setGetResponse(getResponse(alpha)); + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "beta"); + await r.press("down"); + await r.write("/"); + await r.write("missing"); + await waitForText(r.lastFrame, "/ Filter: missing"); + await r.press("escape"); + await r.write("/"); + await r.write("alpha"); + await waitForText(r.lastFrame, "/ Filter: alpha"); + expect( + core.harness.calls.some((call) => call.method === "listHarnesses" && call.args[0] === "t2"), + ).toBe(false); + + await r.press("return"); + await r.press("return"); + await waitFor(() => + core.harness.calls.some((call) => call.method === "getHarness" && call.args[0] === "alpha-1"), + ); + expect(r.lastFrame()).toContain("agentcore → harness → get → alpha-1"); + }); +}); diff --git a/src/components/PaginatedTablePicker.tsx b/src/components/PaginatedTablePicker.tsx new file mode 100644 index 000000000..3149c65db --- /dev/null +++ b/src/components/PaginatedTablePicker.tsx @@ -0,0 +1,131 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { Text, useInput } from "ink"; +import { Layout } from "./Layout"; +import { usePagedList } from "./usePagedList"; +import { darkTheme } from "./ui/_core.js"; +import { DataTable, type DataTableColumn } from "./ui/data-table"; +import { Spinner } from "./ui/spinner"; + +export interface TokenPage { + items: TItem[]; + nextToken?: string; +} + +export interface PaginatedTablePickerProps> { + breadcrumb: string[]; + description?: string; + queryKey: readonly unknown[]; + loadPage: (token: string | undefined, pageSize: number) => Promise>; + toRow: (item: TItem) => TRow; + columns: DataTableColumn[]; + sortRows?: (rows: TRow[]) => TRow[]; + getValue: (row: TRow) => string | undefined; + onSelect: (value: string) => void; + onBack: () => void; + loadingMessage: string; + errorMessage: (error: Error) => string; + emptyMessage: string; + emptyPageMessage: string; +} + +export function PaginatedTablePicker>({ + breadcrumb, + description, + queryKey, + loadPage, + toRow, + columns, + sortRows, + getValue, + onSelect, + onBack, + loadingMessage, + errorMessage, + emptyMessage, + emptyPageMessage, +}: PaginatedTablePickerProps) { + const paging = usePagedList(); + const list = useQuery({ + queryKey: [...queryKey, paging.pageSize, paging.token], + queryFn: () => loadPage(paging.token, paging.pageSize), + placeholderData: keepPreviousData, + }); + const nextToken = list.data?.nextToken; + const paginated = paging.pageIndex > 0 || nextToken !== undefined; + const pageTransition = list.isFetching && !list.isPending; + const mappedRows = (list.data?.items ?? []).map(toRow); + const rows = sortRows ? sortRows(mappedRows) : mappedRows; + + useInput( + (input, key) => { + if (key.escape) { + onBack(); + return; + } + if (list.isError && paging.pageIndex > 0 && (key.leftArrow || input === "h")) { + paging.prev(); + return; + } + if (input === "r" && list.isError) void list.refetch(); + }, + { isActive: list.isPending || list.isError || pageTransition }, + ); + + return ( + 0 ? [{ key: "←/h", label: "previous page" }] : []), + ...(list.isError ? [{ key: "r", label: "retry" }] : []), + { key: "esc", label: "back" }, + { key: "ctl+c", label: "quit" }, + ]} + > + {list.isPending ? ( + + ) : list.isError ? ( + {errorMessage(list.error as Error)} + ) : ( + <> + { + const value = getValue(row); + if (value) onSelect(value); + }} + onEscape={onBack} + onPrevPage={!pageTransition && paging.pageIndex > 0 ? paging.prev : undefined} + onNextPage={!pageTransition && nextToken ? () => paging.next(nextToken) : undefined} + /> + {(paginated || pageTransition) && ( + + {pageTransition + ? `loading page ${paging.pageIndex + 1}…` + : `page ${paging.pageIndex + 1}${nextToken ? " · more →" : ""}`} + + )} + + )} + + ); +} diff --git a/src/components/Root.tsx b/src/components/Root.tsx index ef8af5bf9..5d59130e0 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -19,6 +19,15 @@ import { HarnessDeleteEndpointScreen } from "../handlers/harness/endpoint/delete import { HarnessVersionScreen } from "../handlers/harness/version/screen.tsx"; import { HarnessGetVersionScreen } from "../handlers/harness/version/get/screen.tsx"; import { HarnessListVersionsScreen } from "../handlers/harness/version/list/screen.tsx"; +import { RuntimeScreen } from "../handlers/runtime/screen.tsx"; +import { RuntimeGetJsonScreen, RuntimeGetScreen } from "../handlers/runtime/get/screen.tsx"; +import { RuntimeListScreen } from "../handlers/runtime/list/screen.tsx"; +import { RuntimeEndpointScreen } from "../handlers/runtime/endpoint/screen.tsx"; +import { RuntimeGetEndpointScreen } from "../handlers/runtime/endpoint/get/screen.tsx"; +import { RuntimeListEndpointsScreen } from "../handlers/runtime/endpoint/list/screen.tsx"; +import { RuntimeVersionScreen } from "../handlers/runtime/version/screen.tsx"; +import { RuntimeGetVersionScreen } from "../handlers/runtime/version/get/screen.tsx"; +import { RuntimeListVersionsScreen } from "../handlers/runtime/version/list/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -189,6 +198,63 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/harness/version/list/:harnessId" element={} /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> } /> diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index 2de9f30f0..0fe9c235c 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -1,5 +1,5 @@ import { test, expect, describe, afterEach } from "bun:test"; -import { renderScreen, waitForText, cleanupScreens, tick } from "../testing"; +import { cleanupScreens, renderScreen, tick, waitForText } from "../testing"; afterEach(cleanupScreens); @@ -137,15 +137,4 @@ describe("navigation", () => { expect(r.lastFrame()).toContain("❯ harness"); r.unmount(); }); - - test("ctrl+c is handled (quit) without crashing", async () => { - const r = renderScreen("/agentcore"); - await waitForText(r.lastFrame, "❯ harness"); - - // ctrl+c is 0x03; the menu's handler calls exit(), which unmounts the app. - // Driving the branch must not throw; after exit the renderer stops updating. - await r.write(String.fromCharCode(3)); - await tick(20); - r.unmount(); - }); }); diff --git a/src/components/RuntimeEndpointPicker.tsx b/src/components/RuntimeEndpointPicker.tsx new file mode 100644 index 000000000..509d5ca57 --- /dev/null +++ b/src/components/RuntimeEndpointPicker.tsx @@ -0,0 +1,73 @@ +import type { AgentRuntimeEndpoint } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import type { ScreenProps } from "../handlers/types"; +import { coreOptsFromCtx } from "../handlers/utils"; +import { PaginatedTablePicker } from "./PaginatedTablePicker"; + +interface RuntimeEndpointRow extends Record { + qualifier: string; + liveVersion: string; + targetVersion: string; + status: string; + lastUpdatedAt: string; +} + +function toRow(endpoint: AgentRuntimeEndpoint): RuntimeEndpointRow { + return { + qualifier: endpoint.name ?? endpoint.id ?? "", + liveVersion: endpoint.liveVersion ?? "-", + targetVersion: endpoint.targetVersion ?? "-", + status: endpoint.status ?? "-", + lastUpdatedAt: endpoint.lastUpdatedAt?.toISOString() ?? "-", + }; +} + +export interface RuntimeEndpointPickerProps extends ScreenProps { + runtimeId: string; + breadcrumb: string[]; + description?: string; + onSelect: (qualifier: string) => void; +} + +export function RuntimeEndpointPicker({ + ctx, + core, + runtimeId, + breadcrumb, + description, + onSelect, +}: RuntimeEndpointPickerProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const goBack = () => navigate(-1); + + return ( + { + const response = await core.runtime.listRuntimeEndpoints(runtimeId, token, pageSize, opts); + return { + items: response.runtimeEndpoints ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={[ + { key: "qualifier", header: "qualifier" }, + { key: "liveVersion", header: "live" }, + { key: "targetVersion", header: "target" }, + { key: "status", header: "status" }, + { key: "lastUpdatedAt", header: "lastUpdatedAt" }, + ]} + getValue={(row) => row.qualifier} + onSelect={onSelect} + onBack={goBack} + loadingMessage={`Loading endpoints for Runtime ${runtimeId}…`} + errorMessage={(error) => `Error loading endpoints for Runtime ${runtimeId}: ${error.message}`} + emptyMessage="This Runtime has no endpoints." + emptyPageMessage={`No endpoints on this page for Runtime ${runtimeId}.`} + /> + ); +} diff --git a/src/components/RuntimePicker.tsx b/src/components/RuntimePicker.tsx new file mode 100644 index 000000000..30cb87301 --- /dev/null +++ b/src/components/RuntimePicker.tsx @@ -0,0 +1,72 @@ +import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import type { ScreenProps } from "../handlers/types"; +import { coreOptsFromCtx } from "../handlers/utils"; +import { PaginatedTablePicker } from "./PaginatedTablePicker"; + +interface RuntimeRow extends Record { + runtimeId: string; + runtimeName: string; + runtimeVersion: string; + status: string; + lastUpdatedAt: string; +} + +function toRow(runtime: AgentRuntime): RuntimeRow { + const runtimeId = runtime.agentRuntimeId ?? ""; + return { + runtimeId, + runtimeName: runtime.agentRuntimeName ?? runtimeId, + runtimeVersion: runtime.agentRuntimeVersion ?? "-", + status: runtime.status ?? "-", + lastUpdatedAt: runtime.lastUpdatedAt?.toISOString() ?? "-", + }; +} + +export interface RuntimePickerProps extends ScreenProps { + breadcrumb: string[]; + description?: string; + onSelect: (runtimeId: string) => void; +} + +export function RuntimePicker({ + ctx, + core, + breadcrumb, + description, + onSelect, +}: RuntimePickerProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const goBack = () => navigate("/" + breadcrumb.slice(0, -1).join("/")); + + return ( + { + const response = await core.runtime.listRuntimes(token, pageSize, opts); + return { + items: response.agentRuntimes ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={[ + { key: "runtimeName", header: "name" }, + { key: "runtimeId", header: "id" }, + { key: "runtimeVersion", header: "latestVersion" }, + { key: "status", header: "status" }, + { key: "lastUpdatedAt", header: "lastUpdatedAt" }, + ]} + getValue={(row) => row.runtimeId} + onSelect={onSelect} + onBack={goBack} + loadingMessage="Loading Runtimes…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No Runtimes found in this Region." + emptyPageMessage="No Runtimes on this page." + /> + ); +} diff --git a/src/components/RuntimeVersionPicker.tsx b/src/components/RuntimeVersionPicker.tsx new file mode 100644 index 000000000..ba0ede080 --- /dev/null +++ b/src/components/RuntimeVersionPicker.tsx @@ -0,0 +1,70 @@ +import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import type { ScreenProps } from "../handlers/types"; +import { coreOptsFromCtx } from "../handlers/utils"; +import { PaginatedTablePicker } from "./PaginatedTablePicker"; + +interface RuntimeVersionRow extends Record { + version: string; + status: string; + lastUpdatedAt: string; +} + +function toRow(runtime: AgentRuntime): RuntimeVersionRow { + return { + version: runtime.agentRuntimeVersion ?? "", + status: runtime.status ?? "-", + lastUpdatedAt: runtime.lastUpdatedAt?.toISOString() ?? "-", + }; +} + +export interface RuntimeVersionPickerProps extends ScreenProps { + runtimeId: string; + breadcrumb: string[]; + description?: string; + onSelect: (version: string) => void; +} + +export function RuntimeVersionPicker({ + ctx, + core, + runtimeId, + breadcrumb, + description, + onSelect, +}: RuntimeVersionPickerProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const goBack = () => navigate(-1); + + return ( + { + const response = await core.runtime.listRuntimeVersions(runtimeId, token, pageSize, opts); + return { + items: response.agentRuntimes ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={[ + { key: "version", header: "version" }, + { key: "status", header: "status" }, + { key: "lastUpdatedAt", header: "lastUpdatedAt" }, + ]} + sortRows={(rows) => + [...rows].sort((left, right) => Number(right.version) - Number(left.version)) + } + getValue={(row) => row.version} + onSelect={onSelect} + onBack={goBack} + loadingMessage={`Loading versions for Runtime ${runtimeId}…`} + errorMessage={(error) => `Error loading versions for Runtime ${runtimeId}: ${error.message}`} + emptyMessage={`No versions found for Runtime ${runtimeId}.`} + emptyPageMessage={`No versions on this page for Runtime ${runtimeId}.`} + /> + ); +} diff --git a/src/components/VersionPicker.tsx b/src/components/VersionPicker.tsx deleted file mode 100644 index 1d38f5839..000000000 --- a/src/components/VersionPicker.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { Text, useWindowSize } from "ink"; -import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; -import type { HarnessVersionSummary } from "@aws-sdk/client-bedrock-agentcore-control"; -import { DataTable } from "./ui/data-table"; -import type { ScreenProps } from "../handlers/types"; -import { coreOptsFromCtx } from "../handlers/utils"; -import { usePagedList } from "./usePagedList"; -import { Spinner } from "./ui/spinner"; -import { Layout } from "./Layout"; -import { darkTheme } from "./ui/_core.js"; - -// VersionRow is the flat, display-ready shape the table renders. -interface VersionRow extends Record { - harnessVersion: string; - status: string; - createdAt: string; - updatedAt: string; -} - -function toRow(v: HarnessVersionSummary): VersionRow { - return { - harnessVersion: v.harnessVersion!, - status: v.status!, - createdAt: v.createdAt!.toISOString(), - updatedAt: v.updatedAt!.toISOString(), - }; -} - -export interface VersionPickerProps extends ScreenProps { - // harnessId scopes the listing to one harness's versions. - harnessId: string; - // breadcrumb labels the screen the picker is serving. - breadcrumb: string[]; - // description tells the user what selecting a version will do. - description?: string; - // onSelect receives the chosen version (e.g. "2"). - onSelect: (version: string) => void; -} - -// VersionPicker fetches a harness's versions and renders them as a navigable -// table, newest first. Esc pops back. -export function VersionPicker({ - ctx, - core, - harnessId, - breadcrumb, - description, - onSelect, -}: VersionPickerProps) { - const opts = coreOptsFromCtx(ctx); - const { columns } = useWindowSize(); - const navigate = useNavigate(); - const paging = usePagedList(); - - const list = useQuery({ - queryKey: ["harness-versions", opts.region, harnessId, paging.pageSize, paging.token], - queryFn: () => core.harness.listHarnessVersions(harnessId, paging.token, paging.pageSize, opts), - placeholderData: keepPreviousData, - }); - - const nextToken = list.data?.nextToken; - // Pagination surfaces only once a response reports more pages (nextToken). - const paginated = paging.pageIndex > 0 || nextToken !== undefined; - - // Newest first within the page: versions are numeric strings incremented by - // UpdateHarness (the service already pages newest-first). - const rows = (list.data?.harnessVersions ?? []) - .map(toRow) - .sort((a, b) => Number(b.harnessVersion) - Number(a.harnessVersion)); - - return ( - - {list.isPending ? ( - - ) : list.isError ? ( - Error: {(list.error as Error).message} - ) : ( - <> - { - if (row.harnessVersion) onSelect(row.harnessVersion); - }} - onEscape={() => navigate(-1)} - onPrevPage={paging.pageIndex > 0 ? paging.prev : undefined} - onNextPage={nextToken ? () => paging.next(nextToken) : undefined} - /> - {paginated && ( - - page {paging.pageIndex + 1} - {nextToken ? " · more →" : ""} - - )} - - )} - - ); -} diff --git a/src/components/ui/data-table/DataTable.tsx b/src/components/ui/data-table/DataTable.tsx index 0d6a94521..9e23cf13f 100644 --- a/src/components/ui/data-table/DataTable.tsx +++ b/src/components/ui/data-table/DataTable.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Box, Text, useInput } from "ink"; import { darkTheme } from "../_core.js"; import type { InkUITheme } from "../_core.js"; @@ -40,6 +40,7 @@ export interface DataTableProps { showFooter?: boolean; emptyMessage?: string; focus?: boolean; + selectionResetKey?: string | number; theme?: InkUITheme; } @@ -62,6 +63,7 @@ export function DataTable>({ showFooter = true, emptyMessage = "No data", focus = true, + selectionResetKey, theme = darkTheme, }: DataTableProps): React.ReactElement { const [selectedRow, setSelectedRow] = useState(0); @@ -71,6 +73,11 @@ export function DataTable>({ const [searchQuery, setSearchQuery] = useState(""); const [searchMode, setSearchMode] = useState(false); + useEffect(() => { + setSelectedRow(0); + setCurrentPage(0); + }, [selectionResetKey]); + // Filter const filtered = data.filter((row) => { if (!searchQuery) return true; @@ -108,7 +115,9 @@ export function DataTable>({ setSearchQuery((q) => q.slice(0, -1)); return; } - if (input && input.length === 1 && !key.ctrl) setSearchQuery((q) => q + input); + if (input && !key.ctrl && !key.meta && !/\p{Cc}/u.test(input)) { + setSearchQuery((q) => q + input); + } return; } @@ -135,8 +144,11 @@ export function DataTable>({ onNextPage(); } } else setCurrentPage((p) => Math.min(totalPages - 1, p + 1)); - } else if (input === "/") setSearchMode(true); - else if (input === "s") { + } else if (input === "/") { + setSelectedRow(0); + setCurrentPage(0); + setSearchMode(true); + } else if (input === "s") { const col = columns.find((c) => c.sortable); if (col) { if (sortColumn === col.key) setSortDirection((d) => (d === "asc" ? "desc" : "asc")); diff --git a/src/components/usePagedList.tsx b/src/components/usePagedList.tsx index e5565cf52..fa6ed7a25 100644 --- a/src/components/usePagedList.tsx +++ b/src/components/usePagedList.tsx @@ -20,6 +20,20 @@ export interface PagedList { prev: () => void; } +interface PaginationState { + pageSize: number; + pageIndex: number; + tokens: (string | undefined)[]; +} + +function initialPagination(pageSize: number): PaginationState { + return { + pageSize, + pageIndex: 0, + tokens: [undefined], + }; +} + // usePagedList holds the server-side pagination state shared by the picker // tables: a terminal-height-derived page size and the trail of nextTokens // leading to the current page, so ←/h can walk back through cached pages. @@ -27,26 +41,39 @@ export function usePagedList(): PagedList { const { rows } = useWindowSize(); const pageSize = Math.max(3, rows - CHROME_ROWS); - const [pageIndex, setPageIndex] = useState(0); - // tokens[i] is the nextToken that fetched page i; page 0 has none. - const [tokens, setTokens] = useState<(string | undefined)[]>([undefined]); + const [state, setState] = useState(() => initialPagination(pageSize)); + const pageSizeChanged = state.pageSize !== pageSize; + const pageIndex = pageSizeChanged ? 0 : state.pageIndex; + const token = pageSizeChanged ? undefined : state.tokens[state.pageIndex]; // A resize changes maxResults, which invalidates the token trail (tokens // encode positions relative to the old page size) — restart from page 1. useEffect(() => { - setPageIndex(0); - setTokens([undefined]); + setState((current) => (current.pageSize === pageSize ? current : initialPagination(pageSize))); }, [pageSize]); return { pageSize, pageIndex, - token: tokens[pageIndex], + token, next: (nextToken) => { if (!nextToken) return; - setTokens((trail) => [...trail.slice(0, pageIndex + 1), nextToken]); - setPageIndex((i) => i + 1); + setState((current) => { + const active = current.pageSize === pageSize ? current : initialPagination(pageSize); + return { + pageSize, + pageIndex: active.pageIndex + 1, + tokens: [...active.tokens.slice(0, active.pageIndex + 1), nextToken], + }; + }); }, - prev: () => setPageIndex((i) => Math.max(0, i - 1)), + prev: () => + setState((current) => { + const active = current.pageSize === pageSize ? current : initialPagination(pageSize); + return { + ...active, + pageIndex: Math.max(0, active.pageIndex - 1), + }; + }), }; } diff --git a/src/handlers/harness/endpoint/delete/screen.tsx b/src/handlers/harness/endpoint/delete/screen.tsx index 9a2e9ecee..122ccf987 100644 --- a/src/handlers/harness/endpoint/delete/screen.tsx +++ b/src/handlers/harness/endpoint/delete/screen.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; import { HarnessPicker } from "../../../../components/HarnessPicker"; -import { EndpointPicker } from "../../../../components/EndpointPicker"; +import { HarnessEndpointPicker } from "../../../../components/HarnessEndpointPicker"; import { ConfirmAction } from "../../../../components/ConfirmAction"; import { useFinishFlow } from "../../../../components/useFinishFlow"; @@ -26,7 +26,7 @@ export function HarnessDeleteEndpointScreen(props: ScreenProps) { } if (!endpointName) { return ( - { r.unmount(); }); - test("renders each endpoint as a row with its versions and status", async () => { - const core = coreWithEndpoints([ - endpoint(), - endpoint({ endpointName: "staging", targetVersion: "2", status: "UPDATING" }), - ]); + test("makes one exact scoped endpoint list call", async () => { + const core = coreWithEndpoints([endpoint()]); const r = renderScreen("/agentcore/harness/endpoint/list/MyHarness-abc123", { core }); - await waitForText(r.lastFrame, "prod"); - const frame = r.lastFrame()!; - expect(frame).toContain("staging"); - expect(frame).toContain("READY"); - expect(frame).toContain("UPDATING"); + await waitFor(() => core.harness.calls.some((call) => call.method === "listHarnessEndpoints")); + expect(core.harness.calls.filter((call) => call.method === "listHarnessEndpoints")).toEqual([ + { + method: "listHarnessEndpoints", + args: [ + "MyHarness-abc123", + undefined, + expect.any(Number), + { + region: "us-east-1", + endpointUrl: undefined, + }, + ], + }, + ]); r.unmount(); }); - test("says so when the harness has no endpoints", async () => { - const core = coreWithEndpoints([]); + test("renders endpoint columns and values", async () => { + const core = coreWithEndpoints([ + endpoint({ + endpointName: "visible-endpoint", + liveVersion: "7", + targetVersion: "88", + status: "UPDATE_FAILED", + updatedAt: new Date("2026-07-18T02:00:00.000Z"), + }), + ]); const r = renderScreen("/agentcore/harness/endpoint/list/MyHarness-abc123", { core }); - await waitForText(r.lastFrame, "no endpoints"); + await waitForText(r.lastFrame, "visible-endpoint"); + const frame = r.lastFrame()!; + expect(frame).toContain("name"); + expect(frame).toContain("live"); + expect(frame).toContain("target"); + expect(frame).toContain("status"); + expect(frame).toContain("updatedAt"); + expect(frame).toMatch(/visible-endpoint\s+7\s+88\s+UPDATE_FAILED/); + expect(frame).toContain("2026-07-18T02:00:00.000Z"); r.unmount(); }); - test("pages forward and back when the response has a nextToken", async () => { + test("uses harness-specific first-page wording", async () => { const core = coreWithEndpoints([]); - core.harness.setListEndpointsResponse({ - endpoints: [endpoint({ endpointName: "alpha_ep" }), endpoint({ endpointName: "beta_ep" })], - nextToken: "ep2", - }); - core.harness.setListEndpointsResponse( - { endpoints: [endpoint({ endpointName: "gamma_ep" })] }, - "ep2", - ); const r = renderScreen("/agentcore/harness/endpoint/list/MyHarness-abc123", { core }); - await waitForText(r.lastFrame, "alpha_ep"); - expect(r.lastFrame()).toContain("page 1 · more →"); - - await r.write("l"); - await waitForText(r.lastFrame, "gamma_ep"); - expect(r.lastFrame()).toContain("page 2"); - const paged = core.harness.calls.filter((c) => c.method === "listHarnessEndpoints"); - expect(paged.at(-1)!.args[1]).toBe("ep2"); - expect(paged.at(-1)!.args[2] as number).toBeGreaterThan(0); - - await r.write("h"); - await waitForText(r.lastFrame, "alpha_ep"); - expect(r.lastFrame()).toContain("page 1"); + await waitForText(r.lastFrame, "This harness has no endpoints yet."); + expect(r.lastFrame()).not.toContain("No endpoints on this page"); r.unmount(); }); @@ -122,14 +132,4 @@ describe("harness endpoint list screen", () => { expect(call.args[1]).toBe("prod"); r.unmount(); }); - - test("shows the error message when the list call fails", async () => { - const core = new TestCoreClient(); - core.harness.setError(new Error("access denied")); - const r = renderScreen("/agentcore/harness/endpoint/list/MyHarness-abc123", { core }); - - await waitForText(r.lastFrame, "Error:"); - expect(r.lastFrame()).toContain("access denied"); - r.unmount(); - }); }); diff --git a/src/handlers/harness/endpoint/list/screen.tsx b/src/handlers/harness/endpoint/list/screen.tsx index 499630139..29fbd76e9 100644 --- a/src/handlers/harness/endpoint/list/screen.tsx +++ b/src/handlers/harness/endpoint/list/screen.tsx @@ -1,7 +1,7 @@ import { useNavigate, useParams } from "react-router"; import type { ScreenProps } from "../../../types"; import { HarnessPicker } from "../../../../components/HarnessPicker"; -import { EndpointPicker } from "../../../../components/EndpointPicker"; +import { HarnessEndpointPicker } from "../../../../components/HarnessEndpointPicker"; // HarnessListEndpointsScreen lists a harness's endpoints. Without a `:harnessId` // route value it renders a harness picker first; with one it lists that @@ -22,7 +22,7 @@ export function HarnessListEndpointsScreen(props: ScreenProps) { } return ( - [0]; -} - describe("harness list screen", () => { - test("renders each harness as a row once loaded", async () => { + test("renders each harness with its columns", async () => { const core = coreWith([ harness({ harnessName: "alpha", harnessId: "alpha-1" }), harness({ harnessName: "beta", harnessId: "beta-2" }), @@ -52,141 +45,30 @@ describe("harness list screen", () => { const frame = r.lastFrame()!; expect(frame).toContain("beta"); expect(frame).toContain("READY"); - // Column headers are present. expect(frame).toContain("name"); + expect(frame).toContain("version"); expect(frame).toContain("status"); - r.unmount(); - }); - - test("shows a loading indicator before data arrives", async () => { - // A list response that never resolves keeps the screen pending. - const core = new TestCoreClient(); - let release: (() => void) | undefined; - const pending = new Promise((resolve) => { - release = resolve; - }); - const original = core.harness.listHarnesses.bind(core.harness); - core.harness.listHarnesses = async (...args) => { - await pending; - return original(...args); - }; - - const r = renderScreen("/agentcore/harness/list", { core }); - await waitForText(r.lastFrame, "Loading harnesses"); - expect(r.lastFrame()).toContain("Loading harnesses"); - release?.(); - r.unmount(); - }); - - test("shows the error message when the list call fails", async () => { - const core = new TestCoreClient(); - core.harness.setError(new Error("access denied")); - const r = renderScreen("/agentcore/harness/list", { core }); - - await waitForText(r.lastFrame, "Error:"); - expect(r.lastFrame()).toContain("access denied"); - r.unmount(); - }); - - test("calls listHarnesses with the region from context", async () => { - const core = coreWith([harness()]); - const r = renderScreen("/agentcore/harness/list", { core }); - - await waitFor(() => core.harness.calls.length > 0); - const call = core.harness.calls.find((c) => c.method === "listHarnesses")!; - expect((call.args[2] as { region: string }).region).toBe("us-east-1"); - r.unmount(); - }); - - test("enter on a row navigates to that harness's detail screen", async () => { - const core = coreWith([harness({ harnessName: "pickme", harnessId: "pickme-9" })]); - // The get screen refetches the single harness; give it a response too. - core.harness.setGetResponse( - getResponse(harness({ harnessName: "pickme", harnessId: "pickme-9" })), - ); - const r = renderScreen("/agentcore/harness/list", { core }); - - await waitForText(r.lastFrame, "pickme"); - await r.press("return"); - // Detail screen breadcrumb includes the harness id. - await waitForText(r.lastFrame, "pickme-9"); - r.unmount(); + expect(frame).toContain("updatedAt"); }); - test("pages forward and back when the response has a nextToken", async () => { - const core = new TestCoreClient(); - core.harness.setListResponse({ - harnesses: [ - harness({ harnessName: "alpha_one", harnessId: "a1" }), - harness({ harnessName: "alpha_two", harnessId: "a2" }), - ], - nextToken: "t2", - }); - core.harness.setListResponse( - { harnesses: [harness({ harnessName: "beta_one", harnessId: "b1" })] }, - "t2", - ); - const r = renderScreen("/agentcore/harness/list", { core }); - - // Page 1 advertises pagination: key hint plus the status line. - await waitForText(r.lastFrame, "alpha_one"); - expect(r.lastFrame()).toContain("←→/hl"); - expect(r.lastFrame()).toContain("page 1 · more →"); - - // l → page 2, fetched with the nextToken and a terminal-derived maxResults. - await r.write("l"); - await waitForText(r.lastFrame, "beta_one"); - expect(r.lastFrame()).toContain("page 2"); - expect(r.lastFrame()).not.toContain("more →"); - const paged = core.harness.calls.filter((c) => c.method === "listHarnesses"); - expect(paged.at(-1)!.args[0]).toBe("t2"); - expect(paged.at(-1)!.args[1] as number).toBeGreaterThan(0); - - // ← → back to page 1. - await r.press("left"); - await waitForText(r.lastFrame, "alpha_one"); - expect(r.lastFrame()).toContain("page 1"); - r.unmount(); - }); - - test("no pagination hint when the listing fits one page", async () => { - const core = coreWith([harness({ harnessName: "only_one" })]); - const r = renderScreen("/agentcore/harness/list", { core }); - - await waitForText(r.lastFrame, "only_one"); - expect(r.lastFrame()).not.toContain("←→/hl"); - expect(r.lastFrame()).not.toContain("page 1"); - r.unmount(); - }); - - test("typing l into the filter does not turn the page", async () => { - const core = new TestCoreClient(); - core.harness.setListResponse({ - harnesses: [harness({ harnessName: "alpha_one", harnessId: "a1" })], - nextToken: "t2", - }); - const r = renderScreen("/agentcore/harness/list", { core }); - - await waitForText(r.lastFrame, "alpha_one"); - await r.write("/"); - await r.write("l"); - await waitForText(r.lastFrame, "/ Filter: l"); - expect(core.harness.calls.some((c) => c.method === "listHarnesses" && c.args[0] === "t2")).toBe( - false, - ); - r.unmount(); - }); - - test("esc returns to the harness menu", async () => { + test("makes one initial list request with context options", async () => { const core = coreWith([harness()]); const r = renderScreen("/agentcore/harness/list", { core }); - await waitForText(r.lastFrame, "MyHarness"); - await r.press("escape"); - // Back at the harness command menu — it shows the harness subcommands (e.g. - // the "get a harness" option), which the list table never does. - await waitForText(r.lastFrame, "get a harness"); - expect(r.lastFrame()).toContain("manage agentcore harnesses"); + await waitFor(() => core.harness.calls.some((call) => call.method === "listHarnesses")); + expect(core.harness.calls.filter((call) => call.method === "listHarnesses")).toEqual([ + { + method: "listHarnesses", + args: [ + undefined, + expect.any(Number), + { + region: "us-east-1", + endpointUrl: undefined, + }, + ], + }, + ]); r.unmount(); }); }); diff --git a/src/handlers/harness/version/list/list.screen.test.tsx b/src/handlers/harness/version/list/list.screen.test.tsx index 8cd0d38e3..67f8e5b8b 100644 --- a/src/handlers/harness/version/list/list.screen.test.tsx +++ b/src/handlers/harness/version/list/list.screen.test.tsx @@ -47,66 +47,111 @@ function coreWithVersions(versions: HarnessVersionSummary[]): TestCoreClient { describe("harness version list screen", () => { test("without a harness id, picking a harness lists its versions", async () => { - const core = coreWithVersions([version()]); + const core = coreWithVersions([version({ harnessVersion: "42" })]); const r = renderScreen("/agentcore/harness/version/list", { core }); await waitForText(r.lastFrame, "MyHarness"); expect(r.lastFrame()).toContain("choose a harness to list versions for"); await r.press("return"); - await waitFor(() => core.harness.calls.some((c) => c.method === "listHarnessVersions")); - const call = core.harness.calls.find((c) => c.method === "listHarnessVersions")!; - expect(call.args[0]).toBe("MyHarness-abc123"); + await waitForText(r.lastFrame, "42"); + expect(r.lastFrame()).toContain("version → list → MyHarness-abc123"); r.unmount(); }); - test("renders versions newest first", async () => { - const core = coreWithVersions([version(), version({ harnessVersion: "2" })]); + test("makes one exact scoped version list call", async () => { + const core = coreWithVersions([version()]); const r = renderScreen("/agentcore/harness/version/list/MyHarness-abc123", { core }); - await waitForText(r.lastFrame, "READY"); - const frame = r.lastFrame()!; - // Both rows render, "2" above "1". - expect(frame.indexOf(" 2 ")).toBeGreaterThan(-1); - expect(frame.indexOf("Version")).toBeLessThan(frame.indexOf("READY")); + await waitFor(() => core.harness.calls.some((call) => call.method === "listHarnessVersions")); + expect(core.harness.calls.filter((call) => call.method === "listHarnessVersions")).toEqual([ + { + method: "listHarnessVersions", + args: [ + "MyHarness-abc123", + undefined, + expect.any(Number), + { + region: "us-east-1", + endpointUrl: undefined, + }, + ], + }, + ]); r.unmount(); }); - test("pages forward and back when the response has a nextToken", async () => { - const core = coreWithVersions([]); - core.harness.setListVersionsResponse({ - harnessVersions: [version({ harnessVersion: "42" }), version({ harnessVersion: "41" })], - nextToken: "v2", - }); - core.harness.setListVersionsResponse( - { harnessVersions: [version({ harnessVersion: "40" })] }, - "v2", - ); + test("sorts numeric versions newest first", async () => { + const core = coreWithVersions([ + version({ harnessVersion: "2", status: "UPDATE_FAILED" }), + version({ harnessVersion: "10", status: "READY" }), + ]); const r = renderScreen("/agentcore/harness/version/list/MyHarness-abc123", { core }); - await waitForText(r.lastFrame, "42"); - expect(r.lastFrame()).toContain("page 1 · more →"); + await waitForText(r.lastFrame, "UPDATE_FAILED"); + const frame = r.lastFrame()!; + const lines = frame.split("\n"); + const versionTen = lines.findIndex((line) => line.includes("10") && line.includes("READY")); + const versionTwo = lines.findIndex( + (line) => line.includes("2") && line.includes("UPDATE_FAILED"), + ); + expect(versionTen).toBeGreaterThanOrEqual(0); + expect(versionTwo).toBeGreaterThanOrEqual(0); + expect(versionTen).toBeLessThan(versionTwo); + r.unmount(); + }); - await r.write("l"); - await waitForText(r.lastFrame, "40"); - expect(r.lastFrame()).toContain("page 2"); - const paged = core.harness.calls.filter((c) => c.method === "listHarnessVersions"); - expect(paged.at(-1)!.args[1]).toBe("v2"); - expect(paged.at(-1)!.args[2] as number).toBeGreaterThan(0); + test("renders version columns and values", async () => { + const core = coreWithVersions([ + version({ + harnessVersion: "123", + status: "UPDATE_FAILED", + createdAt: new Date("2026-07-18T02:00:00.000Z"), + }), + ]); + const r = renderScreen("/agentcore/harness/version/list/MyHarness-abc123", { core }); - await r.write("h"); - await waitForText(r.lastFrame, "42"); - expect(r.lastFrame()).toContain("page 1"); + await waitForText(r.lastFrame, "UPDATE_FAILED"); + const frame = r.lastFrame()!; + expect(frame).toContain("version"); + expect(frame).toContain("status"); + expect(frame).toContain("createdAt"); + expect(frame).toContain("123"); + expect(frame).toContain("UPDATE_FAILED"); + expect(frame).toContain("2026-07-18T02:00:00.000Z"); r.unmount(); }); + test("uses harness-version wording for empty pages", async () => { + const firstPage = renderScreen("/agentcore/harness/version/list/MyHarness-abc123"); + await waitForText(firstPage.lastFrame, "No versions found."); + firstPage.unmount(); + + const core = coreWithVersions([version({ harnessVersion: "42" })]); + core.harness.setListVersionsResponse({ + harnessVersions: [version({ harnessVersion: "42" })], + nextToken: "v2", + }); + core.harness.setListVersionsResponse({ harnessVersions: [] }, "v2"); + const laterPage = renderScreen("/agentcore/harness/version/list/MyHarness-abc123", { core }); + + await waitForText(laterPage.lastFrame, "page 1 · more →"); + await laterPage.write("l"); + await waitForText( + laterPage.lastFrame, + "No versions on this page for harness MyHarness-abc123.", + ); + expect(laterPage.lastFrame()).not.toContain("No versions found."); + laterPage.unmount(); + }); + test("enter on a row opens the version's JSON detail", async () => { - const core = coreWithVersions([version({ harnessVersion: "2" }), version()]); + const core = coreWithVersions([version({ harnessVersion: "42" })]); core.harness.setGetVersionResponse({ harness: { harnessId: "MyHarness-abc123", harnessName: "MyHarness", - harnessVersion: "2", + harnessVersion: "42", status: "READY", }, } as Awaited>); @@ -115,20 +160,10 @@ describe("harness version list screen", () => { await waitForText(r.lastFrame, "READY"); await r.press("return"); await waitForText(r.lastFrame, '"harnessVersion"'); - expect(r.lastFrame()).toContain("version → get → MyHarness-abc123 → 2"); + expect(r.lastFrame()).toContain("version → get → MyHarness-abc123 → 42"); const call = core.harness.calls.find((c) => c.method === "getHarnessVersion")!; expect(call.args[0]).toBe("MyHarness-abc123"); - expect(call.args[1]).toBe("2"); - r.unmount(); - }); - - test("shows the error message when the list call fails", async () => { - const core = new TestCoreClient(); - core.harness.setError(new Error("access denied")); - const r = renderScreen("/agentcore/harness/version/list/MyHarness-abc123", { core }); - - await waitForText(r.lastFrame, "Error:"); - expect(r.lastFrame()).toContain("access denied"); + expect(call.args[1]).toBe("42"); r.unmount(); }); }); diff --git a/src/handlers/harness/version/list/screen.tsx b/src/handlers/harness/version/list/screen.tsx index 6aba210a4..35578155f 100644 --- a/src/handlers/harness/version/list/screen.tsx +++ b/src/handlers/harness/version/list/screen.tsx @@ -1,7 +1,7 @@ import { useNavigate, useParams } from "react-router"; import type { ScreenProps } from "../../../types"; import { HarnessPicker } from "../../../../components/HarnessPicker"; -import { VersionPicker } from "../../../../components/VersionPicker"; +import { HarnessVersionPicker } from "../../../../components/HarnessVersionPicker"; // HarnessListVersionsScreen lists a harness's versions. Without a `:harnessId` // route value it renders a harness picker first; with one it lists that @@ -22,7 +22,7 @@ export function HarnessListVersionsScreen(props: ScreenProps) { } return ( - = {}): AgentRuntime { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeId: "runtime-123", + agentRuntimeVersion: "3", + agentRuntimeName: "checkout", + description: "Checkout Runtime", + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + status: "READY", + ...overrides, + }; +} + +function endpoint(overrides: Partial = {}): AgentRuntimeEndpoint { + return { + name: "prod", + liveVersion: "3", + targetVersion: "99", + agentRuntimeEndpointArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123/endpoint/prod", + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + status: "READY", + id: "prod", + description: "Production endpoint", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + }; +} + +function getEndpointResponse( + overrides: Partial = {}, +): GetAgentRuntimeEndpointResponse { + return { + liveVersion: "3", + targetVersion: "4", + agentRuntimeEndpointArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123/endpoint/prod", + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + description: "Production endpoint", + status: "READY", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + name: "prod", + id: "prod", + ...overrides, + }; +} + +async function waitForRuntimePicker(lastFrame: () => string | undefined): Promise { + await waitFor(() => { + const frame = lastFrame() ?? ""; + return ( + frame.includes("agentcore → runtime → endpoint → list") && + !frame.includes("agentcore → runtime → endpoint → list → runtime-123") && + frame.includes("checkout") + ); + }); +} + +describe("Runtime endpoint flow", () => { + test("uses the Runtime picker to scope an unscoped endpoint list", async () => { + const runtimeId = "runtime/blue one"; + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeId: runtimeId, agentRuntimeName: "pick-runtime" })], + }); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint()], + }); + const r = renderScreen("/agentcore/runtime/endpoint/list", { core }); + + await waitForText(r.lastFrame, "pick-runtime"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → endpoint → list → runtime/blue one"); + await waitForText(r.lastFrame, "prod"); + expect( + core.runtime.calls.some( + (call) => call.method === "listRuntimeEndpoints" && call.args[0] === runtimeId, + ), + ).toBe(true); + }); + + test("calls listRuntimeEndpoints once with exact scope and options", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint()], + }); + renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { + core, + endpointUrl: runtimeEndpointUrl, + }); + + await waitFor(() => core.runtime.calls.some((call) => call.method === "listRuntimeEndpoints")); + expect(core.runtime.calls.filter((call) => call.method === "listRuntimeEndpoints")).toEqual([ + { + method: "listRuntimeEndpoints", + args: [ + "runtime-123", + undefined, + expect.any(Number), + { + region: "us-east-1", + endpointUrl: runtimeEndpointUrl, + }, + ], + }, + ]); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); + }); + + test("renders qualifier, live and target versions, status, and update time", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [ + endpoint({ + name: "production", + liveVersion: "7", + targetVersion: "8", + status: "UPDATE_FAILED", + lastUpdatedAt: new Date("2026-07-18T02:00:00.000Z"), + }), + ], + }); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "production"); + const frame = r.lastFrame()!; + expect(frame).toContain("qualifier"); + expect(frame).toContain("live"); + expect(frame).toContain("target"); + expect(frame).toContain("status"); + expect(frame).toContain("lastUpdatedAt"); + expect(frame).toMatch(/target\s+status/); + expect(frame).toMatch(/production\s+7\s+8\s+UPDATE_FAILED/); + expect(frame).toContain("UPDATE_FAILED"); + expect(frame).toContain("2026-07-18T02:00:00.000Z"); + expect(frame).not.toContain("protocol"); + }); + + test("shows the Runtime-scoped empty state", async () => { + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123"); + + await waitForText(r.lastFrame, "This Runtime has no endpoints."); + expect(r.lastFrame()).toContain("runtime-123"); + }); + + test("describes an empty later page without claiming the Runtime has no endpoints", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint({ name: "page-one" })], + nextToken: "page-2", + }); + core.runtime.setListEndpointsResponse({ runtimeEndpoints: [] }, "page-2"); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "No endpoints on this page for Runtime runtime-123."); + expect(r.lastFrame()).not.toContain("This Runtime has no endpoints."); + }); + + test("names the selected Runtime in the error state", async () => { + const core = new TestCoreClient(); + core.runtime.setError(new Error("endpoint access denied")); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "Error loading endpoints for Runtime runtime-123"); + expect(r.lastFrame()).toContain("endpoint access denied"); + }); + + test("selecting an encoded qualifier opens complete endpoint JSON with exact selectors", async () => { + const qualifier = "prod/blue one"; + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint({ name: qualifier, id: qualifier })], + }); + core.runtime.setGetEndpointResponse(getEndpointResponse({ name: qualifier, id: qualifier })); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { + core, + endpointUrl: runtimeEndpointUrl, + }); + + await waitForText(r.lastFrame, qualifier); + await r.press("return"); + await waitForText( + r.lastFrame, + `agentcore → runtime → endpoint → get → runtime-123 → ${qualifier}`, + ); + await waitForText(r.lastFrame, '"targetVersion"'); + await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntimeEndpoint")); + expect(core.runtime.calls.find((call) => call.method === "getRuntimeEndpoint")).toEqual({ + method: "getRuntimeEndpoint", + args: [ + "runtime-123", + qualifier, + { + region: "us-east-1", + endpointUrl: runtimeEndpointUrl, + }, + ], + }); + }); + + test("uses a structural parent for the Runtime picker and history below it", async () => { + const parentCore = new TestCoreClient(); + parentCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); + const parent = renderScreen("/agentcore/runtime/endpoint/list", { + core: parentCore, + }); + await waitForText(parent.lastFrame, "checkout"); + await parent.press("escape"); + await waitForText( + parent.lastFrame, + "agentcore → runtime → endpoint → inspect AgentCore Runtime endpoints", + ); + parent.unmount(); + + const listCore = new TestCoreClient(); + listCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); + listCore.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint()], + }); + listCore.runtime.setGetEndpointResponse(getEndpointResponse()); + const list = renderScreen("/agentcore/runtime/endpoint/list", { core: listCore }); + await waitForText(list.lastFrame, "checkout"); + await list.press("return"); + await waitForText(list.lastFrame, "prod"); + await list.press("escape"); + await waitForRuntimePicker(list.lastFrame); + + await list.press("return"); + await waitForText(list.lastFrame, "prod"); + await list.press("return"); + await waitForText(list.lastFrame, '"agentRuntimeEndpointArn"'); + await list.press("escape"); + await waitForText(list.lastFrame, "agentcore → runtime → endpoint → list → runtime-123"); + }); + + test("bare endpoint get redirects to parent selection", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeName: "redirect-parent" })], + }); + const r = renderScreen("/agentcore/runtime/endpoint/get", { core }); + + await waitForText(r.lastFrame, "redirect-parent"); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(true); + }); +}); diff --git a/src/handlers/runtime/endpoint/get/screen.tsx b/src/handlers/runtime/endpoint/get/screen.tsx new file mode 100644 index 000000000..c570650b7 --- /dev/null +++ b/src/handlers/runtime/endpoint/get/screen.tsx @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export function RuntimeGetEndpointScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const { runtimeId, qualifier } = useParams(); + const detail = useQuery({ + queryKey: ["runtime-endpoint", opts.region, runtimeId, qualifier], + queryFn: () => core.runtime.getRuntimeEndpoint(runtimeId!, qualifier!, opts), + enabled: runtimeId !== undefined && qualifier !== undefined, + }); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/runtime/endpoint/index.tsx b/src/handlers/runtime/endpoint/index.tsx index 1af837ecd..4e35e5a19 100644 --- a/src/handlers/runtime/endpoint/index.tsx +++ b/src/handlers/runtime/endpoint/index.tsx @@ -1,12 +1,12 @@ import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; import type { AppIO, Core } from "../../types"; -import { createHelpDefault } from "../../help"; import { createGetRuntimeEndpointHandler } from "./get"; import { createListRuntimeEndpointsHandler } from "./list"; export function createRuntimeEndpointHandler(core: Core, io: AppIO): Router { return new Router("endpoint", "inspect AgentCore Runtime endpoints") - .default(createHelpDefault(io)) + .default(renderTui(core, io)) .handler(createGetRuntimeEndpointHandler(core)) .handler(createListRuntimeEndpointsHandler(core)); } diff --git a/src/handlers/runtime/endpoint/list/screen.tsx b/src/handlers/runtime/endpoint/list/screen.tsx new file mode 100644 index 000000000..bdfa79865 --- /dev/null +++ b/src/handlers/runtime/endpoint/list/screen.tsx @@ -0,0 +1,33 @@ +import { useNavigate, useParams } from "react-router"; +import type { ScreenProps } from "../../../types"; +import { RuntimeEndpointPicker } from "../../../../components/RuntimeEndpointPicker"; +import { RuntimePicker } from "../../../../components/RuntimePicker"; + +export function RuntimeListEndpointsScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { runtimeId } = useParams(); + + if (!runtimeId) { + return ( + navigate(`/agentcore/runtime/endpoint/list/${encodeURIComponent(id)}`)} + /> + ); + } + + return ( + + navigate( + `/agentcore/runtime/endpoint/get/${encodeURIComponent(runtimeId)}/${encodeURIComponent(qualifier)}`, + ) + } + /> + ); +} diff --git a/src/handlers/runtime/endpoint/screen.tsx b/src/handlers/runtime/endpoint/screen.tsx new file mode 100644 index 000000000..b2f14ac5f --- /dev/null +++ b/src/handlers/runtime/endpoint/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function RuntimeEndpointScreen(props: ScreenProps) { + return ; +} diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx new file mode 100644 index 000000000..892ee9340 --- /dev/null +++ b/src/handlers/runtime/get/screen.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Box, Text, useInput } from "ink"; +import { useNavigate, useParams } from "react-router"; +import { JsonDetail } from "../../../components/JsonDetail"; +import { KeyValueTable } from "../../../components/KeyValueTable.js"; +import { Layout } from "../../../components/Layout"; +import { darkTheme } from "../../../components/ui/_core.js"; +import { Divider } from "../../../components/ui/divider/Divider.js"; +import { Spinner } from "../../../components/ui/spinner"; +import type { ScreenProps } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; + +const ACTIONS = [ + { + name: "detail", + description: "show the full JSON definition", + to: (id: string) => `/agentcore/runtime/get/${encodeURIComponent(id)}/json`, + }, + { + name: "versions", + description: "list immutable Runtime versions", + to: (id: string) => `/agentcore/runtime/version/list/${encodeURIComponent(id)}`, + }, + { + name: "endpoints", + description: "list this Runtime's endpoints", + to: (id: string) => `/agentcore/runtime/endpoint/list/${encodeURIComponent(id)}`, + }, +] as const; + +export function RuntimeGetScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const { runtimeId } = useParams(); + const [index, setIndex] = useState(0); + const detail = useQuery({ + queryKey: ["runtime", opts.region, runtimeId], + queryFn: () => core.runtime.getRuntime(runtimeId!, opts), + enabled: runtimeId !== undefined, + }); + + useInput((input, key) => { + if (key.escape) { + navigate(-1); + return; + } + if (input === "r" && detail.isError) { + void detail.refetch(); + return; + } + if (detail.isError || !detail.data) return; + if (key.upArrow || input === "k") { + setIndex((current) => Math.max(0, current - 1)); + return; + } + if (key.downArrow || input === "j") { + setIndex((current) => Math.min(ACTIONS.length - 1, current + 1)); + return; + } + if (key.return && runtimeId) navigate(ACTIONS[index]!.to(runtimeId)); + }); + + const nameWidth = ACTIONS.reduce((width, action) => Math.max(width, action.name.length), 0) + 3; + + return ( + + {detail.isPending ? ( + + ) : detail.isError ? ( + Error: {(detail.error as Error).message} + ) : ( + + + + + + + + + {ACTIONS.map((action, actionIndex) => { + const selected = actionIndex === index; + return ( + + {selected ? "❯ " : " "} + + {action.name.padEnd(nameWidth)} + + {action.description} + + ); + })} + + + )} + + ); +} + +export function RuntimeGetJsonScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const { runtimeId } = useParams(); + const detail = useQuery({ + queryKey: ["runtime", opts.region, runtimeId], + queryFn: () => core.runtime.getRuntime(runtimeId!, opts), + enabled: runtimeId !== undefined, + }); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/runtime/index.tsx b/src/handlers/runtime/index.tsx index 0f827148e..dfe2b73bb 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -1,14 +1,16 @@ +import { withTuiOnEmptyFlagsAndArgs } from "../../middleware"; import { Router } from "../../router"; +import { renderTui } from "../../tui"; import type { AppIO, Core } from "../types"; import { createRuntimeEndpointHandler } from "./endpoint"; import { createGetRuntimeHandler } from "./get"; -import { createHelpDefault } from "../help"; import { createListRuntimesHandler } from "./list"; import { createRuntimeVersionHandler } from "./version"; export function createRuntimeHandler(core: Core, io: AppIO): Router { return new Router("runtime", "inspect AgentCore Runtimes") - .default(createHelpDefault(io)) + .use(withTuiOnEmptyFlagsAndArgs(core, io)) + .default(renderTui(core, io)) .handler(createGetRuntimeHandler(core)) .handler(createListRuntimesHandler(core)) .handler(createRuntimeVersionHandler(core, io)) diff --git a/src/handlers/runtime/list/screen.tsx b/src/handlers/runtime/list/screen.tsx new file mode 100644 index 000000000..73233523b --- /dev/null +++ b/src/handlers/runtime/list/screen.tsx @@ -0,0 +1,15 @@ +import { useNavigate } from "react-router"; +import type { ScreenProps } from "../../types"; +import { RuntimePicker } from "../../../components/RuntimePicker"; + +export function RuntimeListScreen(props: ScreenProps) { + const navigate = useNavigate(); + + return ( + navigate(`/agentcore/runtime/get/${encodeURIComponent(runtimeId)}`)} + /> + ); +} diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx new file mode 100644 index 000000000..473e46729 --- /dev/null +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -0,0 +1,453 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + AgentRuntime, + GetAgentRuntimeResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { QueryClient } from "@tanstack/react-query"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + tick, + waitFor, + waitForText, +} from "../../testing"; + +afterEach(cleanupScreens); + +const runtimeEndpointUrl = "https://runtime.test"; + +function runtime(overrides: Partial = {}): AgentRuntime { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-1", + agentRuntimeId: "runtime-1", + agentRuntimeVersion: "7", + agentRuntimeName: "checkout", + description: "Checkout Runtime", + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + status: "READY", + ...overrides, + }; +} + +function getRuntimeResponse( + overrides: Partial = {}, +): GetAgentRuntimeResponse { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeName: "checkout", + agentRuntimeId: "runtime-123", + agentRuntimeVersion: "7", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + roleArn: "arn:aws:iam::123456789012:role/runtime-role", + networkConfiguration: { networkMode: "PUBLIC" }, + status: "READY", + protocolConfiguration: { serverProtocol: "HTTP" }, + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 900, + maxLifetime: 28_800, + }, + description: "Checkout Runtime", + workloadIdentityDetails: { + workloadIdentityArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:workload-identity/checkout", + }, + agentRuntimeArtifact: { + containerConfiguration: { + containerUri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/checkout:latest", + }, + }, + metadataConfiguration: { requireMMDSV2: true }, + ...overrides, + }; +} + +function coreWithRuntimes(runtimes: AgentRuntime[]): TestCoreClient { + const core = new TestCoreClient(); + core.runtime.setListResponse({ agentRuntimes: runtimes }); + return core; +} + +describe("runtime picker", () => { + test("renders Runtime identity, latest version, status, and update time", async () => { + const core = coreWithRuntimes([ + runtime({ + agentRuntimeId: "runtime-visible-id", + agentRuntimeName: "orders", + agentRuntimeVersion: "42", + status: "CREATE_FAILED", + lastUpdatedAt: new Date("2026-07-19T01:02:03.000Z"), + }), + ]); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "orders"); + const frame = r.lastFrame()!; + expect(frame).toContain("name"); + expect(frame).toContain("id"); + expect(frame).toContain("latestVersion"); + expect(frame).toContain("status"); + expect(frame).toContain("lastUpdatedAt"); + expect(frame).toContain("runtime-visible-id"); + expect(frame).toContain("42"); + expect(frame).toContain("CREATE_FAILED"); + expect(frame).toContain("2026-07-19T01:02:03.000Z"); + }); + + test("calls listRuntimes once with exact Core options", async () => { + const core = coreWithRuntimes([runtime()]); + renderScreen("/agentcore/runtime/list", { core, endpointUrl: runtimeEndpointUrl }); + + await waitFor(() => core.runtime.calls.some((call) => call.method === "listRuntimes")); + expect(core.runtime.calls.filter((call) => call.method === "listRuntimes")).toEqual([ + { + method: "listRuntimes", + args: [ + undefined, + expect.any(Number), + { + region: "us-east-1", + endpointUrl: runtimeEndpointUrl, + }, + ], + }, + ]); + }); + + test("shows the first-page empty state", async () => { + const r = renderScreen("/agentcore/runtime/list"); + + await waitForText(r.lastFrame, "No Runtimes found in this Region."); + }); + + test("describes an empty later page without claiming the Region has no Runtimes", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeName: "page-one" })], + nextToken: "page-2", + }); + core.runtime.setListResponse({ agentRuntimes: [] }, "page-2"); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "No Runtimes on this page."); + expect(r.lastFrame()).not.toContain("No Runtimes found in this Region."); + }); + + test("Esc returns to the Runtime menu from a successful direct entry", async () => { + const core = coreWithRuntimes([runtime()]); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "checkout"); + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → runtime → inspect AgentCore Runtimes"); + expect(r.lastFrame()).toContain("list AgentCore Runtimes"); + }); + + test("bare Runtime get redirects to the picker", async () => { + const core = coreWithRuntimes([runtime({ agentRuntimeName: "redirected" })]); + const r = renderScreen("/agentcore/runtime/get", { core }); + + await waitForText(r.lastFrame, "redirected"); + expect(core.runtime.calls[0]?.method).toBe("listRuntimes"); + }); +}); + +describe("runtime hub", () => { + test("fetches the route ID with exact Core options and renders its summary", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse(getRuntimeResponse()); + const r = renderScreen("/agentcore/runtime/get/runtime-123", { + core, + endpointUrl: runtimeEndpointUrl, + }); + + await waitForText(r.lastFrame, "arn:aws:bedrock-agentcore:us-east-1"); + expect(r.lastFrame()).toContain("runtime-123"); + expect(r.lastFrame()).toContain("READY"); + expect(r.lastFrame()).toMatch(/version\s+7/); + expect(r.lastFrame()).toMatch(/protocol\s+HTTP/); + expect(r.lastFrame()).toMatch(/network\s+PUBLIC/); + expect(r.lastFrame()).not.toContain("failureReason"); + await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntime")); + expect(core.runtime.calls.find((call) => call.method === "getRuntime")).toEqual({ + method: "getRuntime", + args: [ + "runtime-123", + { + region: "us-east-1", + endpointUrl: runtimeEndpointUrl, + }, + ], + }); + }); + + test("shows the Runtime failure reason only when the service provides one", async () => { + const healthyCore = new TestCoreClient(); + healthyCore.runtime.setGetResponse(getRuntimeResponse()); + const healthy = renderScreen("/agentcore/runtime/get/runtime-123", { core: healthyCore }); + + await waitForText(healthy.lastFrame, "show the full JSON definition"); + expect(healthy.lastFrame()).not.toContain("failureReason"); + healthy.unmount(); + + const failedCore = new TestCoreClient(); + failedCore.runtime.setGetResponse( + getRuntimeResponse({ + status: "CREATE_FAILED", + failureReason: "Image could not be pulled", + }), + ); + const failed = renderScreen("/agentcore/runtime/get/runtime-123", { core: failedCore }); + + await waitForText(failed.lastFrame, "Image could not be pulled"); + expect(failed.lastFrame()).toMatch(/failureReason\s+Image could not be pulled/); + }); + + test("renders exactly the read-only detail, versions, and endpoints actions", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse(getRuntimeResponse()); + const r = renderScreen("/agentcore/runtime/get/runtime-123", { core }); + + await waitForText(r.lastFrame, "show the full JSON definition"); + const frame = r.lastFrame()!; + expect(frame).toContain("versions"); + expect(frame).toContain("endpoints"); + for (const excluded of ["invoke", "exec", "update", "create", "delete"]) { + expect(frame).not.toContain(excluded); + } + }); + + test("picker selection encodes the ID and opens the matching Runtime hub", async () => { + const runtimeId = "runtime/blue one"; + const core = coreWithRuntimes([ + runtime({ agentRuntimeId: runtimeId, agentRuntimeName: "encoded-runtime" }), + ]); + core.runtime.setGetResponse( + getRuntimeResponse({ + agentRuntimeId: runtimeId, + agentRuntimeName: "encoded-runtime", + }), + ); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "encoded-runtime"); + await r.press("return"); + await waitForText(r.lastFrame, `agentcore → runtime → get → ${runtimeId}`); + await waitFor(() => + core.runtime.calls.some((call) => call.method === "getRuntime" && call.args[0] === runtimeId), + ); + }); + + test.each([ + ["versions", 1], + ["endpoints", 2], + ] as const)( + "selecting %s opens its encoded Runtime-scoped route", + async (action, downPresses) => { + const runtimeId = "runtime/blue one"; + const core = new TestCoreClient(); + core.runtime.setGetResponse(getRuntimeResponse({ agentRuntimeId: runtimeId })); + if (action === "versions") { + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime({ agentRuntimeId: runtimeId, agentRuntimeVersion: "7" })], + }); + } else { + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [ + { + name: "prod", + id: "prod", + liveVersion: "7", + agentRuntimeEndpointArn: "arn:endpoint", + agentRuntimeArn: "arn:runtime", + status: "READY", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + }, + ], + }); + } + const r = renderScreen(`/agentcore/runtime/get/${encodeURIComponent(runtimeId)}`, { + core, + }); + + await waitForText(r.lastFrame, "show the full JSON definition"); + for (let index = 0; index < downPresses; index += 1) { + await r.press("down"); + } + await r.press("return"); + + await waitForText( + r.lastFrame, + action === "versions" + ? `agentcore → runtime → version → list → ${runtimeId}` + : `agentcore → runtime → endpoint → list → ${runtimeId}`, + ); + }, + ); + + test("opens complete Runtime JSON from the detail action and scrolls", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse( + getRuntimeResponse({ + environmentVariables: Object.fromEntries( + Array.from({ length: 30 }, (_, index) => [`VARIABLE_${index}`, `value-${index}`]), + ), + }), + ); + const r = renderScreen("/agentcore/runtime/get/runtime-123", { core }); + + await waitForText(r.lastFrame, "show the full JSON definition"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123 → json"); + const frame = r.lastFrame()!; + expect(frame).toContain('"agentRuntimeId"'); + expect(frame).toContain('"networkConfiguration"'); + expect(frame).toContain('"lifecycleConfiguration"'); + expect(frame).not.toContain('"VARIABLE_29"'); + for (let index = 0; index < 20; index += 1) await r.press("down"); + for (let index = 0; index < 20; index += 1) await r.write("j"); + await waitForText(r.lastFrame, '"VARIABLE_29"'); + expect(r.lastFrame()).not.toContain('"agentRuntimeId"'); + await r.press("up"); + await r.write("k"); + expect(r.lastFrame()).toContain('"VARIABLE_28"'); + }); + + test("retries a failed hub query without leaving the route", async () => { + const core = new TestCoreClient(); + core.runtime.setError(new Error("runtime unavailable")); + const r = renderScreen("/agentcore/runtime/get/runtime-123", { core }); + + await waitForText(r.lastFrame, "runtime unavailable"); + expect(r.lastFrame()).toContain("agentcore → runtime → get → runtime-123"); + expect(r.lastFrame()).toContain("[r] retry"); + + core.runtime.setError(undefined); + core.runtime.setGetResponse(getRuntimeResponse()); + const callsBeforeRetry = core.runtime.calls.length; + await r.write("r"); + await waitForText(r.lastFrame, "show the full JSON definition"); + expect(core.runtime.calls).toHaveLength(callsBeforeRetry + 1); + }); + + test("does not activate cached hub actions after a background refetch fails", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse(getRuntimeResponse()); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity, staleTime: 0 }, + }, + }); + const r = renderScreen("/agentcore/runtime/get/runtime-123", { + core, + queryClient, + }); + + await waitForText(r.lastFrame, "show the full JSON definition"); + core.runtime.setError(new Error("background refresh failed")); + await queryClient.invalidateQueries({ + queryKey: ["runtime", "us-east-1", "runtime-123"], + }); + await waitForText(r.lastFrame, "background refresh failed"); + + await r.press("return"); + await tick(); + expect(r.lastFrame()).toContain("agentcore → runtime → get → runtime-123"); + expect(r.lastFrame()).not.toContain("→ json"); + expect(r.lastFrame()).toContain("background refresh failed"); + }); + + test("retries a failed JSON query without leaving the route", async () => { + const core = new TestCoreClient(); + core.runtime.setError(new Error("detail unavailable")); + const r = renderScreen("/agentcore/runtime/get/runtime-123/json", { core }); + + await waitForText(r.lastFrame, "detail unavailable"); + expect(r.lastFrame()).toContain("[r] retry"); + + core.runtime.setError(undefined); + core.runtime.setGetResponse(getRuntimeResponse()); + const callsBeforeRetry = core.runtime.calls.length; + await r.write("r"); + await waitForText(r.lastFrame, '"agentRuntimeId"'); + expect(core.runtime.calls).toHaveLength(callsBeforeRetry + 1); + }); + + test("Esc from the hub returns through history to the Runtime picker", async () => { + const core = coreWithRuntimes([runtime({ agentRuntimeId: "runtime-123" })]); + core.runtime.setGetResponse(getRuntimeResponse()); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "checkout"); + await r.press("return"); + await waitForText(r.lastFrame, "show the full JSON definition"); + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → runtime → list"); + }); + + test("Esc from Runtime JSON returns through history to the Runtime hub", async () => { + const core = coreWithRuntimes([runtime({ agentRuntimeId: "runtime-123" })]); + core.runtime.setGetResponse(getRuntimeResponse()); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "checkout"); + await r.press("return"); + await waitForText(r.lastFrame, "show the full JSON definition"); + await r.press("return"); + await waitForText(r.lastFrame, '"agentRuntimeId"'); + await r.press("escape"); + await waitFor(() => { + const frame = r.lastFrame() ?? ""; + return frame.includes("agentcore → runtime → get → runtime-123") && !frame.includes("→ json"); + }); + await waitForText(r.lastFrame, "show the full JSON definition"); + }); + + test("Esc remains active while the hub is loading", async () => { + const hubCore = coreWithRuntimes([runtime({ agentRuntimeId: "runtime-123" })]); + const hubPending = Promise.withResolvers(); + hubCore.runtime.getRuntime = async () => hubPending.promise; + const hub = renderScreen("/agentcore/runtime/list", { core: hubCore }); + + await waitForText(hub.lastFrame, "checkout"); + await hub.press("return"); + await waitForText(hub.lastFrame, "Loading Runtime…"); + await hub.press("escape"); + await waitForText(hub.lastFrame, "agentcore → runtime → list"); + }); + + test("Esc remains active while hub and JSON routes show errors", async () => { + const hubCore = coreWithRuntimes([runtime({ agentRuntimeId: "runtime-123" })]); + hubCore.runtime.getRuntime = async () => { + throw new Error("hub failed"); + }; + const hub = renderScreen("/agentcore/runtime/list", { core: hubCore }); + + await waitForText(hub.lastFrame, "checkout"); + await hub.press("return"); + await waitForText(hub.lastFrame, "hub failed"); + await hub.press("escape"); + await waitForText(hub.lastFrame, "agentcore → runtime → list"); + hub.unmount(); + + const jsonCore = coreWithRuntimes([runtime({ agentRuntimeId: "runtime-123" })]); + jsonCore.runtime.setGetResponse(getRuntimeResponse()); + const json = renderScreen("/agentcore/runtime/list", { core: jsonCore }); + + await waitForText(json.lastFrame, "checkout"); + await json.press("return"); + await waitForText(json.lastFrame, "show the full JSON definition"); + jsonCore.runtime.setError(new Error("json failed")); + await json.press("return"); + await waitForText(json.lastFrame, "json failed"); + await json.press("escape"); + await waitFor(() => { + const frame = json.lastFrame() ?? ""; + return frame.includes("agentcore → runtime → get → runtime-123") && !frame.includes("→ json"); + }); + }); +}); diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 501aed620..987848006 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; import { CoreClient } from "../../core"; -import { createSilentLogger, fixtureFactories, matchGolden, testIO } from "../../testing"; +import { + createSilentLogger, + fixtureFactories, + matchGolden, + TestCoreClient, + testIO, +} from "../../testing"; import { createRootHandler } from "../index"; const REGION = "us-west-2"; @@ -16,6 +22,7 @@ const MISSING_RUNTIME_ID = "missing_runtime-0000000000"; function createFixtureCore(): CoreClient { const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES); + return new CoreClient({ createControlClient, createDataClient, @@ -35,6 +42,20 @@ async function run(args: string[]): Promise { return io.stdout(); } +function testRuntimeCommand() { + const core = new TestCoreClient(); + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + }); + + return { + core, + route: (args: string[]) => root.route(["node", "agentcore", ...args, "--region", REGION]), + }; +} + describe("runtime command hierarchy", () => { test("registers the Runtime read-only command hierarchy", () => { const root = createRootHandler(createFixtureCore(), { @@ -43,6 +64,7 @@ describe("runtime command hierarchy", () => { }); const runtime = root.children().find((child) => child.name() === "runtime"); + expect(runtime?.flags().map((flag) => flag.name)).not.toContain("interactive"); expect(runtime?.children().map((child) => child.name())).toEqual([ "get", "list", @@ -66,9 +88,9 @@ describe("runtime command hierarchy", () => { }); test.each(["runtime", "runtime version", "runtime endpoint"])( - "prints help for bare `%s` without an SDK call", + "prints help for `%s --json` without an SDK call", async (command) => { - const stdout = await run(command.split(" ")); + const stdout = await run([...command.split(" "), "--json"]); expect(stdout).toContain(`Usage: agentcore ${command}`); expect(stdout).toContain("Commands:"); @@ -76,6 +98,20 @@ describe("runtime command hierarchy", () => { ); }); +describe("runtime TUI dispatch", () => { + test.each([ + ["get", ["runtime", "get"]], + ["endpoint list", ["runtime", "endpoint", "list"]], + ] as const)("opens the TUI for a bare Runtime %s leaf", async (_label, args) => { + const { core, route } = testRuntimeCommand(); + + await expect(route([...args])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + expect(core.runtime.calls).toEqual([]); + }); +}); + describe("runtime read-only commands", () => { test("gets a Runtime whose ID exceeds the 48-character Runtime name limit", async () => { expect(FIXTURE_RUNTIME_ID.length).toBeGreaterThan(48); @@ -218,9 +254,12 @@ describe("runtime read-only commands", () => { /--qualifier/, ], ["runtime endpoint list", ["runtime", "endpoint", "list"], /--id/], - ] as const)("rejects a missing required selector for `%s`", async (_label, args, message) => { - expect(run([...args])).rejects.toThrow(message); - }); + ] as const)( + "rejects a missing required selector for headless `%s`", + async (_label, args, message) => { + await expect(run([...args, "--json"])).rejects.toThrow(message); + }, + ); test.each([ [ diff --git a/src/handlers/runtime/screen.tsx b/src/handlers/runtime/screen.tsx new file mode 100644 index 000000000..76430b642 --- /dev/null +++ b/src/handlers/runtime/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../components/RouterScreen"; +import type { ScreenProps } from "../types"; + +export function RuntimeScreen(props: ScreenProps) { + return ; +} diff --git a/src/handlers/runtime/version/get/screen.tsx b/src/handlers/runtime/version/get/screen.tsx new file mode 100644 index 000000000..924c5ba36 --- /dev/null +++ b/src/handlers/runtime/version/get/screen.tsx @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export function RuntimeGetVersionScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const { runtimeId, version } = useParams(); + const detail = useQuery({ + queryKey: ["runtime-version", opts.region, runtimeId, version], + queryFn: () => core.runtime.getRuntimeVersion(runtimeId!, version!, opts), + enabled: runtimeId !== undefined && version !== undefined, + }); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/runtime/version/index.tsx b/src/handlers/runtime/version/index.tsx index d54543a8c..6f82bd3b6 100644 --- a/src/handlers/runtime/version/index.tsx +++ b/src/handlers/runtime/version/index.tsx @@ -1,12 +1,12 @@ import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; import type { AppIO, Core } from "../../types"; -import { createHelpDefault } from "../../help"; import { createGetRuntimeVersionHandler } from "./get"; import { createListRuntimeVersionsHandler } from "./list"; export function createRuntimeVersionHandler(core: Core, io: AppIO): Router { return new Router("version", "inspect AgentCore Runtime versions") - .default(createHelpDefault(io)) + .default(renderTui(core, io)) .handler(createGetRuntimeVersionHandler(core)) .handler(createListRuntimeVersionsHandler(core)); } diff --git a/src/handlers/runtime/version/list/screen.tsx b/src/handlers/runtime/version/list/screen.tsx new file mode 100644 index 000000000..bf9484063 --- /dev/null +++ b/src/handlers/runtime/version/list/screen.tsx @@ -0,0 +1,33 @@ +import { useNavigate, useParams } from "react-router"; +import type { ScreenProps } from "../../../types"; +import { RuntimePicker } from "../../../../components/RuntimePicker"; +import { RuntimeVersionPicker } from "../../../../components/RuntimeVersionPicker"; + +export function RuntimeListVersionsScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { runtimeId } = useParams(); + + if (!runtimeId) { + return ( + navigate(`/agentcore/runtime/version/list/${encodeURIComponent(id)}`)} + /> + ); + } + + return ( + + navigate( + `/agentcore/runtime/version/get/${encodeURIComponent(runtimeId)}/${encodeURIComponent(version)}`, + ) + } + /> + ); +} diff --git a/src/handlers/runtime/version/screen.tsx b/src/handlers/runtime/version/screen.tsx new file mode 100644 index 000000000..c7dbbdaee --- /dev/null +++ b/src/handlers/runtime/version/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function RuntimeVersionScreen(props: ScreenProps) { + return ; +} diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx new file mode 100644 index 000000000..c0e2ebcb0 --- /dev/null +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -0,0 +1,265 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + AgentRuntime, + GetAgentRuntimeResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + waitFor, + waitForText, +} from "../../../testing"; + +afterEach(cleanupScreens); + +const runtimeEndpointUrl = "https://runtime.test"; + +function runtime(overrides: Partial = {}): AgentRuntime { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeId: "runtime-123", + agentRuntimeVersion: "3", + agentRuntimeName: "checkout", + description: "Checkout Runtime", + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + status: "READY", + ...overrides, + }; +} + +function getVersionResponse( + overrides: Partial = {}, +): GetAgentRuntimeResponse { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeName: "checkout", + agentRuntimeId: "runtime-123", + agentRuntimeVersion: "3", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + roleArn: "arn:aws:iam::123456789012:role/runtime-role", + networkConfiguration: { networkMode: "PUBLIC" }, + status: "READY", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 900, + maxLifetime: 28_800, + }, + description: "Checkout Runtime version", + metadataConfiguration: { requireMMDSV2: true }, + ...overrides, + }; +} + +async function waitForRuntimePicker(lastFrame: () => string | undefined): Promise { + await waitFor(() => { + const frame = lastFrame() ?? ""; + return ( + frame.includes("agentcore → runtime → version → list") && + !frame.includes("agentcore → runtime → version → list → runtime-123") && + frame.includes("checkout") + ); + }); +} + +describe("Runtime version flow", () => { + test("uses the Runtime picker to scope an unscoped version list", async () => { + const runtimeId = "runtime/blue one"; + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeId: runtimeId, agentRuntimeName: "pick-runtime" })], + }); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime({ agentRuntimeId: runtimeId, agentRuntimeVersion: "9" })], + }); + const r = renderScreen("/agentcore/runtime/version/list", { core }); + + await waitForText(r.lastFrame, "pick-runtime"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → version → list → runtime/blue one"); + await waitForText(r.lastFrame, "9"); + expect( + core.runtime.calls.some( + (call) => call.method === "listRuntimeVersions" && call.args[0] === runtimeId, + ), + ).toBe(true); + }); + + test("calls listRuntimeVersions once with exact scope and options", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime()], + }); + renderScreen("/agentcore/runtime/version/list/runtime-123", { + core, + endpointUrl: runtimeEndpointUrl, + }); + + await waitFor(() => core.runtime.calls.some((call) => call.method === "listRuntimeVersions")); + expect(core.runtime.calls.filter((call) => call.method === "listRuntimeVersions")).toEqual([ + { + method: "listRuntimeVersions", + args: [ + "runtime-123", + undefined, + expect.any(Number), + { + region: "us-east-1", + endpointUrl: runtimeEndpointUrl, + }, + ], + }, + ]); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); + }); + + test("renders numeric versions newest first with only version status and update time", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [ + runtime({ + agentRuntimeId: "hidden-id", + agentRuntimeName: "hidden-name", + agentRuntimeVersion: "2", + status: "UPDATE_FAILED", + lastUpdatedAt: new Date("2026-07-18T02:00:00.000Z"), + }), + runtime({ + agentRuntimeId: "hidden-id", + agentRuntimeName: "hidden-name", + agentRuntimeVersion: "10", + status: "READY", + lastUpdatedAt: new Date("2026-07-20T10:00:00.000Z"), + }), + ], + }); + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "UPDATE_FAILED"); + const frame = r.lastFrame()!; + expect(frame).toContain("version"); + expect(frame).toContain("status"); + expect(frame).toContain("lastUpdatedAt"); + const lines = frame.split("\n"); + const versionTen = lines.findIndex((line) => line.includes("10") && line.includes("READY")); + const versionTwo = lines.findIndex( + (line) => line.includes("2") && line.includes("UPDATE_FAILED"), + ); + expect(versionTen).toBeGreaterThanOrEqual(0); + expect(versionTwo).toBeGreaterThanOrEqual(0); + expect(versionTen).toBeLessThan(versionTwo); + expect(frame).not.toContain("hidden-id"); + expect(frame).not.toContain("hidden-name"); + }); + + test("describes an empty later page without claiming the Runtime has no versions", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime({ agentRuntimeVersion: "3" })], + nextToken: "page-2", + }); + core.runtime.setListVersionsResponse({ agentRuntimes: [] }, "page-2"); + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "No versions on this page for Runtime runtime-123."); + expect(r.lastFrame()).not.toContain("No versions found for Runtime runtime-123."); + }); + + test("names the selected Runtime in empty and error states", async () => { + const empty = renderScreen("/agentcore/runtime/version/list/runtime-123"); + await waitForText(empty.lastFrame, "No versions found for Runtime runtime-123."); + empty.unmount(); + + const core = new TestCoreClient(); + core.runtime.setError(new Error("version access denied")); + const error = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + await waitForText(error.lastFrame, "Error loading versions for Runtime runtime-123"); + expect(error.lastFrame()).toContain("version access denied"); + expect(error.lastFrame()).toContain("[r] retry"); + }); + + test("selecting a version opens complete JSON with exact selectors", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime({ agentRuntimeVersion: "9" })], + }); + core.runtime.setGetVersionResponse(getVersionResponse({ agentRuntimeVersion: "9" })); + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { + core, + endpointUrl: runtimeEndpointUrl, + }); + + await waitForText(r.lastFrame, "9"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → version → get → runtime-123 → 9"); + await waitForText(r.lastFrame, '"networkConfiguration"'); + await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntimeVersion")); + expect(core.runtime.calls.find((call) => call.method === "getRuntimeVersion")).toEqual({ + method: "getRuntimeVersion", + args: [ + "runtime-123", + "9", + { + region: "us-east-1", + endpointUrl: runtimeEndpointUrl, + }, + ], + }); + }); + + test("uses a structural parent for the Runtime picker and history below it", async () => { + const parentCore = new TestCoreClient(); + parentCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); + const parent = renderScreen("/agentcore/runtime/version/list", { + core: parentCore, + }); + await waitForText(parent.lastFrame, "checkout"); + await parent.press("escape"); + await waitForText( + parent.lastFrame, + "agentcore → runtime → version → inspect AgentCore Runtime versions", + ); + parent.unmount(); + + const listCore = new TestCoreClient(); + listCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); + listCore.runtime.setListVersionsResponse({ + agentRuntimes: [runtime()], + }); + listCore.runtime.setGetVersionResponse(getVersionResponse()); + const list = renderScreen("/agentcore/runtime/version/list", { core: listCore }); + await waitForText(list.lastFrame, "checkout"); + await list.press("return"); + await waitForText(list.lastFrame, "agentcore → runtime → version → list → runtime-123"); + await waitForText(list.lastFrame, "3"); + await list.press("escape"); + await waitForRuntimePicker(list.lastFrame); + await waitForText(list.lastFrame, "[enter] select"); + + await list.press("return"); + await waitForText(list.lastFrame, "agentcore → runtime → version → list → runtime-123"); + await waitForText(list.lastFrame, "3"); + await waitForText(list.lastFrame, "[enter] select"); + await list.press("return"); + await waitForText(list.lastFrame, '"agentRuntimeVersion"'); + await list.press("escape"); + await waitForText(list.lastFrame, "agentcore → runtime → version → list → runtime-123"); + }); + + test("bare version get redirects to parent selection", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeName: "redirect-parent" })], + }); + const r = renderScreen("/agentcore/runtime/version/get", { core }); + + await waitForText(r.lastFrame, "redirect-parent"); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(true); + }); +}); diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 93a2c33aa..531c2df07 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -436,51 +436,130 @@ export class TestHarnessClient implements CoreHarnessClient { } } -class TestRuntimeClient implements CoreRuntimeClient { - async getRuntime(_id: string, _options: CoreOptions): Promise { - return DEFAULT_GET_RUNTIME_RESPONSE; +export class TestRuntimeClient implements CoreRuntimeClient { + readonly calls: RecordedCall[] = []; + + private getResponse: GetAgentRuntimeResponse = DEFAULT_GET_RUNTIME_RESPONSE; + private getVersionResponse: GetAgentRuntimeResponse = DEFAULT_GET_RUNTIME_RESPONSE; + private getEndpointResponse: GetAgentRuntimeEndpointResponse = + DEFAULT_GET_RUNTIME_ENDPOINT_RESPONSE; + private listResponses = new Map(); + private listVersionResponses = new Map(); + private listEndpointResponses = new Map(); + private error?: Error; + + setGetResponse(response: GetAgentRuntimeResponse): this { + this.getResponse = response; + return this; + } + + setGetVersionResponse(response: GetAgentRuntimeResponse): this { + this.getVersionResponse = response; + return this; + } + + setGetEndpointResponse(response: GetAgentRuntimeEndpointResponse): this { + this.getEndpointResponse = response; + return this; + } + + setListResponse(response: ListAgentRuntimesResponse, forNextToken?: string): this { + this.listResponses.set(forNextToken, response); + return this; + } + + setListVersionsResponse(response: ListAgentRuntimeVersionsResponse, forNextToken?: string): this { + this.listVersionResponses.set(forNextToken, response); + return this; + } + + setListEndpointsResponse( + response: ListAgentRuntimeEndpointsResponse, + forNextToken?: string, + ): this { + this.listEndpointResponses.set(forNextToken, response); + return this; + } + + setError(error: Error | undefined): this { + this.error = error; + return this; + } + + async getRuntime(id: string, options: CoreOptions): Promise { + this.calls.push({ method: "getRuntime", args: [id, options] }); + if (this.error) throw this.error; + return this.getResponse; } async getRuntimeVersion( - _id: string, - _version: string, - _options: CoreOptions, + id: string, + version: string, + options: CoreOptions, ): Promise { - return DEFAULT_GET_RUNTIME_RESPONSE; + this.calls.push({ method: "getRuntimeVersion", args: [id, version, options] }); + if (this.error) throw this.error; + return this.getVersionResponse; } async getRuntimeEndpoint( - _id: string, - _qualifier: string, - _options: CoreOptions, + id: string, + qualifier: string, + options: CoreOptions, ): Promise { - return DEFAULT_GET_RUNTIME_ENDPOINT_RESPONSE; + this.calls.push({ method: "getRuntimeEndpoint", args: [id, qualifier, options] }); + if (this.error) throw this.error; + return this.getEndpointResponse; } async listRuntimes( - _nextToken: string | undefined, - _maxResults: number | undefined, - _options: CoreOptions, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, ): Promise { - return DEFAULT_LIST_RUNTIMES_RESPONSE; + this.calls.push({ method: "listRuntimes", args: [nextToken, maxResults, options] }); + if (this.error) throw this.error; + return ( + this.listResponses.get(nextToken) ?? + this.listResponses.get(undefined) ?? + DEFAULT_LIST_RUNTIMES_RESPONSE + ); } async listRuntimeVersions( - _id: string, - _nextToken: string | undefined, - _maxResults: number | undefined, - _options: CoreOptions, + id: string, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, ): Promise { - return DEFAULT_LIST_RUNTIME_VERSIONS_RESPONSE; + this.calls.push({ + method: "listRuntimeVersions", + args: [id, nextToken, maxResults, options], + }); + if (this.error) throw this.error; + return ( + this.listVersionResponses.get(nextToken) ?? + this.listVersionResponses.get(undefined) ?? + DEFAULT_LIST_RUNTIME_VERSIONS_RESPONSE + ); } async listRuntimeEndpoints( - _id: string, - _nextToken: string | undefined, - _maxResults: number | undefined, - _options: CoreOptions, + id: string, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, ): Promise { - return DEFAULT_LIST_RUNTIME_ENDPOINTS_RESPONSE; + this.calls.push({ + method: "listRuntimeEndpoints", + args: [id, nextToken, maxResults, options], + }); + if (this.error) throw this.error; + return ( + this.listEndpointResponses.get(nextToken) ?? + this.listEndpointResponses.get(undefined) ?? + DEFAULT_LIST_RUNTIME_ENDPOINTS_RESPONSE + ); } } type TestCoreClientOptions = { diff --git a/src/testing/index.tsx b/src/testing/index.tsx index 30ee08187..e3e711ee6 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -2,7 +2,12 @@ export { parse, stringify } from "./serialization"; export { fixtureFactories, isRecording, matchGolden } from "./fixtures"; export { testIO, type TestIO } from "./testIO"; export { tick, waitFor } from "./timing"; -export { TestCoreClient, TestHarnessClient, type RecordedCall } from "./TestCoreClient"; +export { + TestCoreClient, + TestHarnessClient, + TestRuntimeClient, + type RecordedCall, +} from "./TestCoreClient"; export { StreamController } from "./StreamController"; export { renderScreen, diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index 18973cce1..4ed219ade 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -2,7 +2,7 @@ import React from "react"; import { render, cleanup } from "ink-testing-library"; import { QueryClient } from "@tanstack/react-query"; import { ValueContext, compile, CommandKey, type Context } from "../router"; -import { RegionKey, JsonKey, DebugKey } from "../handlers/keys"; +import { RegionKey, JsonKey, DebugKey, EndpointKey } from "../handlers/keys"; import { JsonRendererKey } from "../tui"; import { createRootHandler } from "../handlers"; import { Root } from "../components/Root"; @@ -27,7 +27,7 @@ import { createSilentLogger } from "./logging"; // RouterScreen walks it to resolve each menu's subcommands), the global flags // (region/json/debug), and a no-op JsonRenderer. Compiling the real handler tree // keeps the command menus faithful to the production command structure. -function baseContext(core: TestCoreClient): Context { +function baseContext(core: TestCoreClient, endpointUrl?: string): Context { const rootCommand = compile( createRootHandler(core, { io: testIO().io, logger: createSilentLogger() }), ValueContext.EmptyContext(), @@ -36,6 +36,7 @@ function baseContext(core: TestCoreClient): Context { return ValueContext.EmptyContext() .withValue(CommandKey, rootCommand) .withValue(RegionKey, "us-east-1") + .withValue(EndpointKey, endpointUrl) .withValue(JsonKey, false) .withValue(DebugKey, false) .withValue(JsonRendererKey, { renderJson: () => {} }); @@ -56,6 +57,10 @@ export interface RenderScreenOptions { core?: TestCoreClient; // ctx overrides the base context (rarely needed). ctx?: Context; + // queryClient overrides the deterministic default when a test needs to + // exercise cache behavior. + queryClient?: QueryClient; + endpointUrl?: string; } export interface RenderScreenResult { @@ -72,14 +77,29 @@ export interface RenderScreenResult { // press sends a named key (e.g. "return", "escape", "down"), then yields a tick // for the same reason as `write`. press: (key: keyof typeof keys) => Promise; + resize: (columns: number, rows?: number) => Promise; rerender: () => void; unmount: () => void; } +interface ResizableStdout { + readonly columns: number; + readonly rows?: number; + emit(event: "resize"): boolean; +} + +function setWindowSize(stdout: ResizableStdout, columns: number, rows: number): void { + Object.defineProperties(stdout, { + columns: { configurable: true, value: columns }, + rows: { configurable: true, value: rows }, + }); + stdout.emit("resize"); +} + // keys maps friendly names to the escape sequences Ink decodes into key events. export const keys = { return: "\r", - escape: "", + escape: "\u001B[27u", up: "", down: "", left: "", @@ -98,17 +118,12 @@ export function cleanupScreens(): void { // and returns handles to read frames and send input. export function renderScreen(path: string, options: RenderScreenOptions = {}): RenderScreenResult { const core = options.core ?? new TestCoreClient(); - const ctx = options.ctx ?? baseContext(core); - const queryClient = testQueryClient(); - - const instance = render(); + const ctx = options.ctx ?? baseContext(core, options.endpointUrl); + const queryClient = options.queryClient ?? testQueryClient(); - // ink-testing-library's fake stdout reports columns=100 but no rows, so Ink - // falls back to the host terminal's height — making tests clip (and fail) - // differently per environment. Pin a fixed, realistically sized window and - // announce it; useWindowSize listens for "resize" and re-renders. - Object.defineProperty(instance.stdout, "rows", { value: 40 }); - instance.stdout.emit("resize"); + const instance = render(<>); + setWindowSize(instance.stdout, 100, 40); + instance.rerender(); return { core, @@ -127,6 +142,13 @@ export function renderScreen(path: string, options: RenderScreenOptions = {}): R instance.stdin.write(keys[key]); await tick(); }, + resize: async (columns, rows = 40) => { + setWindowSize(instance.stdout, columns, rows); + await waitFor(() => { + const lines = (instance.lastFrame() ?? "").split("\n"); + return lines.length === rows && Math.max(...lines.map((line) => line.length)) === columns; + }); + }, rerender: () => instance.rerender(), unmount: instance.unmount, diff --git a/src/testing/testIO.tsx b/src/testing/testIO.tsx index 8c6da4c88..a07ed36d1 100644 --- a/src/testing/testIO.tsx +++ b/src/testing/testIO.tsx @@ -14,6 +14,10 @@ export interface TestIO { stderr(): string; } +export interface TestIOOptions { + isTTY?: boolean; +} + // collect wraps a PassThrough, accumulating everything written to it as a string. function collect(): { stream: NodeJS.WriteStream; read: () => string } { const stream = new PassThrough(); @@ -29,11 +33,15 @@ function collect(): { stream: NodeJS.WriteStream; read: () => string } { // testIO builds a fresh in-memory TestIO for a single test. stdin is an idle // PassThrough (no input) so screens that read input simply see nothing. -export function testIO(): TestIO { +export function testIO({ isTTY = false }: TestIOOptions = {}): TestIO { const out = collect(); const err = collect(); const stdin = new PassThrough() as unknown as NodeJS.ReadStream; + for (const stream of [stdin, out.stream, err.stream]) { + Object.defineProperty(stream, "isTTY", { configurable: true, value: isTTY }); + } + return { io: { stdin, stdout: out.stream, stderr: err.stream }, stdout: out.read, diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 9c6f91ec2..4978d1f9f 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -41,6 +41,10 @@ export async function renderTuiAt( core: Core, io: AppIO, ): Promise { + if (!io.stdin.isTTY || !io.stdout.isTTY) { + throw new TypeError("interactive mode requires a TTY on stdin and stdout"); + } + // alternateScreen switches the terminal to its alternate buffer so the TUI // takes over the screen and the prior scrollback is restored on exit (like Vim). const { waitUntilExit } = render(, { diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index c018fb748..9fb274b11 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -1,7 +1,30 @@ import { test, expect, describe } from "bun:test"; import { createRootHandler } from "../handlers"; import { renderJson } from "./index"; -import { createSilentLogger, TestCoreClient, testIO } from "../testing"; +import { createSilentLogger, TestCoreClient, testIO, tick, waitFor } from "../testing"; + +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", () => { @@ -40,3 +63,63 @@ describe("--json short-circuits the TUI", () => { expect(out).toContain("get"); }); }); + +describe("TUI stream boundary", () => { + test.each([ + ["stdin and stdout", false, false], + ["stdin", false, true], + ["stdout", true, false], + ] as const)( + "rejects interactive mode when %s is non-TTY", + async (_label, stdinIsTTY, stdoutIsTTY) => { + const io = testIO({ isTTY: true }); + Object.defineProperty(io.io.stdin, "isTTY", { configurable: true, value: stdinIsTTY }); + Object.defineProperty(io.io.stdout, "isTTY", { configurable: true, value: stdoutIsTTY }); + const root = createRootHandler(new TestCoreClient(), { + io: io.io, + logger: createSilentLogger(), + }); + + await expect(root.route(["node", "agentcore"])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + }, + ); + + test("Ctrl+C exits a Runtime list and ignores input after exit", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [ + { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeId: "runtime-123", + agentRuntimeVersion: "7", + agentRuntimeName: "checkout", + description: "Checkout Runtime", + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + status: "READY", + }, + ], + nextToken: "page-2", + }); + const { streams, stdin } = ttyTestIO(); + const root = createRootHandler(core, { + io: streams.io, + logger: createSilentLogger(), + }); + const routePromise = root.route(["node", "agentcore", "runtime", "list"]); + const listCalls = () => core.runtime.calls.filter((call) => call.method === "listRuntimes"); + + await waitFor(() => listCalls().length > 0); + await tick(); + const callsBeforeExit = listCalls().length; + + stdin.write(String.fromCharCode(3)); + await expect(routePromise).resolves.toBeUndefined(); + + stdin.write("l"); + await tick(); + expect(listCalls()).toHaveLength(callsBeforeExit); + expect(listCalls().some((call) => call.args[0] === "page-2")).toBe(false); + }); +});