diff --git a/e2e-tests/web-search-lifecycle.test.ts b/e2e-tests/web-search-lifecycle.test.ts new file mode 100644 index 000000000..5063d858b --- /dev/null +++ b/e2e-tests/web-search-lifecycle.test.ts @@ -0,0 +1,147 @@ +import { type RunResult, hasAwsCredentials, parseJsonOutput, prereqs } from '../src/test-utils/index.js'; +import { installCdkTarball, runAgentCoreCLI, teardownE2EProject, writeAwsTargets } from './e2e-helper.js'; +import { randomUUID } from 'node:crypto'; +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'; + +const hasAws = hasAwsCredentials(); +const canRun = prereqs.npm && prereqs.git && prereqs.uv && hasAws; + +describe.sequential('e2e: web-search gateway target lifecycle', () => { + const suffix = Date.now().toString().slice(-8); + const agentName = `E2eWs${suffix}`; + const gatewayName = 'WsGw'; + const targetName = 'ws1'; + const toolName = `${targetName}___WebSearch`; + + let testDir: string; + let projectPath: string; + let originalRegion: string | undefined; + let lifecycleFailed = false; + + beforeAll(async () => { + if (!canRun) return; + + // Web search is only available in us-east-1. Pin the region + // so deploy doesn't target a region where the connector doesn't exist + originalRegion = process.env.AWS_REGION; + process.env.AWS_REGION = 'us-east-1'; + + testDir = join(tmpdir(), `agentcore-e2e-web-search-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + const create = await runAgentCoreCLI( + ['create', '--name', agentName, '--no-agent', '--defaults', '--skip-git', '--skip-python-setup', '--json'], + testDir + ); + expect(create.exitCode, `Create failed: ${create.stderr}`).toBe(0); + projectPath = (parseJsonOutput(create.stdout) as { projectPath: string }).projectPath; + + await writeAwsTargets(projectPath); + installCdkTarball(projectPath); + }, 600_000); + + afterAll(async () => { + if (projectPath && hasAws) { + await teardownE2EProject(projectPath, agentName, 'Bedrock'); + } + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + if (originalRegion === undefined) delete process.env.AWS_REGION; + else process.env.AWS_REGION = originalRegion; + }, 600_000); + + const run = (args: string[]): Promise => runAgentCoreCLI(args, projectPath); + + // Each step short-circuits if a prior step in the lifecycle failed + const step = (label: string, fn: () => Promise, timeout: number) => + it.skipIf(!canRun)( + label, + async () => { + if (lifecycleFailed) { + throw new Error('Skipped: a prior step in the lifecycle failed.'); + } + try { + await fn(); + } catch (err) { + lifecycleFailed = true; + throw err; + } + }, + timeout + ); + + step( + 'adds a gateway and a web-search target', + async () => { + const gw = await run(['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json']); + expect(gw.exitCode, `Add gateway failed: ${gw.stderr}`).toBe(0); + + const target = await run(['add', 'web-search', '--gateway', gatewayName, '--name', targetName, '--json']); + expect(target.exitCode, `Add web-search failed: ${target.stdout}\n${target.stderr}`).toBe(0); + }, + 120_000 + ); + + step( + 'deploys the stack', + async () => { + const result = await run(['deploy', '--yes', '--json']); + expect(result.exitCode, `Deploy failed: ${result.stderr}`).toBe(0); + }, + 900_000 + ); + + step( + 'status shows the gateway as deployed', + async () => { + const result = await run(['status', '--json']); + expect(result.exitCode, `Status failed: ${result.stderr}`).toBe(0); + + const json = parseJsonOutput(result.stdout) as { + success: boolean; + resources: { resourceType: string; name: string; deploymentState: string }[]; + }; + expect(json.success).toBe(true); + + const gateway = json.resources.find(r => r.resourceType === 'gateway' && r.name === gatewayName); + expect(gateway, 'Gateway should appear in status').toBeDefined(); + expect(gateway?.deploymentState).toBe('deployed'); + }, + 120_000 + ); + + step( + 'call-tool returns search results', + async () => { + const result = await run([ + 'invoke', + 'call-tool', + '--gateway', + gatewayName, + '--tool', + toolName, + '--input', + '{"query":"AWS Lambda pricing"}', + '--json', + ]); + expect(result.exitCode, `call-tool failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(0); + + const payload = parseJsonOutput(result.stdout) as { success: boolean; response?: string }; + expect(payload.success, `call-tool result not successful. Raw stdout:\n${result.stdout}`).toBe(true); + expect(typeof payload.response, 'response should be a JSON envelope string').toBe('string'); + + const envelope = JSON.parse(payload.response!) as { + result?: { content?: { text?: string }[] }; + }; + const text = envelope.result?.content?.[0]?.text; + expect(text, `call-tool envelope missing result.content[0].text. Envelope:\n${payload.response}`).toBeTruthy(); + + const inner = JSON.parse(text!) as { results?: { url?: string }[] }; + expect(Array.isArray(inner.results), 'Results array should be present').toBe(true); + expect(inner.results!.length, 'Results array should be non-empty').toBeGreaterThan(0); + }, + 180_000 + ); +}); diff --git a/integ-tests/add-remove-web-search.test.ts b/integ-tests/add-remove-web-search.test.ts new file mode 100644 index 000000000..fa5d0ac98 --- /dev/null +++ b/integ-tests/add-remove-web-search.test.ts @@ -0,0 +1,284 @@ +import { createTestProject, runCLI } from '../src/test-utils/index.js'; +import type { TestProject } from '../src/test-utils/index.js'; +import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const telemetry = createTelemetryHelper(); + +interface WebSearchTarget { + name: string; + targetType: string; + excludeDomains?: string[]; +} + +interface Gateway { + name: string; + targets?: WebSearchTarget[]; +} + +async function readProjectConfig(projectPath: string) { + return JSON.parse(await readFile(join(projectPath, 'agentcore/agentcore.json'), 'utf-8')); +} + +async function findTarget( + projectPath: string, + gatewayName: string, + targetName: string +): Promise { + const config = await readProjectConfig(projectPath); + const gateway = (config.agentCoreGateways as Gateway[]).find(g => g.name === gatewayName); + return gateway?.targets?.find(t => t.name === targetName); +} + +describe('integration: add and remove web-search via gateway-target form', () => { + let project: TestProject; + const gatewayName = 'WsGateway'; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + const result = await runCLI( + ['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json'], + project.projectPath + ); + expect(result.exitCode).toBe(0); + }); + + afterAll(async () => { + await project.cleanup(); + telemetry.destroy(); + }); + + it('adds a web-search target with no excludeDomains', async () => { + const result = await runCLI( + ['add', 'gateway-target', '--type', 'web-search', '--gateway', gatewayName, '--name', 'ws-plain', '--json'], + project.projectPath, + { env: telemetry.env } + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + + const target = await findTarget(project.projectPath, gatewayName, 'ws-plain'); + expect(target).toBeTruthy(); + expect(target?.targetType).toBe('webSearch'); + expect(target?.excludeDomains).toBeUndefined(); + telemetry.assertMetricEmitted({ command: 'add.gateway-target', exit_reason: 'success' }); + }); + + it('adds a web-search target with --exclude-domains', async () => { + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--type', + 'web-search', + '--gateway', + gatewayName, + '--name', + 'ws-filtered', + '--exclude-domains', + 'foo.com,bar.net', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + + const target = await findTarget(project.projectPath, gatewayName, 'ws-filtered'); + expect(target?.excludeDomains).toEqual(['foo.com', 'bar.net']); + }); + + it('trims whitespace in --exclude-domains values', async () => { + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--type', + 'web-search', + '--gateway', + gatewayName, + '--name', + 'ws-trimmed', + '--exclude-domains', + 'a.com, b.com, c.com', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(0); + const target = await findTarget(project.projectPath, gatewayName, 'ws-trimmed'); + expect(target?.excludeDomains).toEqual(['a.com', 'b.com', 'c.com']); + }); + + it('rejects repeated --exclude-domains', async () => { + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--type', + 'web-search', + '--gateway', + gatewayName, + '--name', + 'ws-repeat', + '--exclude-domains', + 'foo.com', + '--exclude-domains', + 'bar.net', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain('--exclude-domains may only be specified once'); + const target = await findTarget(project.projectPath, gatewayName, 'ws-repeat'); + expect(target).toBeUndefined(); + }); + + it('removes a web-search target via remove gateway-target', async () => { + const result = await runCLI( + ['remove', 'gateway-target', '--name', 'ws-plain', '--yes', '--json'], + project.projectPath, + { env: telemetry.env } + ); + + expect(result.exitCode).toBe(0); + const target = await findTarget(project.projectPath, gatewayName, 'ws-plain'); + expect(target).toBeUndefined(); + telemetry.assertMetricEmitted({ command: 'remove.gateway-target', exit_reason: 'success' }); + }); + + it('rejects re-adding a target with the same name as an existing one', async () => { + const result = await runCLI( + ['add', 'gateway-target', '--type', 'web-search', '--gateway', gatewayName, '--name', 'ws-filtered', '--json'], + project.projectPath + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain('already exists'); + }); +}); + +describe('integration: add and remove web-search via top-level shortcut', () => { + let project: TestProject; + const gatewayName = 'WsShortcutGateway'; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + const result = await runCLI( + ['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json'], + project.projectPath + ); + expect(result.exitCode).toBe(0); + }); + + afterAll(async () => { + await project.cleanup(); + telemetry.destroy(); + }); + + it('add web-search produces the same spec shape as the long-form path', async () => { + const result = await runCLI( + ['add', 'web-search', '--gateway', gatewayName, '--name', 'ws1', '--json'], + project.projectPath, + { env: telemetry.env } + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const target = await findTarget(project.projectPath, gatewayName, 'ws1'); + expect(target?.targetType).toBe('webSearch'); + expect(target?.excludeDomains).toBeUndefined(); + telemetry.assertMetricEmitted({ command: 'add.web-search', exit_reason: 'success' }); + }); + + it('add web-search defaults --name to "web-search" when omitted', async () => { + const result = await runCLI(['add', 'web-search', '--gateway', gatewayName, '--json'], project.projectPath); + + expect(result.exitCode).toBe(0); + const target = await findTarget(project.projectPath, gatewayName, 'web-search'); + expect(target?.targetType).toBe('webSearch'); + }); + + it('rejects a second default-name add when "web-search" already exists', async () => { + const result = await runCLI(['add', 'web-search', '--gateway', gatewayName, '--json'], project.projectPath); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain('already exists'); + }); + + it('add web-search persists --exclude-domains', async () => { + const result = await runCLI( + [ + 'add', + 'web-search', + '--gateway', + gatewayName, + '--name', + 'ws-with-filter', + '--exclude-domains', + 'example.com,blocked.org', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(0); + const target = await findTarget(project.projectPath, gatewayName, 'ws-with-filter'); + expect(target?.excludeDomains).toEqual(['example.com', 'blocked.org']); + }); + + it('remove web-search --name removes the target', async () => { + const result = await runCLI(['remove', 'web-search', '--name', 'ws1', '--yes', '--json'], project.projectPath, { + env: telemetry.env, + }); + + expect(result.exitCode).toBe(0); + const target = await findTarget(project.projectPath, gatewayName, 'ws1'); + expect(target).toBeUndefined(); + telemetry.assertMetricEmitted({ command: 'remove.web-search', exit_reason: 'success' }); + }); + + it('remove web-search rejects an unknown target name', async () => { + const result = await runCLI(['remove', 'web-search', '--name', 'does-not-exist', '--json'], project.projectPath); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain('not found'); + }); + + it('remove web-search rejects a non-webSearch target name', async () => { + const addMcp = await runCLI( + [ + 'add', + 'gateway-target', + '--type', + 'mcp-server', + '--gateway', + gatewayName, + '--name', + 'mcp-tmp', + '--endpoint', + 'https://example.com/mcp', + '--json', + ], + project.projectPath + ); + expect(addMcp.exitCode).toBe(0); + + const result = await runCLI(['remove', 'web-search', '--name', 'mcp-tmp', '--json'], project.projectPath); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain('not webSearch'); + const stillThere = await findTarget(project.projectPath, gatewayName, 'mcp-tmp'); + expect(stillThere).toBeTruthy(); + }); + + it('remove web-search without --name errors', async () => { + const result = await runCLI(['remove', 'web-search', '--json'], project.projectPath); + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain('--name is required'); + }); +});