|
6 | 6 | npm test # Run unit tests |
7 | 7 | npm run test:watch # Run tests in watch mode |
8 | 8 | npm run test:integ # Run integration tests |
| 9 | +npm run test:tui # Run TUI integration tests (builds first) |
9 | 10 | npm run test:all # Run all tests (unit + integ) |
10 | 11 | ``` |
11 | 12 |
|
@@ -125,12 +126,234 @@ Review the changes in `src/assets/__tests__/__snapshots__/` before committing. |
125 | 126 | - Contents of all template files (CDK, Python frameworks, MCP, static assets) |
126 | 127 | - Any file addition or removal |
127 | 128 |
|
| 129 | +## TUI Integration Tests |
| 130 | + |
| 131 | +TUI integration tests run the full CLI binary inside a pseudo-terminal (PTY) and verify screen output, keyboard |
| 132 | +navigation, and end-to-end wizard flows. |
| 133 | + |
| 134 | +> **Note:** TUI tests require `node-pty` (native addon). If node-pty is not installed, TUI tests are automatically |
| 135 | +> skipped. |
| 136 | +
|
| 137 | +### Running TUI Tests |
| 138 | + |
| 139 | +```bash |
| 140 | +npm run test:tui # Builds first, then runs TUI tests |
| 141 | +npx vitest run --project tui # Skip build (use when build is fresh) |
| 142 | +``` |
| 143 | + |
| 144 | +### Test Organization |
| 145 | + |
| 146 | +``` |
| 147 | +integ-tests/tui/ |
| 148 | +├── setup.ts # Global setup: availability check, afterAll cleanup |
| 149 | +├── helpers.ts # createMinimalProjectDir, common test setup |
| 150 | +├── harness.test.ts # TuiSession self-tests (spawn, send, read) |
| 151 | +├── navigation.test.ts # Screen navigation flows |
| 152 | +├── create-flow.test.ts # Create wizard end-to-end |
| 153 | +├── add-flow.test.ts # Add resource flows |
| 154 | +└── deploy-screen.test.ts # Deploy screen rendering |
| 155 | +``` |
| 156 | + |
| 157 | +### Writing a TUI Flow Test |
| 158 | + |
| 159 | +Below is a complete example showing the typical pattern for a TUI flow test: |
| 160 | + |
| 161 | +```typescript |
| 162 | +import { isAvailable } from '../../src/test-utils/tui-harness/index.js'; |
| 163 | +import { TuiSession } from '../../src/test-utils/tui-harness/index.js'; |
| 164 | +import { createMinimalProjectDir } from './helpers.js'; |
| 165 | +import { afterEach, describe, expect, it } from 'vitest'; |
| 166 | + |
| 167 | +describe.skipIf(!isAvailable)('my TUI flow', () => { |
| 168 | + let session: TuiSession; |
| 169 | + |
| 170 | + afterEach(async () => { |
| 171 | + await session?.close(); |
| 172 | + }); |
| 173 | + |
| 174 | + it('navigates to the add screen', async () => { |
| 175 | + // createMinimalProjectDir makes a temp dir with agentcore config (~10ms) |
| 176 | + const { dir, cleanup } = await createMinimalProjectDir({ hasAgents: true }); |
| 177 | + |
| 178 | + try { |
| 179 | + // Launch the CLI TUI in the project directory |
| 180 | + session = await TuiSession.launch({ |
| 181 | + command: 'node', |
| 182 | + args: ['../../dist/cli/index.mjs'], |
| 183 | + cwd: dir, |
| 184 | + }); |
| 185 | + |
| 186 | + // Wait for the HelpScreen to render |
| 187 | + await session.waitFor('Commands'); |
| 188 | + |
| 189 | + // Navigate: type 'add' to filter, then Enter |
| 190 | + await session.sendKeys('add'); |
| 191 | + await session.sendSpecialKey('enter'); |
| 192 | + |
| 193 | + // Verify we reached the AddScreen |
| 194 | + await session.waitFor('agent'); |
| 195 | + const screen = session.readScreen(); |
| 196 | + expect(screen.lines.join('\n')).toContain('agent'); |
| 197 | + } finally { |
| 198 | + await cleanup(); |
| 199 | + } |
| 200 | + }); |
| 201 | +}); |
| 202 | +``` |
| 203 | + |
| 204 | +Key points: |
| 205 | + |
| 206 | +- **`describe.skipIf(!isAvailable)`** -- gracefully skips when `node-pty` is missing. |
| 207 | +- **`afterEach` with `session?.close()`** -- always clean up PTY processes. |
| 208 | +- **`createMinimalProjectDir`** -- fast temp directory setup (no `npm install`). |
| 209 | +- **`try/finally` with `cleanup()`** -- always remove temp directories. |
| 210 | + |
| 211 | +### TuiSession API Quick Reference |
| 212 | + |
| 213 | +| Method | Returns | Description | |
| 214 | +| -------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------- | |
| 215 | +| `TuiSession.launch(options)` | `Promise<TuiSession>` | Spawn CLI in PTY. Throws `LaunchError` if process exits during startup. | |
| 216 | +| `session.sendKeys(text, waitMs?)` | `Promise<ScreenState>` | Type text, wait for screen to settle, return screen. | |
| 217 | +| `session.sendSpecialKey(key, waitMs?)` | `Promise<ScreenState>` | Send special key (enter, tab, escape, etc.), wait, return screen. | |
| 218 | +| `session.readScreen(options?)` | `ScreenState` | Read current screen (synchronous). Options: `{ includeScrollback?, numbered? }`. | |
| 219 | +| `session.waitFor(pattern, timeoutMs?)` | `Promise<ScreenState>` | Wait for text/regex on screen. **Throws `WaitForTimeoutError` on timeout** (default 5000ms). | |
| 220 | +| `session.close(signal?)` | `Promise<CloseResult>` | Close session. Returns exit code, signal, final screen. | |
| 221 | +| `session.info` | `SessionInfo` | Session metadata: sessionId, pid, dimensions, alive status. | |
| 222 | +| `session.alive` | `boolean` | Whether the PTY process is still running. | |
| 223 | + |
| 224 | +### ScreenState Shape |
| 225 | + |
| 226 | +```typescript |
| 227 | +interface ScreenState { |
| 228 | + lines: string[]; // Each line of terminal text |
| 229 | + cursor: { x: number; y: number }; // Cursor position |
| 230 | + dimensions: { cols: number; rows: number }; // Terminal size |
| 231 | + bufferType: 'normal' | 'alternate'; // Active buffer |
| 232 | +} |
| 233 | +``` |
| 234 | + |
| 235 | +### Special Keys |
| 236 | + |
| 237 | +The following special keys can be passed to `session.sendSpecialKey()`: |
| 238 | + |
| 239 | +`enter`, `tab`, `escape`, `backspace`, `delete`, `space`, `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, |
| 240 | +`pagedown`, `ctrl+c`, `ctrl+d`, `ctrl+q`, `ctrl+g`, `ctrl+a`, `ctrl+e`, `ctrl+w`, `ctrl+u`, `ctrl+k`, `f1` through |
| 241 | +`f12`. |
| 242 | + |
| 243 | +### Key Concepts |
| 244 | + |
| 245 | +#### waitFor vs Settling |
| 246 | + |
| 247 | +- **Settling** (automatic after `sendKeys`/`sendSpecialKey`): Waits for screen text to stop changing. Good for most |
| 248 | + screens. Fails on spinner/animation screens because text changes continuously. |
| 249 | +- **waitFor**: Polls for a specific text pattern. Use for: (a) async operations with spinners, (b) confirming you |
| 250 | + reached the right screen, (c) any case where you need a specific pattern before proceeding. |
| 251 | +- **Rule of thumb**: Use `waitFor` when waiting for an async result (project creation, deployment). Use |
| 252 | + `sendKeys`/`sendSpecialKey` (which auto-settle) for navigating between static screens. |
| 253 | + |
| 254 | +#### waitFor Throws on Timeout |
| 255 | + |
| 256 | +`waitFor()` throws `WaitForTimeoutError` when the pattern is not found within the timeout. The error includes: |
| 257 | + |
| 258 | +- The pattern that was not found |
| 259 | +- How long it waited |
| 260 | +- The full screen content at timeout |
| 261 | + |
| 262 | +This means tests fail fast with useful diagnostics. You do not need to check a `found` boolean. |
| 263 | + |
| 264 | +#### WaitForTimeoutError Output |
| 265 | + |
| 266 | +When `waitFor()` times out, the thrown `WaitForTimeoutError` produces a message like this: |
| 267 | + |
| 268 | +``` |
| 269 | +WaitForTimeoutError: waitFor("created successfully") timed out after 5000ms. |
| 270 | +Screen content: |
| 271 | +AgentCore Create |
| 272 | +
|
| 273 | +Creating project... |
| 274 | +⠋ Installing dependencies |
| 275 | +``` |
| 276 | + |
| 277 | +The error message includes the full non-blank screen content at the time of the timeout. This makes it straightforward |
| 278 | +to diagnose why the expected pattern was not found -- was the screen still loading? Did the test land on the wrong |
| 279 | +screen? Was there a typo in the pattern? |
| 280 | + |
| 281 | +If you need to inspect the error properties programmatically (for example, to log additional context or make assertions |
| 282 | +on the screen state), you can catch the error directly: |
| 283 | + |
| 284 | +```typescript |
| 285 | +import { WaitForTimeoutError } from '../../src/test-utils/tui-harness/index.js'; |
| 286 | + |
| 287 | +try { |
| 288 | + await session.waitFor('expected text', 3000); |
| 289 | +} catch (err) { |
| 290 | + if (err instanceof WaitForTimeoutError) { |
| 291 | + console.log(err.pattern); // 'expected text' |
| 292 | + console.log(err.elapsed); // ~3000 |
| 293 | + console.log(err.screen); // ScreenState with full content |
| 294 | + } |
| 295 | + throw err; |
| 296 | +} |
| 297 | +``` |
| 298 | + |
| 299 | +#### createMinimalProjectDir |
| 300 | + |
| 301 | +Creates a temp directory that AgentCore recognizes as a project in ~10ms (no npm install). Use it when your test needs a |
| 302 | +project context: |
| 303 | + |
| 304 | +```typescript |
| 305 | +const { dir, cleanup } = await createMinimalProjectDir({ |
| 306 | + projectName: 'mytest', // optional, defaults to 'testproject' |
| 307 | + hasAgents: true, // optional, adds a sample agent |
| 308 | +}); |
| 309 | +``` |
| 310 | + |
| 311 | +Always call `cleanup()` when done (in `finally` or `afterEach`). |
| 312 | + |
| 313 | +#### LaunchError |
| 314 | + |
| 315 | +`TuiSession.launch()` throws `LaunchError` when the spawned process exits before the screen settles. Common causes |
| 316 | +include a missing binary, a crash on startup, or an invalid working directory. |
| 317 | + |
| 318 | +The error includes the following diagnostic properties: |
| 319 | + |
| 320 | +- `command` -- the executable that was launched |
| 321 | +- `args` -- the arguments passed to the command |
| 322 | +- `cwd` -- the working directory used for the spawned process |
| 323 | +- `exitCode` -- the process exit code (or `null` if terminated by signal) |
| 324 | +- `screen` -- the `ScreenState` captured at the time of exit |
| 325 | + |
| 326 | +You can assert that a launch fails with `LaunchError`: |
| 327 | + |
| 328 | +```typescript |
| 329 | +import { LaunchError, TuiSession } from '../../src/test-utils/tui-harness/index.js'; |
| 330 | + |
| 331 | +it('throws LaunchError for missing binary', async () => { |
| 332 | + await expect(TuiSession.launch({ command: 'nonexistent-binary' })).rejects.toThrow(LaunchError); |
| 333 | +}); |
| 334 | + |
| 335 | +// Or if you need to inspect the error: |
| 336 | +it('provides diagnostics in LaunchError', async () => { |
| 337 | + try { |
| 338 | + await TuiSession.launch({ command: 'node', args: ['missing-file.js'] }); |
| 339 | + } catch (err) { |
| 340 | + if (err instanceof LaunchError) { |
| 341 | + console.log(err.command); // 'node' |
| 342 | + console.log(err.exitCode); // 1 |
| 343 | + console.log(err.screen); // ScreenState at time of crash |
| 344 | + } |
| 345 | + throw err; |
| 346 | + } |
| 347 | +}); |
| 348 | +``` |
| 349 | + |
128 | 350 | ## Configuration |
129 | 351 |
|
130 | 352 | Test configuration is in `vitest.config.ts` using Vitest projects: |
131 | 353 |
|
132 | 354 | - **unit** project: `src/**/*.test.ts` (includes snapshot tests) |
133 | 355 | - **integ** project: `integ-tests/**/*.test.ts` |
| 356 | +- **tui** project: `integ-tests/tui/**/*.test.ts` (TUI integration tests) |
134 | 357 | - Test timeout: 120 seconds |
135 | 358 | - Hook timeout: 120 seconds |
136 | 359 |
|
|
0 commit comments