diff --git a/package-lock.json b/package-lock.json
index edbb75e8..3fc82867 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -54,6 +54,7 @@
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-security": "^3.0.1",
"husky": "^9.1.7",
+ "ink-testing-library": "^4.0.0",
"lint-staged": "^16.2.7",
"prettier": "^3.7.4",
"secretlint": "^11.3.0",
@@ -9628,6 +9629,24 @@
"react": ">=18.0.0"
}
},
+ "node_modules/ink-testing-library": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz",
+ "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/ink/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
diff --git a/package.json b/package.json
index 15edbcef..65edec94 100644
--- a/package.json
+++ b/package.json
@@ -112,6 +112,7 @@
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-security": "^3.0.1",
"husky": "^9.1.7",
+ "ink-testing-library": "^4.0.0",
"lint-staged": "^16.2.7",
"prettier": "^3.7.4",
"secretlint": "^11.3.0",
diff --git a/src/cli/tui/__tests__/exit-message.test.ts b/src/cli/tui/__tests__/exit-message.test.ts
new file mode 100644
index 00000000..3a512a58
--- /dev/null
+++ b/src/cli/tui/__tests__/exit-message.test.ts
@@ -0,0 +1,30 @@
+import { clearExitMessage, getExitMessage, setExitMessage } from '../exit-message.js';
+import { afterEach, describe, expect, it } from 'vitest';
+
+describe('exit-message', () => {
+ afterEach(() => clearExitMessage());
+
+ it('returns null when no message set', () => {
+ expect(getExitMessage()).toBeNull();
+ });
+
+ it('stores and retrieves a message', () => {
+ setExitMessage('Goodbye!');
+
+ expect(getExitMessage()).toBe('Goodbye!');
+ });
+
+ it('clears the message', () => {
+ setExitMessage('Bye');
+ clearExitMessage();
+
+ expect(getExitMessage()).toBeNull();
+ });
+
+ it('overwrites previous message', () => {
+ setExitMessage('First');
+ setExitMessage('Second');
+
+ expect(getExitMessage()).toBe('Second');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/AwsTargetConfigUI.test.tsx b/src/cli/tui/components/__tests__/AwsTargetConfigUI.test.tsx
new file mode 100644
index 00000000..e6bf3800
--- /dev/null
+++ b/src/cli/tui/components/__tests__/AwsTargetConfigUI.test.tsx
@@ -0,0 +1,35 @@
+import { getAwsConfigHelpText } from '../AwsTargetConfigUI.js';
+import { describe, expect, it } from 'vitest';
+
+describe('getAwsConfigHelpText', () => {
+ it('returns exact help text for choice phase', () => {
+ expect(getAwsConfigHelpText('choice')).toBe('↑↓ navigate · Enter select · Esc exit');
+ });
+
+ it('returns same help text for token-expired as choice', () => {
+ expect(getAwsConfigHelpText('token-expired')).toBe(getAwsConfigHelpText('choice'));
+ });
+
+ it('returns exact help text for select-target phase', () => {
+ expect(getAwsConfigHelpText('select-target')).toBe('↑↓ navigate · Space toggle · Enter deploy · Esc exit');
+ });
+
+ it('returns exact help text for manual-account phase', () => {
+ expect(getAwsConfigHelpText('manual-account')).toBe('12-digit account ID · Esc back');
+ });
+
+ it('returns exact help text for manual-region phase', () => {
+ expect(getAwsConfigHelpText('manual-region')).toBe('Type to filter · ↑↓ navigate · Enter select · Esc back');
+ });
+
+ it('returns undefined for loading phases', () => {
+ expect(getAwsConfigHelpText('checking')).toBeUndefined();
+ expect(getAwsConfigHelpText('detecting')).toBeUndefined();
+ expect(getAwsConfigHelpText('saving')).toBeUndefined();
+ });
+
+ it('returns undefined for terminal phases', () => {
+ expect(getAwsConfigHelpText('configured')).toBeUndefined();
+ expect(getAwsConfigHelpText('error')).toBeUndefined();
+ });
+});
diff --git a/src/cli/tui/components/__tests__/ConfirmReview.test.tsx b/src/cli/tui/components/__tests__/ConfirmReview.test.tsx
new file mode 100644
index 00000000..f9c14200
--- /dev/null
+++ b/src/cli/tui/components/__tests__/ConfirmReview.test.tsx
@@ -0,0 +1,89 @@
+import { ConfirmReview } from '../ConfirmReview.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+describe('ConfirmReview', () => {
+ it('renders default title and help text', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('Review Configuration');
+ expect(frame).toContain('Enter confirm');
+ expect(frame).toContain('Esc back');
+ });
+
+ it('renders custom title', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('Review Deploy');
+ expect(lastFrame()).not.toContain('Review Configuration');
+ });
+
+ it('renders each field as label: value on the same line', () => {
+ const { lastFrame } = render(
+
+ );
+ const lines = lastFrame()!.split('\n');
+
+ // Each label and its value should appear on the same line
+ const nameLine = lines.find(l => l.includes('Name'))!;
+ expect(nameLine).toContain('my-agent');
+
+ const sdkLine = lines.find(l => l.includes('SDK'))!;
+ expect(sdkLine).toContain('Strands');
+
+ const langLine = lines.find(l => l.includes('Language'))!;
+ expect(langLine).toContain('Python');
+ });
+
+ it('renders label with colon separator', () => {
+ const { lastFrame } = render();
+ const lines = lastFrame()!.split('\n');
+
+ const regionLine = lines.find(l => l.includes('Region'))!;
+ expect(regionLine).toMatch(/Region.*:.*us-east-1/);
+ });
+
+ it('renders custom help text replacing default', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('Press Y to confirm');
+ expect(lastFrame()).not.toContain('Enter confirm');
+ });
+
+ it('renders multiple fields in order', () => {
+ const { lastFrame } = render(
+
+ );
+ const frame = lastFrame()!;
+
+ // All three labels should be present
+ expect(frame).toContain('First');
+ expect(frame).toContain('Second');
+ expect(frame).toContain('Third');
+
+ // Verify ordering: First appears before Second
+ const firstIdx = frame.indexOf('First');
+ const secondIdx = frame.indexOf('Second');
+ const thirdIdx = frame.indexOf('Third');
+ expect(firstIdx).toBeLessThan(secondIdx);
+ expect(secondIdx).toBeLessThan(thirdIdx);
+ });
+});
diff --git a/src/cli/tui/components/__tests__/CredentialSourcePrompt.test.tsx b/src/cli/tui/components/__tests__/CredentialSourcePrompt.test.tsx
new file mode 100644
index 00000000..0c7e3764
--- /dev/null
+++ b/src/cli/tui/components/__tests__/CredentialSourcePrompt.test.tsx
@@ -0,0 +1,83 @@
+import { CredentialSourcePrompt } from '../CredentialSourcePrompt.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const ENTER = '\r';
+
+afterEach(() => vi.restoreAllMocks());
+
+const defaultProps = {
+ missingCredentials: [
+ { providerName: 'OpenAI', envVarName: 'OPENAI_API_KEY' },
+ { providerName: 'Anthropic', envVarName: 'ANTHROPIC_API_KEY' },
+ ],
+ onUseEnvLocal: vi.fn(),
+ onManualEntry: vi.fn(),
+ onSkip: vi.fn(),
+};
+
+describe('CredentialSourcePrompt', () => {
+ it('renders title', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Identity Provider Setup');
+ });
+
+ it('renders provider names', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('OpenAI');
+ expect(frame).toContain('Anthropic');
+ });
+
+ it('renders credential count', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('2 identity providers');
+ });
+
+ it('renders singular provider count', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('1 identity provider configured');
+ });
+
+ it('renders source options', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('.env.local');
+ expect(frame).toContain('Enter credentials manually');
+ expect(frame).toContain('Skip for now');
+ });
+
+ it('calls onUseEnvLocal when first option selected', () => {
+ const onUseEnvLocal = vi.fn();
+ const { stdin } = render();
+
+ // First option is already selected
+ stdin.write(ENTER);
+
+ expect(onUseEnvLocal).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders "Not saved to disk" description for manual entry option', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Not saved to disk');
+ });
+
+ it('shows navigation help text', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('navigate');
+ expect(lastFrame()).toContain('Enter select');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/Cursor.test.tsx b/src/cli/tui/components/__tests__/Cursor.test.tsx
new file mode 100644
index 00000000..376b48cb
--- /dev/null
+++ b/src/cli/tui/components/__tests__/Cursor.test.tsx
@@ -0,0 +1,40 @@
+import { Cursor } from '../Cursor.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('Cursor', () => {
+ it('renders the provided character on initial mount', () => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('X');
+ });
+
+ it('sets up a blink interval using setInterval', () => {
+ const spy = vi.spyOn(globalThis, 'setInterval');
+ render();
+ // Cursor uses setInterval with the provided interval for blinking
+ expect(spy).toHaveBeenCalledWith(expect.any(Function), 500);
+ });
+
+ it('uses custom interval value for the blink timer', () => {
+ const spy = vi.spyOn(globalThis, 'setInterval');
+ render();
+ expect(spy).toHaveBeenCalledWith(expect.any(Function), 200);
+ });
+
+ it('renders with default space character when no char prop given', () => {
+ const { lastFrame } = render();
+ // Default char is a space — component should render without errors
+ expect(lastFrame()).toBeDefined();
+ });
+
+ it('cleans up interval timer on unmount', () => {
+ const spy = vi.spyOn(globalThis, 'clearInterval');
+ const { unmount } = render();
+ unmount();
+ // clearInterval should be called during cleanup
+ expect(spy).toHaveBeenCalled();
+ });
+});
diff --git a/src/cli/tui/components/__tests__/DeployStatus.test.tsx b/src/cli/tui/components/__tests__/DeployStatus.test.tsx
new file mode 100644
index 00000000..f13ad796
--- /dev/null
+++ b/src/cli/tui/components/__tests__/DeployStatus.test.tsx
@@ -0,0 +1,176 @@
+import type { DeployMessage } from '../../../cdk/toolkit-lib/index.js';
+import { DeployStatus } from '../DeployStatus.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+function makeMsg(
+ message: string,
+ code = 'CDK_TOOLKIT_I5502',
+ progress?: { completed: number; total: number }
+): DeployMessage {
+ return {
+ message,
+ code,
+ level: 'info',
+ time: new Date(),
+ timestamp: new Date(),
+ progress,
+ } as DeployMessage;
+}
+
+function makeResourceMsg(resourceType: string, status: string): DeployMessage {
+ return makeMsg(`MyStack | ${status} | AWS::${resourceType} | LogicalId`);
+}
+
+describe('DeployStatus', () => {
+ describe('header state', () => {
+ it('shows "Deploying to AWS" when not complete', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Deploying to AWS');
+ });
+
+ it('shows success message when complete without error', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('✓');
+ expect(frame).toContain('Deploy to AWS Complete');
+ });
+
+ it('shows failure message when complete with error', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('✗');
+ expect(frame).toContain('Deploy to AWS Failed');
+ });
+ });
+
+ describe('resource event parsing', () => {
+ it('displays parsed resource type and status from CDK event messages', () => {
+ const messages = [
+ makeResourceMsg('Lambda::Function', 'CREATE_IN_PROGRESS'),
+ makeResourceMsg('Lambda::Function', 'CREATE_COMPLETE'),
+ ];
+
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('Lambda::Function');
+ expect(frame).toContain('CREATE_COMPLETE');
+ });
+
+ it('strips AWS:: prefix from resource types', () => {
+ const messages = [makeResourceMsg('S3::Bucket', 'CREATE_COMPLETE')];
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('S3::Bucket');
+ expect(lastFrame()).not.toContain('AWS::S3::Bucket');
+ });
+
+ it('skips CLEANUP messages', () => {
+ const messages = [
+ makeResourceMsg('Lambda::Function', 'CREATE_COMPLETE'),
+ makeMsg('MyStack | CLEANUP_IN_PROGRESS | AWS::Lambda::Function | OldFunc'),
+ ];
+
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('CREATE_COMPLETE');
+ expect(frame).not.toContain('CLEANUP');
+ });
+
+ it('ignores non-resource-event messages (non-I5502 codes)', () => {
+ const messages = [makeMsg('Some general info', 'CDK_TOOLKIT_I1234')];
+
+ const { lastFrame } = render();
+
+ // Should show deploying text but no resource lines
+ expect(lastFrame()).toContain('Deploying to AWS');
+ expect(lastFrame()).not.toContain('Some general info');
+ });
+
+ it('shows only last 8 resource events', () => {
+ const messages = Array.from({ length: 12 }, (_, i) =>
+ makeResourceMsg(`Service::Resource${i}`, 'CREATE_COMPLETE')
+ );
+
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ // First 4 should be trimmed (12 - 8 = 4)
+ expect(frame).not.toContain('Resource0');
+ expect(frame).not.toContain('Resource3');
+ // Last 8 should be visible
+ expect(frame).toContain('Resource4');
+ expect(frame).toContain('Resource11');
+ });
+ });
+
+ describe('progress bar', () => {
+ it('renders progress bar with completed/total count', () => {
+ const messages = [makeMsg('deploying', 'CDK_TOOLKIT_I5502', { completed: 3, total: 10 })];
+
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('3/10');
+ expect(frame).toContain('█');
+ expect(frame).toContain('░');
+ });
+
+ it('shows full progress bar on completion', () => {
+ const messages = [makeMsg('done', 'CDK_TOOLKIT_I5502', { completed: 10, total: 10 })];
+
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ // On completion, bar shows total/total
+ expect(frame).toContain('10/10');
+ });
+
+ it('does not show progress bar when no progress data', () => {
+ const messages = [makeResourceMsg('Lambda::Function', 'CREATE_COMPLETE')];
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).not.toContain('█');
+ expect(lastFrame()).not.toContain('░');
+ });
+
+ it('uses most recent progress data', () => {
+ const messages = [
+ makeMsg('step1', 'CDK_TOOLKIT_I5502', { completed: 2, total: 10 }),
+ makeMsg('step2', 'CDK_TOOLKIT_I5502', { completed: 7, total: 10 }),
+ ];
+
+ const { lastFrame } = render();
+
+ // Should show the latest progress
+ expect(lastFrame()).toContain('7/10');
+ });
+ });
+
+ describe('error state details', () => {
+ it('shows last 3 resource events on failure', () => {
+ const messages = [
+ makeResourceMsg('Lambda::Function', 'CREATE_COMPLETE'),
+ makeResourceMsg('IAM::Role', 'CREATE_COMPLETE'),
+ makeResourceMsg('S3::Bucket', 'CREATE_COMPLETE'),
+ makeResourceMsg('DynamoDB::Table', 'CREATE_FAILED'),
+ ];
+
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ // Last 3 of 4 resource events should show
+ expect(frame).toContain('IAM::Role');
+ expect(frame).toContain('S3::Bucket');
+ expect(frame).toContain('DynamoDB::Table');
+ });
+ });
+});
diff --git a/src/cli/tui/components/__tests__/FatalError.test.tsx b/src/cli/tui/components/__tests__/FatalError.test.tsx
new file mode 100644
index 00000000..e13bd480
--- /dev/null
+++ b/src/cli/tui/components/__tests__/FatalError.test.tsx
@@ -0,0 +1,45 @@
+import { FatalError } from '../FatalError.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+describe('FatalError', () => {
+ it('renders error message', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Something went wrong');
+ });
+
+ it('renders detail when provided', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Error');
+ expect(lastFrame()).toContain('Check your config file');
+ });
+
+ it('renders suggested command when provided', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('No project found');
+ expect(lastFrame()).toContain('agentcore create');
+ expect(lastFrame()).toContain('to fix this');
+ });
+
+ it('renders all props together', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('Deploy failed');
+ expect(lastFrame()).toContain('Stack is in ROLLBACK state');
+ expect(lastFrame()).toContain('agentcore status');
+ });
+
+ it('does not render detail when not provided', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('Error');
+ expect(frame).not.toContain('to fix this');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/FullScreenLogView.test.tsx b/src/cli/tui/components/__tests__/FullScreenLogView.test.tsx
new file mode 100644
index 00000000..887d3f9b
--- /dev/null
+++ b/src/cli/tui/components/__tests__/FullScreenLogView.test.tsx
@@ -0,0 +1,157 @@
+import { FullScreenLogView } from '../FullScreenLogView.js';
+import type { LogEntry } from '../LogPanel.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const ESCAPE = '\x1B';
+const UP = '\x1B[A';
+
+function delay(ms = 50) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+function makeLogs(count: number): LogEntry[] {
+ return Array.from({ length: count }, (_, i) => ({
+ level: 'info' as const,
+ message: `Log message ${i + 1}`,
+ }));
+}
+
+describe('FullScreenLogView', () => {
+ it('renders log entries', () => {
+ const logs: LogEntry[] = [
+ { level: 'info', message: 'Starting deploy' },
+ { level: 'error', message: 'Deploy failed' },
+ ];
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('Starting deploy');
+ expect(frame).toContain('Deploy failed');
+ });
+
+ it('renders header with entry count', () => {
+ const logs = makeLogs(5);
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('5 entries');
+ });
+
+ it('renders log file path when provided', () => {
+ const logs = makeLogs(2);
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('/tmp/deploy.log');
+ });
+
+ it('shows "No logs yet" when empty', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('No logs yet');
+ });
+
+ it('calls onExit on Escape key', () => {
+ const onExit = vi.fn();
+ const { stdin } = render();
+
+ stdin.write(ESCAPE);
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onExit on q key', () => {
+ const onExit = vi.fn();
+ const { stdin } = render();
+
+ stdin.write('\x11'); // Ctrl+Q
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onExit on l key', () => {
+ const onExit = vi.fn();
+ const { stdin } = render();
+
+ stdin.write('l');
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders error log with level label', () => {
+ const logs: LogEntry[] = [{ level: 'error', message: 'Something broke' }];
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('ERROR');
+ expect(frame).toContain('Something broke');
+ });
+
+ it('renders response log with special formatting', () => {
+ const logs: LogEntry[] = [{ level: 'response', message: 'Agent response text' }];
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('Response');
+ expect(frame).toContain('Agent response text');
+ });
+
+ it('renders footer with navigation hints', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Esc/q/l exit');
+ });
+
+ it('shows scroll percentage', () => {
+ const logs = makeLogs(3);
+ const { lastFrame } = render();
+
+ // Should show some percentage
+ expect(lastFrame()).toMatch(/\d+%/);
+ });
+
+ it('scrolls with arrow keys', async () => {
+ // Create enough logs to require scrolling
+ const logs = makeLogs(50);
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(UP);
+ await delay();
+
+ // After scrolling up, the frame should change
+ const frame = lastFrame()!;
+ expect(frame).toMatch(/\d+%/);
+ });
+
+ it('supports vim-style navigation with j/k', async () => {
+ const logs = makeLogs(50);
+ const { stdin } = render();
+
+ // These should not throw
+ await delay();
+ stdin.write('k'); // scroll up
+ stdin.write('j'); // scroll down
+ await delay();
+ });
+
+ it('supports g/G for top/bottom navigation', async () => {
+ const logs = makeLogs(50);
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write('g'); // go to top
+ await delay();
+
+ // At top, should show first log
+ expect(lastFrame()).toContain('Log message 1');
+
+ stdin.write('G'); // go to bottom
+ await delay();
+
+ // At bottom, should show last log
+ expect(lastFrame()).toContain('Log message 50');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/Header.test.tsx b/src/cli/tui/components/__tests__/Header.test.tsx
new file mode 100644
index 00000000..5abf4358
--- /dev/null
+++ b/src/cli/tui/components/__tests__/Header.test.tsx
@@ -0,0 +1,34 @@
+import { Header } from '../Header.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+describe('Header', () => {
+ it('renders title', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('AgentCore');
+ });
+
+ it('renders subtitle when provided', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('AgentCore');
+ expect(lastFrame()).toContain('CLI for AI agents');
+ });
+
+ it('renders version when provided', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('AgentCore');
+ expect(lastFrame()).toContain('1.2.3');
+ });
+
+ it('renders all props', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('AgentCore');
+ expect(lastFrame()).toContain('CLI');
+ expect(lastFrame()).toContain('0.1.0');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/HelpText.test.tsx b/src/cli/tui/components/__tests__/HelpText.test.tsx
new file mode 100644
index 00000000..ccfcc146
--- /dev/null
+++ b/src/cli/tui/components/__tests__/HelpText.test.tsx
@@ -0,0 +1,20 @@
+import { ExitHelpText, HelpText } from '../HelpText.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+describe('HelpText', () => {
+ it('renders text', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Press Enter to continue');
+ });
+});
+
+describe('ExitHelpText', () => {
+ it('renders exit instructions', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Press ESC or Ctrl+Q to exit');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/LogLink.test.tsx b/src/cli/tui/components/__tests__/LogLink.test.tsx
new file mode 100644
index 00000000..4c79d790
--- /dev/null
+++ b/src/cli/tui/components/__tests__/LogLink.test.tsx
@@ -0,0 +1,30 @@
+import { LogLink } from '../LogLink.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+describe('LogLink', () => {
+ it('renders with prefix and relative path', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Log:');
+ });
+
+ it('renders custom display text', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('test.log');
+ });
+
+ it('hides prefix when showPrefix is false', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).not.toContain('Log:');
+ });
+
+ it('renders custom label', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Output:');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/LogPanel.test.tsx b/src/cli/tui/components/__tests__/LogPanel.test.tsx
new file mode 100644
index 00000000..e7e445e4
--- /dev/null
+++ b/src/cli/tui/components/__tests__/LogPanel.test.tsx
@@ -0,0 +1,205 @@
+import type { LogEntry } from '../LogPanel.js';
+import { LogPanel } from '../LogPanel.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const UP_ARROW = '\x1B[A';
+const DOWN_ARROW = '\x1B[B';
+
+afterEach(() => vi.restoreAllMocks());
+
+const makeLogs = (count: number, level: LogEntry['level'] = 'system'): LogEntry[] =>
+ Array.from({ length: count }, (_, i) => ({
+ level,
+ message: `Log message ${i + 1}`,
+ }));
+
+describe('LogPanel', () => {
+ describe('empty state', () => {
+ it('renders "No output yet" with no other content', () => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toBe('No output yet');
+ });
+ });
+
+ describe('rendering', () => {
+ it('renders system log messages without level label', () => {
+ const logs: LogEntry[] = [{ level: 'system', message: 'Agent started' }];
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+ expect(frame).toContain('Agent started');
+ // System logs don't show the level label prefix
+ expect(frame).not.toContain('SYSTEM');
+ });
+
+ it('renders response logs with "Response" separator and message', () => {
+ const logs: LogEntry[] = [{ level: 'response', message: 'Hello from agent' }];
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+ expect(frame).toContain('─── Response ───');
+ expect(frame).toContain('Hello from agent');
+ });
+
+ it('renders error logs with ERROR level prefix', () => {
+ const logs: LogEntry[] = [{ level: 'error', message: 'Something broke' }];
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+ // ERROR label is padded to 6 chars
+ expect(frame).toMatch(/ERROR\s+Something broke/);
+ });
+
+ it('renders warn logs with WARN level prefix', () => {
+ const logs: LogEntry[] = [{ level: 'warn', message: 'Slow response' }];
+ const { lastFrame } = render();
+ expect(lastFrame()).toMatch(/WARN\s+Slow response/);
+ });
+ });
+
+ describe('minimal filtering', () => {
+ it('hides info-level logs in minimal mode (default)', () => {
+ const logs: LogEntry[] = [
+ { level: 'info', message: 'Debug info' },
+ { level: 'system', message: 'Visible system log' },
+ ];
+ const { lastFrame } = render();
+ expect(lastFrame()).not.toContain('Debug info');
+ expect(lastFrame()).toContain('Visible system log');
+ });
+
+ it('hides logs containing JSON debug markers like "timestamp" or "level"', () => {
+ const logs: LogEntry[] = [
+ { level: 'error', message: '{"timestamp": "2024-01-01", "level": "ERROR"}' },
+ { level: 'system', message: 'Visible log' },
+ ];
+ const { lastFrame } = render();
+ expect(lastFrame()).not.toContain('timestamp');
+ expect(lastFrame()).toContain('Visible log');
+ });
+
+ it('hides warn/error logs starting with [ or { as JSON debug', () => {
+ const logs: LogEntry[] = [
+ { level: 'warn', message: '[{"key": "value"}]' },
+ { level: 'error', message: '{"error": "details"}' },
+ { level: 'system', message: 'Keep this' },
+ ];
+ const { lastFrame } = render();
+ expect(lastFrame()).not.toContain('key');
+ expect(lastFrame()).not.toContain('details');
+ expect(lastFrame()).toContain('Keep this');
+ });
+
+ it('always shows response and system logs even with JSON-like content', () => {
+ const logs: LogEntry[] = [
+ { level: 'response', message: '{"data": "json response"}' },
+ { level: 'system', message: '{"internal": true}' },
+ ];
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('json response');
+ expect(lastFrame()).toContain('internal');
+ });
+
+ it('shows plain error/warn messages that are not JSON', () => {
+ const logs: LogEntry[] = [
+ { level: 'error', message: 'Connection timeout' },
+ { level: 'warn', message: 'Retrying in 5s' },
+ ];
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('Connection timeout');
+ expect(lastFrame()).toContain('Retrying in 5s');
+ });
+
+ it('shows all logs including info when minimal is false', () => {
+ const logs: LogEntry[] = [
+ { level: 'info', message: 'Debug info visible' },
+ { level: 'system', message: 'System log' },
+ ];
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('Debug info visible');
+ expect(lastFrame()).toContain('System log');
+ });
+ });
+
+ describe('scrolling', () => {
+ it('shows "↑↓ scroll" indicator when logs exceed maxLines', () => {
+ const logs = makeLogs(20);
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('↑↓ scroll');
+ });
+
+ it('does not show scroll indicator when all logs fit in maxLines', () => {
+ const logs = makeLogs(3);
+ const { lastFrame } = render();
+ expect(lastFrame()).not.toContain('↑↓ scroll');
+ });
+
+ it('auto-scrolls to bottom showing latest logs', () => {
+ const logs = makeLogs(20);
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+ // Should show the last 5 logs (16-20) and "more above"
+ expect(frame).toContain('Log message 20');
+ expect(frame).toContain('Log message 16');
+ // 'Log message 1' would match 'Log message 16' etc, so use regex for exact match
+ expect(frame).not.toMatch(/Log message 1\b/);
+ expect(frame).toContain('more above');
+ });
+
+ it('switches to manual scroll on up arrow, showing earliest logs', async () => {
+ const logs = makeLogs(20);
+ const { lastFrame, stdin } = render();
+
+ // Initially auto-scrolled to bottom
+ expect(lastFrame()).toContain('Log message 20');
+
+ // Up arrow sets userScrolled=true and scrollOffset stays at 0 (initial state),
+ // so we jump to the top of the log showing messages 1-5
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(UP_ARROW);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ const frame = lastFrame()!;
+ expect(frame).toContain('Log message 1');
+ expect(frame).not.toContain('Log message 20');
+ expect(frame).toContain('more below');
+ });
+
+ it('scrolls back down to bottom after scrolling up', async () => {
+ const logs = makeLogs(20);
+ const { lastFrame, stdin } = render();
+
+ // Scroll up to top
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(UP_ARROW);
+ await new Promise(resolve => setTimeout(resolve, 50));
+ expect(lastFrame()).toContain('Log message 1');
+
+ // Scroll down past maxScroll (15) to reach the bottom
+ for (let i = 0; i < 15; i++) {
+ await new Promise(resolve => setTimeout(resolve, 20));
+ stdin.write(DOWN_ARROW);
+ }
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(lastFrame()).toContain('Log message 20');
+ });
+
+ it('supports vim-style j/k keys for scrolling', async () => {
+ const logs = makeLogs(20);
+ const { lastFrame, stdin } = render();
+
+ // k scrolls up (same as up arrow)
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write('k');
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(lastFrame()).toContain('Log message 1');
+
+ // j scrolls down
+ stdin.write('j');
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(lastFrame()).toContain('Log message 2');
+ });
+ });
+});
diff --git a/src/cli/tui/components/__tests__/MultiSelectList.test.tsx b/src/cli/tui/components/__tests__/MultiSelectList.test.tsx
new file mode 100644
index 00000000..81edde5c
--- /dev/null
+++ b/src/cli/tui/components/__tests__/MultiSelectList.test.tsx
@@ -0,0 +1,55 @@
+import { MultiSelectList } from '../MultiSelectList.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+describe('MultiSelectList', () => {
+ const items = [
+ { id: 'agent-1', title: 'Agent One' },
+ { id: 'agent-2', title: 'Agent Two', description: 'Secondary agent' },
+ { id: 'agent-3', title: 'Agent Three' },
+ ];
+
+ it('renders all items with checkboxes', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Agent One');
+ expect(lastFrame()).toContain('Agent Two');
+ expect(lastFrame()).toContain('Agent Three');
+ expect(lastFrame()).toContain('[ ]');
+ });
+
+ it('shows checked items', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('[✓]');
+ });
+
+ it('shows cursor on current index', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('❯');
+ });
+
+ it('shows descriptions', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Secondary agent');
+ });
+
+ it('shows empty state when no items', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('No agents found');
+ });
+
+ it('shows custom empty message', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('No targets');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/NextSteps.test.tsx b/src/cli/tui/components/__tests__/NextSteps.test.tsx
new file mode 100644
index 00000000..d77c715d
--- /dev/null
+++ b/src/cli/tui/components/__tests__/NextSteps.test.tsx
@@ -0,0 +1,106 @@
+import { NextSteps } from '../NextSteps.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const ENTER = '\r';
+const ESCAPE = '\x1B';
+const DOWN_ARROW = '\x1B[B';
+
+afterEach(() => vi.restoreAllMocks());
+
+const singleStep = [{ command: 'deploy', label: 'Deploy your agent' }];
+const multipleSteps = [
+ { command: 'deploy', label: 'Deploy your agent' },
+ { command: 'invoke', label: 'Test your agent' },
+];
+
+describe('NextSteps non-interactive', () => {
+ it('renders command hint for a single step', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('agentcore deploy');
+ expect(lastFrame()).toContain('deploy your agent');
+ });
+
+ it('renders all commands for multiple steps', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('agentcore deploy');
+ expect(lastFrame()).toContain('agentcore invoke');
+ expect(lastFrame()).toContain('or');
+ });
+
+ it('returns null for empty steps', () => {
+ const { lastFrame } = render();
+
+ // null render produces empty frame
+ expect(lastFrame()).toBe('');
+ });
+});
+
+describe('NextSteps interactive', () => {
+ it('renders Next steps header and selectable items', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Next steps:');
+ expect(lastFrame()).toContain('deploy');
+ expect(lastFrame()).toContain('return');
+ });
+
+ it('includes return to main menu option', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Return to main menu');
+ });
+
+ it('calls onSelect with correct step on Enter', async () => {
+ const onSelect = vi.fn();
+ const { stdin } = render();
+
+ // First item is 'deploy', press Enter
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(ENTER);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(onSelect).toHaveBeenCalledWith({ command: 'deploy', label: 'Deploy your agent' });
+ });
+
+ it('calls onSelect with second step after navigating down', async () => {
+ const onSelect = vi.fn();
+ const { stdin } = render();
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(DOWN_ARROW);
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(ENTER);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(onSelect).toHaveBeenCalledWith({ command: 'invoke', label: 'Test your agent' });
+ });
+
+ it('calls onBack when return option is selected', async () => {
+ const onBack = vi.fn();
+ const { stdin } = render();
+
+ // Navigate down past the single step to the "return" option
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(DOWN_ARROW);
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(ENTER);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(onBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onBack on Escape', async () => {
+ const onBack = vi.fn();
+ const { stdin } = render();
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(ESCAPE);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(onBack).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/cli/tui/components/__tests__/Panel.test.tsx b/src/cli/tui/components/__tests__/Panel.test.tsx
new file mode 100644
index 00000000..9435ff66
--- /dev/null
+++ b/src/cli/tui/components/__tests__/Panel.test.tsx
@@ -0,0 +1,115 @@
+import { Panel } from '../Panel.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const { mockContentWidth } = vi.hoisted(() => ({
+ mockContentWidth: { value: 60 },
+}));
+
+vi.mock('../../context/index.js', () => ({
+ useLayout: () => ({ contentWidth: mockContentWidth.value }),
+}));
+
+afterEach(() => {
+ mockContentWidth.value = 60;
+});
+
+describe('Panel', () => {
+ it('renders children content inside a border', () => {
+ const { lastFrame } = render(
+
+ Panel body
+
+ );
+ const frame = lastFrame()!;
+ expect(frame).toContain('Panel body');
+ // Verify border structure: top-left corner on first line, bottom-right on last
+ const lines = frame.split('\n');
+ expect(lines[0]).toContain('╭');
+ expect(lines[lines.length - 1]).toContain('╯');
+ });
+
+ it('renders title as first line inside border when provided', () => {
+ const { lastFrame } = render(
+
+ body
+
+ );
+ const frame = lastFrame()!;
+ expect(frame).toContain('Settings');
+ expect(frame).toContain('body');
+ // Title should appear before body in the output
+ const titleIdx = frame.indexOf('Settings');
+ const bodyIdx = frame.indexOf('body');
+ expect(titleIdx).toBeLessThan(bodyIdx);
+ });
+
+ it('does not include title text when title is omitted', () => {
+ const { lastFrame } = render(
+
+ body only
+
+ );
+ const frame = lastFrame()!;
+ expect(frame).toContain('body only');
+ // The frame should only have border + body, no extra text before body
+ const lines = frame.split('\n').filter(l => l.trim().length > 0);
+ // First meaningful content line after the top border should be the body
+ expect(lines.length).toBeGreaterThanOrEqual(3); // top border, body, bottom border
+ });
+
+ it('renders with fullWidth when fullWidth prop is true', () => {
+ // With fullWidth=false (default), Panel uses contentWidth from context
+ // With fullWidth=true, Panel uses 100%
+ const { lastFrame: narrowFrame } = render(
+
+ narrow
+
+ );
+ const { lastFrame: wideFrame } = render(
+
+ wide
+
+ );
+ // Both should render their content
+ expect(narrowFrame()).toContain('narrow');
+ expect(wideFrame()).toContain('wide');
+ });
+
+ it('adapts to different content widths from context', () => {
+ mockContentWidth.value = 30;
+ const { lastFrame: narrow } = render(
+
+ test
+
+ );
+
+ mockContentWidth.value = 100;
+ const { lastFrame: wide } = render(
+
+ test
+
+ );
+
+ // Both render successfully — the narrow panel's top border should be shorter
+ const narrowTopLine = narrow()!.split('\n')[0]!;
+ const wideTopLine = wide()!.split('\n')[0]!;
+ expect(narrowTopLine.length).toBeLessThan(wideTopLine.length);
+ });
+
+ it('renders with borderColor prop without breaking layout', () => {
+ const { lastFrame } = render(
+
+ colored border
+
+ );
+ const frame = lastFrame()!;
+ expect(frame).toContain('colored border');
+ // Border structure should still be intact
+ const lines = frame.split('\n');
+ expect(lines[0]).toContain('╭');
+ expect(lines[lines.length - 1]).toContain('╯');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/PathInput.test.tsx b/src/cli/tui/components/__tests__/PathInput.test.tsx
new file mode 100644
index 00000000..f673cc60
--- /dev/null
+++ b/src/cli/tui/components/__tests__/PathInput.test.tsx
@@ -0,0 +1,345 @@
+import { PathInput } from '../PathInput.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const mockReaddirSync = vi.hoisted(() => vi.fn());
+const mockExistsSync = vi.hoisted(() => vi.fn());
+const mockStatSync = vi.hoisted(() => vi.fn());
+
+vi.mock('node:fs', () => ({
+ readdirSync: mockReaddirSync,
+ existsSync: mockExistsSync,
+ statSync: mockStatSync,
+}));
+
+const ENTER = '\r';
+const ESCAPE = '\x1B';
+const ARROW_UP = '\x1B[A';
+const ARROW_DOWN = '\x1B[B';
+const ARROW_RIGHT = '\x1B[C';
+const ARROW_LEFT = '\x1B[D';
+const TAB = '\t';
+
+function delay(ms = 50) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ mockReaddirSync.mockReset();
+ mockExistsSync.mockReset();
+ mockStatSync.mockReset();
+});
+
+function setupEmptyFs() {
+ mockReaddirSync.mockReturnValue([]);
+ mockExistsSync.mockReturnValue(false);
+}
+
+function makeDirent(name: string, isDir: boolean) {
+ return {
+ name,
+ isDirectory: () => isDir,
+ isFile: () => !isDir,
+ isBlockDevice: () => false,
+ isCharacterDevice: () => false,
+ isFIFO: () => false,
+ isSocket: () => false,
+ isSymbolicLink: () => false,
+ parentPath: '/base',
+ path: '/base',
+ };
+}
+
+describe('PathInput', () => {
+ it('renders "Select a file:" by default', () => {
+ setupEmptyFs();
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Select a file:');
+ });
+
+ it('renders "Select a directory:" when pathType is directory', () => {
+ setupEmptyFs();
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('Select a directory:');
+ });
+
+ it('shows placeholder when value is empty', () => {
+ setupEmptyFs();
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('Enter path here');
+ });
+
+ it('shows help text', () => {
+ setupEmptyFs();
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('move');
+ expect(lastFrame()).toContain('open');
+ expect(lastFrame()).toContain('back');
+ expect(lastFrame()).toContain('Enter submit');
+ expect(lastFrame()).toContain('Esc cancel');
+ });
+
+ it('calls onCancel on Escape', async () => {
+ setupEmptyFs();
+ const onCancel = vi.fn();
+ const { stdin } = render();
+
+ await delay();
+ stdin.write(ESCAPE);
+ await delay();
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows error when submitting empty value', async () => {
+ setupEmptyFs();
+ const onSubmit = vi.fn();
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(lastFrame()).toContain('Please enter a path');
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('shows error for invalid path on submit', async () => {
+ mockReaddirSync.mockReturnValue([]);
+ mockExistsSync.mockReturnValue(false);
+
+ const onSubmit = vi.fn();
+ const { lastFrame, stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(lastFrame()).toContain('is not a valid path');
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('calls onSubmit with valid path', async () => {
+ mockReaddirSync.mockReturnValue([]);
+ mockExistsSync.mockReturnValue(true);
+ mockStatSync.mockReturnValue({ isDirectory: () => true });
+
+ const onSubmit = vi.fn();
+ const { stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onSubmit).toHaveBeenCalledWith('mydir');
+ });
+
+ it('calls onSubmit for valid file path', async () => {
+ mockReaddirSync.mockReturnValue([]);
+ mockExistsSync.mockReturnValue(true);
+
+ const onSubmit = vi.fn();
+ const { stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onSubmit).toHaveBeenCalledWith('file.txt');
+ });
+
+ it('shows completions dropdown', () => {
+ mockReaddirSync.mockReturnValue([makeDirent('src', true), makeDirent('readme.md', false)]);
+
+ const { lastFrame } = render();
+
+ const frame = lastFrame()!;
+ expect(frame).toContain('src/');
+ expect(frame).toContain('readme.md');
+ });
+
+ it('hides dotfiles from completions', () => {
+ mockReaddirSync.mockReturnValue([
+ makeDirent('.hidden', true),
+ makeDirent('.gitignore', false),
+ makeDirent('visible', true),
+ ]);
+
+ const { lastFrame } = render();
+
+ const frame = lastFrame()!;
+ expect(frame).toContain('visible/');
+ expect(frame).not.toContain('.hidden');
+ expect(frame).not.toContain('.gitignore');
+ });
+
+ it('navigates dropdown with arrow keys', async () => {
+ mockReaddirSync.mockReturnValue([makeDirent('alpha', true), makeDirent('beta', true), makeDirent('gamma', true)]);
+
+ const { lastFrame, stdin } = render();
+
+ await delay();
+
+ // Initially first item is selected
+ let frame = lastFrame()!;
+ const alphaLine = frame.split('\n').find(l => l.includes('alpha'));
+ expect(alphaLine).toContain('❯');
+
+ // Press down arrow to select second item
+ stdin.write(ARROW_DOWN);
+ await delay();
+
+ frame = lastFrame()!;
+ const betaLine = frame.split('\n').find(l => l.includes('beta'));
+ expect(betaLine).toContain('❯');
+
+ // Press up arrow to go back to first
+ stdin.write(ARROW_UP);
+ await delay();
+
+ frame = lastFrame()!;
+ const alphaLineAgain = frame.split('\n').find(l => l.includes('alpha'));
+ expect(alphaLineAgain).toContain('❯');
+ });
+
+ it('selects completion with right arrow', async () => {
+ mockReaddirSync.mockReturnValue([makeDirent('src', true), makeDirent('lib', true)]);
+
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(ARROW_RIGHT);
+ await delay();
+
+ expect(lastFrame()).toContain('src/');
+ });
+
+ it('selects completion with tab', async () => {
+ mockReaddirSync.mockReturnValue([makeDirent('docs', true)]);
+
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(TAB);
+ await delay();
+
+ // After tab the value should contain docs/
+ expect(lastFrame()).toContain('docs/');
+ });
+
+ it('goes back with left arrow', async () => {
+ mockReaddirSync.mockReturnValue([]);
+
+ const { lastFrame, stdin } = render(
+
+ );
+
+ await delay();
+ // Left arrow should go back one level
+ stdin.write(ARROW_LEFT);
+ await delay();
+
+ // Should show src/ (parent) as the current value, not src/lib/
+ const frame = lastFrame()!;
+ expect(frame).toContain('src/');
+ expect(frame).not.toContain('src/lib/');
+ });
+
+ it('shows error when directory path points to a file', async () => {
+ mockReaddirSync.mockReturnValue([]);
+ mockExistsSync.mockReturnValue(true);
+ mockStatSync.mockReturnValue({ isDirectory: () => false });
+
+ const onSubmit = vi.fn();
+ const { lastFrame, stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(lastFrame()).toContain('is not a directory');
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('only shows directories when pathType is directory', () => {
+ mockReaddirSync.mockReturnValue([makeDirent('mydir', true), makeDirent('myfile.txt', false)]);
+
+ const { lastFrame } = render(
+
+ );
+
+ const frame = lastFrame()!;
+ expect(frame).toContain('mydir/');
+ expect(frame).not.toContain('myfile.txt');
+ });
+
+ it('clears error on next input', async () => {
+ mockReaddirSync.mockReturnValue([]);
+ mockExistsSync.mockReturnValue(false);
+
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(lastFrame()).toContain('Please enter a path');
+
+ // Type something to clear error
+ stdin.write('a');
+ await delay();
+
+ expect(lastFrame()).not.toContain('Please enter a path');
+ });
+
+ it('wraps around when navigating past the last item', async () => {
+ mockReaddirSync.mockReturnValue([makeDirent('aaa', true), makeDirent('bbb', true)]);
+
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ // Down twice wraps to first
+ stdin.write(ARROW_DOWN);
+ await delay();
+ stdin.write(ARROW_DOWN);
+ await delay();
+
+ const frame = lastFrame()!;
+ const aaaLine = frame.split('\n').find(l => l.includes('aaa'));
+ expect(aaaLine).toContain('❯');
+ });
+
+ it('sorts directories before files', () => {
+ mockReaddirSync.mockReturnValue([
+ makeDirent('zfile.txt', false),
+ makeDirent('adir', true),
+ makeDirent('afile.txt', false),
+ ]);
+
+ const { lastFrame } = render();
+
+ const frame = lastFrame()!;
+ const lines = frame.split('\n');
+ const dirLine = lines.findIndex(l => l.includes('adir'));
+ const fileLine = lines.findIndex(l => l.includes('afile.txt'));
+ expect(dirLine).toBeLessThan(fileLine);
+ });
+});
diff --git a/src/cli/tui/components/__tests__/PromptScreen.test.tsx b/src/cli/tui/components/__tests__/PromptScreen.test.tsx
new file mode 100644
index 00000000..ada95f2c
--- /dev/null
+++ b/src/cli/tui/components/__tests__/PromptScreen.test.tsx
@@ -0,0 +1,286 @@
+import { ConfirmPrompt, ErrorPrompt, PromptScreen, SuccessPrompt } from '../PromptScreen.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const ENTER = '\r';
+const ESCAPE = '\x1B';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('PromptScreen', () => {
+ it('renders children and help text', () => {
+ const { lastFrame } = render(
+
+ Hello
+
+ );
+
+ expect(lastFrame()).toContain('Hello');
+ expect(lastFrame()).toContain('Press Enter');
+ });
+
+ it('calls onConfirm on Enter key', () => {
+ const onConfirm = vi.fn();
+ const { stdin } = render(
+
+ msg
+
+ );
+
+ stdin.write(ENTER);
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onConfirm on y key', () => {
+ const onConfirm = vi.fn();
+ const { stdin } = render(
+
+ msg
+
+ );
+
+ stdin.write('y');
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onExit on Escape key', () => {
+ const onExit = vi.fn();
+ const { stdin } = render(
+
+ msg
+
+ );
+
+ stdin.write(ESCAPE);
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onExit on n key', () => {
+ const onExit = vi.fn();
+ const { stdin } = render(
+
+ msg
+
+ );
+
+ stdin.write('n');
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onBack on b key', () => {
+ const onBack = vi.fn();
+ const { stdin } = render(
+
+ msg
+
+ );
+
+ stdin.write('b');
+
+ expect(onBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('ignores input when inputEnabled is false', () => {
+ const onConfirm = vi.fn();
+ const onExit = vi.fn();
+ const { stdin } = render(
+
+ msg
+
+ );
+
+ stdin.write(ENTER);
+ stdin.write(ESCAPE);
+ stdin.write('y');
+ stdin.write('n');
+
+ expect(onConfirm).not.toHaveBeenCalled();
+ expect(onExit).not.toHaveBeenCalled();
+ });
+
+ it('does not call absent callbacks', () => {
+ // PromptScreen with no onConfirm/onExit/onBack should not throw
+ const { stdin } = render(
+
+ msg
+
+ );
+
+ // These should not throw
+ stdin.write(ENTER);
+ stdin.write(ESCAPE);
+ stdin.write('b');
+ });
+});
+
+describe('SuccessPrompt', () => {
+ it('renders success message', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Deployment complete');
+ });
+
+ it('renders detail text when provided', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('3 agents deployed');
+ });
+
+ it('shows continue/exit help text when onConfirm provided', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('continue');
+ expect(frame).toContain('exit');
+ });
+
+ it('shows any key help text when no onConfirm', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('any key');
+ });
+
+ it('uses custom confirmText and exitText', () => {
+ const { lastFrame } = render(
+
+ );
+ const frame = lastFrame()!.toLowerCase();
+
+ expect(frame).toContain('deploy');
+ expect(frame).toContain('cancel');
+ });
+});
+
+describe('ErrorPrompt', () => {
+ it('renders error message with cross mark', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('✗');
+ expect(frame).toContain('Something failed');
+ });
+
+ it('renders detail text when provided', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Stack rollback');
+ });
+
+ it('shows back and exit help text', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('Enter/B to go back');
+ expect(frame).toContain('Esc/Q to exit');
+ });
+
+ it('calls onBack on Enter key', () => {
+ const onBack = vi.fn();
+ const { stdin } = render();
+
+ stdin.write(ENTER);
+
+ expect(onBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onBack on b key', () => {
+ const onBack = vi.fn();
+ const { stdin } = render();
+
+ stdin.write('b');
+
+ expect(onBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onExit on Escape key', () => {
+ const onExit = vi.fn();
+ const { stdin } = render();
+
+ stdin.write(ESCAPE);
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onExit on n key', () => {
+ const onExit = vi.fn();
+ const { stdin } = render();
+
+ stdin.write('n');
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('ConfirmPrompt', () => {
+ it('renders confirmation message', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Delete agent?');
+ });
+
+ it('renders detail when provided', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('This is irreversible');
+ });
+
+ it('shows keyboard help when showInput is false', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('Enter/Y confirm');
+ expect(frame).toContain('Esc/N cancel');
+ });
+
+ it('shows input help when showInput is true', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Type y/n');
+ });
+
+ it('calls onConfirm on Enter key', () => {
+ const onConfirm = vi.fn();
+ const { stdin } = render();
+
+ stdin.write(ENTER);
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onCancel on Escape key', () => {
+ const onCancel = vi.fn();
+ const { stdin } = render();
+
+ stdin.write(ESCAPE);
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onConfirm on y key', () => {
+ const onConfirm = vi.fn();
+ const { stdin } = render();
+
+ stdin.write('y');
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onCancel on n key', () => {
+ const onCancel = vi.fn();
+ const { stdin } = render();
+
+ stdin.write('n');
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx
new file mode 100644
index 00000000..60c07003
--- /dev/null
+++ b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx
@@ -0,0 +1,140 @@
+import type { AgentCoreMcpSpec, AgentCoreProjectSpec } from '../../../../schema/index.js';
+import { ResourceGraph } from '../ResourceGraph.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+const baseProject: AgentCoreProjectSpec = {
+ name: 'test-project',
+ agents: [],
+ memories: [],
+ credentials: [],
+} as unknown as AgentCoreProjectSpec;
+
+describe('ResourceGraph', () => {
+ it('renders project name', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('test-project');
+ });
+
+ it('shows empty state when no resources configured', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('No resources configured');
+ });
+
+ it('renders agents section', () => {
+ const project = {
+ ...baseProject,
+ agents: [{ name: 'my-agent' }],
+ } as unknown as AgentCoreProjectSpec;
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Agents');
+ expect(lastFrame()).toContain('my-agent');
+ });
+
+ it('renders memories with strategies', () => {
+ const project = {
+ ...baseProject,
+ memories: [{ name: 'my-memory', strategies: [{ type: 'semantic_search' }] }],
+ } as unknown as AgentCoreProjectSpec;
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Memories');
+ expect(lastFrame()).toContain('my-memory');
+ expect(lastFrame()).toContain('semantic_search');
+ });
+
+ it('renders credentials section', () => {
+ const project = {
+ ...baseProject,
+ credentials: [{ name: 'my-cred', type: 'OAuthCredentialProvider' }],
+ } as unknown as AgentCoreProjectSpec;
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Credentials');
+ expect(lastFrame()).toContain('my-cred');
+ expect(lastFrame()).toContain('OAuth');
+ });
+
+ it('filters agents by agentName prop', () => {
+ const project = {
+ ...baseProject,
+ agents: [{ name: 'agent-a' }, { name: 'agent-b' }],
+ } as unknown as AgentCoreProjectSpec;
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('agent-a');
+ expect(lastFrame()).not.toContain('agent-b');
+ });
+
+ it('renders agent status when provided', () => {
+ const project = {
+ ...baseProject,
+ agents: [{ name: 'my-agent' }],
+ } as unknown as AgentCoreProjectSpec;
+
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('READY');
+ });
+
+ it('renders agent error status', () => {
+ const project = {
+ ...baseProject,
+ agents: [{ name: 'my-agent' }],
+ } as unknown as AgentCoreProjectSpec;
+
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('error');
+ });
+
+ it('renders MCP gateways with tool counts', () => {
+ const mcp: AgentCoreMcpSpec = {
+ agentCoreGateways: [
+ {
+ name: 'my-gateway',
+ targets: [{ toolDefinitions: [{ name: 'tool-a' }, { name: 'tool-b' }] }],
+ },
+ ],
+ } as unknown as AgentCoreMcpSpec;
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Gateways');
+ expect(lastFrame()).toContain('my-gateway');
+ expect(lastFrame()).toContain('2 tools');
+ expect(lastFrame()).toContain('tool-a');
+ });
+
+ it('renders MCP runtime tools', () => {
+ const mcp: AgentCoreMcpSpec = {
+ agentCoreGateways: [],
+ mcpRuntimeTools: [{ name: 'runtime-tool', toolDefinition: { name: 'rt-display' } }],
+ } as unknown as AgentCoreMcpSpec;
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Runtime Tools');
+ expect(lastFrame()).toContain('rt-display');
+ });
+
+ it('renders legend', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('agent');
+ expect(lastFrame()).toContain('memory');
+ expect(lastFrame()).toContain('credential');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/Screen.test.tsx b/src/cli/tui/components/__tests__/Screen.test.tsx
new file mode 100644
index 00000000..3d6bec19
--- /dev/null
+++ b/src/cli/tui/components/__tests__/Screen.test.tsx
@@ -0,0 +1,111 @@
+import { Screen } from '../Screen.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const ESCAPE = '\x1B';
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('Screen', () => {
+ it('renders title in the header', () => {
+ const { lastFrame } = render(
+
+ Content
+
+ );
+
+ expect(lastFrame()).toContain('Deploy');
+ });
+
+ it('renders children content', () => {
+ const { lastFrame } = render(
+
+ Hello World
+
+ );
+
+ expect(lastFrame()).toContain('Hello World');
+ });
+
+ it('renders default help text when none provided', () => {
+ const { lastFrame } = render(
+
+ Content
+
+ );
+
+ expect(lastFrame()).toContain('Esc back');
+ });
+
+ it('renders custom help text when provided', () => {
+ const { lastFrame } = render(
+
+ Content
+
+ );
+
+ expect(lastFrame()).toContain('Press Enter to continue');
+ });
+
+ it('calls onExit on Escape key', () => {
+ const onExit = vi.fn();
+ const { stdin } = render(
+
+ Content
+
+ );
+
+ stdin.write(ESCAPE);
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onExit on Ctrl+Q', () => {
+ const onExit = vi.fn();
+ const { stdin } = render(
+
+ Content
+
+ );
+
+ stdin.write('\x11'); // Ctrl+Q
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call onExit when exitEnabled is false', () => {
+ const onExit = vi.fn();
+ const { stdin } = render(
+
+ Content
+
+ );
+
+ stdin.write(ESCAPE);
+ stdin.write('\x11');
+
+ expect(onExit).not.toHaveBeenCalled();
+ });
+
+ it('renders header content when provided', () => {
+ const { lastFrame } = render(
+ Status: Active}>
+ Content
+
+ );
+
+ expect(lastFrame()).toContain('Status: Active');
+ });
+
+ it('renders footer content when provided', () => {
+ const { lastFrame } = render(
+ 3 items selected}>
+ Content
+
+ );
+
+ expect(lastFrame()).toContain('3 items selected');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/ScreenHeader.test.tsx b/src/cli/tui/components/__tests__/ScreenHeader.test.tsx
new file mode 100644
index 00000000..941116a2
--- /dev/null
+++ b/src/cli/tui/components/__tests__/ScreenHeader.test.tsx
@@ -0,0 +1,30 @@
+import { ScreenHeader } from '../ScreenHeader.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+describe('ScreenHeader', () => {
+ it('renders title', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Deploy');
+ });
+
+ it('renders children when provided', () => {
+ const { lastFrame } = render(
+
+ Target: us-east-1
+
+ );
+
+ expect(lastFrame()).toContain('Status');
+ expect(lastFrame()).toContain('Target: us-east-1');
+ });
+
+ it('does not render children area when no children', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Help');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/ScreenLayout.test.tsx b/src/cli/tui/components/__tests__/ScreenLayout.test.tsx
new file mode 100644
index 00000000..42dbe882
--- /dev/null
+++ b/src/cli/tui/components/__tests__/ScreenLayout.test.tsx
@@ -0,0 +1,49 @@
+import { ScreenLayout } from '../ScreenLayout.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const ESCAPE = '\x1B';
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('ScreenLayout', () => {
+ it('renders children', () => {
+ const { lastFrame } = render(
+
+ Hello Layout
+
+ );
+
+ expect(lastFrame()).toContain('Hello Layout');
+ });
+
+ it('calls onExit on Escape when onExit provided', async () => {
+ const onExit = vi.fn();
+ const { stdin } = render(
+
+ Content
+
+ );
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(ESCAPE);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not crash on Escape when no onExit', () => {
+ const { stdin, lastFrame } = render(
+
+ No Exit Handler
+
+ );
+
+ // Should not throw
+ stdin.write(ESCAPE);
+
+ expect(lastFrame()).toContain('No Exit Handler');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/ScrollableList.test.tsx b/src/cli/tui/components/__tests__/ScrollableList.test.tsx
new file mode 100644
index 00000000..48cb722c
--- /dev/null
+++ b/src/cli/tui/components/__tests__/ScrollableList.test.tsx
@@ -0,0 +1,157 @@
+import { ScrollableList } from '../ScrollableList.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+const UP_ARROW = '\x1B[A';
+const DOWN_ARROW = '\x1B[B';
+
+const items = [
+ { timestamp: '12:00', message: 'Starting deploy', color: 'green' as const },
+ { timestamp: '12:01', message: 'Creating stack' },
+ { timestamp: '12:02', message: 'Stack created', color: 'green' as const },
+ { timestamp: '12:03', message: 'Deploying lambda' },
+ { timestamp: '12:04', message: 'Deploy complete' },
+];
+
+function delay(ms = 50) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+describe('ScrollableList', () => {
+ it('auto-scrolls to bottom showing last N items in viewport', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ // With height=3, auto-scroll shows items 3-5 (offset=2)
+ expect(frame).toContain('Stack created');
+ expect(frame).toContain('Deploying lambda');
+ expect(frame).toContain('Deploy complete');
+ // First two items should NOT be visible
+ expect(frame).not.toContain('Starting deploy');
+ expect(frame).not.toContain('Creating stack');
+ });
+
+ it('shows all items when height exceeds item count', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('Starting deploy');
+ expect(frame).toContain('Creating stack');
+ expect(frame).toContain('Stack created');
+ expect(frame).toContain('Deploying lambda');
+ expect(frame).toContain('Deploy complete');
+ });
+
+ it('renders title when provided', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Deployment Log');
+ });
+
+ it('does not render title when not provided', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).not.toContain('Deployment Log');
+ });
+
+ it('shows scroll indicator with position when items exceed height', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ // Auto-scrolled to bottom: items 3-5 of 5
+ expect(frame).toContain('3-5 of 5');
+ expect(frame).toContain('↑↓');
+ });
+
+ it('does not show scroll indicator when all items fit', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).not.toContain('↑↓');
+ expect(lastFrame()).not.toContain('of');
+ });
+
+ it('formats items as [timestamp] message', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('[12:00] Starting deploy');
+ expect(frame).toContain('[12:01] Creating stack');
+ });
+
+ it('renders empty list without scroll indicator', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).not.toContain('↑↓');
+ expect(frame).not.toContain('of');
+ });
+
+ it('scrolls up to reveal earlier items', async () => {
+ const { lastFrame, stdin } = render();
+
+ // Initially auto-scrolled to bottom
+ expect(lastFrame()).not.toContain('Starting deploy');
+ expect(lastFrame()).toContain('Deploy complete');
+
+ // Scroll up twice
+ await delay();
+ stdin.write(UP_ARROW);
+ stdin.write(UP_ARROW);
+ await delay();
+
+ // Now should see first items
+ expect(lastFrame()).toContain('Starting deploy');
+ // Position indicator should update
+ expect(lastFrame()).toContain('1-3 of 5');
+ });
+
+ it('scrolls down after scrolling up', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ // Scroll up to top
+ stdin.write(UP_ARROW);
+ stdin.write(UP_ARROW);
+ await delay();
+ expect(lastFrame()).toContain('Starting deploy');
+
+ // Scroll back down
+ stdin.write(DOWN_ARROW);
+ stdin.write(DOWN_ARROW);
+ await delay();
+
+ expect(lastFrame()).toContain('Deploy complete');
+ expect(lastFrame()).toContain('3-5 of 5');
+ });
+
+ it('does not scroll above first item', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ // Scroll up many times past the top
+ for (let i = 0; i < 10; i++) {
+ stdin.write(UP_ARROW);
+ }
+ await delay();
+
+ // Should be at position 1-3, not negative
+ expect(lastFrame()).toContain('1-3 of 5');
+ expect(lastFrame()).toContain('Starting deploy');
+ });
+
+ it('does not scroll below last item', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ // Already at bottom, try scrolling down more
+ for (let i = 0; i < 10; i++) {
+ stdin.write(DOWN_ARROW);
+ }
+ await delay();
+
+ // Should still be at bottom position
+ expect(lastFrame()).toContain('3-5 of 5');
+ expect(lastFrame()).toContain('Deploy complete');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/ScrollableText.test.tsx b/src/cli/tui/components/__tests__/ScrollableText.test.tsx
new file mode 100644
index 00000000..b81c766b
--- /dev/null
+++ b/src/cli/tui/components/__tests__/ScrollableText.test.tsx
@@ -0,0 +1,150 @@
+import { ScrollableText } from '../ScrollableText.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const UP = '\x1B[A';
+const DOWN = '\x1B[B';
+
+function delay(ms = 50) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+function makeContent(lineCount: number): string {
+ return Array.from({ length: lineCount }, (_, i) => `Line ${i + 1}`).join('\n');
+}
+
+describe('ScrollableText', () => {
+ it('returns null when content is empty', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toBe('');
+ });
+
+ it('renders all lines when content fits within height', () => {
+ const content = 'Line 1\nLine 2\nLine 3';
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('Line 1');
+ expect(frame).toContain('Line 2');
+ expect(frame).toContain('Line 3');
+ });
+
+ it('does not show scrollbar when content fits', () => {
+ const content = 'Line 1\nLine 2';
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).not.toContain('\u2588'); // block char
+ expect(frame).not.toContain('\u2591'); // light shade
+ });
+
+ it('shows only height lines when content overflows', () => {
+ const content = makeContent(20);
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ // Should show status line with scroll info
+ expect(frame).toContain('of 20');
+ });
+
+ it('shows scrollbar when content overflows', () => {
+ const content = makeContent(30);
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ // Scrollbar chars should appear
+ expect(frame).toMatch(/[█░]/);
+ });
+
+ it('hides scrollbar when showScrollbar is false', () => {
+ const content = makeContent(30);
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ // Should still show status line but no scrollbar track
+ expect(frame).not.toContain('░');
+ });
+
+ it('scrolls down with arrow key', async () => {
+ const content = makeContent(20);
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(DOWN);
+ await delay();
+
+ // After scrolling down, should no longer show from the top
+ const frame = lastFrame()!;
+ expect(frame).toContain('of 20');
+ });
+
+ it('scrolls up with arrow key', async () => {
+ const content = makeContent(20);
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ // Scroll down first, then back up
+ stdin.write(DOWN);
+ stdin.write(DOWN);
+ await delay();
+ stdin.write(UP);
+ await delay();
+
+ const frame = lastFrame()!;
+ expect(frame).toContain('of 20');
+ });
+
+ it('auto-scrolls to bottom when streaming', () => {
+ const content = makeContent(20);
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ // When streaming, should show the last lines
+ expect(frame).toContain('Line 20');
+ });
+
+ it('shows status line with line range when scrolling needed', () => {
+ const content = makeContent(20);
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ // Status line should show range and total
+ expect(frame).toMatch(/\[\d+-\d+ of 20\]/);
+ expect(frame).toContain('PgUp/PgDn');
+ });
+
+ it('does not show status line when content fits', () => {
+ const content = 'Line 1\nLine 2';
+ const { lastFrame } = render();
+
+ expect(lastFrame()).not.toContain('PgUp/PgDn');
+ });
+
+ it('wraps long lines to fit terminal width', () => {
+ // Create a line longer than any reasonable terminal width
+ const longLine = 'A'.repeat(200);
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ // Content should appear (wrapped)
+ expect(frame).toContain('A');
+ });
+
+ it('does not respond to input when isActive is false', async () => {
+ const content = makeContent(20);
+ const { lastFrame, stdin } = render();
+
+ const before = lastFrame();
+ await delay();
+ stdin.write(DOWN);
+ stdin.write(DOWN);
+ await delay();
+
+ // Frame shouldn't change since input is disabled
+ expect(lastFrame()).toBe(before);
+ });
+});
diff --git a/src/cli/tui/components/__tests__/SecretInput.test.tsx b/src/cli/tui/components/__tests__/SecretInput.test.tsx
new file mode 100644
index 00000000..3b326ea2
--- /dev/null
+++ b/src/cli/tui/components/__tests__/SecretInput.test.tsx
@@ -0,0 +1,299 @@
+import { ApiKeySecretInput, SecretInput } from '../SecretInput.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { z } from 'zod';
+
+const ENTER = '\r';
+const ESCAPE = '\x1B';
+const TAB = '\t';
+
+function delay(ms = 50) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('SecretInput', () => {
+ it('renders prompt text in bold', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('API Key');
+ });
+
+ it('renders description when provided', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('Enter your key');
+ });
+
+ it('renders placeholder when value is empty', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('sk-...');
+ });
+
+ it('masks input with default * character', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write('secret');
+ await delay();
+
+ const frame = lastFrame()!;
+ expect(frame).toContain('******');
+ expect(frame).not.toContain('secret');
+ });
+
+ it('masks input with custom character', async () => {
+ const { lastFrame, stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write('abc');
+ await delay();
+
+ expect(lastFrame()).toContain('###');
+ });
+
+ it('toggles show/hide on Tab', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write('mykey');
+ await delay();
+
+ // Should be masked initially
+ expect(lastFrame()).toContain('*****');
+ expect(lastFrame()).not.toContain('mykey');
+
+ // Tab to show
+ stdin.write(TAB);
+ await delay();
+
+ expect(lastFrame()).toContain('mykey');
+
+ // Tab to hide again
+ stdin.write(TAB);
+ await delay();
+
+ expect(lastFrame()).toContain('*****');
+ });
+
+ it('shows "Tab to show" when masked', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Tab to show');
+ });
+
+ it('shows "Tab to hide" after toggling', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(TAB);
+ await delay();
+
+ expect(lastFrame()).toContain('Tab to hide');
+ });
+
+ it('calls onSubmit with trimmed value on Enter', async () => {
+ const onSubmit = vi.fn();
+ const { stdin } = render();
+
+ await delay();
+ stdin.write(' mykey ');
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onSubmit).toHaveBeenCalledWith('mykey');
+ });
+
+ it('calls onCancel on Escape', async () => {
+ const onCancel = vi.fn();
+ const { stdin } = render();
+
+ await delay();
+ stdin.write(ESCAPE);
+ await delay();
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onSkip when submitting empty value with onSkip provided', async () => {
+ const onSkip = vi.fn();
+ const onCancel = vi.fn();
+ const { stdin } = render();
+
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onSkip).toHaveBeenCalledTimes(1);
+ expect(onCancel).not.toHaveBeenCalled();
+ });
+
+ it('calls onCancel when submitting empty value without onSkip', async () => {
+ const onCancel = vi.fn();
+ const { stdin } = render();
+
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows skip hint when onSkip is provided', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Leave empty to skip');
+ });
+
+ it('shows "go back" instead of "cancel" when onSkip is provided', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('go back');
+ expect(lastFrame()).not.toContain('cancel');
+ });
+
+ it('does not submit when schema validation fails', async () => {
+ const onSubmit = vi.fn();
+ const schema = z.string().min(10, 'Too short');
+ const { stdin } = render();
+
+ await delay();
+ stdin.write('abc');
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('shows validation error after submit attempt', async () => {
+ const schema = z.string().min(10, 'Key is too short');
+ const { lastFrame, stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write('abc');
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(lastFrame()).toContain('Key is too short');
+ });
+
+ it('shows checkmark when input passes schema validation', async () => {
+ const schema = z.string().min(3);
+ const { lastFrame, stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write('validkey');
+ await delay();
+
+ expect(lastFrame()).toContain('\u2713');
+ });
+
+ it('shows cross mark when input fails schema validation', async () => {
+ const schema = z.string().min(10);
+ const { lastFrame, stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write('ab');
+ await delay();
+
+ expect(lastFrame()).toContain('\u2717');
+ });
+
+ it('supports custom validation', async () => {
+ const onSubmit = vi.fn();
+ const customValidation = (val: string) => (val.startsWith('sk-') ? true : 'Must start with sk-');
+ const { lastFrame, stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write('bad');
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(lastFrame()).toContain('Must start with sk-');
+ });
+
+ it('renders partial reveal when revealChars is set', async () => {
+ const { lastFrame, stdin } = render(
+
+ );
+
+ await delay();
+ // Need value > revealChars * 2 (4) to trigger partial reveal
+ stdin.write('abcdefgh');
+ await delay();
+
+ const frame = lastFrame()!;
+ // With revealChars=2, should show first 2 and last 2 chars with masks in middle
+ // "ab****gh" pattern
+ expect(frame).toContain('ab');
+ expect(frame).toContain('gh');
+ });
+});
+
+describe('ApiKeySecretInput', () => {
+ it('renders provider name in prompt', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('OpenAI API Key');
+ });
+
+ it('renders env var name as placeholder', () => {
+ const { lastFrame } = render(
+
+ );
+
+ // Placeholder is displayed when input is empty (slice(1) of placeholder)
+ expect(lastFrame()).toContain('PENAI_API_KEY');
+ });
+
+ it('renders description about secure storage', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('.env.local');
+ expect(lastFrame()).toContain('AgentCore Identity');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/SelectList.test.tsx b/src/cli/tui/components/__tests__/SelectList.test.tsx
new file mode 100644
index 00000000..b94afbab
--- /dev/null
+++ b/src/cli/tui/components/__tests__/SelectList.test.tsx
@@ -0,0 +1,100 @@
+import { SelectList } from '../SelectList.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+describe('SelectList', () => {
+ const items = [
+ { id: 'a', title: 'Agent', description: 'Add an agent' },
+ { id: 'b', title: 'Memory', description: 'Add memory' },
+ { id: 'c', title: 'Identity' },
+ ];
+
+ it('renders all item titles', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('Agent');
+ expect(frame).toContain('Memory');
+ expect(frame).toContain('Identity');
+ });
+
+ it('shows cursor only on the selected item line', () => {
+ const { lastFrame } = render();
+ const lines = lastFrame()!.split('\n');
+
+ const agentLine = lines.find(l => l.includes('Agent'))!;
+ const memoryLine = lines.find(l => l.includes('Memory'))!;
+ const identityLine = lines.find(l => l.includes('Identity'))!;
+
+ expect(memoryLine).toContain('❯');
+ expect(agentLine).not.toContain('❯');
+ expect(identityLine).not.toContain('❯');
+ });
+
+ it('moves cursor when selectedIndex changes', () => {
+ const { lastFrame: frame0 } = render();
+ const lines0 = frame0()!.split('\n');
+ expect(lines0.find(l => l.includes('Agent'))).toContain('❯');
+ expect(lines0.find(l => l.includes('Memory'))).not.toContain('❯');
+
+ const { lastFrame: frame2 } = render();
+ const lines2 = frame2()!.split('\n');
+ expect(lines2.find(l => l.includes('Identity'))).toContain('❯');
+ expect(lines2.find(l => l.includes('Agent'))).not.toContain('❯');
+ expect(lines2.find(l => l.includes('Memory'))).not.toContain('❯');
+ });
+
+ it('shows descriptions inline with items', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('Add an agent');
+ expect(frame).toContain('Add memory');
+ // Identity has no description
+ const identityLine = frame.split('\n').find(l => l.includes('Identity'))!;
+ expect(identityLine).not.toContain(' - ');
+ });
+
+ it('shows empty state with default message when no items', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('No matches');
+ expect(frame).toContain('No items available');
+ expect(frame).toContain('Esc to clear search');
+ });
+
+ it('shows custom empty message', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Nothing here');
+ expect(lastFrame()).not.toContain('No items available');
+ });
+
+ it('renders disabled items without cursor styling', () => {
+ const disabledItems = [
+ { id: 'a', title: 'Available' },
+ { id: 'b', title: 'Disabled', disabled: true },
+ ];
+
+ // Select the disabled item (index 1)
+ const { lastFrame } = render();
+ const lines = lastFrame()!.split('\n');
+
+ const disabledLine = lines.find(l => l.includes('Disabled'))!;
+ // Cursor should still appear on the selected line even when disabled
+ expect(disabledLine).toContain('❯');
+ // Available line should not have cursor
+ const availableLine = lines.find(l => l.includes('Available'))!;
+ expect(availableLine).not.toContain('❯');
+ });
+
+ it('renders exactly one cursor across all items', () => {
+ const { lastFrame } = render();
+ const lines = lastFrame()!.split('\n');
+ const cursorCount = lines.filter(l => l.includes('❯')).length;
+
+ expect(cursorCount).toBe(1);
+ });
+});
diff --git a/src/cli/tui/components/__tests__/SelectScreen.test.tsx b/src/cli/tui/components/__tests__/SelectScreen.test.tsx
new file mode 100644
index 00000000..2add4d53
--- /dev/null
+++ b/src/cli/tui/components/__tests__/SelectScreen.test.tsx
@@ -0,0 +1,61 @@
+import { type SelectableItem } from '../SelectList.js';
+import { SelectScreen } from '../SelectScreen.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const ESCAPE = '\x1B';
+
+afterEach(() => vi.restoreAllMocks());
+
+const items: SelectableItem[] = [
+ { id: 'a', title: 'Alpha', description: 'First item' },
+ { id: 'b', title: 'Beta', description: 'Second item' },
+ { id: 'c', title: 'Gamma', description: 'Third item' },
+];
+
+describe('SelectScreen', () => {
+ it('renders title', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Pick One');
+ });
+
+ it('renders items', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Alpha');
+ expect(lastFrame()).toContain('Beta');
+ expect(lastFrame()).toContain('Gamma');
+ });
+
+ it('shows emptyMessage when items is empty', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('Nothing here');
+ });
+
+ it('calls onExit on Escape', async () => {
+ const onExit = vi.fn();
+ const { stdin } = render();
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(ESCAPE);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(onExit).toHaveBeenCalled();
+ });
+
+ it('renders children below the list', () => {
+ const { lastFrame } = render(
+
+ Extra footer content
+
+ );
+
+ expect(lastFrame()).toContain('Extra footer content');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/StepIndicator.test.tsx b/src/cli/tui/components/__tests__/StepIndicator.test.tsx
new file mode 100644
index 00000000..165bbb7e
--- /dev/null
+++ b/src/cli/tui/components/__tests__/StepIndicator.test.tsx
@@ -0,0 +1,96 @@
+import { StepIndicator } from '../StepIndicator.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const { mockWidth } = vi.hoisted(() => ({ mockWidth: { value: 120 } }));
+
+vi.mock('../../hooks/useResponsive.js', () => ({
+ useResponsive: () => ({ width: mockWidth.value, height: 40, isNarrow: mockWidth.value < 60 }),
+}));
+
+type Step = 'setup' | 'config' | 'deploy' | 'done';
+
+const steps: Step[] = ['setup', 'config', 'deploy', 'done'];
+const labels: Record = {
+ setup: 'Setup',
+ config: 'Configure',
+ deploy: 'Deploy',
+ done: 'Done',
+};
+
+afterEach(() => {
+ mockWidth.value = 120;
+});
+
+describe('StepIndicator', () => {
+ it('renders all step labels', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Setup');
+ expect(lastFrame()).toContain('Configure');
+ expect(lastFrame()).toContain('Deploy');
+ expect(lastFrame()).toContain('Done');
+ });
+
+ it('shows current step indicator', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('●');
+ });
+
+ it('shows completed steps with checkmark', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('✓');
+ });
+
+ it('shows pending steps with circle', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('○');
+ });
+
+ it('shows arrows between steps by default', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('→');
+ });
+
+ it('hides arrows when showArrows is false', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).not.toContain('→');
+ });
+
+ it('wraps steps to multiple rows on narrow screens', () => {
+ // Set width narrow enough that all 4 steps can't fit on one row
+ mockWidth.value = 30;
+
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ // All labels should still be present even if wrapped
+ expect(frame).toContain('Setup');
+ expect(frame).toContain('Configure');
+ expect(frame).toContain('Deploy');
+ expect(frame).toContain('Done');
+
+ // On a narrow screen, the frame should have multiple lines of steps
+ // (more lines than a wide screen would produce)
+ const lines = frame.split('\n').filter(l => l.trim().length > 0);
+ expect(lines.length).toBeGreaterThan(1);
+ });
+
+ it('shows all three icon types when step is in the middle', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ // Should have completed (✓), current (●), and pending (○) icons
+ expect(frame).toContain('✓');
+ expect(frame).toContain('●');
+ expect(frame).toContain('○');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/StepProgress.test.tsx b/src/cli/tui/components/__tests__/StepProgress.test.tsx
new file mode 100644
index 00000000..31a455ff
--- /dev/null
+++ b/src/cli/tui/components/__tests__/StepProgress.test.tsx
@@ -0,0 +1,137 @@
+import { type Step, StepProgress, areStepsComplete, hasStepError } from '../StepProgress.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+describe('hasStepError', () => {
+ it('returns true when any step has error status', () => {
+ const steps: Step[] = [
+ { label: 'Build', status: 'success' },
+ { label: 'Deploy', status: 'error' },
+ ];
+
+ expect(hasStepError(steps)).toBe(true);
+ });
+
+ it('returns false when no step has error status', () => {
+ const steps: Step[] = [
+ { label: 'Build', status: 'success' },
+ { label: 'Deploy', status: 'running' },
+ ];
+
+ expect(hasStepError(steps)).toBe(false);
+ });
+
+ it('returns false for empty array', () => {
+ expect(hasStepError([])).toBe(false);
+ });
+});
+
+describe('areStepsComplete', () => {
+ it('returns true when all steps are terminal (success/error/warn/info)', () => {
+ const steps: Step[] = [
+ { label: 'Build', status: 'success' },
+ { label: 'Test', status: 'warn' },
+ { label: 'Deploy', status: 'error' },
+ ];
+
+ expect(areStepsComplete(steps)).toBe(true);
+ });
+
+ it('returns false when a step is still running', () => {
+ const steps: Step[] = [
+ { label: 'Build', status: 'success' },
+ { label: 'Deploy', status: 'running' },
+ ];
+
+ expect(areStepsComplete(steps)).toBe(false);
+ });
+
+ it('returns false when a step is pending', () => {
+ const steps: Step[] = [
+ { label: 'Build', status: 'success' },
+ { label: 'Deploy', status: 'pending' },
+ ];
+
+ expect(areStepsComplete(steps)).toBe(false);
+ });
+
+ it('returns false for empty array', () => {
+ expect(areStepsComplete([])).toBe(false);
+ });
+
+ it('returns true when all steps are info', () => {
+ const steps: Step[] = [{ label: 'Note', status: 'info' }];
+
+ expect(areStepsComplete(steps)).toBe(true);
+ });
+});
+
+describe('StepProgress', () => {
+ it('renders step labels', () => {
+ const steps: Step[] = [
+ { label: 'Building project', status: 'success' },
+ { label: 'Deploying stack', status: 'pending' },
+ ];
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Building project');
+ expect(lastFrame()).toContain('Deploying stack');
+ });
+
+ it('shows [done] on the same line as the success step label', () => {
+ const steps: Step[] = [{ label: 'Build', status: 'success' }];
+
+ const { lastFrame } = render();
+ const lines = lastFrame()!.split('\n');
+ const buildLine = lines.find(l => l.includes('Build'))!;
+
+ expect(buildLine).toContain('[done]');
+ });
+
+ it('shows [error] on the same line as the error step label', () => {
+ const steps: Step[] = [{ label: 'Deploy', status: 'error', error: 'Stack creation failed' }];
+
+ const { lastFrame } = render();
+ const lines = lastFrame()!.split('\n');
+ const deployLine = lines.find(l => l.includes('Deploy'))!;
+
+ expect(deployLine).toContain('[error]');
+ // Error message should appear in the output
+ expect(lastFrame()).toContain('Stack creation failed');
+ });
+
+ it('shows [warning] on the same line as the warn step label', () => {
+ const steps: Step[] = [{ label: 'Validate', status: 'warn', warn: 'Deprecated config field' }];
+
+ const { lastFrame } = render();
+ const lines = lastFrame()!.split('\n');
+ const validateLine = lines.find(l => l.includes('Validate'))!;
+
+ expect(validateLine).toContain('[warning]');
+ expect(lastFrame()).toContain('Deprecated config field');
+ });
+
+ it('shows info message for info steps', () => {
+ const steps: Step[] = [{ label: 'Note', status: 'info', info: 'First deploy takes longer' }];
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('First deploy takes longer');
+ });
+
+ it('hides pending steps after an error', () => {
+ const steps: Step[] = [
+ { label: 'Build', status: 'success' },
+ { label: 'Deploy', status: 'error', error: 'Failed' },
+ { label: 'Verify', status: 'pending' },
+ ];
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Build');
+ expect(lastFrame()).toContain('Deploy');
+ expect(lastFrame()).not.toContain('Verify');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/TextInput.test.tsx b/src/cli/tui/components/__tests__/TextInput.test.tsx
new file mode 100644
index 00000000..79865a60
--- /dev/null
+++ b/src/cli/tui/components/__tests__/TextInput.test.tsx
@@ -0,0 +1,220 @@
+import { TextInput } from '../TextInput.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { z } from 'zod';
+
+const ENTER = '\r';
+const ESCAPE = '\x1B';
+
+function delay(ms = 50) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('TextInput', () => {
+ it('renders prompt text', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Enter name:');
+ });
+
+ it('renders placeholder when value is empty', () => {
+ const { lastFrame } = render(
+
+ );
+
+ // Placeholder shows all chars after cursor position (slice(1))
+ expect(lastFrame()).toContain('y-agent');
+ });
+
+ it('renders initial value', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('hello');
+ });
+
+ it('shows > arrow by default', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('>');
+ });
+
+ it('hides arrow when hideArrow is true', () => {
+ const { lastFrame } = render();
+ const lines = lastFrame()!.split('\n');
+ // The input line should not start with >
+ const inputLine = lines.find(l => !l.includes('Name'))!;
+ expect(inputLine).not.toMatch(/>\s/);
+ });
+
+ it('accepts character input and displays it', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write('a');
+ stdin.write('b');
+ stdin.write('c');
+ await delay();
+
+ expect(lastFrame()).toContain('abc');
+ });
+
+ it('calls onSubmit with trimmed value on Enter', async () => {
+ const onSubmit = vi.fn();
+ const { stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onSubmit).toHaveBeenCalledWith('hello');
+ });
+
+ it('does not call onSubmit when value is empty and allowEmpty is false', async () => {
+ const onSubmit = vi.fn();
+ const { stdin } = render();
+
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('calls onSubmit with empty value when allowEmpty is true', async () => {
+ const onSubmit = vi.fn();
+ const { stdin } = render();
+
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ // allowEmpty + no validation error => submit
+ expect(onSubmit).toHaveBeenCalledWith('');
+ });
+
+ it('calls onCancel on Escape', async () => {
+ const onCancel = vi.fn();
+ const { stdin } = render();
+
+ await delay();
+ stdin.write(ESCAPE);
+ await delay();
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('masks input when mask character is provided', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write('abc');
+ await delay();
+
+ const frame = lastFrame()!;
+ expect(frame).toContain('***');
+ expect(frame).not.toContain('abc');
+ });
+
+ it('shows checkmark when valid input with schema', async () => {
+ const schema = z.string().min(3);
+ const { lastFrame, stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write('hello');
+ await delay();
+
+ expect(lastFrame()).toContain('\u2713'); // checkmark
+ });
+
+ it('shows invalid mark when input fails schema validation', async () => {
+ const schema = z.string().min(5);
+ const { lastFrame, stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write('hi');
+ await delay();
+
+ expect(lastFrame()).toContain('\u2717'); // cross mark
+ });
+
+ it('does not submit when schema validation fails', async () => {
+ const onSubmit = vi.fn();
+ const schema = z.string().min(5);
+ const { stdin } = render();
+
+ await delay();
+ stdin.write('hi');
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('shows validation error message after submit attempt with invalid input', async () => {
+ const schema = z.string().min(5, 'Must be at least 5 characters');
+ const { lastFrame, stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write('hi');
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(lastFrame()).toContain('Must be at least 5 characters');
+ });
+
+ it('supports custom validation', async () => {
+ const customValidation = (val: string) => (val.startsWith('a') ? true : 'Must start with a');
+ const { lastFrame, stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write('bbb');
+ await delay();
+
+ expect(lastFrame()).toContain('\u2717'); // cross mark
+ });
+
+ it('does not submit when custom validation fails', async () => {
+ const onSubmit = vi.fn();
+ const customValidation = (val: string) => (val.startsWith('a') ? true : 'Must start with a');
+ const { stdin } = render(
+
+ );
+
+ await delay();
+ stdin.write('bbb');
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('does not show checkmark/crossmark when no schema or customValidation', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write('hello');
+ await delay();
+
+ const frame = lastFrame()!;
+ expect(frame).not.toContain('\u2713');
+ expect(frame).not.toContain('\u2717');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/TwoColumn.test.tsx b/src/cli/tui/components/__tests__/TwoColumn.test.tsx
new file mode 100644
index 00000000..e7cc96ec
--- /dev/null
+++ b/src/cli/tui/components/__tests__/TwoColumn.test.tsx
@@ -0,0 +1,94 @@
+import { TwoColumn } from '../TwoColumn.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const { mockWidth, mockIsNarrow } = vi.hoisted(() => ({
+ mockWidth: { value: 120 },
+ mockIsNarrow: { value: false },
+}));
+
+vi.mock('../../hooks/useResponsive.js', () => ({
+ useResponsive: () => ({
+ width: mockWidth.value,
+ height: 40,
+ isNarrow: mockIsNarrow.value,
+ }),
+}));
+
+afterEach(() => {
+ mockWidth.value = 120;
+ mockIsNarrow.value = false;
+});
+
+describe('TwoColumn', () => {
+ it('renders both left and right content on wide screen', () => {
+ const { lastFrame } = render(LEFT_MARKER} right={RIGHT_MARKER} />);
+ const frame = lastFrame()!;
+ expect(frame).toContain('LEFT_MARKER');
+ expect(frame).toContain('RIGHT_MARKER');
+ // On wide screen, both should be on the same line (side by side)
+ const lines = frame.split('\n');
+ const lineWithLeft = lines.find(l => l.includes('LEFT_MARKER'));
+ expect(lineWithLeft).toContain('RIGHT_MARKER');
+ });
+
+ it('stacks columns vertically on narrow screen', () => {
+ mockIsNarrow.value = true;
+ mockWidth.value = 40;
+
+ const { lastFrame } = render(LEFT_MARKER} right={RIGHT_MARKER} />);
+ const frame = lastFrame()!;
+ expect(frame).toContain('LEFT_MARKER');
+ expect(frame).toContain('RIGHT_MARKER');
+ // On narrow screen, left and right should be on different lines (stacked)
+ const lines = frame.split('\n');
+ const lineWithLeft = lines.find(l => l.includes('LEFT_MARKER'));
+ expect(lineWithLeft).not.toContain('RIGHT_MARKER');
+ });
+
+ it('renders only left content when no right provided', () => {
+ const { lastFrame } = render(Only left} />);
+ expect(lastFrame()).toContain('Only left');
+ });
+
+ it('stacks when width is below collapseBelow threshold', () => {
+ mockWidth.value = 60;
+ mockIsNarrow.value = false;
+
+ const { lastFrame } = render(
+ LEFT_MARKER} right={RIGHT_MARKER} collapseBelow={80} />
+ );
+ // Width 60 < collapseBelow 80 → stacked
+ const lines = lastFrame()!.split('\n');
+ const lineWithLeft = lines.find(l => l.includes('LEFT_MARKER'));
+ expect(lineWithLeft).not.toContain('RIGHT_MARKER');
+ });
+
+ it('shows side-by-side when width exceeds collapseBelow', () => {
+ mockWidth.value = 120;
+ mockIsNarrow.value = false;
+
+ const { lastFrame } = render(
+ LEFT_MARKER} right={RIGHT_MARKER} collapseBelow={80} />
+ );
+ // Width 120 > collapseBelow 80 → side by side
+ const lines = lastFrame()!.split('\n');
+ const lineWithLeft = lines.find(l => l.includes('LEFT_MARKER'));
+ expect(lineWithLeft).toContain('RIGHT_MARKER');
+ });
+
+ it('renders both columns with custom ratio prop', () => {
+ mockWidth.value = 120;
+ mockIsNarrow.value = false;
+
+ const { lastFrame } = render(
+ LEFT_MARKER} right={RIGHT_MARKER} ratio={[3, 1]} />
+ );
+ // Both columns should still render side-by-side with a 3:1 ratio
+ const lines = lastFrame()!.split('\n');
+ const lineWithLeft = lines.find(l => l.includes('LEFT_MARKER'));
+ expect(lineWithLeft).toContain('RIGHT_MARKER');
+ });
+});
diff --git a/src/cli/tui/components/__tests__/WizardSelect.test.tsx b/src/cli/tui/components/__tests__/WizardSelect.test.tsx
new file mode 100644
index 00000000..61c4d180
--- /dev/null
+++ b/src/cli/tui/components/__tests__/WizardSelect.test.tsx
@@ -0,0 +1,87 @@
+import { WizardMultiSelect, WizardSelect } from '../WizardSelect.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+describe('WizardSelect', () => {
+ const items = [
+ { id: 'strands', title: 'Strands', description: 'AWS Strands SDK' },
+ { id: 'langchain', title: 'LangChain' },
+ ];
+
+ it('renders title and items', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Select SDK');
+ expect(lastFrame()).toContain('Strands');
+ expect(lastFrame()).toContain('LangChain');
+ });
+
+ it('renders description when provided', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('Choose your framework');
+ });
+
+ it('does not render description when not provided', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('Pick one');
+ expect(lastFrame()).toContain('Strands');
+ });
+
+ it('passes empty message to SelectList', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('No SDKs');
+ });
+});
+
+describe('WizardMultiSelect', () => {
+ const items = [
+ { id: 'agent-1', title: 'Agent A' },
+ { id: 'agent-2', title: 'Agent B' },
+ ];
+
+ it('renders title and items', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('Select agents');
+ expect(lastFrame()).toContain('Agent A');
+ expect(lastFrame()).toContain('Agent B');
+ });
+
+ it('renders description when provided', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('Select which agents');
+ });
+
+ it('shows checked items', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('[✓]');
+ });
+
+ it('passes empty message', () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain('No agents');
+ });
+});
diff --git a/src/cli/tui/context/__tests__/LayoutContext.test.ts b/src/cli/tui/context/__tests__/LayoutContext.test.ts
new file mode 100644
index 00000000..862039b2
--- /dev/null
+++ b/src/cli/tui/context/__tests__/LayoutContext.test.ts
@@ -0,0 +1,33 @@
+import { buildLogo } from '../LayoutContext.js';
+import { describe, expect, it } from 'vitest';
+
+describe('buildLogo', () => {
+ it('builds logo with correct width', () => {
+ const logo = buildLogo(40);
+
+ expect(logo).toContain('>_ AgentCore');
+ expect(logo).toContain('┌');
+ expect(logo).toContain('┐');
+ expect(logo).toContain('└');
+ expect(logo).toContain('┘');
+ });
+
+ it('includes version when provided', () => {
+ const logo = buildLogo(50, '1.2.3');
+
+ expect(logo).toContain('>_ AgentCore');
+ expect(logo).toContain('v1.2.3');
+ });
+
+ it('does not include version when not provided', () => {
+ const logo = buildLogo(40);
+
+ expect(logo).not.toContain('v');
+ });
+
+ it('handles narrow width without crashing', () => {
+ const logo = buildLogo(20);
+
+ expect(logo).toContain('>_ AgentCore');
+ });
+});
diff --git a/src/cli/tui/guards/__tests__/project.test.tsx b/src/cli/tui/guards/__tests__/project.test.tsx
new file mode 100644
index 00000000..d205302c
--- /dev/null
+++ b/src/cli/tui/guards/__tests__/project.test.tsx
@@ -0,0 +1,109 @@
+import { MissingProjectMessage, WrongDirectoryMessage, getProjectRootMismatch, projectExists } from '../project.js';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const { mockFindConfigRoot, mockGetWorkingDirectory } = vi.hoisted(() => ({
+ mockFindConfigRoot: vi.fn(),
+ mockGetWorkingDirectory: vi.fn(() => '/project'),
+}));
+
+vi.mock('../../../../lib/index.js', () => ({
+ findConfigRoot: mockFindConfigRoot,
+ getWorkingDirectory: mockGetWorkingDirectory,
+ NoProjectError: class NoProjectError extends Error {
+ constructor(message = 'No agentcore project found') {
+ super(message);
+ this.name = 'NoProjectError';
+ }
+ },
+}));
+
+describe('projectExists', () => {
+ afterEach(() => vi.clearAllMocks());
+
+ it('returns true when config root is found', () => {
+ mockFindConfigRoot.mockReturnValue('/project/agentcore');
+
+ expect(projectExists('/project')).toBe(true);
+ });
+
+ it('returns false when config root is not found', () => {
+ mockFindConfigRoot.mockReturnValue(null);
+
+ expect(projectExists('/project')).toBe(false);
+ });
+
+ it('uses default working directory when no baseDir provided', () => {
+ mockFindConfigRoot.mockReturnValue('/project/agentcore');
+
+ projectExists();
+
+ expect(mockFindConfigRoot).toHaveBeenCalledWith('/project');
+ });
+
+ it('passes baseDir to findConfigRoot when provided', () => {
+ mockFindConfigRoot.mockReturnValue(null);
+
+ projectExists('/custom/path');
+
+ expect(mockFindConfigRoot).toHaveBeenCalledWith('/custom/path');
+ });
+});
+
+describe('getProjectRootMismatch', () => {
+ afterEach(() => vi.clearAllMocks());
+
+ it('returns null when no project found', () => {
+ mockFindConfigRoot.mockReturnValue(null);
+
+ expect(getProjectRootMismatch('/somewhere')).toBeNull();
+ });
+
+ it('returns null when cwd matches project root', () => {
+ mockFindConfigRoot.mockReturnValue('/project/agentcore');
+
+ expect(getProjectRootMismatch('/project')).toBeNull();
+ });
+
+ it('returns project root when cwd is a subdirectory', () => {
+ mockFindConfigRoot.mockReturnValue('/project/agentcore');
+
+ const result = getProjectRootMismatch('/project/src');
+
+ expect(result).toBe('/project');
+ });
+});
+
+describe('MissingProjectMessage', () => {
+ it('renders error message and "agentcore create" for CLI mode', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('No agentcore project found');
+ expect(frame).toContain('agentcore create');
+ });
+
+ it('renders "create" without "agentcore" prefix for TUI mode', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('No agentcore project found');
+ expect(frame).toContain('create');
+ // In TUI mode, should NOT show the full CLI command
+ const lines = frame.split('\n');
+ const createLine = lines.find(l => l.includes('create'))!;
+ expect(createLine).not.toContain('agentcore create');
+ });
+});
+
+describe('WrongDirectoryMessage', () => {
+ it('renders project root path with cd suggestion', () => {
+ const { lastFrame } = render();
+ const frame = lastFrame()!;
+
+ expect(frame).toContain('project root directory');
+ expect(frame).toContain('/home/user/my-project');
+ expect(frame).toContain('cd /home/user/my-project');
+ });
+});
diff --git a/src/cli/tui/hooks/__tests__/useExitHandler.test.tsx b/src/cli/tui/hooks/__tests__/useExitHandler.test.tsx
new file mode 100644
index 00000000..ba819c28
--- /dev/null
+++ b/src/cli/tui/hooks/__tests__/useExitHandler.test.tsx
@@ -0,0 +1,64 @@
+import { useExitHandler } from '../useExitHandler.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const ESCAPE = '\x1B';
+
+afterEach(() => vi.restoreAllMocks());
+
+function ExitHandlerHarness({ onExit, enabled }: { onExit: () => void; enabled?: boolean }) {
+ useExitHandler(onExit, enabled);
+ return Active;
+}
+
+describe('useExitHandler', () => {
+ it('calls onExit when Escape is pressed', () => {
+ const onExit = vi.fn();
+ const { stdin } = render();
+
+ stdin.write(ESCAPE);
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onExit when Ctrl+Q is pressed', () => {
+ const onExit = vi.fn();
+ const { stdin } = render();
+
+ stdin.write('\x11'); // Ctrl+Q
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call onExit when enabled is false', () => {
+ const onExit = vi.fn();
+ const { stdin } = render();
+
+ stdin.write(ESCAPE);
+ stdin.write('\x11'); // Ctrl+Q
+
+ expect(onExit).not.toHaveBeenCalled();
+ });
+
+ it('does not call onExit on unrelated keys', () => {
+ const onExit = vi.fn();
+ const { stdin } = render();
+
+ stdin.write('a');
+ stdin.write('\r'); // Enter
+ stdin.write('\x1B[A'); // Up arrow
+
+ expect(onExit).not.toHaveBeenCalled();
+ });
+
+ it('enabled defaults to true', () => {
+ const onExit = vi.fn();
+ const { stdin } = render();
+
+ stdin.write(ESCAPE);
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/cli/tui/hooks/__tests__/useListNavigation.test.ts b/src/cli/tui/hooks/__tests__/useListNavigation.test.ts
deleted file mode 100644
index 04d1c1ce..00000000
--- a/src/cli/tui/hooks/__tests__/useListNavigation.test.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { findNextEnabledIndex } from '../useListNavigation.js';
-import { describe, expect, it } from 'vitest';
-
-describe('findNextEnabledIndex', () => {
- const items = ['a', 'b', 'c', 'd', 'e'];
-
- describe('without isDisabled', () => {
- it('moves forward by 1', () => {
- expect(findNextEnabledIndex(items, 0, 1)).toBe(1);
- expect(findNextEnabledIndex(items, 2, 1)).toBe(3);
- });
-
- it('moves backward by 1', () => {
- expect(findNextEnabledIndex(items, 2, -1)).toBe(1);
- expect(findNextEnabledIndex(items, 1, -1)).toBe(0);
- });
-
- it('wraps forward from last to first', () => {
- expect(findNextEnabledIndex(items, 4, 1)).toBe(0);
- });
-
- it('wraps backward from first to last', () => {
- expect(findNextEnabledIndex(items, 0, -1)).toBe(4);
- });
- });
-
- describe('with isDisabled', () => {
- const isDisabled = (item: string) => item === 'b' || item === 'd';
-
- it('skips disabled items going forward', () => {
- // From 'a' (0), skip 'b' (1), land on 'c' (2)
- expect(findNextEnabledIndex(items, 0, 1, isDisabled)).toBe(2);
- });
-
- it('skips disabled items going backward', () => {
- // From 'c' (2), skip 'b' (1), land on 'a' (0)
- expect(findNextEnabledIndex(items, 2, -1, isDisabled)).toBe(0);
- });
-
- it('skips multiple consecutive disabled items', () => {
- const allItems = ['a', 'b', 'c', 'd', 'e'];
- const skip = (item: string) => item === 'b' || item === 'c';
- // From 'a' (0), skip 'b' (1) and 'c' (2), land on 'd' (3)
- expect(findNextEnabledIndex(allItems, 0, 1, skip)).toBe(3);
- });
-
- it('wraps around to find enabled item', () => {
- // From 'e' (4), wrap to 'a' (0) — 'a' is enabled
- expect(findNextEnabledIndex(items, 4, 1, isDisabled)).toBe(0);
- });
-
- it('stays in place when all items are disabled', () => {
- const allDisabled = (_item: string) => true;
- expect(findNextEnabledIndex(items, 2, 1, allDisabled)).toBe(2);
- expect(findNextEnabledIndex(items, 2, -1, allDisabled)).toBe(2);
- });
- });
-
- describe('edge cases', () => {
- it('handles single-item list', () => {
- expect(findNextEnabledIndex(['only'], 0, 1)).toBe(0);
- expect(findNextEnabledIndex(['only'], 0, -1)).toBe(0);
- });
-
- it('handles two-item list', () => {
- expect(findNextEnabledIndex(['a', 'b'], 0, 1)).toBe(1);
- expect(findNextEnabledIndex(['a', 'b'], 1, 1)).toBe(0);
- });
- });
-});
diff --git a/src/cli/tui/hooks/__tests__/useListNavigation.test.tsx b/src/cli/tui/hooks/__tests__/useListNavigation.test.tsx
new file mode 100644
index 00000000..8650ba62
--- /dev/null
+++ b/src/cli/tui/hooks/__tests__/useListNavigation.test.tsx
@@ -0,0 +1,236 @@
+import { findNextEnabledIndex, useListNavigation } from '../useListNavigation.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const UP_ARROW = '\x1B[A';
+const DOWN_ARROW = '\x1B[B';
+const ENTER = '\r';
+const ESCAPE = '\x1B';
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('findNextEnabledIndex', () => {
+ const items = ['a', 'b', 'c', 'd', 'e'];
+
+ describe('without isDisabled', () => {
+ it('moves forward by 1', () => {
+ expect(findNextEnabledIndex(items, 0, 1)).toBe(1);
+ expect(findNextEnabledIndex(items, 2, 1)).toBe(3);
+ });
+
+ it('moves backward by 1', () => {
+ expect(findNextEnabledIndex(items, 2, -1)).toBe(1);
+ expect(findNextEnabledIndex(items, 1, -1)).toBe(0);
+ });
+
+ it('wraps forward from last to first', () => {
+ expect(findNextEnabledIndex(items, 4, 1)).toBe(0);
+ });
+
+ it('wraps backward from first to last', () => {
+ expect(findNextEnabledIndex(items, 0, -1)).toBe(4);
+ });
+ });
+
+ describe('with isDisabled', () => {
+ const isDisabled = (item: string) => item === 'b' || item === 'd';
+
+ it('skips disabled items going forward', () => {
+ expect(findNextEnabledIndex(items, 0, 1, isDisabled)).toBe(2);
+ });
+
+ it('skips disabled items going backward', () => {
+ expect(findNextEnabledIndex(items, 2, -1, isDisabled)).toBe(0);
+ });
+
+ it('skips multiple consecutive disabled items', () => {
+ const allItems = ['a', 'b', 'c', 'd', 'e'];
+ const skip = (item: string) => item === 'b' || item === 'c';
+ expect(findNextEnabledIndex(allItems, 0, 1, skip)).toBe(3);
+ });
+
+ it('wraps around to find enabled item', () => {
+ expect(findNextEnabledIndex(items, 4, 1, isDisabled)).toBe(0);
+ });
+
+ it('stays in place when all items are disabled', () => {
+ const allDisabled = (_item: string) => true;
+ expect(findNextEnabledIndex(items, 2, 1, allDisabled)).toBe(2);
+ expect(findNextEnabledIndex(items, 2, -1, allDisabled)).toBe(2);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles single-item list', () => {
+ expect(findNextEnabledIndex(['only'], 0, 1)).toBe(0);
+ expect(findNextEnabledIndex(['only'], 0, -1)).toBe(0);
+ });
+
+ it('handles two-item list', () => {
+ expect(findNextEnabledIndex(['a', 'b'], 0, 1)).toBe(1);
+ expect(findNextEnabledIndex(['a', 'b'], 1, 1)).toBe(0);
+ });
+ });
+});
+
+// Wrapper component to test the hook via rendering
+function ListNav({
+ items,
+ onSelect,
+ onExit,
+ isDisabled,
+ getHotkeys,
+ onHotkeySelect,
+}: {
+ items: string[];
+ onSelect?: (item: string, index: number) => void;
+ onExit?: () => void;
+ isDisabled?: (item: string) => boolean;
+ getHotkeys?: (item: string) => string[] | undefined;
+ onHotkeySelect?: (item: string, index: number) => void;
+}) {
+ const { selectedIndex } = useListNavigation({
+ items,
+ onSelect,
+ onExit,
+ isDisabled,
+ getHotkeys,
+ onHotkeySelect,
+ });
+ return idx:{selectedIndex};
+}
+
+describe('useListNavigation hook', () => {
+ const items = ['alpha', 'beta', 'gamma'];
+
+ it('starts at index 0', () => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('idx:0');
+ });
+
+ it('moves down with arrow key', async () => {
+ const { lastFrame, stdin } = render();
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(DOWN_ARROW);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(lastFrame()).toContain('idx:1');
+ });
+
+ it('moves up with arrow key', async () => {
+ const { lastFrame, stdin } = render();
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(DOWN_ARROW);
+ stdin.write(DOWN_ARROW);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(lastFrame()).toContain('idx:2');
+
+ stdin.write(UP_ARROW);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(lastFrame()).toContain('idx:1');
+ });
+
+ it('wraps around when navigating past the end', async () => {
+ const { lastFrame, stdin } = render();
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(DOWN_ARROW);
+ stdin.write(DOWN_ARROW);
+ stdin.write(DOWN_ARROW); // wraps to 0
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(lastFrame()).toContain('idx:0');
+ });
+
+ it('calls onSelect on Enter', async () => {
+ const onSelect = vi.fn();
+ const { stdin } = render();
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(ENTER);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(onSelect).toHaveBeenCalledWith('alpha', 0);
+ });
+
+ it('calls onSelect with correct item after navigation', async () => {
+ const onSelect = vi.fn();
+ const { stdin } = render();
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(DOWN_ARROW);
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(ENTER);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(onSelect).toHaveBeenCalledWith('beta', 1);
+ });
+
+ it('calls onExit on Escape', async () => {
+ const onExit = vi.fn();
+ const { stdin } = render();
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(ESCAPE);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('skips disabled items during navigation', async () => {
+ const isDisabled = (item: string) => item === 'beta';
+ const { lastFrame, stdin } = render();
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(DOWN_ARROW); // should skip beta (1) and land on gamma (2)
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(lastFrame()).toContain('idx:2');
+ });
+
+ it('does not select disabled items on Enter', async () => {
+ // When all items are disabled, the hook starts at index 0 and Enter should not call onSelect
+ const isDisabled = () => true;
+ const onSelect = vi.fn();
+ const { stdin } = render();
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write(ENTER);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('supports hotkey selection', async () => {
+ const onHotkeySelect = vi.fn();
+ const getHotkeys = (item: string) => (item === 'gamma' ? ['g'] : undefined);
+ const { stdin } = render();
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write('g');
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(onHotkeySelect).toHaveBeenCalledWith('gamma', 2);
+ });
+
+ it('navigates with j/k keys', async () => {
+ const { lastFrame, stdin } = render();
+
+ await new Promise(resolve => setTimeout(resolve, 50));
+ stdin.write('j'); // down
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(lastFrame()).toContain('idx:1');
+
+ stdin.write('k'); // up
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ expect(lastFrame()).toContain('idx:0');
+ });
+});
diff --git a/src/cli/tui/hooks/__tests__/useMultiSelectNavigation.test.tsx b/src/cli/tui/hooks/__tests__/useMultiSelectNavigation.test.tsx
new file mode 100644
index 00000000..34989782
--- /dev/null
+++ b/src/cli/tui/hooks/__tests__/useMultiSelectNavigation.test.tsx
@@ -0,0 +1,220 @@
+import { useMultiSelectNavigation } from '../useMultiSelectNavigation.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const UP_ARROW = '\x1B[A';
+const DOWN_ARROW = '\x1B[B';
+const ENTER = '\r';
+const ESCAPE = '\x1B';
+const SPACE = ' ';
+
+const delay = (ms = 50) => new Promise(resolve => setTimeout(resolve, ms));
+
+afterEach(() => vi.restoreAllMocks());
+
+interface Item {
+ id: string;
+ name: string;
+}
+
+const items: Item[] = [
+ { id: '1', name: 'alpha' },
+ { id: '2', name: 'beta' },
+ { id: '3', name: 'gamma' },
+];
+
+const getId = (item: Item) => item.id;
+
+function Harness({
+ testItems = items,
+ onConfirm,
+ onExit,
+ isActive,
+ textInputActive,
+ requireSelection,
+}: {
+ testItems?: Item[];
+ onConfirm?: (ids: string[]) => void;
+ onExit?: () => void;
+ isActive?: boolean;
+ textInputActive?: boolean;
+ requireSelection?: boolean;
+}) {
+ const { cursorIndex, selectedIds } = useMultiSelectNavigation({
+ items: testItems,
+ getId,
+ onConfirm,
+ onExit,
+ isActive,
+ textInputActive,
+ requireSelection,
+ });
+ return (
+
+ cursor:{cursorIndex} selected:{Array.from(selectedIds).sort().join(',')}
+
+ );
+}
+
+describe('useMultiSelectNavigation', () => {
+ it('starts with cursorIndex=0 and empty selectedIds', () => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('cursor:0');
+ expect(lastFrame()).toContain('selected:');
+ // Ensure no ids are selected (selected: is followed by nothing meaningful)
+ expect(lastFrame()).not.toMatch(/selected:\S/);
+ });
+
+ it('arrow down moves cursor', async () => {
+ const { lastFrame, stdin } = render();
+ await delay();
+ stdin.write(DOWN_ARROW);
+ await delay();
+ expect(lastFrame()).toContain('cursor:1');
+
+ stdin.write(DOWN_ARROW);
+ await delay();
+ expect(lastFrame()).toContain('cursor:2');
+ });
+
+ it('arrow up moves cursor', async () => {
+ const { lastFrame, stdin } = render();
+ await delay();
+ stdin.write(DOWN_ARROW);
+ stdin.write(DOWN_ARROW);
+ await delay();
+ expect(lastFrame()).toContain('cursor:2');
+
+ stdin.write(UP_ARROW);
+ await delay();
+ expect(lastFrame()).toContain('cursor:1');
+ });
+
+ it('cursor does not go below 0', async () => {
+ const { lastFrame, stdin } = render();
+ await delay();
+ stdin.write(UP_ARROW);
+ await delay();
+ expect(lastFrame()).toContain('cursor:0');
+ });
+
+ it('cursor does not go past items.length-1', async () => {
+ const { lastFrame, stdin } = render();
+ await delay();
+ stdin.write(DOWN_ARROW);
+ stdin.write(DOWN_ARROW);
+ stdin.write(DOWN_ARROW);
+ stdin.write(DOWN_ARROW);
+ await delay();
+ expect(lastFrame()).toContain('cursor:2');
+ });
+
+ it('j/k keys navigate when textInputActive=false', async () => {
+ const { lastFrame, stdin } = render();
+ await delay();
+ stdin.write('j');
+ await delay();
+ expect(lastFrame()).toContain('cursor:1');
+
+ stdin.write('k');
+ await delay();
+ expect(lastFrame()).toContain('cursor:0');
+ });
+
+ it('j/k keys do NOT navigate when textInputActive=true', async () => {
+ const { lastFrame, stdin } = render();
+ await delay();
+ stdin.write('j');
+ await delay();
+ expect(lastFrame()).toContain('cursor:0');
+
+ stdin.write('k');
+ await delay();
+ expect(lastFrame()).toContain('cursor:0');
+ });
+
+ it('space toggles selection (add then remove)', async () => {
+ const { lastFrame, stdin } = render();
+ await delay();
+ // Select item at cursor 0 (id '1')
+ stdin.write(SPACE);
+ await delay();
+ expect(lastFrame()).toContain('selected:1');
+
+ // Toggle again to deselect
+ stdin.write(SPACE);
+ await delay();
+ expect(lastFrame()).not.toMatch(/selected:\S/);
+ });
+
+ it('enter calls onConfirm with selected IDs', async () => {
+ const onConfirm = vi.fn();
+ const { stdin } = render();
+ await delay();
+
+ // Select first item
+ stdin.write(SPACE);
+ await delay();
+
+ // Move down and select second item
+ stdin.write(DOWN_ARROW);
+ await delay();
+ stdin.write(SPACE);
+ await delay();
+
+ // Confirm
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ const calledWith = onConfirm.mock.calls[0]![0] as string[];
+ expect(calledWith.sort()).toEqual(['1', '2']);
+ });
+
+ it('enter does nothing when requireSelection=true and nothing selected', async () => {
+ const onConfirm = vi.fn();
+ const { stdin } = render();
+ await delay();
+
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onConfirm).not.toHaveBeenCalled();
+ });
+
+ it('escape calls onExit', async () => {
+ const onExit = vi.fn();
+ const { stdin } = render();
+ await delay();
+
+ stdin.write(ESCAPE);
+ await delay();
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not respond when isActive=false', async () => {
+ const onConfirm = vi.fn();
+ const onExit = vi.fn();
+ const { lastFrame, stdin } = render();
+ await delay();
+
+ stdin.write(DOWN_ARROW);
+ await delay();
+ expect(lastFrame()).toContain('cursor:0');
+
+ stdin.write(SPACE);
+ await delay();
+ expect(lastFrame()).not.toMatch(/selected:\S/);
+
+ stdin.write(ENTER);
+ await delay();
+ expect(onConfirm).not.toHaveBeenCalled();
+
+ stdin.write(ESCAPE);
+ await delay();
+ expect(onExit).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/cli/tui/hooks/__tests__/useProject.test.tsx b/src/cli/tui/hooks/__tests__/useProject.test.tsx
new file mode 100644
index 00000000..cab2f7b6
--- /dev/null
+++ b/src/cli/tui/hooks/__tests__/useProject.test.tsx
@@ -0,0 +1,61 @@
+import { useProject } from '../useProject.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const { mockFindConfigRoot } = vi.hoisted(() => ({
+ mockFindConfigRoot: vi.fn(),
+}));
+
+vi.mock('../../../../lib/index.js', () => ({
+ findConfigRoot: mockFindConfigRoot,
+ NoProjectError: class NoProjectError extends Error {
+ constructor() {
+ super('No agentcore project found');
+ this.name = 'NoProjectError';
+ }
+ },
+}));
+
+function Harness() {
+ const { hasProject, project, error } = useProject();
+ return (
+
+ hasProject:{String(hasProject)} configRoot:{project?.configRoot ?? 'null'} projectRoot:
+ {project?.projectRoot ?? 'null'} error:{error ?? 'null'}
+
+ );
+}
+
+describe('useProject', () => {
+ afterEach(() => vi.clearAllMocks());
+
+ it('returns hasProject=true with correct paths when config found', () => {
+ mockFindConfigRoot.mockReturnValue('/home/user/my-project/agentcore');
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('hasProject:true');
+ expect(lastFrame()).toContain('configRoot:/home/user/my-project/agentcore');
+ expect(lastFrame()).toContain('error:null');
+ });
+
+ it('returns hasProject=false with error when no config found', () => {
+ mockFindConfigRoot.mockReturnValue(null);
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('hasProject:false');
+ expect(lastFrame()).toContain('configRoot:null');
+ expect(lastFrame()).toContain('error:No agentcore project found');
+ });
+
+ it('projectRoot is parent directory of configRoot', () => {
+ mockFindConfigRoot.mockReturnValue('/a/b/c/agentcore');
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('projectRoot:/a/b/c');
+ });
+});
diff --git a/src/cli/tui/hooks/__tests__/useRemove.test.tsx b/src/cli/tui/hooks/__tests__/useRemove.test.tsx
new file mode 100644
index 00000000..4cae4b2a
--- /dev/null
+++ b/src/cli/tui/hooks/__tests__/useRemove.test.tsx
@@ -0,0 +1,208 @@
+import {
+ useRemovableAgents,
+ useRemovableGateways,
+ useRemovableIdentities,
+ useRemovableMemories,
+ useRemoveAgent,
+} from '../useRemove.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React, { useEffect } from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+// Mock the operations/remove module
+const mockGetRemovableAgents = vi.fn();
+const mockGetRemovableGateways = vi.fn();
+const mockGetRemovableMemories = vi.fn();
+const mockGetRemovableIdentities = vi.fn();
+const mockRemoveAgent = vi.fn();
+
+vi.mock('../../../operations/remove', () => ({
+ getRemovableAgents: (...args: unknown[]) => mockGetRemovableAgents(...args),
+ getRemovableGateways: (...args: unknown[]) => mockGetRemovableGateways(...args),
+ getRemovableMcpTools: vi.fn().mockResolvedValue([]),
+ getRemovableMemories: (...args: unknown[]) => mockGetRemovableMemories(...args),
+ getRemovableIdentities: (...args: unknown[]) => mockGetRemovableIdentities(...args),
+ previewRemoveAgent: vi.fn(),
+ previewRemoveGateway: vi.fn(),
+ previewRemoveMcpTool: vi.fn(),
+ previewRemoveMemory: vi.fn(),
+ previewRemoveIdentity: vi.fn(),
+ removeAgent: (...args: unknown[]) => mockRemoveAgent(...args),
+ removeGateway: vi.fn(),
+ removeMcpTool: vi.fn(),
+ removeMemory: vi.fn(),
+ removeIdentity: vi.fn(),
+}));
+
+// Mock the logging module
+vi.mock('../../../logging', () => ({
+ RemoveLogger: vi.fn().mockImplementation(() => ({
+ logRemoval: vi.fn(),
+ getAbsoluteLogPath: vi.fn().mockReturnValue('/tmp/test.log'),
+ })),
+}));
+
+function delay(ms = 100) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+afterEach(() => vi.clearAllMocks());
+
+// ─── Harnesses ───────────────────────────────────────────────────────
+
+function RemovableAgentsHarness() {
+ const { agents, isLoading } = useRemovableAgents();
+ return (
+
+ loading:{String(isLoading)} agents:{agents.join(',')}
+
+ );
+}
+
+function RemovableGatewaysHarness() {
+ const { gateways, isLoading } = useRemovableGateways();
+ return (
+
+ loading:{String(isLoading)} gateways:{gateways.join(',')}
+
+ );
+}
+
+function RemovableMemoriesHarness() {
+ const { memories, isLoading } = useRemovableMemories();
+ return (
+
+ loading:{String(isLoading)} count:{memories.length}
+
+ );
+}
+
+function RemovableIdentitiesHarness() {
+ const { identities, isLoading } = useRemovableIdentities();
+ return (
+
+ loading:{String(isLoading)} count:{identities.length}
+
+ );
+}
+
+function RemoveAgentHarness({ agentName }: { agentName?: string }) {
+ const { isLoading, result, remove } = useRemoveAgent();
+
+ useEffect(() => {
+ if (agentName) {
+ void remove(agentName);
+ }
+ }, [agentName, remove]);
+
+ return (
+
+ loading:{String(isLoading)} result:{result ? (result.ok ? 'ok' : 'fail') : 'null'}
+
+ );
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────
+
+describe('useRemovableAgents', () => {
+ it('starts in loading state with empty agents array', () => {
+ mockGetRemovableAgents.mockReturnValue(
+ new Promise(() => {
+ /* never resolves */
+ })
+ );
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('loading:true');
+ expect(lastFrame()).toContain('agents:');
+ });
+
+ it('loads agents and exits loading state', async () => {
+ mockGetRemovableAgents.mockResolvedValue(['agent-a', 'agent-b']);
+ const { lastFrame } = render();
+
+ await delay();
+
+ expect(lastFrame()).toContain('loading:false');
+ expect(lastFrame()).toContain('agents:agent-a,agent-b');
+ });
+
+ it('returns empty array when backend returns empty', async () => {
+ mockGetRemovableAgents.mockResolvedValue([]);
+ const { lastFrame } = render();
+
+ await delay();
+
+ expect(lastFrame()).toContain('loading:false');
+ expect(lastFrame()).toContain('agents:');
+ });
+});
+
+describe('useRemovableGateways', () => {
+ it('loads gateways', async () => {
+ mockGetRemovableGateways.mockResolvedValue(['gw-1']);
+ const { lastFrame } = render();
+
+ await delay();
+
+ expect(lastFrame()).toContain('loading:false');
+ expect(lastFrame()).toContain('gateways:gw-1');
+ });
+});
+
+describe('useRemovableMemories', () => {
+ it('loads memories', async () => {
+ mockGetRemovableMemories.mockResolvedValue([
+ { name: 'mem-1', type: 'knowledge_base' },
+ { name: 'mem-2', type: 'knowledge_base' },
+ ]);
+ const { lastFrame } = render();
+
+ await delay();
+
+ expect(lastFrame()).toContain('loading:false');
+ expect(lastFrame()).toContain('count:2');
+ });
+});
+
+describe('useRemovableIdentities', () => {
+ it('loads identities', async () => {
+ mockGetRemovableIdentities.mockResolvedValue([{ name: 'id-1', type: 'api_key' }]);
+ const { lastFrame } = render();
+
+ await delay();
+
+ expect(lastFrame()).toContain('loading:false');
+ expect(lastFrame()).toContain('count:1');
+ });
+});
+
+describe('useRemoveAgent', () => {
+ it('starts with no result and not loading', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('loading:false');
+ expect(lastFrame()).toContain('result:null');
+ });
+
+ it('calls removeAgent and shows success result', async () => {
+ mockRemoveAgent.mockResolvedValue({ ok: true });
+ const { lastFrame } = render();
+
+ await delay();
+
+ expect(mockRemoveAgent).toHaveBeenCalledWith('my-agent');
+ expect(lastFrame()).toContain('result:ok');
+ });
+
+ it('calls removeAgent and shows failure result', async () => {
+ mockRemoveAgent.mockResolvedValue({ ok: false, error: 'Not found' });
+ const { lastFrame } = render();
+
+ await delay();
+
+ expect(mockRemoveAgent).toHaveBeenCalledWith('bad-agent');
+ expect(lastFrame()).toContain('result:fail');
+ });
+});
diff --git a/src/cli/tui/hooks/__tests__/useResponsive.test.tsx b/src/cli/tui/hooks/__tests__/useResponsive.test.tsx
new file mode 100644
index 00000000..eb0d5f55
--- /dev/null
+++ b/src/cli/tui/hooks/__tests__/useResponsive.test.tsx
@@ -0,0 +1,34 @@
+import { useResponsive } from '../useResponsive.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+function Harness() {
+ const { width, height, isNarrow } = useResponsive();
+ return (
+
+ width:{width} height:{height} isNarrow:{String(isNarrow)}
+
+ );
+}
+
+describe('useResponsive', () => {
+ it('returns default dimensions', () => {
+ const { lastFrame } = render();
+
+ // ink-testing-library provides no stdout, so defaults apply (100x24)
+ expect(lastFrame()).toContain('width:');
+ expect(lastFrame()).toContain('height:');
+ // Verify numeric values are present
+ expect(lastFrame()).toMatch(/width:\d+/);
+ expect(lastFrame()).toMatch(/height:\d+/);
+ });
+
+ it('isNarrow is false when width >= 80', () => {
+ const { lastFrame } = render();
+
+ // Default width is 100, which is >= 80
+ expect(lastFrame()).toContain('isNarrow:false');
+ });
+});
diff --git a/src/cli/tui/hooks/__tests__/useSchemaDocument.test.tsx b/src/cli/tui/hooks/__tests__/useSchemaDocument.test.tsx
new file mode 100644
index 00000000..eb0b2f18
--- /dev/null
+++ b/src/cli/tui/hooks/__tests__/useSchemaDocument.test.tsx
@@ -0,0 +1,80 @@
+import { useSchemaDocument } from '../useSchemaDocument.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { z } from 'zod';
+
+const { mockLoadSchemaDocument, mockSaveSchemaDocument } = vi.hoisted(() => ({
+ mockLoadSchemaDocument: vi.fn(),
+ mockSaveSchemaDocument: vi.fn(),
+}));
+
+vi.mock('../../../schema/index.js', () => ({
+ loadSchemaDocument: mockLoadSchemaDocument,
+ saveSchemaDocument: mockSaveSchemaDocument,
+}));
+
+const testSchema = z.object({ name: z.string() });
+
+function Harness({ filePath }: { filePath: string }) {
+ const { content, status, validationMessage } = useSchemaDocument(filePath, testSchema);
+ return (
+
+ status:{status.status} content:{content || 'empty'} validation:{validationMessage ?? 'none'} message:
+ {status.status === 'error' ? status.message : 'none'}
+
+ );
+}
+
+describe('useSchemaDocument', () => {
+ afterEach(() => vi.clearAllMocks());
+
+ it('starts in loading status', () => {
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ mockLoadSchemaDocument.mockReturnValue(new Promise(() => {})); // never resolves
+
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('status:loading');
+ });
+
+ it('loads content and transitions to ready', async () => {
+ mockLoadSchemaDocument.mockResolvedValue({
+ content: 'name: test',
+ validationError: undefined,
+ });
+
+ const { lastFrame } = render();
+
+ await vi.waitFor(() => {
+ expect(lastFrame()).toContain('status:ready');
+ });
+ expect(lastFrame()).toContain('content:name: test');
+ });
+
+ it('shows error status when load fails', async () => {
+ mockLoadSchemaDocument.mockRejectedValue(new Error('File not found'));
+
+ const { lastFrame } = render();
+
+ await vi.waitFor(() => {
+ expect(lastFrame()).toContain('status:error');
+ });
+ expect(lastFrame()).toContain('message:File not found');
+ });
+
+ it('shows validation message from load result', async () => {
+ mockLoadSchemaDocument.mockResolvedValue({
+ content: 'invalid: true',
+ validationError: 'Missing required field: name',
+ });
+
+ const { lastFrame } = render();
+
+ await vi.waitFor(() => {
+ expect(lastFrame()).toContain('status:ready');
+ });
+ expect(lastFrame()).toContain('validation:Missing required field: name');
+ });
+});
diff --git a/src/cli/tui/hooks/__tests__/useTextInput.test.ts b/src/cli/tui/hooks/__tests__/useTextInput.test.ts
deleted file mode 100644
index 858e371b..00000000
--- a/src/cli/tui/hooks/__tests__/useTextInput.test.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { findNextWordBoundary, findPrevWordBoundary } from '../useTextInput.js';
-import { describe, expect, it } from 'vitest';
-
-describe('findPrevWordBoundary', () => {
- it('returns 0 when cursor is at start', () => {
- expect(findPrevWordBoundary('hello world', 0)).toBe(0);
- });
-
- it('moves to start of current word', () => {
- expect(findPrevWordBoundary('hello world', 8)).toBe(6);
- });
-
- it('skips trailing spaces before previous word', () => {
- expect(findPrevWordBoundary('hello world', 6)).toBe(0);
- });
-
- it('moves to start from end of single word', () => {
- expect(findPrevWordBoundary('hello', 5)).toBe(0);
- });
-
- it('handles multiple spaces between words', () => {
- expect(findPrevWordBoundary('hello world', 8)).toBe(0);
- });
-
- it('handles cursor in middle of word', () => {
- expect(findPrevWordBoundary('hello world', 3)).toBe(0);
- });
-
- it('handles three words', () => {
- // cursor at 'b' in 'baz': "foo bar baz"
- // ^8
- expect(findPrevWordBoundary('foo bar baz', 8)).toBe(4);
- });
-
- it('returns 0 for single character', () => {
- expect(findPrevWordBoundary('x', 1)).toBe(0);
- });
-});
-
-describe('findNextWordBoundary', () => {
- it('returns text length when cursor is at end', () => {
- expect(findNextWordBoundary('hello world', 11)).toBe(11);
- });
-
- it('moves past current word and spaces to next word', () => {
- expect(findNextWordBoundary('hello world', 0)).toBe(6);
- });
-
- it('moves from middle of word to start of next word', () => {
- expect(findNextWordBoundary('hello world', 3)).toBe(6);
- });
-
- it('moves to end from start of last word', () => {
- expect(findNextWordBoundary('hello world', 6)).toBe(11);
- });
-
- it('handles multiple spaces between words', () => {
- expect(findNextWordBoundary('hello world', 0)).toBe(8);
- });
-
- it('handles single word', () => {
- expect(findNextWordBoundary('hello', 0)).toBe(5);
- });
-
- it('handles three words', () => {
- // from 'b' in 'bar': "foo bar baz"
- // ^4
- expect(findNextWordBoundary('foo bar baz', 4)).toBe(8);
- });
-
- it('returns text length for single character', () => {
- expect(findNextWordBoundary('x', 0)).toBe(1);
- });
-});
diff --git a/src/cli/tui/hooks/__tests__/useTextInput.test.tsx b/src/cli/tui/hooks/__tests__/useTextInput.test.tsx
new file mode 100644
index 00000000..b634ac1c
--- /dev/null
+++ b/src/cli/tui/hooks/__tests__/useTextInput.test.tsx
@@ -0,0 +1,369 @@
+import { findNextWordBoundary, findPrevWordBoundary, useTextInput } from '../useTextInput.js';
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const ENTER = '\r';
+const ESCAPE = '\x1B';
+const BACKSPACE = '\x7f';
+const LEFT = '\x1B[D';
+const RIGHT = '\x1B[C';
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('findPrevWordBoundary', () => {
+ it('returns 0 when cursor is at start', () => {
+ expect(findPrevWordBoundary('hello world', 0)).toBe(0);
+ });
+
+ it('moves to start of current word', () => {
+ expect(findPrevWordBoundary('hello world', 8)).toBe(6);
+ });
+
+ it('skips trailing spaces before previous word', () => {
+ expect(findPrevWordBoundary('hello world', 6)).toBe(0);
+ });
+
+ it('moves to start from end of single word', () => {
+ expect(findPrevWordBoundary('hello', 5)).toBe(0);
+ });
+
+ it('handles multiple spaces between words', () => {
+ expect(findPrevWordBoundary('hello world', 8)).toBe(0);
+ });
+
+ it('handles cursor in middle of word', () => {
+ expect(findPrevWordBoundary('hello world', 3)).toBe(0);
+ });
+
+ it('handles three words', () => {
+ expect(findPrevWordBoundary('foo bar baz', 8)).toBe(4);
+ });
+
+ it('returns 0 for single character', () => {
+ expect(findPrevWordBoundary('x', 1)).toBe(0);
+ });
+});
+
+describe('findNextWordBoundary', () => {
+ it('returns text length when cursor is at end', () => {
+ expect(findNextWordBoundary('hello world', 11)).toBe(11);
+ });
+
+ it('moves past current word and spaces to next word', () => {
+ expect(findNextWordBoundary('hello world', 0)).toBe(6);
+ });
+
+ it('moves from middle of word to start of next word', () => {
+ expect(findNextWordBoundary('hello world', 3)).toBe(6);
+ });
+
+ it('moves to end from start of last word', () => {
+ expect(findNextWordBoundary('hello world', 6)).toBe(11);
+ });
+
+ it('handles multiple spaces between words', () => {
+ expect(findNextWordBoundary('hello world', 0)).toBe(8);
+ });
+
+ it('handles single word', () => {
+ expect(findNextWordBoundary('hello', 0)).toBe(5);
+ });
+
+ it('handles three words', () => {
+ expect(findNextWordBoundary('foo bar baz', 4)).toBe(8);
+ });
+
+ it('returns text length for single character', () => {
+ expect(findNextWordBoundary('x', 0)).toBe(1);
+ });
+});
+
+// Wrapper component to test the hook via rendering
+function TextInputHarness({
+ initialValue = '',
+ onSubmit,
+ onCancel,
+ onChange,
+ onUpArrow,
+ onDownArrow,
+ isActive,
+}: {
+ initialValue?: string;
+ onSubmit?: (value: string) => void;
+ onCancel?: () => void;
+ onChange?: (value: string) => void;
+ onUpArrow?: () => void;
+ onDownArrow?: () => void;
+ isActive?: boolean;
+}) {
+ const { value, cursor } = useTextInput({
+ initialValue,
+ onSubmit,
+ onCancel,
+ onChange,
+ onUpArrow,
+ onDownArrow,
+ isActive,
+ });
+ return (
+
+ val:[{value}] cur:{cursor}
+
+ );
+}
+
+function delay(ms = 50) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+describe('useTextInput hook', () => {
+ it('starts with initial value and cursor at end', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('val:[hello]');
+ expect(lastFrame()).toContain('cur:5');
+ });
+
+ it('starts empty by default', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('val:[]');
+ expect(lastFrame()).toContain('cur:0');
+ });
+
+ it('accepts character input', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write('a');
+ await delay();
+
+ expect(lastFrame()).toContain('val:[a]');
+ expect(lastFrame()).toContain('cur:1');
+ });
+
+ it('accepts multiple characters', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write('h');
+ stdin.write('i');
+ await delay();
+
+ expect(lastFrame()).toContain('val:[hi]');
+ expect(lastFrame()).toContain('cur:2');
+ });
+
+ it('handles backspace', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(BACKSPACE);
+ await delay();
+
+ expect(lastFrame()).toContain('val:[ab]');
+ expect(lastFrame()).toContain('cur:2');
+ });
+
+ it('backspace at start does nothing', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(BACKSPACE);
+ await delay();
+
+ expect(lastFrame()).toContain('val:[]');
+ expect(lastFrame()).toContain('cur:0');
+ });
+
+ it('calls onSubmit on Enter with current text', async () => {
+ const onSubmit = vi.fn();
+ const { stdin } = render();
+
+ await delay();
+ stdin.write(ENTER);
+ await delay();
+
+ expect(onSubmit).toHaveBeenCalledWith('test');
+ });
+
+ it('calls onCancel on Escape', async () => {
+ const onCancel = vi.fn();
+ const { stdin } = render();
+
+ await delay();
+ stdin.write(ESCAPE);
+ await delay();
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('moves cursor left with arrow key', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(LEFT);
+ await delay();
+
+ expect(lastFrame()).toContain('val:[abc]');
+ expect(lastFrame()).toContain('cur:2');
+ });
+
+ it('moves cursor right with arrow key', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(LEFT);
+ stdin.write(LEFT);
+ await delay();
+ expect(lastFrame()).toContain('cur:1');
+
+ stdin.write(RIGHT);
+ await delay();
+ expect(lastFrame()).toContain('cur:2');
+ });
+
+ it('cursor does not go below 0', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(LEFT);
+ stdin.write(LEFT);
+ stdin.write(LEFT); // try to go past 0
+ await delay();
+
+ expect(lastFrame()).toContain('cur:0');
+ });
+
+ it('cursor does not go past text length', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(RIGHT); // already at end (2)
+ await delay();
+
+ expect(lastFrame()).toContain('cur:2');
+ });
+
+ it('inserts character at cursor position (middle of text)', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write(LEFT); // cursor at 1
+ await delay();
+ stdin.write('b');
+ await delay();
+
+ expect(lastFrame()).toContain('val:[abc]');
+ expect(lastFrame()).toContain('cur:2');
+ });
+
+ it('calls onChange when text changes', async () => {
+ const onChange = vi.fn();
+ const { stdin } = render();
+
+ await delay();
+ stdin.write('x');
+ await delay(100);
+
+ expect(onChange).toHaveBeenCalledWith('x');
+ });
+
+ it('calls onUpArrow on up arrow key', async () => {
+ const onUpArrow = vi.fn();
+ const { stdin } = render();
+
+ await delay();
+ stdin.write('\x1B[A'); // up arrow
+ await delay();
+
+ expect(onUpArrow).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onDownArrow on down arrow key', async () => {
+ const onDownArrow = vi.fn();
+ const { stdin } = render();
+
+ await delay();
+ stdin.write('\x1B[B'); // down arrow
+ await delay();
+
+ expect(onDownArrow).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('useTextInput keyboard shortcuts', () => {
+ it('Ctrl+A moves cursor to start', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write('\x01'); // Ctrl+A
+ await delay();
+
+ expect(lastFrame()).toContain('val:[hello world]');
+ expect(lastFrame()).toContain('cur:0');
+ });
+
+ it('Ctrl+E moves cursor to end', async () => {
+ const { lastFrame, stdin } = render();
+
+ // Move to start first, then Ctrl+E
+ await delay();
+ stdin.write('\x01'); // Ctrl+A → cursor:0
+ await delay();
+ stdin.write('\x05'); // Ctrl+E
+ await delay();
+
+ expect(lastFrame()).toContain('cur:11');
+ });
+
+ it('Ctrl+W deletes previous word', async () => {
+ const { lastFrame, stdin } = render();
+
+ await delay();
+ stdin.write('\x17'); // Ctrl+W
+ await delay();
+
+ expect(lastFrame()).toContain('val:[hello ]');
+ expect(lastFrame()).toContain('cur:6');
+ });
+
+ it('Ctrl+U deletes from cursor to start', async () => {
+ const { lastFrame, stdin } = render();
+
+ // Move cursor to middle first
+ await delay();
+ stdin.write(LEFT);
+ stdin.write(LEFT);
+ stdin.write(LEFT);
+ stdin.write(LEFT);
+ stdin.write(LEFT); // cursor at 6
+ await delay();
+ stdin.write('\x15'); // Ctrl+U
+ await delay();
+
+ expect(lastFrame()).toContain('val:[world]');
+ expect(lastFrame()).toContain('cur:0');
+ });
+
+ it('Ctrl+K deletes from cursor to end', async () => {
+ const { lastFrame, stdin } = render();
+
+ // Move cursor to position 5
+ await delay();
+ stdin.write(LEFT);
+ stdin.write(LEFT);
+ stdin.write(LEFT);
+ stdin.write(LEFT);
+ stdin.write(LEFT);
+ stdin.write(LEFT); // cursor at 5
+ await delay();
+ stdin.write('\x0B'); // Ctrl+K
+ await delay();
+
+ expect(lastFrame()).toContain('val:[hello]');
+ expect(lastFrame()).toContain('cur:5');
+ });
+});
diff --git a/src/lib/errors/__tests__/config-extended.test.ts b/src/lib/errors/__tests__/config-extended.test.ts
deleted file mode 100644
index b95c1a7d..00000000
--- a/src/lib/errors/__tests__/config-extended.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { ConfigValidationError } from '../config.js';
-import { describe, expect, it } from 'vitest';
-import { z } from 'zod';
-
-describe('formatZodIssue extended branches', () => {
- it('formats invalid_union_discriminator with options', () => {
- const schema = z.discriminatedUnion('kind', [
- z.object({ kind: z.literal('cat'), meow: z.boolean() }),
- z.object({ kind: z.literal('dog'), bark: z.boolean() }),
- ]);
- const result = schema.safeParse({ kind: 'fish' });
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
- expect(err.message).toBeDefined();
- }
- });
-
- it('formats invalid_type with expected only (no received)', () => {
- const schema = z.string();
- const result = schema.safeParse(undefined);
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
- expect(err.message).toBeDefined();
- }
- });
-
- it('formats invalid_enum_value with received', () => {
- const schema = z.enum(['a', 'b', 'c']);
- const result = schema.safeParse('x');
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
- expect(err.message).toBeDefined();
- }
- });
-
- it('falls back to Zod message for custom issue codes', () => {
- const schema = z.string().refine(v => v.length > 5, { message: 'Too short' });
- const result = schema.safeParse('hi');
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
- expect(err.message).toContain('Too short');
- }
- });
-
- it('formats invalid_type with both expected and received', () => {
- const schema = z.object({ count: z.number() });
- const result = schema.safeParse({ count: 'hello' });
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
- expect(err.message).toContain('count');
- }
- });
-
- it('formats invalid_enum_value with options list', () => {
- const schema = z.object({ mode: z.enum(['fast', 'slow', 'balanced']) });
- const result = schema.safeParse({ mode: 'turbo' });
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
- expect(err.message).toContain('mode');
- }
- });
-});
diff --git a/src/lib/errors/__tests__/config.test.ts b/src/lib/errors/__tests__/config.test.ts
index 8f704900..d5d1d30a 100644
--- a/src/lib/errors/__tests__/config.test.ts
+++ b/src/lib/errors/__tests__/config.test.ts
@@ -7,7 +7,7 @@ import {
ConfigWriteError,
} from '../config.js';
import { describe, expect, it } from 'vitest';
-import { z } from 'zod';
+import { ZodError, ZodIssueCode, z } from 'zod';
describe('ConfigNotFoundError', () => {
it('has correct message', () => {
@@ -83,127 +83,185 @@ describe('ConfigParseError', () => {
});
describe('ConfigValidationError', () => {
- it('formats Zod errors into readable messages', () => {
- const schema = z.object({
- name: z.string().min(1),
- version: z.number().int(),
- });
- const result = schema.safeParse({ name: '', version: 1.5 });
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path/config.json', 'project', result.error);
- expect(err.message).toContain('/path/config.json');
- }
- });
-
- it('stores zodError', () => {
+ it('stores zodError and is instance of ConfigError', () => {
const schema = z.object({ name: z.string() });
const result = schema.safeParse({ name: 123 });
expect(result.success).toBe(false);
if (!result.success) {
const err = new ConfigValidationError('/path', 'project', result.error);
expect(err.zodError).toBe(result.error);
+ expect(err).toBeInstanceOf(ConfigError);
+ expect(err).toBeInstanceOf(Error);
}
});
- it('formats invalid_type errors with expected/received', () => {
- const schema = z.object({ count: z.number() });
- const result = schema.safeParse({ count: 'not a number' });
+ it('includes file path in message', () => {
+ const schema = z.object({ name: z.string().min(1) });
+ const result = schema.safeParse({ name: '' });
expect(result.success).toBe(false);
if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
- expect(err.message).toContain('count');
+ const err = new ConfigValidationError('/path/config.json', 'project', result.error);
+ expect(err.message).toContain('/path/config.json');
}
});
- it('formats unrecognized_keys errors', () => {
- const schema = z.object({ name: z.string() }).strict();
- const result = schema.safeParse({ name: 'test', extra: true });
+ it('formats multiple errors', () => {
+ const schema = z.object({ name: z.string(), version: z.number() });
+ const result = schema.safeParse({ name: 123, version: 'abc' });
expect(result.success).toBe(false);
if (!result.success) {
const err = new ConfigValidationError('/path', 'project', result.error);
- expect(err.message).toContain('extra');
+ expect(err.message).toContain('name');
+ expect(err.message).toContain('version');
}
});
- it('formats invalid_enum_value errors', () => {
- const schema = z.object({ mode: z.enum(['a', 'b']) });
- const result = schema.safeParse({ mode: 'c' });
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
- expect(err.message).toContain('mode');
- }
- });
+ describe('formatZodIssue branches', () => {
+ it('formats invalid_type with expected type', () => {
+ const schema = z.object({ count: z.number() });
+ const result = schema.safeParse({ count: 'hello' });
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const err = new ConfigValidationError('/path', 'project', result.error);
+ expect(err.message).toContain('count');
+ expect(err.message).toContain('expected');
+ }
+ });
- it('is instance of ConfigError', () => {
- const schema = z.object({ x: z.string() });
- const result = schema.safeParse({});
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
- expect(err).toBeInstanceOf(ConfigError);
- expect(err).toBeInstanceOf(Error);
- }
- });
+ it('formats invalid_type with expected only (no received)', () => {
+ // Zod always sets received, so use a synthetic ZodError to test the branch
+ // where received is undefined (line 92-93 of config.ts)
+ const zodError = new ZodError([
+ {
+ code: ZodIssueCode.invalid_type,
+ path: ['field'],
+ message: 'Expected string',
+ expected: 'string',
+ } as any,
+ ]);
+ const err = new ConfigValidationError('/path', 'project', zodError);
+ expect(err.message).toContain('field');
+ expect(err.message).toContain('expected "string"');
+ expect(err.message).not.toContain('got');
+ });
- it('formats invalid_literal errors', () => {
- const schema = z.object({ version: z.literal(1) });
- const result = schema.safeParse({ version: 2 });
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
+ it('formats invalid_enum_value with received value and valid options', () => {
+ const schema = z.object({ mode: z.enum(['fast', 'slow', 'balanced']) });
+ const result = schema.safeParse({ mode: 'turbo' });
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const err = new ConfigValidationError('/path', 'project', result.error);
+ expect(err.message).toContain('mode');
+ expect(err.message).toMatch(/"fast"|"slow"|"balanced"/);
+ }
+ });
+
+ it('formats invalid_literal with got and expected values', () => {
+ // Use synthetic ZodError since Zod may emit invalid_value instead of invalid_literal
+ const zodError = new ZodError([
+ {
+ code: 'invalid_literal',
+ path: ['version'],
+ message: 'Invalid literal',
+ expected: 1,
+ received: 2,
+ } as any,
+ ]);
+ const err = new ConfigValidationError('/path', 'project', zodError);
expect(err.message).toContain('version');
- }
- });
+ expect(err.message).toContain('got 2');
+ expect(err.message).toContain('expected 1');
+ });
- it('formats discriminated union errors', () => {
- const schema = z.object({
- mode: z.enum(['fast', 'slow']),
+ it('formats unrecognized_keys listing the unknown keys', () => {
+ const schema = z.object({ name: z.string() }).strict();
+ const result = schema.safeParse({ name: 'test', extra: true, bonus: 42 });
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const err = new ConfigValidationError('/path', 'project', result.error);
+ expect(err.message).toContain('unknown keys');
+ expect(err.message).toContain('"extra"');
+ expect(err.message).toContain('"bonus"');
+ }
});
- const result = schema.safeParse({ mode: 'invalid' });
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
- expect(err.message).toContain('mode');
- }
- });
- it('formats nested path errors', () => {
- const schema = z.object({
- agents: z.array(z.object({ name: z.string() })),
+ it('formats invalid_union_discriminator with options', () => {
+ // Use synthetic ZodError with string code since ZodIssueCode may not include
+ // invalid_union_discriminator in all Zod versions
+ const zodError = new ZodError([
+ {
+ code: 'invalid_union_discriminator',
+ path: ['kind'],
+ message: 'Invalid discriminator',
+ options: ['cat', 'dog'],
+ } as any,
+ ]);
+ const err = new ConfigValidationError('/path', 'project', zodError);
+ expect(err.message).toContain('"cat"');
+ expect(err.message).toContain('"dog"');
+ expect(err.message).toMatch(/"cat" \| "dog"/);
});
- const result = schema.safeParse({ agents: [{ name: 123 }] });
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
- // Path should show agents[0].name or similar
- expect(err.message).toContain('agents');
- expect(err.message).toContain('name');
- }
- });
- it('formats root-level error path', () => {
- const schema = z.string();
- const result = schema.safeParse(123);
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
- expect(err.message).toContain('root');
- }
+ it('formats invalid_union with discriminator field (Zod 4)', () => {
+ // Construct a synthetic invalid_union issue with discriminator property
+ const zodError = new ZodError([
+ {
+ code: ZodIssueCode.invalid_union,
+ path: ['config'],
+ message: 'Invalid union',
+ } as any,
+ ]);
+ (zodError.issues[0] as any).discriminator = 'type';
+
+ const err = new ConfigValidationError('/path', 'project', zodError);
+ expect(err.message).toContain('invalid "type" value');
+ });
+
+ it('crashes on invalid_union with nested errors missing path (known bug)', () => {
+ // z.union produces invalid_union issues where nested errors lack `path`.
+ // formatPath(issue.path) crashes because path is undefined.
+ const schema = z.union([z.string(), z.number()]);
+ const result = schema.safeParse(true);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(() => new ConfigValidationError('/path', 'project', result.error)).toThrow(
+ /Cannot read properties of undefined/
+ );
+ }
+ });
+
+ it('falls back to Zod message for custom issue codes (refine)', () => {
+ const schema = z.string().refine(v => v.length > 5, { message: 'Too short' });
+ const result = schema.safeParse('hi');
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const err = new ConfigValidationError('/path', 'project', result.error);
+ expect(err.message).toContain('Too short');
+ }
+ });
});
- it('formats multiple errors', () => {
- const schema = z.object({
- name: z.string(),
- version: z.number(),
+ describe('formatPath', () => {
+ it('formats root-level error as "root"', () => {
+ const schema = z.string();
+ const result = schema.safeParse(123);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const err = new ConfigValidationError('/path', 'project', result.error);
+ expect(err.message).toContain('root');
+ }
+ });
+
+ it('formats nested path with array indices as bracket notation', () => {
+ const schema = z.object({
+ agents: z.array(z.object({ name: z.string() })),
+ });
+ const result = schema.safeParse({ agents: [{ name: 123 }] });
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const err = new ConfigValidationError('/path', 'project', result.error);
+ expect(err.message).toMatch(/agents\[0\]\.name/);
+ }
});
- const result = schema.safeParse({ name: 123, version: 'abc' });
- expect(result.success).toBe(false);
- if (!result.success) {
- const err = new ConfigValidationError('/path', 'project', result.error);
- expect(err.message).toContain('name');
- expect(err.message).toContain('version');
- }
});
});
diff --git a/src/lib/packaging/__tests__/helpers.test.ts b/src/lib/packaging/__tests__/helpers.test.ts
index c0d2943b..0958f075 100644
--- a/src/lib/packaging/__tests__/helpers.test.ts
+++ b/src/lib/packaging/__tests__/helpers.test.ts
@@ -1,3 +1,4 @@
+/* eslint-disable security/detect-non-literal-fs-filename */
import {
MAX_ZIP_SIZE_BYTES,
convertWindowsScriptsToLinux,
@@ -354,4 +355,37 @@ describe('convertWindowsScriptsToLinux (shebang rewriting on non-Windows)', () =
const content = readFileSync(join(binDir, 'script'), 'utf-8');
expect(content).toMatch(/^#!\/usr\/bin\/env python3/);
});
+
+ it('async version skips subdirectories in bin (only processes files)', async () => {
+ const staging = join(root, 'staging-subdir-async');
+ const binDir = join(staging, 'bin');
+ mkdirSync(join(binDir, 'subdir'), { recursive: true });
+ writeFileSync(join(binDir, 'myscript'), '#!/Users/dev/.venv/bin/python3\nimport os');
+
+ await convertWindowsScriptsToLinux(staging);
+
+ const content = readFileSync(join(binDir, 'myscript'), 'utf-8');
+ expect(content).toMatch(/^#!\/usr\/bin\/env python3/);
+ expect(existsSync(join(binDir, 'subdir'))).toBe(true);
+ });
+
+ it('sync version skips subdirectories in bin (only processes files)', () => {
+ const staging = join(root, 'staging-subdir-sync');
+ const binDir = join(staging, 'bin');
+ mkdirSync(join(binDir, 'subdir'), { recursive: true });
+ writeFileSync(join(binDir, 'myscript'), '#!/Users/dev/.venv/bin/python3\nimport os');
+
+ convertWindowsScriptsToLinuxSync(staging);
+
+ const content = readFileSync(join(binDir, 'myscript'), 'utf-8');
+ expect(content).toMatch(/^#!\/usr\/bin\/env python3/);
+ expect(existsSync(join(binDir, 'subdir'))).toBe(true);
+ });
+
+ it('sync version handles missing bin directory gracefully', () => {
+ const staging = join(root, 'staging-no-bin-sync');
+ mkdirSync(staging, { recursive: true });
+ convertWindowsScriptsToLinuxSync(staging);
+ expect(existsSync(join(staging, 'bin'))).toBe(false);
+ });
});
diff --git a/src/lib/schemas/io/__tests__/config-io-extended.test.ts b/src/lib/schemas/io/__tests__/config-io-extended.test.ts
deleted file mode 100644
index 64667532..00000000
--- a/src/lib/schemas/io/__tests__/config-io-extended.test.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-/* eslint-disable security/detect-non-literal-fs-filename */
-import { ConfigNotFoundError, ConfigParseError, ConfigValidationError } from '../../../errors/config.js';
-import { ConfigIO } from '../config-io.js';
-import { NoProjectError } from '../path-resolver.js';
-import { randomUUID } from 'node:crypto';
-import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
-import { mkdir, rm } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-
-describe('ConfigIO extended', () => {
- let testDir: string;
- let originalCwd: string;
- let originalInitCwd: string | undefined;
-
- beforeAll(async () => {
- testDir = join(tmpdir(), `agentcore-configio-ext-${randomUUID()}`);
- await mkdir(testDir, { recursive: true });
- originalCwd = process.cwd();
- originalInitCwd = process.env.INIT_CWD;
- });
-
- afterAll(async () => {
- process.chdir(originalCwd);
- if (originalInitCwd !== undefined) {
- process.env.INIT_CWD = originalInitCwd;
- } else {
- delete process.env.INIT_CWD;
- }
- await rm(testDir, { recursive: true, force: true });
- });
-
- function changeWorkingDir(dir: string): void {
- process.chdir(dir);
- delete process.env.INIT_CWD;
- }
-
- describe('readProjectSpec error paths', () => {
- it('throws ConfigNotFoundError when agentcore.json does not exist', async () => {
- const projectDir = join(testDir, `missing-config-${randomUUID()}`);
- const agentcoreDir = join(projectDir, 'agentcore');
- mkdirSync(agentcoreDir, { recursive: true });
- // Create a minimal file so findConfigRoot finds the directory
- writeFileSync(join(agentcoreDir, 'agentcore.json'), '{}');
- changeWorkingDir(projectDir);
-
- const configIO = new ConfigIO();
- // Delete the file after ConfigIO discovers the root
- const fs = await import('node:fs/promises');
- await fs.unlink(join(agentcoreDir, 'agentcore.json'));
-
- await expect(configIO.readProjectSpec()).rejects.toThrow(ConfigNotFoundError);
- });
-
- it('throws ConfigParseError for invalid JSON', async () => {
- const projectDir = join(testDir, `bad-json-${randomUUID()}`);
- const agentcoreDir = join(projectDir, 'agentcore');
- mkdirSync(agentcoreDir, { recursive: true });
- writeFileSync(join(agentcoreDir, 'agentcore.json'), '{not valid json!!!}');
- changeWorkingDir(projectDir);
-
- const configIO = new ConfigIO();
-
- await expect(configIO.readProjectSpec()).rejects.toThrow(ConfigParseError);
- });
-
- it('throws ConfigValidationError for valid JSON that fails schema', async () => {
- const projectDir = join(testDir, `bad-schema-${randomUUID()}`);
- const agentcoreDir = join(projectDir, 'agentcore');
- mkdirSync(agentcoreDir, { recursive: true });
- writeFileSync(join(agentcoreDir, 'agentcore.json'), JSON.stringify({ invalid: true }));
- changeWorkingDir(projectDir);
-
- const configIO = new ConfigIO();
-
- await expect(configIO.readProjectSpec()).rejects.toThrow(ConfigValidationError);
- });
- });
-
- describe('writeProjectSpec error paths', () => {
- it('throws NoProjectError when no project discovered', async () => {
- const emptyDir = join(testDir, `empty-write-${randomUUID()}`);
- mkdirSync(emptyDir, { recursive: true });
- changeWorkingDir(emptyDir);
-
- const configIO = new ConfigIO();
-
- await expect(configIO.writeProjectSpec({} as any)).rejects.toThrow(NoProjectError);
- });
-
- it('throws ConfigValidationError for invalid project data', async () => {
- const projectDir = join(testDir, `invalid-write-${randomUUID()}`);
- const agentcoreDir = join(projectDir, 'agentcore');
- mkdirSync(agentcoreDir, { recursive: true });
-
- const configIO = new ConfigIO({ baseDir: agentcoreDir });
-
- await expect(configIO.writeProjectSpec({ bad: 'data' } as any)).rejects.toThrow(ConfigValidationError);
- });
- });
-
- describe('configExists', () => {
- it('returns true when agentcore.json exists', () => {
- const projectDir = join(testDir, `exists-${randomUUID()}`);
- const agentcoreDir = join(projectDir, 'agentcore');
- mkdirSync(agentcoreDir, { recursive: true });
- writeFileSync(join(agentcoreDir, 'agentcore.json'), '{}');
- changeWorkingDir(projectDir);
-
- const configIO = new ConfigIO();
-
- expect(configIO.configExists('project')).toBe(true);
- });
-
- it('returns false when aws-targets.json does not exist', () => {
- const projectDir = join(testDir, `no-targets-${randomUUID()}`);
- const agentcoreDir = join(projectDir, 'agentcore');
- mkdirSync(agentcoreDir, { recursive: true });
- writeFileSync(join(agentcoreDir, 'agentcore.json'), '{}');
- changeWorkingDir(projectDir);
-
- const configIO = new ConfigIO();
-
- expect(configIO.configExists('awsTargets')).toBe(false);
- expect(configIO.configExists('state')).toBe(false);
- expect(configIO.configExists('mcp')).toBe(false);
- expect(configIO.configExists('mcpDefs')).toBe(false);
- });
- });
-
- describe('initializeBaseDir', () => {
- it('creates base and cli system directories', async () => {
- const projectDir = join(testDir, `init-base-${randomUUID()}`);
- const agentcoreDir = join(projectDir, 'agentcore');
-
- const configIO = new ConfigIO({ baseDir: agentcoreDir });
- await configIO.initializeBaseDir();
-
- expect(existsSync(agentcoreDir)).toBe(true);
- expect(existsSync(join(agentcoreDir, '.cli'))).toBe(true);
- });
-
- it('throws NoProjectError when project not discovered', async () => {
- const emptyDir = join(testDir, `no-init-${randomUUID()}`);
- mkdirSync(emptyDir, { recursive: true });
- changeWorkingDir(emptyDir);
-
- const configIO = new ConfigIO();
-
- await expect(configIO.initializeBaseDir()).rejects.toThrow(NoProjectError);
- });
- });
-
- describe('baseDirExists', () => {
- it('returns true when base dir exists', () => {
- const projectDir = join(testDir, `basedir-exists-${randomUUID()}`);
- const agentcoreDir = join(projectDir, 'agentcore');
- mkdirSync(agentcoreDir, { recursive: true });
-
- const configIO = new ConfigIO({ baseDir: agentcoreDir });
-
- expect(configIO.baseDirExists()).toBe(true);
- });
-
- it('returns false when base dir does not exist', () => {
- const configIO = new ConfigIO({ baseDir: join(testDir, 'nonexistent') });
-
- expect(configIO.baseDirExists()).toBe(false);
- });
- });
-});
diff --git a/src/lib/schemas/io/__tests__/config-io.test.ts b/src/lib/schemas/io/__tests__/config-io.test.ts
index 0a2a3982..ead1f46f 100644
--- a/src/lib/schemas/io/__tests__/config-io.test.ts
+++ b/src/lib/schemas/io/__tests__/config-io.test.ts
@@ -1,9 +1,10 @@
/* eslint-disable security/detect-non-literal-fs-filename */
+import { ConfigNotFoundError, ConfigParseError, ConfigValidationError } from '../../../errors/config.js';
import { ConfigIO } from '../config-io.js';
import { NoProjectError } from '../path-resolver.js';
import { randomUUID } from 'node:crypto';
-import { existsSync } from 'node:fs';
-import { mkdir, rm, writeFile } from 'node:fs/promises';
+import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
+import { mkdir, rm, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
@@ -39,7 +40,6 @@ describe('ConfigIO', () => {
describe('hasProject()', () => {
it('returns false when no project exists and no baseDir provided', async () => {
- // Create a directory with no agentcore project
const emptyDir = join(testDir, `empty-${randomUUID()}`);
await mkdir(emptyDir, { recursive: true });
changeWorkingDir(emptyDir);
@@ -55,7 +55,6 @@ describe('ConfigIO', () => {
});
it('returns true when project is discovered', async () => {
- // Create a valid project structure
const projectDir = join(testDir, `project-${randomUUID()}`);
const agentcoreDir = join(projectDir, 'agentcore');
await mkdir(agentcoreDir, { recursive: true });
@@ -68,11 +67,10 @@ describe('ConfigIO', () => {
});
});
- describe('issue #94: prevents agentcore directory creation when no project found', () => {
+ describe('NoProjectError prevention (issue #94)', () => {
let emptyDir: string;
beforeEach(async () => {
- // Create a fresh empty directory for each test
emptyDir = join(testDir, `empty-${randomUUID()}`);
await mkdir(emptyDir, { recursive: true });
changeWorkingDir(emptyDir);
@@ -80,40 +78,24 @@ describe('ConfigIO', () => {
it('initializeBaseDir() throws NoProjectError when no project exists', async () => {
const configIO = new ConfigIO();
-
await expect(configIO.initializeBaseDir()).rejects.toThrow(NoProjectError);
expect(existsSync(join(emptyDir, 'agentcore'))).toBe(false);
});
it('writeProjectSpec() throws NoProjectError when no project exists', async () => {
const configIO = new ConfigIO();
-
- const projectSpec = {
- version: '1.0',
- agents: [],
- };
-
- await expect(configIO.writeProjectSpec(projectSpec as never)).rejects.toThrow(NoProjectError);
+ await expect(configIO.writeProjectSpec({ version: '1.0', agents: [] } as never)).rejects.toThrow(NoProjectError);
expect(existsSync(join(emptyDir, 'agentcore'))).toBe(false);
});
it('writeMcpSpec() throws NoProjectError when no project exists', async () => {
const configIO = new ConfigIO();
-
- // Minimal valid MCP spec structure (validation happens after project check)
- const mcpSpec = {
- agentCoreGateways: [],
- };
-
- await expect(configIO.writeMcpSpec(mcpSpec)).rejects.toThrow(NoProjectError);
+ await expect(configIO.writeMcpSpec({ agentCoreGateways: [] })).rejects.toThrow(NoProjectError);
expect(existsSync(join(emptyDir, 'agentcore'))).toBe(false);
});
it('does not create agentcore directory on any write operation', async () => {
const configIO = new ConfigIO();
-
- // Try all write operations - all should fail with NoProjectError without creating directory
- // Note: NoProjectError is thrown before schema validation, so data shape doesn't matter
const operations = [
() => configIO.initializeBaseDir(),
() => configIO.writeProjectSpec({ version: '1.0', agents: [] } as never),
@@ -133,17 +115,197 @@ describe('ConfigIO', () => {
});
});
- describe('allows operations when project is explicitly configured', () => {
- it('initializeBaseDir() succeeds when baseDir is provided', async () => {
+ describe('initializeBaseDir', () => {
+ it('creates base and cli system directories when baseDir is provided', async () => {
const projectDir = join(testDir, `new-project-${randomUUID()}`);
const agentcoreDir = join(projectDir, 'agentcore');
const configIO = new ConfigIO({ baseDir: agentcoreDir });
-
await configIO.initializeBaseDir();
expect(existsSync(agentcoreDir)).toBe(true);
expect(existsSync(join(agentcoreDir, '.cli'))).toBe(true);
});
});
+
+ describe('readProjectSpec error paths', () => {
+ it('throws ConfigNotFoundError when agentcore.json does not exist', async () => {
+ const projectDir = join(testDir, `missing-config-${randomUUID()}`);
+ const agentcoreDir = join(projectDir, 'agentcore');
+ mkdirSync(agentcoreDir, { recursive: true });
+ writeFileSync(join(agentcoreDir, 'agentcore.json'), '{}');
+ changeWorkingDir(projectDir);
+
+ const configIO = new ConfigIO();
+ // Delete the file after ConfigIO discovers the root
+ await unlink(join(agentcoreDir, 'agentcore.json'));
+
+ await expect(configIO.readProjectSpec()).rejects.toThrow(ConfigNotFoundError);
+ });
+
+ it('throws ConfigParseError for invalid JSON', async () => {
+ const projectDir = join(testDir, `bad-json-${randomUUID()}`);
+ const agentcoreDir = join(projectDir, 'agentcore');
+ mkdirSync(agentcoreDir, { recursive: true });
+ writeFileSync(join(agentcoreDir, 'agentcore.json'), '{not valid json!!!}');
+ changeWorkingDir(projectDir);
+
+ const configIO = new ConfigIO();
+ await expect(configIO.readProjectSpec()).rejects.toThrow(ConfigParseError);
+ });
+
+ it('throws ConfigValidationError for valid JSON that fails schema', async () => {
+ const projectDir = join(testDir, `bad-schema-${randomUUID()}`);
+ const agentcoreDir = join(projectDir, 'agentcore');
+ mkdirSync(agentcoreDir, { recursive: true });
+ writeFileSync(join(agentcoreDir, 'agentcore.json'), JSON.stringify({ invalid: true }));
+ changeWorkingDir(projectDir);
+
+ const configIO = new ConfigIO();
+ await expect(configIO.readProjectSpec()).rejects.toThrow(ConfigValidationError);
+ });
+ });
+
+ describe('writeProjectSpec', () => {
+ it('throws ConfigValidationError for invalid project data', async () => {
+ const projectDir = join(testDir, `invalid-write-${randomUUID()}`);
+ const agentcoreDir = join(projectDir, 'agentcore');
+ mkdirSync(agentcoreDir, { recursive: true });
+
+ const configIO = new ConfigIO({ baseDir: agentcoreDir });
+ await expect(configIO.writeProjectSpec({ bad: 'data' } as any)).rejects.toThrow(ConfigValidationError);
+ });
+
+ it('writes and round-trips a valid project spec', async () => {
+ const projectDir = join(testDir, `write-valid-${randomUUID()}`);
+ const agentcoreDir = join(projectDir, 'agentcore');
+ mkdirSync(agentcoreDir, { recursive: true });
+
+ const configIO = new ConfigIO({ baseDir: agentcoreDir });
+
+ // Use 'as any' to avoid branded type issues with FilePath/DirectoryPath
+ const validSpec = {
+ name: 'TestProject',
+ version: 1,
+ agents: [
+ {
+ type: 'AgentCoreRuntime',
+ name: 'myagent',
+ build: 'CodeZip',
+ entrypoint: 'main.py',
+ codeLocation: './app',
+ runtimeVersion: 'PYTHON_3_13',
+ },
+ ],
+ } as any;
+
+ await configIO.writeProjectSpec(validSpec);
+ expect(existsSync(join(agentcoreDir, 'agentcore.json'))).toBe(true);
+
+ const readBack = await configIO.readProjectSpec();
+ expect(readBack.version).toBe(1);
+ expect(readBack.agents).toHaveLength(1);
+ expect(readBack.agents[0]!.name).toBe('myagent');
+ });
+ });
+
+ describe('configExists', () => {
+ it('returns true when agentcore.json exists', () => {
+ const projectDir = join(testDir, `exists-${randomUUID()}`);
+ const agentcoreDir = join(projectDir, 'agentcore');
+ mkdirSync(agentcoreDir, { recursive: true });
+ writeFileSync(join(agentcoreDir, 'agentcore.json'), '{}');
+ changeWorkingDir(projectDir);
+
+ const configIO = new ConfigIO();
+ expect(configIO.configExists('project')).toBe(true);
+ });
+
+ it('returns false for config types that do not exist', () => {
+ const projectDir = join(testDir, `no-targets-${randomUUID()}`);
+ const agentcoreDir = join(projectDir, 'agentcore');
+ mkdirSync(agentcoreDir, { recursive: true });
+ writeFileSync(join(agentcoreDir, 'agentcore.json'), '{}');
+ changeWorkingDir(projectDir);
+
+ const configIO = new ConfigIO();
+ expect(configIO.configExists('awsTargets')).toBe(false);
+ expect(configIO.configExists('state')).toBe(false);
+ expect(configIO.configExists('mcp')).toBe(false);
+ expect(configIO.configExists('mcpDefs')).toBe(false);
+ });
+ });
+
+ describe('baseDirExists', () => {
+ it('returns true when base dir exists', () => {
+ const projectDir = join(testDir, `basedir-exists-${randomUUID()}`);
+ const agentcoreDir = join(projectDir, 'agentcore');
+ mkdirSync(agentcoreDir, { recursive: true });
+
+ const configIO = new ConfigIO({ baseDir: agentcoreDir });
+ expect(configIO.baseDirExists()).toBe(true);
+ });
+
+ it('returns false when base dir does not exist', () => {
+ const configIO = new ConfigIO({ baseDir: join(testDir, 'nonexistent') });
+ expect(configIO.baseDirExists()).toBe(false);
+ });
+ });
+
+ describe('getPathResolver, getProjectRoot, getConfigRoot', () => {
+ it('returns the path resolver, project root, and config root', () => {
+ const projectDir = join(testDir, `paths-${randomUUID()}`);
+ const agentcoreDir = join(projectDir, 'agentcore');
+ mkdirSync(agentcoreDir, { recursive: true });
+
+ const configIO = new ConfigIO({ baseDir: agentcoreDir });
+ expect(configIO.getPathResolver()).toBeDefined();
+ expect(configIO.getProjectRoot()).toBe(projectDir);
+ expect(configIO.getConfigRoot()).toBe(agentcoreDir);
+ });
+ });
+
+ describe('setBaseDir', () => {
+ it('updates the base directory', () => {
+ const configIO = new ConfigIO({ baseDir: '/original' });
+ expect(configIO.getConfigRoot()).toBe('/original');
+
+ configIO.setBaseDir('/updated');
+ expect(configIO.getConfigRoot()).toBe('/updated');
+ });
+ });
+
+ describe('writeMcpSpec and readMcpSpec', () => {
+ it('round-trips a valid MCP spec', async () => {
+ const projectDir = join(testDir, `mcp-rt-${randomUUID()}`);
+ const agentcoreDir = join(projectDir, 'agentcore');
+ mkdirSync(agentcoreDir, { recursive: true });
+
+ const configIO = new ConfigIO({ baseDir: agentcoreDir });
+
+ const mcpSpec = { agentCoreGateways: [] };
+ await configIO.writeMcpSpec(mcpSpec);
+ expect(configIO.configExists('mcp')).toBe(true);
+
+ const readBack = await configIO.readMcpSpec();
+ expect(readBack.agentCoreGateways).toEqual([]);
+ });
+ });
+
+ describe('writeMcpDefs and readMcpDefs', () => {
+ it('round-trips valid MCP definitions', async () => {
+ const projectDir = join(testDir, `mcpdefs-rt-${randomUUID()}`);
+ const agentcoreDir = join(projectDir, 'agentcore');
+ mkdirSync(agentcoreDir, { recursive: true });
+
+ const configIO = new ConfigIO({ baseDir: agentcoreDir });
+
+ const mcpDefs = { tools: {} };
+ await configIO.writeMcpDefs(mcpDefs);
+ expect(configIO.configExists('mcpDefs')).toBe(true);
+
+ const readBack = await configIO.readMcpDefs();
+ expect(readBack.tools).toEqual({});
+ });
+ });
});
diff --git a/src/lib/utils/__tests__/platform.test.ts b/src/lib/utils/__tests__/platform.test.ts
index 489e29e0..8f02c7b2 100644
--- a/src/lib/utils/__tests__/platform.test.ts
+++ b/src/lib/utils/__tests__/platform.test.ts
@@ -1,11 +1,9 @@
import { getShellArgs, getShellCommand, getVenvExecutable, normalizeCommand } from '../platform.js';
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
describe('getVenvExecutable', () => {
- // These tests verify the logic based on the current platform (macOS/Linux in CI)
it('returns bin path on unix', () => {
const result = getVenvExecutable('.venv', 'python');
- // On macOS/Linux: .venv/bin/python
expect(result).toContain('python');
expect(result).toMatch(/\.venv/);
});
@@ -28,23 +26,72 @@ describe('getShellArgs', () => {
it('wraps command with shell flag', () => {
const args = getShellArgs('echo hello');
expect(args).toHaveLength(2);
- // On Unix: ['-c', 'echo hello']
expect(args[1]).toBe('echo hello');
});
});
describe('normalizeCommand', () => {
- // On non-Windows (this test will run on macOS/Linux), commands should pass through unchanged
it('returns command unchanged on non-Windows', () => {
- if (process.platform !== 'win32') {
- expect(normalizeCommand('python')).toBe('python');
- expect(normalizeCommand('node')).toBe('node');
- expect(normalizeCommand('npm')).toBe('npm');
- }
+ expect(normalizeCommand('python')).toBe('python');
+ expect(normalizeCommand('node')).toBe('node');
+ expect(normalizeCommand('npm')).toBe('npm');
});
- it('preserves commands that already have extensions', () => {
- // Even on any platform, already-extended commands should pass through
+ it('preserves commands that already have .exe extension', () => {
expect(normalizeCommand('python.exe')).toBe('python.exe');
});
+
+ it('preserves commands that already have .cmd extension', () => {
+ expect(normalizeCommand('npm.cmd')).toBe('npm.cmd');
+ });
+
+ it('preserves commands that already have .bat extension', () => {
+ expect(normalizeCommand('run.bat')).toBe('run.bat');
+ });
+
+ it('returns unknown commands unchanged', () => {
+ expect(normalizeCommand('custom-tool')).toBe('custom-tool');
+ expect(normalizeCommand('my-script')).toBe('my-script');
+ });
+});
+
+describe('normalizeCommand (Windows behavior)', () => {
+ const originalPlatform = process.platform;
+
+ afterEach(() => {
+ Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true });
+ vi.resetModules();
+ });
+
+ it('appends .exe to known commands on Windows', async () => {
+ vi.resetModules();
+ Object.defineProperty(process, 'platform', { value: 'win32', writable: true });
+ const { normalizeCommand: normalizeWin } = await import('../platform.js');
+
+ expect(normalizeWin('python')).toBe('python.exe');
+ expect(normalizeWin('node')).toBe('node.exe');
+ expect(normalizeWin('npm')).toBe('npm.exe');
+ expect(normalizeWin('git')).toBe('git.exe');
+ expect(normalizeWin('uvicorn')).toBe('uvicorn.exe');
+ expect(normalizeWin('pip')).toBe('pip.exe');
+ });
+
+ it('does not append .exe to commands already with extensions on Windows', async () => {
+ vi.resetModules();
+ Object.defineProperty(process, 'platform', { value: 'win32', writable: true });
+ const { normalizeCommand: normalizeWin } = await import('../platform.js');
+
+ expect(normalizeWin('python.exe')).toBe('python.exe');
+ expect(normalizeWin('npm.cmd')).toBe('npm.cmd');
+ expect(normalizeWin('run.bat')).toBe('run.bat');
+ });
+
+ it('does not append .exe to unknown commands on Windows', async () => {
+ vi.resetModules();
+ Object.defineProperty(process, 'platform', { value: 'win32', writable: true });
+ const { normalizeCommand: normalizeWin } = await import('../platform.js');
+
+ expect(normalizeWin('custom-tool')).toBe('custom-tool');
+ expect(normalizeWin('my-script')).toBe('my-script');
+ });
});
diff --git a/vitest.config.ts b/vitest.config.ts
index 00f88b25..8ed66077 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -38,7 +38,7 @@ export default defineConfig({
extends: true,
test: {
name: 'unit',
- include: ['src/**/*.test.ts'],
+ include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
exclude: ['src/assets/cdk/test/*.test.ts'],
},
},