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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/components/ui/task-list/TaskList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, test } from "bun:test";
import { cleanup, render } from "ink-testing-library";
import { TaskList, type Task } from "./TaskList";

afterEach(cleanup);

function frameOf(tasks: Task[], tailLines?: number): string {
const instance = render(<TaskList tasks={tasks} tailLines={tailLines} />);
const frame = instance.lastFrame() ?? "";
instance.unmount();
return frame;
}

describe("TaskList", () => {
test("marks done, running, and failed tasks with the shared glyphs", () => {
const frame = frameOf([
{ title: "Verifying AWS account", state: "done", tail: [] },
{ title: "Deploying stack", state: "running", tail: [] },
{ title: "Removing stack", state: "failed", tail: [] },
]);

expect(frame).toContain("✓ Verifying AWS account");
expect(frame).toContain("✕ Removing stack");
// The running task renders a spinner frame instead of a completion glyph.
expect(frame).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Deploying stack/);
});

test("shows the tail only under a running task, gutter-prefixed", () => {
const frame = frameOf([
{ title: "Synthesizing", state: "done", tail: ["stale synth line"] },
{ title: "Deploying", state: "running", tail: ["3/12 | CREATE_IN_PROGRESS"] },
]);

expect(frame).toContain(" │ 3/12 | CREATE_IN_PROGRESS");
expect(frame).not.toContain("stale synth line");
});

test("keeps a failed task's tail visible", () => {
const frame = frameOf([
{ title: "Deploying", state: "failed", tail: ["CREATE_FAILED | AWS::IAM::Role"] },
]);

expect(frame).toContain("✕ Deploying");
expect(frame).toContain(" │ CREATE_FAILED | AWS::IAM::Role");
});

test("shows only the last tailLines lines", () => {
const frame = frameOf(
[{ title: "Deploying", state: "running", tail: ["one", "two", "three"] }],
2,
);

expect(frame).not.toContain("│ one");
expect(frame).toContain("│ two");
expect(frame).toContain("│ three");
});

test("truncates tail lines to the terminal width", () => {
const instance = render(<></>);
Object.defineProperty(instance.stdout, "columns", { configurable: true, value: 24 });
instance.rerender(
<TaskList
tasks={[
{
title: "Deploying",
state: "running",
tail: ["a very long resource event line that would wrap"],
},
]}
/>,
);

const tailLine = (instance.lastFrame() ?? "").split("\n").find((line) => line.includes("│"))!;
expect(tailLine.length).toBeLessThanOrEqual(24);
expect(tailLine).toContain("…");
instance.unmount();
});
});
68 changes: 68 additions & 0 deletions src/components/ui/task-list/TaskList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React from "react";
import { Box, Text, useStdout } from "ink";
import cliTruncate from "cli-truncate";
import { darkTheme } from "../_core.js";
import type { InkUITheme } from "../_core.js";
import { Spinner } from "../spinner/Spinner.js";

export type TaskState = "running" | "done" | "failed";

export interface Task {
title: string;
state: TaskState;
/** Recent output lines attributed to this task. Only shown while it runs (or after it fails). */
tail: string[];
}

export interface TaskListProps {
tasks: Task[];
/** Maximum tail lines rendered under a running or failed task. */
tailLines?: number;
theme?: InkUITheme;
}

const DEFAULT_TAIL_LINES = 5;

/**
* A vertical list of long-running steps: a spinner marks the running task, ✓/✕
* mark finished ones (the Stepper glyph vocabulary), and the running task shows
* a live tail of its recent output behind a muted `│` gutter. Presentational
* only — drive it from runWithProgress (src/tui/progress.tsx) or feed it Task
* state directly. Designed for inline (scrollback) rendering, where a finished
* task's collapsed tail leaves only its ✓ line behind.
*/
export const TaskList: React.FC<TaskListProps> = ({
tasks,
tailLines = DEFAULT_TAIL_LINES,
theme = darkTheme,
}) => {
const { stdout } = useStdout();
// `||`, not `??`: a pty can report 0 columns, which would truncate every
// tail line to nothing. Match Ink's own layout fallback of 80.
const columns = stdout?.columns || 80;

return (
<Box flexDirection="column">
{tasks.map((task, index) => (
<Box key={`${index}-${task.title}`} flexDirection="column">
{task.state === "running" ? (
<Spinner label={task.title} theme={theme} />
) : (
<Box>
<Text color={task.state === "done" ? theme.colors.success : theme.colors.error}>
{task.state === "done" ? "✓" : "✕"}
</Text>
<Text color={theme.colors.text}> {task.title}</Text>
</Box>
)}
{task.state !== "done" &&
task.tail.slice(-tailLines).map((line, lineIndex) => (
<Text key={`${lineIndex}-${line}`} color={theme.colors.muted} wrap="truncate-end">
{cliTruncate(` │ ${line}`, columns)}
</Text>
))}
</Box>
))}
</Box>
);
};
2 changes: 2 additions & 0 deletions src/components/ui/task-list/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { Task, TaskListProps, TaskState } from "./TaskList.js";
export { TaskList } from "./TaskList.js";
67 changes: 62 additions & 5 deletions src/core/project/backends/cdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ type HarnessOptions = {
bootstrapError?: Error;
/** Stack returned by CloudFormation. Defaults to a present stack; null means absent. */
describedStack?: Stack | null;
/** Chunks the fake synth process streams through onOutput. */
synthOutput?: string[];
/** Lines the fake Toolkit reports through each operation's onOutput sink. */
cdkOutput?: string[];
};

function harness(options: HarnessOptions = {}) {
Expand All @@ -148,8 +152,9 @@ function harness(options: HarnessOptions = {}) {

const backend = new CdkBackend({
logger: createSilentLogger(),
runner: async (command, { cwd }) => {
runner: async (command, { cwd, onOutput }) => {
commands.push({ command, cwd });
for (const chunk of options.synthOutput ?? []) onOutput?.(chunk);
},
checkTool: async () => {},
resolveCredentials: async (region) => {
Expand All @@ -169,6 +174,7 @@ function harness(options: HarnessOptions = {}) {
},
cdk: async (operation, runOptions) => {
runs.push({ operation, options: runOptions });
for (const line of options.cdkOutput ?? []) runOptions.onOutput?.(line);
if (operation.kind === options.failOperation) {
throw new Error(`${operation.kind} failed`);
}
Expand Down Expand Up @@ -248,11 +254,25 @@ describe("CdkBackend.build", () => {
const subject = harness();

expect(await collect(subject.backend.build(input))).toEqual([
{ message: "Synthesizing CloudFormation templates" },
{ type: "step", message: "Synthesizing CloudFormation templates" },
]);
expect(subject.commands).toEqual([{ command: synthCommand(input), cwd: cdkDirectory(input) }]);
});

test("streams synth output as line-buffered output events", async () => {
const input = await project();
// The chunk boundary splits a line, so a chunk-per-event bridge would leak
// the fragments "line t" / "wo".
const subject = harness({ synthOutput: ["line one\nline t", "wo\ntrailing partial"] });

expect(await collect(subject.backend.build(input))).toEqual([
{ type: "step", message: "Synthesizing CloudFormation templates" },
{ type: "output", line: "line one" },
{ type: "output", line: "line two" },
{ type: "output", line: "trailing partial" },
]);
});

test("fails actionably when CDK dependencies are missing", async () => {
const input = await project(false);
const subject = harness();
Expand Down Expand Up @@ -284,9 +304,9 @@ describe("CdkBackend.deploy", () => {
const deployed = await collectDeploy(subject.backend.deploy(input, deployInput()));

expect(deployed.events).toEqual([
{ message: `Verifying AWS account ${TARGET.account}` },
{ message: "Synthesizing CloudFormation templates" },
{ message: "Deploying AgentCore-example-default-0" },
{ type: "step", message: `Verifying AWS account ${TARGET.account}` },
{ type: "step", message: "Synthesizing CloudFormation templates" },
{ type: "step", message: "Deploying AgentCore-example-default-0" },
]);
expect(deployed.result).toEqual({ outputs: { RuntimeArn: "arn:runtime" } });
expect(subject.commands).toEqual([{ command: synthCommand(input), cwd: cdkDirectory(input) }]);
Expand All @@ -300,6 +320,9 @@ describe("CdkBackend.deploy", () => {
assemblyDirectory: assemblyDirectory(input),
credentials: subject.credentials,
region: TARGET.region,
// The backend wires each operation's Toolkit output into its own
// event stream through this per-operation sink.
onOutput: expect.any(Function),
},
},
]);
Expand All @@ -311,6 +334,38 @@ describe("CdkBackend.deploy", () => {
expect(subject.templateLoads()).toBe(0);
});

test("streams Toolkit lines as output events under the deploy step", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name]);
const subject = harness({
outputs: {},
cdkOutput: ["AgentCore-example-default-0 | 4/12 | CREATE_IN_PROGRESS"],
});

const deployed = await collectDeploy(subject.backend.deploy(input, deployInput()));

const deployStep = deployed.events.findIndex(
(event) => event.type === "step" && event.message.startsWith("Deploying"),
);
expect(deployed.events.slice(deployStep)).toEqual([
{ type: "step", message: "Deploying AgentCore-example-default-0" },
{ type: "output", line: "AgentCore-example-default-0 | 4/12 | CREATE_IN_PROGRESS" },
]);
});

test("attaches the Toolkit's recent output to a terse operation failure", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name]);
const subject = harness({
failOperation: "deploy",
cdkOutput: ["CREATE_FAILED | AWS::IAM::Role | RuntimeRole", "ROLLBACK_IN_PROGRESS"],
});

await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow(
/deploy failed[\s\S]*Recent output:[\s\S]*CREATE_FAILED \| AWS::IAM::Role \| RuntimeRole[\s\S]*ROLLBACK_IN_PROGRESS/,
);
});

test("persists the deployed stack ARN under the target", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name]);
Expand Down Expand Up @@ -440,6 +495,7 @@ describe("CdkBackend.deploy", () => {

expect(deployed.result).toEqual({ outputs: {}, tornDown: true });
expect(deployed.events).toContainEqual({
type: "step",
message: "Removing stack AgentCore-example-default-0",
});
// Destroyed explicitly, rather than by deploying an empty template and
Expand Down Expand Up @@ -500,6 +556,7 @@ describe("CdkBackend.deploy", () => {
{ kind: "deploy", stackArtifactId: "AgentCore-example-default-0" },
]);
expect(deployed.events).toContainEqual({
type: "step",
message: `Bootstrapping aws://${TARGET.account}/${TARGET.region}`,
});
});
Expand Down
Loading
Loading