diff --git a/AGENTS.md b/AGENTS.md index a26e5305..d9d163fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,7 +129,8 @@ The TUI harness provides MCP tools for programmatically driving the AgentCore CL ### Getting Started -1. Run `npm run build` to compile the project before using the harness tools. +1. Run `npm run build:harness` to compile both the CLI and the MCP harness binary. The harness is dev-only tooling and + is not included in the standard `npm run build`. 2. Call `tui_launch` to start a TUI session. It returns a `sessionId` that all subsequent tool calls require. - `tui_launch({})` with no arguments defaults to `command="node"`, `args=["dist/cli/index.mjs"]` (the AgentCore CLI). - The `cwd` parameter determines what the TUI sees: if `cwd` is a directory with an `agentcore.config.json`, the TUI @@ -357,6 +358,6 @@ When `tui_send_keys` doesn't change the screen: When `tui_launch` returns an error: -1. Ensure `npm run build` was run recently -- the CLI binary at `dist/cli/index.mjs` must be up to date. +1. Ensure `npm run build:harness` was run recently -- both the CLI binary and the MCP harness must be up to date. 2. Check that `cwd` points to a valid directory. 3. The error response includes the screen content at time of failure -- use it to diagnose. diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 2b9b5b0b..d47f25bb 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -61,11 +61,17 @@ fs.chmodSync('./dist/cli/index.mjs', '755'); console.log('CLI build complete: dist/cli/index.mjs'); -// MCP harness build — only run if the entry point source exists. -// The source file is created separately; skip gracefully until then. +// --------------------------------------------------------------------------- +// MCP harness build — opt-in via BUILD_HARNESS=1 +// +// The TUI harness is dev-only tooling for AI agents and integration tests. +// It is NOT shipped to end users. Build it separately with: +// BUILD_HARNESS=1 node esbuild.config.mjs +// npm run build:harness +// --------------------------------------------------------------------------- const mcpEntryPoint = './src/tui-harness/mcp/index.ts'; -if (fs.existsSync(mcpEntryPoint)) { +if (process.env.BUILD_HARNESS === '1' && fs.existsSync(mcpEntryPoint)) { await esbuild.build({ entryPoints: [mcpEntryPoint], outfile: './dist/mcp-harness/index.mjs', @@ -91,6 +97,6 @@ if (fs.existsSync(mcpEntryPoint)) { fs.chmodSync('./dist/mcp-harness/index.mjs', '755'); console.log('MCP harness build complete: dist/mcp-harness/index.mjs'); -} else { +} else if (process.env.BUILD_HARNESS === '1') { console.log(`MCP harness build skipped: entry point ${mcpEntryPoint} does not exist yet`); } diff --git a/integ-tests/tui/add-flow.test.ts b/integ-tests/tui/add-flow.test.ts new file mode 100644 index 00000000..32482142 --- /dev/null +++ b/integ-tests/tui/add-flow.test.ts @@ -0,0 +1,151 @@ +/** + * Integration tests for TUI add-resource flow. + * + * Verifies navigation into the Add Resource screen, drilling into the + * Add Agent wizard, and backing out via Escape at each level. + * + * These tests launch the real CLI entry point against a minimal project + * directory (no npm install required) and interact with the Ink-based TUI + * through the headless PTY harness. + */ +import { TuiSession, isAvailable } from '../../src/tui-harness/index.js'; +import { createMinimalProjectDir } from './helpers.js'; +import { join } from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const CLI_ENTRY = join(process.cwd(), 'dist/cli/index.mjs'); + +describe.skipIf(!isAvailable)('TUI add-resource flow', () => { + let session: TuiSession | undefined; + let cleanup: (() => Promise) | undefined; + + afterEach(async () => { + if (session?.alive) await session.close(); + session = undefined; + await cleanup?.(); + cleanup = undefined; + }); + + // --------------------------------------------------------------------------- + // (a) Navigate to Add Resource screen + // --------------------------------------------------------------------------- + it('navigates from HelpScreen to Add Resource screen', async () => { + const project = await createMinimalProjectDir(); + cleanup = project.cleanup; + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: project.dir, + }); + + // Wait for the HelpScreen to render with its command list. + await session.waitFor('Commands', 10000); + + // Type 'add' to filter the command list, then press Enter to select it. + await session.sendKeys('add'); + await session.waitFor('add', 3000); + await session.sendSpecialKey('enter'); + + // Confirm the Add Resource screen has rendered. + const screen = await session.waitFor('Add Resource', 10000); + const text = screen.lines.join('\n'); + + expect(text).toContain('Agent'); + expect(text).toContain('Memory'); + expect(text).toContain('Identity'); + }); + + // --------------------------------------------------------------------------- + // (b) Navigate to Add Agent wizard + // --------------------------------------------------------------------------- + it('navigates from Add Resource to Add Agent wizard', async () => { + const project = await createMinimalProjectDir(); + cleanup = project.cleanup; + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: project.dir, + }); + + await session.waitFor('Commands', 10000); + + // Navigate to Add Resource screen. + await session.sendKeys('add'); + await session.waitFor('add', 3000); + await session.sendSpecialKey('enter'); + await session.waitFor('Add Resource', 10000); + + // Agent is the first item in the list -- press Enter to select it. + await session.sendSpecialKey('enter'); + + // Wait for the Add Agent wizard to appear. It may show "Add Agent" + // as a title or prompt for "Agent name". + const screen = await session.waitFor(/Add Agent|Agent name/, 10000); + const text = screen.lines.join('\n'); + + // The screen should contain some form of agent name input prompt. + expect(text).toMatch(/Add Agent|Agent name/); + }); + + // --------------------------------------------------------------------------- + // (c) Back from Add Agent to Add Resource + // --------------------------------------------------------------------------- + it('returns from Add Agent to Add Resource via Escape', async () => { + const project = await createMinimalProjectDir(); + cleanup = project.cleanup; + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: project.dir, + }); + + await session.waitFor('Commands', 10000); + + // Navigate: HelpScreen -> Add Resource -> Add Agent + await session.sendKeys('add'); + await session.waitFor('add', 3000); + await session.sendSpecialKey('enter'); + await session.waitFor('Add Resource', 10000); + await session.sendSpecialKey('enter'); + await session.waitFor(/Add Agent|Agent name/, 10000); + + // Press Escape to go back to Add Resource. + await session.sendSpecialKey('escape'); + + const screen = await session.waitFor('Add Resource', 5000); + const text = screen.lines.join('\n'); + expect(text).toContain('Add Resource'); + }); + + // --------------------------------------------------------------------------- + // (d) Back from Add Resource to HelpScreen + // --------------------------------------------------------------------------- + it('returns from Add Resource to HelpScreen via Escape', async () => { + const project = await createMinimalProjectDir(); + cleanup = project.cleanup; + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: project.dir, + }); + + await session.waitFor('Commands', 10000); + + // Navigate to Add Resource screen. + await session.sendKeys('add'); + await session.waitFor('add', 3000); + await session.sendSpecialKey('enter'); + await session.waitFor('Add Resource', 10000); + + // Press Escape to go back to HelpScreen. + await session.sendSpecialKey('escape'); + + const screen = await session.waitFor('Commands', 5000); + const text = screen.lines.join('\n'); + expect(text).toContain('Commands'); + }); +}); diff --git a/integ-tests/tui/create-flow.test.ts b/integ-tests/tui/create-flow.test.ts new file mode 100644 index 00000000..4caa146e --- /dev/null +++ b/integ-tests/tui/create-flow.test.ts @@ -0,0 +1,209 @@ +/** + * Integration tests for the TUI create-project wizard. + * + * These tests launch the agentcore CLI in a headless PTY session and drive + * through the interactive project creation flow: entering a project name, + * optionally adding an agent (stepping through the full agent wizard), and + * verifying that the project is created successfully on disk. + * + * All tests are wrapped in describe.skipIf(!isAvailable) so they are + * gracefully skipped when node-pty is not available. + */ +import { TuiSession, isAvailable } from '../../src/tui-harness/index.js'; +import { realpathSync } from 'fs'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +describe.skipIf(!isAvailable)('create wizard TUI flow', () => { + const CLI_ENTRY = join(process.cwd(), 'dist/cli/index.mjs'); + + let session: TuiSession | undefined; + let tempDir: string | undefined; + + afterEach(async () => { + if (session?.alive) await session.close(); + session = undefined; + if (tempDir) { + // eslint-disable-next-line @typescript-eslint/no-empty-function + await rm(tempDir, { recursive: true, force: true }).catch(() => {}); + tempDir = undefined; + } + }); + + // --------------------------------------------------------------------------- + // (a) Full create wizard — create project with agent + // --------------------------------------------------------------------------- + it('creates a project with an agent through the full wizard', async () => { + tempDir = realpathSync(await mkdtemp(join(tmpdir(), 'tui-create-'))); + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: tempDir, + cols: 120, + rows: 40, + }); + + // Step 1: HomeScreen — no project found + await session.waitFor('No AgentCore project found', 15000); + + // Step 2: Press enter to navigate to CreateScreen + await session.sendSpecialKey('enter'); + + // Step 3: Wait for the project name prompt + await session.waitFor('Project name', 10000); + + // Step 4: Type the project name and submit + await session.sendKeys('testproject'); + await session.sendSpecialKey('enter'); + + // Step 5: Wait for the "add agent" prompt + await session.waitFor('Would you like to add an agent now?', 10000); + + // Step 6: Select "Yes, add an agent" (first option — just press enter) + await session.sendSpecialKey('enter'); + + // Step 7: Agent name prompt + await session.waitFor('Agent name', 10000); + + // Step 8: Accept default agent name + await session.sendSpecialKey('enter'); + + // Step 9: Agent type selection + await session.waitFor('Select agent type', 10000); + + // Step 10: Select first option (Create new agent) + await session.sendSpecialKey('enter'); + + // Step 11: Language selection + await session.waitFor(/Python|Select language/, 10000); + + // Step 12: Select Python (first option) + await session.sendSpecialKey('enter'); + + // Step 13: Build type selection + await session.waitFor(/build|CodeZip|Container|Direct Code Deploy/i, 10000); + + // Step 14: Select first build type + await session.sendSpecialKey('enter'); + + // Step 15: Framework selection + await session.waitFor(/Strands|Select.*framework|Select/i, 10000); + + // Step 16: Select first framework + await session.sendSpecialKey('enter'); + + // Step 17: Model provider selection + await session.waitFor(/Bedrock|Select.*model|model.*provider/i, 10000); + + // Step 18: Select first model provider + await session.sendSpecialKey('enter'); + + // Step 19: Memory selection — some model providers may insert an API key + // step before this. Wait for either memory or API key text. + const memoryOrApiKeyScreen = await session.waitFor(/memory|api.?key|Review Configuration/i, 10000); + + const memoryOrApiKeyText = memoryOrApiKeyScreen.lines.join('\n'); + + if (/api.?key/i.test(memoryOrApiKeyText)) { + // API key step — accept default / skip + await session.sendSpecialKey('enter'); + // Now wait for memory + await session.waitFor(/memory|Review Configuration/i, 10000); + } + + // If we landed on memory selection (not yet at Review), select first option + const currentScreen = session.readScreen(); + const currentText = currentScreen.lines.join('\n'); + + if (!/Review Configuration/i.test(currentText)) { + await session.sendSpecialKey('enter'); + } + + // Step 20: Review and confirm + await session.waitFor('Review Configuration', 10000); + await session.sendSpecialKey('enter'); + + // Step 21: Wait for project creation to complete (generous timeout) + const successScreen = await session.waitFor('Project created successfully', 30000); + + const successText = successScreen.lines.join('\n'); + expect(successText).toContain('Project created successfully'); + }, 120_000); + + // --------------------------------------------------------------------------- + // (b) Create wizard — skip agent creation + // --------------------------------------------------------------------------- + it('creates a project without adding an agent', async () => { + tempDir = realpathSync(await mkdtemp(join(tmpdir(), 'tui-create-skip-'))); + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: tempDir, + cols: 120, + rows: 40, + }); + + // HomeScreen + await session.waitFor('No AgentCore project found', 15000); + + // Navigate to CreateScreen + await session.sendSpecialKey('enter'); + + // Project name prompt + await session.waitFor('Project name', 10000); + + // Type project name and submit + await session.sendKeys('skiptest'); + await session.sendSpecialKey('enter'); + + // Wait for the "add agent" prompt + await session.waitFor('Would you like to add an agent now?', 10000); + + // Move to "No, I'll do it later" and select it + await session.sendSpecialKey('down'); + await session.sendSpecialKey('enter'); + + // Wait for project creation to complete + const successScreen = await session.waitFor('Project created successfully', 30000); + + const successText = successScreen.lines.join('\n'); + expect(successText).toContain('Project created successfully'); + }); + + // --------------------------------------------------------------------------- + // (c) Back navigation during wizard + // --------------------------------------------------------------------------- + it('navigates back from CreateScreen to HelpScreen with escape', async () => { + tempDir = realpathSync(await mkdtemp(join(tmpdir(), 'tui-create-back-'))); + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: tempDir, + cols: 120, + rows: 40, + }); + + // HomeScreen + await session.waitFor('No AgentCore project found', 15000); + + // Navigate to CreateScreen + await session.sendSpecialKey('enter'); + + // Confirm we are on the CreateScreen + await session.waitFor('Project name', 10000); + + // Press escape to go back + await session.sendSpecialKey('escape'); + + // Verify we are back on HelpScreen + const homeScreen = await session.waitFor('Commands', 10000); + + const homeText = homeScreen.lines.join('\n'); + expect(homeText).toContain('Commands'); + }); +}); diff --git a/integ-tests/tui/deploy-screen.test.ts b/integ-tests/tui/deploy-screen.test.ts new file mode 100644 index 00000000..4e8e6932 --- /dev/null +++ b/integ-tests/tui/deploy-screen.test.ts @@ -0,0 +1,124 @@ +/** + * Integration tests for TUI deploy screen navigation. + * + * Verifies that the deploy command screen renders correctly when launched + * from a project that has agents, shows AWS configuration prompts, and + * supports escaping back to the HelpScreen. + * + * IMPORTANT: These tests never actually deploy to AWS. They only verify + * that the deploy screen renders and navigation works. The deploy screen + * will display AWS credential/config prompts which we observe but do not + * interact with beyond verifying they appear. + */ +import { TuiSession, isAvailable } from '../../src/tui-harness/index.js'; +import { createMinimalProjectDir } from './helpers.js'; +import { join } from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const CLI_ENTRY = join(process.cwd(), 'dist/cli/index.mjs'); + +describe.skipIf(!isAvailable)('TUI deploy screen', () => { + let session: TuiSession | undefined; + let cleanup: (() => Promise) | undefined; + + afterEach(async () => { + if (session?.alive) await session.close(); + session = undefined; + await cleanup?.(); + cleanup = undefined; + }); + + // --------------------------------------------------------------------------- + // (a) Navigate to Deploy screen + // --------------------------------------------------------------------------- + it('navigates from HelpScreen to Deploy screen', async () => { + const project = await createMinimalProjectDir({ hasAgents: true }); + cleanup = project.cleanup; + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: project.dir, + }); + + // Wait for the HelpScreen to render with its command list. + await session.waitFor('Commands', 10000); + + // Type 'deploy' to filter the command list, then press Enter. + await session.sendKeys('deploy'); + await session.waitFor('deploy', 3000); + await session.sendSpecialKey('enter'); + + // The deploy screen should render. It may show "AgentCore Deploy" + // as a title or immediately begin checking AWS configuration. + const screen = await session.waitFor(/AgentCore Deploy|Checking AWS|AWS/, 10000); + const text = screen.lines.join('\n'); + + expect(text).toMatch(/AgentCore Deploy|Checking AWS|AWS|deploy/i); + }); + + // --------------------------------------------------------------------------- + // (b) Deploy screen shows AWS configuration prompt + // --------------------------------------------------------------------------- + it('shows AWS-related content on the deploy screen', async () => { + const project = await createMinimalProjectDir({ hasAgents: true }); + cleanup = project.cleanup; + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: project.dir, + }); + + await session.waitFor('Commands', 10000); + + // Navigate to Deploy screen. + await session.sendKeys('deploy'); + await session.waitFor('deploy', 3000); + await session.sendSpecialKey('enter'); + + // Wait for some AWS-related text to appear. In a test environment + // without credentials this will typically be one of: + // - "Checking AWS configuration..." + // - "No AWS credentials detected" + // - "AWS credentials have expired" + // - "AgentCore Deploy" + const screen = await session.waitFor('AWS', 10000); + const text = screen.lines.join('\n'); + + // Verify the screen contains AWS-related content -- we just need to + // confirm the deploy screen rendered its AWS configuration phase. + expect(text).toContain('AWS'); + }); + + // --------------------------------------------------------------------------- + // (c) Escape from Deploy back to HelpScreen + // --------------------------------------------------------------------------- + it('returns from Deploy screen to HelpScreen via Escape', async () => { + const project = await createMinimalProjectDir({ hasAgents: true }); + cleanup = project.cleanup; + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: project.dir, + }); + + await session.waitFor('Commands', 10000); + + // Navigate to Deploy screen. + await session.sendKeys('deploy'); + await session.waitFor('deploy', 3000); + await session.sendSpecialKey('enter'); + + // Wait for the deploy screen to render before pressing Escape. + await session.waitFor(/AgentCore Deploy|AWS/, 10000); + + // Press Escape to return to HelpScreen. + await session.sendSpecialKey('escape'); + + const screen = await session.waitFor('Commands', 5000); + const text = screen.lines.join('\n'); + expect(text).toContain('Commands'); + }); +}); diff --git a/integ-tests/tui/harness.test.ts b/integ-tests/tui/harness.test.ts new file mode 100644 index 00000000..d5f84483 --- /dev/null +++ b/integ-tests/tui/harness.test.ts @@ -0,0 +1,216 @@ +/** + * Integration tests for the TUI test harness itself. + * + * These tests exercise the TuiSession class against simple Unix commands + * (echo, cat, bash) to verify core harness functionality: launching + * processes, reading screen output, sending keystrokes, waiting for + * patterns, and session lifecycle management. + * + * All tests are wrapped in describe.skipIf(!isAvailable) so they are + * gracefully skipped when node-pty is not available. + */ +import { LaunchError, TuiSession, WaitForTimeoutError, isAvailable } from '../../src/tui-harness/index.js'; +import { afterEach, describe, expect, it } from 'vitest'; + +describe.skipIf(!isAvailable)('TuiSession harness self-tests', () => { + let session: TuiSession | undefined; + + afterEach(async () => { + if (session?.alive) { + await session.close(); + } + session = undefined; + }); + + // ------------------------------------------------------------------------- + // (a) Launch echo -- reads output + // ------------------------------------------------------------------------- + it('launches /bin/echo and reads output from screen', async () => { + session = await TuiSession.launch({ + command: '/bin/echo', + args: ['hello world'], + }); + + // echo exits immediately; session may already be dead -- that is fine + const screen = session.readScreen(); + const text = screen.lines.join('\n'); + expect(text).toContain('hello world'); + }); + + // ------------------------------------------------------------------------- + // (b) Launch cat + send keys + // ------------------------------------------------------------------------- + it('launches /bin/cat and echoes back typed input', async () => { + session = await TuiSession.launch({ + command: '/bin/cat', + args: [], + }); + + expect(session.alive).toBe(true); + + const screenAfterKeys = await session.sendKeys('hello'); + const text = screenAfterKeys.lines.join('\n'); + expect(text).toContain('hello'); + + // Close with ctrl+d (EOF) to terminate cat + await session.sendSpecialKey('ctrl+d'); + }); + + // ------------------------------------------------------------------------- + // (c) Launch and close + // ------------------------------------------------------------------------- + it('launches /bin/cat and closes the session cleanly', async () => { + session = await TuiSession.launch({ + command: '/bin/cat', + args: [], + }); + + expect(session.alive).toBe(true); + + const result = await session.close(); + + expect(session.alive).toBe(false); + // exitCode is a number (for normal exit) or null (if terminated by signal) + expect(typeof result.exitCode === 'number' || result.exitCode === null).toBe(true); + // finalScreen should be a ScreenState with required properties + expect(result.finalScreen).toHaveProperty('lines'); + expect(result.finalScreen).toHaveProperty('cursor'); + expect(result.finalScreen).toHaveProperty('dimensions'); + expect(result.finalScreen).toHaveProperty('bufferType'); + expect(Array.isArray(result.finalScreen.lines)).toBe(true); + }); + + // ------------------------------------------------------------------------- + // (d) Send keys to dead session + // ------------------------------------------------------------------------- + it('throws when sending keys to a dead session', async () => { + session = await TuiSession.launch({ + command: '/bin/cat', + args: [], + }); + + // Close the session first so it becomes dead + await session.close(); + + expect(session.alive).toBe(false); + + await expect(session.sendKeys('hi')).rejects.toThrow(/not alive/); + }); + + // ------------------------------------------------------------------------- + // (e) waitFor succeeds + // ------------------------------------------------------------------------- + it('waitFor resolves when the expected pattern appears on screen', async () => { + session = await TuiSession.launch({ + command: '/bin/bash', + args: ['-c', 'sleep 0.5 && echo READY'], + }); + + const screen = await session.waitFor('READY', 5000); + const text = screen.lines.join('\n'); + expect(text).toContain('READY'); + }); + + // ------------------------------------------------------------------------- + // (f) waitFor throws on timeout + // ------------------------------------------------------------------------- + it('waitFor throws WaitForTimeoutError when pattern never appears', async () => { + session = await TuiSession.launch({ + command: '/bin/cat', + args: [], + }); + + await expect(session.waitFor('NONEXISTENT', 1000)).rejects.toThrow(WaitForTimeoutError); + + // Verify the error has the expected diagnostic properties + try { + await session.waitFor('ANOTHER_MISSING', 500); + } catch (err) { + expect(err).toBeInstanceOf(WaitForTimeoutError); + const timeoutErr = err as WaitForTimeoutError; + expect(timeoutErr.pattern).toBe('ANOTHER_MISSING'); + expect(typeof timeoutErr.elapsed).toBe('number'); + expect(timeoutErr.screen).toHaveProperty('lines'); + } + }); + + // ------------------------------------------------------------------------- + // (g) Launch with bad command + // ------------------------------------------------------------------------- + it('throws LaunchError for a nonexistent binary', async () => { + await expect(TuiSession.launch({ command: '/nonexistent/binary' })).rejects.toThrow(LaunchError); + + // Verify the error has the expected diagnostic properties + try { + await TuiSession.launch({ command: '/nonexistent/binary' }); + } catch (err) { + expect(err).toBeInstanceOf(LaunchError); + const launchErr = err as LaunchError; + expect(launchErr.command).toBe('/nonexistent/binary'); + expect(typeof launchErr.exitCode).toBe('number'); + } + }); + + // ------------------------------------------------------------------------- + // (h) Multiple concurrent sessions + // ------------------------------------------------------------------------- + it('runs multiple concurrent sessions without cross-contamination', async () => { + let session2: TuiSession | undefined; + + try { + session = await TuiSession.launch({ + command: '/bin/cat', + args: [], + }); + + session2 = await TuiSession.launch({ + command: '/bin/cat', + args: [], + }); + + // Send different text to each session + await session.sendKeys('alpha'); + await session2.sendKeys('bravo'); + + // Read each session's screen + const screen1 = session.readScreen(); + const screen2 = session2.readScreen(); + + const text1 = screen1.lines.join('\n'); + const text2 = screen2.lines.join('\n'); + + // Each session should see only its own text + expect(text1).toContain('alpha'); + expect(text1).not.toContain('bravo'); + + expect(text2).toContain('bravo'); + expect(text2).not.toContain('alpha'); + } finally { + if (session2?.alive) { + await session2.close(); + } + } + }); + + // ------------------------------------------------------------------------- + // (i) readScreen with options + // ------------------------------------------------------------------------- + it('readScreen supports numbered line output', async () => { + session = await TuiSession.launch({ + command: '/bin/echo', + args: ['numbered test'], + }); + + // Read with numbered lines + const numberedScreen = session.readScreen({ numbered: true }); + // Numbered lines should have the " N | " prefix format + const firstNumberedLine = numberedScreen.lines[0] ?? ''; + expect(firstNumberedLine).toMatch(/^\s*1 \| /); + + // Read without numbered lines + const plainScreen = session.readScreen(); + const firstPlainLine = plainScreen.lines[0] ?? ''; + // Plain lines should NOT have the numbered prefix + expect(firstPlainLine).not.toMatch(/^\s*1 \| /); + }); +}); diff --git a/integ-tests/tui/navigation.test.ts b/integ-tests/tui/navigation.test.ts new file mode 100644 index 00000000..f6b67cce --- /dev/null +++ b/integ-tests/tui/navigation.test.ts @@ -0,0 +1,222 @@ +/** + * Integration tests for TUI navigation flows. + * + * These tests exercise the real agentcore CLI TUI to verify navigation + * between screens: HomeScreen (no project), HelpScreen (with project), + * forward navigation into sub-screens, backward navigation via Escape, + * and process exit via double-Escape and Ctrl+C. + * + * All tests are wrapped in describe.skipIf(!isAvailable) so they are + * gracefully skipped when node-pty is not available. + */ +import { TuiSession, isAvailable } from '../../src/tui-harness/index.js'; +import { createMinimalProjectDir } from './helpers.js'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +// The CLI entry point. Tests are run from the repo root, so resolve to +// an absolute path to be safe with different cwd values. +const CLI_ENTRY = join(process.cwd(), 'dist/cli/index.mjs'); + +describe.skipIf(!isAvailable)('TUI navigation flows', () => { + let session: TuiSession | undefined; + let cleanup: (() => Promise) | undefined; + + afterEach(async () => { + if (session?.alive) await session.close(); + session = undefined; + await cleanup?.(); + cleanup = undefined; + }); + + // --------------------------------------------------------------------------- + // (a) HomeScreen renders when no project exists + // --------------------------------------------------------------------------- + it('renders HomeScreen when launched in a directory without a project', async () => { + const bareDir = await mkdtemp(join(tmpdir(), 'tui-nav-bare-')); + cleanup = () => rm(bareDir, { recursive: true, force: true }); + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: bareDir, + }); + + const screen = await session.waitFor('No AgentCore project found', 10_000); + const text = screen.lines.join('\n'); + expect(text).toContain('Press Enter to create a new project'); + }); + + // --------------------------------------------------------------------------- + // (b) HelpScreen renders when project exists + // --------------------------------------------------------------------------- + it('renders HelpScreen when launched in a directory with a project', async () => { + const project = await createMinimalProjectDir(); + cleanup = project.cleanup; + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: project.dir, + }); + + const screen = await session.waitFor('Commands', 10_000); + const text = screen.lines.join('\n'); + expect(text).toContain('add'); + expect(text).toContain('deploy'); + expect(text).toContain('status'); + }); + + // --------------------------------------------------------------------------- + // (c) HomeScreen -> CreateScreen forward navigation + // --------------------------------------------------------------------------- + it('navigates from HomeScreen to CreateScreen on Enter', async () => { + const bareDir = await mkdtemp(join(tmpdir(), 'tui-nav-create-')); + cleanup = () => rm(bareDir, { recursive: true, force: true }); + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: bareDir, + }); + + await session.waitFor('No AgentCore project found', 10_000); + + await session.sendSpecialKey('enter'); + + // The CreateScreen should ask for the project name or show the create title. + await session.waitFor(/Project name|AgentCore Create/, 10_000); + }); + + // --------------------------------------------------------------------------- + // (d) CreateScreen -> back with Escape + // --------------------------------------------------------------------------- + it('navigates back from CreateScreen to HelpScreen on Escape', async () => { + const bareDir = await mkdtemp(join(tmpdir(), 'tui-nav-back-')); + cleanup = () => rm(bareDir, { recursive: true, force: true }); + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: bareDir, + }); + + await session.waitFor('No AgentCore project found', 10_000); + + // Navigate forward to CreateScreen + await session.sendSpecialKey('enter'); + await session.waitFor(/Project name|AgentCore Create/, 10_000); + + // Navigate back with Escape + await session.sendSpecialKey('escape'); + // Escape from CreateScreen goes to HelpScreen (command list), not HomeScreen. + // This is the expected TUI behavior — the router navigates back to 'help'. + await session.waitFor('Commands', 5_000); + }); + + // --------------------------------------------------------------------------- + // (e) HelpScreen -> command screen forward navigation + // --------------------------------------------------------------------------- + it('navigates from HelpScreen into a command screen on Enter', async () => { + const project = await createMinimalProjectDir(); + cleanup = project.cleanup; + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: project.dir, + }); + + await session.waitFor('Commands', 10_000); + + // Press Enter to select the first highlighted command + await session.sendSpecialKey('enter'); + + // Wait for a different screen to appear. The first command in the list + // is typically 'add', which shows an "Add Resource" or similar screen. + // Use a regex to match common sub-screen indicators. + await session.waitFor(/Add Resource|add|Select/, 10_000); + }); + + // --------------------------------------------------------------------------- + // (f) Command screen -> back with Escape + // --------------------------------------------------------------------------- + it('navigates back from a command screen to HelpScreen on Escape', async () => { + const project = await createMinimalProjectDir(); + cleanup = project.cleanup; + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: project.dir, + }); + + await session.waitFor('Commands', 10_000); + + // Navigate into a command screen + await session.sendSpecialKey('enter'); + await session.waitFor(/Add Resource|add|Select/, 10_000); + + // Navigate back with Escape + await session.sendSpecialKey('escape'); + await session.waitFor('Commands', 5_000); + }); + + // --------------------------------------------------------------------------- + // (g) Exit via double-Escape from HelpScreen + // --------------------------------------------------------------------------- + it('exits the TUI via double-Escape from HelpScreen', async () => { + const project = await createMinimalProjectDir(); + cleanup = project.cleanup; + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: project.dir, + }); + + await session.waitFor('Commands', 10_000); + + // First Escape: should show a warning prompt + await session.sendSpecialKey('escape'); + await session.waitFor('Press Esc again to exit', 3_000); + + // Second Escape: should exit the TUI process + await session.sendSpecialKey('escape'); + + // Wait briefly for the process to terminate + const deadline = Date.now() + 5_000; + while (session.alive && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + expect(session.alive).toBe(false); + }); + + // --------------------------------------------------------------------------- + // (h) Exit via Ctrl+C + // --------------------------------------------------------------------------- + it('exits the TUI via Ctrl+C', async () => { + const project = await createMinimalProjectDir(); + cleanup = project.cleanup; + + session = await TuiSession.launch({ + command: 'node', + args: [CLI_ENTRY], + cwd: project.dir, + }); + + await session.waitFor('Commands', 10_000); + + // Ctrl+C should terminate the process + await session.sendSpecialKey('ctrl+c'); + + // Wait briefly for the process to terminate + const deadline = Date.now() + 5_000; + while (session.alive && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + expect(session.alive).toBe(false); + }); +}); diff --git a/integ-tests/tui/setup.ts b/integ-tests/tui/setup.ts new file mode 100644 index 00000000..f18a59b2 --- /dev/null +++ b/integ-tests/tui/setup.ts @@ -0,0 +1,25 @@ +import { afterAll, beforeAll } from 'vitest'; + +// NOTE: The dynamic imports below reference modules that don't exist until Phase 2. +// TS diagnostics on the import paths are expected and will resolve once the modules are created. + +beforeAll(async () => { + try { + const { isAvailable, unavailableReason } = await import('../../src/tui-harness/lib/availability.js'); + if (!isAvailable) { + console.warn(`TUI harness unavailable: ${unavailableReason}. Skipping all TUI tests.`); + } + } catch { + // Harness not yet built + } +}); + +afterAll(async () => { + // Safety net: kill any orphaned PTY sessions + try { + const { closeAll } = await import('../../src/tui-harness/lib/session-manager.js'); + await closeAll(); + } catch { + // Harness not yet built — nothing to clean up + } +}); diff --git a/package.json b/package.json index 946b84a7..ffb059b2 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { - "agentcore": "dist/cli/index.mjs", - "agent-tui-harness": "dist/mcp-harness/index.mjs" + "agentcore": "dist/cli/index.mjs" }, "exports": { ".": { @@ -41,7 +40,8 @@ }, "files": [ "dist", - "scripts" + "scripts", + "!dist/mcp-harness" ], "scripts": { "preinstall": "node scripts/check-old-cli.mjs", @@ -49,6 +49,7 @@ "build:lib": "tsc -p tsconfig.build.json", "build:cli": "node esbuild.config.mjs", "build:assets": "node scripts/copy-assets.mjs", + "build:harness": "BUILD_HARNESS=1 node esbuild.config.mjs", "cli": "npx tsx src/cli/index.ts", "typecheck": "tsc --noEmit", "lint": "eslint src/", @@ -66,7 +67,7 @@ "test:unit": "vitest run --project unit --coverage", "test:e2e": "vitest run --project e2e", "test:update-snapshots": "vitest run --project unit --update", - "test:tui": "npm run build && vitest run --project tui" + "test:tui": "npm run build:harness && vitest run --project tui" }, "dependencies": { "@aws-cdk/toolkit-lib": "^1.16.0", @@ -129,9 +130,6 @@ "typescript-eslint": "^8.50.1", "vitest": "^4.0.18" }, - "optionalDependencies": { - "node-pty": "^1.1.0" - }, "overridesComments": { "minimatch": "GHSA-7r86-cg39-jmmj, GHSA-23c5-xmqv-rm74: minimatch 10.0.0-10.2.2 has ReDoS vulnerabilities. Multiple transitive deps (eslint, typescript-eslint, eslint-plugin-import, eslint-plugin-react, prettier-plugin-sort-imports, aws-cdk-lib) pin older versions. Remove this override once upstream packages update their minimatch dependency to >=10.2.3.", "fast-xml-parser": "GHSA-fj3w-jwp8-x2g3: fast-xml-parser <5.3.8 has stack overflow in XMLBuilder. Transitive via @aws-sdk/xml-builder. Remove this override once @aws-sdk updates to fast-xml-parser >=5.3.8."