From 6a06c2530a4580119cf5cba4621ae492a7597b82 Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 16 Feb 2026 11:51:37 -0500 Subject: [PATCH 01/10] test: add TUI component tests with ink-testing-library --- package-lock.json | 20 +++ package.json | 1 + .../__tests__/ConfirmReview.test.tsx | 50 +++++++ .../components/__tests__/FatalError.test.tsx | 45 ++++++ .../tui/components/__tests__/Header.test.tsx | 34 +++++ .../components/__tests__/HelpText.test.tsx | 20 +++ .../__tests__/ScreenHeader.test.tsx | 30 ++++ .../__tests__/StepProgress.test.tsx | 132 ++++++++++++++++++ .../context/__tests__/LayoutContext.test.ts | 33 +++++ vitest.config.ts | 2 +- 10 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 src/cli/tui/components/__tests__/ConfirmReview.test.tsx create mode 100644 src/cli/tui/components/__tests__/FatalError.test.tsx create mode 100644 src/cli/tui/components/__tests__/Header.test.tsx create mode 100644 src/cli/tui/components/__tests__/HelpText.test.tsx create mode 100644 src/cli/tui/components/__tests__/ScreenHeader.test.tsx create mode 100644 src/cli/tui/components/__tests__/StepProgress.test.tsx create mode 100644 src/cli/tui/context/__tests__/LayoutContext.test.ts diff --git a/package-lock.json b/package-lock.json index ac31187b..83fb4555 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "@aws/agentcore", "version": "0.3.0-preview.1.0", "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { "@aws-cdk/toolkit-lib": "^1.13.0", "@aws-sdk/client-bedrock-agentcore": "^3.893.0", @@ -53,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", @@ -9454,6 +9456,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 1325c9da..24535b82 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,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/components/__tests__/ConfirmReview.test.tsx b/src/cli/tui/components/__tests__/ConfirmReview.test.tsx new file mode 100644 index 00000000..04126244 --- /dev/null +++ b/src/cli/tui/components/__tests__/ConfirmReview.test.tsx @@ -0,0 +1,50 @@ +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(); + + expect(lastFrame()).toContain('Review Configuration'); + expect(lastFrame()).toContain('Enter confirm'); + expect(lastFrame()).toContain('Esc back'); + }); + + it('renders custom title', () => { + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('Review Deploy'); + }); + + it('renders all fields', () => { + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('Name'); + expect(lastFrame()).toContain('my-agent'); + expect(lastFrame()).toContain('SDK'); + expect(lastFrame()).toContain('Strands'); + expect(lastFrame()).toContain('Language'); + expect(lastFrame()).toContain('Python'); + }); + + it('renders custom help text', () => { + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('Press Y to confirm'); + expect(lastFrame()).not.toContain('Enter confirm'); + }); +}); 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__/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__/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__/StepProgress.test.tsx b/src/cli/tui/components/__tests__/StepProgress.test.tsx new file mode 100644 index 00000000..9bd9cb76 --- /dev/null +++ b/src/cli/tui/components/__tests__/StepProgress.test.tsx @@ -0,0 +1,132 @@ +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 label for success steps', () => { + const steps: Step[] = [{ label: 'Build', status: 'success' }]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('[done]'); + expect(lastFrame()).toContain('Build'); + }); + + it('shows error label and message for error steps', () => { + const steps: Step[] = [{ label: 'Deploy', status: 'error', error: 'Stack creation failed' }]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('[error]'); + expect(lastFrame()).toContain('Deploy'); + expect(lastFrame()).toContain('Stack creation failed'); + }); + + it('shows warning label and message for warn steps', () => { + const steps: Step[] = [{ label: 'Validate', status: 'warn', warn: 'Deprecated config field' }]; + + const { lastFrame } = render(); + + expect(lastFrame()).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/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/vitest.config.ts b/vitest.config.ts index d5f08629..7ff5630f 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'], }, }, From f6c47321c7344f113d720f8405de9757c81be615 Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 16 Feb 2026 12:08:09 -0500 Subject: [PATCH 02/10] test: add tests for SelectList, MultiSelectList, WizardSelect, LogLink, exit-message --- src/cli/tui/__tests__/exit-message.test.ts | 30 +++++++ .../tui/components/__tests__/LogLink.test.tsx | 30 +++++++ .../__tests__/MultiSelectList.test.tsx | 55 ++++++++++++ .../components/__tests__/SelectList.test.tsx | 59 +++++++++++++ .../__tests__/WizardSelect.test.tsx | 87 +++++++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 src/cli/tui/__tests__/exit-message.test.ts create mode 100644 src/cli/tui/components/__tests__/LogLink.test.tsx create mode 100644 src/cli/tui/components/__tests__/MultiSelectList.test.tsx create mode 100644 src/cli/tui/components/__tests__/SelectList.test.tsx create mode 100644 src/cli/tui/components/__tests__/WizardSelect.test.tsx 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__/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__/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__/SelectList.test.tsx b/src/cli/tui/components/__tests__/SelectList.test.tsx new file mode 100644 index 00000000..8bcb92ef --- /dev/null +++ b/src/cli/tui/components/__tests__/SelectList.test.tsx @@ -0,0 +1,59 @@ +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 items', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Agent'); + expect(lastFrame()).toContain('Memory'); + expect(lastFrame()).toContain('Identity'); + }); + + it('shows cursor on selected item', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('❯'); + expect(lastFrame()).toContain('Memory'); + }); + + it('shows descriptions when provided', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Add an agent'); + expect(lastFrame()).toContain('Add memory'); + }); + + it('shows empty state when no items', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('No matches'); + expect(lastFrame()).toContain('No items available'); + }); + + it('shows custom empty message', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Nothing here'); + }); + + it('renders disabled items', () => { + const disabledItems = [ + { id: 'a', title: 'Available' }, + { id: 'b', title: 'Disabled', disabled: true }, + ]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Available'); + expect(lastFrame()).toContain('Disabled'); + }); +}); 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'); + }); +}); From 654aa66112a249d52d73264b00ee7dd0f00cd2ce Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 16 Feb 2026 15:28:03 -0500 Subject: [PATCH 03/10] test: add TUI component, guard, and hook tests with keyboard simulation --- .../__tests__/DeployStatus.test.tsx | 74 +++++ .../__tests__/PromptScreen.test.tsx | 271 ++++++++++++++++++ .../__tests__/ResourceGraph.test.tsx | 140 +++++++++ .../__tests__/ScrollableList.test.tsx | 115 ++++++++ .../__tests__/StepIndicator.test.tsx | 96 +++++++ src/cli/tui/guards/__tests__/project.test.tsx | 92 ++++++ .../hooks/__tests__/useListNavigation.test.ts | 70 ----- .../__tests__/useListNavigation.test.tsx | 236 +++++++++++++++ .../tui/hooks/__tests__/useTextInput.test.ts | 74 ----- .../tui/hooks/__tests__/useTextInput.test.tsx | 224 +++++++++++++++ 10 files changed, 1248 insertions(+), 144 deletions(-) create mode 100644 src/cli/tui/components/__tests__/DeployStatus.test.tsx create mode 100644 src/cli/tui/components/__tests__/PromptScreen.test.tsx create mode 100644 src/cli/tui/components/__tests__/ResourceGraph.test.tsx create mode 100644 src/cli/tui/components/__tests__/ScrollableList.test.tsx create mode 100644 src/cli/tui/components/__tests__/StepIndicator.test.tsx create mode 100644 src/cli/tui/guards/__tests__/project.test.tsx delete mode 100644 src/cli/tui/hooks/__tests__/useListNavigation.test.ts create mode 100644 src/cli/tui/hooks/__tests__/useListNavigation.test.tsx delete mode 100644 src/cli/tui/hooks/__tests__/useTextInput.test.ts create mode 100644 src/cli/tui/hooks/__tests__/useTextInput.test.tsx 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..ebf3041c --- /dev/null +++ b/src/cli/tui/components/__tests__/DeployStatus.test.tsx @@ -0,0 +1,74 @@ +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; +} + +describe('DeployStatus', () => { + it('renders deploying state with gradient text', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Deploying to AWS'); + }); + + it('renders success state when complete', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Deploy to AWS Complete'); + }); + + it('renders failure state when complete with error', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Deploy to AWS Failed'); + }); + + it('renders resource events during deployment', () => { + const messages = [ + makeMsg('MyStack | CREATE_IN_PROGRESS | AWS::Lambda::Function | MyFunc'), + makeMsg('MyStack | CREATE_COMPLETE | AWS::Lambda::Function | MyFunc'), + ]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Lambda::Function'); + expect(lastFrame()).toContain('CREATE_COMPLETE'); + }); + + it('renders progress bar when progress data exists', () => { + const messages = [makeMsg('deploying', 'CDK_TOOLKIT_I5502', { completed: 3, total: 10 })]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('3/10'); + }); + + it('skips CLEANUP messages', () => { + const messages = [ + makeMsg('MyStack | CREATE_COMPLETE | AWS::Lambda::Function | MyFunc'), + makeMsg('MyStack | CLEANUP_IN_PROGRESS | AWS::Lambda::Function | OldFunc'), + ]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Lambda::Function'); + expect(lastFrame()).toContain('CREATE_COMPLETE'); + }); + + it('ignores non-resource-event messages', () => { + const messages = [makeMsg('Some general info', 'CDK_TOOLKIT_I1234')]; + + const { lastFrame } = render(); + + // Should still show the deploying text but no resource lines + expect(lastFrame()).toContain('Deploying to AWS'); + }); +}); 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..712e1499 --- /dev/null +++ b/src/cli/tui/components/__tests__/PromptScreen.test.tsx @@ -0,0 +1,271 @@ +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', async () => { + const onConfirm = vi.fn(); + const { stdin } = render( + + msg + + ); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write(ENTER); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('calls onConfirm on y key', async () => { + const onConfirm = vi.fn(); + const { stdin } = render( + + msg + + ); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('y'); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('calls onExit on Escape key', async () => { + const onExit = vi.fn(); + const { stdin } = render( + + msg + + ); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write(ESCAPE); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onExit).toHaveBeenCalledTimes(1); + }); + + it('calls onExit on n key', async () => { + const onExit = vi.fn(); + const { stdin } = render( + + msg + + ); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('n'); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onExit).toHaveBeenCalledTimes(1); + }); + + it('calls onBack on b key', async () => { + const onBack = vi.fn(); + const { stdin } = render( + + msg + + ); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('b'); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it('ignores input when inputEnabled is false', async () => { + const onConfirm = vi.fn(); + const onExit = vi.fn(); + const { stdin } = render( + + msg + + ); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write(ENTER); + stdin.write(ESCAPE); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onConfirm).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + }); +}); + +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 confirm and exit help text when onConfirm provided', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('continue'); + expect(lastFrame()).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( + + ); + + expect(lastFrame()).toContain('deploy'); + expect(lastFrame()).toContain('cancel'); + }); +}); + +describe('ErrorPrompt', () => { + it('renders error message with cross mark', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('✗'); + expect(lastFrame()).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(); + + expect(lastFrame()).toContain('Enter/B to go back'); + expect(lastFrame()).toContain('Esc/Q to exit'); + }); + + it('calls onBack on Enter key', async () => { + const onBack = vi.fn(); + const { stdin } = render(); + + 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 b key', async () => { + const onBack = vi.fn(); + const { stdin } = render(); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('b'); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it('calls onExit on Escape key', 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('calls onExit on n key', async () => { + const onExit = vi.fn(); + const { stdin } = render(); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('n'); + await new Promise(resolve => setTimeout(resolve, 50)); + + 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(); + + expect(lastFrame()).toContain('Enter/Y confirm'); + expect(lastFrame()).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 (no showInput)', async () => { + const onConfirm = vi.fn(); + const { stdin } = render(); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write(ENTER); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('calls onCancel on Escape key', async () => { + const onCancel = vi.fn(); + const { stdin } = render(); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write(ESCAPE); + await new Promise(resolve => setTimeout(resolve, 50)); + + 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__/ScrollableList.test.tsx b/src/cli/tui/components/__tests__/ScrollableList.test.tsx new file mode 100644 index 00000000..8db9dce0 --- /dev/null +++ b/src/cli/tui/components/__tests__/ScrollableList.test.tsx @@ -0,0 +1,115 @@ +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' }, +]; + +describe('ScrollableList', () => { + it('renders visible items within height', () => { + const { lastFrame } = render(); + + // Auto-scrolls to bottom, so last 3 items visible + expect(lastFrame()).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 when items exceed height', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('of 5'); + expect(lastFrame()).toContain('↑↓'); + }); + + it('does not show scroll indicator when all items fit', () => { + const { lastFrame } = render(); + + expect(lastFrame()).not.toContain('↑↓'); + }); + + it('renders timestamps and messages', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('[12:00]'); + expect(lastFrame()).toContain('Starting deploy'); + expect(lastFrame()).toContain('[12:01]'); + expect(lastFrame()).toContain('Creating stack'); + }); + + it('renders empty list without scroll indicator', () => { + const { lastFrame } = render(); + + expect(lastFrame()).not.toContain('↑↓'); + expect(lastFrame()).not.toContain('of'); + }); + + it('scrolls up with arrow key to reveal earlier items', async () => { + const { lastFrame, stdin } = render(); + + // Initially auto-scrolled to bottom — last items visible + expect(lastFrame()).toContain('Deploy complete'); + expect(lastFrame()).not.toContain('Starting deploy'); + + // Scroll up twice to reveal first item + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write(UP_ARROW); + stdin.write(UP_ARROW); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(lastFrame()).toContain('Starting deploy'); + }); + + it('scrolls down after scrolling up', async () => { + const { lastFrame, stdin } = render(); + + await new Promise(resolve => setTimeout(resolve, 50)); + // Scroll up to top + stdin.write(UP_ARROW); + stdin.write(UP_ARROW); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(lastFrame()).toContain('Starting deploy'); + + // Scroll back down + stdin.write(DOWN_ARROW); + stdin.write(DOWN_ARROW); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(lastFrame()).toContain('Deploy complete'); + }); + + it('updates scroll position indicator when scrolling', async () => { + const { lastFrame, stdin } = render(); + + // Initially at bottom: items 3-5 of 5 + expect(lastFrame()).toContain('3-5 of 5'); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write(UP_ARROW); + stdin.write(UP_ARROW); + await new Promise(resolve => setTimeout(resolve, 50)); + + // After scrolling up: items 1-3 of 5 + expect(lastFrame()).toContain('1-3 of 5'); + }); +}); 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/guards/__tests__/project.test.tsx b/src/cli/tui/guards/__tests__/project.test.tsx new file mode 100644 index 00000000..bf9d7082 --- /dev/null +++ b/src/cli/tui/guards/__tests__/project.test.tsx @@ -0,0 +1,92 @@ +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 } = vi.hoisted(() => ({ + mockFindConfigRoot: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + findConfigRoot: mockFindConfigRoot, + getWorkingDirectory: () => '/project', + NoProjectError: class extends Error { + constructor() { + super('No agentcore project found'); + } + }, +})); + +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'); + }); +}); + +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 with agentcore create for CLI mode', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('No agentcore project found'); + expect(lastFrame()).toContain('agentcore create'); + }); + + it('renders with create for TUI mode', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('No agentcore project found'); + expect(lastFrame()).toContain('create'); + }); +}); + +describe('WrongDirectoryMessage', () => { + it('renders project root path', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('project root directory'); + expect(lastFrame()).toContain('/home/user/my-project'); + expect(lastFrame()).toContain('cd /home/user/my-project'); + }); +}); 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__/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..b5744710 --- /dev/null +++ b/src/cli/tui/hooks/__tests__/useTextInput.test.tsx @@ -0,0 +1,224 @@ +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'; + +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, +}: { + initialValue?: string; + onSubmit?: (value: string) => void; + onCancel?: () => void; + onChange?: (value: string) => void; +}) { + const { value, cursor } = useTextInput({ initialValue, onSubmit, onCancel, onChange }); + return ( + + val:[{value}] cur:{cursor} + + ); +} + +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 new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('a'); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(lastFrame()).toContain('val:[a]'); + expect(lastFrame()).toContain('cur:1'); + }); + + it('accepts multiple characters', async () => { + const { lastFrame, stdin } = render(); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('h'); + stdin.write('i'); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(lastFrame()).toContain('val:[hi]'); + expect(lastFrame()).toContain('cur:2'); + }); + + it('handles backspace', async () => { + const { lastFrame, stdin } = render(); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write(BACKSPACE); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(lastFrame()).toContain('val:[ab]'); + expect(lastFrame()).toContain('cur:2'); + }); + + it('calls onSubmit on Enter', async () => { + const onSubmit = vi.fn(); + const { stdin } = render(); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write(ENTER); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onSubmit).toHaveBeenCalledWith('test'); + }); + + it('calls onCancel on Escape', async () => { + const onCancel = vi.fn(); + const { stdin } = render(); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write(ESCAPE); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('moves cursor left with arrow key', async () => { + const { lastFrame, stdin } = render(); + + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('\x1B[D'); // left arrow + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(lastFrame()).toContain('val:[abc]'); + expect(lastFrame()).toContain('cur:2'); + }); + + it('moves cursor right with arrow key', async () => { + const { lastFrame, stdin } = render(); + + // Move left first, then right + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('\x1B[D'); // left + stdin.write('\x1B[D'); // left + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(lastFrame()).toContain('cur:1'); + + stdin.write('\x1B[C'); // right arrow + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(lastFrame()).toContain('cur:2'); + }); + + it('inserts character at cursor position', async () => { + const { lastFrame, stdin } = render(); + + // Move cursor left once (between a and c), then insert b + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('\x1B[D'); // left, cursor now at 1 + await new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('b'); + await new Promise(resolve => setTimeout(resolve, 50)); + + 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 new Promise(resolve => setTimeout(resolve, 50)); + stdin.write('x'); + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(onChange).toHaveBeenCalledWith('x'); + }); +}); From efc347564327c781609a2ec8e3905ff774805247 Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 16 Feb 2026 17:30:47 -0500 Subject: [PATCH 04/10] test: add TUI component tests for Panel, TwoColumn, LogPanel, Cursor, NextSteps, AwsTargetConfigUI --- .../__tests__/AwsTargetConfigUI.test.tsx | 35 +++ .../tui/components/__tests__/Cursor.test.tsx | 40 ++++ .../components/__tests__/LogPanel.test.tsx | 205 ++++++++++++++++++ .../components/__tests__/NextSteps.test.tsx | 106 +++++++++ .../tui/components/__tests__/Panel.test.tsx | 115 ++++++++++ .../components/__tests__/TwoColumn.test.tsx | 94 ++++++++ 6 files changed, 595 insertions(+) create mode 100644 src/cli/tui/components/__tests__/AwsTargetConfigUI.test.tsx create mode 100644 src/cli/tui/components/__tests__/Cursor.test.tsx create mode 100644 src/cli/tui/components/__tests__/LogPanel.test.tsx create mode 100644 src/cli/tui/components/__tests__/NextSteps.test.tsx create mode 100644 src/cli/tui/components/__tests__/Panel.test.tsx create mode 100644 src/cli/tui/components/__tests__/TwoColumn.test.tsx 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__/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__/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__/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__/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'); + }); +}); From 5c071ae92379fe2b7128f147486819862d703ac5 Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 17 Feb 2026 12:56:11 -0500 Subject: [PATCH 05/10] test: improve unit test coverage for lib modules --- .../errors/__tests__/config-extended.test.ts | 68 ----- src/lib/errors/__tests__/config.test.ts | 242 +++++++++++------- src/lib/packaging/__tests__/helpers.test.ts | 34 +++ .../io/__tests__/config-io-extended.test.ts | 172 ------------- .../schemas/io/__tests__/config-io.test.ts | 216 ++++++++++++++-- src/lib/utils/__tests__/platform.test.ts | 71 ++++- 6 files changed, 432 insertions(+), 371 deletions(-) delete mode 100644 src/lib/errors/__tests__/config-extended.test.ts delete mode 100644 src/lib/schemas/io/__tests__/config-io-extended.test.ts 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'); + }); }); From 00541fb5f1e628c4772590dfe6b4460b0fad4c06 Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 17 Feb 2026 18:06:18 -0500 Subject: [PATCH 06/10] test: strengthen TUI component test assertions and fix flaky patterns --- .../__tests__/ConfirmReview.test.tsx | 61 ++++-- .../__tests__/DeployStatus.test.tsx | 180 ++++++++++++++---- .../__tests__/PromptScreen.test.tsx | 109 ++++++----- .../__tests__/ScrollableList.test.tsx | 106 +++++++---- .../components/__tests__/SelectList.test.tsx | 75 ++++++-- .../__tests__/StepProgress.test.tsx | 21 +- 6 files changed, 398 insertions(+), 154 deletions(-) diff --git a/src/cli/tui/components/__tests__/ConfirmReview.test.tsx b/src/cli/tui/components/__tests__/ConfirmReview.test.tsx index 04126244..f9c14200 100644 --- a/src/cli/tui/components/__tests__/ConfirmReview.test.tsx +++ b/src/cli/tui/components/__tests__/ConfirmReview.test.tsx @@ -6,10 +6,11 @@ import { describe, expect, it } from 'vitest'; describe('ConfirmReview', () => { it('renders default title and help text', () => { const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('Review Configuration'); - expect(lastFrame()).toContain('Enter confirm'); - expect(lastFrame()).toContain('Esc back'); + expect(frame).toContain('Review Configuration'); + expect(frame).toContain('Enter confirm'); + expect(frame).toContain('Esc back'); }); it('renders custom title', () => { @@ -18,9 +19,10 @@ describe('ConfirmReview', () => { ); expect(lastFrame()).toContain('Review Deploy'); + expect(lastFrame()).not.toContain('Review Configuration'); }); - it('renders all fields', () => { + it('renders each field as label: value on the same line', () => { const { lastFrame } = render( { ]} /> ); + const lines = lastFrame()!.split('\n'); - expect(lastFrame()).toContain('Name'); - expect(lastFrame()).toContain('my-agent'); - expect(lastFrame()).toContain('SDK'); - expect(lastFrame()).toContain('Strands'); - expect(lastFrame()).toContain('Language'); - expect(lastFrame()).toContain('Python'); + // 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', () => { + it('renders custom help text replacing default', () => { const { lastFrame } = render( ); @@ -47,4 +61,29 @@ describe('ConfirmReview', () => { 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__/DeployStatus.test.tsx b/src/cli/tui/components/__tests__/DeployStatus.test.tsx index ebf3041c..f13ad796 100644 --- a/src/cli/tui/components/__tests__/DeployStatus.test.tsx +++ b/src/cli/tui/components/__tests__/DeployStatus.test.tsx @@ -9,66 +9,168 @@ function makeMsg( code = 'CDK_TOOLKIT_I5502', progress?: { completed: number; total: number } ): DeployMessage { - return { message, code, level: 'info', time: new Date(), timestamp: new Date(), progress } as 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', () => { - it('renders deploying state with gradient text', () => { - const { lastFrame } = render(); + describe('header state', () => { + it('shows "Deploying to AWS" when not complete', () => { + const { lastFrame } = render(); - expect(lastFrame()).toContain('Deploying to AWS'); - }); + expect(lastFrame()).toContain('Deploying to AWS'); + }); - it('renders success state when complete', () => { - const { lastFrame } = render(); + it('shows success message when complete without error', () => { + const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('Deploy to AWS Complete'); - }); + expect(frame).toContain('✓'); + expect(frame).toContain('Deploy to AWS Complete'); + }); - it('renders failure state when complete with error', () => { - const { lastFrame } = render(); + it('shows failure message when complete with error', () => { + const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('Deploy to AWS Failed'); + expect(frame).toContain('✗'); + expect(frame).toContain('Deploy to AWS Failed'); + }); }); - it('renders resource events during deployment', () => { - const messages = [ - makeMsg('MyStack | CREATE_IN_PROGRESS | AWS::Lambda::Function | MyFunc'), - makeMsg('MyStack | CREATE_COMPLETE | AWS::Lambda::Function | MyFunc'), - ]; + 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 { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('Lambda::Function'); - expect(lastFrame()).toContain('CREATE_COMPLETE'); - }); + expect(frame).toContain('Lambda::Function'); + expect(frame).toContain('CREATE_COMPLETE'); + }); - it('renders progress bar when progress data exists', () => { - const messages = [makeMsg('deploying', 'CDK_TOOLKIT_I5502', { completed: 3, total: 10 })]; + it('strips AWS:: prefix from resource types', () => { + const messages = [makeResourceMsg('S3::Bucket', 'CREATE_COMPLETE')]; - const { lastFrame } = render(); + const { lastFrame } = render(); - expect(lastFrame()).toContain('3/10'); - }); + 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('skips CLEANUP messages', () => { - const messages = [ - makeMsg('MyStack | CREATE_COMPLETE | AWS::Lambda::Function | MyFunc'), - makeMsg('MyStack | CLEANUP_IN_PROGRESS | AWS::Lambda::Function | OldFunc'), - ]; + it('shows only last 8 resource events', () => { + const messages = Array.from({ length: 12 }, (_, i) => + makeResourceMsg(`Service::Resource${i}`, 'CREATE_COMPLETE') + ); - const { lastFrame } = render(); + const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('Lambda::Function'); - expect(lastFrame()).toContain('CREATE_COMPLETE'); + // 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'); + }); }); - it('ignores non-resource-event messages', () => { - const messages = [makeMsg('Some general info', 'CDK_TOOLKIT_I1234')]; + 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 { 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'); + }); + }); - // Should still show the deploying text but no resource lines - expect(lastFrame()).toContain('Deploying to AWS'); + 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__/PromptScreen.test.tsx b/src/cli/tui/components/__tests__/PromptScreen.test.tsx index 712e1499..ada95f2c 100644 --- a/src/cli/tui/components/__tests__/PromptScreen.test.tsx +++ b/src/cli/tui/components/__tests__/PromptScreen.test.tsx @@ -23,7 +23,7 @@ describe('PromptScreen', () => { expect(lastFrame()).toContain('Press Enter'); }); - it('calls onConfirm on Enter key', async () => { + it('calls onConfirm on Enter key', () => { const onConfirm = vi.fn(); const { stdin } = render( @@ -31,14 +31,12 @@ describe('PromptScreen', () => { ); - await new Promise(resolve => setTimeout(resolve, 50)); stdin.write(ENTER); - await new Promise(resolve => setTimeout(resolve, 50)); expect(onConfirm).toHaveBeenCalledTimes(1); }); - it('calls onConfirm on y key', async () => { + it('calls onConfirm on y key', () => { const onConfirm = vi.fn(); const { stdin } = render( @@ -46,14 +44,12 @@ describe('PromptScreen', () => { ); - await new Promise(resolve => setTimeout(resolve, 50)); stdin.write('y'); - await new Promise(resolve => setTimeout(resolve, 50)); expect(onConfirm).toHaveBeenCalledTimes(1); }); - it('calls onExit on Escape key', async () => { + it('calls onExit on Escape key', () => { const onExit = vi.fn(); const { stdin } = render( @@ -61,14 +57,12 @@ describe('PromptScreen', () => { ); - await new Promise(resolve => setTimeout(resolve, 50)); stdin.write(ESCAPE); - await new Promise(resolve => setTimeout(resolve, 50)); expect(onExit).toHaveBeenCalledTimes(1); }); - it('calls onExit on n key', async () => { + it('calls onExit on n key', () => { const onExit = vi.fn(); const { stdin } = render( @@ -76,14 +70,12 @@ describe('PromptScreen', () => { ); - await new Promise(resolve => setTimeout(resolve, 50)); stdin.write('n'); - await new Promise(resolve => setTimeout(resolve, 50)); expect(onExit).toHaveBeenCalledTimes(1); }); - it('calls onBack on b key', async () => { + it('calls onBack on b key', () => { const onBack = vi.fn(); const { stdin } = render( @@ -91,14 +83,12 @@ describe('PromptScreen', () => { ); - await new Promise(resolve => setTimeout(resolve, 50)); stdin.write('b'); - await new Promise(resolve => setTimeout(resolve, 50)); expect(onBack).toHaveBeenCalledTimes(1); }); - it('ignores input when inputEnabled is false', async () => { + it('ignores input when inputEnabled is false', () => { const onConfirm = vi.fn(); const onExit = vi.fn(); const { stdin } = render( @@ -107,14 +97,28 @@ describe('PromptScreen', () => { ); - await new Promise(resolve => setTimeout(resolve, 50)); stdin.write(ENTER); stdin.write(ESCAPE); - await new Promise(resolve => setTimeout(resolve, 50)); + 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', () => { @@ -130,11 +134,12 @@ describe('SuccessPrompt', () => { expect(lastFrame()).toContain('3 agents deployed'); }); - it('shows confirm and exit help text when onConfirm provided', () => { + it('shows continue/exit help text when onConfirm provided', () => { const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('continue'); - expect(lastFrame()).toContain('exit'); + expect(frame).toContain('continue'); + expect(frame).toContain('exit'); }); it('shows any key help text when no onConfirm', () => { @@ -147,18 +152,20 @@ describe('SuccessPrompt', () => { const { lastFrame } = render( ); + const frame = lastFrame()!.toLowerCase(); - expect(lastFrame()).toContain('deploy'); - expect(lastFrame()).toContain('cancel'); + expect(frame).toContain('deploy'); + expect(frame).toContain('cancel'); }); }); describe('ErrorPrompt', () => { it('renders error message with cross mark', () => { const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('✗'); - expect(lastFrame()).toContain('Something failed'); + expect(frame).toContain('✗'); + expect(frame).toContain('Something failed'); }); it('renders detail text when provided', () => { @@ -169,51 +176,44 @@ describe('ErrorPrompt', () => { it('shows back and exit help text', () => { const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('Enter/B to go back'); - expect(lastFrame()).toContain('Esc/Q to exit'); + expect(frame).toContain('Enter/B to go back'); + expect(frame).toContain('Esc/Q to exit'); }); - it('calls onBack on Enter key', async () => { + it('calls onBack on Enter key', () => { const onBack = vi.fn(); const { stdin } = render(); - 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 b key', async () => { + it('calls onBack on b key', () => { const onBack = vi.fn(); const { stdin } = render(); - await new Promise(resolve => setTimeout(resolve, 50)); stdin.write('b'); - await new Promise(resolve => setTimeout(resolve, 50)); expect(onBack).toHaveBeenCalledTimes(1); }); - it('calls onExit on Escape key', async () => { + it('calls onExit on Escape key', () => { 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('calls onExit on n key', async () => { + it('calls onExit on n key', () => { const onExit = vi.fn(); const { stdin } = render(); - await new Promise(resolve => setTimeout(resolve, 50)); stdin.write('n'); - await new Promise(resolve => setTimeout(resolve, 50)); expect(onExit).toHaveBeenCalledTimes(1); }); @@ -236,9 +236,10 @@ describe('ConfirmPrompt', () => { it('shows keyboard help when showInput is false', () => { const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('Enter/Y confirm'); - expect(lastFrame()).toContain('Esc/N cancel'); + expect(frame).toContain('Enter/Y confirm'); + expect(frame).toContain('Esc/N cancel'); }); it('shows input help when showInput is true', () => { @@ -247,24 +248,38 @@ describe('ConfirmPrompt', () => { expect(lastFrame()).toContain('Type y/n'); }); - it('calls onConfirm on Enter key (no showInput)', async () => { + it('calls onConfirm on Enter key', () => { const onConfirm = vi.fn(); const { stdin } = render(); - await new Promise(resolve => setTimeout(resolve, 50)); stdin.write(ENTER); - await new Promise(resolve => setTimeout(resolve, 50)); expect(onConfirm).toHaveBeenCalledTimes(1); }); - it('calls onCancel on Escape key', async () => { + it('calls onCancel on Escape key', () => { const onCancel = vi.fn(); const { stdin } = render(); - await new Promise(resolve => setTimeout(resolve, 50)); stdin.write(ESCAPE); - await new Promise(resolve => setTimeout(resolve, 50)); + + 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__/ScrollableList.test.tsx b/src/cli/tui/components/__tests__/ScrollableList.test.tsx index 8db9dce0..48cb722c 100644 --- a/src/cli/tui/components/__tests__/ScrollableList.test.tsx +++ b/src/cli/tui/components/__tests__/ScrollableList.test.tsx @@ -14,12 +14,33 @@ const items = [ { timestamp: '12:04', message: 'Deploy complete' }, ]; +function delay(ms = 50) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + describe('ScrollableList', () => { - it('renders visible items within height', () => { + 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'); + }); - // Auto-scrolls to bottom, so last 3 items visible - expect(lastFrame()).toContain('Deploy complete'); + 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', () => { @@ -34,82 +55,103 @@ describe('ScrollableList', () => { expect(lastFrame()).not.toContain('Deployment Log'); }); - it('shows scroll indicator when items exceed height', () => { + it('shows scroll indicator with position when items exceed height', () => { const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('of 5'); - expect(lastFrame()).toContain('↑↓'); + // 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('renders timestamps and messages', () => { + it('formats items as [timestamp] message', () => { const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('[12:00]'); - expect(lastFrame()).toContain('Starting deploy'); - expect(lastFrame()).toContain('[12:01]'); - expect(lastFrame()).toContain('Creating stack'); + 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(lastFrame()).not.toContain('↑↓'); - expect(lastFrame()).not.toContain('of'); + expect(frame).not.toContain('↑↓'); + expect(frame).not.toContain('of'); }); - it('scrolls up with arrow key to reveal earlier items', async () => { + it('scrolls up to reveal earlier items', async () => { const { lastFrame, stdin } = render(); - // Initially auto-scrolled to bottom — last items visible - expect(lastFrame()).toContain('Deploy complete'); + // Initially auto-scrolled to bottom expect(lastFrame()).not.toContain('Starting deploy'); + expect(lastFrame()).toContain('Deploy complete'); - // Scroll up twice to reveal first item - await new Promise(resolve => setTimeout(resolve, 50)); + // Scroll up twice + await delay(); stdin.write(UP_ARROW); stdin.write(UP_ARROW); - await new Promise(resolve => setTimeout(resolve, 50)); + 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 new Promise(resolve => setTimeout(resolve, 50)); + await delay(); // Scroll up to top stdin.write(UP_ARROW); stdin.write(UP_ARROW); - await new Promise(resolve => setTimeout(resolve, 50)); - + await delay(); expect(lastFrame()).toContain('Starting deploy'); // Scroll back down stdin.write(DOWN_ARROW); stdin.write(DOWN_ARROW); - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); expect(lastFrame()).toContain('Deploy complete'); + expect(lastFrame()).toContain('3-5 of 5'); }); - it('updates scroll position indicator when scrolling', async () => { + it('does not scroll above first item', async () => { const { lastFrame, stdin } = render(); - // Initially at bottom: items 3-5 of 5 - expect(lastFrame()).toContain('3-5 of 5'); + await delay(); + // Scroll up many times past the top + for (let i = 0; i < 10; i++) { + stdin.write(UP_ARROW); + } + await delay(); - await new Promise(resolve => setTimeout(resolve, 50)); - stdin.write(UP_ARROW); - stdin.write(UP_ARROW); - await new Promise(resolve => setTimeout(resolve, 50)); - - // After scrolling up: items 1-3 of 5 + // 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__/SelectList.test.tsx b/src/cli/tui/components/__tests__/SelectList.test.tsx index 8bcb92ef..b94afbab 100644 --- a/src/cli/tui/components/__tests__/SelectList.test.tsx +++ b/src/cli/tui/components/__tests__/SelectList.test.tsx @@ -10,50 +10,91 @@ describe('SelectList', () => { { id: 'c', title: 'Identity' }, ]; - it('renders all items', () => { + it('renders all item titles', () => { const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('Agent'); - expect(lastFrame()).toContain('Memory'); - expect(lastFrame()).toContain('Identity'); + expect(frame).toContain('Agent'); + expect(frame).toContain('Memory'); + expect(frame).toContain('Identity'); }); - it('shows cursor on selected item', () => { + it('shows cursor only on the selected item line', () => { const { lastFrame } = render(); + const lines = lastFrame()!.split('\n'); - expect(lastFrame()).toContain('❯'); - expect(lastFrame()).toContain('Memory'); + 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 when provided', () => { + it('shows descriptions inline with items', () => { const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('Add an agent'); - expect(lastFrame()).toContain('Add memory'); + 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 when no items', () => { + it('shows empty state with default message when no items', () => { const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('No matches'); - expect(lastFrame()).toContain('No items available'); + 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', () => { + it('renders disabled items without cursor styling', () => { const disabledItems = [ { id: 'a', title: 'Available' }, { id: 'b', title: 'Disabled', disabled: true }, ]; - const { lastFrame } = render(); + // 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(lastFrame()).toContain('Available'); - expect(lastFrame()).toContain('Disabled'); + expect(cursorCount).toBe(1); }); }); diff --git a/src/cli/tui/components/__tests__/StepProgress.test.tsx b/src/cli/tui/components/__tests__/StepProgress.test.tsx index 9bd9cb76..31a455ff 100644 --- a/src/cli/tui/components/__tests__/StepProgress.test.tsx +++ b/src/cli/tui/components/__tests__/StepProgress.test.tsx @@ -80,31 +80,36 @@ describe('StepProgress', () => { expect(lastFrame()).toContain('Deploying stack'); }); - it('shows done label for success steps', () => { + 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(lastFrame()).toContain('[done]'); - expect(lastFrame()).toContain('Build'); + expect(buildLine).toContain('[done]'); }); - it('shows error label and message for error steps', () => { + 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(lastFrame()).toContain('[error]'); - expect(lastFrame()).toContain('Deploy'); + expect(deployLine).toContain('[error]'); + // Error message should appear in the output expect(lastFrame()).toContain('Stack creation failed'); }); - it('shows warning label and message for warn steps', () => { + 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(lastFrame()).toContain('[warning]'); + expect(validateLine).toContain('[warning]'); expect(lastFrame()).toContain('Deprecated config field'); }); From c593579cb6ac2662f733f7e931832f948572054c Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 17 Feb 2026 18:12:54 -0500 Subject: [PATCH 07/10] test: add useTextInput shortcut tests, fix guards mock, strengthen StepProgress --- src/cli/tui/guards/__tests__/project.test.tsx | 49 +++-- .../tui/hooks/__tests__/useTextInput.test.tsx | 207 +++++++++++++++--- 2 files changed, 209 insertions(+), 47 deletions(-) diff --git a/src/cli/tui/guards/__tests__/project.test.tsx b/src/cli/tui/guards/__tests__/project.test.tsx index bf9d7082..d205302c 100644 --- a/src/cli/tui/guards/__tests__/project.test.tsx +++ b/src/cli/tui/guards/__tests__/project.test.tsx @@ -3,16 +3,18 @@ import { render } from 'ink-testing-library'; import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -const { mockFindConfigRoot } = vi.hoisted(() => ({ +const { mockFindConfigRoot, mockGetWorkingDirectory } = vi.hoisted(() => ({ mockFindConfigRoot: vi.fn(), + mockGetWorkingDirectory: vi.fn(() => '/project'), })); vi.mock('../../../../lib/index.js', () => ({ findConfigRoot: mockFindConfigRoot, - getWorkingDirectory: () => '/project', - NoProjectError: class extends Error { - constructor() { - super('No agentcore project found'); + getWorkingDirectory: mockGetWorkingDirectory, + NoProjectError: class NoProjectError extends Error { + constructor(message = 'No agentcore project found') { + super(message); + this.name = 'NoProjectError'; } }, })); @@ -39,6 +41,14 @@ describe('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', () => { @@ -66,27 +76,34 @@ describe('getProjectRootMismatch', () => { }); describe('MissingProjectMessage', () => { - it('renders with agentcore create for CLI mode', () => { + it('renders error message and "agentcore create" for CLI mode', () => { const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('No agentcore project found'); - expect(lastFrame()).toContain('agentcore create'); + expect(frame).toContain('No agentcore project found'); + expect(frame).toContain('agentcore create'); }); - it('renders with create for TUI mode', () => { + it('renders "create" without "agentcore" prefix for TUI mode', () => { const { lastFrame } = render(); - - expect(lastFrame()).toContain('No agentcore project found'); - expect(lastFrame()).toContain('create'); + 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', () => { + it('renders project root path with cd suggestion', () => { const { lastFrame } = render(); + const frame = lastFrame()!; - expect(lastFrame()).toContain('project root directory'); - expect(lastFrame()).toContain('/home/user/my-project'); - expect(lastFrame()).toContain('cd /home/user/my-project'); + 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__/useTextInput.test.tsx b/src/cli/tui/hooks/__tests__/useTextInput.test.tsx index b5744710..b634ac1c 100644 --- a/src/cli/tui/hooks/__tests__/useTextInput.test.tsx +++ b/src/cli/tui/hooks/__tests__/useTextInput.test.tsx @@ -7,6 +7,8 @@ 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()); @@ -84,13 +86,27 @@ function TextInputHarness({ 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 }); + const { value, cursor } = useTextInput({ + initialValue, + onSubmit, + onCancel, + onChange, + onUpArrow, + onDownArrow, + isActive, + }); return ( val:[{value}] cur:{cursor} @@ -98,6 +114,10 @@ function TextInputHarness({ ); } +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(); @@ -116,9 +136,9 @@ describe('useTextInput hook', () => { it('accepts character input', async () => { const { lastFrame, stdin } = render(); - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); stdin.write('a'); - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); expect(lastFrame()).toContain('val:[a]'); expect(lastFrame()).toContain('cur:1'); @@ -127,10 +147,10 @@ describe('useTextInput hook', () => { it('accepts multiple characters', async () => { const { lastFrame, stdin } = render(); - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); stdin.write('h'); stdin.write('i'); - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); expect(lastFrame()).toContain('val:[hi]'); expect(lastFrame()).toContain('cur:2'); @@ -139,21 +159,32 @@ describe('useTextInput hook', () => { it('handles backspace', async () => { const { lastFrame, stdin } = render(); - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); stdin.write(BACKSPACE); - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); expect(lastFrame()).toContain('val:[ab]'); expect(lastFrame()).toContain('cur:2'); }); - it('calls onSubmit on Enter', async () => { + 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 new Promise(resolve => setTimeout(resolve, 50)); + await delay(); stdin.write(ENTER); - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); expect(onSubmit).toHaveBeenCalledWith('test'); }); @@ -162,9 +193,9 @@ describe('useTextInput hook', () => { const onCancel = vi.fn(); const { stdin } = render(); - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); stdin.write(ESCAPE); - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); expect(onCancel).toHaveBeenCalledTimes(1); }); @@ -172,9 +203,9 @@ describe('useTextInput hook', () => { it('moves cursor left with arrow key', async () => { const { lastFrame, stdin } = render(); - await new Promise(resolve => setTimeout(resolve, 50)); - stdin.write('\x1B[D'); // left arrow - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); + stdin.write(LEFT); + await delay(); expect(lastFrame()).toContain('val:[abc]'); expect(lastFrame()).toContain('cur:2'); @@ -183,29 +214,47 @@ describe('useTextInput hook', () => { it('moves cursor right with arrow key', async () => { const { lastFrame, stdin } = render(); - // Move left first, then right - await new Promise(resolve => setTimeout(resolve, 50)); - stdin.write('\x1B[D'); // left - stdin.write('\x1B[D'); // left - await new Promise(resolve => setTimeout(resolve, 50)); - + await delay(); + stdin.write(LEFT); + stdin.write(LEFT); + await delay(); expect(lastFrame()).toContain('cur:1'); - stdin.write('\x1B[C'); // right arrow - await new Promise(resolve => setTimeout(resolve, 50)); + 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', async () => { + it('inserts character at cursor position (middle of text)', async () => { const { lastFrame, stdin } = render(); - // Move cursor left once (between a and c), then insert b - await new Promise(resolve => setTimeout(resolve, 50)); - stdin.write('\x1B[D'); // left, cursor now at 1 - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); + stdin.write(LEFT); // cursor at 1 + await delay(); stdin.write('b'); - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); expect(lastFrame()).toContain('val:[abc]'); expect(lastFrame()).toContain('cur:2'); @@ -215,10 +264,106 @@ describe('useTextInput hook', () => { const onChange = vi.fn(); const { stdin } = render(); - await new Promise(resolve => setTimeout(resolve, 50)); + await delay(); stdin.write('x'); - await new Promise(resolve => setTimeout(resolve, 100)); + 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'); + }); }); From 5b3ccdff67a5146fd31437d412153f0b4d9568a6 Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 17 Feb 2026 18:38:33 -0500 Subject: [PATCH 08/10] test: add TUI tests for TextInput, SecretInput, ScrollableText, useExitHandler, useRemove --- .../__tests__/ScrollableText.test.tsx | 152 ++++++++ .../components/__tests__/SecretInput.test.tsx | 324 ++++++++++++++++++ .../components/__tests__/TextInput.test.tsx | 240 +++++++++++++ .../hooks/__tests__/useExitHandler.test.tsx | 64 ++++ .../tui/hooks/__tests__/useRemove.test.tsx | 208 +++++++++++ 5 files changed, 988 insertions(+) create mode 100644 src/cli/tui/components/__tests__/ScrollableText.test.tsx create mode 100644 src/cli/tui/components/__tests__/SecretInput.test.tsx create mode 100644 src/cli/tui/components/__tests__/TextInput.test.tsx create mode 100644 src/cli/tui/hooks/__tests__/useExitHandler.test.tsx create mode 100644 src/cli/tui/hooks/__tests__/useRemove.test.tsx 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..5df96c72 --- /dev/null +++ b/src/cli/tui/components/__tests__/ScrollableText.test.tsx @@ -0,0 +1,152 @@ +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..f6ad96a5 --- /dev/null +++ b/src/cli/tui/components/__tests__/SecretInput.test.tsx @@ -0,0 +1,324 @@ +import { ApiKeySecretInput, SecretInput } from '../SecretInput.js'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { z } from 'zod'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +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__/TextInput.test.tsx b/src/cli/tui/components/__tests__/TextInput.test.tsx new file mode 100644 index 00000000..1c1384e7 --- /dev/null +++ b/src/cli/tui/components/__tests__/TextInput.test.tsx @@ -0,0 +1,240 @@ +import { TextInput } from '../TextInput.js'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { z } from 'zod'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +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/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__/useRemove.test.tsx b/src/cli/tui/hooks/__tests__/useRemove.test.tsx new file mode 100644 index 00000000..5ba8aeba --- /dev/null +++ b/src/cli/tui/hooks/__tests__/useRemove.test.tsx @@ -0,0 +1,208 @@ +import { + useRemovableAgents, + useRemovableGateways, + useRemovableMemories, + useRemovableIdentities, + 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'); + }); +}); From 2652c184f1c2f91a2a390135fe781dba82151534 Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 17 Feb 2026 18:43:17 -0500 Subject: [PATCH 09/10] test: add TUI tests for Screen, FullScreenLogView, CredentialSourcePrompt --- .../__tests__/CredentialSourcePrompt.test.tsx | 85 ++++++++++ .../__tests__/FullScreenLogView.test.tsx | 160 ++++++++++++++++++ .../tui/components/__tests__/Screen.test.tsx | 111 ++++++++++++ 3 files changed, 356 insertions(+) create mode 100644 src/cli/tui/components/__tests__/CredentialSourcePrompt.test.tsx create mode 100644 src/cli/tui/components/__tests__/FullScreenLogView.test.tsx create mode 100644 src/cli/tui/components/__tests__/Screen.test.tsx 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..f6e28fe0 --- /dev/null +++ b/src/cli/tui/components/__tests__/CredentialSourcePrompt.test.tsx @@ -0,0 +1,85 @@ +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__/FullScreenLogView.test.tsx b/src/cli/tui/components/__tests__/FullScreenLogView.test.tsx new file mode 100644 index 00000000..d1bfc5d9 --- /dev/null +++ b/src/cli/tui/components/__tests__/FullScreenLogView.test.tsx @@ -0,0 +1,160 @@ +import type { LogEntry } from '../LogPanel.js'; +import { FullScreenLogView } from '../FullScreenLogView.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}`, + timestamp: new Date(2024, 0, 1, 0, 0, i), + })); +} + +describe('FullScreenLogView', () => { + it('renders log entries', () => { + const logs: LogEntry[] = [ + { level: 'info', message: 'Starting deploy', timestamp: new Date() }, + { level: 'error', message: 'Deploy failed', timestamp: new Date() }, + ]; + 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', timestamp: new Date() }]; + 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', timestamp: new Date() }]; + 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__/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'); + }); +}); From b415def05b039c6d88638ca0abf5b98ba361468a Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 17 Feb 2026 22:20:03 -0500 Subject: [PATCH 10/10] test: add TUI tests for PathInput, SelectScreen, ScreenLayout, useMultiSelectNavigation, useProject, useSchemaDocument, useResponsive; fix tsc and formatting after merge --- .../__tests__/CredentialSourcePrompt.test.tsx | 4 +- .../__tests__/FullScreenLogView.test.tsx | 15 +- .../components/__tests__/PathInput.test.tsx | 345 ++++++++++++++++++ .../__tests__/ScreenLayout.test.tsx | 49 +++ .../__tests__/ScrollableText.test.tsx | 4 +- .../components/__tests__/SecretInput.test.tsx | 53 +-- .../__tests__/SelectScreen.test.tsx | 61 ++++ .../components/__tests__/TextInput.test.tsx | 42 +-- .../useMultiSelectNavigation.test.tsx | 220 +++++++++++ .../tui/hooks/__tests__/useProject.test.tsx | 61 ++++ .../tui/hooks/__tests__/useRemove.test.tsx | 2 +- .../hooks/__tests__/useResponsive.test.tsx | 34 ++ .../__tests__/useSchemaDocument.test.tsx | 80 ++++ 13 files changed, 884 insertions(+), 86 deletions(-) create mode 100644 src/cli/tui/components/__tests__/PathInput.test.tsx create mode 100644 src/cli/tui/components/__tests__/ScreenLayout.test.tsx create mode 100644 src/cli/tui/components/__tests__/SelectScreen.test.tsx create mode 100644 src/cli/tui/hooks/__tests__/useMultiSelectNavigation.test.tsx create mode 100644 src/cli/tui/hooks/__tests__/useProject.test.tsx create mode 100644 src/cli/tui/hooks/__tests__/useResponsive.test.tsx create mode 100644 src/cli/tui/hooks/__tests__/useSchemaDocument.test.tsx diff --git a/src/cli/tui/components/__tests__/CredentialSourcePrompt.test.tsx b/src/cli/tui/components/__tests__/CredentialSourcePrompt.test.tsx index f6e28fe0..0c7e3764 100644 --- a/src/cli/tui/components/__tests__/CredentialSourcePrompt.test.tsx +++ b/src/cli/tui/components/__tests__/CredentialSourcePrompt.test.tsx @@ -60,9 +60,7 @@ describe('CredentialSourcePrompt', () => { it('calls onUseEnvLocal when first option selected', () => { const onUseEnvLocal = vi.fn(); - const { stdin } = render( - - ); + const { stdin } = render(); // First option is already selected stdin.write(ENTER); diff --git a/src/cli/tui/components/__tests__/FullScreenLogView.test.tsx b/src/cli/tui/components/__tests__/FullScreenLogView.test.tsx index d1bfc5d9..887d3f9b 100644 --- a/src/cli/tui/components/__tests__/FullScreenLogView.test.tsx +++ b/src/cli/tui/components/__tests__/FullScreenLogView.test.tsx @@ -1,5 +1,5 @@ -import type { LogEntry } from '../LogPanel.js'; 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'; @@ -17,15 +17,14 @@ function makeLogs(count: number): LogEntry[] { return Array.from({ length: count }, (_, i) => ({ level: 'info' as const, message: `Log message ${i + 1}`, - timestamp: new Date(2024, 0, 1, 0, 0, i), })); } describe('FullScreenLogView', () => { it('renders log entries', () => { const logs: LogEntry[] = [ - { level: 'info', message: 'Starting deploy', timestamp: new Date() }, - { level: 'error', message: 'Deploy failed', timestamp: new Date() }, + { level: 'info', message: 'Starting deploy' }, + { level: 'error', message: 'Deploy failed' }, ]; const { lastFrame } = render(); const frame = lastFrame()!; @@ -43,9 +42,7 @@ describe('FullScreenLogView', () => { it('renders log file path when provided', () => { const logs = makeLogs(2); - const { lastFrame } = render( - - ); + const { lastFrame } = render(); expect(lastFrame()).toContain('/tmp/deploy.log'); }); @@ -84,7 +81,7 @@ describe('FullScreenLogView', () => { }); it('renders error log with level label', () => { - const logs: LogEntry[] = [{ level: 'error', message: 'Something broke', timestamp: new Date() }]; + const logs: LogEntry[] = [{ level: 'error', message: 'Something broke' }]; const { lastFrame } = render(); const frame = lastFrame()!; @@ -93,7 +90,7 @@ describe('FullScreenLogView', () => { }); it('renders response log with special formatting', () => { - const logs: LogEntry[] = [{ level: 'response', message: 'Agent response text', timestamp: new Date() }]; + const logs: LogEntry[] = [{ level: 'response', message: 'Agent response text' }]; const { lastFrame } = render(); const frame = lastFrame()!; 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__/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__/ScrollableText.test.tsx b/src/cli/tui/components/__tests__/ScrollableText.test.tsx index 5df96c72..b81c766b 100644 --- a/src/cli/tui/components/__tests__/ScrollableText.test.tsx +++ b/src/cli/tui/components/__tests__/ScrollableText.test.tsx @@ -136,9 +136,7 @@ describe('ScrollableText', () => { it('does not respond to input when isActive is false', async () => { const content = makeContent(20); - const { lastFrame, stdin } = render( - - ); + const { lastFrame, stdin } = render(); const before = lastFrame(); await delay(); diff --git a/src/cli/tui/components/__tests__/SecretInput.test.tsx b/src/cli/tui/components/__tests__/SecretInput.test.tsx index f6ad96a5..3b326ea2 100644 --- a/src/cli/tui/components/__tests__/SecretInput.test.tsx +++ b/src/cli/tui/components/__tests__/SecretInput.test.tsx @@ -1,8 +1,8 @@ import { ApiKeySecretInput, SecretInput } from '../SecretInput.js'; import { render } from 'ink-testing-library'; import React from 'react'; -import { z } from 'zod'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; const ENTER = '\r'; const ESCAPE = '\x1B'; @@ -16,9 +16,7 @@ afterEach(() => vi.restoreAllMocks()); describe('SecretInput', () => { it('renders prompt text in bold', () => { - const { lastFrame } = render( - - ); + const { lastFrame } = render(); expect(lastFrame()).toContain('API Key'); }); @@ -40,9 +38,7 @@ describe('SecretInput', () => { }); it('masks input with default * character', async () => { - const { lastFrame, stdin } = render( - - ); + const { lastFrame, stdin } = render(); await delay(); stdin.write('secret'); @@ -66,9 +62,7 @@ describe('SecretInput', () => { }); it('toggles show/hide on Tab', async () => { - const { lastFrame, stdin } = render( - - ); + const { lastFrame, stdin } = render(); await delay(); stdin.write('mykey'); @@ -92,17 +86,13 @@ describe('SecretInput', () => { }); it('shows "Tab to show" when masked', () => { - const { lastFrame } = render( - - ); + const { lastFrame } = render(); expect(lastFrame()).toContain('Tab to show'); }); it('shows "Tab to hide" after toggling', async () => { - const { lastFrame, stdin } = render( - - ); + const { lastFrame, stdin } = render(); await delay(); stdin.write(TAB); @@ -113,9 +103,7 @@ describe('SecretInput', () => { it('calls onSubmit with trimmed value on Enter', async () => { const onSubmit = vi.fn(); - const { stdin } = render( - - ); + const { stdin } = render(); await delay(); stdin.write(' mykey '); @@ -128,9 +116,7 @@ describe('SecretInput', () => { it('calls onCancel on Escape', async () => { const onCancel = vi.fn(); - const { stdin } = render( - - ); + const { stdin } = render(); await delay(); stdin.write(ESCAPE); @@ -142,9 +128,7 @@ describe('SecretInput', () => { it('calls onSkip when submitting empty value with onSkip provided', async () => { const onSkip = vi.fn(); const onCancel = vi.fn(); - const { stdin } = render( - - ); + const { stdin } = render(); await delay(); stdin.write(ENTER); @@ -156,9 +140,7 @@ describe('SecretInput', () => { it('calls onCancel when submitting empty value without onSkip', async () => { const onCancel = vi.fn(); - const { stdin } = render( - - ); + const { stdin } = render(); await delay(); stdin.write(ENTER); @@ -168,17 +150,13 @@ describe('SecretInput', () => { }); it('shows skip hint when onSkip is provided', () => { - const { lastFrame } = render( - - ); + const { lastFrame } = render(); expect(lastFrame()).toContain('Leave empty to skip'); }); it('shows "go back" instead of "cancel" when onSkip is provided', () => { - const { lastFrame } = render( - - ); + const { lastFrame } = render(); expect(lastFrame()).toContain('go back'); expect(lastFrame()).not.toContain('cancel'); @@ -187,9 +165,7 @@ describe('SecretInput', () => { it('does not submit when schema validation fails', async () => { const onSubmit = vi.fn(); const schema = z.string().min(10, 'Too short'); - const { stdin } = render( - - ); + const { stdin } = render(); await delay(); stdin.write('abc'); @@ -243,8 +219,7 @@ describe('SecretInput', () => { it('supports custom validation', async () => { const onSubmit = vi.fn(); - const customValidation = (val: string) => - val.startsWith('sk-') ? true : 'Must start with sk-'; + const customValidation = (val: string) => (val.startsWith('sk-') ? true : 'Must start with sk-'); const { lastFrame, stdin } = render( ); 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__/TextInput.test.tsx b/src/cli/tui/components/__tests__/TextInput.test.tsx index 1c1384e7..79865a60 100644 --- a/src/cli/tui/components/__tests__/TextInput.test.tsx +++ b/src/cli/tui/components/__tests__/TextInput.test.tsx @@ -1,8 +1,8 @@ import { TextInput } from '../TextInput.js'; import { render } from 'ink-testing-library'; import React from 'react'; -import { z } from 'zod'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; const ENTER = '\r'; const ESCAPE = '\x1B'; @@ -15,9 +15,7 @@ afterEach(() => vi.restoreAllMocks()); describe('TextInput', () => { it('renders prompt text', () => { - const { lastFrame } = render( - - ); + const { lastFrame } = render(); expect(lastFrame()).toContain('Enter name:'); }); @@ -40,17 +38,13 @@ describe('TextInput', () => { }); it('shows > arrow by default', () => { - const { lastFrame } = render( - - ); + const { lastFrame } = render(); expect(lastFrame()).toContain('>'); }); it('hides arrow when hideArrow is true', () => { - const { lastFrame } = render( - - ); + const { lastFrame } = render(); const lines = lastFrame()!.split('\n'); // The input line should not start with > const inputLine = lines.find(l => !l.includes('Name'))!; @@ -58,9 +52,7 @@ describe('TextInput', () => { }); it('accepts character input and displays it', async () => { - const { lastFrame, stdin } = render( - - ); + const { lastFrame, stdin } = render(); await delay(); stdin.write('a'); @@ -86,9 +78,7 @@ describe('TextInput', () => { it('does not call onSubmit when value is empty and allowEmpty is false', async () => { const onSubmit = vi.fn(); - const { stdin } = render( - - ); + const { stdin } = render(); await delay(); stdin.write(ENTER); @@ -99,9 +89,7 @@ describe('TextInput', () => { it('calls onSubmit with empty value when allowEmpty is true', async () => { const onSubmit = vi.fn(); - const { stdin } = render( - - ); + const { stdin } = render(); await delay(); stdin.write(ENTER); @@ -113,9 +101,7 @@ describe('TextInput', () => { it('calls onCancel on Escape', async () => { const onCancel = vi.fn(); - const { stdin } = render( - - ); + const { stdin } = render(); await delay(); stdin.write(ESCAPE); @@ -125,9 +111,7 @@ describe('TextInput', () => { }); it('masks input when mask character is provided', async () => { - const { lastFrame, stdin } = render( - - ); + const { lastFrame, stdin } = render(); await delay(); stdin.write('abc'); @@ -167,9 +151,7 @@ describe('TextInput', () => { it('does not submit when schema validation fails', async () => { const onSubmit = vi.fn(); const schema = z.string().min(5); - const { stdin } = render( - - ); + const { stdin } = render(); await delay(); stdin.write('hi'); @@ -225,9 +207,7 @@ describe('TextInput', () => { }); it('does not show checkmark/crossmark when no schema or customValidation', async () => { - const { lastFrame, stdin } = render( - - ); + const { lastFrame, stdin } = render(); await delay(); stdin.write('hello'); 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 index 5ba8aeba..4cae4b2a 100644 --- a/src/cli/tui/hooks/__tests__/useRemove.test.tsx +++ b/src/cli/tui/hooks/__tests__/useRemove.test.tsx @@ -1,8 +1,8 @@ import { useRemovableAgents, useRemovableGateways, - useRemovableMemories, useRemovableIdentities, + useRemovableMemories, useRemoveAgent, } from '../useRemove.js'; import { Text } from 'ink'; 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'); + }); +});