diff --git a/.github/workflows/e2e-tests-full.yml b/.github/workflows/e2e-tests-full.yml index 50528a8b3..26df33b74 100644 --- a/.github/workflows/e2e-tests-full.yml +++ b/.github/workflows/e2e-tests-full.yml @@ -79,3 +79,19 @@ jobs: GEMINI_API_KEY: ${{ env.E2E_GEMINI_API_KEY }} CDK_TARBALL: ${{ env.CDK_TARBALL }} run: npm run test:e2e + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + - name: Run browser tests + env: + AWS_ACCOUNT_ID: ${{ steps.aws.outputs.account_id }} + AWS_REGION: ${{ inputs.aws_region || 'us-east-1' }} + PLAYWRIGHT_TRACE: 'off' + run: npm run test:browser + - name: Print browser test debug info + if: failure() + run: | + echo "=== Dev server PTY output ===" + cat test-results/agentcore-dev-pty.log 2>/dev/null || echo "(no pty log)" + echo "" + echo "=== Error contexts ===" + find test-results -name 'error-context.md' -exec echo "--- {} ---" \; -exec cat {} \; 2>/dev/null || echo "(no error contexts)" diff --git a/.gitignore b/.gitignore index 3b00d92ae..d22e52199 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,8 @@ ProtocolTesting/ # Auto-cloned CDK constructs (from scripts/bundle.mjs) .cdk-constructs-clone/ + +# Browser tests +browser-tests/.browser-test-env +browser-tests/test-results/ +browser-tests/playwright-report/ diff --git a/browser-tests/constants.ts b/browser-tests/constants.ts new file mode 100644 index 000000000..7ef4d4848 --- /dev/null +++ b/browser-tests/constants.ts @@ -0,0 +1,3 @@ +import { join } from 'node:path'; + +export const ENV_FILE = join(__dirname, '.browser-test-env'); diff --git a/browser-tests/fixtures.ts b/browser-tests/fixtures.ts new file mode 100644 index 000000000..b6aa7b601 --- /dev/null +++ b/browser-tests/fixtures.ts @@ -0,0 +1,56 @@ +import { ENV_FILE } from './constants'; +import { type Page, test as base, expect } from '@playwright/test'; +import { readFileSync } from 'node:fs'; + +interface BrowserTestEnv { + projectPath: string; + port: number; + projectName: string; +} + +function readTestEnv(): BrowserTestEnv { + const raw = readFileSync(ENV_FILE, 'utf-8'); + const parsed: Record = {}; + for (const line of raw.split('\n')) { + const match = line.match(/^(\w+)=(.+)$/); + if (match) parsed[match[1]!] = match[2]!; + } + return { + projectPath: parsed.PROJECT_PATH!, + port: Number(parsed.PORT), + projectName: parsed.PROJECT_NAME!, + }; +} + +export const test = base.extend<{ testEnv: BrowserTestEnv }>({ + testEnv: async ({}, use) => { + await use(readTestEnv()); + }, +}); + +/** + * Send a chat message and wait for the agent to finish responding. + * Returns the assistant message locator. + */ +export async function sendMessage(page: Page, text: string) { + const chatInput = page.getByTestId('chat-input'); + await expect(chatInput).toBeEnabled({ timeout: 60_000 }); + + const messageList = page.getByTestId('message-list'); + const existingCount = await messageList.getByTestId(/^chat-message-/).count(); + + await chatInput.fill(text); + await page.getByRole('button', { name: 'Send message' }).click(); + + const assistantMessage = messageList.getByTestId(`chat-message-${existingCount + 1}`); + await expect(assistantMessage).toBeVisible({ timeout: 60_000 }); + await expect(assistantMessage).not.toContainText('ECONNREFUSED'); + + // Wait for streaming to complete so the agent is idle for subsequent tests. + await chatInput.fill('.'); + await expect(page.getByRole('button', { name: 'Send message' })).toBeEnabled({ timeout: 30_000 }); + + return assistantMessage; +} + +export { expect }; diff --git a/browser-tests/global-setup.ts b/browser-tests/global-setup.ts new file mode 100644 index 000000000..462cfa260 --- /dev/null +++ b/browser-tests/global-setup.ts @@ -0,0 +1,144 @@ +import { ENV_FILE } from './constants'; +import * as pty from 'node-pty'; +import { type ExecSyncOptions, execSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { createWriteStream, mkdirSync, writeFileSync } from 'node:fs'; +import { createConnection } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const CLI_PATH = join(__dirname, '..', 'dist', 'cli', 'index.mjs'); +const PTY_LOG = join(__dirname, 'test-results', 'agentcore-dev-pty.log'); + +function hasAwsCredentials(): boolean { + try { + execSync('aws sts get-caller-identity', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function hasCommand(cmd: string): boolean { + try { + execSync(`which ${cmd}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +async function waitForServerReady(port: number, timeoutMs = 90000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const listening = await new Promise(resolve => { + const socket = createConnection({ port, host: '127.0.0.1' }, () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => { + socket.destroy(); + resolve(false); + }); + }); + if (listening) return true; + await new Promise(resolve => setTimeout(resolve, 500)); + } + return false; +} + +export default async function globalSetup() { + const missing: string[] = []; + if (!hasAwsCredentials()) missing.push('AWS credentials (run `aws sts get-caller-identity`)'); + if (!hasCommand('uv')) missing.push('`uv` on PATH'); + + if (missing.length > 0) { + if (process.env.CI) { + throw new Error(`Browser tests require: ${missing.join(', ')}`); + } + console.log(`\nSkipping browser tests — missing: ${missing.join(', ')}\n`); + process.exit(0); + } + + const testDir = join(tmpdir(), `agentcore-browser-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + + const projectName = `BrTest${String(Date.now()).slice(-8)}`; + + console.log(`\nCreating test project "${projectName}" in ${testDir}`); + + const cleanEnv = { ...process.env }; + delete cleanEnv.INIT_CWD; + + const execOpts: ExecSyncOptions = { cwd: testDir, stdio: 'pipe', env: cleanEnv }; + + let createRaw: string; + try { + createRaw = execSync( + `node ${CLI_PATH} create --name ${projectName} --language Python --framework Strands --model-provider Bedrock --memory none --json`, + execOpts + ).toString(); + } catch (err: unknown) { + const e = err as { stderr?: Buffer; stdout?: Buffer; status?: number }; + const stderr = e.stderr?.toString() ?? ''; + const stdout = e.stdout?.toString() ?? ''; + throw new Error(`agentcore create failed (exit ${e.status}):\nstdout: ${stdout}\nstderr: ${stderr}`); + } + + // eslint-disable-next-line no-control-regex + const createResult = createRaw.replace(/\x1B\[\??\d*[a-zA-Z]/g, '').trim(); + const parsed = JSON.parse(createResult.split('\n').pop()!); + const projectPath: string = resolve(testDir, parsed.projectPath); + + console.log(`Project created at ${projectPath}`); + console.log(`Starting agentcore dev...`); + + const env = { ...process.env }; + delete env.INIT_CWD; + if (env.AGENT_INSPECTOR_PATH) { + env.AGENT_INSPECTOR_PATH = resolve(env.AGENT_INSPECTOR_PATH); + } + + const ptyProcess = pty.spawn('node', [CLI_PATH, 'dev'], { + cwd: projectPath, + env, + cols: 80, + rows: 24, + }); + + mkdirSync(join(__dirname, 'test-results'), { recursive: true }); + // eslint-disable-next-line no-control-regex + const stripAnsi = (s: string) => s.replace(/\x1B\[\??[\d;]*[a-zA-Z]/g, ''); + const ptyLog = createWriteStream(PTY_LOG); + + let serverOutput = ''; + const webUIPort = await new Promise((resolvePort, reject) => { + const timeout = setTimeout(() => { + ptyProcess.kill(); + reject(new Error(`agentcore dev failed to start within timeout.\nOutput: ${serverOutput}`)); + }, 90000); + + ptyProcess.onData((data: string) => { + serverOutput += data; + ptyLog.write(stripAnsi(data)); + const match = serverOutput.match(/Chat UI: http:\/\/localhost:(\d+)/); + if (match) { + clearTimeout(timeout); + resolvePort(parseInt(match[1]!, 10)); + } + }); + }); + + const ready = await waitForServerReady(webUIPort); + if (!ready) { + ptyProcess.kill(); + throw new Error(`Web UI reported port ${webUIPort} but it is not responding.\nOutput: ${serverOutput}`); + } + + console.log(`Dev server ready on port ${webUIPort}`); + + writeFileSync( + ENV_FILE, + `PROJECT_PATH=${projectPath}\nPORT=${webUIPort}\nTEST_DIR=${testDir}\nSERVER_PID=${ptyProcess.pid}\nPROJECT_NAME=${projectName}\n` + ); +} diff --git a/browser-tests/global-teardown.ts b/browser-tests/global-teardown.ts new file mode 100644 index 000000000..be3ad64bf --- /dev/null +++ b/browser-tests/global-teardown.ts @@ -0,0 +1,39 @@ +import { ENV_FILE } from './constants'; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; + +export default async function globalTeardown() { + if (!existsSync(ENV_FILE)) return; + + const raw = readFileSync(ENV_FILE, 'utf-8'); + + const serverPid = raw.match(/^SERVER_PID=(.+)$/m)?.[1]; + if (serverPid) { + try { + process.kill(Number(serverPid), 'SIGTERM'); + console.log(`\nStopped dev server (PID ${serverPid})`); + } catch { + // Process already exited + } + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + const projectPath = raw.match(/^PROJECT_PATH=(.+)$/m)?.[1]; + const testDir = raw.match(/^TEST_DIR=(.+)$/m)?.[1]; + + if (projectPath) { + const logsDir = join(projectPath, 'agentcore', '.cli', 'logs'); + const outputDir = join(__dirname, 'test-results', 'dev-server-logs'); + if (existsSync(logsDir)) { + mkdirSync(outputDir, { recursive: true }); + cpSync(logsDir, outputDir, { recursive: true }); + } + } + + if (testDir && existsSync(testDir)) { + console.log(`Cleaning up ${testDir}`); + rmSync(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + } + + unlinkSync(ENV_FILE); +} diff --git a/browser-tests/playwright.config.ts b/browser-tests/playwright.config.ts new file mode 100644 index 000000000..988b0004f --- /dev/null +++ b/browser-tests/playwright.config.ts @@ -0,0 +1,33 @@ +import { ENV_FILE } from './constants'; +import { defineConfig, devices } from '@playwright/test'; +import { readFileSync } from 'node:fs'; + +function getPort(): number { + try { + const raw = readFileSync(ENV_FILE, 'utf-8'); + const match = raw.match(/^PORT=(\d+)$/m); + if (match) return parseInt(match[1]!, 10); + } catch {} + return 8081; +} + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + workers: 1, + timeout: 120_000, + retries: process.env.CI ? 1 : 0, + outputDir: './test-results', + reporter: [['html', { open: 'never', outputFolder: './playwright-report' }]], + + globalSetup: './global-setup.ts', + globalTeardown: './global-teardown.ts', + + use: { + baseURL: `http://localhost:${getPort()}`, + trace: process.env.PLAYWRIGHT_TRACE === 'off' ? 'off' : 'retain-on-failure', + screenshot: 'only-on-failure', + }, + + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/browser-tests/tests/chat-invocation.test.ts b/browser-tests/tests/chat-invocation.test.ts new file mode 100644 index 000000000..fb82b39a0 --- /dev/null +++ b/browser-tests/tests/chat-invocation.test.ts @@ -0,0 +1,10 @@ +import { expect, sendMessage, test } from '../fixtures'; + +test.describe('Chat invocation', () => { + test('send a message and receive a response', async ({ page }) => { + await page.goto('/'); + + const assistantMessage = await sendMessage(page, 'What is 2 plus 2? Reply with just the number.'); + await expect(assistantMessage).not.toBeEmpty(); + }); +}); diff --git a/browser-tests/tests/inspector-loads.test.ts b/browser-tests/tests/inspector-loads.test.ts new file mode 100644 index 000000000..649a3edb4 --- /dev/null +++ b/browser-tests/tests/inspector-loads.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from '../fixtures'; + +test.describe('Inspector loads', () => { + test('page renders and shows the agent', async ({ page, testEnv }) => { + await page.goto('/'); + + await expect(page.locator('header')).toBeVisible(); + + const agentStatus = page.getByTestId('agent-status'); + await expect(agentStatus).toBeVisible({ timeout: 30_000 }); + await expect(agentStatus).toContainText(testEnv.projectName); + }); +}); diff --git a/browser-tests/tests/resources.test.ts b/browser-tests/tests/resources.test.ts new file mode 100644 index 000000000..87e06c1c5 --- /dev/null +++ b/browser-tests/tests/resources.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from '../fixtures'; + +test.describe('Resources', () => { + test('resource panel shows the agent', async ({ page, testEnv }) => { + await page.goto('/'); + + const resourcePanel = page.getByTestId('resource-panel'); + await expect(resourcePanel).toBeVisible({ timeout: 10_000 }); + + const resourcesTab = resourcePanel.getByRole('tab', { name: 'Resources' }); + await resourcesTab.click(); + + const agentNode = resourcePanel.getByRole('button', { name: new RegExp(`agent: ${testEnv.projectName}`, 'i') }); + await expect(agentNode).toBeVisible({ timeout: 10_000 }); + + await page.getByRole('button', { name: 'Toggle resource panel' }).click(); + await expect(resourcePanel).not.toBeVisible(); + }); +}); diff --git a/browser-tests/tests/start-agent.test.ts b/browser-tests/tests/start-agent.test.ts new file mode 100644 index 000000000..a18c39747 --- /dev/null +++ b/browser-tests/tests/start-agent.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '../fixtures'; + +test.describe('Start agent', () => { + test('agent starts and shows running status', async ({ page }) => { + await page.goto('/'); + + const agentStatus = page.getByTestId('agent-status'); + await expect(agentStatus).toBeVisible({ timeout: 30_000 }); + + const chatInput = page.getByTestId('chat-input'); + await expect(chatInput).toBeVisible({ timeout: 60_000 }); + await expect(chatInput).toBeEnabled({ timeout: 60_000 }); + + await expect(page.getByText('Error')).not.toBeVisible(); + }); +}); diff --git a/browser-tests/tests/traces.test.ts b/browser-tests/tests/traces.test.ts new file mode 100644 index 000000000..77c1b5ad4 --- /dev/null +++ b/browser-tests/tests/traces.test.ts @@ -0,0 +1,22 @@ +import { expect, sendMessage, test } from '../fixtures'; + +test.describe('Traces', () => { + test('traces panel shows trace after invocation', async ({ page }) => { + await page.goto('/'); + + await sendMessage(page, 'Say hello'); + + await page.getByRole('tab', { name: 'Traces' }).click(); + + const traceList = page.getByTestId('trace-list'); + await expect(traceList).toBeVisible({ timeout: 30_000 }); + + const traceButton = traceList.getByRole('button').first(); + await expect(traceButton).toBeVisible({ timeout: 30_000 }); + + await traceButton.click(); + + const spanRow = page.locator('[role="button"]').filter({ hasText: /.+/ }); + await expect(spanRow.first()).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/docs/TESTING.md b/docs/TESTING.md index 30889b17a..700ab3aae 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -3,11 +3,12 @@ ## Quick Start ```bash -npm test # Run unit tests -npm run test:watch # Run tests in watch mode -npm run test:integ # Run integration tests -npm run test:tui # Run TUI integration tests (builds first) -npm run test:all # Run all tests (unit + integ) +npm test # Run unit tests +npm run test:watch # Run tests in watch mode +npm run test:integ # Run integration tests +npm run test:tui # Run TUI integration tests (builds first) +npm run test:browser # Run browser tests (requires AWS creds, uv, agentcore) +npm run test:all # Run all tests (unit + integ) ``` ## Test Organization @@ -347,6 +348,63 @@ it('provides diagnostics in LaunchError', async () => { }); ``` +## Browser Tests + +Browser tests use Playwright to test the web UI (agent inspector) served by `agentcore dev`. + +### Prerequisites + +- AWS credentials configured (`aws sts get-caller-identity` must succeed) +- `uv` on PATH +- Local build (`npm run build`) +- Playwright browsers installed: `npx playwright install chromium` + +### Running + +```bash +npm run test:browser +``` + +Test results and the HTML report are written to `browser-tests/test-results/` and `browser-tests/playwright-report/` +respectively. To view the report: + +```bash +npx playwright show-report browser-tests/playwright-report +``` + +By default, tests run against the `@aws/agent-inspector` package from npm (in `node_modules`). + +### Testing against a local agent-inspector build + +To test with a local checkout of the agent-inspector (e.g. when developing new UI features or adding test IDs): + +1. Clone `agent-inspector` as a sibling directory and build it +2. Run with `AGENT_INSPECTOR_PATH`: + +```bash +AGENT_INSPECTOR_PATH=../agent-inspector/dist-assets npm run test:browser +``` + +### Test structure + +``` +browser-tests/ +├── playwright.config.ts # Playwright configuration +├── global-setup.ts # Creates test project, starts agentcore dev +├── global-teardown.ts # Stops dev server, cleans up temp files +├── constants.ts # Shared constants (env file path) +├── fixtures.ts # Custom test fixtures (testEnv with port, project path) +└── tests/ # Test files + ├── chat-invocation.test.ts + ├── inspector-loads.test.ts + ├── resources.test.ts + ├── start-agent.test.ts + └── traces.test.ts +``` + +The global setup creates a temporary project via `agentcore create`, starts `agentcore dev`, and writes connection +details to an env file. Tests read the env file via the `testEnv` fixture. + ## Configuration Test configuration is in `vitest.config.ts` using Vitest projects: diff --git a/eslint.config.mjs b/eslint.config.mjs index 72990753f..5111978cb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -143,7 +143,7 @@ export default tseslint.config( prettier, // Relaxed rules for test files { - files: ['**/*.test.ts', '**/*.test.tsx', '**/test-utils/**', 'integ-tests/**'], + files: ['**/*.test.ts', '**/*.test.tsx', '**/test-utils/**', 'integ-tests/**', 'browser-tests/**'], rules: { 'partition/no-hardcoded-arn-partition': 'off', 'partition/no-hardcoded-endpoint-tld': 'off', @@ -154,6 +154,10 @@ export default tseslint.config( '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/prefer-nullish-coalescing': 'off', + '@typescript-eslint/prefer-regexp-exec': 'off', + 'no-empty-pattern': 'off', + 'no-empty': 'off', + 'react-hooks/rules-of-hooks': 'off', }, }, { diff --git a/package-lock.json b/package-lock.json index e7aada6c1..3abf38dc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "@aws-sdk/client-cognito-identity-provider": "^3.1018.0", "@eslint/js": "^9.39.2", "@modelcontextprotocol/sdk": "^1.0.0", + "@playwright/test": "^1.59.1", "@secretlint/secretlint-rule-preset-recommend": "^11.3.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/js-yaml": "^4.0.9", @@ -4228,6 +4229,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -13337,6 +13354,53 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/package.json b/package.json index 01a066d7e..569244245 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "test:e2e": "vitest run --project e2e", "test:update-snapshots": "vitest run --project unit --update", "test:tui": "npm run build:harness && vitest run --project tui", + "test:browser": "npx playwright test --config browser-tests/playwright.config.ts", "bundle": "node scripts/bundle.mjs" }, "dependencies": { @@ -111,6 +112,7 @@ "@aws-sdk/client-cognito-identity-provider": "^3.1018.0", "@eslint/js": "^9.39.2", "@modelcontextprotocol/sdk": "^1.0.0", + "@playwright/test": "^1.59.1", "@secretlint/secretlint-rule-preset-recommend": "^11.3.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/js-yaml": "^4.0.9", diff --git a/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts b/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts new file mode 100644 index 000000000..710281293 --- /dev/null +++ b/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts @@ -0,0 +1,62 @@ +import { resolveUIDistDir } from '../web-server.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:fs'); + +const existsSync = vi.mocked(fs.existsSync); + +describe('resolveUIDistDir', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.AGENT_INSPECTOR_PATH; + existsSync.mockReturnValue(false); + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('returns null when no candidate has index.html', () => { + expect(resolveUIDistDir()).toBeNull(); + }); + + it('returns AGENT_INSPECTOR_PATH when env var is set and dir has index.html', () => { + const customPath = '/custom/inspector/dist'; + process.env.AGENT_INSPECTOR_PATH = customPath; + + existsSync.mockImplementation(p => p === path.join(customPath, 'index.html')); + + expect(resolveUIDistDir()).toBe(customPath); + }); + + it('skips AGENT_INSPECTOR_PATH when env var is set but dir lacks index.html', () => { + process.env.AGENT_INSPECTOR_PATH = '/missing/inspector'; + existsSync.mockReturnValue(false); + + expect(resolveUIDistDir()).toBeNull(); + }); + + it('returns the first candidate that has index.html', () => { + existsSync.mockImplementation(p => { + return String(p).endsWith(path.join('agent-inspector', 'index.html')); + }); + + const result = resolveUIDistDir(); + expect(result).not.toBeNull(); + expect(result!).toMatch(/agent-inspector$/); + }); + + it('prefers AGENT_INSPECTOR_PATH over bundled candidates', () => { + const customPath = '/custom/path'; + process.env.AGENT_INSPECTOR_PATH = customPath; + + existsSync.mockReturnValue(true); + + expect(resolveUIDistDir()).toBe(customPath); + }); +}); diff --git a/src/cli/operations/dev/web-ui/handlers/start.ts b/src/cli/operations/dev/web-ui/handlers/start.ts index 7fea6b742..41857bda1 100644 --- a/src/cli/operations/dev/web-ui/handlers/start.ts +++ b/src/cli/operations/dev/web-ui/handlers/start.ts @@ -170,10 +170,9 @@ async function doStartAgent( return { success: false, name: agentName, port: 0, error: errorMsg }; } - ctx.runningAgents.set(agentName, { server: agentServer, port: agentPort, protocol: config.protocol }); - - // Wait for the server to actually accept connections before telling the - // frontend it's ready — otherwise immediate invocations get ECONNREFUSED. + // Wait for the server to accept connections before adding to runningAgents. + // runningAgents gates /api/status, so adding early lets the frontend send + // invocations before the server is ready. const ready = await waitForServerReady(agentPort); if (!ready) { const errorMsg = @@ -182,5 +181,6 @@ async function doStartAgent( return { success: false, name: agentName, port: 0, error: errorMsg }; } + ctx.runningAgents.set(agentName, { server: agentServer, port: agentPort, protocol: config.protocol }); return { success: true, name: agentName, port: agentPort }; } diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts index 8632fedb7..c3f9c6f36 100644 --- a/src/cli/operations/dev/web-ui/web-server.ts +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -32,16 +32,17 @@ const CSP_HEADER = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self' data:"; /** Resolve the frontend dist directory. Returns null if not found. */ -function resolveUIDistDir(): string | null { +export function resolveUIDistDir(): string | null { const thisDir = path.dirname(fileURLToPath(import.meta.url)); const candidates = [ + process.env.AGENT_INSPECTOR_PATH, // Bundled CLI: dist/cli/index.mjs → dist/agent-inspector/ path.resolve(thisDir, '..', 'agent-inspector'), // npm package: @aws/agent-inspector/dist-assets/ path.resolve(thisDir, '..', '..', '..', '..', '..', 'node_modules', '@aws', 'agent-inspector', 'dist-assets'), // Dev via tsx: src/cli/operations/dev/web-ui/ → src/assets/agent-inspector/ path.resolve(thisDir, '..', '..', '..', '..', 'assets', 'agent-inspector'), - ]; + ].filter((c): c is string => !!c); for (const dir of candidates) { if (fs.existsSync(path.join(dir, 'index.html'))) return dir; } diff --git a/tsconfig.json b/tsconfig.json index ea8523c03..eedb71730 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,7 @@ "src/**/*", "integ-tests/**/*", "e2e-tests/**/*", + "browser-tests/**/*", "scripts/**/*", "vitest.config.ts", "vitest.integ.config.ts",