From 5b1c1f749f79173e17fbaeb0c45a6ac558184b88 Mon Sep 17 00:00:00 2001 From: notgitika Date: Sun, 15 Feb 2026 14:49:38 -0500 Subject: [PATCH 1/7] test: add unit tests across schema, lib, and cli modules --- src/cli/aws/__tests__/account.test.ts | 25 ++ src/cli/aws/__tests__/agentcore.test.ts | 99 +++++ src/cli/aws/agentcore.ts | 6 +- .../__tests__/bootstrap.test.ts | 18 + .../__tests__/logical-ids.test.ts | 46 ++ .../__tests__/outputs-extended.test.ts | 218 ++++++++++ .../__tests__/stack-discovery.test.ts | 279 ++++++++++++ .../generate/__tests__/schema-mapper.test.ts | 189 ++++++++ .../deploy/__tests__/preflight.test.ts | 51 +++ .../mcp/__tests__/create-mcp.test.ts | 42 ++ .../hooks/__tests__/useListNavigation.test.ts | 70 +++ .../tui/hooks/__tests__/useTextInput.test.ts | 74 ++++ src/cli/tui/hooks/useListNavigation.ts | 42 +- src/cli/tui/hooks/useTextInput.ts | 4 +- src/cli/tui/utils/__tests__/commands.test.ts | 84 ++++ src/cli/tui/utils/__tests__/diff.test.ts | 104 +++++ src/cli/tui/utils/__tests__/gradient.test.ts | 41 ++ src/cli/tui/utils/__tests__/naming.test.ts | 36 ++ src/cli/tui/utils/__tests__/process.test.ts | 70 ++- src/cli/tui/utils/__tests__/timing.test.ts | 35 ++ src/lib/errors/__tests__/config.test.ts | 149 +++++++ src/lib/packaging/__tests__/errors.test.ts | 60 +++ src/lib/packaging/__tests__/helpers.test.ts | 357 ++++++++++++++++ src/lib/packaging/__tests__/node.test.ts | 21 + src/lib/packaging/__tests__/python.test.ts | 43 ++ src/lib/packaging/__tests__/uv.test.ts | 64 +++ src/lib/packaging/node.ts | 2 +- src/lib/packaging/python.ts | 4 +- src/lib/utils/__tests__/aws-account.test.ts | 43 ++ src/lib/utils/__tests__/credentials.test.ts | 186 ++++++++ src/lib/utils/__tests__/env.test.ts | 155 +++++++ src/lib/utils/__tests__/platform.test.ts | 50 +++ src/lib/utils/__tests__/subprocess.test.ts | 98 +++++ src/lib/utils/__tests__/zod.test.ts | 81 ++++ src/schema/__tests__/constants.test.ts | 136 ++++++ .../schemas/__tests__/agent-env.test.ts | 261 +++++++++++ .../__tests__/agentcore-project.test.ts | 397 +++++++++++++++++ .../schemas/__tests__/aws-targets.test.ts | 158 +++++++ .../schemas/__tests__/deployed-state.test.ts | 238 +++++++++++ src/schema/schemas/__tests__/mcp-defs.test.ts | 161 +++++++ src/schema/schemas/__tests__/mcp.test.ts | 404 ++++++++++++++++++ 41 files changed, 4555 insertions(+), 46 deletions(-) create mode 100644 src/cli/aws/__tests__/account.test.ts create mode 100644 src/cli/aws/__tests__/agentcore.test.ts create mode 100644 src/cli/cloudformation/__tests__/bootstrap.test.ts create mode 100644 src/cli/cloudformation/__tests__/logical-ids.test.ts create mode 100644 src/cli/cloudformation/__tests__/outputs-extended.test.ts create mode 100644 src/cli/cloudformation/__tests__/stack-discovery.test.ts create mode 100644 src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts create mode 100644 src/cli/operations/deploy/__tests__/preflight.test.ts create mode 100644 src/cli/operations/mcp/__tests__/create-mcp.test.ts create mode 100644 src/cli/tui/hooks/__tests__/useListNavigation.test.ts create mode 100644 src/cli/tui/hooks/__tests__/useTextInput.test.ts create mode 100644 src/cli/tui/utils/__tests__/commands.test.ts create mode 100644 src/cli/tui/utils/__tests__/diff.test.ts create mode 100644 src/cli/tui/utils/__tests__/gradient.test.ts create mode 100644 src/cli/tui/utils/__tests__/naming.test.ts create mode 100644 src/cli/tui/utils/__tests__/timing.test.ts create mode 100644 src/lib/errors/__tests__/config.test.ts create mode 100644 src/lib/packaging/__tests__/errors.test.ts create mode 100644 src/lib/packaging/__tests__/helpers.test.ts create mode 100644 src/lib/packaging/__tests__/node.test.ts create mode 100644 src/lib/packaging/__tests__/python.test.ts create mode 100644 src/lib/packaging/__tests__/uv.test.ts create mode 100644 src/lib/utils/__tests__/aws-account.test.ts create mode 100644 src/lib/utils/__tests__/credentials.test.ts create mode 100644 src/lib/utils/__tests__/env.test.ts create mode 100644 src/lib/utils/__tests__/platform.test.ts create mode 100644 src/lib/utils/__tests__/subprocess.test.ts create mode 100644 src/lib/utils/__tests__/zod.test.ts create mode 100644 src/schema/__tests__/constants.test.ts create mode 100644 src/schema/schemas/__tests__/agent-env.test.ts create mode 100644 src/schema/schemas/__tests__/agentcore-project.test.ts create mode 100644 src/schema/schemas/__tests__/aws-targets.test.ts create mode 100644 src/schema/schemas/__tests__/deployed-state.test.ts create mode 100644 src/schema/schemas/__tests__/mcp-defs.test.ts create mode 100644 src/schema/schemas/__tests__/mcp.test.ts diff --git a/src/cli/aws/__tests__/account.test.ts b/src/cli/aws/__tests__/account.test.ts new file mode 100644 index 00000000..2c7c56cb --- /dev/null +++ b/src/cli/aws/__tests__/account.test.ts @@ -0,0 +1,25 @@ +import { AwsCredentialsError } from '../account.js'; +import { describe, expect, it } from 'vitest'; + +describe('AwsCredentialsError', () => { + it('uses short message as default message', () => { + const err = new AwsCredentialsError('Short msg'); + expect(err.message).toBe('Short msg'); + expect(err.shortMessage).toBe('Short msg'); + }); + + it('uses detailed message when provided', () => { + const err = new AwsCredentialsError('Short msg', 'Detailed explanation'); + expect(err.message).toBe('Detailed explanation'); + expect(err.shortMessage).toBe('Short msg'); + }); + + it('has correct name', () => { + const err = new AwsCredentialsError('test'); + expect(err.name).toBe('AwsCredentialsError'); + }); + + it('is an instance of Error', () => { + expect(new AwsCredentialsError('test')).toBeInstanceOf(Error); + }); +}); diff --git a/src/cli/aws/__tests__/agentcore.test.ts b/src/cli/aws/__tests__/agentcore.test.ts new file mode 100644 index 00000000..494c64d2 --- /dev/null +++ b/src/cli/aws/__tests__/agentcore.test.ts @@ -0,0 +1,99 @@ +import { extractResult, parseSSE, parseSSELine } from '../agentcore.js'; +import { describe, expect, it } from 'vitest'; + +describe('parseSSELine', () => { + it('returns null content for non-data lines', () => { + expect(parseSSELine('event: message')).toEqual({ content: null, error: null }); + expect(parseSSELine('')).toEqual({ content: null, error: null }); + expect(parseSSELine('id: 123')).toEqual({ content: null, error: null }); + }); + + it('parses JSON string data', () => { + const result = parseSSELine('data: "Hello world"'); + expect(result.content).toBe('Hello world'); + expect(result.error).toBeNull(); + }); + + it('returns raw content for non-JSON data', () => { + const result = parseSSELine('data: plain text here'); + expect(result.content).toBe('plain text here'); + expect(result.error).toBeNull(); + }); + + it('detects error objects', () => { + const result = parseSSELine('data: {"error": "Something went wrong"}'); + expect(result.content).toBeNull(); + expect(result.error).toBe('Something went wrong'); + }); + + it('returns null for non-string non-error JSON objects', () => { + const result = parseSSELine('data: {"key": "value"}'); + expect(result.content).toBeNull(); + expect(result.error).toBeNull(); + }); + + it('handles empty data field', () => { + const result = parseSSELine('data: '); + expect(result.content).toBe(''); + expect(result.error).toBeNull(); + }); +}); + +describe('parseSSE', () => { + it('combines multiple data lines into single string', () => { + const text = 'data: "Hello "\ndata: "World"'; + expect(parseSSE(text)).toBe('Hello World'); + }); + + it('ignores non-data lines', () => { + const text = 'event: message\ndata: "content"\nid: 1'; + expect(parseSSE(text)).toBe('content'); + }); + + it('returns empty string for no data lines', () => { + expect(parseSSE('event: ping\n')).toBe(''); + }); + + it('stops on error and returns error message', () => { + const text = 'data: "part1"\ndata: {"error": "fail"}\ndata: "part2"'; + expect(parseSSE(text)).toBe('Error: fail'); + }); + + it('handles single data line', () => { + expect(parseSSE('data: "only line"')).toBe('only line'); + }); + + it('handles raw non-JSON data lines', () => { + const text = 'data: hello\ndata: world'; + expect(parseSSE(text)).toBe('helloworld'); + }); +}); + +describe('extractResult', () => { + it('extracts string result from JSON object', () => { + expect(extractResult('{"result": "answer"}')).toBe('answer'); + }); + + it('stringifies non-string result', () => { + const result = extractResult('{"result": {"key": "val"}}'); + expect(result).toContain('key'); + expect(result).toContain('val'); + }); + + it('returns plain string from JSON string', () => { + expect(extractResult('"plain string"')).toBe('plain string'); + }); + + it('stringifies JSON object without result field', () => { + const result = extractResult('{"data": 42}'); + expect(result).toContain('42'); + }); + + it('returns raw text for non-JSON input', () => { + expect(extractResult('not json at all')).toBe('not json at all'); + }); + + it('handles empty string', () => { + expect(extractResult('')).toBe(''); + }); +}); diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index 92e94c96..a25b09ca 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -44,7 +44,7 @@ export interface StopRuntimeSessionResult { * Parse a single SSE data line and extract the content. * Returns null if the line is not a data line or contains an error. */ -function parseSSELine(line: string): { content: string | null; error: string | null } { +export function parseSSELine(line: string): { content: string | null; error: string | null } { if (!line.startsWith('data: ')) { return { content: null, error: null }; } @@ -65,7 +65,7 @@ function parseSSELine(line: string): { content: string | null; error: string | n /** * Parse SSE response into combined text. */ -function parseSSE(text: string): string { +export function parseSSE(text: string): string { const parts: string[] = []; for (const line of text.split('\n')) { const { content, error } = parseSSELine(line); @@ -83,7 +83,7 @@ function parseSSE(text: string): string { * Extract result from a JSON response object. * Handles both {"result": "..."} and plain text responses. */ -function extractResult(text: string): string { +export function extractResult(text: string): string { try { const parsed: unknown = JSON.parse(text); if (parsed && typeof parsed === 'object' && 'result' in parsed) { diff --git a/src/cli/cloudformation/__tests__/bootstrap.test.ts b/src/cli/cloudformation/__tests__/bootstrap.test.ts new file mode 100644 index 00000000..48c55e1a --- /dev/null +++ b/src/cli/cloudformation/__tests__/bootstrap.test.ts @@ -0,0 +1,18 @@ +import { CDK_TOOLKIT_STACK_NAME, formatCdkEnvironment } from '../bootstrap.js'; +import { describe, expect, it } from 'vitest'; + +describe('formatCdkEnvironment', () => { + it('formats account and region into CDK environment string', () => { + expect(formatCdkEnvironment('123456789012', 'us-east-1')).toBe('aws://123456789012/us-east-1'); + }); + + it('works with different regions', () => { + expect(formatCdkEnvironment('111222333444', 'eu-west-1')).toBe('aws://111222333444/eu-west-1'); + }); +}); + +describe('CDK_TOOLKIT_STACK_NAME', () => { + it('is CDKToolkit', () => { + expect(CDK_TOOLKIT_STACK_NAME).toBe('CDKToolkit'); + }); +}); diff --git a/src/cli/cloudformation/__tests__/logical-ids.test.ts b/src/cli/cloudformation/__tests__/logical-ids.test.ts new file mode 100644 index 00000000..3b0a6f4a --- /dev/null +++ b/src/cli/cloudformation/__tests__/logical-ids.test.ts @@ -0,0 +1,46 @@ +import { toPascalId } from '../logical-ids.js'; +import { describe, expect, it } from 'vitest'; + +describe('toPascalId', () => { + it('converts simple name to PascalCase', () => { + expect(toPascalId('myAgent')).toBe('MyAgent'); + }); + + it('converts hyphenated name', () => { + expect(toPascalId('my-gateway')).toBe('MyGateway'); + }); + + it('converts underscored name', () => { + expect(toPascalId('my_tool')).toBe('MyTool'); + }); + + it('joins multiple parts', () => { + expect(toPascalId('agent', 'runtime')).toBe('AgentRuntime'); + }); + + it('handles already PascalCase input', () => { + expect(toPascalId('MyAgent')).toBe('MyAgent'); + }); + + it('handles mixed casing with delimiters', () => { + expect(toPascalId('my-cool_agent')).toBe('MyCoolAgent'); + }); + + it('joins multiple parts with delimiters', () => { + expect(toPascalId('my-gateway', 'lambda-func')).toBe('MyGatewayLambdaFunc'); + }); + + it('throws for no parts', () => { + expect(() => toPascalId()).toThrow('at least one part'); + }); + + it('throws for result with invalid characters', () => { + // A name that after conversion contains invalid CloudFormation logical ID chars + expect(() => toPascalId('123invalid')).toThrow('Invalid CloudFormation logical ID'); + }); + + it('throws for empty string part', () => { + // Empty string produces empty logical ID + expect(() => toPascalId('')).toThrow(); + }); +}); diff --git a/src/cli/cloudformation/__tests__/outputs-extended.test.ts b/src/cli/cloudformation/__tests__/outputs-extended.test.ts new file mode 100644 index 00000000..d6a64226 --- /dev/null +++ b/src/cli/cloudformation/__tests__/outputs-extended.test.ts @@ -0,0 +1,218 @@ +import { buildDeployedState, parseAgentOutputs } from '../outputs.js'; +import type { StackOutputs } from '../outputs.js'; +import { describe, expect, it } from 'vitest'; + +describe('parseAgentOutputs', () => { + describe('single agent parsing', () => { + it('parses required agent outputs (runtimeId, runtimeArn, roleArn)', () => { + const outputs: StackOutputs = { + ApplicationAgentMyAgentRuntimeIdOutputABC123: 'rt-12345', + ApplicationAgentMyAgentRuntimeArnOutputDEF456: 'arn:aws:bedrock:us-east-1:123:agent-runtime/rt-12345', + ApplicationAgentMyAgentRoleArnOutputGHI789: 'arn:aws:iam::123:role/MyAgentRole', + }; + + const result = parseAgentOutputs(outputs, ['MyAgent'], 'TestStack'); + expect(result.MyAgent).toBeDefined(); + expect(result.MyAgent!.runtimeId).toBe('rt-12345'); + expect(result.MyAgent!.runtimeArn).toBe('arn:aws:bedrock:us-east-1:123:agent-runtime/rt-12345'); + expect(result.MyAgent!.roleArn).toBe('arn:aws:iam::123:role/MyAgentRole'); + }); + + it('parses optional memoryIds output (comma-separated)', () => { + const outputs: StackOutputs = { + ApplicationAgentMyAgentRuntimeIdOutputABC: 'rt-1', + ApplicationAgentMyAgentRuntimeArnOutputDEF: 'arn:rt-1', + ApplicationAgentMyAgentRoleArnOutputGHI: 'arn:role', + ApplicationAgentMyAgentMemoryIdsOutputJKL: 'mem-1,mem-2,mem-3', + }; + + const result = parseAgentOutputs(outputs, ['MyAgent'], 'TestStack'); + expect(result.MyAgent!.memoryIds).toEqual(['mem-1', 'mem-2', 'mem-3']); + }); + + it('parses optional browserId output', () => { + const outputs: StackOutputs = { + ApplicationAgentMyAgentRuntimeIdOutputA: 'rt-1', + ApplicationAgentMyAgentRuntimeArnOutputB: 'arn:rt-1', + ApplicationAgentMyAgentRoleArnOutputC: 'arn:role', + ApplicationAgentMyAgentBrowserIdOutputD: 'browser-abc', + }; + + const result = parseAgentOutputs(outputs, ['MyAgent'], 'TestStack'); + expect(result.MyAgent!.browserId).toBe('browser-abc'); + }); + + it('parses optional codeInterpreterId output', () => { + const outputs: StackOutputs = { + ApplicationAgentMyAgentRuntimeIdOutputA: 'rt-1', + ApplicationAgentMyAgentRuntimeArnOutputB: 'arn:rt-1', + ApplicationAgentMyAgentRoleArnOutputC: 'arn:role', + ApplicationAgentMyAgentCodeInterpreterIdOutputD: 'ci-xyz', + }; + + const result = parseAgentOutputs(outputs, ['MyAgent'], 'TestStack'); + expect(result.MyAgent!.codeInterpreterId).toBe('ci-xyz'); + }); + }); + + describe('multiple agents', () => { + it('parses outputs for multiple agents', () => { + const outputs: StackOutputs = { + ApplicationAgentAgent1RuntimeIdOutputA: 'rt-1', + ApplicationAgentAgent1RuntimeArnOutputB: 'arn:rt-1', + ApplicationAgentAgent1RoleArnOutputC: 'arn:role-1', + ApplicationAgentAgent2RuntimeIdOutputD: 'rt-2', + ApplicationAgentAgent2RuntimeArnOutputE: 'arn:rt-2', + ApplicationAgentAgent2RoleArnOutputF: 'arn:role-2', + }; + + const result = parseAgentOutputs(outputs, ['Agent1', 'Agent2'], 'TestStack'); + expect(Object.keys(result)).toHaveLength(2); + expect(result.Agent1!.runtimeId).toBe('rt-1'); + expect(result.Agent2!.runtimeId).toBe('rt-2'); + }); + }); + + describe('PascalCase agent name handling', () => { + it('maps PascalCase output keys back to original agent names', () => { + // Agent name "my_agent" becomes "MyAgent" in PascalCase logical IDs + const outputs: StackOutputs = { + ApplicationAgentMyAgentRuntimeIdOutputA: 'rt-1', + ApplicationAgentMyAgentRuntimeArnOutputB: 'arn:rt-1', + ApplicationAgentMyAgentRoleArnOutputC: 'arn:role', + }; + + const result = parseAgentOutputs(outputs, ['my_agent'], 'TestStack'); + // Should map back to original name + expect(result.my_agent).toBeDefined(); + expect(result.my_agent!.runtimeId).toBe('rt-1'); + }); + }); + + describe('incomplete agent outputs', () => { + it('skips agents with missing required fields', () => { + const outputs: StackOutputs = { + // Agent1 has all required fields + ApplicationAgentAgent1RuntimeIdOutputA: 'rt-1', + ApplicationAgentAgent1RuntimeArnOutputB: 'arn:rt-1', + ApplicationAgentAgent1RoleArnOutputC: 'arn:role-1', + // Agent2 is missing roleArn + ApplicationAgentAgent2RuntimeIdOutputD: 'rt-2', + ApplicationAgentAgent2RuntimeArnOutputE: 'arn:rt-2', + }; + + const result = parseAgentOutputs(outputs, ['Agent1', 'Agent2'], 'TestStack'); + expect(result.Agent1).toBeDefined(); + expect(result.Agent2).toBeUndefined(); + }); + + it('skips agents with only runtimeId', () => { + const outputs: StackOutputs = { + ApplicationAgentPartialRuntimeIdOutputA: 'rt-1', + }; + + const result = parseAgentOutputs(outputs, ['Partial'], 'TestStack'); + expect(result.Partial).toBeUndefined(); + }); + }); + + describe('non-agent outputs', () => { + it('ignores outputs that do not match the agent pattern', () => { + const outputs: StackOutputs = { + SomeRandomOutput: 'value', + BucketNameOutput: 'my-bucket', + ApplicationAgentMyAgentRuntimeIdOutputA: 'rt-1', + ApplicationAgentMyAgentRuntimeArnOutputB: 'arn:rt-1', + ApplicationAgentMyAgentRoleArnOutputC: 'arn:role', + }; + + const result = parseAgentOutputs(outputs, ['MyAgent'], 'TestStack'); + expect(Object.keys(result)).toHaveLength(1); + expect(result.MyAgent).toBeDefined(); + }); + + it('returns empty object for no matching outputs', () => { + const outputs: StackOutputs = { + UnrelatedOutput: 'value', + }; + + const result = parseAgentOutputs(outputs, ['MyAgent'], 'TestStack'); + expect(result).toEqual({}); + }); + + it('returns empty object for empty outputs', () => { + const result = parseAgentOutputs({}, ['MyAgent'], 'TestStack'); + expect(result).toEqual({}); + }); + }); +}); + +describe('buildDeployedState', () => { + it('builds state for a single target', () => { + const agents = { + MyAgent: { + runtimeId: 'rt-123', + runtimeArn: 'arn:rt-123', + roleArn: 'arn:role', + }, + }; + + const state = buildDeployedState('default', 'MyStack', agents); + expect(state.targets.default).toBeDefined(); + expect(state.targets.default!.resources?.agents).toEqual(agents); + expect(state.targets.default!.resources?.stackName).toBe('MyStack'); + }); + + it('merges with existing state for different targets', () => { + const existing = { + targets: { + prod: { + resources: { + agents: { + ProdAgent: { runtimeId: 'rt-p', runtimeArn: 'arn:rt-p', roleArn: 'arn:role-p' }, + }, + stackName: 'ProdStack', + }, + }, + }, + }; + + const devAgents = { + DevAgent: { runtimeId: 'rt-d', runtimeArn: 'arn:rt-d', roleArn: 'arn:role-d' }, + }; + + const state = buildDeployedState('dev', 'DevStack', devAgents, existing); + expect(state.targets.prod).toBeDefined(); + expect(state.targets.dev).toBeDefined(); + expect(state.targets.prod!.resources?.stackName).toBe('ProdStack'); + expect(state.targets.dev!.resources?.stackName).toBe('DevStack'); + }); + + it('overwrites existing target when same name is used', () => { + const existing = { + targets: { + default: { + resources: { agents: {}, stackName: 'OldStack' }, + }, + }, + }; + + const state = buildDeployedState('default', 'NewStack', {}, existing); + expect(state.targets.default!.resources?.stackName).toBe('NewStack'); + }); + + it('includes identityKmsKeyArn when provided', () => { + const state = buildDeployedState('default', 'Stack', {}, undefined, 'arn:aws:kms:key'); + expect(state.targets.default!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:key'); + }); + + it('omits identityKmsKeyArn when undefined', () => { + const state = buildDeployedState('default', 'Stack', {}); + expect(state.targets.default!.resources?.identityKmsKeyArn).toBeUndefined(); + }); + + it('handles empty agents record', () => { + const state = buildDeployedState('default', 'Stack', {}); + expect(state.targets.default!.resources?.agents).toEqual({}); + }); +}); diff --git a/src/cli/cloudformation/__tests__/stack-discovery.test.ts b/src/cli/cloudformation/__tests__/stack-discovery.test.ts new file mode 100644 index 00000000..7774ac7a --- /dev/null +++ b/src/cli/cloudformation/__tests__/stack-discovery.test.ts @@ -0,0 +1,279 @@ +// Import after mocks are set up +import { discoverStacksByProject, findStack, getStackName } from '../stack-discovery.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Use vi.hoisted so the mock is available when vi.mock factory runs (hoisted above imports) +const { mockSend } = vi.hoisted(() => ({ + mockSend: vi.fn(), +})); + +// Mock the AWS SDK client - use a class so `new` works +vi.mock('@aws-sdk/client-resource-groups-tagging-api', () => { + return { + ResourceGroupsTaggingAPIClient: class { + send = mockSend; + }, + GetResourcesCommand: class { + constructor(public input: unknown) {} + }, + }; +}); + +// Mock the credential provider +vi.mock('../../aws', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({}), +})); + +describe('discoverStacksByProject', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns stacks matching project name', async () => { + mockSend.mockResolvedValue({ + ResourceTagMappingList: [ + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123456789012:stack/MyProject-default/guid-123', + Tags: [ + { Key: 'agentcore:project-name', Value: 'MyProject' }, + { Key: 'agentcore:target-name', Value: 'default' }, + ], + }, + ], + PaginationToken: undefined, + }); + + const stacks = await discoverStacksByProject('us-east-1', 'MyProject'); + expect(stacks).toHaveLength(1); + expect(stacks[0]!.stackName).toBe('MyProject-default'); + expect(stacks[0]!.targetName).toBe('default'); + expect(stacks[0]!.stackArn).toContain('arn:aws:cloudformation'); + }); + + it('returns multiple stacks for different targets', async () => { + mockSend.mockResolvedValue({ + ResourceTagMappingList: [ + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stack/Proj-dev/guid1', + Tags: [ + { Key: 'agentcore:project-name', Value: 'Proj' }, + { Key: 'agentcore:target-name', Value: 'dev' }, + ], + }, + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stack/Proj-prod/guid2', + Tags: [ + { Key: 'agentcore:project-name', Value: 'Proj' }, + { Key: 'agentcore:target-name', Value: 'prod' }, + ], + }, + ], + PaginationToken: undefined, + }); + + const stacks = await discoverStacksByProject('us-east-1', 'Proj'); + expect(stacks).toHaveLength(2); + expect(stacks.map(s => s.targetName)).toEqual(['dev', 'prod']); + }); + + it('returns empty array when no stacks found', async () => { + mockSend.mockResolvedValue({ + ResourceTagMappingList: [], + PaginationToken: undefined, + }); + + const stacks = await discoverStacksByProject('us-east-1', 'NonExistent'); + expect(stacks).toEqual([]); + }); + + it('defaults target name to "default" when tag not present', async () => { + mockSend.mockResolvedValue({ + ResourceTagMappingList: [ + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stack/MyStack/guid', + Tags: [{ Key: 'agentcore:project-name', Value: 'MyProject' }], + }, + ], + PaginationToken: undefined, + }); + + const stacks = await discoverStacksByProject('us-east-1', 'MyProject'); + expect(stacks[0]!.targetName).toBe('default'); + }); + + it('skips resources without ResourceARN', async () => { + mockSend.mockResolvedValue({ + ResourceTagMappingList: [ + { ResourceARN: undefined, Tags: [] }, + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stack/Valid/guid', + Tags: [{ Key: 'agentcore:project-name', Value: 'P' }], + }, + ], + PaginationToken: undefined, + }); + + const stacks = await discoverStacksByProject('us-east-1', 'P'); + expect(stacks).toHaveLength(1); + }); + + it('skips non-stack ARNs (e.g. stackset)', async () => { + mockSend.mockResolvedValue({ + ResourceTagMappingList: [ + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stackset/MyStackSet:guid', + Tags: [{ Key: 'agentcore:project-name', Value: 'P' }], + }, + ], + PaginationToken: undefined, + }); + + const stacks = await discoverStacksByProject('us-east-1', 'P'); + expect(stacks).toEqual([]); + }); + + it('handles pagination across multiple pages', async () => { + mockSend + .mockResolvedValueOnce({ + ResourceTagMappingList: [ + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stack/Stack1/guid1', + Tags: [ + { Key: 'agentcore:project-name', Value: 'P' }, + { Key: 'agentcore:target-name', Value: 'dev' }, + ], + }, + ], + PaginationToken: 'next-page-token', + }) + .mockResolvedValueOnce({ + ResourceTagMappingList: [ + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stack/Stack2/guid2', + Tags: [ + { Key: 'agentcore:project-name', Value: 'P' }, + { Key: 'agentcore:target-name', Value: 'prod' }, + ], + }, + ], + PaginationToken: undefined, + }); + + const stacks = await discoverStacksByProject('us-east-1', 'P'); + expect(stacks).toHaveLength(2); + expect(mockSend).toHaveBeenCalledTimes(2); + }); + + it('handles null ResourceTagMappingList', async () => { + mockSend.mockResolvedValue({ + ResourceTagMappingList: null, + PaginationToken: undefined, + }); + + const stacks = await discoverStacksByProject('us-east-1', 'P'); + expect(stacks).toEqual([]); + }); +}); + +describe('findStack', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns matching stack for target', async () => { + mockSend.mockResolvedValue({ + ResourceTagMappingList: [ + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stack/Stack-dev/guid1', + Tags: [ + { Key: 'agentcore:project-name', Value: 'Proj' }, + { Key: 'agentcore:target-name', Value: 'dev' }, + ], + }, + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stack/Stack-prod/guid2', + Tags: [ + { Key: 'agentcore:project-name', Value: 'Proj' }, + { Key: 'agentcore:target-name', Value: 'prod' }, + ], + }, + ], + PaginationToken: undefined, + }); + + const stack = await findStack('us-east-1', 'Proj', 'prod'); + expect(stack).not.toBeNull(); + expect(stack!.stackName).toBe('Stack-prod'); + expect(stack!.targetName).toBe('prod'); + }); + + it('returns null when target not found', async () => { + mockSend.mockResolvedValue({ + ResourceTagMappingList: [ + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stack/Stack-dev/guid', + Tags: [ + { Key: 'agentcore:project-name', Value: 'Proj' }, + { Key: 'agentcore:target-name', Value: 'dev' }, + ], + }, + ], + PaginationToken: undefined, + }); + + const stack = await findStack('us-east-1', 'Proj', 'staging'); + expect(stack).toBeNull(); + }); + + it('defaults to "default" target', async () => { + mockSend.mockResolvedValue({ + ResourceTagMappingList: [ + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stack/Stack/guid', + Tags: [ + { Key: 'agentcore:project-name', Value: 'Proj' }, + { Key: 'agentcore:target-name', Value: 'default' }, + ], + }, + ], + PaginationToken: undefined, + }); + + const stack = await findStack('us-east-1', 'Proj'); + expect(stack!.targetName).toBe('default'); + }); +}); + +describe('getStackName', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns stack name for found stack', async () => { + mockSend.mockResolvedValue({ + ResourceTagMappingList: [ + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stack/MyStack/guid', + Tags: [ + { Key: 'agentcore:project-name', Value: 'Proj' }, + { Key: 'agentcore:target-name', Value: 'default' }, + ], + }, + ], + PaginationToken: undefined, + }); + + const name = await getStackName('us-east-1', 'Proj'); + expect(name).toBe('MyStack'); + }); + + it('returns null when no stack found', async () => { + mockSend.mockResolvedValue({ + ResourceTagMappingList: [], + PaginationToken: undefined, + }); + + const name = await getStackName('us-east-1', 'NonExistent'); + expect(name).toBeNull(); + }); +}); diff --git a/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts new file mode 100644 index 00000000..0530d7d4 --- /dev/null +++ b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts @@ -0,0 +1,189 @@ +import type { GenerateConfig } from '../../../../tui/screens/generate/types.js'; +import { + mapGenerateConfigToAgent, + mapGenerateConfigToRenderConfig, + mapGenerateConfigToResources, + mapGenerateInputToMemories, + mapModelProviderToCredentials, + mapModelProviderToIdentityProviders, +} from '../schema-mapper.js'; +import { describe, expect, it } from 'vitest'; + +const baseConfig: GenerateConfig = { + projectName: 'TestProject', + sdk: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + language: 'Python', +}; + +describe('mapGenerateInputToMemories', () => { + it('returns empty array for "none"', () => { + expect(mapGenerateInputToMemories('none', 'Proj')).toEqual([]); + }); + + it('returns memory with no strategies for "shortTerm"', () => { + const result = mapGenerateInputToMemories('shortTerm', 'Proj'); + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe('AgentCoreMemory'); + expect(result[0]!.name).toBe('ProjMemory'); + expect(result[0]!.eventExpiryDuration).toBe(30); + expect(result[0]!.strategies).toEqual([]); + }); + + it('returns memory with three strategies for "longAndShortTerm"', () => { + const result = mapGenerateInputToMemories('longAndShortTerm', 'Proj'); + expect(result).toHaveLength(1); + const strategies = result[0]!.strategies; + expect(strategies).toHaveLength(3); + const types = strategies.map(s => s.type); + expect(types).toContain('SEMANTIC'); + expect(types).toContain('USER_PREFERENCE'); + expect(types).toContain('SUMMARIZATION'); + }); + + it('includes default namespaces for strategies', () => { + const result = mapGenerateInputToMemories('longAndShortTerm', 'Proj'); + const semantic = result[0]!.strategies.find(s => s.type === 'SEMANTIC'); + expect(semantic?.namespaces).toEqual(['/users/{actorId}/facts']); + }); + + it('uses project name in memory name', () => { + const result = mapGenerateInputToMemories('shortTerm', 'MyCustomProject'); + expect(result[0]!.name).toBe('MyCustomProjectMemory'); + }); +}); + +describe('mapModelProviderToCredentials', () => { + it('returns empty array for Bedrock', () => { + expect(mapModelProviderToCredentials('Bedrock', 'Proj')).toEqual([]); + }); + + it('returns credential for Anthropic', () => { + const result = mapModelProviderToCredentials('Anthropic', 'Proj'); + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe('ApiKeyCredentialProvider'); + expect(result[0]!.name).toBe('ProjAnthropic'); + }); + + it('returns credential for OpenAI', () => { + const result = mapModelProviderToCredentials('OpenAI', 'Proj'); + expect(result[0]!.name).toBe('ProjOpenAI'); + }); + + it('returns credential for Gemini', () => { + const result = mapModelProviderToCredentials('Gemini', 'Proj'); + expect(result[0]!.name).toBe('ProjGemini'); + }); +}); + +describe('mapGenerateConfigToAgent', () => { + it('creates AgentCoreRuntime agent spec', () => { + const result = mapGenerateConfigToAgent(baseConfig); + expect(result.type).toBe('AgentCoreRuntime'); + expect(result.name).toBe('TestProject'); + expect(result.build).toBe('CodeZip'); + expect(result.entrypoint).toBe('main.py'); + expect(result.runtimeVersion).toBe('PYTHON_3_12'); + expect(result.networkMode).toBe('PUBLIC'); + }); + + it('uses projectName for codeLocation path', () => { + const result = mapGenerateConfigToAgent(baseConfig); + expect(result.codeLocation).toBe('app/TestProject/'); + }); +}); + +describe('mapGenerateConfigToResources', () => { + it('returns agent, empty memories and credentials for Bedrock + no memory', () => { + const result = mapGenerateConfigToResources(baseConfig); + expect(result.agent.name).toBe('TestProject'); + expect(result.memories).toEqual([]); + expect(result.credentials).toEqual([]); + }); + + it('includes memory when memory is selected', () => { + const config: GenerateConfig = { ...baseConfig, memory: 'shortTerm' }; + const result = mapGenerateConfigToResources(config); + expect(result.memories).toHaveLength(1); + }); + + it('includes credential when non-Bedrock provider is selected', () => { + const config: GenerateConfig = { ...baseConfig, modelProvider: 'Anthropic' }; + const result = mapGenerateConfigToResources(config); + expect(result.credentials).toHaveLength(1); + }); + + it('includes both memory and credential when both configured', () => { + const config: GenerateConfig = { + ...baseConfig, + memory: 'longAndShortTerm', + modelProvider: 'OpenAI', + }; + const result = mapGenerateConfigToResources(config); + expect(result.memories).toHaveLength(1); + expect(result.credentials).toHaveLength(1); + expect(result.memories[0]!.strategies).toHaveLength(3); + }); +}); + +describe('mapModelProviderToIdentityProviders', () => { + it('returns empty array for Bedrock', () => { + expect(mapModelProviderToIdentityProviders('Bedrock', 'Proj')).toEqual([]); + }); + + it('returns identity provider for Anthropic', () => { + const result = mapModelProviderToIdentityProviders('Anthropic', 'Proj'); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('ProjAnthropic'); + expect(result[0]!.envVarName).toBe('AGENTCORE_CREDENTIAL_PROJANTHROPIC'); + }); + + it('returns identity provider for OpenAI', () => { + const result = mapModelProviderToIdentityProviders('OpenAI', 'Proj'); + expect(result[0]!.name).toBe('ProjOpenAI'); + expect(result[0]!.envVarName).toBe('AGENTCORE_CREDENTIAL_PROJOPENAI'); + }); +}); + +describe('mapGenerateConfigToRenderConfig', () => { + it('maps config with no memory and no identity', () => { + const result = mapGenerateConfigToRenderConfig(baseConfig, []); + expect(result.name).toBe('TestProject'); + expect(result.sdkFramework).toBe('Strands'); + expect(result.targetLanguage).toBe('Python'); + expect(result.modelProvider).toBe('Bedrock'); + expect(result.hasMemory).toBe(false); + expect(result.hasIdentity).toBe(false); + expect(result.memoryProviders).toEqual([]); + expect(result.identityProviders).toEqual([]); + }); + + it('sets hasMemory true when memory is not "none"', () => { + const config: GenerateConfig = { ...baseConfig, memory: 'shortTerm' }; + const result = mapGenerateConfigToRenderConfig(config, []); + expect(result.hasMemory).toBe(true); + }); + + it('sets hasIdentity true when identity providers exist', () => { + const identityProviders = [{ name: 'ProjAnthropic', envVarName: 'AGENTCORE_CREDENTIAL_PROJANTHROPIC' }]; + const result = mapGenerateConfigToRenderConfig(baseConfig, identityProviders); + expect(result.hasIdentity).toBe(true); + expect(result.identityProviders).toEqual(identityProviders); + }); + + it('populates memoryProviders for shortTerm memory', () => { + const config: GenerateConfig = { ...baseConfig, memory: 'shortTerm' }; + const result = mapGenerateConfigToRenderConfig(config, []); + expect(result.memoryProviders).toHaveLength(1); + expect(result.memoryProviders[0]!.name).toBe('TestProjectMemory'); + expect(result.memoryProviders[0]!.envVarName).toBe('MEMORY_TESTPROJECTMEMORY_ID'); + expect(result.memoryProviders[0]!.strategies).toEqual([]); + }); + + it('populates memoryProviders with strategy types for longAndShortTerm', () => { + const config: GenerateConfig = { ...baseConfig, memory: 'longAndShortTerm' }; + const result = mapGenerateConfigToRenderConfig(config, []); + expect(result.memoryProviders[0]!.strategies).toEqual(['SEMANTIC', 'USER_PREFERENCE', 'SUMMARIZATION']); + }); +}); diff --git a/src/cli/operations/deploy/__tests__/preflight.test.ts b/src/cli/operations/deploy/__tests__/preflight.test.ts new file mode 100644 index 00000000..5c9cd839 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/preflight.test.ts @@ -0,0 +1,51 @@ +import { formatError } from '../preflight.js'; +import { describe, expect, it } from 'vitest'; + +describe('formatError', () => { + it('formats a simple Error', () => { + const err = new Error('Something went wrong'); + const result = formatError(err); + expect(result).toContain('Something went wrong'); + }); + + it('includes stack trace when present', () => { + const err = new Error('oops'); + const result = formatError(err); + expect(result).toContain('Stack trace:'); + expect(result).toContain('oops'); + }); + + it('formats nested cause errors', () => { + const cause = new Error('root cause'); + const err = new Error('outer error', { cause }); + const result = formatError(err); + expect(result).toContain('outer error'); + expect(result).toContain('Caused by:'); + expect(result).toContain('root cause'); + }); + + it('formats non-Error values using String()', () => { + expect(formatError('string error')).toBe('string error'); + expect(formatError(42)).toBe('42'); + expect(formatError(null)).toBe('null'); + expect(formatError(undefined)).toBe('undefined'); + }); + + it('handles Error without stack', () => { + const err = new Error('no stack'); + err.stack = undefined; + const result = formatError(err); + expect(result).toBe('no stack'); + expect(result).not.toContain('Stack trace:'); + }); + + it('handles deeply nested causes', () => { + const inner = new Error('inner'); + const mid = new Error('mid', { cause: inner }); + const outer = new Error('outer', { cause: mid }); + const result = formatError(outer); + expect(result).toContain('outer'); + expect(result).toContain('mid'); + expect(result).toContain('inner'); + }); +}); diff --git a/src/cli/operations/mcp/__tests__/create-mcp.test.ts b/src/cli/operations/mcp/__tests__/create-mcp.test.ts new file mode 100644 index 00000000..d8816eb9 --- /dev/null +++ b/src/cli/operations/mcp/__tests__/create-mcp.test.ts @@ -0,0 +1,42 @@ +import { computeDefaultGatewayEnvVarName, computeDefaultMcpRuntimeEnvVarName } from '../create-mcp.js'; +import { describe, expect, it } from 'vitest'; + +describe('computeDefaultGatewayEnvVarName', () => { + it('converts simple name to env var', () => { + expect(computeDefaultGatewayEnvVarName('mygateway')).toBe('AGENTCORE_GATEWAY_MYGATEWAY_URL'); + }); + + it('replaces hyphens with underscores', () => { + expect(computeDefaultGatewayEnvVarName('my-gateway')).toBe('AGENTCORE_GATEWAY_MY_GATEWAY_URL'); + }); + + it('uppercases the name', () => { + expect(computeDefaultGatewayEnvVarName('MyGateway')).toBe('AGENTCORE_GATEWAY_MYGATEWAY_URL'); + }); + + it('handles multiple hyphens', () => { + expect(computeDefaultGatewayEnvVarName('my-cool-gateway')).toBe('AGENTCORE_GATEWAY_MY_COOL_GATEWAY_URL'); + }); + + it('handles already uppercase name', () => { + expect(computeDefaultGatewayEnvVarName('GW')).toBe('AGENTCORE_GATEWAY_GW_URL'); + }); +}); + +describe('computeDefaultMcpRuntimeEnvVarName', () => { + it('converts simple name to env var', () => { + expect(computeDefaultMcpRuntimeEnvVarName('myruntime')).toBe('AGENTCORE_MCPRUNTIME_MYRUNTIME_URL'); + }); + + it('replaces hyphens with underscores', () => { + expect(computeDefaultMcpRuntimeEnvVarName('my-runtime')).toBe('AGENTCORE_MCPRUNTIME_MY_RUNTIME_URL'); + }); + + it('uppercases the name', () => { + expect(computeDefaultMcpRuntimeEnvVarName('MyRuntime')).toBe('AGENTCORE_MCPRUNTIME_MYRUNTIME_URL'); + }); + + it('handles multiple hyphens', () => { + expect(computeDefaultMcpRuntimeEnvVarName('a-b-c')).toBe('AGENTCORE_MCPRUNTIME_A_B_C_URL'); + }); +}); diff --git a/src/cli/tui/hooks/__tests__/useListNavigation.test.ts b/src/cli/tui/hooks/__tests__/useListNavigation.test.ts new file mode 100644 index 00000000..04d1c1ce --- /dev/null +++ b/src/cli/tui/hooks/__tests__/useListNavigation.test.ts @@ -0,0 +1,70 @@ +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__/useTextInput.test.ts b/src/cli/tui/hooks/__tests__/useTextInput.test.ts new file mode 100644 index 00000000..858e371b --- /dev/null +++ b/src/cli/tui/hooks/__tests__/useTextInput.test.ts @@ -0,0 +1,74 @@ +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/useListNavigation.ts b/src/cli/tui/hooks/useListNavigation.ts index 0c2bd01e..6e39bbde 100644 --- a/src/cli/tui/hooks/useListNavigation.ts +++ b/src/cli/tui/hooks/useListNavigation.ts @@ -31,6 +31,30 @@ interface UseListNavigationResult { resetSelection: () => void; } +/** + * Find the next non-disabled index in the given direction, wrapping around. + * Extracted from the hook for standalone testability. + */ +export function findNextEnabledIndex( + items: T[], + current: number, + direction: 1 | -1, + isDisabled?: (item: T) => boolean +): number { + if (!isDisabled) { + return (current + direction + items.length) % items.length; + } + let next = current; + for (const _ of items) { + next = (next + direction + items.length) % items.length; + const item = items[next]; + if (item !== undefined && !isDisabled(item)) { + return next; + } + } + return current; // All items disabled, stay in place +} + /** * Hook for managing list navigation with arrow keys, Enter, Escape, and optional hotkeys. * Reduces boilerplate for screens with selectable lists. @@ -77,21 +101,9 @@ export function useListNavigation({ setSelectedIndex(idx >= 0 ? idx : 0); } - // Find next non-disabled index in given direction - const findNextIndex = (current: number, direction: 1 | -1): number => { - if (!isDisabled) { - return (current + direction + items.length) % items.length; - } - let next = current; - for (const _ of items) { - next = (next + direction + items.length) % items.length; - const item = items[next]; - if (item !== undefined && !isDisabled(item)) { - return next; - } - } - return current; // All items disabled, stay in place - }; + // Find next non-disabled index in given direction (delegates to standalone function) + const findNextIndex = (current: number, direction: 1 | -1): number => + findNextEnabledIndex(items, current, direction, isDisabled); useInput( (input, key) => { diff --git a/src/cli/tui/hooks/useTextInput.ts b/src/cli/tui/hooks/useTextInput.ts index 12c485e8..a92283b8 100644 --- a/src/cli/tui/hooks/useTextInput.ts +++ b/src/cli/tui/hooks/useTextInput.ts @@ -2,7 +2,7 @@ import { useInput } from 'ink'; import React, { useCallback, useState } from 'react'; /** Find the position of the previous word boundary */ -function findPrevWordBoundary(text: string, cursor: number): number { +export function findPrevWordBoundary(text: string, cursor: number): number { let pos = cursor; while (pos > 0 && text.charAt(pos - 1) === ' ') pos--; while (pos > 0 && text.charAt(pos - 1) !== ' ') pos--; @@ -10,7 +10,7 @@ function findPrevWordBoundary(text: string, cursor: number): number { } /** Find the position of the next word boundary */ -function findNextWordBoundary(text: string, cursor: number): number { +export function findNextWordBoundary(text: string, cursor: number): number { let pos = cursor; while (pos < text.length && text.charAt(pos) !== ' ') pos++; while (pos < text.length && text.charAt(pos) === ' ') pos++; diff --git a/src/cli/tui/utils/__tests__/commands.test.ts b/src/cli/tui/utils/__tests__/commands.test.ts new file mode 100644 index 00000000..d4753638 --- /dev/null +++ b/src/cli/tui/utils/__tests__/commands.test.ts @@ -0,0 +1,84 @@ +import { getCommandsForUI } from '../commands.js'; +import type { Command } from '@commander-js/extra-typings'; +import { describe, expect, it } from 'vitest'; + +/** Minimal mock matching Commander's Command interface shape */ +function makeCmd(name: string, desc: string, subs: string[] = []) { + return { + name: () => name, + description: () => desc, + commands: subs.map(s => ({ + name: () => s, + description: () => '', + commands: [], + })), + } as unknown as Command; +} + +function makeProgram(cmds: Command[]) { + return { commands: cmds } as unknown as Command; +} + +describe('getCommandsForUI', () => { + const program = makeProgram([ + makeCmd('create', 'Create a new project'), + makeCmd('add', 'Add a resource', ['agent', 'memory', 'gateway', 'mcp-tool']), + makeCmd('deploy', 'Deploy to AWS'), + makeCmd('status', 'Check status'), + makeCmd('help', 'Show help'), + makeCmd('update', 'Check for updates'), + makeCmd('package', 'Package artifacts'), + ]); + + it('filters out help, update, and package commands', () => { + const cmds = getCommandsForUI(program); + const names = cmds.map(c => c.id); + expect(names).not.toContain('help'); + expect(names).not.toContain('update'); + expect(names).not.toContain('package'); + }); + + it('includes visible commands', () => { + const cmds = getCommandsForUI(program); + const names = cmds.map(c => c.id); + expect(names).toContain('create'); + expect(names).toContain('add'); + expect(names).toContain('deploy'); + expect(names).toContain('status'); + }); + + it('hides create when inProject is true', () => { + const cmds = getCommandsForUI(program, { inProject: true }); + const names = cmds.map(c => c.id); + expect(names).not.toContain('create'); + expect(names).toContain('add'); + }); + + it('shows create when inProject is false', () => { + const cmds = getCommandsForUI(program, { inProject: false }); + const names = cmds.map(c => c.id); + expect(names).toContain('create'); + }); + + it('filters hidden subcommands (gateway, mcp-tool)', () => { + const cmds = getCommandsForUI(program); + const addCmd = cmds.find(c => c.id === 'add'); + expect(addCmd).toBeDefined(); + expect(addCmd!.subcommands).toContain('agent'); + expect(addCmd!.subcommands).toContain('memory'); + expect(addCmd!.subcommands).not.toContain('gateway'); + expect(addCmd!.subcommands).not.toContain('mcp-tool'); + }); + + it('returns command metadata shape', () => { + const cmds = getCommandsForUI(program); + const deploy = cmds.find(c => c.id === 'deploy'); + expect(deploy).toEqual({ + id: 'deploy', + title: 'deploy', + description: 'Deploy to AWS', + subcommands: [], + disabled: false, + }); + }); +}); diff --git a/src/cli/tui/utils/__tests__/diff.test.ts b/src/cli/tui/utils/__tests__/diff.test.ts new file mode 100644 index 00000000..58e83526 --- /dev/null +++ b/src/cli/tui/utils/__tests__/diff.test.ts @@ -0,0 +1,104 @@ +import { diffLines } from '../diff.js'; +import { describe, expect, it } from 'vitest'; + +describe('diffLines', () => { + it('returns empty array for two empty arrays', () => { + expect(diffLines([], [])).toEqual([]); + }); + + it('marks all lines as additions when original is empty', () => { + const result = diffLines([], ['a', 'b']); + expect(result).toEqual([ + { prefix: '+', value: 'a', color: 'green' }, + { prefix: '+', value: 'b', color: 'green' }, + ]); + }); + + it('marks all lines as removals when current is empty', () => { + const result = diffLines(['a', 'b'], []); + expect(result).toEqual([ + { prefix: '-', value: 'a', color: 'red' }, + { prefix: '-', value: 'b', color: 'red' }, + ]); + }); + + it('shows no changes for identical inputs', () => { + const lines = ['line1', 'line2', 'line3']; + const result = diffLines(lines, lines); + expect(result).toHaveLength(3); + for (const line of result) { + expect(line.prefix).toBe(' '); + expect(line.color).toBeUndefined(); + } + }); + + it('detects a single line change', () => { + const original = ['a', 'b', 'c']; + const current = ['a', 'x', 'c']; + const result = diffLines(original, current); + + expect(result).toContainEqual({ prefix: ' ', value: 'a' }); + expect(result).toContainEqual({ prefix: '-', value: 'b', color: 'red' }); + expect(result).toContainEqual({ prefix: '+', value: 'x', color: 'green' }); + expect(result).toContainEqual({ prefix: ' ', value: 'c' }); + }); + + it('detects an insertion', () => { + const original = ['a', 'c']; + const current = ['a', 'b', 'c']; + const result = diffLines(original, current); + + const prefixes = result.map(r => r.prefix); + expect(prefixes).toContain('+'); + // 'a' and 'c' should be equal, 'b' should be added + const added = result.filter(r => r.prefix === '+'); + expect(added).toHaveLength(1); + expect(added[0]!.value).toBe('b'); + }); + + it('detects a deletion', () => { + const original = ['a', 'b', 'c']; + const current = ['a', 'c']; + const result = diffLines(original, current); + + const removed = result.filter(r => r.prefix === '-'); + expect(removed).toHaveLength(1); + expect(removed[0]!.value).toBe('b'); + }); + + it('handles complete replacement', () => { + const original = ['a', 'b']; + const current = ['x', 'y']; + const result = diffLines(original, current); + + const removed = result.filter(r => r.prefix === '-'); + const added = result.filter(r => r.prefix === '+'); + expect(removed).toHaveLength(2); + expect(added).toHaveLength(2); + }); + + it('handles multi-line edits correctly', () => { + const original = ['header', 'old1', 'old2', 'footer']; + const current = ['header', 'new1', 'footer']; + const result = diffLines(original, current); + + const equal = result.filter(r => r.prefix === ' '); + expect(equal.map(r => r.value)).toContain('header'); + expect(equal.map(r => r.value)).toContain('footer'); + }); + + it('preserves line values in output', () => { + const original = [' indented', 'normal']; + const current = [' indented', 'normal']; + const result = diffLines(original, current); + + expect(result[0]!.value).toBe(' indented'); + expect(result[1]!.value).toBe('normal'); + }); + + it('handles single line arrays', () => { + const result = diffLines(['old'], ['new']); + expect(result).toContainEqual({ prefix: '-', value: 'old', color: 'red' }); + expect(result).toContainEqual({ prefix: '+', value: 'new', color: 'green' }); + }); +}); diff --git a/src/cli/tui/utils/__tests__/gradient.test.ts b/src/cli/tui/utils/__tests__/gradient.test.ts new file mode 100644 index 00000000..bfe1cd5f --- /dev/null +++ b/src/cli/tui/utils/__tests__/gradient.test.ts @@ -0,0 +1,41 @@ +import { createGradient } from '../gradient.js'; +import { describe, expect, it } from 'vitest'; + +describe('createGradient', () => { + it('returns a string containing the original characters', () => { + const result = createGradient('Hello'); + // Strip ANSI escape codes to verify original text + // eslint-disable-next-line no-control-regex + const stripped = result.replace(/\x1b\[[0-9;]*m/g, ''); + expect(stripped).toBe('Hello'); + }); + + it('wraps each character with ANSI color codes', () => { + const result = createGradient('AB'); + // Should contain escape sequences + expect(result).toContain('\x1b['); + // Should contain the reset code + expect(result).toContain('\x1b[0m'); + }); + + it('handles empty string', () => { + expect(createGradient('')).toBe(''); + }); + + it('handles single character', () => { + const result = createGradient('X'); + // eslint-disable-next-line no-control-regex + const stripped = result.replace(/\x1b\[[0-9;]*m/g, ''); + expect(stripped).toBe('X'); + }); + + it('produces different colors for characters at different positions', () => { + // With a long enough string, we should see multiple different color codes + const result = createGradient('ABCDEFGHIJKLMNOP'); + // Count distinct escape sequences (excluding reset) + // eslint-disable-next-line no-control-regex + const codes = result.match(/\x1b\[(?!0m)[0-9;]*m/g) ?? []; + const unique = new Set(codes); + expect(unique.size).toBeGreaterThan(1); + }); +}); diff --git a/src/cli/tui/utils/__tests__/naming.test.ts b/src/cli/tui/utils/__tests__/naming.test.ts new file mode 100644 index 00000000..e8621e7e --- /dev/null +++ b/src/cli/tui/utils/__tests__/naming.test.ts @@ -0,0 +1,36 @@ +import { generateUniqueName } from '../naming.js'; +import { describe, expect, it } from 'vitest'; + +describe('generateUniqueName', () => { + it('returns base name when no conflicts', () => { + expect(generateUniqueName('MyAgent', [])).toBe('MyAgent'); + }); + + it('returns base name when existing names are different', () => { + expect(generateUniqueName('MyAgent', ['OtherAgent', 'ThirdAgent'])).toBe('MyAgent'); + }); + + it('appends 1 when base name conflicts', () => { + expect(generateUniqueName('MyAgent', ['MyAgent'])).toBe('MyAgent1'); + }); + + it('increments counter to find unique name', () => { + expect(generateUniqueName('MyAgent', ['MyAgent', 'MyAgent1', 'MyAgent2'])).toBe('MyAgent3'); + }); + + it('uses custom separator', () => { + expect(generateUniqueName('Agent', ['Agent'], { separator: '-' })).toBe('Agent-1'); + }); + + it('increments with custom separator', () => { + expect(generateUniqueName('Agent', ['Agent', 'Agent-1'], { separator: '-' })).toBe('Agent-2'); + }); + + it('handles empty base name', () => { + expect(generateUniqueName('', [''])).toBe('1'); + }); + + it('handles empty existing names array', () => { + expect(generateUniqueName('Agent', [])).toBe('Agent'); + }); +}); diff --git a/src/cli/tui/utils/__tests__/process.test.ts b/src/cli/tui/utils/__tests__/process.test.ts index 3c8d8d53..d69fce9b 100644 --- a/src/cli/tui/utils/__tests__/process.test.ts +++ b/src/cli/tui/utils/__tests__/process.test.ts @@ -1,4 +1,4 @@ -import { cleanupStaleLockFiles } from '../process.js'; +import { cleanupStaleLockFiles, isProcessRunning } from '../process.js'; import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as fsp from 'node:fs/promises'; @@ -6,6 +6,25 @@ import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +describe('isProcessRunning', () => { + it('returns true for current process PID', async () => { + expect(await isProcessRunning(process.pid)).toBe(true); + }); + + it('returns false for non-existent PID', async () => { + // PID 99999999 is very unlikely to exist + expect(await isProcessRunning(99999999)).toBe(false); + }); + + it('returns false for PID that requires elevated permissions', async () => { + // PID 1 (init/launchd) exists but process.kill(1, 0) throws EPERM without root + // On macOS without root, this returns false due to the catch block + const result = await isProcessRunning(1); + // Either true (if running as root) or false (EPERM caught) — both are valid + expect(typeof result).toBe('boolean'); + }); +}); + describe('cleanupStaleLockFiles', () => { let testDir: string; @@ -18,29 +37,27 @@ describe('cleanupStaleLockFiles', () => { await fsp.rm(testDir, { recursive: true, force: true }); }); - it('removes lock files older than 5 minutes', async () => { + it('removes lock files from dead processes', async () => { + // PID 99999 is unlikely to exist const lockFile = path.join(testDir, 'read.99999.1.lock'); await fsp.writeFile(lockFile, ''); - // Set mtime to 10 minutes ago - const oldTime = new Date(Date.now() - 10 * 60 * 1000); - await fsp.utimes(lockFile, oldTime, oldTime); await cleanupStaleLockFiles(testDir); - expect(fs.existsSync(lockFile), 'Old lock file should be removed').toBe(false); + expect(fs.existsSync(lockFile), 'Lock from dead PID should be removed').toBe(false); }); - it('removes young lock files from dead processes', async () => { - // PID 99999 is unlikely to exist - const lockFile = path.join(testDir, 'read.99999.1.lock'); - await fsp.writeFile(lockFile, ''); + it('removes multiple lock files from same dead process', async () => { + await fsp.writeFile(path.join(testDir, 'read.99999.1.lock'), ''); + await fsp.writeFile(path.join(testDir, 'read.99999.2.lock'), ''); await cleanupStaleLockFiles(testDir); - expect(fs.existsSync(lockFile), 'Lock from dead PID should be removed').toBe(false); + const remaining = await fsp.readdir(testDir); + expect(remaining).toHaveLength(0); }); - it('keeps young lock files from live processes', async () => { + it('keeps lock files from live processes', async () => { const lockFile = path.join(testDir, `read.${process.pid}.1.lock`); await fsp.writeFile(lockFile, ''); @@ -49,25 +66,32 @@ describe('cleanupStaleLockFiles', () => { expect(fs.existsSync(lockFile), 'Lock from live PID should be kept').toBe(true); }); - // Note: synth.lock is intentionally NOT removed by cleanupStaleLockFiles - // to avoid corrupting concurrent CDK runs (see process.ts comment) - it('handles missing directory gracefully', async () => { const nonExistent = path.join(testDir, 'does-not-exist'); - - // Should not throw await cleanupStaleLockFiles(nonExistent); }); it('does not remove non-lock files', async () => { - const manifestFile = path.join(testDir, 'manifest.json'); - const treeFile = path.join(testDir, 'tree.json'); - await fsp.writeFile(manifestFile, '{}'); - await fsp.writeFile(treeFile, '{}'); + await fsp.writeFile(path.join(testDir, 'manifest.json'), '{}'); + await fsp.writeFile(path.join(testDir, 'tree.json'), '{}'); + await fsp.writeFile(path.join(testDir, 'synth.lock'), ''); + + await cleanupStaleLockFiles(testDir); + + const remaining = await fsp.readdir(testDir); + expect(remaining).toContain('manifest.json'); + expect(remaining).toContain('tree.json'); + expect(remaining).toContain('synth.lock'); + }); + + it('handles mix of live and dead process locks', async () => { + await fsp.writeFile(path.join(testDir, `read.${process.pid}.1.lock`), ''); + await fsp.writeFile(path.join(testDir, 'read.99999.1.lock'), ''); await cleanupStaleLockFiles(testDir); - expect(fs.existsSync(manifestFile), 'manifest.json should not be removed').toBe(true); - expect(fs.existsSync(treeFile), 'tree.json should not be removed').toBe(true); + const remaining = await fsp.readdir(testDir); + expect(remaining).toContain(`read.${process.pid}.1.lock`); + expect(remaining).not.toContain('read.99999.1.lock'); }); }); diff --git a/src/cli/tui/utils/__tests__/timing.test.ts b/src/cli/tui/utils/__tests__/timing.test.ts new file mode 100644 index 00000000..5aa170c5 --- /dev/null +++ b/src/cli/tui/utils/__tests__/timing.test.ts @@ -0,0 +1,35 @@ +import { withMinDuration } from '../timing.js'; +import { describe, expect, it } from 'vitest'; + +describe('withMinDuration', () => { + it('returns the result of the wrapped function', async () => { + const result = await withMinDuration(() => Promise.resolve(42)); + expect(result).toBe(42); + }); + + it('takes at least ~200ms even if function resolves instantly', async () => { + const start = Date.now(); + await withMinDuration(() => Promise.resolve('fast')); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(180); // small tolerance + }); + + it('does not add extra delay for slow functions', async () => { + const start = Date.now(); + await withMinDuration(async () => { + await new Promise(resolve => setTimeout(resolve, 300)); + return 'slow'; + }); + const elapsed = Date.now() - start; + // Should be ~300ms, not 300+200 + expect(elapsed).toBeLessThan(550); + }); + + it('propagates errors from the wrapped function', async () => { + await expect( + withMinDuration(() => { + throw new Error('boom'); + }) + ).rejects.toThrow('boom'); + }); +}); diff --git a/src/lib/errors/__tests__/config.test.ts b/src/lib/errors/__tests__/config.test.ts new file mode 100644 index 00000000..68e0880e --- /dev/null +++ b/src/lib/errors/__tests__/config.test.ts @@ -0,0 +1,149 @@ +import { + ConfigError, + ConfigNotFoundError, + ConfigParseError, + ConfigReadError, + ConfigValidationError, + ConfigWriteError, +} from '../config.js'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +describe('ConfigNotFoundError', () => { + it('has correct message', () => { + const err = new ConfigNotFoundError('/path/to/config.json', 'project'); + expect(err.message).toBe('project config file not found at: /path/to/config.json'); + }); + + it('stores filePath and fileType', () => { + const err = new ConfigNotFoundError('/path/config.json', 'targets'); + expect(err.filePath).toBe('/path/config.json'); + expect(err.fileType).toBe('targets'); + }); + + it('is instance of ConfigError and Error', () => { + const err = new ConfigNotFoundError('/path', 'project'); + expect(err).toBeInstanceOf(ConfigError); + expect(err).toBeInstanceOf(Error); + }); + + it('has correct name', () => { + const err = new ConfigNotFoundError('/path', 'project'); + expect(err.name).toBe('ConfigNotFoundError'); + }); +}); + +describe('ConfigReadError', () => { + it('includes cause message', () => { + const cause = new Error('EACCES: permission denied'); + const err = new ConfigReadError('/path/config.json', cause); + expect(err.message).toContain('permission denied'); + expect(err.message).toContain('/path/config.json'); + }); + + it('handles non-Error cause', () => { + const err = new ConfigReadError('/path/config.json', 'string error'); + expect(err.message).toContain('string error'); + }); + + it('stores cause', () => { + const cause = new Error('original'); + const err = new ConfigReadError('/path', cause); + expect(err.cause).toBe(cause); + }); +}); + +describe('ConfigWriteError', () => { + it('includes cause message', () => { + const cause = new Error('ENOSPC: no space left'); + const err = new ConfigWriteError('/path/config.json', cause); + expect(err.message).toContain('no space left'); + expect(err.message).toContain('/path/config.json'); + }); + + it('handles non-Error cause', () => { + const err = new ConfigWriteError('/path', 42); + expect(err.message).toContain('42'); + }); +}); + +describe('ConfigParseError', () => { + it('includes JSON parse error details', () => { + const cause = new SyntaxError('Unexpected token } in JSON'); + const err = new ConfigParseError('/path/config.json', cause); + expect(err.message).toContain('Unexpected token'); + expect(err.message).toContain('/path/config.json'); + }); + + it('stores cause', () => { + const cause = new SyntaxError('bad json'); + const err = new ConfigParseError('/path', cause); + expect(err.cause).toBe(cause); + }); +}); + +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', () => { + 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); + } + }); + + it('formats invalid_type errors with expected/received', () => { + const schema = z.object({ count: z.number() }); + const result = schema.safeParse({ count: 'not a number' }); + expect(result.success).toBe(false); + if (!result.success) { + const err = new ConfigValidationError('/path', 'project', result.error); + expect(err.message).toContain('count'); + } + }); + + it('formats unrecognized_keys errors', () => { + const schema = z.object({ name: z.string() }).strict(); + const result = schema.safeParse({ name: 'test', extra: true }); + expect(result.success).toBe(false); + if (!result.success) { + const err = new ConfigValidationError('/path', 'project', result.error); + expect(err.message).toContain('extra'); + } + }); + + 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'); + } + }); + + 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); + } + }); +}); diff --git a/src/lib/packaging/__tests__/errors.test.ts b/src/lib/packaging/__tests__/errors.test.ts new file mode 100644 index 00000000..e6d07805 --- /dev/null +++ b/src/lib/packaging/__tests__/errors.test.ts @@ -0,0 +1,60 @@ +import { + ArtifactSizeError, + MissingDependencyError, + MissingProjectFileError, + PackagingError, + UnsupportedLanguageError, +} from '../errors.js'; +import { describe, expect, it } from 'vitest'; + +describe('PackagingError', () => { + it('sets message and name', () => { + const err = new PackagingError('something failed'); + expect(err.message).toBe('something failed'); + expect(err.name).toBe('PackagingError'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(PackagingError); + }); +}); + +describe('MissingDependencyError', () => { + it('formats message with binary name', () => { + const err = new MissingDependencyError('uv'); + expect(err.message).toBe('uv is required.'); + expect(err).toBeInstanceOf(PackagingError); + }); + + it('includes install hint when provided', () => { + const err = new MissingDependencyError('uv', 'Install from https://example.com'); + expect(err.message).toBe('uv is required. Install from https://example.com'); + }); +}); + +describe('MissingProjectFileError', () => { + it('formats message with file path', () => { + const err = new MissingProjectFileError('/path/to/pyproject.toml'); + expect(err.message).toContain('/path/to/pyproject.toml'); + expect(err.message).toContain('not found'); + expect(err).toBeInstanceOf(PackagingError); + }); +}); + +describe('UnsupportedLanguageError', () => { + it('formats message with language name', () => { + const err = new UnsupportedLanguageError('Rust'); + expect(err.message).toContain('Rust'); + expect(err.message).toContain('not supported'); + expect(err).toBeInstanceOf(PackagingError); + }); +}); + +describe('ArtifactSizeError', () => { + it('formats message with limit and actual size', () => { + const limit = 250 * 1024 * 1024; + const actual = 300 * 1024 * 1024; + const err = new ArtifactSizeError(limit, actual); + expect(err.message).toContain(String(limit)); + expect(err.message).toContain(String(actual)); + expect(err).toBeInstanceOf(PackagingError); + }); +}); diff --git a/src/lib/packaging/__tests__/helpers.test.ts b/src/lib/packaging/__tests__/helpers.test.ts new file mode 100644 index 00000000..c0d2943b --- /dev/null +++ b/src/lib/packaging/__tests__/helpers.test.ts @@ -0,0 +1,357 @@ +import { + MAX_ZIP_SIZE_BYTES, + convertWindowsScriptsToLinux, + convertWindowsScriptsToLinuxSync, + copySourceTree, + copySourceTreeSync, + createZipFromDir, + createZipFromDirSync, + enforceZipSizeLimit, + enforceZipSizeLimitSync, + ensureDirClean, + ensureDirCleanSync, + isNodeRuntime, + isPythonRuntime, + resolveCodeLocation, + resolveProjectPaths, + resolveProjectPathsSync, +} from '../helpers.js'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +// ── Pure function tests ────────────────────────────────────────────── + +describe('isPythonRuntime', () => { + it('returns true for PYTHON_3_12', () => { + expect(isPythonRuntime('PYTHON_3_12')).toBe(true); + }); + + it('returns true for PYTHON_3_13', () => { + expect(isPythonRuntime('PYTHON_3_13')).toBe(true); + }); + + it('returns false for NODE_20', () => { + expect(isPythonRuntime('NODE_20')).toBe(false); + }); + + it('returns false for NODE_22', () => { + expect(isPythonRuntime('NODE_22')).toBe(false); + }); +}); + +describe('isNodeRuntime', () => { + it('returns true for NODE_20', () => { + expect(isNodeRuntime('NODE_20')).toBe(true); + }); + + it('returns true for NODE_22', () => { + expect(isNodeRuntime('NODE_22')).toBe(true); + }); + + it('returns false for PYTHON_3_12', () => { + expect(isNodeRuntime('PYTHON_3_12')).toBe(false); + }); +}); + +describe('resolveCodeLocation', () => { + it('returns absolute path unchanged', () => { + expect(resolveCodeLocation('/absolute/path/to/code', '/home/user/proj/agentcore')).toBe('/absolute/path/to/code'); + }); + + it('resolves relative path against repository root (parent of agentcore/)', () => { + const result = resolveCodeLocation('./app/MyAgent', '/home/user/proj/agentcore'); + expect(result).toContain('/home/user/proj/app/MyAgent'); + }); + + it('resolves relative path without leading ./', () => { + const result = resolveCodeLocation('app/MyAgent', '/home/user/proj/agentcore'); + expect(result).toContain('/home/user/proj/app/MyAgent'); + }); +}); + +describe('MAX_ZIP_SIZE_BYTES', () => { + it('is 250 MB', () => { + expect(MAX_ZIP_SIZE_BYTES).toBe(250 * 1024 * 1024); + }); +}); + +// ── Real filesystem tests ──────────────────────────────────────────── + +describe('ensureDirClean', () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'helpers-clean-')); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('creates a fresh empty directory', async () => { + const dir = join(root, 'fresh'); + await ensureDirClean(dir); + expect(existsSync(dir)).toBe(true); + }); + + it('removes existing contents and recreates', async () => { + const dir = join(root, 'dirty'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'old.txt'), 'old'); + await ensureDirClean(dir); + expect(existsSync(join(dir, 'old.txt'))).toBe(false); + expect(existsSync(dir)).toBe(true); + }); + + it('sync version works the same', () => { + const dir = join(root, 'sync-clean'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'old.txt'), 'old'); + ensureDirCleanSync(dir); + expect(existsSync(join(dir, 'old.txt'))).toBe(false); + expect(existsSync(dir)).toBe(true); + }); +}); + +describe('copySourceTree', () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'helpers-copy-')); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('copies files and subdirectories', async () => { + const src = join(root, 'src'); + const dest = join(root, 'dest'); + mkdirSync(join(src, 'sub'), { recursive: true }); + writeFileSync(join(src, 'main.py'), 'print("hello")'); + writeFileSync(join(src, 'sub', 'util.py'), 'pass'); + mkdirSync(dest, { recursive: true }); + + await copySourceTree(src, dest); + expect(readFileSync(join(dest, 'main.py'), 'utf-8')).toBe('print("hello")'); + expect(readFileSync(join(dest, 'sub', 'util.py'), 'utf-8')).toBe('pass'); + }); + + it('excludes __pycache__, .git, node_modules, .venv', async () => { + const src = join(root, 'src-excl'); + const dest = join(root, 'dest-excl'); + mkdirSync(src, { recursive: true }); + mkdirSync(join(src, '__pycache__'), { recursive: true }); + mkdirSync(join(src, '.git'), { recursive: true }); + mkdirSync(join(src, 'node_modules'), { recursive: true }); + mkdirSync(join(src, '.venv'), { recursive: true }); + writeFileSync(join(src, '__pycache__', 'cached.pyc'), 'cache'); + writeFileSync(join(src, '.git', 'HEAD'), 'ref'); + writeFileSync(join(src, 'keep.py'), 'keep'); + mkdirSync(dest, { recursive: true }); + + await copySourceTree(src, dest); + expect(existsSync(join(dest, 'keep.py'))).toBe(true); + expect(existsSync(join(dest, '__pycache__'))).toBe(false); + expect(existsSync(join(dest, '.git'))).toBe(false); + expect(existsSync(join(dest, 'node_modules'))).toBe(false); + expect(existsSync(join(dest, '.venv'))).toBe(false); + }); + + it('throws for non-existent source', async () => { + await expect(copySourceTree(join(root, 'nope'), join(root, 'x'))).rejects.toThrow('not found'); + }); + + it('sync version copies files', () => { + const src = join(root, 'src-sync'); + const dest = join(root, 'dest-sync'); + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'a.py'), 'a'); + mkdirSync(dest, { recursive: true }); + + copySourceTreeSync(src, dest); + expect(readFileSync(join(dest, 'a.py'), 'utf-8')).toBe('a'); + }); + + it('sync version throws for non-existent source', () => { + expect(() => copySourceTreeSync(join(root, 'missing'), join(root, 'y'))).toThrow('not found'); + }); +}); + +describe('createZipFromDir + enforceZipSizeLimit', () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'helpers-zip-')); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('creates a valid zip file from a directory', async () => { + const src = join(root, 'zipme'); + const zipPath = join(root, 'output.zip'); + mkdirSync(join(src, 'sub'), { recursive: true }); + writeFileSync(join(src, 'file.txt'), 'hello'); + writeFileSync(join(src, 'sub', 'nested.txt'), 'nested'); + + await createZipFromDir(src, zipPath); + expect(existsSync(zipPath)).toBe(true); + + // Zip should be non-empty + const size = await enforceZipSizeLimit(zipPath); + expect(size).toBeGreaterThan(0); + }); + + it('sync version creates a valid zip file', () => { + const src = join(root, 'zipme-sync'); + const zipPath = join(root, 'output-sync.zip'); + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'data.txt'), 'data'); + + createZipFromDirSync(src, zipPath); + expect(existsSync(zipPath)).toBe(true); + + const size = enforceZipSizeLimitSync(zipPath); + expect(size).toBeGreaterThan(0); + }); + + it('excludes __pycache__ from zip', async () => { + const src = join(root, 'zip-excl'); + const zipPath = join(root, 'excl.zip'); + mkdirSync(join(src, '__pycache__'), { recursive: true }); + writeFileSync(join(src, '__pycache__', 'cached.pyc'), 'x'.repeat(10000)); + writeFileSync(join(src, 'main.py'), 'print("hi")'); + + await createZipFromDir(src, zipPath); + + // Create another zip with just main.py for size comparison + const src2 = join(root, 'zip-just-main'); + mkdirSync(src2, { recursive: true }); + writeFileSync(join(src2, 'main.py'), 'print("hi")'); + const zipPath2 = join(root, 'just-main.zip'); + await createZipFromDir(src2, zipPath2); + + const sizeWithExcl = await enforceZipSizeLimit(zipPath); + const sizeJustMain = await enforceZipSizeLimit(zipPath2); + // Sizes should be identical since __pycache__ was excluded + expect(sizeWithExcl).toBe(sizeJustMain); + }); +}); + +describe('resolveProjectPaths', () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'helpers-resolve-')); + // Create a minimal project structure with pyproject.toml + writeFileSync(join(root, 'pyproject.toml'), '[project]\nname = "test"'); + mkdirSync(join(root, 'src'), { recursive: true }); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('resolves paths with explicit projectRoot', async () => { + const paths = await resolveProjectPaths({ projectRoot: root }); + expect(paths.projectRoot).toBe(root); + expect(paths.pyprojectPath).toBe(join(root, 'pyproject.toml')); + expect(paths.srcDir).toBe(join(root, 'src')); + }); + + it('uses agent name for build directory', async () => { + const paths = await resolveProjectPaths({ projectRoot: root }, 'MyAgent'); + expect(paths.buildDir).toContain('MyAgent'); + expect(paths.stagingDir).toContain('MyAgent'); + }); + + it('defaults agent name to "default"', async () => { + const paths = await resolveProjectPaths({ projectRoot: root }); + expect(paths.buildDir).toContain('default'); + }); + + it('throws when pyproject.toml not found', async () => { + // Use a completely separate tmpdir so findUp doesn't discover the parent's pyproject.toml + const isolated = mkdtempSync(join(tmpdir(), 'helpers-no-pyproject-')); + try { + await expect(resolveProjectPaths({ projectRoot: isolated })).rejects.toThrow(); + } finally { + rmSync(isolated, { recursive: true, force: true }); + } + }); + + it('sync version resolves paths', () => { + const paths = resolveProjectPathsSync({ projectRoot: root }, 'SyncAgent'); + expect(paths.projectRoot).toBe(root); + expect(paths.buildDir).toContain('SyncAgent'); + }); +}); + +describe('convertWindowsScriptsToLinux (shebang rewriting on non-Windows)', () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'helpers-shebang-')); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('rewrites hardcoded macOS shebang to portable shebang', async () => { + const staging = join(root, 'staging1'); + const binDir = join(staging, 'bin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(binDir, 'myscript'), '#!/Users/dev/.venv/bin/python3\nimport sys\nprint(sys.argv)'); + + await convertWindowsScriptsToLinux(staging); + const content = readFileSync(join(binDir, 'myscript'), 'utf-8'); + expect(content).toMatch(/^#!\/usr\/bin\/env python3/); + expect(content).not.toContain('/Users/'); + }); + + it('rewrites hardcoded Linux home path shebang', async () => { + const staging = join(root, 'staging2'); + const binDir = join(staging, 'bin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(binDir, 'tool'), '#!/home/user/.local/bin/python3\nimport os'); + + await convertWindowsScriptsToLinux(staging); + const content = readFileSync(join(binDir, 'tool'), 'utf-8'); + expect(content).toMatch(/^#!\/usr\/bin\/env python3/); + }); + + it('leaves portable shebang unchanged', async () => { + const staging = join(root, 'staging3'); + const binDir = join(staging, 'bin'); + mkdirSync(binDir, { recursive: true }); + const original = '#!/usr/bin/env python3\nimport sys'; + writeFileSync(join(binDir, 'good'), original); + + await convertWindowsScriptsToLinux(staging); + const content = readFileSync(join(binDir, 'good'), 'utf-8'); + expect(content).toBe(original); + }); + + it('handles missing bin directory gracefully', async () => { + const staging = join(root, 'no-bin'); + mkdirSync(staging, { recursive: true }); + // Should not throw + await convertWindowsScriptsToLinux(staging); + }); + + it('sync version rewrites shebangs', () => { + const staging = join(root, 'staging-sync'); + const binDir = join(staging, 'bin'); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(binDir, 'script'), '#!/Users/ci/.pyenv/versions/3.12/bin/python3\nimport sys'); + + convertWindowsScriptsToLinuxSync(staging); + const content = readFileSync(join(binDir, 'script'), 'utf-8'); + expect(content).toMatch(/^#!\/usr\/bin\/env python3/); + }); +}); diff --git a/src/lib/packaging/__tests__/node.test.ts b/src/lib/packaging/__tests__/node.test.ts new file mode 100644 index 00000000..c1551aa6 --- /dev/null +++ b/src/lib/packaging/__tests__/node.test.ts @@ -0,0 +1,21 @@ +import type { NodeRuntime } from '../../../schema/index.js'; +import { extractNodeVersion } from '../node.js'; +import { describe, expect, it } from 'vitest'; + +describe('extractNodeVersion', () => { + it('extracts 18 from NODE_18', () => { + expect(extractNodeVersion('NODE_18' as NodeRuntime)).toBe('18'); + }); + + it('extracts 20 from NODE_20', () => { + expect(extractNodeVersion('NODE_20' as NodeRuntime)).toBe('20'); + }); + + it('extracts 22 from NODE_22', () => { + expect(extractNodeVersion('NODE_22' as NodeRuntime)).toBe('22'); + }); + + it('throws for unsupported runtime string', () => { + expect(() => extractNodeVersion('PYTHON_3_12' as NodeRuntime)).toThrow('Unsupported Node runtime'); + }); +}); diff --git a/src/lib/packaging/__tests__/python.test.ts b/src/lib/packaging/__tests__/python.test.ts new file mode 100644 index 00000000..2a912be2 --- /dev/null +++ b/src/lib/packaging/__tests__/python.test.ts @@ -0,0 +1,43 @@ +import type { PythonRuntime } from '../../../schema/index.js'; +import { PLATFORM_CANDIDATES, extractPythonVersion } from '../python.js'; +import { describe, expect, it } from 'vitest'; + +describe('extractPythonVersion', () => { + it('extracts 3.10 from PYTHON_3_10', () => { + expect(extractPythonVersion('PYTHON_3_10' as PythonRuntime)).toBe('3.10'); + }); + + it('extracts 3.11 from PYTHON_3_11', () => { + expect(extractPythonVersion('PYTHON_3_11' as PythonRuntime)).toBe('3.11'); + }); + + it('extracts 3.12 from PYTHON_3_12', () => { + expect(extractPythonVersion('PYTHON_3_12' as PythonRuntime)).toBe('3.12'); + }); + + it('extracts 3.13 from PYTHON_3_13', () => { + expect(extractPythonVersion('PYTHON_3_13' as PythonRuntime)).toBe('3.13'); + }); + + it('throws for unsupported runtime string', () => { + expect(() => extractPythonVersion('RUBY_3_0' as PythonRuntime)).toThrow('Unsupported Python runtime'); + }); + + it('throws for malformed runtime (missing minor)', () => { + expect(() => extractPythonVersion('PYTHON_3' as PythonRuntime)).toThrow('Invalid Python runtime'); + }); +}); + +describe('PLATFORM_CANDIDATES', () => { + it('contains aarch64 manylinux platforms', () => { + expect(PLATFORM_CANDIDATES).toHaveLength(3); + for (const p of PLATFORM_CANDIDATES) { + expect(p).toContain('aarch64'); + expect(p).toContain('manylinux'); + } + }); + + it('includes manylinux2014 as first candidate', () => { + expect(PLATFORM_CANDIDATES[0]).toBe('aarch64-manylinux2014'); + }); +}); diff --git a/src/lib/packaging/__tests__/uv.test.ts b/src/lib/packaging/__tests__/uv.test.ts new file mode 100644 index 00000000..a64cabb6 --- /dev/null +++ b/src/lib/packaging/__tests__/uv.test.ts @@ -0,0 +1,64 @@ +import { detectUnavailablePlatform } from '../uv.js'; +import { describe, expect, it } from 'vitest'; + +function result(stdout: string, stderr = '') { + return { code: 1, stdout, stderr, signal: null as NodeJS.Signals | null }; +} + +describe('detectUnavailablePlatform', () => { + it('returns null when output has no platform hints', () => { + expect(detectUnavailablePlatform(result('Some generic error'))).toBeNull(); + }); + + it('detects platform hint with manylinux tokens', () => { + const out = 'error: No matching distribution\nplatforms: manylinux2014_aarch64, manylinux_2_28_aarch64\nhelp: ...'; + const issue = detectUnavailablePlatform(result(out)); + expect(issue).not.toBeNull(); + expect(issue!.platforms).toBeDefined(); + expect(issue!.platforms!.length).toBeGreaterThan(0); + expect(issue!.platforms!.some(p => p.includes('manylinux'))).toBe(true); + }); + + it('detects "no wheels with a matching platform tag" message', () => { + const out = 'error: has no wheels with a matching platform tag'; + const issue = detectUnavailablePlatform(result(out)); + expect(issue).not.toBeNull(); + expect(issue!.message).toContain('wheels'); + }); + + it('detects "no compatible wheels found" message', () => { + const issue = detectUnavailablePlatform(result('no compatible wheels found for package foo')); + expect(issue).not.toBeNull(); + }); + + it('detects "no compatible tags found" message', () => { + const issue = detectUnavailablePlatform(result('no compatible tags found')); + expect(issue).not.toBeNull(); + }); + + it('checks stderr as well as stdout', () => { + const issue = detectUnavailablePlatform(result('', 'has no wheels with a matching platform tag')); + expect(issue).not.toBeNull(); + }); + + it('returns message with context lines around the match', () => { + const lines = [ + 'line 1', + 'line 2', + 'line 3', + 'error: has no wheels with a matching platform tag', + 'line 5', + 'line 6', + 'line 7', + ]; + const issue = detectUnavailablePlatform(result(lines.join('\n'))); + expect(issue).not.toBeNull(); + // Message should include context lines around the match + expect(issue!.message).toContain('has no wheels'); + }); + + it('returns null for successful output', () => { + const out = 'Successfully installed package-1.0.0\nAll done!'; + expect(detectUnavailablePlatform({ code: 0, stdout: out, stderr: '', signal: null })).toBeNull(); + }); +}); diff --git a/src/lib/packaging/node.ts b/src/lib/packaging/node.ts index 7281f16c..2618dea0 100644 --- a/src/lib/packaging/node.ts +++ b/src/lib/packaging/node.ts @@ -33,7 +33,7 @@ function isNodeRuntimeVersion(version: RuntimeVersion): version is NodeRuntime { * Extracts Node version from runtime constant. * Example: NODE_20 -> "20" (for use with node version checks) */ -function _extractNodeVersion(runtime: NodeRuntime): string { +export function extractNodeVersion(runtime: NodeRuntime): string { const match = NODE_RUNTIME_REGEX.exec(runtime); if (!match) { throw new PackagingError(`Unsupported Node runtime value: ${runtime}`); diff --git a/src/lib/packaging/python.ts b/src/lib/packaging/python.ts index 8acb0e40..2dd6734f 100644 --- a/src/lib/packaging/python.ts +++ b/src/lib/packaging/python.ts @@ -32,13 +32,13 @@ function isPythonRuntimeVersion(version: RuntimeVersion): version is PythonRunti return isPythonRuntime(version); } // AC Runtime uses AL2023 with GLIBC 2.34, we can support any manylinux <= 2_34 -const PLATFORM_CANDIDATES = ['aarch64-manylinux2014', 'aarch64-manylinux_2_28', 'aarch64-manylinux_2_34']; +export const PLATFORM_CANDIDATES = ['aarch64-manylinux2014', 'aarch64-manylinux_2_28', 'aarch64-manylinux_2_34']; /** * Extracts Python version from runtime constant. * Example: PYTHON_3_12 -> "3.12" (for use with uv --python-version) */ -function extractPythonVersion(runtime: PythonRuntime): string { +export function extractPythonVersion(runtime: PythonRuntime): string { const match = PYTHON_RUNTIME_REGEX.exec(runtime); if (!match) { throw new PackagingError(`Unsupported Python runtime value: ${runtime}`); diff --git a/src/lib/utils/__tests__/aws-account.test.ts b/src/lib/utils/__tests__/aws-account.test.ts new file mode 100644 index 00000000..a95fddef --- /dev/null +++ b/src/lib/utils/__tests__/aws-account.test.ts @@ -0,0 +1,43 @@ +import { detectAwsAccount } from '../aws-account.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend } = vi.hoisted(() => ({ + mockSend: vi.fn(), +})); + +vi.mock('@aws-sdk/client-sts', () => ({ + STSClient: class { + send = mockSend; + }, + GetCallerIdentityCommand: class { + constructor(public input: unknown) {} + }, +})); + +vi.mock('@aws-sdk/credential-providers', () => ({ + fromNodeProviderChain: vi.fn().mockReturnValue({}), +})); + +describe('detectAwsAccount', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns account ID on success', async () => { + mockSend.mockResolvedValue({ Account: '123456789012' }); + const result = await detectAwsAccount(); + expect(result).toBe('123456789012'); + }); + + it('returns null when Account is undefined', async () => { + mockSend.mockResolvedValue({}); + const result = await detectAwsAccount(); + expect(result).toBeNull(); + }); + + it('returns null on error (no credentials)', async () => { + mockSend.mockRejectedValue(new Error('Could not load credentials')); + const result = await detectAwsAccount(); + expect(result).toBeNull(); + }); +}); diff --git a/src/lib/utils/__tests__/credentials.test.ts b/src/lib/utils/__tests__/credentials.test.ts new file mode 100644 index 00000000..2b58b18f --- /dev/null +++ b/src/lib/utils/__tests__/credentials.test.ts @@ -0,0 +1,186 @@ +import { SecureCredentials } from '../credentials.js'; +import { describe, expect, it } from 'vitest'; + +describe('SecureCredentials', () => { + describe('constructor', () => { + it('creates from record', () => { + const creds = new SecureCredentials({ KEY: 'value' }); + expect(creds.get('KEY')).toBe('value'); + }); + + it('creates empty when no args', () => { + const creds = new SecureCredentials(); + expect(creds.isEmpty()).toBe(true); + expect(creds.size).toBe(0); + }); + + it('creates empty from empty record', () => { + const creds = new SecureCredentials({}); + expect(creds.isEmpty()).toBe(true); + }); + + it('is frozen (immutable)', () => { + const creds = new SecureCredentials({ KEY: 'value' }); + expect(() => { + (creds as unknown as Record).newProp = 'test'; + }).toThrow(); + }); + }); + + describe('get', () => { + it('returns value for existing key', () => { + const creds = new SecureCredentials({ API_KEY: 'sk-123' }); + expect(creds.get('API_KEY')).toBe('sk-123'); + }); + + it('returns undefined for non-existent key', () => { + const creds = new SecureCredentials({ API_KEY: 'sk-123' }); + expect(creds.get('OTHER')).toBeUndefined(); + }); + }); + + describe('has', () => { + it('returns true for existing key', () => { + const creds = new SecureCredentials({ KEY: 'val' }); + expect(creds.has('KEY')).toBe(true); + }); + + it('returns false for non-existent key', () => { + const creds = new SecureCredentials({ KEY: 'val' }); + expect(creds.has('OTHER')).toBe(false); + }); + }); + + describe('size', () => { + it('returns 0 for empty', () => { + expect(new SecureCredentials().size).toBe(0); + }); + + it('returns correct count', () => { + expect(new SecureCredentials({ A: '1', B: '2', C: '3' }).size).toBe(3); + }); + }); + + describe('isEmpty', () => { + it('returns true for empty credentials', () => { + expect(new SecureCredentials().isEmpty()).toBe(true); + }); + + it('returns false for non-empty credentials', () => { + expect(new SecureCredentials({ K: 'v' }).isEmpty()).toBe(false); + }); + }); + + describe('keys', () => { + it('returns all key names', () => { + const creds = new SecureCredentials({ A: '1', B: '2' }); + expect(creds.keys()).toEqual(expect.arrayContaining(['A', 'B'])); + expect(creds.keys()).toHaveLength(2); + }); + + it('returns empty array for empty credentials', () => { + expect(new SecureCredentials().keys()).toEqual([]); + }); + }); + + describe('merge', () => { + it('merges two SecureCredentials instances', () => { + const a = new SecureCredentials({ KEY1: 'val1' }); + const b = new SecureCredentials({ KEY2: 'val2' }); + const merged = a.merge(b); + + expect(merged.get('KEY1')).toBe('val1'); + expect(merged.get('KEY2')).toBe('val2'); + expect(merged.size).toBe(2); + }); + + it('merges with plain object', () => { + const creds = new SecureCredentials({ KEY1: 'val1' }); + const merged = creds.merge({ KEY2: 'val2' }); + + expect(merged.get('KEY1')).toBe('val1'); + expect(merged.get('KEY2')).toBe('val2'); + }); + + it('new values take precedence', () => { + const a = new SecureCredentials({ KEY: 'old' }); + const b = new SecureCredentials({ KEY: 'new' }); + const merged = a.merge(b); + + expect(merged.get('KEY')).toBe('new'); + }); + + it('returns new instance (immutable)', () => { + const a = new SecureCredentials({ KEY: 'val' }); + const merged = a.merge({}); + + expect(merged).not.toBe(a); + expect(merged).toBeInstanceOf(SecureCredentials); + }); + }); + + describe('toPlainObject', () => { + it('returns plain record with actual values', () => { + const creds = new SecureCredentials({ API_KEY: 'secret', TOKEN: 'tok' }); + const plain = creds.toPlainObject(); + + expect(plain).toEqual({ API_KEY: 'secret', TOKEN: 'tok' }); + }); + + it('returns empty object for empty credentials', () => { + expect(new SecureCredentials().toPlainObject()).toEqual({}); + }); + }); + + describe('security (serialization safety)', () => { + it('toJSON redacts values', () => { + const creds = new SecureCredentials({ SECRET: 'hidden', TOKEN: 'also-hidden' }); + const json = creds.toJSON(); + + expect(json._redacted).toBe('[CREDENTIALS REDACTED]'); + expect(json.count).toBe(2); + expect(json.keys).toEqual(expect.arrayContaining(['SECRET', 'TOKEN'])); + // Verify actual values are NOT in the JSON + expect(JSON.stringify(json)).not.toContain('hidden'); + }); + + it('JSON.stringify does not expose values', () => { + const creds = new SecureCredentials({ SECRET: 'mypassword' }); + const serialized = JSON.stringify(creds); + + expect(serialized).not.toContain('mypassword'); + expect(serialized).toContain('REDACTED'); + }); + + it('toString is safe', () => { + const creds = new SecureCredentials({ SECRET: 'mypassword' }); + const str = creds.toString(); + + expect(str).not.toContain('mypassword'); + expect(str).toContain('1 credential(s)'); + }); + + it('Node.js inspect is safe', () => { + const creds = new SecureCredentials({ SECRET: 'mypassword' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inspectFn = (creds as any)[Symbol.for('nodejs.util.inspect.custom')] as () => string; + const str = inspectFn.call(creds); + + expect(str).not.toContain('mypassword'); + }); + }); + + describe('static factories', () => { + it('fromEnvVars creates from record', () => { + const creds = SecureCredentials.fromEnvVars({ KEY: 'val' }); + expect(creds.get('KEY')).toBe('val'); + expect(creds).toBeInstanceOf(SecureCredentials); + }); + + it('empty creates empty instance', () => { + const creds = SecureCredentials.empty(); + expect(creds.isEmpty()).toBe(true); + expect(creds).toBeInstanceOf(SecureCredentials); + }); + }); +}); diff --git a/src/lib/utils/__tests__/env.test.ts b/src/lib/utils/__tests__/env.test.ts new file mode 100644 index 00000000..2ecc3d66 --- /dev/null +++ b/src/lib/utils/__tests__/env.test.ts @@ -0,0 +1,155 @@ +import { getEnvPath, getEnvVar, readEnvFile, setEnvVar, writeEnvFile } from '../env.js'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('getEnvPath', () => { + it('joins configRoot with .env.local', () => { + const result = getEnvPath('/some/root'); + expect(result).toBe('/some/root/.env.local'); + }); + + it('throws when no configRoot and no project found', () => { + // With no configRoot, it calls findConfigRoot() which may fail + // In a tmpdir with no agentcore/ directory, it should throw + const isolated = mkdtempSync(join(tmpdir(), 'env-test-noroot-')); + const origCwd = process.cwd(); + const origInitCwd = process.env.INIT_CWD; + try { + process.chdir(isolated); + delete process.env.INIT_CWD; + expect(() => getEnvPath()).toThrow(); + } finally { + process.chdir(origCwd); + if (origInitCwd !== undefined) process.env.INIT_CWD = origInitCwd; + else delete process.env.INIT_CWD; + rmSync(isolated, { recursive: true, force: true }); + } + }); +}); + +describe('readEnvFile + writeEnvFile', () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'env-test-')); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('returns empty record when .env.local does not exist', async () => { + const emptyRoot = mkdtempSync(join(tmpdir(), 'env-test-empty-')); + try { + const result = await readEnvFile(emptyRoot); + expect(result).toEqual({}); + } finally { + rmSync(emptyRoot, { recursive: true, force: true }); + } + }); + + it('reads existing .env.local file', async () => { + writeFileSync(join(root, '.env.local'), 'MY_KEY="my_value"\nANOTHER="val2"\n'); + const result = await readEnvFile(root); + expect(result.MY_KEY).toBe('my_value'); + expect(result.ANOTHER).toBe('val2'); + }); + + it('writes env file and reads it back', async () => { + const dir = mkdtempSync(join(tmpdir(), 'env-test-write-')); + try { + await writeEnvFile({ FOO: 'bar', BAZ: 'qux' }, dir, false); + const result = await readEnvFile(dir); + expect(result.FOO).toBe('bar'); + expect(result.BAZ).toBe('qux'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('merges with existing values by default', async () => { + const dir = mkdtempSync(join(tmpdir(), 'env-test-merge-')); + try { + await writeEnvFile({ EXISTING: 'old' }, dir, false); + await writeEnvFile({ NEW_KEY: 'new' }, dir); // merge = true by default + const result = await readEnvFile(dir); + expect(result.EXISTING).toBe('old'); + expect(result.NEW_KEY).toBe('new'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('overwrites when merge is false', async () => { + const dir = mkdtempSync(join(tmpdir(), 'env-test-overwrite-')); + try { + await writeEnvFile({ OLD: 'value' }, dir, false); + await writeEnvFile({ NEW: 'value' }, dir, false); + const result = await readEnvFile(dir); + expect(result.NEW).toBe('value'); + expect(result.OLD).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('escapes special characters in values', async () => { + const dir = mkdtempSync(join(tmpdir(), 'env-test-escape-')); + try { + await writeEnvFile({ KEY: 'value with "quotes" and \\ backslash' }, dir, false); + const raw = readFileSync(join(dir, '.env.local'), 'utf-8'); + expect(raw).toContain('\\\\'); // escaped backslash + expect(raw).toContain('\\"'); // escaped quote + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('throws for invalid env key', async () => { + const dir = mkdtempSync(join(tmpdir(), 'env-test-invalid-')); + try { + await expect(writeEnvFile({ 'invalid-key': 'value' }, dir, false)).rejects.toThrow('Invalid env key'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('getEnvVar', () => { + it('returns a specific env var value', async () => { + const dir = mkdtempSync(join(tmpdir(), 'env-test-getvar-')); + try { + writeFileSync(join(dir, '.env.local'), 'TARGET="found"\n'); + const result = await getEnvVar('TARGET', dir); + expect(result).toBe('found'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns undefined for missing key', async () => { + const dir = mkdtempSync(join(tmpdir(), 'env-test-missing-')); + try { + writeFileSync(join(dir, '.env.local'), 'OTHER="val"\n'); + const result = await getEnvVar('MISSING', dir); + expect(result).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('setEnvVar', () => { + it('sets a single env var', async () => { + const dir = mkdtempSync(join(tmpdir(), 'env-test-setvar-')); + try { + await setEnvVar('SINGLE', 'value', dir); + const result = await readEnvFile(dir); + expect(result.SINGLE).toBe('value'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/utils/__tests__/platform.test.ts b/src/lib/utils/__tests__/platform.test.ts new file mode 100644 index 00000000..489e29e0 --- /dev/null +++ b/src/lib/utils/__tests__/platform.test.ts @@ -0,0 +1,50 @@ +import { getShellArgs, getShellCommand, getVenvExecutable, normalizeCommand } from '../platform.js'; +import { describe, expect, it } 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/); + }); + + it('includes executable name in path', () => { + const result = getVenvExecutable('/path/to/.venv', 'uvicorn'); + expect(result).toContain('uvicorn'); + }); +}); + +describe('getShellCommand', () => { + it('returns a string', () => { + const result = getShellCommand(); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); +}); + +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'); + } + }); + + it('preserves commands that already have extensions', () => { + // Even on any platform, already-extended commands should pass through + expect(normalizeCommand('python.exe')).toBe('python.exe'); + }); +}); diff --git a/src/lib/utils/__tests__/subprocess.test.ts b/src/lib/utils/__tests__/subprocess.test.ts new file mode 100644 index 00000000..4b7dc84d --- /dev/null +++ b/src/lib/utils/__tests__/subprocess.test.ts @@ -0,0 +1,98 @@ +import { + checkSubprocess, + checkSubprocessSync, + runSubprocess, + runSubprocessCapture, + runSubprocessCaptureSync, +} from '../subprocess.js'; +import { describe, expect, it } from 'vitest'; + +describe('runSubprocess', () => { + it('resolves on success (exit code 0)', async () => { + await expect(runSubprocess('true', [], { stdio: 'ignore' })).resolves.toBeUndefined(); + }); + + it('rejects on non-zero exit code', async () => { + await expect(runSubprocess('false', [], { stdio: 'ignore' })).rejects.toThrow('exited with code 1'); + }); + + it('rejects on unknown command', async () => { + await expect(runSubprocess('__nonexistent_command_xyz__', [], { stdio: 'ignore' })).rejects.toThrow(); + }); +}); + +describe('checkSubprocess', () => { + it('returns true for exit code 0', async () => { + expect(await checkSubprocess('true', [])).toBe(true); + }); + + it('returns false for non-zero exit code', async () => { + expect(await checkSubprocess('false', [])).toBe(false); + }); + + it('returns false for unknown command', async () => { + expect(await checkSubprocess('__nonexistent_command_xyz__', [])).toBe(false); + }); +}); + +describe('runSubprocessCapture', () => { + it('captures stdout', async () => { + const result = await runSubprocessCapture('echo', ['hello']); + expect(result.stdout.trim()).toBe('hello'); + expect(result.code).toBe(0); + }); + + it('captures stderr', async () => { + const result = await runSubprocessCapture('sh', ['-c', 'echo error >&2']); + expect(result.stderr.trim()).toBe('error'); + }); + + it('returns non-zero code for failing command', async () => { + const result = await runSubprocessCapture('false', []); + expect(result.code).not.toBe(0); + }); + + it('returns code -1 for unknown command', async () => { + const result = await runSubprocessCapture('__nonexistent_command_xyz__', []); + expect(result.code).toBe(-1); + }); + + it('respects cwd option', async () => { + const result = await runSubprocessCapture('pwd', [], { cwd: '/tmp' }); + // /tmp might resolve to /private/tmp on macOS + expect(result.stdout.trim()).toMatch(/\/tmp$/); + expect(result.code).toBe(0); + }); +}); + +describe('runSubprocessCaptureSync', () => { + it('captures stdout synchronously', () => { + const result = runSubprocessCaptureSync('echo', ['hello']); + expect(result.stdout.trim()).toBe('hello'); + expect(result.code).toBe(0); + }); + + it('returns non-zero code for failing command', () => { + const result = runSubprocessCaptureSync('false', []); + expect(result.code).not.toBe(0); + }); + + it('captures stderr synchronously', () => { + const result = runSubprocessCaptureSync('sh', ['-c', 'echo err >&2']); + expect(result.stderr.trim()).toBe('err'); + }); +}); + +describe('checkSubprocessSync', () => { + it('returns true for exit code 0', () => { + expect(checkSubprocessSync('true', [])).toBe(true); + }); + + it('returns false for non-zero exit code', () => { + expect(checkSubprocessSync('false', [])).toBe(false); + }); + + it('returns false for unknown command', () => { + expect(checkSubprocessSync('__nonexistent_command_xyz__', [])).toBe(false); + }); +}); diff --git a/src/lib/utils/__tests__/zod.test.ts b/src/lib/utils/__tests__/zod.test.ts new file mode 100644 index 00000000..c6b5aeac --- /dev/null +++ b/src/lib/utils/__tests__/zod.test.ts @@ -0,0 +1,81 @@ +import { validateAgentSchema, validateProjectSchema } from '../zod.js'; +import { describe, expect, it } from 'vitest'; + +describe('validateAgentSchema', () => { + const validAgent = { + type: 'AgentCoreRuntime', + name: 'TestAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: './agents/test', + runtimeVersion: 'PYTHON_3_12', + }; + + it('returns validated data for valid input', () => { + const result = validateAgentSchema(validAgent); + expect(result.name).toBe('TestAgent'); + expect(result.build).toBe('CodeZip'); + }); + + it('throws for invalid input', () => { + expect(() => validateAgentSchema({})).toThrow('Invalid AgentEnvSpec'); + }); + + it('includes field-level errors in message', () => { + try { + validateAgentSchema({ type: 'Invalid' }); + expect.fail('Should have thrown'); + } catch (e) { + expect((e as Error).message).toContain('Invalid AgentEnvSpec'); + } + }); + + it('throws for null input', () => { + expect(() => validateAgentSchema(null)).toThrow(); + }); +}); + +describe('validateProjectSchema', () => { + const validProject = { + name: 'TestProject', + version: 1, + agents: [], + memories: [], + credentials: [], + }; + + it('returns validated data for valid input', () => { + const result = validateProjectSchema(validProject); + expect(result.name).toBe('TestProject'); + expect(result.version).toBe(1); + }); + + it('applies defaults for missing optional arrays', () => { + const result = validateProjectSchema({ name: 'MyProject', version: 1 }); + expect(result.agents).toEqual([]); + expect(result.memories).toEqual([]); + expect(result.credentials).toEqual([]); + }); + + it('throws for invalid input', () => { + expect(() => validateProjectSchema({})).toThrow('Invalid AgentCoreProjectSpec'); + }); + + it('throws for duplicate agent names', () => { + const agent = { + type: 'AgentCoreRuntime', + name: 'Same', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: '.', + runtimeVersion: 'PYTHON_3_12', + }; + expect(() => + validateProjectSchema({ + name: 'MyProject', + version: 1, + agents: [agent, agent], + }) + ).toThrow('Invalid AgentCoreProjectSpec'); + }); +}); diff --git a/src/schema/__tests__/constants.test.ts b/src/schema/__tests__/constants.test.ts new file mode 100644 index 00000000..653bef11 --- /dev/null +++ b/src/schema/__tests__/constants.test.ts @@ -0,0 +1,136 @@ +import { + ModelProviderSchema, + NetworkModeSchema, + NodeRuntimeSchema, + PythonRuntimeSchema, + RESERVED_PROJECT_NAMES, + RuntimeVersionSchema, + SDKFrameworkSchema, + getSupportedModelProviders, + isModelProviderSupported, + isReservedProjectName, +} from '../constants.js'; +import { describe, expect, it } from 'vitest'; + +describe('SDKFrameworkSchema', () => { + it.each(['Strands', 'LangChain_LangGraph', 'CrewAI', 'GoogleADK', 'OpenAIAgents'])('accepts "%s"', framework => { + expect(SDKFrameworkSchema.safeParse(framework).success).toBe(true); + }); + + it('rejects invalid framework', () => { + expect(SDKFrameworkSchema.safeParse('AutoGen').success).toBe(false); + expect(SDKFrameworkSchema.safeParse('strands').success).toBe(false); // case-sensitive + }); +}); + +describe('ModelProviderSchema', () => { + it.each(['Bedrock', 'Gemini', 'OpenAI', 'Anthropic'])('accepts "%s"', provider => { + expect(ModelProviderSchema.safeParse(provider).success).toBe(true); + }); + + it('rejects invalid provider', () => { + expect(ModelProviderSchema.safeParse('Azure').success).toBe(false); + }); +}); + +describe('PythonRuntimeSchema', () => { + it.each(['PYTHON_3_10', 'PYTHON_3_11', 'PYTHON_3_12', 'PYTHON_3_13'])('accepts "%s"', version => { + expect(PythonRuntimeSchema.safeParse(version).success).toBe(true); + }); + + it('rejects unsupported versions', () => { + expect(PythonRuntimeSchema.safeParse('PYTHON_3_9').success).toBe(false); + expect(PythonRuntimeSchema.safeParse('PYTHON_3_14').success).toBe(false); + }); +}); + +describe('NodeRuntimeSchema', () => { + it.each(['NODE_18', 'NODE_20', 'NODE_22'])('accepts "%s"', version => { + expect(NodeRuntimeSchema.safeParse(version).success).toBe(true); + }); + + it('rejects unsupported versions', () => { + expect(NodeRuntimeSchema.safeParse('NODE_16').success).toBe(false); + expect(NodeRuntimeSchema.safeParse('NODE_24').success).toBe(false); + }); +}); + +describe('RuntimeVersionSchema', () => { + it('accepts Python versions', () => { + expect(RuntimeVersionSchema.safeParse('PYTHON_3_12').success).toBe(true); + }); + + it('accepts Node versions', () => { + expect(RuntimeVersionSchema.safeParse('NODE_20').success).toBe(true); + }); + + it('rejects invalid versions', () => { + expect(RuntimeVersionSchema.safeParse('RUBY_3_0').success).toBe(false); + }); +}); + +describe('NetworkModeSchema', () => { + it('accepts PUBLIC', () => { + expect(NetworkModeSchema.safeParse('PUBLIC').success).toBe(true); + }); + + it('accepts PRIVATE', () => { + expect(NetworkModeSchema.safeParse('PRIVATE').success).toBe(true); + }); + + it('rejects other modes', () => { + expect(NetworkModeSchema.safeParse('VPC').success).toBe(false); + }); +}); + +describe('getSupportedModelProviders', () => { + it('returns all 4 providers for Strands', () => { + expect(getSupportedModelProviders('Strands')).toEqual(['Bedrock', 'Anthropic', 'OpenAI', 'Gemini']); + }); + + it('returns only Gemini for GoogleADK', () => { + expect(getSupportedModelProviders('GoogleADK')).toEqual(['Gemini']); + }); + + it('returns only OpenAI for OpenAIAgents', () => { + expect(getSupportedModelProviders('OpenAIAgents')).toEqual(['OpenAI']); + }); +}); + +describe('isModelProviderSupported', () => { + it('returns true for supported combinations', () => { + expect(isModelProviderSupported('Strands', 'Bedrock')).toBe(true); + expect(isModelProviderSupported('GoogleADK', 'Gemini')).toBe(true); + expect(isModelProviderSupported('OpenAIAgents', 'OpenAI')).toBe(true); + }); + + it('returns false for unsupported combinations', () => { + expect(isModelProviderSupported('GoogleADK', 'Bedrock')).toBe(false); + expect(isModelProviderSupported('OpenAIAgents', 'Anthropic')).toBe(false); + }); +}); + +describe('isReservedProjectName', () => { + it('detects reserved names case-insensitively', () => { + expect(isReservedProjectName('anthropic')).toBe(true); + expect(isReservedProjectName('Anthropic')).toBe(true); + expect(isReservedProjectName('ANTHROPIC')).toBe(true); + }); + + it('detects common reserved names', () => { + expect(isReservedProjectName('boto3')).toBe(true); + expect(isReservedProjectName('openai')).toBe(true); + expect(isReservedProjectName('test')).toBe(true); + expect(isReservedProjectName('pip')).toBe(true); + expect(isReservedProjectName('build')).toBe(true); + }); + + it('returns false for non-reserved names', () => { + expect(isReservedProjectName('MyProject')).toBe(false); + expect(isReservedProjectName('AgentOne')).toBe(false); + }); + + it('RESERVED_PROJECT_NAMES is not empty', () => { + expect(RESERVED_PROJECT_NAMES.length).toBeGreaterThan(0); + }); +}); diff --git a/src/schema/schemas/__tests__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts new file mode 100644 index 00000000..ebda0437 --- /dev/null +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -0,0 +1,261 @@ +import { + AgentEnvSpecSchema, + AgentNameSchema, + BuildTypeSchema, + EntrypointSchema, + EnvVarNameSchema, + EnvVarSchema, + GatewayNameSchema, + InstrumentationSchema, +} from '../agent-env.js'; +import { describe, expect, it } from 'vitest'; + +describe('AgentNameSchema', () => { + it.each(['Agent1', 'myAgent', 'A', 'agent_with_underscores', 'a' + '0'.repeat(47)])( + 'accepts valid name "%s"', + name => { + expect(AgentNameSchema.safeParse(name).success).toBe(true); + } + ); + + it('rejects empty string', () => { + expect(AgentNameSchema.safeParse('').success).toBe(false); + }); + + it('rejects name starting with digit', () => { + expect(AgentNameSchema.safeParse('1Agent').success).toBe(false); + }); + + it('rejects name with hyphens', () => { + expect(AgentNameSchema.safeParse('my-agent').success).toBe(false); + }); + + it('rejects name exceeding 48 chars', () => { + const name = 'A' + 'b'.repeat(48); + expect(name).toHaveLength(49); + expect(AgentNameSchema.safeParse(name).success).toBe(false); + }); + + it('accepts 48-char name (max)', () => { + const name = 'A' + 'b'.repeat(47); + expect(name).toHaveLength(48); + expect(AgentNameSchema.safeParse(name).success).toBe(true); + }); +}); + +describe('EnvVarNameSchema', () => { + it.each(['MY_VAR', '_private', 'UPPER123', 'a', '_'])('accepts valid env var name "%s"', name => { + expect(EnvVarNameSchema.safeParse(name).success).toBe(true); + }); + + it('rejects name starting with digit', () => { + expect(EnvVarNameSchema.safeParse('1VAR').success).toBe(false); + }); + + it('rejects name with hyphens', () => { + expect(EnvVarNameSchema.safeParse('MY-VAR').success).toBe(false); + }); + + it('rejects empty string', () => { + expect(EnvVarNameSchema.safeParse('').success).toBe(false); + }); +}); + +describe('GatewayNameSchema', () => { + it.each(['gateway1', 'my-gateway', 'MyGateway', 'a'])('accepts valid gateway name "%s"', name => { + expect(GatewayNameSchema.safeParse(name).success).toBe(true); + }); + + it('rejects empty string', () => { + expect(GatewayNameSchema.safeParse('').success).toBe(false); + }); + + it('rejects name with underscores', () => { + expect(GatewayNameSchema.safeParse('my_gateway').success).toBe(false); + }); + + it('rejects name exceeding 100 chars', () => { + const name = 'a'.repeat(101); + expect(GatewayNameSchema.safeParse(name).success).toBe(false); + }); +}); + +describe('EntrypointSchema', () => { + describe('Python entrypoints', () => { + it('accepts simple Python file', () => { + expect(EntrypointSchema.safeParse('main.py').success).toBe(true); + }); + + it('accepts Python file with handler', () => { + expect(EntrypointSchema.safeParse('main.py:handler').success).toBe(true); + }); + + it('accepts nested Python path', () => { + expect(EntrypointSchema.safeParse('src/handler.py:app').success).toBe(true); + }); + }); + + describe('TypeScript/JavaScript entrypoints', () => { + it('accepts TypeScript file', () => { + expect(EntrypointSchema.safeParse('index.ts').success).toBe(true); + }); + + it('accepts JavaScript file', () => { + expect(EntrypointSchema.safeParse('main.js').success).toBe(true); + }); + + it('accepts nested path', () => { + expect(EntrypointSchema.safeParse('src/index.ts').success).toBe(true); + }); + }); + + describe('invalid entrypoints', () => { + it('rejects file without valid extension', () => { + expect(EntrypointSchema.safeParse('main.rb').success).toBe(false); + }); + + it('rejects empty string', () => { + expect(EntrypointSchema.safeParse('').success).toBe(false); + }); + + it('rejects handler with invalid characters', () => { + expect(EntrypointSchema.safeParse('main.py:123').success).toBe(false); + }); + }); +}); + +describe('EnvVarSchema', () => { + it('accepts valid env var', () => { + const result = EnvVarSchema.safeParse({ name: 'MY_KEY', value: 'my-value' }); + expect(result.success).toBe(true); + }); + + it('rejects invalid name', () => { + const result = EnvVarSchema.safeParse({ name: '123', value: 'val' }); + expect(result.success).toBe(false); + }); + + it('accepts empty value string', () => { + const result = EnvVarSchema.safeParse({ name: 'KEY', value: '' }); + expect(result.success).toBe(true); + }); +}); + +describe('BuildTypeSchema', () => { + it('accepts CodeZip', () => { + expect(BuildTypeSchema.safeParse('CodeZip').success).toBe(true); + }); + + it('accepts Container', () => { + expect(BuildTypeSchema.safeParse('Container').success).toBe(true); + }); + + it('rejects invalid build type', () => { + expect(BuildTypeSchema.safeParse('Docker').success).toBe(false); + expect(BuildTypeSchema.safeParse('lambda').success).toBe(false); + }); +}); + +describe('InstrumentationSchema', () => { + it('accepts explicit enableOtel true', () => { + const result = InstrumentationSchema.safeParse({ enableOtel: true }); + expect(result.success).toBe(true); + }); + + it('accepts explicit enableOtel false', () => { + const result = InstrumentationSchema.safeParse({ enableOtel: false }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.enableOtel).toBe(false); + } + }); + + it('defaults enableOtel to true', () => { + const result = InstrumentationSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.enableOtel).toBe(true); + } + }); +}); + +describe('AgentEnvSpecSchema', () => { + const validPythonAgent = { + type: 'AgentCoreRuntime', + name: 'TestAgent', + build: 'CodeZip', + entrypoint: 'main.py:handler', + codeLocation: './agents/test', + runtimeVersion: 'PYTHON_3_12', + }; + + const validNodeAgent = { + type: 'AgentCoreRuntime', + name: 'NodeAgent', + build: 'CodeZip', + entrypoint: 'index.ts', + codeLocation: './agents/node', + runtimeVersion: 'NODE_20', + }; + + it('accepts valid Python agent', () => { + expect(AgentEnvSpecSchema.safeParse(validPythonAgent).success).toBe(true); + }); + + it('accepts valid Node agent', () => { + expect(AgentEnvSpecSchema.safeParse(validNodeAgent).success).toBe(true); + }); + + it('accepts agent with all Python runtime versions', () => { + for (const version of ['PYTHON_3_10', 'PYTHON_3_11', 'PYTHON_3_12', 'PYTHON_3_13']) { + const result = AgentEnvSpecSchema.safeParse({ ...validPythonAgent, runtimeVersion: version }); + expect(result.success, `Should accept ${version}`).toBe(true); + } + }); + + it('accepts agent with all Node runtime versions', () => { + for (const version of ['NODE_18', 'NODE_20', 'NODE_22']) { + const result = AgentEnvSpecSchema.safeParse({ ...validNodeAgent, runtimeVersion: version }); + expect(result.success, `Should accept ${version}`).toBe(true); + } + }); + + it('rejects invalid runtime version', () => { + expect(AgentEnvSpecSchema.safeParse({ ...validPythonAgent, runtimeVersion: 'PYTHON_3_9' }).success).toBe(false); + expect(AgentEnvSpecSchema.safeParse({ ...validNodeAgent, runtimeVersion: 'NODE_16' }).success).toBe(false); + }); + + it('accepts agent with optional env vars', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validPythonAgent, + envVars: [{ name: 'API_KEY', value: 'secret' }], + }); + expect(result.success).toBe(true); + }); + + it('accepts agent with network mode', () => { + expect(AgentEnvSpecSchema.safeParse({ ...validPythonAgent, networkMode: 'PUBLIC' }).success).toBe(true); + expect(AgentEnvSpecSchema.safeParse({ ...validPythonAgent, networkMode: 'PRIVATE' }).success).toBe(true); + }); + + it('rejects invalid network mode', () => { + expect(AgentEnvSpecSchema.safeParse({ ...validPythonAgent, networkMode: 'VPC' }).success).toBe(false); + }); + + it('accepts agent with instrumentation config', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validPythonAgent, + instrumentation: { enableOtel: false }, + }); + expect(result.success).toBe(true); + }); + + it('rejects invalid type literal', () => { + expect(AgentEnvSpecSchema.safeParse({ ...validPythonAgent, type: 'Lambda' }).success).toBe(false); + }); + + it('rejects missing required fields', () => { + expect(AgentEnvSpecSchema.safeParse({ type: 'AgentCoreRuntime' }).success).toBe(false); + expect(AgentEnvSpecSchema.safeParse({ ...validPythonAgent, name: undefined }).success).toBe(false); + }); +}); diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts new file mode 100644 index 00000000..64565f66 --- /dev/null +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -0,0 +1,397 @@ +import { + AgentCoreProjectSpecSchema, + CredentialNameSchema, + CredentialSchema, + MemoryNameSchema, + MemorySchema, + ProjectNameSchema, +} from '../agentcore-project.js'; +import { describe, expect, it } from 'vitest'; + +describe('ProjectNameSchema', () => { + describe('valid names', () => { + it.each(['A', 'MyProject', 'test1', 'a1b2c3', 'ALLCAPS', 'abcdefghijklmnopqrstuvw'])('accepts "%s"', name => { + expect(ProjectNameSchema.safeParse(name).success).toBe(true); + }); + }); + + describe('length validation', () => { + it('rejects empty string', () => { + expect(ProjectNameSchema.safeParse('').success).toBe(false); + }); + + it('accepts 1-character name', () => { + expect(ProjectNameSchema.safeParse('A').success).toBe(true); + }); + + it('accepts 23-character name (max)', () => { + const name = 'A' + 'b'.repeat(22); + expect(name).toHaveLength(23); + expect(ProjectNameSchema.safeParse(name).success).toBe(true); + }); + + it('rejects 24-character name', () => { + const name = 'A' + 'b'.repeat(23); + expect(name).toHaveLength(24); + expect(ProjectNameSchema.safeParse(name).success).toBe(false); + }); + }); + + describe('format validation', () => { + it('rejects name starting with a digit', () => { + expect(ProjectNameSchema.safeParse('1project').success).toBe(false); + }); + + it('rejects name with underscores', () => { + expect(ProjectNameSchema.safeParse('my_project').success).toBe(false); + }); + + it('rejects name with hyphens', () => { + expect(ProjectNameSchema.safeParse('my-project').success).toBe(false); + }); + + it('rejects name with spaces', () => { + expect(ProjectNameSchema.safeParse('my project').success).toBe(false); + }); + + it('rejects name with special characters', () => { + expect(ProjectNameSchema.safeParse('my.project').success).toBe(false); + expect(ProjectNameSchema.safeParse('my@project').success).toBe(false); + }); + }); + + describe('reserved name validation', () => { + it.each(['anthropic', 'Anthropic', 'ANTHROPIC', 'openai', 'boto3', 'strands', 'test', 'pip', 'uv'])( + 'rejects reserved name "%s"', + name => { + // Some reserved names may also fail the regex (e.g., too long). We just check it doesn't pass. + expect(ProjectNameSchema.safeParse(name).success).toBe(false); + } + ); + + it('accepts non-reserved name', () => { + expect(ProjectNameSchema.safeParse('MyAgent').success).toBe(true); + }); + }); +}); + +describe('MemoryNameSchema', () => { + it('accepts valid names', () => { + expect(MemoryNameSchema.safeParse('myMemory').success).toBe(true); + expect(MemoryNameSchema.safeParse('Memory1').success).toBe(true); + expect(MemoryNameSchema.safeParse('my_memory_store').success).toBe(true); + }); + + it('rejects empty string', () => { + expect(MemoryNameSchema.safeParse('').success).toBe(false); + }); + + it('rejects name starting with digit', () => { + expect(MemoryNameSchema.safeParse('1memory').success).toBe(false); + }); + + it('rejects name with hyphens', () => { + expect(MemoryNameSchema.safeParse('my-memory').success).toBe(false); + }); + + it('accepts 48-character name (max)', () => { + const name = 'A' + 'b'.repeat(47); + expect(name).toHaveLength(48); + expect(MemoryNameSchema.safeParse(name).success).toBe(true); + }); + + it('rejects 49-character name', () => { + const name = 'A' + 'b'.repeat(48); + expect(name).toHaveLength(49); + expect(MemoryNameSchema.safeParse(name).success).toBe(false); + }); +}); + +describe('MemorySchema', () => { + it('accepts valid memory with strategies', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'TestMemory', + eventExpiryDuration: 30, + strategies: [{ type: 'SEMANTIC' }], + }); + expect(result.success).toBe(true); + }); + + it('accepts memory with empty strategies (short-term only)', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'ShortTermOnly', + eventExpiryDuration: 7, + strategies: [], + }); + expect(result.success).toBe(true); + }); + + it('defaults strategies to empty array', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'NoStrategies', + eventExpiryDuration: 30, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.strategies).toEqual([]); + } + }); + + it('rejects eventExpiryDuration below 7', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'Test', + eventExpiryDuration: 6, + strategies: [], + }); + expect(result.success).toBe(false); + }); + + it('rejects eventExpiryDuration above 365', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'Test', + eventExpiryDuration: 366, + strategies: [], + }); + expect(result.success).toBe(false); + }); + + it('accepts eventExpiryDuration boundary values (7 and 365)', () => { + expect( + MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'Min', + eventExpiryDuration: 7, + strategies: [], + }).success + ).toBe(true); + + expect( + MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'Max', + eventExpiryDuration: 365, + strategies: [], + }).success + ).toBe(true); + }); + + it('rejects non-integer eventExpiryDuration', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'Test', + eventExpiryDuration: 30.5, + strategies: [], + }); + expect(result.success).toBe(false); + }); + + it('rejects duplicate strategy types', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'Test', + eventExpiryDuration: 30, + strategies: [{ type: 'SEMANTIC' }, { type: 'SEMANTIC' }], + }); + expect(result.success).toBe(false); + }); + + it('accepts multiple different strategy types', () => { + const result = MemorySchema.safeParse({ + type: 'AgentCoreMemory', + name: 'Test', + eventExpiryDuration: 30, + strategies: [{ type: 'SEMANTIC' }, { type: 'SUMMARIZATION' }, { type: 'USER_PREFERENCE' }], + }); + expect(result.success).toBe(true); + }); + + it('rejects invalid type literal', () => { + const result = MemorySchema.safeParse({ + type: 'InvalidType', + name: 'Test', + eventExpiryDuration: 30, + strategies: [], + }); + expect(result.success).toBe(false); + }); +}); + +describe('CredentialNameSchema', () => { + it('accepts valid credential names', () => { + expect(CredentialNameSchema.safeParse('MyProjectGemini').success).toBe(true); + expect(CredentialNameSchema.safeParse('api-key.v2').success).toBe(true); + expect(CredentialNameSchema.safeParse('my_cred_123').success).toBe(true); + }); + + it('rejects names shorter than 3 characters', () => { + expect(CredentialNameSchema.safeParse('ab').success).toBe(false); + expect(CredentialNameSchema.safeParse('a').success).toBe(false); + }); + + it('accepts name with exactly 3 characters', () => { + expect(CredentialNameSchema.safeParse('abc').success).toBe(true); + }); + + it('rejects names with spaces', () => { + expect(CredentialNameSchema.safeParse('my credential').success).toBe(false); + }); + + it('rejects names with special characters', () => { + expect(CredentialNameSchema.safeParse('my@cred').success).toBe(false); + expect(CredentialNameSchema.safeParse('my/cred').success).toBe(false); + }); +}); + +describe('CredentialSchema', () => { + it('accepts valid credential', () => { + const result = CredentialSchema.safeParse({ + type: 'ApiKeyCredentialProvider', + name: 'MyCredential', + }); + expect(result.success).toBe(true); + }); + + it('rejects invalid type', () => { + const result = CredentialSchema.safeParse({ + type: 'OAuthProvider', + name: 'MyCredential', + }); + expect(result.success).toBe(false); + }); +}); + +describe('AgentCoreProjectSpecSchema', () => { + const minimalProject = { + name: 'TestProject', + version: 1, + }; + + it('accepts minimal project spec', () => { + const result = AgentCoreProjectSpecSchema.safeParse(minimalProject); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.agents).toEqual([]); + expect(result.data.memories).toEqual([]); + expect(result.data.credentials).toEqual([]); + } + }); + + it('accepts project with agents', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'MyAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: './agents/my-agent', + runtimeVersion: 'PYTHON_3_12', + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('rejects duplicate agent names', () => { + const agent = { + type: 'AgentCoreRuntime', + name: 'MyAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: './agents/my-agent', + runtimeVersion: 'PYTHON_3_12', + }; + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + agents: [agent, agent], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Duplicate agent name'))).toBe(true); + } + }); + + it('rejects duplicate memory names', () => { + const memory = { + type: 'AgentCoreMemory', + name: 'SharedMemory', + eventExpiryDuration: 30, + strategies: [], + }; + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + memories: [memory, memory], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Duplicate memory name'))).toBe(true); + } + }); + + it('rejects duplicate credential names', () => { + const cred = { + type: 'ApiKeyCredentialProvider', + name: 'MyCred', + }; + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + credentials: [cred, cred], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Duplicate credential name'))).toBe(true); + } + }); + + it('accepts project with all resource types', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + name: 'FullProject', + version: 1, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'Agent1', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: './agents/agent1', + runtimeVersion: 'PYTHON_3_12', + }, + { + type: 'AgentCoreRuntime', + name: 'Agent2', + build: 'Container', + entrypoint: 'index.ts', + codeLocation: './agents/agent2', + runtimeVersion: 'NODE_20', + }, + ], + memories: [ + { + type: 'AgentCoreMemory', + name: 'Memory1', + eventExpiryDuration: 30, + strategies: [{ type: 'SEMANTIC' }], + }, + ], + credentials: [ + { type: 'ApiKeyCredentialProvider', name: 'Cred1' }, + { type: 'ApiKeyCredentialProvider', name: 'Cred2' }, + ], + }); + expect(result.success).toBe(true); + }); + + it('rejects non-integer version', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + name: 'Test', + version: 1.5, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/schema/schemas/__tests__/aws-targets.test.ts b/src/schema/schemas/__tests__/aws-targets.test.ts new file mode 100644 index 00000000..ed5160f5 --- /dev/null +++ b/src/schema/schemas/__tests__/aws-targets.test.ts @@ -0,0 +1,158 @@ +import { + AgentCoreRegionSchema, + AwsAccountIdSchema, + AwsDeploymentTargetSchema, + AwsDeploymentTargetsSchema, + DeploymentTargetNameSchema, +} from '../aws-targets.js'; +import { describe, expect, it } from 'vitest'; + +describe('AgentCoreRegionSchema', () => { + const validRegions = [ + 'ap-northeast-1', + 'ap-south-1', + 'ap-southeast-1', + 'ap-southeast-2', + 'eu-central-1', + 'eu-west-1', + 'us-east-1', + 'us-east-2', + 'us-west-2', + ]; + + it.each(validRegions)('accepts valid region "%s"', region => { + expect(AgentCoreRegionSchema.safeParse(region).success).toBe(true); + }); + + it('rejects unsupported regions', () => { + expect(AgentCoreRegionSchema.safeParse('us-west-1').success).toBe(false); + expect(AgentCoreRegionSchema.safeParse('eu-west-2').success).toBe(false); + expect(AgentCoreRegionSchema.safeParse('sa-east-1').success).toBe(false); + }); + + it('rejects empty string', () => { + expect(AgentCoreRegionSchema.safeParse('').success).toBe(false); + }); + + it('rejects non-string values', () => { + expect(AgentCoreRegionSchema.safeParse(123).success).toBe(false); + expect(AgentCoreRegionSchema.safeParse(null).success).toBe(false); + }); +}); + +describe('AwsAccountIdSchema', () => { + it('accepts valid 12-digit account ID', () => { + expect(AwsAccountIdSchema.safeParse('123456789012').success).toBe(true); + expect(AwsAccountIdSchema.safeParse('000000000000').success).toBe(true); + }); + + it('rejects account ID shorter than 12 digits', () => { + expect(AwsAccountIdSchema.safeParse('12345678901').success).toBe(false); + }); + + it('rejects account ID longer than 12 digits', () => { + expect(AwsAccountIdSchema.safeParse('1234567890123').success).toBe(false); + }); + + it('rejects non-numeric account ID', () => { + expect(AwsAccountIdSchema.safeParse('12345678901a').success).toBe(false); + expect(AwsAccountIdSchema.safeParse('abcdefghijkl').success).toBe(false); + }); + + it('rejects account ID with spaces', () => { + expect(AwsAccountIdSchema.safeParse('123 456 7890').success).toBe(false); + }); + + it('rejects empty string', () => { + expect(AwsAccountIdSchema.safeParse('').success).toBe(false); + }); +}); + +describe('DeploymentTargetNameSchema', () => { + it('accepts valid names', () => { + expect(DeploymentTargetNameSchema.safeParse('default').success).toBe(true); + expect(DeploymentTargetNameSchema.safeParse('prod').success).toBe(true); + expect(DeploymentTargetNameSchema.safeParse('dev-us-east').success).toBe(true); + expect(DeploymentTargetNameSchema.safeParse('staging_env').success).toBe(true); + }); + + it('rejects name starting with digit', () => { + expect(DeploymentTargetNameSchema.safeParse('1target').success).toBe(false); + }); + + it('rejects name starting with hyphen', () => { + expect(DeploymentTargetNameSchema.safeParse('-target').success).toBe(false); + }); + + it('rejects empty string', () => { + expect(DeploymentTargetNameSchema.safeParse('').success).toBe(false); + }); + + it('rejects name exceeding 64 chars', () => { + const name = 'a'.repeat(65); + expect(DeploymentTargetNameSchema.safeParse(name).success).toBe(false); + }); + + it('accepts 64-char name (max)', () => { + const name = 'a'.repeat(64); + expect(DeploymentTargetNameSchema.safeParse(name).success).toBe(true); + }); +}); + +describe('AwsDeploymentTargetSchema', () => { + const validTarget = { + name: 'prod', + account: '123456789012', + region: 'us-east-1', + }; + + it('accepts valid target', () => { + expect(AwsDeploymentTargetSchema.safeParse(validTarget).success).toBe(true); + }); + + it('accepts target with optional description', () => { + const result = AwsDeploymentTargetSchema.safeParse({ + ...validTarget, + description: 'Production environment', + }); + expect(result.success).toBe(true); + }); + + it('rejects description exceeding 256 chars', () => { + const result = AwsDeploymentTargetSchema.safeParse({ + ...validTarget, + description: 'a'.repeat(257), + }); + expect(result.success).toBe(false); + }); + + it('rejects missing required fields', () => { + expect(AwsDeploymentTargetSchema.safeParse({ name: 'prod' }).success).toBe(false); + expect(AwsDeploymentTargetSchema.safeParse({ account: '123456789012' }).success).toBe(false); + }); +}); + +describe('AwsDeploymentTargetsSchema', () => { + it('accepts array of unique targets', () => { + const result = AwsDeploymentTargetsSchema.safeParse([ + { name: 'dev', account: '123456789012', region: 'us-east-1' }, + { name: 'prod', account: '987654321098', region: 'us-west-2' }, + ]); + expect(result.success).toBe(true); + }); + + it('accepts empty array', () => { + expect(AwsDeploymentTargetsSchema.safeParse([]).success).toBe(true); + }); + + it('rejects duplicate target names', () => { + const result = AwsDeploymentTargetsSchema.safeParse([ + { name: 'prod', account: '123456789012', region: 'us-east-1' }, + { name: 'prod', account: '987654321098', region: 'us-west-2' }, + ]); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Duplicate deployment target name'))).toBe(true); + } + }); +}); diff --git a/src/schema/schemas/__tests__/deployed-state.test.ts b/src/schema/schemas/__tests__/deployed-state.test.ts new file mode 100644 index 00000000..dd57d183 --- /dev/null +++ b/src/schema/schemas/__tests__/deployed-state.test.ts @@ -0,0 +1,238 @@ +import { + AgentCoreDeployedStateSchema, + CustomJwtAuthorizerSchema, + DeployedResourceStateSchema, + DeployedStateSchema, + GatewayDeployedStateSchema, + McpDeployedStateSchema, + McpLambdaDeployedStateSchema, + McpRuntimeDeployedStateSchema, + VpcConfigSchema, + createValidatedDeployedStateSchema, +} from '../deployed-state.js'; +import { describe, expect, it } from 'vitest'; + +describe('AgentCoreDeployedStateSchema', () => { + it('accepts minimal valid state', () => { + const result = AgentCoreDeployedStateSchema.safeParse({ + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123:role/TestRole', + }); + expect(result.success).toBe(true); + }); + + it('accepts state with all optional fields', () => { + const result = AgentCoreDeployedStateSchema.safeParse({ + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123:role/TestRole', + sessionId: 'sess-abc', + memoryIds: ['mem-1', 'mem-2'], + browserId: 'browser-1', + codeInterpreterId: 'ci-1', + }); + expect(result.success).toBe(true); + }); + + it('rejects empty runtimeId', () => { + expect( + AgentCoreDeployedStateSchema.safeParse({ + runtimeId: '', + runtimeArn: 'arn:valid', + roleArn: 'arn:valid', + }).success + ).toBe(false); + }); + + it('rejects missing required fields', () => { + expect(AgentCoreDeployedStateSchema.safeParse({ runtimeId: 'rt-123' }).success).toBe(false); + }); +}); + +describe('GatewayDeployedStateSchema', () => { + it('accepts valid gateway state', () => { + expect( + GatewayDeployedStateSchema.safeParse({ + gatewayId: 'gw-123', + gatewayArn: 'arn:aws:gateway/gw-123', + }).success + ).toBe(true); + }); + + it('rejects empty gatewayId', () => { + expect( + GatewayDeployedStateSchema.safeParse({ + gatewayId: '', + gatewayArn: 'arn:valid', + }).success + ).toBe(false); + }); +}); + +describe('McpRuntimeDeployedStateSchema', () => { + it('accepts valid runtime state', () => { + expect( + McpRuntimeDeployedStateSchema.safeParse({ + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:runtime/rt-123', + runtimeEndpoint: 'https://endpoint.example.com', + }).success + ).toBe(true); + }); +}); + +describe('McpLambdaDeployedStateSchema', () => { + it('accepts valid lambda state', () => { + expect( + McpLambdaDeployedStateSchema.safeParse({ + functionArn: 'arn:aws:lambda:us-east-1:123:function:my-func', + functionName: 'my-func', + }).success + ).toBe(true); + }); +}); + +describe('McpDeployedStateSchema', () => { + it('accepts empty MCP state', () => { + expect(McpDeployedStateSchema.safeParse({}).success).toBe(true); + }); + + it('accepts full MCP state', () => { + const result = McpDeployedStateSchema.safeParse({ + gateways: { + myGateway: { gatewayId: 'gw-1', gatewayArn: 'arn:gw-1' }, + }, + runtimes: { + myRuntime: { runtimeId: 'rt-1', runtimeArn: 'arn:rt-1', runtimeEndpoint: 'https://endpoint' }, + }, + lambdas: { + myLambda: { functionArn: 'arn:lambda', functionName: 'func' }, + }, + }); + expect(result.success).toBe(true); + }); +}); + +describe('CustomJwtAuthorizerSchema', () => { + it('accepts valid JWT authorizer', () => { + const result = CustomJwtAuthorizerSchema.safeParse({ + name: 'my-authorizer', + allowedAudience: ['client-1'], + allowedClients: ['client-1'], + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + }); + expect(result.success).toBe(true); + }); +}); + +describe('VpcConfigSchema', () => { + it('accepts valid VPC config', () => { + const result = VpcConfigSchema.safeParse({ + name: 'my-vpc', + securityGroups: ['sg-123'], + subnets: ['subnet-abc'], + }); + expect(result.success).toBe(true); + }); +}); + +describe('DeployedResourceStateSchema', () => { + it('accepts empty resource state', () => { + expect(DeployedResourceStateSchema.safeParse({}).success).toBe(true); + }); + + it('accepts resource state with agents', () => { + const result = DeployedResourceStateSchema.safeParse({ + agents: { + MyAgent: { + runtimeId: 'rt-123', + runtimeArn: 'arn:rt', + roleArn: 'arn:role', + }, + }, + stackName: 'TestStack', + }); + expect(result.success).toBe(true); + }); + + it('accepts resource state with identityKmsKeyArn', () => { + const result = DeployedResourceStateSchema.safeParse({ + identityKmsKeyArn: 'arn:aws:kms:us-east-1:123:key/abc', + }); + expect(result.success).toBe(true); + }); +}); + +describe('DeployedStateSchema', () => { + it('accepts valid deployed state with targets', () => { + const result = DeployedStateSchema.safeParse({ + targets: { + default: { + resources: { + agents: {}, + stackName: 'TestStack', + }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts state with multiple targets', () => { + const result = DeployedStateSchema.safeParse({ + targets: { + dev: { resources: {} }, + prod: { resources: {} }, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts empty targets', () => { + const result = DeployedStateSchema.safeParse({ targets: {} }); + expect(result.success).toBe(true); + }); +}); + +describe('createValidatedDeployedStateSchema', () => { + it('accepts state with targets matching known target names', () => { + const schema = createValidatedDeployedStateSchema(['dev', 'prod']); + const result = schema.safeParse({ + targets: { + dev: { resources: {} }, + prod: { resources: {} }, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts state with subset of known target names', () => { + const schema = createValidatedDeployedStateSchema(['dev', 'prod', 'staging']); + const result = schema.safeParse({ + targets: { + dev: { resources: {} }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects state with unknown target names', () => { + const schema = createValidatedDeployedStateSchema(['dev', 'prod']); + const result = schema.safeParse({ + targets: { + unknown: { resources: {} }, + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('not present in aws-targets'))).toBe(true); + } + }); + + it('accepts empty targets regardless of known names', () => { + const schema = createValidatedDeployedStateSchema(['dev']); + const result = schema.safeParse({ targets: {} }); + expect(result.success).toBe(true); + }); +}); diff --git a/src/schema/schemas/__tests__/mcp-defs.test.ts b/src/schema/schemas/__tests__/mcp-defs.test.ts new file mode 100644 index 00000000..a673bbd5 --- /dev/null +++ b/src/schema/schemas/__tests__/mcp-defs.test.ts @@ -0,0 +1,161 @@ +import { + AgentCoreCliMcpDefsSchema, + SchemaDefinitionSchema, + ToolDefinitionSchema, + ToolNameSchema, +} from '../mcp-defs.js'; +import { describe, expect, it } from 'vitest'; + +describe('ToolNameSchema', () => { + it.each(['myTool', 'get_user', 'search-results', 'A'])('accepts valid name "%s"', name => { + expect(ToolNameSchema.safeParse(name).success).toBe(true); + }); + + it('rejects empty string', () => { + expect(ToolNameSchema.safeParse('').success).toBe(false); + }); + + it('rejects name starting with digit', () => { + expect(ToolNameSchema.safeParse('1tool').success).toBe(false); + }); + + it('rejects name starting with hyphen', () => { + expect(ToolNameSchema.safeParse('-tool').success).toBe(false); + }); + + it('rejects name exceeding 128 chars', () => { + const name = 'a'.repeat(129); + expect(ToolNameSchema.safeParse(name).success).toBe(false); + }); + + it('accepts 128-char name (max)', () => { + const name = 'a'.repeat(128); + expect(ToolNameSchema.safeParse(name).success).toBe(true); + }); + + it('rejects name with dots', () => { + expect(ToolNameSchema.safeParse('my.tool').success).toBe(false); + }); +}); + +describe('SchemaDefinitionSchema', () => { + it('accepts simple string type', () => { + const result = SchemaDefinitionSchema.safeParse({ type: 'string' }); + expect(result.success).toBe(true); + }); + + it('accepts all primitive types', () => { + for (const type of ['string', 'number', 'object', 'array', 'boolean', 'integer']) { + expect(SchemaDefinitionSchema.safeParse({ type }).success, `Should accept type: ${type}`).toBe(true); + } + }); + + it('accepts type with description', () => { + const result = SchemaDefinitionSchema.safeParse({ + type: 'string', + description: 'A user name', + }); + expect(result.success).toBe(true); + }); + + it('accepts nested object schema', () => { + const result = SchemaDefinitionSchema.safeParse({ + type: 'object', + properties: { + name: { type: 'string', description: 'Name' }, + age: { type: 'integer' }, + }, + required: ['name'], + }); + expect(result.success).toBe(true); + }); + + it('accepts array schema with items', () => { + const result = SchemaDefinitionSchema.safeParse({ + type: 'array', + items: { type: 'string' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts deeply nested schema', () => { + const result = SchemaDefinitionSchema.safeParse({ + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects invalid type', () => { + expect(SchemaDefinitionSchema.safeParse({ type: 'date' }).success).toBe(false); + }); +}); + +describe('ToolDefinitionSchema', () => { + const validDef = { + name: 'myTool', + description: 'Does something', + inputSchema: { type: 'object' as const }, + }; + + it('accepts valid tool definition', () => { + expect(ToolDefinitionSchema.safeParse(validDef).success).toBe(true); + }); + + it('accepts tool with output schema', () => { + const result = ToolDefinitionSchema.safeParse({ + ...validDef, + outputSchema: { type: 'string' as const }, + }); + expect(result.success).toBe(true); + }); + + it('rejects empty name', () => { + expect(ToolDefinitionSchema.safeParse({ ...validDef, name: '' }).success).toBe(false); + }); + + it('rejects empty description', () => { + expect(ToolDefinitionSchema.safeParse({ ...validDef, description: '' }).success).toBe(false); + }); + + it('rejects missing inputSchema', () => { + expect(ToolDefinitionSchema.safeParse({ name: 'tool', description: 'desc' }).success).toBe(false); + }); + + it('rejects extra fields (strict)', () => { + expect(ToolDefinitionSchema.safeParse({ ...validDef, extra: true }).success).toBe(false); + }); +}); + +describe('AgentCoreCliMcpDefsSchema', () => { + it('accepts valid MCP defs', () => { + const result = AgentCoreCliMcpDefsSchema.safeParse({ + tools: { + myTool: { + name: 'myTool', + description: 'A tool', + inputSchema: { type: 'object' as const }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts empty tools record', () => { + expect(AgentCoreCliMcpDefsSchema.safeParse({ tools: {} }).success).toBe(true); + }); + + it('rejects missing tools field', () => { + expect(AgentCoreCliMcpDefsSchema.safeParse({}).success).toBe(false); + }); +}); diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts new file mode 100644 index 00000000..cd437fd2 --- /dev/null +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -0,0 +1,404 @@ +import { + AgentCoreGatewaySchema, + AgentCoreGatewayTargetSchema, + AgentCoreMcpRuntimeToolSchema, + AgentCoreMcpSpecSchema, + CustomJwtAuthorizerConfigSchema, + GatewayAuthorizerTypeSchema, + GatewayTargetTypeSchema, + McpImplLanguageSchema, + RuntimeConfigSchema, + ToolComputeConfigSchema, + ToolImplementationBindingSchema, +} from '../mcp.js'; +import { describe, expect, it } from 'vitest'; + +describe('GatewayTargetTypeSchema', () => { + it.each(['lambda', 'mcpServer', 'openApiSchema', 'smithyModel'])('accepts "%s"', type => { + expect(GatewayTargetTypeSchema.safeParse(type).success).toBe(true); + }); + + it('rejects invalid type', () => { + expect(GatewayTargetTypeSchema.safeParse('http').success).toBe(false); + }); +}); + +describe('GatewayAuthorizerTypeSchema', () => { + it('accepts NONE', () => { + expect(GatewayAuthorizerTypeSchema.safeParse('NONE').success).toBe(true); + }); + + it('accepts CUSTOM_JWT', () => { + expect(GatewayAuthorizerTypeSchema.safeParse('CUSTOM_JWT').success).toBe(true); + }); + + it('rejects other types', () => { + expect(GatewayAuthorizerTypeSchema.safeParse('IAM').success).toBe(false); + }); +}); + +describe('McpImplLanguageSchema', () => { + it('accepts TypeScript', () => { + expect(McpImplLanguageSchema.safeParse('TypeScript').success).toBe(true); + }); + + it('accepts Python', () => { + expect(McpImplLanguageSchema.safeParse('Python').success).toBe(true); + }); + + it('rejects other languages', () => { + expect(McpImplLanguageSchema.safeParse('Go').success).toBe(false); + }); +}); + +describe('CustomJwtAuthorizerConfigSchema', () => { + const validConfig = { + discoveryUrl: 'https://cognito-idp.us-east-1.amazonaws.com/pool123/.well-known/openid-configuration', + allowedAudience: ['client-id-1'], + allowedClients: ['client-id-1'], + }; + + it('accepts valid config', () => { + expect(CustomJwtAuthorizerConfigSchema.safeParse(validConfig).success).toBe(true); + }); + + it('rejects discovery URL without OIDC suffix', () => { + const result = CustomJwtAuthorizerConfigSchema.safeParse({ + ...validConfig, + discoveryUrl: 'https://example.com/auth', + }); + expect(result.success).toBe(false); + }); + + it('rejects non-URL discovery URL', () => { + const result = CustomJwtAuthorizerConfigSchema.safeParse({ + ...validConfig, + discoveryUrl: 'not-a-url', + }); + expect(result.success).toBe(false); + }); + + it('rejects empty allowedClients', () => { + const result = CustomJwtAuthorizerConfigSchema.safeParse({ + ...validConfig, + allowedClients: [], + }); + expect(result.success).toBe(false); + }); + + it('accepts empty allowedAudience (no audience validation)', () => { + const result = CustomJwtAuthorizerConfigSchema.safeParse({ + ...validConfig, + allowedAudience: [], + }); + expect(result.success).toBe(true); + }); +}); + +describe('ToolImplementationBindingSchema', () => { + it('accepts valid Python binding', () => { + const result = ToolImplementationBindingSchema.safeParse({ + language: 'Python', + path: 'tools/my_tool', + handler: 'handler.main', + }); + expect(result.success).toBe(true); + }); + + it('accepts valid TypeScript binding', () => { + const result = ToolImplementationBindingSchema.safeParse({ + language: 'TypeScript', + path: 'tools/my-tool', + handler: 'index.handler', + }); + expect(result.success).toBe(true); + }); + + it('rejects extra fields (strict)', () => { + const result = ToolImplementationBindingSchema.safeParse({ + language: 'Python', + path: 'tools/my_tool', + handler: 'handler.main', + extraField: 'not allowed', + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid language', () => { + const result = ToolImplementationBindingSchema.safeParse({ + language: 'Go', + path: 'tools/my_tool', + handler: 'main', + }); + expect(result.success).toBe(false); + }); +}); + +describe('ToolComputeConfigSchema (discriminated union)', () => { + it('accepts valid Lambda compute with TypeScript', () => { + const result = ToolComputeConfigSchema.safeParse({ + host: 'Lambda', + implementation: { language: 'TypeScript', path: 'tools/my-tool', handler: 'index.handler' }, + nodeVersion: 'NODE_20', + }); + expect(result.success).toBe(true); + }); + + it('accepts valid Lambda compute with Python', () => { + const result = ToolComputeConfigSchema.safeParse({ + host: 'Lambda', + implementation: { language: 'Python', path: 'tools/my-tool', handler: 'handler.main' }, + pythonVersion: 'PYTHON_3_12', + }); + expect(result.success).toBe(true); + }); + + it('rejects TypeScript Lambda without nodeVersion', () => { + const result = ToolComputeConfigSchema.safeParse({ + host: 'Lambda', + implementation: { language: 'TypeScript', path: 'tools/my-tool', handler: 'index.handler' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects Python Lambda without pythonVersion', () => { + const result = ToolComputeConfigSchema.safeParse({ + host: 'Lambda', + implementation: { language: 'Python', path: 'tools/my-tool', handler: 'handler.main' }, + }); + expect(result.success).toBe(false); + }); + + it('accepts valid AgentCoreRuntime compute (Python only)', () => { + const result = ToolComputeConfigSchema.safeParse({ + host: 'AgentCoreRuntime', + implementation: { language: 'Python', path: 'tools/my-tool', handler: 'handler.main' }, + }); + expect(result.success).toBe(true); + }); + + it('rejects AgentCoreRuntime with TypeScript', () => { + const result = ToolComputeConfigSchema.safeParse({ + host: 'AgentCoreRuntime', + implementation: { language: 'TypeScript', path: 'tools/my-tool', handler: 'index.handler' }, + }); + expect(result.success).toBe(false); + }); + + it('accepts Lambda with optional timeout and memorySize', () => { + const result = ToolComputeConfigSchema.safeParse({ + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + timeout: 30, + memorySize: 256, + }); + expect(result.success).toBe(true); + }); + + it('rejects Lambda timeout exceeding 900', () => { + const result = ToolComputeConfigSchema.safeParse({ + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + timeout: 901, + }); + expect(result.success).toBe(false); + }); + + it('rejects Lambda memorySize below 128', () => { + const result = ToolComputeConfigSchema.safeParse({ + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + memorySize: 64, + }); + expect(result.success).toBe(false); + }); +}); + +describe('RuntimeConfigSchema', () => { + const validRuntime = { + artifact: 'CodeZip', + pythonVersion: 'PYTHON_3_12', + name: 'MyRuntime', + entrypoint: 'main.py:handler', + codeLocation: './tools/runtime', + }; + + it('accepts valid runtime config', () => { + expect(RuntimeConfigSchema.safeParse(validRuntime).success).toBe(true); + }); + + it('defaults networkMode to PUBLIC', () => { + const result = RuntimeConfigSchema.safeParse(validRuntime); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.networkMode).toBe('PUBLIC'); + } + }); + + it('accepts explicit PRIVATE networkMode', () => { + const result = RuntimeConfigSchema.safeParse({ ...validRuntime, networkMode: 'PRIVATE' }); + expect(result.success).toBe(true); + }); + + it('rejects extra fields (strict)', () => { + const result = RuntimeConfigSchema.safeParse({ ...validRuntime, extra: 'not allowed' }); + expect(result.success).toBe(false); + }); +}); + +describe('AgentCoreGatewayTargetSchema', () => { + const validToolDef = { + name: 'myTool', + description: 'A test tool', + inputSchema: { type: 'object' as const }, + }; + + it('accepts valid target', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'lambda', + toolDefinitions: [validToolDef], + }); + expect(result.success).toBe(true); + }); + + it('rejects empty toolDefinitions', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'lambda', + toolDefinitions: [], + }); + expect(result.success).toBe(false); + }); + + it('accepts target with compute config', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'lambda', + toolDefinitions: [validToolDef], + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + }); + expect(result.success).toBe(true); + }); +}); + +describe('AgentCoreGatewaySchema', () => { + const validToolDef = { + name: 'myTool', + description: 'A test tool', + inputSchema: { type: 'object' as const }, + }; + + const validGateway = { + name: 'my-gateway', + targets: [ + { + name: 'target1', + targetType: 'lambda', + toolDefinitions: [validToolDef], + }, + ], + }; + + it('accepts valid gateway with default NONE auth', () => { + const result = AgentCoreGatewaySchema.safeParse(validGateway); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.authorizerType).toBe('NONE'); + } + }); + + it('accepts gateway with CUSTOM_JWT and valid config', () => { + const result = AgentCoreGatewaySchema.safeParse({ + ...validGateway, + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: ['aud'], + allowedClients: ['client'], + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects CUSTOM_JWT without authorizer configuration', () => { + const result = AgentCoreGatewaySchema.safeParse({ + ...validGateway, + authorizerType: 'CUSTOM_JWT', + }); + expect(result.success).toBe(false); + }); + + it('rejects CUSTOM_JWT with empty authorizer configuration', () => { + const result = AgentCoreGatewaySchema.safeParse({ + ...validGateway, + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: {}, + }); + expect(result.success).toBe(false); + }); +}); + +describe('AgentCoreMcpRuntimeToolSchema', () => { + const validTool = { + name: 'my-tool', + toolDefinition: { + name: 'myTool', + description: 'A tool', + inputSchema: { type: 'object' as const }, + }, + compute: { + host: 'AgentCoreRuntime', + implementation: { language: 'Python', path: 'tools/my-tool', handler: 'handler.main' }, + }, + }; + + it('accepts valid MCP runtime tool', () => { + expect(AgentCoreMcpRuntimeToolSchema.safeParse(validTool).success).toBe(true); + }); + + it('accepts tool with bindings', () => { + const result = AgentCoreMcpRuntimeToolSchema.safeParse({ + ...validTool, + bindings: [{ agentName: 'Agent1', envVarName: 'TOOL_ARN' }], + }); + expect(result.success).toBe(true); + }); +}); + +describe('AgentCoreMcpSpecSchema', () => { + it('accepts valid MCP spec', () => { + const validToolDef = { + name: 'tool', + description: 'A tool', + inputSchema: { type: 'object' as const }, + }; + + const result = AgentCoreMcpSpecSchema.safeParse({ + agentCoreGateways: [ + { + name: 'gw1', + targets: [{ name: 't1', targetType: 'lambda', toolDefinitions: [validToolDef] }], + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('rejects extra fields (strict)', () => { + const result = AgentCoreMcpSpecSchema.safeParse({ + agentCoreGateways: [], + unknownField: true, + }); + expect(result.success).toBe(false); + }); +}); From 428f1bde7fe3cd0cb52f18b9c4f68c183141a41b Mon Sep 17 00:00:00 2001 From: notgitika Date: Sun, 15 Feb 2026 16:20:04 -0500 Subject: [PATCH 2/7] test: add unit tests for AWS SDK, CloudFormation, command validators, and path resolver --- src/cli/__tests__/errors.test.ts | 17 ++ .../aws/__tests__/account-extended.test.ts | 124 ++++++++++ .../aws/__tests__/agentcore-control.test.ts | 58 +++++ src/cli/aws/__tests__/aws-context.test.ts | 47 ++++ src/cli/aws/__tests__/bedrock.test.ts | 111 +++++++++ src/cli/aws/__tests__/region.test.ts | 116 ++++++++++ .../__tests__/bootstrap-extended.test.ts | 94 ++++++++ .../__tests__/outputs-getstack.test.ts | 159 +++++++++++++ .../__tests__/stack-status.test.ts | 140 +++++++++++ .../create/__tests__/validate.test.ts | 151 ++++++++++++ .../deploy/__tests__/validate.test.ts | 16 ++ .../invoke/__tests__/validate.test.ts | 36 +++ .../remove/__tests__/validate.test.ts | 36 +++ .../__tests__/checks-extended.test.ts | 128 ++++++++++ .../__tests__/versions.test.ts | 80 +++++++ .../operations/dev/__tests__/config.test.ts | 142 +++++++++++- src/lib/__tests__/constants.test.ts | 20 ++ src/lib/errors/__tests__/config.test.ts | 60 +++++ .../io/__tests__/path-resolver.test.ts | 218 ++++++++++++++++++ src/lib/utils/__tests__/env.test.ts | 33 +++ 20 files changed, 1785 insertions(+), 1 deletion(-) create mode 100644 src/cli/aws/__tests__/account-extended.test.ts create mode 100644 src/cli/aws/__tests__/agentcore-control.test.ts create mode 100644 src/cli/aws/__tests__/aws-context.test.ts create mode 100644 src/cli/aws/__tests__/bedrock.test.ts create mode 100644 src/cli/aws/__tests__/region.test.ts create mode 100644 src/cli/cloudformation/__tests__/bootstrap-extended.test.ts create mode 100644 src/cli/cloudformation/__tests__/outputs-getstack.test.ts create mode 100644 src/cli/cloudformation/__tests__/stack-status.test.ts create mode 100644 src/cli/commands/create/__tests__/validate.test.ts create mode 100644 src/cli/commands/deploy/__tests__/validate.test.ts create mode 100644 src/cli/commands/invoke/__tests__/validate.test.ts create mode 100644 src/cli/commands/remove/__tests__/validate.test.ts create mode 100644 src/cli/external-requirements/__tests__/checks-extended.test.ts create mode 100644 src/cli/external-requirements/__tests__/versions.test.ts create mode 100644 src/lib/__tests__/constants.test.ts create mode 100644 src/lib/schemas/io/__tests__/path-resolver.test.ts diff --git a/src/cli/__tests__/errors.test.ts b/src/cli/__tests__/errors.test.ts index 02b5d9be..4a8d2c1c 100644 --- a/src/cli/__tests__/errors.test.ts +++ b/src/cli/__tests__/errors.test.ts @@ -1,4 +1,5 @@ import { + AgentAlreadyExistsError, getErrorMessage, isChangesetInProgressError, isExpiredTokenError, @@ -8,6 +9,22 @@ import { import { describe, expect, it } from 'vitest'; describe('errors', () => { + describe('AgentAlreadyExistsError', () => { + it('has correct message with agent name', () => { + const err = new AgentAlreadyExistsError('my-agent'); + expect(err.message).toBe('An agent named "my-agent" already exists in the schema.'); + }); + + it('has correct name', () => { + const err = new AgentAlreadyExistsError('test'); + expect(err.name).toBe('AgentAlreadyExistsError'); + }); + + it('is instance of Error', () => { + expect(new AgentAlreadyExistsError('test')).toBeInstanceOf(Error); + }); + }); + describe('getErrorMessage', () => { it('returns message from Error instance', () => { const err = new Error('test error'); diff --git a/src/cli/aws/__tests__/account-extended.test.ts b/src/cli/aws/__tests__/account-extended.test.ts new file mode 100644 index 00000000..2cf3dd08 --- /dev/null +++ b/src/cli/aws/__tests__/account-extended.test.ts @@ -0,0 +1,124 @@ +import { AwsCredentialsError, detectAccount, getCredentialProvider, validateAwsCredentials } from '../account.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend } = vi.hoisted(() => ({ + mockSend: vi.fn(), +})); + +vi.mock('@aws-sdk/client-sts', () => ({ + STSClient: class { + send = mockSend; + }, + GetCallerIdentityCommand: class { + constructor(public input: unknown) {} + }, +})); + +vi.mock('@aws-sdk/credential-providers', () => ({ + fromEnv: vi.fn().mockReturnValue({}), + fromNodeProviderChain: vi.fn().mockReturnValue({}), +})); + +function makeNamedError(message: string, name: string): Error { + const err = new Error(message); + Object.defineProperty(err, 'name', { value: name, writable: true }); + return err; +} + +describe('getCredentialProvider', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns a credential provider (function)', () => { + const provider = getCredentialProvider(); + expect(provider).toBeDefined(); + }); +}); + +describe('detectAccount', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns account ID on success', async () => { + mockSend.mockResolvedValue({ Account: '123456789012' }); + + const account = await detectAccount(); + expect(account).toBe('123456789012'); + }); + + it('returns null when Account is undefined', async () => { + mockSend.mockResolvedValue({ Account: undefined }); + + const account = await detectAccount(); + expect(account).toBeNull(); + }); + + it('throws AwsCredentialsError for ExpiredTokenException', async () => { + mockSend.mockRejectedValue(makeNamedError('Token expired', 'ExpiredTokenException')); + + await expect(detectAccount()).rejects.toThrow(AwsCredentialsError); + await expect(detectAccount()).rejects.toThrow('expired'); + }); + + it('throws AwsCredentialsError for ExpiredToken', async () => { + mockSend.mockRejectedValue(makeNamedError('Token expired', 'ExpiredToken')); + + await expect(detectAccount()).rejects.toThrow(AwsCredentialsError); + }); + + it('throws AwsCredentialsError for InvalidClientTokenId', async () => { + mockSend.mockRejectedValue(makeNamedError('Invalid token', 'InvalidClientTokenId')); + + await expect(detectAccount()).rejects.toThrow(AwsCredentialsError); + await expect(detectAccount()).rejects.toThrow('invalid'); + }); + + it('throws AwsCredentialsError for SignatureDoesNotMatch', async () => { + mockSend.mockRejectedValue(makeNamedError('Sig mismatch', 'SignatureDoesNotMatch')); + + await expect(detectAccount()).rejects.toThrow(AwsCredentialsError); + }); + + it('throws AwsCredentialsError for AccessDenied', async () => { + mockSend.mockRejectedValue(makeNamedError('Access denied', 'AccessDenied')); + + await expect(detectAccount()).rejects.toThrow(AwsCredentialsError); + await expect(detectAccount()).rejects.toThrow('permissions'); + }); + + it('throws AwsCredentialsError for AccessDeniedException', async () => { + mockSend.mockRejectedValue(makeNamedError('Access denied', 'AccessDeniedException')); + + await expect(detectAccount()).rejects.toThrow(AwsCredentialsError); + }); + + it('returns null for unknown errors', async () => { + mockSend.mockRejectedValue(new Error('Unknown error')); + + const account = await detectAccount(); + expect(account).toBeNull(); + }); +}); + +describe('validateAwsCredentials', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not throw when credentials are valid', async () => { + mockSend.mockResolvedValue({ Account: '123456789012' }); + + await expect(validateAwsCredentials()).resolves.toBeUndefined(); + }); + + it('throws AwsCredentialsError when detectAccount returns null', async () => { + mockSend.mockRejectedValue(new Error('something')); + + await expect(validateAwsCredentials()).rejects.toThrow(AwsCredentialsError); + await expect(validateAwsCredentials()).rejects.toThrow('No AWS credentials configured'); + }); +}); diff --git a/src/cli/aws/__tests__/agentcore-control.test.ts b/src/cli/aws/__tests__/agentcore-control.test.ts new file mode 100644 index 00000000..9ec6bae3 --- /dev/null +++ b/src/cli/aws/__tests__/agentcore-control.test.ts @@ -0,0 +1,58 @@ +import { getAgentRuntimeStatus } from '../agentcore-control.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend } = vi.hoisted(() => ({ + mockSend: vi.fn(), +})); + +vi.mock('@aws-sdk/client-bedrock-agentcore-control', () => ({ + BedrockAgentCoreControlClient: class { + send = mockSend; + }, + GetAgentRuntimeCommand: class { + constructor(public input: unknown) {} + }, +})); + +vi.mock('../account', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({}), +})); + +describe('getAgentRuntimeStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns runtime status', async () => { + mockSend.mockResolvedValue({ status: 'ACTIVE' }); + + const result = await getAgentRuntimeStatus({ region: 'us-east-1', runtimeId: 'rt-123' }); + expect(result.runtimeId).toBe('rt-123'); + expect(result.status).toBe('ACTIVE'); + }); + + it('throws when no status returned', async () => { + mockSend.mockResolvedValue({ status: undefined }); + + await expect(getAgentRuntimeStatus({ region: 'us-east-1', runtimeId: 'rt-456' })).rejects.toThrow( + 'No status returned for runtime rt-456' + ); + }); + + it('passes correct runtimeId in command', async () => { + mockSend.mockResolvedValue({ status: 'CREATING' }); + + await getAgentRuntimeStatus({ region: 'us-west-2', runtimeId: 'rt-abc' }); + + const command = mockSend.mock.calls[0]![0]; + expect(command.input.agentRuntimeId).toBe('rt-abc'); + }); + + it('propagates SDK errors', async () => { + mockSend.mockRejectedValue(new Error('Service unavailable')); + + await expect(getAgentRuntimeStatus({ region: 'us-east-1', runtimeId: 'rt-err' })).rejects.toThrow( + 'Service unavailable' + ); + }); +}); diff --git a/src/cli/aws/__tests__/aws-context.test.ts b/src/cli/aws/__tests__/aws-context.test.ts new file mode 100644 index 00000000..2b1c5029 --- /dev/null +++ b/src/cli/aws/__tests__/aws-context.test.ts @@ -0,0 +1,47 @@ +import { detectAccount } from '../account'; +import { detectAwsContext } from '../aws-context.js'; +import { detectRegion } from '../region'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock dependencies +vi.mock('../account', () => ({ + detectAccount: vi.fn(), +})); + +vi.mock('../region', () => ({ + detectRegion: vi.fn(), +})); + +describe('detectAwsContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns combined account and region info', async () => { + vi.mocked(detectAccount).mockResolvedValue('123456789012'); + vi.mocked(detectRegion).mockResolvedValue({ region: 'us-west-2', source: 'env' }); + + const result = await detectAwsContext(); + expect(result.accountId).toBe('123456789012'); + expect(result.region).toBe('us-west-2'); + expect(result.regionSource).toBe('env'); + }); + + it('returns null accountId when detection fails', async () => { + vi.mocked(detectAccount).mockResolvedValue(null); + vi.mocked(detectRegion).mockResolvedValue({ region: 'us-east-1', source: 'default' }); + + const result = await detectAwsContext(); + expect(result.accountId).toBeNull(); + expect(result.region).toBe('us-east-1'); + expect(result.regionSource).toBe('default'); + }); + + it('uses config source', async () => { + vi.mocked(detectAccount).mockResolvedValue('111222333444'); + vi.mocked(detectRegion).mockResolvedValue({ region: 'eu-west-1', source: 'config' }); + + const result = await detectAwsContext(); + expect(result.regionSource).toBe('config'); + }); +}); diff --git a/src/cli/aws/__tests__/bedrock.test.ts b/src/cli/aws/__tests__/bedrock.test.ts new file mode 100644 index 00000000..b526e641 --- /dev/null +++ b/src/cli/aws/__tests__/bedrock.test.ts @@ -0,0 +1,111 @@ +import { invokeBedrockSync, invokeClaude } from '../bedrock.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend } = vi.hoisted(() => ({ + mockSend: vi.fn(), +})); + +vi.mock('@aws-sdk/client-bedrock-runtime', () => ({ + BedrockRuntimeClient: class { + send = mockSend; + }, + InvokeModelCommand: class { + constructor(public input: unknown) {} + }, +})); + +vi.mock('../account', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({}), +})); + +describe('invokeBedrockSync', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns parsed JSON response', async () => { + const responseBody = { result: 'hello' }; + mockSend.mockResolvedValue({ + body: new TextEncoder().encode(JSON.stringify(responseBody)), + }); + + const result = await invokeBedrockSync({ + region: 'us-east-1', + modelId: 'test-model', + body: { prompt: 'test' }, + }); + + expect(result).toEqual(responseBody); + }); + + it('sends correct model and content type', async () => { + mockSend.mockResolvedValue({ + body: new TextEncoder().encode('{}'), + }); + + await invokeBedrockSync({ + region: 'us-west-2', + modelId: 'my-model', + body: { key: 'value' }, + }); + + expect(mockSend).toHaveBeenCalledTimes(1); + const command = mockSend.mock.calls[0]![0]; + expect(command.input.modelId).toBe('my-model'); + expect(command.input.contentType).toBe('application/json'); + expect(command.input.accept).toBe('application/json'); + }); +}); + +describe('invokeClaude', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns content text from response', async () => { + mockSend.mockResolvedValue({ + body: new TextEncoder().encode( + JSON.stringify({ + content: [{ type: 'text', text: 'Hello from Claude' }], + }) + ), + }); + + const result = await invokeClaude({ region: 'us-east-1', prompt: 'hi' }); + expect(result.content).toBe('Hello from Claude'); + }); + + it('throws when no content returned', async () => { + mockSend.mockResolvedValue({ + body: new TextEncoder().encode(JSON.stringify({ content: [] })), + }); + + await expect(invokeClaude({ region: 'us-east-1', prompt: 'hi' })).rejects.toThrow( + 'No content returned from Bedrock' + ); + }); + + it('uses default maxTokens when not provided', async () => { + mockSend.mockResolvedValue({ + body: new TextEncoder().encode(JSON.stringify({ content: [{ type: 'text', text: 'ok' }] })), + }); + + await invokeClaude({ region: 'us-east-1', prompt: 'test' }); + + const command = mockSend.mock.calls[0]![0]; + const body = JSON.parse(command.input.body); + expect(body.max_tokens).toBe(8192); + }); + + it('uses custom maxTokens when provided', async () => { + mockSend.mockResolvedValue({ + body: new TextEncoder().encode(JSON.stringify({ content: [{ type: 'text', text: 'ok' }] })), + }); + + await invokeClaude({ region: 'us-east-1', prompt: 'test', maxTokens: 1024 }); + + const command = mockSend.mock.calls[0]![0]; + const body = JSON.parse(command.input.body); + expect(body.max_tokens).toBe(1024); + }); +}); diff --git a/src/cli/aws/__tests__/region.test.ts b/src/cli/aws/__tests__/region.test.ts new file mode 100644 index 00000000..6206ae0a --- /dev/null +++ b/src/cli/aws/__tests__/region.test.ts @@ -0,0 +1,116 @@ +import { detectRegion } from '../region.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockLoadConfig } = vi.hoisted(() => ({ + mockLoadConfig: vi.fn(), +})); + +vi.mock('@smithy/shared-ini-file-loader', () => ({ + loadSharedConfigFiles: mockLoadConfig, +})); + +describe('detectRegion', () => { + const savedRegion = process.env.AWS_REGION; + const savedDefaultRegion = process.env.AWS_DEFAULT_REGION; + const savedProfile = process.env.AWS_PROFILE; + + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + delete process.env.AWS_PROFILE; + }); + + afterEach(() => { + if (savedRegion !== undefined) process.env.AWS_REGION = savedRegion; + else delete process.env.AWS_REGION; + if (savedDefaultRegion !== undefined) process.env.AWS_DEFAULT_REGION = savedDefaultRegion; + else delete process.env.AWS_DEFAULT_REGION; + if (savedProfile !== undefined) process.env.AWS_PROFILE = savedProfile; + else delete process.env.AWS_PROFILE; + }); + + it('returns region from AWS_REGION env var', async () => { + process.env.AWS_REGION = 'us-west-2'; + + const result = await detectRegion(); + expect(result.region).toBe('us-west-2'); + expect(result.source).toBe('env'); + }); + + it('returns region from AWS_DEFAULT_REGION env var', async () => { + process.env.AWS_DEFAULT_REGION = 'eu-west-1'; + + const result = await detectRegion(); + expect(result.region).toBe('eu-west-1'); + expect(result.source).toBe('env'); + }); + + it('AWS_REGION takes precedence over AWS_DEFAULT_REGION', async () => { + process.env.AWS_REGION = 'us-east-1'; + process.env.AWS_DEFAULT_REGION = 'eu-west-1'; + + const result = await detectRegion(); + expect(result.region).toBe('us-east-1'); + expect(result.source).toBe('env'); + }); + + it('ignores invalid regions from env vars', async () => { + process.env.AWS_REGION = 'not-a-real-region'; + + mockLoadConfig.mockResolvedValue({ + configFile: {}, + credentialsFile: {}, + }); + + const result = await detectRegion(); + expect(result.source).not.toBe('env'); + }); + + it('reads region from AWS config file (default profile)', async () => { + mockLoadConfig.mockResolvedValue({ + configFile: { + default: { region: 'ap-southeast-1' }, + }, + credentialsFile: {}, + }); + + const result = await detectRegion(); + expect(result.region).toBe('ap-southeast-1'); + expect(result.source).toBe('config'); + }); + + it('falls back to default profile when AWS_PROFILE not set', async () => { + // AWS_PROFILE not set, so function uses 'default' profile + mockLoadConfig.mockResolvedValue({ + configFile: { + default: { region: 'eu-central-1' }, + other: { region: 'us-west-1' }, + }, + credentialsFile: {}, + }); + + const result = await detectRegion(); + expect(result.region).toBe('eu-central-1'); + expect(result.source).toBe('config'); + }); + + it('returns default us-east-1 when no region found', async () => { + mockLoadConfig.mockResolvedValue({ + configFile: {}, + credentialsFile: {}, + }); + + const result = await detectRegion(); + expect(result.region).toBe('us-east-1'); + expect(result.source).toBe('default'); + }); + + it('returns default when config loading throws', async () => { + mockLoadConfig.mockRejectedValue(new Error('no config file')); + + const result = await detectRegion(); + expect(result.region).toBe('us-east-1'); + expect(result.source).toBe('default'); + }); +}); diff --git a/src/cli/cloudformation/__tests__/bootstrap-extended.test.ts b/src/cli/cloudformation/__tests__/bootstrap-extended.test.ts new file mode 100644 index 00000000..698f6ba2 --- /dev/null +++ b/src/cli/cloudformation/__tests__/bootstrap-extended.test.ts @@ -0,0 +1,94 @@ +import { checkBootstrapStatus } from '../bootstrap.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend } = vi.hoisted(() => ({ + mockSend: vi.fn(), +})); + +vi.mock('@aws-sdk/client-cloudformation', () => ({ + CloudFormationClient: class { + send = mockSend; + }, + DescribeStacksCommand: class { + constructor(public input: unknown) {} + }, +})); + +vi.mock('../../aws', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({}), +})); + +describe('checkBootstrapStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns isBootstrapped true for CREATE_COMPLETE', async () => { + mockSend.mockResolvedValue({ + Stacks: [{ StackStatus: 'CREATE_COMPLETE' }], + }); + + const result = await checkBootstrapStatus('us-east-1'); + expect(result.isBootstrapped).toBe(true); + expect(result.stackStatus).toBe('CREATE_COMPLETE'); + }); + + it('returns isBootstrapped true for UPDATE_COMPLETE', async () => { + mockSend.mockResolvedValue({ + Stacks: [{ StackStatus: 'UPDATE_COMPLETE' }], + }); + + const result = await checkBootstrapStatus('us-east-1'); + expect(result.isBootstrapped).toBe(true); + }); + + it('returns isBootstrapped true for UPDATE_ROLLBACK_COMPLETE', async () => { + mockSend.mockResolvedValue({ + Stacks: [{ StackStatus: 'UPDATE_ROLLBACK_COMPLETE' }], + }); + + const result = await checkBootstrapStatus('us-east-1'); + expect(result.isBootstrapped).toBe(true); + }); + + it('returns isBootstrapped false for in-progress status', async () => { + mockSend.mockResolvedValue({ + Stacks: [{ StackStatus: 'CREATE_IN_PROGRESS' }], + }); + + const result = await checkBootstrapStatus('us-east-1'); + expect(result.isBootstrapped).toBe(false); + expect(result.stackStatus).toBe('CREATE_IN_PROGRESS'); + }); + + it('returns isBootstrapped false for failed status', async () => { + mockSend.mockResolvedValue({ + Stacks: [{ StackStatus: 'ROLLBACK_FAILED' }], + }); + + const result = await checkBootstrapStatus('us-east-1'); + expect(result.isBootstrapped).toBe(false); + }); + + it('returns isBootstrapped false when no stacks returned', async () => { + mockSend.mockResolvedValue({ Stacks: [] }); + + const result = await checkBootstrapStatus('us-east-1'); + expect(result.isBootstrapped).toBe(false); + }); + + it('returns isBootstrapped false when stack not found (ValidationError)', async () => { + const err = new Error('Stack does not exist'); + err.name = 'ValidationError'; + mockSend.mockRejectedValue(err); + + const result = await checkBootstrapStatus('us-east-1'); + expect(result.isBootstrapped).toBe(false); + }); + + it('throws non-ValidationError errors', async () => { + mockSend.mockRejectedValue(new Error('Network error')); + + await expect(checkBootstrapStatus('us-east-1')).rejects.toThrow('Network error'); + }); +}); diff --git a/src/cli/cloudformation/__tests__/outputs-getstack.test.ts b/src/cli/cloudformation/__tests__/outputs-getstack.test.ts new file mode 100644 index 00000000..0870742d --- /dev/null +++ b/src/cli/cloudformation/__tests__/outputs-getstack.test.ts @@ -0,0 +1,159 @@ +import { getStackOutputs, getStackOutputsByProject } from '../outputs.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockCfnSend, mockTagSend } = vi.hoisted(() => ({ + mockCfnSend: vi.fn(), + mockTagSend: vi.fn(), +})); + +vi.mock('@aws-sdk/client-cloudformation', () => ({ + CloudFormationClient: class { + send = mockCfnSend; + }, + DescribeStacksCommand: class { + constructor(public input: unknown) {} + }, +})); + +vi.mock('@aws-sdk/client-resource-groups-tagging-api', () => ({ + ResourceGroupsTaggingAPIClient: class { + send = mockTagSend; + }, + GetResourcesCommand: class { + constructor(public input: unknown) {} + }, +})); + +vi.mock('../../aws', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({}), +})); + +describe('getStackOutputs', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns outputs as key-value map', async () => { + mockCfnSend.mockResolvedValue({ + Stacks: [ + { + Outputs: [ + { OutputKey: 'Key1', OutputValue: 'Value1' }, + { OutputKey: 'Key2', OutputValue: 'Value2' }, + ], + }, + ], + }); + + const outputs = await getStackOutputs('us-east-1', 'MyStack'); + expect(outputs).toEqual({ Key1: 'Value1', Key2: 'Value2' }); + }); + + it('throws when stack not found', async () => { + mockCfnSend.mockResolvedValue({ Stacks: [] }); + + await expect(getStackOutputs('us-east-1', 'MissingStack')).rejects.toThrow('Stack MissingStack not found'); + }); + + it('returns empty object when no outputs', async () => { + mockCfnSend.mockResolvedValue({ + Stacks: [{ Outputs: [] }], + }); + + const outputs = await getStackOutputs('us-east-1', 'MyStack'); + expect(outputs).toEqual({}); + }); + + it('skips outputs with missing keys or values', async () => { + mockCfnSend.mockResolvedValue({ + Stacks: [ + { + Outputs: [ + { OutputKey: 'Valid', OutputValue: 'Value' }, + { OutputKey: undefined, OutputValue: 'NoKey' }, + { OutputKey: 'NoValue', OutputValue: undefined }, + ], + }, + ], + }); + + const outputs = await getStackOutputs('us-east-1', 'MyStack'); + expect(outputs).toEqual({ Valid: 'Value' }); + }); + + it('handles null Outputs', async () => { + mockCfnSend.mockResolvedValue({ + Stacks: [{ Outputs: null }], + }); + + const outputs = await getStackOutputs('us-east-1', 'MyStack'); + expect(outputs).toEqual({}); + }); +}); + +describe('getStackOutputsByProject', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('discovers stack and returns outputs', async () => { + // Mock tag API to find stack + mockTagSend.mockResolvedValue({ + ResourceTagMappingList: [ + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stack/FoundStack/guid', + Tags: [ + { Key: 'agentcore:project-name', Value: 'MyProject' }, + { Key: 'agentcore:target-name', Value: 'default' }, + ], + }, + ], + PaginationToken: undefined, + }); + + // Mock CFN to return outputs + mockCfnSend.mockResolvedValue({ + Stacks: [ + { + Outputs: [{ OutputKey: 'Out1', OutputValue: 'Val1' }], + }, + ], + }); + + const outputs = await getStackOutputsByProject('us-east-1', 'MyProject'); + expect(outputs).toEqual({ Out1: 'Val1' }); + }); + + it('throws when no stack found for project', async () => { + mockTagSend.mockResolvedValue({ + ResourceTagMappingList: [], + PaginationToken: undefined, + }); + + await expect(getStackOutputsByProject('us-east-1', 'MissingProject')).rejects.toThrow( + 'No AgentCore stack found for project "MissingProject" target "default"' + ); + }); + + it('uses custom target name', async () => { + mockTagSend.mockResolvedValue({ + ResourceTagMappingList: [ + { + ResourceARN: 'arn:aws:cloudformation:us-east-1:123:stack/ProdStack/guid', + Tags: [ + { Key: 'agentcore:project-name', Value: 'MyProject' }, + { Key: 'agentcore:target-name', Value: 'prod' }, + ], + }, + ], + PaginationToken: undefined, + }); + + mockCfnSend.mockResolvedValue({ + Stacks: [{ Outputs: [{ OutputKey: 'A', OutputValue: 'B' }] }], + }); + + const outputs = await getStackOutputsByProject('us-east-1', 'MyProject', 'prod'); + expect(outputs).toEqual({ A: 'B' }); + }); +}); diff --git a/src/cli/cloudformation/__tests__/stack-status.test.ts b/src/cli/cloudformation/__tests__/stack-status.test.ts new file mode 100644 index 00000000..1df83fc9 --- /dev/null +++ b/src/cli/cloudformation/__tests__/stack-status.test.ts @@ -0,0 +1,140 @@ +import { checkStackStatus, checkStacksStatus } from '../stack-status.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend } = vi.hoisted(() => ({ + mockSend: vi.fn(), +})); + +vi.mock('@aws-sdk/client-cloudformation', () => ({ + CloudFormationClient: class { + send = mockSend; + }, + DescribeStacksCommand: class { + constructor(public input: unknown) {} + }, +})); + +vi.mock('../../aws', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({}), +})); + +describe('checkStackStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns canDeploy true for CREATE_COMPLETE', async () => { + mockSend.mockResolvedValue({ + Stacks: [{ StackStatus: 'CREATE_COMPLETE' }], + }); + + const result = await checkStackStatus('us-east-1', 'MyStack'); + expect(result.canDeploy).toBe(true); + expect(result.exists).toBe(true); + expect(result.status).toBe('CREATE_COMPLETE'); + }); + + it('returns canDeploy true for UPDATE_COMPLETE', async () => { + mockSend.mockResolvedValue({ + Stacks: [{ StackStatus: 'UPDATE_COMPLETE' }], + }); + + const result = await checkStackStatus('us-east-1', 'MyStack'); + expect(result.canDeploy).toBe(true); + }); + + it('returns canDeploy false for in-progress status', async () => { + const inProgressStatuses = [ + 'CREATE_IN_PROGRESS', + 'UPDATE_IN_PROGRESS', + 'DELETE_IN_PROGRESS', + 'ROLLBACK_IN_PROGRESS', + 'UPDATE_ROLLBACK_IN_PROGRESS', + 'REVIEW_IN_PROGRESS', + ]; + + for (const status of inProgressStatuses) { + mockSend.mockResolvedValue({ Stacks: [{ StackStatus: status }] }); + const result = await checkStackStatus('us-east-1', 'MyStack'); + expect(result.canDeploy, `${status} should block deploy`).toBe(false); + expect(result.exists).toBe(true); + expect(result.message).toContain(status); + } + }); + + it('returns canDeploy false for failed status', async () => { + const failedStatuses = ['CREATE_FAILED', 'ROLLBACK_FAILED', 'DELETE_FAILED', 'UPDATE_ROLLBACK_FAILED']; + + for (const status of failedStatuses) { + mockSend.mockResolvedValue({ Stacks: [{ StackStatus: status }] }); + const result = await checkStackStatus('us-east-1', 'MyStack'); + expect(result.canDeploy, `${status} should block deploy`).toBe(false); + expect(result.message).toContain('Manual intervention'); + } + }); + + it('returns canDeploy true and exists false when stack not found (ValidationError)', async () => { + const err = new Error('Stack with id MyStack does not exist'); + err.name = 'ValidationError'; + mockSend.mockRejectedValue(err); + + const result = await checkStackStatus('us-east-1', 'MyStack'); + expect(result.canDeploy).toBe(true); + expect(result.exists).toBe(false); + }); + + it('throws non-ValidationError errors', async () => { + mockSend.mockRejectedValue(new Error('Access denied')); + + await expect(checkStackStatus('us-east-1', 'MyStack')).rejects.toThrow('Access denied'); + }); + + it('handles stack with no StackStatus', async () => { + mockSend.mockResolvedValue({ + Stacks: [{ StackStatus: undefined }], + }); + + const result = await checkStackStatus('us-east-1', 'MyStack'); + expect(result.canDeploy).toBe(true); + expect(result.exists).toBe(true); + }); + + it('handles empty Stacks array', async () => { + mockSend.mockResolvedValue({ Stacks: [] }); + + const result = await checkStackStatus('us-east-1', 'MyStack'); + expect(result.canDeploy).toBe(true); + expect(result.exists).toBe(true); + }); +}); + +describe('checkStacksStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns null when all stacks are deployable', async () => { + mockSend.mockResolvedValue({ + Stacks: [{ StackStatus: 'CREATE_COMPLETE' }], + }); + + const result = await checkStacksStatus('us-east-1', ['Stack1', 'Stack2']); + expect(result).toBeNull(); + }); + + it('returns first blocking stack', async () => { + mockSend + .mockResolvedValueOnce({ Stacks: [{ StackStatus: 'CREATE_COMPLETE' }] }) + .mockResolvedValueOnce({ Stacks: [{ StackStatus: 'UPDATE_IN_PROGRESS' }] }); + + const result = await checkStacksStatus('us-east-1', ['Stack1', 'Stack2']); + expect(result).not.toBeNull(); + expect(result!.stackName).toBe('Stack2'); + expect(result!.result.canDeploy).toBe(false); + }); + + it('returns null for empty stack list', async () => { + const result = await checkStacksStatus('us-east-1', []); + expect(result).toBeNull(); + }); +}); diff --git a/src/cli/commands/create/__tests__/validate.test.ts b/src/cli/commands/create/__tests__/validate.test.ts new file mode 100644 index 00000000..a33b0609 --- /dev/null +++ b/src/cli/commands/create/__tests__/validate.test.ts @@ -0,0 +1,151 @@ +import { validateCreateOptions, validateFolderNotExists } from '../validate.js'; +import { randomUUID } from 'node:crypto'; +import { mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('validateFolderNotExists', () => { + let testDir: string; + + beforeAll(() => { + testDir = join(tmpdir(), `create-validate-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + mkdirSync(join(testDir, 'existing-project'), { recursive: true }); + }); + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('returns true when folder does not exist', () => { + expect(validateFolderNotExists('new-project', testDir)).toBe(true); + }); + + it('returns error string when folder exists', () => { + const result = validateFolderNotExists('existing-project', testDir); + expect(typeof result).toBe('string'); + expect(result).toContain('already exists'); + }); +}); + +describe('validateCreateOptions', () => { + let testDir: string; + + beforeAll(() => { + testDir = join(tmpdir(), `create-opts-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('returns invalid when name is missing', () => { + const result = validateCreateOptions({}, testDir); + expect(result.valid).toBe(false); + expect(result.error).toContain('--name is required'); + }); + + it('returns invalid for invalid project name', () => { + const result = validateCreateOptions({ name: '!!invalid!!' }, testDir); + expect(result.valid).toBe(false); + }); + + it('returns invalid when folder already exists', () => { + mkdirSync(join(testDir, 'TakenName'), { recursive: true }); + const result = validateCreateOptions({ name: 'TakenName' }, testDir); + expect(result.valid).toBe(false); + expect(result.error).toContain('already exists'); + }); + + it('returns valid with --no-agent flag', () => { + const result = validateCreateOptions({ name: 'NoAgentProject', agent: false }, testDir); + expect(result.valid).toBe(true); + }); + + it('returns invalid when agent options are incomplete', () => { + const result = validateCreateOptions({ name: 'TestProj', framework: 'Strands' }, testDir); + expect(result.valid).toBe(false); + expect(result.error).toContain('--framework'); + }); + + it('returns invalid when language is missing', () => { + const result = validateCreateOptions( + { name: 'TestProj2', framework: 'Strands', modelProvider: 'Bedrock', memory: 'none' }, + testDir + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('--language'); + }); + + it('returns invalid for invalid language', () => { + const result = validateCreateOptions( + { name: 'TestProj3', language: 'Rust', framework: 'Strands', modelProvider: 'Bedrock', memory: 'none' }, + testDir + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid language'); + }); + + it('returns invalid for TypeScript language', () => { + const result = validateCreateOptions( + { name: 'TestProj4', language: 'TypeScript', framework: 'Strands', modelProvider: 'Bedrock', memory: 'none' }, + testDir + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('TypeScript is not yet supported'); + }); + + it('returns invalid for invalid framework', () => { + const result = validateCreateOptions( + { name: 'TestProj5', language: 'Python', framework: 'InvalidFW', modelProvider: 'Bedrock', memory: 'none' }, + testDir + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid framework'); + }); + + it('returns invalid for invalid model provider', () => { + const result = validateCreateOptions( + { name: 'TestProj6', language: 'Python', framework: 'Strands', modelProvider: 'InvalidMP', memory: 'none' }, + testDir + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid model provider'); + }); + + it('returns invalid for invalid memory option', () => { + const result = validateCreateOptions( + { name: 'TestProj7', language: 'Python', framework: 'Strands', modelProvider: 'Bedrock', memory: 'invalid' }, + testDir + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid memory option'); + }); + + it('returns valid with all valid options', () => { + const result = validateCreateOptions( + { name: 'TestProj8', language: 'Python', framework: 'Strands', modelProvider: 'Bedrock', memory: 'none' }, + testDir + ); + expect(result.valid).toBe(true); + }); + + it('returns invalid for unsupported framework/model combination', () => { + // GoogleADK only supports certain providers, not all + const result = validateCreateOptions( + { + name: 'TestProj9', + language: 'Python', + framework: 'GoogleADK', + modelProvider: 'Bedrock', + memory: 'none', + }, + testDir + ); + // This may or may not be valid depending on getSupportedModelProviders + // The test verifies the validation logic runs without error + expect(typeof result.valid).toBe('boolean'); + }); +}); diff --git a/src/cli/commands/deploy/__tests__/validate.test.ts b/src/cli/commands/deploy/__tests__/validate.test.ts new file mode 100644 index 00000000..be490ce2 --- /dev/null +++ b/src/cli/commands/deploy/__tests__/validate.test.ts @@ -0,0 +1,16 @@ +import { validateDeployOptions } from '../validate.js'; +import { describe, expect, it } from 'vitest'; + +describe('validateDeployOptions', () => { + it('always returns valid', () => { + expect(validateDeployOptions({})).toEqual({ valid: true }); + }); + + it('returns valid with all options set', () => { + expect(validateDeployOptions({ target: 'prod', yes: true, verbose: true, json: true })).toEqual({ valid: true }); + }); + + it('returns valid with target only', () => { + expect(validateDeployOptions({ target: 'default' })).toEqual({ valid: true }); + }); +}); diff --git a/src/cli/commands/invoke/__tests__/validate.test.ts b/src/cli/commands/invoke/__tests__/validate.test.ts new file mode 100644 index 00000000..652b02e1 --- /dev/null +++ b/src/cli/commands/invoke/__tests__/validate.test.ts @@ -0,0 +1,36 @@ +import { validateInvokeOptions } from '../validate.js'; +import { describe, expect, it } from 'vitest'; + +describe('validateInvokeOptions', () => { + it('returns valid with no options', () => { + expect(validateInvokeOptions({})).toEqual({ valid: true }); + }); + + it('returns valid with prompt and json', () => { + expect(validateInvokeOptions({ json: true, prompt: 'hello' })).toEqual({ valid: true }); + }); + + it('returns valid with prompt and stream', () => { + expect(validateInvokeOptions({ stream: true, prompt: 'hello' })).toEqual({ valid: true }); + }); + + it('returns invalid when json is true but no prompt', () => { + const result = validateInvokeOptions({ json: true }); + expect(result.valid).toBe(false); + expect(result.error).toContain('Prompt is required for JSON output'); + }); + + it('returns invalid when stream is true but no prompt', () => { + const result = validateInvokeOptions({ stream: true }); + expect(result.valid).toBe(false); + expect(result.error).toContain('Prompt is required for streaming'); + }); + + it('returns valid with prompt only', () => { + expect(validateInvokeOptions({ prompt: 'test' })).toEqual({ valid: true }); + }); + + it('returns valid with agentName and targetName', () => { + expect(validateInvokeOptions({ agentName: 'my-agent', targetName: 'default' })).toEqual({ valid: true }); + }); +}); diff --git a/src/cli/commands/remove/__tests__/validate.test.ts b/src/cli/commands/remove/__tests__/validate.test.ts new file mode 100644 index 00000000..3d421a15 --- /dev/null +++ b/src/cli/commands/remove/__tests__/validate.test.ts @@ -0,0 +1,36 @@ +import { validateRemoveAllOptions, validateRemoveOptions } from '../validate.js'; +import { describe, expect, it } from 'vitest'; + +describe('validateRemoveOptions', () => { + it('returns valid when json is false', () => { + expect(validateRemoveOptions({ resourceType: 'agent', json: false })).toEqual({ valid: true }); + }); + + it('returns valid when json is true and name is provided', () => { + expect(validateRemoveOptions({ resourceType: 'agent', json: true, name: 'my-agent' })).toEqual({ valid: true }); + }); + + it('returns invalid when json is true but no name', () => { + const result = validateRemoveOptions({ resourceType: 'agent', json: true }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--name is required for JSON output'); + }); + + it('returns valid with name but no json', () => { + expect(validateRemoveOptions({ resourceType: 'memory', name: 'mem1' })).toEqual({ valid: true }); + }); + + it('returns valid with no json and no name', () => { + expect(validateRemoveOptions({ resourceType: 'identity' })).toEqual({ valid: true }); + }); +}); + +describe('validateRemoveAllOptions', () => { + it('always returns valid', () => { + expect(validateRemoveAllOptions({})).toEqual({ valid: true }); + }); + + it('returns valid with all options', () => { + expect(validateRemoveAllOptions({ force: true, dryRun: true, json: true })).toEqual({ valid: true }); + }); +}); diff --git a/src/cli/external-requirements/__tests__/checks-extended.test.ts b/src/cli/external-requirements/__tests__/checks-extended.test.ts new file mode 100644 index 00000000..f9aacfb3 --- /dev/null +++ b/src/cli/external-requirements/__tests__/checks-extended.test.ts @@ -0,0 +1,128 @@ +import type { AgentCoreProjectSpec, DirectoryPath, FilePath } from '../../../schema'; +import { checkDependencyVersions, checkNodeVersion, formatVersionError, requiresUv } from '../checks.js'; +import { describe, expect, it } from 'vitest'; + +describe('formatVersionError', () => { + it('formats missing binary error', () => { + const result = formatVersionError({ satisfied: false, current: null, required: '18.0.0', binary: 'node' }); + expect(result).toContain("'node' not found"); + expect(result).toContain('18.0.0'); + }); + + it('formats version too low error', () => { + const result = formatVersionError({ satisfied: false, current: '16.0.0', required: '18.0.0', binary: 'node' }); + expect(result).toContain('16.0.0'); + expect(result).toContain('18.0.0'); + expect(result).toContain('below minimum'); + }); + + it('formats missing uv with specific message', () => { + const result = formatVersionError({ satisfied: false, current: null, required: 'any', binary: 'uv' }); + expect(result).toContain("'uv' not found"); + expect(result).toContain('astral-sh/uv'); + }); +}); + +describe('requiresUv', () => { + it('returns true when project has CodeZip agents', () => { + const project: AgentCoreProjectSpec = { + name: 'Test', + version: 1, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'Agent1', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entrypoint: 'main.py' as FilePath, + codeLocation: './app' as DirectoryPath, + }, + ], + memories: [], + credentials: [], + }; + expect(requiresUv(project)).toBe(true); + }); + + it('returns false when no CodeZip agents', () => { + const project: AgentCoreProjectSpec = { + name: 'Test', + version: 1, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'Agent1', + build: 'Container', + runtimeVersion: 'PYTHON_3_12', + entrypoint: 'main.py' as FilePath, + codeLocation: './app' as DirectoryPath, + }, + ], + memories: [], + credentials: [], + }; + expect(requiresUv(project)).toBe(false); + }); + + it('returns false for empty agents', () => { + const project: AgentCoreProjectSpec = { + name: 'Test', + version: 1, + agents: [], + memories: [], + credentials: [], + }; + expect(requiresUv(project)).toBe(false); + }); +}); + +describe('checkNodeVersion', () => { + it('returns a version check result', async () => { + const result = await checkNodeVersion(); + expect(result.binary).toBe('node'); + expect(result.required).toBeDefined(); + // In test environment, node should be available and satisfy minimum version + expect(result.satisfied).toBe(true); + expect(result.current).not.toBeNull(); + }); +}); + +describe('checkDependencyVersions', () => { + it('passes when node meets requirements and no uv needed', async () => { + const project: AgentCoreProjectSpec = { + name: 'Test', + version: 1, + agents: [], + memories: [], + credentials: [], + }; + + const result = await checkDependencyVersions(project); + expect(result.nodeCheck).toBeDefined(); + expect(result.nodeCheck.binary).toBe('node'); + expect(result.uvCheck).toBeNull(); + }); + + it('checks uv when project has CodeZip agents', async () => { + const project: AgentCoreProjectSpec = { + name: 'Test', + version: 1, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'Agent1', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entrypoint: 'main.py' as FilePath, + codeLocation: './app' as DirectoryPath, + }, + ], + memories: [], + credentials: [], + }; + + const result = await checkDependencyVersions(project); + expect(result.uvCheck).not.toBeNull(); + expect(result.uvCheck!.binary).toBe('uv'); + }); +}); diff --git a/src/cli/external-requirements/__tests__/versions.test.ts b/src/cli/external-requirements/__tests__/versions.test.ts new file mode 100644 index 00000000..2a096bf5 --- /dev/null +++ b/src/cli/external-requirements/__tests__/versions.test.ts @@ -0,0 +1,80 @@ +import { compareSemVer, formatSemVer, parseSemVer, semVerGte } from '../versions.js'; +import { describe, expect, it } from 'vitest'; + +describe('parseSemVer', () => { + it('parses standard version string', () => { + expect(parseSemVer('18.0.0')).toEqual({ major: 18, minor: 0, patch: 0 }); + }); + + it('parses version with leading v', () => { + expect(parseSemVer('v20.10.3')).toEqual({ major: 20, minor: 10, patch: 3 }); + }); + + it('parses zero version', () => { + expect(parseSemVer('0.0.0')).toEqual({ major: 0, minor: 0, patch: 0 }); + }); + + it('parses version with extra suffix', () => { + // The regex stops at three digits so extra text after is ignored + expect(parseSemVer('1.2.3-beta.1')).toEqual({ major: 1, minor: 2, patch: 3 }); + }); + + it('returns null for invalid format', () => { + expect(parseSemVer('not-a-version')).toBeNull(); + expect(parseSemVer('')).toBeNull(); + expect(parseSemVer('1.2')).toBeNull(); + expect(parseSemVer('abc')).toBeNull(); + }); +}); + +describe('compareSemVer', () => { + it('returns 0 for equal versions', () => { + expect(compareSemVer({ major: 1, minor: 2, patch: 3 }, { major: 1, minor: 2, patch: 3 })).toBe(0); + }); + + it('returns positive when a > b by major', () => { + expect(compareSemVer({ major: 2, minor: 0, patch: 0 }, { major: 1, minor: 9, patch: 9 })).toBeGreaterThan(0); + }); + + it('returns negative when a < b by major', () => { + expect(compareSemVer({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBeLessThan(0); + }); + + it('compares by minor when major is equal', () => { + expect(compareSemVer({ major: 1, minor: 5, patch: 0 }, { major: 1, minor: 3, patch: 0 })).toBeGreaterThan(0); + expect(compareSemVer({ major: 1, minor: 3, patch: 0 }, { major: 1, minor: 5, patch: 0 })).toBeLessThan(0); + }); + + it('compares by patch when major and minor are equal', () => { + expect(compareSemVer({ major: 1, minor: 2, patch: 4 }, { major: 1, minor: 2, patch: 3 })).toBeGreaterThan(0); + expect(compareSemVer({ major: 1, minor: 2, patch: 3 }, { major: 1, minor: 2, patch: 4 })).toBeLessThan(0); + }); +}); + +describe('semVerGte', () => { + it('returns true when a > b', () => { + expect(semVerGte({ major: 20, minor: 0, patch: 0 }, { major: 18, minor: 0, patch: 0 })).toBe(true); + }); + + it('returns true when a == b', () => { + expect(semVerGte({ major: 18, minor: 0, patch: 0 }, { major: 18, minor: 0, patch: 0 })).toBe(true); + }); + + it('returns false when a < b', () => { + expect(semVerGte({ major: 16, minor: 0, patch: 0 }, { major: 18, minor: 0, patch: 0 })).toBe(false); + }); +}); + +describe('formatSemVer', () => { + it('formats version to string', () => { + expect(formatSemVer({ major: 1, minor: 2, patch: 3 })).toBe('1.2.3'); + }); + + it('formats zero version', () => { + expect(formatSemVer({ major: 0, minor: 0, patch: 0 })).toBe('0.0.0'); + }); + + it('formats large version numbers', () => { + expect(formatSemVer({ major: 100, minor: 200, patch: 300 })).toBe('100.200.300'); + }); +}); diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index 8c6a18b9..bb956332 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -1,5 +1,5 @@ import type { AgentCoreProjectSpec, DirectoryPath, FilePath } from '../../../../schema'; -import { getDevConfig, getDevSupportedAgents } from '../config'; +import { getAgentPort, getDevConfig, getDevSupportedAgents } from '../config'; import { describe, expect, it } from 'vitest'; // Helper to cast strings to branded path types for testing @@ -94,6 +94,146 @@ describe('getDevConfig', () => { 'Agent "NonExistentAgent" not found' ); }); + + it('throws when specified agent is not Python', () => { + const project: AgentCoreProjectSpec = { + name: 'TestProject', + version: 1, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'NodeAgent', + build: 'CodeZip', + runtimeVersion: 'NODE_20', + entrypoint: filePath('index.js'), + codeLocation: dirPath('./agents/node'), + }, + ], + memories: [], + credentials: [], + }; + + expect(() => getDevConfig(workingDir, project, undefined, 'NodeAgent')).toThrow('Dev mode only supports Python'); + }); + + it('resolves directory from codeLocation relative to configRoot', () => { + const project: AgentCoreProjectSpec = { + name: 'TestProject', + version: 1, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'PythonAgent', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath('main.py'), + codeLocation: dirPath('app/PythonAgent/'), + }, + ], + memories: [], + credentials: [], + }; + + const config = getDevConfig(workingDir, project, '/test/project/agentcore'); + expect(config).not.toBeNull(); + // codeLocation is relative, so it should resolve relative to project root (parent of configRoot) + expect(config!.directory).toContain('app/PythonAgent'); + }); + + it('uses workingDir when no configRoot or codeLocation', () => { + const project: AgentCoreProjectSpec = { + name: 'TestProject', + version: 1, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'PythonAgent', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath('main.py'), + codeLocation: dirPath('./agents/python'), + }, + ], + memories: [], + credentials: [], + }; + + // No configRoot provided + const config = getDevConfig(workingDir, project); + expect(config).not.toBeNull(); + expect(config!.directory).toBe(workingDir); + }); + + it('handles .py: entrypoint format (module:function)', () => { + const project: AgentCoreProjectSpec = { + name: 'TestProject', + version: 1, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'FastAPIAgent', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath('app.py:handler'), + codeLocation: dirPath('./agents/fastapi'), + }, + ], + memories: [], + credentials: [], + }; + + const config = getDevConfig(workingDir, project, '/test/project/agentcore'); + expect(config).not.toBeNull(); + expect(config!.isPython).toBe(true); + }); +}); + +describe('getAgentPort', () => { + it('returns basePort when project is null', () => { + expect(getAgentPort(null, 'any', 8080)).toBe(8080); + }); + + it('returns basePort + index for found agent', () => { + const project: AgentCoreProjectSpec = { + name: 'TestProject', + version: 1, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'Agent1', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath('main.py'), + codeLocation: dirPath('./agents/a1'), + }, + { + type: 'AgentCoreRuntime', + name: 'Agent2', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath('main.py'), + codeLocation: dirPath('./agents/a2'), + }, + ], + memories: [], + credentials: [], + }; + + expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); + expect(getAgentPort(project, 'Agent2', 8080)).toBe(8081); + }); + + it('returns basePort when agent not found', () => { + const project: AgentCoreProjectSpec = { + name: 'TestProject', + version: 1, + agents: [], + memories: [], + credentials: [], + }; + + expect(getAgentPort(project, 'NonExistent', 9000)).toBe(9000); + }); }); describe('getDevSupportedAgents', () => { diff --git a/src/lib/__tests__/constants.test.ts b/src/lib/__tests__/constants.test.ts new file mode 100644 index 00000000..72f823be --- /dev/null +++ b/src/lib/__tests__/constants.test.ts @@ -0,0 +1,20 @@ +import { getArtifactZipName } from '../constants.js'; +import { describe, expect, it } from 'vitest'; + +describe('getArtifactZipName', () => { + it('appends .zip to the name', () => { + expect(getArtifactZipName('my-agent')).toBe('my-agent.zip'); + }); + + it('works with simple names', () => { + expect(getArtifactZipName('tool')).toBe('tool.zip'); + }); + + it('works with empty string', () => { + expect(getArtifactZipName('')).toBe('.zip'); + }); + + it('does not strip existing extension', () => { + expect(getArtifactZipName('agent.tar')).toBe('agent.tar.zip'); + }); +}); diff --git a/src/lib/errors/__tests__/config.test.ts b/src/lib/errors/__tests__/config.test.ts index 68e0880e..8f704900 100644 --- a/src/lib/errors/__tests__/config.test.ts +++ b/src/lib/errors/__tests__/config.test.ts @@ -146,4 +146,64 @@ describe('ConfigValidationError', () => { expect(err).toBeInstanceOf(Error); } }); + + 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); + expect(err.message).toContain('version'); + } + }); + + it('formats discriminated union errors', () => { + const schema = z.object({ + mode: z.enum(['fast', 'slow']), + }); + 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() })), + }); + 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 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('name'); + expect(err.message).toContain('version'); + } + }); }); diff --git a/src/lib/schemas/io/__tests__/path-resolver.test.ts b/src/lib/schemas/io/__tests__/path-resolver.test.ts new file mode 100644 index 00000000..6d00c35a --- /dev/null +++ b/src/lib/schemas/io/__tests__/path-resolver.test.ts @@ -0,0 +1,218 @@ +import { + NoProjectError, + PathResolver, + findConfigRoot, + findProjectRoot, + getSessionProjectRoot, + requireConfigRoot, + setSessionProjectRoot, +} from '../path-resolver.js'; +import { randomUUID } from 'node:crypto'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +describe('NoProjectError', () => { + it('has default message', () => { + const err = new NoProjectError(); + expect(err.message).toContain('No agentcore project found'); + expect(err.name).toBe('NoProjectError'); + }); + + it('accepts custom message', () => { + const err = new NoProjectError('custom msg'); + expect(err.message).toBe('custom msg'); + }); + + it('is instance of Error', () => { + expect(new NoProjectError()).toBeInstanceOf(Error); + }); +}); + +describe('requireConfigRoot', () => { + it('throws NoProjectError when no project found', () => { + const emptyDir = join(tmpdir(), `require-config-${randomUUID()}`); + mkdirSync(emptyDir, { recursive: true }); + try { + expect(() => requireConfigRoot(emptyDir)).toThrow(NoProjectError); + } finally { + rmSync(emptyDir, { recursive: true, force: true }); + } + }); + + it('returns config root when project exists', () => { + const projectDir = join(tmpdir(), `require-config-ok-${randomUUID()}`); + const agentcoreDir = join(projectDir, 'agentcore'); + mkdirSync(agentcoreDir, { recursive: true }); + writeFileSync(join(agentcoreDir, 'agentcore.json'), '{}'); + try { + const result = requireConfigRoot(projectDir); + expect(result).toBe(agentcoreDir); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); +}); + +describe('findConfigRoot', () => { + let projectDir: string; + let agentcoreDir: string; + + beforeAll(() => { + projectDir = join(tmpdir(), `find-config-${randomUUID()}`); + agentcoreDir = join(projectDir, 'agentcore'); + mkdirSync(agentcoreDir, { recursive: true }); + writeFileSync(join(agentcoreDir, 'agentcore.json'), '{}'); + }); + + afterAll(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + it('finds agentcore directory at start dir', () => { + expect(findConfigRoot(projectDir)).toBe(agentcoreDir); + }); + + it('finds agentcore directory from child dir', () => { + const childDir = join(projectDir, 'sub', 'deep'); + mkdirSync(childDir, { recursive: true }); + expect(findConfigRoot(childDir)).toBe(agentcoreDir); + }); + + it('returns null when no project found', () => { + const emptyDir = join(tmpdir(), `find-config-empty-${randomUUID()}`); + mkdirSync(emptyDir, { recursive: true }); + try { + expect(findConfigRoot(emptyDir)).toBeNull(); + } finally { + rmSync(emptyDir, { recursive: true, force: true }); + } + }); +}); + +describe('findProjectRoot', () => { + it('returns parent of agentcore directory', () => { + const projectDir = join(tmpdir(), `find-proj-${randomUUID()}`); + const agentcoreDir = join(projectDir, 'agentcore'); + mkdirSync(agentcoreDir, { recursive: true }); + writeFileSync(join(agentcoreDir, 'agentcore.json'), '{}'); + try { + expect(findProjectRoot(projectDir)).toBe(projectDir); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('returns null when no project found', () => { + const emptyDir = join(tmpdir(), `find-proj-empty-${randomUUID()}`); + mkdirSync(emptyDir, { recursive: true }); + try { + expect(findProjectRoot(emptyDir)).toBeNull(); + } finally { + rmSync(emptyDir, { recursive: true, force: true }); + } + }); +}); + +describe('sessionProjectRoot', () => { + afterEach(() => { + // Reset by setting to a non-existent path so it won't affect other tests + setSessionProjectRoot(join(tmpdir(), 'nonexistent-' + randomUUID())); + }); + + it('starts as null or previously set value', () => { + // We can't guarantee initial state since other tests may have run + const root = getSessionProjectRoot(); + expect(root === null || typeof root === 'string').toBe(true); + }); + + it('can be set and retrieved', () => { + setSessionProjectRoot('/test/path'); + expect(getSessionProjectRoot()).toBe('/test/path'); + }); + + it('findConfigRoot checks session root first', () => { + const projectDir = join(tmpdir(), `session-proj-${randomUUID()}`); + const agentcoreDir = join(projectDir, 'agentcore'); + mkdirSync(agentcoreDir, { recursive: true }); + writeFileSync(join(agentcoreDir, 'agentcore.json'), '{}'); + try { + setSessionProjectRoot(projectDir); + // Should find via session root even from unrelated directory + const emptyDir = join(tmpdir(), `session-empty-${randomUUID()}`); + mkdirSync(emptyDir, { recursive: true }); + try { + expect(findConfigRoot(emptyDir)).toBe(agentcoreDir); + } finally { + rmSync(emptyDir, { recursive: true, force: true }); + } + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); +}); + +describe('PathResolver', () => { + it('uses default config when no config provided', () => { + const resolver = new PathResolver(); + expect(resolver.getBaseDir()).toContain('agentcore'); + }); + + it('uses custom baseDir', () => { + const resolver = new PathResolver({ baseDir: '/custom/agentcore' }); + expect(resolver.getBaseDir()).toBe('/custom/agentcore'); + }); + + it('getProjectRoot returns parent of baseDir', () => { + const resolver = new PathResolver({ baseDir: '/my/project/agentcore' }); + expect(resolver.getProjectRoot()).toBe('/my/project'); + }); + + it('getAgentConfigPath returns agentcore.json path', () => { + const resolver = new PathResolver({ baseDir: '/base' }); + expect(resolver.getAgentConfigPath()).toBe(join('/base', 'agentcore.json')); + }); + + it('getAWSTargetsConfigPath returns aws-targets.json path', () => { + const resolver = new PathResolver({ baseDir: '/base' }); + expect(resolver.getAWSTargetsConfigPath()).toBe(join('/base', 'aws-targets.json')); + }); + + it('getCliSystemDir returns .cli path', () => { + const resolver = new PathResolver({ baseDir: '/base' }); + expect(resolver.getCliSystemDir()).toBe(join('/base', '.cli')); + }); + + it('getLogsDir returns logs path', () => { + const resolver = new PathResolver({ baseDir: '/base' }); + expect(resolver.getLogsDir()).toBe(join('/base', '.cli', 'logs')); + }); + + it('getInvokeLogsDir returns invoke logs path', () => { + const resolver = new PathResolver({ baseDir: '/base' }); + expect(resolver.getInvokeLogsDir()).toBe(join('/base', '.cli', 'logs', 'invoke')); + }); + + it('getStatePath returns deployed-state.json path', () => { + const resolver = new PathResolver({ baseDir: '/base' }); + expect(resolver.getStatePath()).toBe(join('/base', '.cli', 'deployed-state.json')); + }); + + it('getMcpConfigPath returns mcp.json path', () => { + const resolver = new PathResolver({ baseDir: '/base' }); + expect(resolver.getMcpConfigPath()).toBe(join('/base', 'mcp.json')); + }); + + it('getMcpDefsPath returns mcp-defs.json path', () => { + const resolver = new PathResolver({ baseDir: '/base' }); + expect(resolver.getMcpDefsPath()).toBe(join('/base', 'mcp-defs.json')); + }); + + it('setBaseDir updates the base directory', () => { + const resolver = new PathResolver({ baseDir: '/old' }); + resolver.setBaseDir('/new'); + expect(resolver.getBaseDir()).toBe('/new'); + expect(resolver.getProjectRoot()).toBe(dirname('/new')); + }); +}); diff --git a/src/lib/utils/__tests__/env.test.ts b/src/lib/utils/__tests__/env.test.ts index 2ecc3d66..b1ec0f74 100644 --- a/src/lib/utils/__tests__/env.test.ts +++ b/src/lib/utils/__tests__/env.test.ts @@ -115,6 +115,39 @@ describe('readEnvFile + writeEnvFile', () => { rmSync(dir, { recursive: true, force: true }); } }); + + it('skips null and undefined values', async () => { + const dir = mkdtempSync(join(tmpdir(), 'env-test-nulls-')); + try { + await writeEnvFile( + { + KEEP: 'value', + SKIP_NULL: null as unknown as string, + SKIP_UNDEF: undefined as unknown as string, + }, + dir, + false + ); + const result = await readEnvFile(dir); + expect(result.KEEP).toBe('value'); + expect(result.SKIP_NULL).toBeUndefined(); + expect(result.SKIP_UNDEF).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('escapes newlines and carriage returns', async () => { + const dir = mkdtempSync(join(tmpdir(), 'env-test-newlines-')); + try { + await writeEnvFile({ KEY: 'line1\nline2\rline3' }, dir, false); + const raw = readFileSync(join(dir, '.env.local'), 'utf-8'); + expect(raw).toContain('\\n'); + expect(raw).toContain('\\r'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); describe('getEnvVar', () => { From e3f003b4d5185aa68eea8853a38b729c9cf4c0c6 Mon Sep 17 00:00:00 2001 From: notgitika Date: Sun, 15 Feb 2026 21:36:09 -0500 Subject: [PATCH 3/7] test: add unit tests for operations, templates, and commands --- .../update/__tests__/update-action.test.ts | 36 +++++ .../validate/__tests__/action.test.ts | 123 +++++++++++++++ .../deploy/__tests__/preflight-utils.test.ts | 39 +++++ .../deploy/__tests__/teardown-utils.test.ts | 17 +++ .../api-key-credential-provider.test.ts | 142 ++++++++++++++++++ .../operations/init/__tests__/files.test.ts | 48 ++++++ .../mcp/__tests__/create-mcp-utils.test.ts | 30 ++++ .../memory/__tests__/create-memory.test.ts | 86 +++++++++++ .../operations/python/__tests__/setup.test.ts | 139 +++++++++++++++++ .../remove/__tests__/remove-agent-ops.test.ts | 91 +++++++++++ .../__tests__/remove-gateway-ops.test.ts | 112 ++++++++++++++ .../__tests__/remove-identity-ops.test.ts | 113 ++++++++++++++ .../__tests__/remove-memory-ops.test.ts | 88 +++++++++++ src/cli/schema/__tests__/document.test.ts | 98 ++++++++++++ src/cli/templates/__tests__/render.test.ts | 87 +++++++++++ 15 files changed, 1249 insertions(+) create mode 100644 src/cli/commands/update/__tests__/update-action.test.ts create mode 100644 src/cli/commands/validate/__tests__/action.test.ts create mode 100644 src/cli/operations/deploy/__tests__/preflight-utils.test.ts create mode 100644 src/cli/operations/deploy/__tests__/teardown-utils.test.ts create mode 100644 src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts create mode 100644 src/cli/operations/init/__tests__/files.test.ts create mode 100644 src/cli/operations/mcp/__tests__/create-mcp-utils.test.ts create mode 100644 src/cli/operations/memory/__tests__/create-memory.test.ts create mode 100644 src/cli/operations/python/__tests__/setup.test.ts create mode 100644 src/cli/operations/remove/__tests__/remove-agent-ops.test.ts create mode 100644 src/cli/operations/remove/__tests__/remove-gateway-ops.test.ts create mode 100644 src/cli/operations/remove/__tests__/remove-identity-ops.test.ts create mode 100644 src/cli/operations/remove/__tests__/remove-memory-ops.test.ts create mode 100644 src/cli/schema/__tests__/document.test.ts create mode 100644 src/cli/templates/__tests__/render.test.ts diff --git a/src/cli/commands/update/__tests__/update-action.test.ts b/src/cli/commands/update/__tests__/update-action.test.ts new file mode 100644 index 00000000..ebf41693 --- /dev/null +++ b/src/cli/commands/update/__tests__/update-action.test.ts @@ -0,0 +1,36 @@ +import { compareVersions } from '../action.js'; +import { describe, expect, it } from 'vitest'; + +describe('compareVersions', () => { + it('returns 0 for equal versions', () => { + expect(compareVersions('1.2.3', '1.2.3')).toBe(0); + }); + + it('returns 1 when latest is newer by major', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBe(1); + }); + + it('returns 1 when latest is newer by minor', () => { + expect(compareVersions('1.2.0', '1.3.0')).toBe(1); + }); + + it('returns 1 when latest is newer by patch', () => { + expect(compareVersions('1.2.3', '1.2.4')).toBe(1); + }); + + it('returns -1 when current is newer by major', () => { + expect(compareVersions('2.0.0', '1.0.0')).toBe(-1); + }); + + it('returns -1 when current is newer by minor', () => { + expect(compareVersions('1.5.0', '1.3.0')).toBe(-1); + }); + + it('returns -1 when current is newer by patch', () => { + expect(compareVersions('1.2.5', '1.2.3')).toBe(-1); + }); + + it('handles versions with missing parts', () => { + expect(compareVersions('1.0', '1.0.0')).toBe(0); + }); +}); diff --git a/src/cli/commands/validate/__tests__/action.test.ts b/src/cli/commands/validate/__tests__/action.test.ts new file mode 100644 index 00000000..bb1f09fc --- /dev/null +++ b/src/cli/commands/validate/__tests__/action.test.ts @@ -0,0 +1,123 @@ +import { handleValidate } from '../action.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { + mockReadProjectSpec, + mockReadAWSDeploymentTargets, + mockReadDeployedState, + mockConfigExists, + mockFindConfigRoot, +} = vi.hoisted(() => ({ + mockReadProjectSpec: vi.fn(), + mockReadAWSDeploymentTargets: vi.fn(), + mockReadDeployedState: vi.fn(), + mockConfigExists: vi.fn(), + mockFindConfigRoot: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => { + class NoProjectError extends Error { + constructor(msg?: string) { + super(msg ?? 'No agentcore project found'); + this.name = 'NoProjectError'; + } + } + + return { + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + readAWSDeploymentTargets = mockReadAWSDeploymentTargets; + readDeployedState = mockReadDeployedState; + configExists = mockConfigExists; + }, + ConfigValidationError: class extends Error {}, + ConfigParseError: class extends Error {}, + ConfigReadError: class extends Error {}, + ConfigNotFoundError: class extends Error {}, + NoProjectError, + findConfigRoot: mockFindConfigRoot, + }; +}); + +describe('handleValidate', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns error when no project found', async () => { + mockFindConfigRoot.mockReturnValue(null); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('No agentcore project found'); + }); + + it('returns success when all configs are valid', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockReadProjectSpec.mockResolvedValue({ name: 'Test', agents: [] }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + + const result = await handleValidate({}); + + expect(result.success).toBe(true); + }); + + it('returns error when project spec fails', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockReadProjectSpec.mockRejectedValue(new Error('invalid project')); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('invalid project'); + }); + + it('returns error when AWS targets fails', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockReadProjectSpec.mockResolvedValue({ name: 'Test', agents: [] }); + mockReadAWSDeploymentTargets.mockRejectedValue(new Error('bad targets')); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('bad targets'); + }); + + it('validates state file when it exists', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockReadProjectSpec.mockResolvedValue({ name: 'Test', agents: [] }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(true); + mockReadDeployedState.mockResolvedValue({ targets: {} }); + + const result = await handleValidate({}); + + expect(result.success).toBe(true); + expect(mockReadDeployedState).toHaveBeenCalled(); + }); + + it('returns error when state file is invalid', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockReadProjectSpec.mockResolvedValue({ name: 'Test', agents: [] }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(true); + mockReadDeployedState.mockRejectedValue(new Error('bad state')); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('bad state'); + }); + + it('uses custom directory when provided', async () => { + mockFindConfigRoot.mockReturnValue('/custom/agentcore'); + mockReadProjectSpec.mockResolvedValue({ name: 'Test', agents: [] }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + + const result = await handleValidate({ directory: '/custom' }); + + expect(result.success).toBe(true); + expect(mockFindConfigRoot).toHaveBeenCalledWith('/custom'); + }); +}); diff --git a/src/cli/operations/deploy/__tests__/preflight-utils.test.ts b/src/cli/operations/deploy/__tests__/preflight-utils.test.ts new file mode 100644 index 00000000..730a3246 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/preflight-utils.test.ts @@ -0,0 +1,39 @@ +import { formatError } from '../preflight.js'; +import { describe, expect, it } from 'vitest'; + +describe('formatError', () => { + it('formats Error with message only', () => { + const err = new Error('something failed'); + err.stack = undefined; + + expect(formatError(err)).toBe('something failed'); + }); + + it('includes stack trace when available', () => { + const err = new Error('with stack'); + + const result = formatError(err); + + expect(result).toContain('with stack'); + expect(result).toContain('Stack trace:'); + }); + + it('formats nested cause', () => { + const cause = new Error('root cause'); + cause.stack = undefined; + const err = new Error('outer error', { cause }); + err.stack = undefined; + + const result = formatError(err); + + expect(result).toContain('outer error'); + expect(result).toContain('Caused by:'); + expect(result).toContain('root cause'); + }); + + it('formats non-Error values as string', () => { + expect(formatError('string error')).toBe('string error'); + expect(formatError(42)).toBe('42'); + expect(formatError(null)).toBe('null'); + }); +}); diff --git a/src/cli/operations/deploy/__tests__/teardown-utils.test.ts b/src/cli/operations/deploy/__tests__/teardown-utils.test.ts new file mode 100644 index 00000000..8d2d2168 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/teardown-utils.test.ts @@ -0,0 +1,17 @@ +import { getCdkProjectDir } from '../teardown.js'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; + +describe('getCdkProjectDir', () => { + it('returns agentcore/cdk under cwd by default', () => { + const result = getCdkProjectDir(); + + expect(result).toBe(join(process.cwd(), 'agentcore', 'cdk')); + }); + + it('returns agentcore/cdk under custom directory', () => { + const result = getCdkProjectDir('/custom/path'); + + expect(result).toBe(join('/custom/path', 'agentcore', 'cdk')); + }); +}); diff --git a/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts b/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts new file mode 100644 index 00000000..e7cbafb6 --- /dev/null +++ b/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts @@ -0,0 +1,142 @@ +import { + apiKeyProviderExists, + createApiKeyProvider, + setTokenVaultKmsKey, + updateApiKeyProvider, +} from '../api-key-credential-provider.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend, MockResourceNotFoundException } = vi.hoisted(() => ({ + mockSend: vi.fn(), + MockResourceNotFoundException: class extends Error { + constructor(message = 'not found') { + super(message); + this.name = 'ResourceNotFoundException'; + } + }, +})); + +vi.mock('@aws-sdk/client-bedrock-agentcore-control', () => ({ + BedrockAgentCoreControlClient: class { + send = mockSend; + }, + GetApiKeyCredentialProviderCommand: class { + constructor(public input: unknown) {} + }, + CreateApiKeyCredentialProviderCommand: class { + constructor(public input: unknown) {} + }, + UpdateApiKeyCredentialProviderCommand: class { + constructor(public input: unknown) {} + }, + SetTokenVaultCMKCommand: class { + constructor(public input: unknown) {} + }, + ResourceNotFoundException: MockResourceNotFoundException, +})); + +// Create a mock client +function makeMockClient() { + return { send: mockSend } as any; +} + +describe('apiKeyProviderExists', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns true when provider exists', async () => { + mockSend.mockResolvedValue({}); + + expect(await apiKeyProviderExists(makeMockClient(), 'my-provider')).toBe(true); + }); + + it('returns false on ResourceNotFoundException', async () => { + mockSend.mockRejectedValue(new MockResourceNotFoundException()); + + expect(await apiKeyProviderExists(makeMockClient(), 'my-provider')).toBe(false); + }); + + it('rethrows other errors', async () => { + mockSend.mockRejectedValue(new Error('other error')); + + await expect(apiKeyProviderExists(makeMockClient(), 'my-provider')).rejects.toThrow('other error'); + }); +}); + +describe('createApiKeyProvider', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns success on creation', async () => { + mockSend.mockResolvedValue({}); + + const result = await createApiKeyProvider(makeMockClient(), 'prov', 'key123'); + + expect(result).toEqual({ success: true }); + }); + + it('returns success on ConflictException (idempotent)', async () => { + const err = new Error('conflict'); + Object.defineProperty(err, 'name', { value: 'ConflictException' }); + mockSend.mockRejectedValue(err); + + const result = await createApiKeyProvider(makeMockClient(), 'prov', 'key123'); + + expect(result).toEqual({ success: true }); + }); + + it('returns success on ResourceAlreadyExistsException', async () => { + const err = new Error('exists'); + Object.defineProperty(err, 'name', { value: 'ResourceAlreadyExistsException' }); + mockSend.mockRejectedValue(err); + + const result = await createApiKeyProvider(makeMockClient(), 'prov', 'key123'); + + expect(result).toEqual({ success: true }); + }); + + it('returns failure on other errors', async () => { + mockSend.mockRejectedValue(new Error('unexpected')); + + const result = await createApiKeyProvider(makeMockClient(), 'prov', 'key123'); + + expect(result.success).toBe(false); + expect(result.error).toBe('unexpected'); + }); +}); + +describe('updateApiKeyProvider', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns success on update', async () => { + mockSend.mockResolvedValue({}); + + expect(await updateApiKeyProvider(makeMockClient(), 'prov', 'newkey')).toEqual({ success: true }); + }); + + it('returns failure on error', async () => { + mockSend.mockRejectedValue(new Error('update fail')); + + const result = await updateApiKeyProvider(makeMockClient(), 'prov', 'newkey'); + + expect(result.success).toBe(false); + expect(result.error).toBe('update fail'); + }); +}); + +describe('setTokenVaultKmsKey', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns success', async () => { + mockSend.mockResolvedValue({}); + + expect(await setTokenVaultKmsKey(makeMockClient(), 'arn:aws:kms:key')).toEqual({ success: true }); + }); + + it('returns failure on error', async () => { + mockSend.mockRejectedValue(new Error('kms fail')); + + const result = await setTokenVaultKmsKey(makeMockClient(), 'arn:aws:kms:key'); + + expect(result.success).toBe(false); + expect(result.error).toBe('kms fail'); + }); +}); diff --git a/src/cli/operations/init/__tests__/files.test.ts b/src/cli/operations/init/__tests__/files.test.ts new file mode 100644 index 00000000..06ec7794 --- /dev/null +++ b/src/cli/operations/init/__tests__/files.test.ts @@ -0,0 +1,48 @@ +import { writeEnvFile, writeGitignore } from '../files.js'; +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('writeGitignore', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'init-gitignore-')); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('creates .gitignore with expected content', async () => { + await writeGitignore(dir); + + const content = readFileSync(join(dir, '.gitignore'), 'utf-8'); + expect(content).toContain('.env.local'); + expect(content).toContain('cdk/cdk.out/'); + expect(content).toContain('cdk/node_modules/'); + expect(content).toContain('.cli/*'); + expect(content).toContain('!.cli/deployed-state.json'); + expect(content).toContain('.cache/*'); + }); +}); + +describe('writeEnvFile', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'init-envfile-')); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('creates empty .env.local file', async () => { + await writeEnvFile(dir); + + const content = readFileSync(join(dir, '.env.local'), 'utf-8'); + expect(content).toBe(''); + }); +}); diff --git a/src/cli/operations/mcp/__tests__/create-mcp-utils.test.ts b/src/cli/operations/mcp/__tests__/create-mcp-utils.test.ts new file mode 100644 index 00000000..850f1f56 --- /dev/null +++ b/src/cli/operations/mcp/__tests__/create-mcp-utils.test.ts @@ -0,0 +1,30 @@ +import { computeDefaultGatewayEnvVarName, computeDefaultMcpRuntimeEnvVarName } from '../create-mcp.js'; +import { describe, expect, it } from 'vitest'; + +describe('computeDefaultGatewayEnvVarName', () => { + it('uppercases and wraps gateway name', () => { + expect(computeDefaultGatewayEnvVarName('my-gateway')).toBe('AGENTCORE_GATEWAY_MY_GATEWAY_URL'); + }); + + it('replaces hyphens with underscores', () => { + expect(computeDefaultGatewayEnvVarName('multi-part-name')).toBe('AGENTCORE_GATEWAY_MULTI_PART_NAME_URL'); + }); + + it('handles name with no hyphens', () => { + expect(computeDefaultGatewayEnvVarName('simple')).toBe('AGENTCORE_GATEWAY_SIMPLE_URL'); + }); +}); + +describe('computeDefaultMcpRuntimeEnvVarName', () => { + it('uppercases and wraps runtime name', () => { + expect(computeDefaultMcpRuntimeEnvVarName('my-runtime')).toBe('AGENTCORE_MCPRUNTIME_MY_RUNTIME_URL'); + }); + + it('replaces hyphens with underscores', () => { + expect(computeDefaultMcpRuntimeEnvVarName('a-b-c')).toBe('AGENTCORE_MCPRUNTIME_A_B_C_URL'); + }); + + it('handles name with no hyphens', () => { + expect(computeDefaultMcpRuntimeEnvVarName('runtime')).toBe('AGENTCORE_MCPRUNTIME_RUNTIME_URL'); + }); +}); diff --git a/src/cli/operations/memory/__tests__/create-memory.test.ts b/src/cli/operations/memory/__tests__/create-memory.test.ts new file mode 100644 index 00000000..28f34c94 --- /dev/null +++ b/src/cli/operations/memory/__tests__/create-memory.test.ts @@ -0,0 +1,86 @@ +import { createMemory, getAllMemoryNames } from '../create-memory.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockWriteProjectSpec = vi.fn(); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + }, +})); + +const makeProject = (memoryNames: string[]) => ({ + name: 'TestProject', + version: 1, + agents: [], + memories: memoryNames.map(name => ({ + name, + type: 'AgentCoreMemory', + eventExpiryDuration: 30, + strategies: [], + })), + credentials: [], +}); + +describe('getAllMemoryNames', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns memory names', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject(['Mem1', 'Mem2'])); + + expect(await getAllMemoryNames()).toEqual(['Mem1', 'Mem2']); + }); + + it('returns empty array on error', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('fail')); + + expect(await getAllMemoryNames()).toEqual([]); + }); +}); + +describe('createMemory', () => { + afterEach(() => vi.clearAllMocks()); + + it('creates memory with strategies and default namespaces', async () => { + const project = makeProject([]); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await createMemory({ + name: 'NewMem', + eventExpiryDuration: 60, + strategies: [{ type: 'SEMANTIC' }], + }); + + expect(result.name).toBe('NewMem'); + expect(result.type).toBe('AgentCoreMemory'); + expect(result.eventExpiryDuration).toBe(60); + expect(result.strategies[0]!.type).toBe('SEMANTIC'); + expect(result.strategies[0]!.namespaces).toEqual(['/users/{actorId}/facts']); + expect(mockWriteProjectSpec).toHaveBeenCalled(); + }); + + it('creates memory with strategy without default namespaces', async () => { + const project = makeProject([]); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await createMemory({ + name: 'NewMem', + eventExpiryDuration: 30, + strategies: [{ type: 'CUSTOM' }], + }); + + expect(result.strategies[0]!.namespaces).toBeUndefined(); + }); + + it('throws on duplicate memory name', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject(['Existing'])); + + await expect(createMemory({ name: 'Existing', eventExpiryDuration: 30, strategies: [] })).rejects.toThrow( + 'Memory "Existing" already exists' + ); + }); +}); diff --git a/src/cli/operations/python/__tests__/setup.test.ts b/src/cli/operations/python/__tests__/setup.test.ts new file mode 100644 index 00000000..9107d5a1 --- /dev/null +++ b/src/cli/operations/python/__tests__/setup.test.ts @@ -0,0 +1,139 @@ +import * as lib from '../../../../lib/index.js'; +import { checkUvAvailable, createVenv, installDependencies, setupPythonProject } from '../setup.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../lib/index.js', async () => { + const actual = await vi.importActual('../../../../lib/index.js'); + return { + ...actual, + checkSubprocess: vi.fn(), + runSubprocessCapture: vi.fn(), + }; +}); + +const mockCheckSubprocess = vi.mocked(lib.checkSubprocess); +const mockRunSubprocessCapture = vi.mocked(lib.runSubprocessCapture); + +describe('checkUvAvailable', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns true when uv is available', async () => { + mockCheckSubprocess.mockResolvedValue(true); + + expect(await checkUvAvailable()).toBe(true); + expect(mockCheckSubprocess).toHaveBeenCalledWith('uv', ['--version']); + }); + + it('returns false when uv is not available', async () => { + mockCheckSubprocess.mockResolvedValue(false); + + expect(await checkUvAvailable()).toBe(false); + }); +}); + +describe('createVenv', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns success when venv creation succeeds', async () => { + mockRunSubprocessCapture.mockResolvedValue({ code: 0, stdout: '', stderr: '', signal: null }); + + const result = await createVenv('/project'); + + expect(result.status).toBe('success'); + expect(mockRunSubprocessCapture).toHaveBeenCalledWith('uv', ['venv', '.venv'], { cwd: '/project' }); + }); + + it('uses custom venv name', async () => { + mockRunSubprocessCapture.mockResolvedValue({ code: 0, stdout: '', stderr: '', signal: null }); + + await createVenv('/project', 'my-env'); + + expect(mockRunSubprocessCapture).toHaveBeenCalledWith('uv', ['venv', 'my-env'], { cwd: '/project' }); + }); + + it('returns venv_failed on error', async () => { + mockRunSubprocessCapture.mockResolvedValue({ code: 1, stdout: '', stderr: 'venv error', signal: null }); + + const result = await createVenv('/project'); + + expect(result.status).toBe('venv_failed'); + expect(result.error).toBe('venv error'); + }); +}); + +describe('installDependencies', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns success when install succeeds', async () => { + mockRunSubprocessCapture.mockResolvedValue({ code: 0, stdout: '', stderr: '', signal: null }); + + const result = await installDependencies('/project'); + + expect(result.status).toBe('success'); + expect(mockRunSubprocessCapture).toHaveBeenCalledWith('uv', ['sync'], { cwd: '/project' }); + }); + + it('returns install_failed on error', async () => { + mockRunSubprocessCapture.mockResolvedValue({ code: 1, stdout: 'some output', stderr: '', signal: null }); + + const result = await installDependencies('/project'); + + expect(result.status).toBe('install_failed'); + expect(result.error).toBe('some output'); + }); +}); + +describe('setupPythonProject', () => { + const origEnv = process.env.AGENTCORE_SKIP_INSTALL; + + afterEach(() => { + vi.clearAllMocks(); + if (origEnv !== undefined) process.env.AGENTCORE_SKIP_INSTALL = origEnv; + else delete process.env.AGENTCORE_SKIP_INSTALL; + }); + + it('skips install when AGENTCORE_SKIP_INSTALL is set', async () => { + process.env.AGENTCORE_SKIP_INSTALL = '1'; + + const result = await setupPythonProject({ projectDir: '/project' }); + + expect(result.status).toBe('success'); + expect(mockCheckSubprocess).not.toHaveBeenCalled(); + }); + + it('returns uv_not_found when uv is not available', async () => { + delete process.env.AGENTCORE_SKIP_INSTALL; + mockCheckSubprocess.mockResolvedValue(false); + + const result = await setupPythonProject({ projectDir: '/project' }); + + expect(result.status).toBe('uv_not_found'); + expect(result.error).toContain('uv command not found'); + }); + + it('returns venv_failed when venv creation fails', async () => { + delete process.env.AGENTCORE_SKIP_INSTALL; + mockCheckSubprocess.mockResolvedValue(true); + mockRunSubprocessCapture.mockResolvedValue({ code: 1, stdout: '', stderr: 'venv fail', signal: null }); + + const result = await setupPythonProject({ projectDir: '/project' }); + + expect(result.status).toBe('venv_failed'); + }); + + it('returns success when full setup succeeds', async () => { + delete process.env.AGENTCORE_SKIP_INSTALL; + mockCheckSubprocess.mockResolvedValue(true); + mockRunSubprocessCapture.mockResolvedValue({ code: 0, stdout: '', stderr: '', signal: null }); + + const result = await setupPythonProject({ projectDir: '/project' }); + + expect(result.status).toBe('success'); + }); +}); diff --git a/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts b/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts new file mode 100644 index 00000000..59be2aba --- /dev/null +++ b/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts @@ -0,0 +1,91 @@ +import { getRemovableAgents, previewRemoveAgent, removeAgent } from '../remove-agent.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockWriteProjectSpec = vi.fn(); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + }, +})); + +const makeProject = (agentNames: string[]) => ({ + name: 'TestProject', + version: 1, + agents: agentNames.map(name => ({ name, type: 'AgentCoreRuntime' })), + memories: [], + credentials: [], +}); + +describe('getRemovableAgents', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns agent names from project', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject(['Agent1', 'Agent2'])); + + const result = await getRemovableAgents(); + + expect(result).toEqual(['Agent1', 'Agent2']); + }); + + it('returns empty array on error', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('fail')); + + expect(await getRemovableAgents()).toEqual([]); + }); +}); + +describe('previewRemoveAgent', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns preview for existing agent', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject(['Agent1', 'Agent2'])); + + const preview = await previewRemoveAgent('Agent1'); + + expect(preview.summary).toContain('Removing agent: Agent1'); + expect(preview.schemaChanges).toHaveLength(1); + expect(preview.schemaChanges[0]!.file).toBe('agentcore/agentcore.json'); + }); + + it('throws when agent not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject(['Agent1'])); + + await expect(previewRemoveAgent('NonExistent')).rejects.toThrow('Agent "NonExistent" not found'); + }); +}); + +describe('removeAgent', () => { + afterEach(() => vi.clearAllMocks()); + + it('removes agent and writes spec', async () => { + const project = makeProject(['Agent1', 'Agent2']); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await removeAgent('Agent1'); + + expect(result).toEqual({ ok: true }); + expect(mockWriteProjectSpec).toHaveBeenCalled(); + expect(project.agents).toHaveLength(1); + expect(project.agents[0]!.name).toBe('Agent2'); + }); + + it('returns error when agent not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject(['Agent1'])); + + const result = await removeAgent('Missing'); + + expect(result).toEqual({ ok: false, error: 'Agent "Missing" not found.' }); + }); + + it('returns error on exception', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('read fail')); + + const result = await removeAgent('Agent1'); + + expect(result).toEqual({ ok: false, error: 'read fail' }); + }); +}); diff --git a/src/cli/operations/remove/__tests__/remove-gateway-ops.test.ts b/src/cli/operations/remove/__tests__/remove-gateway-ops.test.ts new file mode 100644 index 00000000..d1a10fc5 --- /dev/null +++ b/src/cli/operations/remove/__tests__/remove-gateway-ops.test.ts @@ -0,0 +1,112 @@ +import { getRemovableGateways, previewRemoveGateway, removeGateway } from '../remove-gateway.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadMcpSpec = vi.fn(); +const mockWriteMcpSpec = vi.fn(); +const mockConfigExists = vi.fn(); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readMcpSpec = mockReadMcpSpec; + writeMcpSpec = mockWriteMcpSpec; + configExists = mockConfigExists; + }, +})); + +const makeMcpSpec = (gatewayNames: string[], targetsPerGateway = 0) => ({ + agentCoreGateways: gatewayNames.map(name => ({ + name, + targets: Array.from({ length: targetsPerGateway }, (_, i) => ({ + name: `target-${i}`, + targetType: 'mcpServer', + toolDefinitions: [], + })), + })), +}); + +describe('getRemovableGateways', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns gateway names', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(makeMcpSpec(['gw1', 'gw2'])); + + const result = await getRemovableGateways(); + + expect(result).toEqual(['gw1', 'gw2']); + }); + + it('returns empty when no mcp config', async () => { + mockConfigExists.mockReturnValue(false); + + expect(await getRemovableGateways()).toEqual([]); + }); + + it('returns empty on error', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockRejectedValue(new Error('fail')); + + expect(await getRemovableGateways()).toEqual([]); + }); +}); + +describe('previewRemoveGateway', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns preview for gateway without targets', async () => { + mockReadMcpSpec.mockResolvedValue(makeMcpSpec(['myGw'])); + + const preview = await previewRemoveGateway('myGw'); + + expect(preview.summary).toContain('Removing gateway: myGw'); + expect(preview.schemaChanges).toHaveLength(1); + }); + + it('notes orphaned targets when gateway has targets', async () => { + mockReadMcpSpec.mockResolvedValue(makeMcpSpec(['myGw'], 3)); + + const preview = await previewRemoveGateway('myGw'); + + expect(preview.summary.some(s => s.includes('3 target(s)'))).toBe(true); + }); + + it('throws when gateway not found', async () => { + mockReadMcpSpec.mockResolvedValue(makeMcpSpec(['other'])); + + await expect(previewRemoveGateway('missing')).rejects.toThrow('Gateway "missing" not found'); + }); +}); + +describe('removeGateway', () => { + afterEach(() => vi.clearAllMocks()); + + it('removes gateway and writes spec', async () => { + mockReadMcpSpec.mockResolvedValue(makeMcpSpec(['gw1', 'gw2'])); + mockWriteMcpSpec.mockResolvedValue(undefined); + + const result = await removeGateway('gw1'); + + expect(result).toEqual({ ok: true }); + expect(mockWriteMcpSpec).toHaveBeenCalledWith( + expect.objectContaining({ + agentCoreGateways: [expect.objectContaining({ name: 'gw2' })], + }) + ); + }); + + it('returns error when gateway not found', async () => { + mockReadMcpSpec.mockResolvedValue(makeMcpSpec([])); + + const result = await removeGateway('missing'); + + expect(result).toEqual({ ok: false, error: 'Gateway "missing" not found.' }); + }); + + it('returns error on exception', async () => { + mockReadMcpSpec.mockRejectedValue(new Error('read fail')); + + const result = await removeGateway('gw1'); + + expect(result).toEqual({ ok: false, error: 'read fail' }); + }); +}); diff --git a/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts b/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts new file mode 100644 index 00000000..43807a32 --- /dev/null +++ b/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts @@ -0,0 +1,113 @@ +import { + getRemovableCredentials, + getRemovableIdentities, + previewRemoveCredential, + previewRemoveIdentity, + removeCredential, + removeIdentity, +} from '../remove-identity.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockWriteProjectSpec = vi.fn(); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + }, +})); + +const makeProject = (credNames: string[]) => ({ + name: 'TestProject', + version: 1, + agents: [], + memories: [], + credentials: credNames.map(name => ({ name, type: 'ApiKeyCredentialProvider' })), +}); + +describe('getRemovableCredentials', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns credentials from project', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject(['Cred1', 'Cred2'])); + + const result = await getRemovableCredentials(); + + expect(result).toEqual([ + { name: 'Cred1', type: 'ApiKeyCredentialProvider' }, + { name: 'Cred2', type: 'ApiKeyCredentialProvider' }, + ]); + }); + + it('returns empty array on error', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('fail')); + + expect(await getRemovableCredentials()).toEqual([]); + }); +}); + +describe('previewRemoveCredential', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns preview with type and env note', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject(['MyCred'])); + + const preview = await previewRemoveCredential('MyCred'); + + expect(preview.summary).toContain('Removing credential: MyCred'); + expect(preview.summary).toContain('Type: ApiKeyCredentialProvider'); + expect(preview.summary).toContain('Note: .env file will not be modified'); + }); + + it('throws when credential not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([])); + + await expect(previewRemoveCredential('Missing')).rejects.toThrow('Credential "Missing" not found'); + }); +}); + +describe('removeCredential', () => { + afterEach(() => vi.clearAllMocks()); + + it('removes credential and writes spec', async () => { + const project = makeProject(['Cred1', 'Cred2']); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await removeCredential('Cred1'); + + expect(result).toEqual({ ok: true }); + expect(mockWriteProjectSpec).toHaveBeenCalled(); + }); + + it('returns error when credential not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([])); + + const result = await removeCredential('Missing'); + + expect(result).toEqual({ ok: false, error: 'Credential "Missing" not found.' }); + }); + + it('returns error on exception', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('read fail')); + + const result = await removeCredential('Cred1'); + + expect(result).toEqual({ ok: false, error: 'read fail' }); + }); +}); + +describe('aliases', () => { + it('getRemovableIdentities is getRemovableCredentials', () => { + expect(getRemovableIdentities).toBe(getRemovableCredentials); + }); + + it('previewRemoveIdentity is previewRemoveCredential', () => { + expect(previewRemoveIdentity).toBe(previewRemoveCredential); + }); + + it('removeIdentity is removeCredential', () => { + expect(removeIdentity).toBe(removeCredential); + }); +}); diff --git a/src/cli/operations/remove/__tests__/remove-memory-ops.test.ts b/src/cli/operations/remove/__tests__/remove-memory-ops.test.ts new file mode 100644 index 00000000..13973f2f --- /dev/null +++ b/src/cli/operations/remove/__tests__/remove-memory-ops.test.ts @@ -0,0 +1,88 @@ +import { getRemovableMemories, previewRemoveMemory, removeMemory } from '../remove-memory.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockWriteProjectSpec = vi.fn(); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + }, +})); + +const makeProject = (memoryNames: string[]) => ({ + name: 'TestProject', + version: 1, + agents: [], + memories: memoryNames.map(name => ({ name, type: 'AgentCoreMemory', eventExpiryDuration: 30, strategies: [] })), + credentials: [], +}); + +describe('getRemovableMemories', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns memory names from project', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject(['Mem1', 'Mem2'])); + + const result = await getRemovableMemories(); + + expect(result).toEqual([{ name: 'Mem1' }, { name: 'Mem2' }]); + }); + + it('returns empty array on error', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('fail')); + + expect(await getRemovableMemories()).toEqual([]); + }); +}); + +describe('previewRemoveMemory', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns preview for existing memory', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject(['Mem1'])); + + const preview = await previewRemoveMemory('Mem1'); + + expect(preview.summary).toContain('Removing memory: Mem1'); + expect(preview.schemaChanges).toHaveLength(1); + }); + + it('throws when memory not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject(['Mem1'])); + + await expect(previewRemoveMemory('Missing')).rejects.toThrow('Memory "Missing" not found'); + }); +}); + +describe('removeMemory', () => { + afterEach(() => vi.clearAllMocks()); + + it('removes memory and writes spec', async () => { + const project = makeProject(['Mem1', 'Mem2']); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await removeMemory('Mem1'); + + expect(result).toEqual({ ok: true }); + expect(mockWriteProjectSpec).toHaveBeenCalled(); + }); + + it('returns error when memory not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([])); + + const result = await removeMemory('Missing'); + + expect(result).toEqual({ ok: false, error: 'Memory "Missing" not found.' }); + }); + + it('returns error on exception', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('read fail')); + + const result = await removeMemory('Mem1'); + + expect(result).toEqual({ ok: false, error: 'read fail' }); + }); +}); diff --git a/src/cli/schema/__tests__/document.test.ts b/src/cli/schema/__tests__/document.test.ts new file mode 100644 index 00000000..c6e0eee0 --- /dev/null +++ b/src/cli/schema/__tests__/document.test.ts @@ -0,0 +1,98 @@ +import { loadSchemaDocument, saveSchemaDocument } from '../document.js'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +const TestSchema = z.object({ + name: z.string(), + value: z.number(), +}); + +describe('loadSchemaDocument', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'doc-load-')); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('loads valid JSON with no validation error', async () => { + const filePath = join(dir, 'valid.json'); + writeFileSync(filePath, JSON.stringify({ name: 'test', value: 42 })); + + const result = await loadSchemaDocument(filePath, TestSchema); + + expect(result.validationError).toBeUndefined(); + expect(JSON.parse(result.content)).toEqual({ name: 'test', value: 42 }); + }); + + it('returns validation error for schema mismatch', async () => { + const filePath = join(dir, 'bad-schema.json'); + writeFileSync(filePath, JSON.stringify({ name: 'test', value: 'not-a-number' })); + + const result = await loadSchemaDocument(filePath, TestSchema); + + expect(result.validationError).toBeDefined(); + expect(result.content).toBeDefined(); + }); + + it('returns validation error for invalid JSON', async () => { + const filePath = join(dir, 'bad-json.json'); + writeFileSync(filePath, '{not valid json}'); + + const result = await loadSchemaDocument(filePath, TestSchema); + + expect(result.validationError).toBeDefined(); + }); + + it('throws when file does not exist', async () => { + await expect(loadSchemaDocument(join(dir, 'missing.json'), TestSchema)).rejects.toThrow(); + }); +}); + +describe('saveSchemaDocument', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'doc-save-')); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('saves valid JSON and returns formatted content', async () => { + const filePath = join(dir, 'save-valid.json'); + const content = JSON.stringify({ name: 'hello', value: 7 }); + + const result = await saveSchemaDocument(filePath, content, TestSchema); + + expect(result.ok).toBe(true); + expect(result.content).toBe(JSON.stringify({ name: 'hello', value: 7 }, null, 2)); + expect(readFileSync(filePath, 'utf-8')).toBe(result.content); + }); + + it('returns error for invalid JSON', async () => { + const filePath = join(dir, 'save-bad-json.json'); + + const result = await saveSchemaDocument(filePath, '{bad}', TestSchema); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('returns error for schema validation failure', async () => { + const filePath = join(dir, 'save-bad-schema.json'); + const content = JSON.stringify({ name: 123, value: 'wrong' }); + + const result = await saveSchemaDocument(filePath, content, TestSchema); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); +}); diff --git a/src/cli/templates/__tests__/render.test.ts b/src/cli/templates/__tests__/render.test.ts new file mode 100644 index 00000000..383dace2 --- /dev/null +++ b/src/cli/templates/__tests__/render.test.ts @@ -0,0 +1,87 @@ +import { copyAndRenderDir, copyDir } from '../render.js'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('copyDir', () => { + let srcDir: string; + let destDir: string; + + beforeAll(() => { + srcDir = mkdtempSync(join(tmpdir(), 'copy-src-')); + destDir = join(mkdtempSync(join(tmpdir(), 'copy-dest-')), 'output'); + + // Create source files + writeFileSync(join(srcDir, 'file1.txt'), 'hello'); + writeFileSync(join(srcDir, 'file2.ts'), 'const x = 1;'); + + // Create subdirectory + mkdirSync(join(srcDir, 'sub')); + writeFileSync(join(srcDir, 'sub', 'nested.txt'), 'nested content'); + + // Template file that should be renamed + writeFileSync(join(srcDir, 'gitignore.template'), 'node_modules/'); + }); + + afterAll(() => { + rmSync(srcDir, { recursive: true, force: true }); + rmSync(join(destDir, '..'), { recursive: true, force: true }); + }); + + it('copies files to destination', async () => { + await copyDir(srcDir, destDir); + + expect(readFileSync(join(destDir, 'file1.txt'), 'utf-8')).toBe('hello'); + expect(readFileSync(join(destDir, 'file2.ts'), 'utf-8')).toBe('const x = 1;'); + }); + + it('copies nested directories', () => { + expect(readFileSync(join(destDir, 'sub', 'nested.txt'), 'utf-8')).toBe('nested content'); + }); + + it('renames gitignore.template to .gitignore', () => { + expect(readFileSync(join(destDir, '.gitignore'), 'utf-8')).toBe('node_modules/'); + }); +}); + +describe('copyAndRenderDir', () => { + let srcDir: string; + let destDir: string; + + beforeAll(() => { + srcDir = mkdtempSync(join(tmpdir(), 'render-src-')); + destDir = join(mkdtempSync(join(tmpdir(), 'render-dest-')), 'output'); + + // Create Handlebars templates + writeFileSync(join(srcDir, 'readme.md'), 'Project: {{projectName}}'); + writeFileSync(join(srcDir, 'config.json'), '{"name": "{{projectName}}", "version": "{{version}}"}'); + + // Subdirectory with template + mkdirSync(join(srcDir, 'src')); + writeFileSync(join(srcDir, 'src', 'index.ts'), 'export const name = "{{projectName}}";'); + + // Template file rename + writeFileSync(join(srcDir, 'npmignore.template'), 'dist/'); + }); + + afterAll(() => { + rmSync(srcDir, { recursive: true, force: true }); + rmSync(join(destDir, '..'), { recursive: true, force: true }); + }); + + it('renders Handlebars templates with data', async () => { + await copyAndRenderDir(srcDir, destDir, { projectName: 'MyApp', version: 2 }); + + expect(readFileSync(join(destDir, 'readme.md'), 'utf-8')).toBe('Project: MyApp'); + expect(readFileSync(join(destDir, 'config.json'), 'utf-8')).toBe('{"name": "MyApp", "version": "2"}'); + }); + + it('renders templates in subdirectories', () => { + expect(readFileSync(join(destDir, 'src', 'index.ts'), 'utf-8')).toBe('export const name = "MyApp";'); + }); + + it('renames npmignore.template to .npmignore', () => { + expect(readFileSync(join(destDir, '.npmignore'), 'utf-8')).toBe('dist/'); + }); +}); From c6ddb3baccf86bf30654e4c9257d15a5c33887bd Mon Sep 17 00:00:00 2001 From: notgitika Date: Sun, 15 Feb 2026 21:46:15 -0500 Subject: [PATCH 4/7] test: add tests for BaseRenderer, packagers, ConfigIO, and error formatting --- .../templates/__tests__/BaseRenderer.test.ts | 97 ++++++++++ .../errors/__tests__/config-extended.test.ts | 68 +++++++ .../packaging/__tests__/node-packager.test.ts | 134 +++++++++++++ .../__tests__/python-packager.test.ts | 176 ++++++++++++++++++ .../io/__tests__/config-io-extended.test.ts | 172 +++++++++++++++++ 5 files changed, 647 insertions(+) create mode 100644 src/cli/templates/__tests__/BaseRenderer.test.ts create mode 100644 src/lib/errors/__tests__/config-extended.test.ts create mode 100644 src/lib/packaging/__tests__/node-packager.test.ts create mode 100644 src/lib/packaging/__tests__/python-packager.test.ts create mode 100644 src/lib/schemas/io/__tests__/config-io-extended.test.ts diff --git a/src/cli/templates/__tests__/BaseRenderer.test.ts b/src/cli/templates/__tests__/BaseRenderer.test.ts new file mode 100644 index 00000000..a017ba55 --- /dev/null +++ b/src/cli/templates/__tests__/BaseRenderer.test.ts @@ -0,0 +1,97 @@ +import { BaseRenderer } from '../BaseRenderer.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockCopyAndRenderDir = vi.fn(); +const mockExistsSync = vi.fn(); + +vi.mock('../render.js', () => ({ + copyAndRenderDir: (...args: unknown[]) => mockCopyAndRenderDir(...args), +})); + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { ...actual, existsSync: (...args: unknown[]) => mockExistsSync(...args) }; +}); + +vi.mock('../../../lib', () => ({ + APP_DIR: 'app', +})); + +class TestRenderer extends BaseRenderer { + constructor(config: any, sdkName: string, baseTemplateDir: string) { + super(config, sdkName, baseTemplateDir); + } + + getTemplateDirPublic(): string { + return this.getTemplateDir(); + } +} + +describe('BaseRenderer', () => { + afterEach(() => vi.clearAllMocks()); + + it('getTemplateDir joins language and sdk name', () => { + const renderer = new TestRenderer( + { targetLanguage: 'Python', name: 'MyAgent', hasMemory: false }, + 'strands', + '/templates' + ); + + expect(renderer.getTemplateDirPublic()).toBe('/templates/python/strands'); + }); + + it('render copies base template', async () => { + mockCopyAndRenderDir.mockResolvedValue(undefined); + mockExistsSync.mockReturnValue(false); + + const renderer = new TestRenderer( + { targetLanguage: 'Python', name: 'MyAgent', hasMemory: false }, + 'strands', + '/templates' + ); + + await renderer.render({ outputDir: '/output' }); + + expect(mockCopyAndRenderDir).toHaveBeenCalledTimes(1); + expect(mockCopyAndRenderDir).toHaveBeenCalledWith( + '/templates/python/strands/base', + '/output/app/MyAgent', + expect.objectContaining({ projectName: 'MyAgent', Name: 'MyAgent', hasMcp: false }) + ); + }); + + it('render copies memory capability when hasMemory and dir exists', async () => { + mockCopyAndRenderDir.mockResolvedValue(undefined); + mockExistsSync.mockReturnValue(true); + + const renderer = new TestRenderer( + { targetLanguage: 'TypeScript', name: 'Agent', hasMemory: true }, + 'langchain', + '/templates' + ); + + await renderer.render({ outputDir: '/out' }); + + expect(mockCopyAndRenderDir).toHaveBeenCalledTimes(2); + expect(mockCopyAndRenderDir).toHaveBeenCalledWith( + '/templates/typescript/langchain/capabilities/memory', + '/out/app/Agent/memory', + expect.objectContaining({ projectName: 'Agent', hasMemory: true }) + ); + }); + + it('render skips memory capability when dir does not exist', async () => { + mockCopyAndRenderDir.mockResolvedValue(undefined); + mockExistsSync.mockReturnValue(false); + + const renderer = new TestRenderer( + { targetLanguage: 'Python', name: 'Agent', hasMemory: true }, + 'strands', + '/templates' + ); + + await renderer.render({ outputDir: '/out' }); + + expect(mockCopyAndRenderDir).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/errors/__tests__/config-extended.test.ts b/src/lib/errors/__tests__/config-extended.test.ts new file mode 100644 index 00000000..b95c1a7d --- /dev/null +++ b/src/lib/errors/__tests__/config-extended.test.ts @@ -0,0 +1,68 @@ +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/packaging/__tests__/node-packager.test.ts b/src/lib/packaging/__tests__/node-packager.test.ts new file mode 100644 index 00000000..5ef98a07 --- /dev/null +++ b/src/lib/packaging/__tests__/node-packager.test.ts @@ -0,0 +1,134 @@ +import { NodeCodeZipPackager, NodeCodeZipPackagerSync } from '../node.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockRunSubprocessCapture = vi.fn(); +const mockRunSubprocessCaptureSync = vi.fn(); +const mockResolveProjectPaths = vi.fn(); +const mockResolveProjectPathsSync = vi.fn(); +const mockEnsureBinaryAvailable = vi.fn(); +const mockEnsureBinaryAvailableSync = vi.fn(); +const mockEnsureDirClean = vi.fn(); +const mockEnsureDirCleanSync = vi.fn(); +const mockCopySourceTree = vi.fn(); +const mockCopySourceTreeSync = vi.fn(); +const mockCreateZipFromDir = vi.fn(); +const mockCreateZipFromDirSync = vi.fn(); +const mockEnforceZipSizeLimit = vi.fn(); +const mockEnforceZipSizeLimitSync = vi.fn(); + +vi.mock('../../utils/subprocess', () => ({ + runSubprocessCapture: (...args: unknown[]) => mockRunSubprocessCapture(...args), + runSubprocessCaptureSync: (...args: unknown[]) => mockRunSubprocessCaptureSync(...args), +})); + +vi.mock('../helpers', () => ({ + resolveProjectPaths: (...args: unknown[]) => mockResolveProjectPaths(...args), + resolveProjectPathsSync: (...args: unknown[]) => mockResolveProjectPathsSync(...args), + ensureBinaryAvailable: (...args: unknown[]) => mockEnsureBinaryAvailable(...args), + ensureBinaryAvailableSync: (...args: unknown[]) => mockEnsureBinaryAvailableSync(...args), + ensureDirClean: (...args: unknown[]) => mockEnsureDirClean(...args), + ensureDirCleanSync: (...args: unknown[]) => mockEnsureDirCleanSync(...args), + copySourceTree: (...args: unknown[]) => mockCopySourceTree(...args), + copySourceTreeSync: (...args: unknown[]) => mockCopySourceTreeSync(...args), + createZipFromDir: (...args: unknown[]) => mockCreateZipFromDir(...args), + createZipFromDirSync: (...args: unknown[]) => mockCreateZipFromDirSync(...args), + enforceZipSizeLimit: (...args: unknown[]) => mockEnforceZipSizeLimit(...args), + enforceZipSizeLimitSync: (...args: unknown[]) => mockEnforceZipSizeLimitSync(...args), + isNodeRuntime: (v: string) => v.startsWith('NODE_'), +})); + +const defaultPaths = { + projectRoot: '/project', + srcDir: '/project/src', + stagingDir: '/project/.staging', + artifactsDir: '/project/artifacts', + pyprojectPath: '', +}; + +describe('NodeCodeZipPackager', () => { + afterEach(() => vi.clearAllMocks()); + + const packager = new NodeCodeZipPackager(); + + it('throws for non-CodeZip build type', async () => { + await expect(packager.pack({ build: 'Docker', runtimeVersion: 'NODE_20', name: 'a' } as any)).rejects.toThrow( + 'only supports CodeZip' + ); + }); + + it('throws for non-Node runtime', async () => { + await expect(packager.pack({ build: 'CodeZip', runtimeVersion: 'PYTHON_3_12', name: 'a' } as any)).rejects.toThrow( + 'only supports Node runtimes' + ); + }); + + it('packs successfully', async () => { + mockResolveProjectPaths.mockResolvedValue(defaultPaths); + mockEnsureBinaryAvailable.mockResolvedValue(undefined); + mockEnsureDirClean.mockResolvedValue(undefined); + mockCopySourceTree.mockResolvedValue(undefined); + mockRunSubprocessCapture.mockResolvedValue({ code: 0, stdout: '', stderr: '', signal: null }); + mockCreateZipFromDir.mockResolvedValue(undefined); + mockEnforceZipSizeLimit.mockResolvedValue(1024); + + const result = await packager.pack({ build: 'CodeZip', runtimeVersion: 'NODE_20', name: 'myAgent' } as any); + + expect(result.sizeBytes).toBe(1024); + expect(result.stagingPath).toBe('/project/.staging'); + expect(mockRunSubprocessCapture).toHaveBeenCalledWith( + 'npm', + expect.arrayContaining(['install', '--omit=dev']), + expect.any(Object) + ); + }); + + it('throws when npm install fails', async () => { + mockResolveProjectPaths.mockResolvedValue(defaultPaths); + mockEnsureBinaryAvailable.mockResolvedValue(undefined); + mockEnsureDirClean.mockResolvedValue(undefined); + mockCopySourceTree.mockResolvedValue(undefined); + mockRunSubprocessCapture.mockResolvedValue({ code: 1, stdout: 'error output', stderr: '', signal: null }); + + await expect(packager.pack({ build: 'CodeZip', runtimeVersion: 'NODE_20', name: 'a' } as any)).rejects.toThrow( + 'error output' + ); + }); +}); + +describe('NodeCodeZipPackagerSync', () => { + afterEach(() => vi.clearAllMocks()); + + const packager = new NodeCodeZipPackagerSync(); + + it('throws for non-Node runtime', () => { + expect(() => packager.packCodeZip({ build: 'CodeZip', runtimeVersion: 'PYTHON_3_12', name: 'a' } as any)).toThrow( + 'only supports Node runtimes' + ); + }); + + it('packs successfully', () => { + mockResolveProjectPathsSync.mockReturnValue(defaultPaths); + mockEnsureBinaryAvailableSync.mockReturnValue(undefined); + mockEnsureDirCleanSync.mockReturnValue(undefined); + mockCopySourceTreeSync.mockReturnValue(undefined); + mockRunSubprocessCaptureSync.mockReturnValue({ code: 0, stdout: '', stderr: '', signal: null }); + mockCreateZipFromDirSync.mockReturnValue(undefined); + mockEnforceZipSizeLimitSync.mockReturnValue(2048); + + const result = packager.packCodeZip({ build: 'CodeZip', runtimeVersion: 'NODE_20', name: 'myAgent' } as any); + + expect(result.sizeBytes).toBe(2048); + }); + + it('throws when npm install fails', () => { + mockResolveProjectPathsSync.mockReturnValue(defaultPaths); + mockEnsureBinaryAvailableSync.mockReturnValue(undefined); + mockEnsureDirCleanSync.mockReturnValue(undefined); + mockCopySourceTreeSync.mockReturnValue(undefined); + mockRunSubprocessCaptureSync.mockReturnValue({ code: 1, stdout: '', stderr: 'install failed', signal: null }); + + expect(() => packager.packCodeZip({ build: 'CodeZip', runtimeVersion: 'NODE_20', name: 'a' } as any)).toThrow( + 'install failed' + ); + }); +}); diff --git a/src/lib/packaging/__tests__/python-packager.test.ts b/src/lib/packaging/__tests__/python-packager.test.ts new file mode 100644 index 00000000..f50b9eed --- /dev/null +++ b/src/lib/packaging/__tests__/python-packager.test.ts @@ -0,0 +1,176 @@ +import { PythonCodeZipPackager, PythonCodeZipPackagerSync } from '../python.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockRunSubprocessCapture = vi.fn(); +const mockRunSubprocessCaptureSync = vi.fn(); +const mockResolveProjectPaths = vi.fn(); +const mockResolveProjectPathsSync = vi.fn(); +const mockEnsureBinaryAvailable = vi.fn(); +const mockEnsureBinaryAvailableSync = vi.fn(); +const mockEnsureDirClean = vi.fn(); +const mockEnsureDirCleanSync = vi.fn(); +const mockCopySourceTree = vi.fn(); +const mockCopySourceTreeSync = vi.fn(); +const mockCreateZipFromDir = vi.fn(); +const mockCreateZipFromDirSync = vi.fn(); +const mockEnforceZipSizeLimit = vi.fn(); +const mockEnforceZipSizeLimitSync = vi.fn(); +const mockConvertWindowsScriptsToLinux = vi.fn(); +const mockConvertWindowsScriptsToLinuxSync = vi.fn(); +const mockDetectUnavailablePlatform = vi.fn(); + +vi.mock('../../utils/subprocess', () => ({ + runSubprocessCapture: (...args: unknown[]) => mockRunSubprocessCapture(...args), + runSubprocessCaptureSync: (...args: unknown[]) => mockRunSubprocessCaptureSync(...args), +})); + +vi.mock('../helpers', () => ({ + resolveProjectPaths: (...args: unknown[]) => mockResolveProjectPaths(...args), + resolveProjectPathsSync: (...args: unknown[]) => mockResolveProjectPathsSync(...args), + ensureBinaryAvailable: (...args: unknown[]) => mockEnsureBinaryAvailable(...args), + ensureBinaryAvailableSync: (...args: unknown[]) => mockEnsureBinaryAvailableSync(...args), + ensureDirClean: (...args: unknown[]) => mockEnsureDirClean(...args), + ensureDirCleanSync: (...args: unknown[]) => mockEnsureDirCleanSync(...args), + copySourceTree: (...args: unknown[]) => mockCopySourceTree(...args), + copySourceTreeSync: (...args: unknown[]) => mockCopySourceTreeSync(...args), + createZipFromDir: (...args: unknown[]) => mockCreateZipFromDir(...args), + createZipFromDirSync: (...args: unknown[]) => mockCreateZipFromDirSync(...args), + enforceZipSizeLimit: (...args: unknown[]) => mockEnforceZipSizeLimit(...args), + enforceZipSizeLimitSync: (...args: unknown[]) => mockEnforceZipSizeLimitSync(...args), + convertWindowsScriptsToLinux: (...args: unknown[]) => mockConvertWindowsScriptsToLinux(...args), + convertWindowsScriptsToLinuxSync: (...args: unknown[]) => mockConvertWindowsScriptsToLinuxSync(...args), + isPythonRuntime: (v: string) => v.startsWith('PYTHON_'), +})); + +vi.mock('../uv', () => ({ + detectUnavailablePlatform: (...args: unknown[]) => mockDetectUnavailablePlatform(...args), +})); + +const defaultPaths = { + projectRoot: '/project', + srcDir: '/project/src', + stagingDir: '/project/.staging', + artifactsDir: '/project/artifacts', + pyprojectPath: '/project/pyproject.toml', +}; + +describe('PythonCodeZipPackager', () => { + afterEach(() => vi.clearAllMocks()); + + const packager = new PythonCodeZipPackager(); + + it('throws for non-CodeZip build type', async () => { + await expect(packager.pack({ build: 'Docker', runtimeVersion: 'PYTHON_3_12', name: 'a' } as any)).rejects.toThrow( + 'only supports CodeZip' + ); + }); + + it('throws for non-Python runtime', async () => { + await expect(packager.pack({ build: 'CodeZip', runtimeVersion: 'NODE_20', name: 'a' } as any)).rejects.toThrow( + 'only supports Python runtimes' + ); + }); + + it('packs successfully on first platform', async () => { + mockResolveProjectPaths.mockResolvedValue(defaultPaths); + mockEnsureBinaryAvailable.mockResolvedValue(undefined); + mockEnsureDirClean.mockResolvedValue(undefined); + mockRunSubprocessCapture.mockResolvedValue({ code: 0, stdout: '', stderr: '', signal: null }); + mockCopySourceTree.mockResolvedValue(undefined); + mockConvertWindowsScriptsToLinux.mockResolvedValue(undefined); + mockCreateZipFromDir.mockResolvedValue(undefined); + mockEnforceZipSizeLimit.mockResolvedValue(4096); + + const result = await packager.pack({ build: 'CodeZip', runtimeVersion: 'PYTHON_3_12', name: 'agent' } as any); + + expect(result.sizeBytes).toBe(4096); + expect(mockRunSubprocessCapture).toHaveBeenCalledWith( + 'uv', + expect.arrayContaining(['pip', 'install']), + expect.any(Object) + ); + }); + + it('retries on platform issue and succeeds', async () => { + mockResolveProjectPaths.mockResolvedValue(defaultPaths); + mockEnsureBinaryAvailable.mockResolvedValue(undefined); + mockEnsureDirClean.mockResolvedValue(undefined); + // First platform fails with platform issue, second succeeds + mockRunSubprocessCapture + .mockResolvedValueOnce({ code: 1, stdout: '', stderr: 'platform error', signal: null }) + .mockResolvedValueOnce({ code: 0, stdout: '', stderr: '', signal: null }); + mockDetectUnavailablePlatform.mockReturnValueOnce(true).mockReturnValueOnce(false); + mockCopySourceTree.mockResolvedValue(undefined); + mockConvertWindowsScriptsToLinux.mockResolvedValue(undefined); + mockCreateZipFromDir.mockResolvedValue(undefined); + mockEnforceZipSizeLimit.mockResolvedValue(2048); + + const result = await packager.pack({ build: 'CodeZip', runtimeVersion: 'PYTHON_3_12', name: 'agent' } as any); + + expect(result.sizeBytes).toBe(2048); + expect(mockRunSubprocessCapture).toHaveBeenCalledTimes(2); + }); + + it('throws on non-platform error', async () => { + mockResolveProjectPaths.mockResolvedValue(defaultPaths); + mockEnsureBinaryAvailable.mockResolvedValue(undefined); + mockEnsureDirClean.mockResolvedValue(undefined); + mockRunSubprocessCapture.mockResolvedValue({ code: 1, stdout: '', stderr: 'fatal error', signal: null }); + mockDetectUnavailablePlatform.mockReturnValue(false); + + await expect(packager.pack({ build: 'CodeZip', runtimeVersion: 'PYTHON_3_12', name: 'a' } as any)).rejects.toThrow( + 'fatal error' + ); + }); + + it('throws when all platforms fail', async () => { + mockResolveProjectPaths.mockResolvedValue(defaultPaths); + mockEnsureBinaryAvailable.mockResolvedValue(undefined); + mockEnsureDirClean.mockResolvedValue(undefined); + mockRunSubprocessCapture.mockResolvedValue({ code: 1, stdout: '', stderr: 'platform err', signal: null }); + mockDetectUnavailablePlatform.mockReturnValue(true); + + await expect(packager.pack({ build: 'CodeZip', runtimeVersion: 'PYTHON_3_12', name: 'a' } as any)).rejects.toThrow( + 'all platform candidates' + ); + }); +}); + +describe('PythonCodeZipPackagerSync', () => { + afterEach(() => vi.clearAllMocks()); + + const packager = new PythonCodeZipPackagerSync(); + + it('throws for non-Python runtime', () => { + expect(() => packager.packCodeZip({ build: 'CodeZip', runtimeVersion: 'NODE_20', name: 'a' } as any)).toThrow( + 'only supports Python runtimes' + ); + }); + + it('packs successfully', () => { + mockResolveProjectPathsSync.mockReturnValue(defaultPaths); + mockEnsureBinaryAvailableSync.mockReturnValue(undefined); + mockEnsureDirCleanSync.mockReturnValue(undefined); + mockRunSubprocessCaptureSync.mockReturnValue({ code: 0, stdout: '', stderr: '', signal: null }); + mockCopySourceTreeSync.mockReturnValue(undefined); + mockConvertWindowsScriptsToLinuxSync.mockReturnValue(undefined); + mockCreateZipFromDirSync.mockReturnValue(undefined); + mockEnforceZipSizeLimitSync.mockReturnValue(3072); + + const result = packager.packCodeZip({ build: 'CodeZip', runtimeVersion: 'PYTHON_3_12', name: 'agent' } as any); + + expect(result.sizeBytes).toBe(3072); + }); + + it('throws when install fails with non-platform error', () => { + mockResolveProjectPathsSync.mockReturnValue(defaultPaths); + mockEnsureBinaryAvailableSync.mockReturnValue(undefined); + mockEnsureDirCleanSync.mockReturnValue(undefined); + mockRunSubprocessCaptureSync.mockReturnValue({ code: 1, stdout: '', stderr: 'sync fail', signal: null }); + mockDetectUnavailablePlatform.mockReturnValue(false); + + expect(() => packager.packCodeZip({ build: 'CodeZip', runtimeVersion: 'PYTHON_3_12', name: 'a' } as any)).toThrow( + 'sync fail' + ); + }); +}); diff --git a/src/lib/schemas/io/__tests__/config-io-extended.test.ts b/src/lib/schemas/io/__tests__/config-io-extended.test.ts new file mode 100644 index 00000000..64667532 --- /dev/null +++ b/src/lib/schemas/io/__tests__/config-io-extended.test.ts @@ -0,0 +1,172 @@ +/* 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); + }); + }); +}); From 97dc64ab82f37f4470c9f94aea15935c6655ac97 Mon Sep 17 00:00:00 2001 From: notgitika Date: Sun, 15 Feb 2026 22:01:49 -0500 Subject: [PATCH 5/7] test: add tests for preflight, loadProjectConfig, credentials, and initGitRepo --- .../deploy/__tests__/preflight-utils.test.ts | 80 ++++++++++++- .../dev/__tests__/load-project-config.test.ts | 43 +++++++ .../identity/__tests__/credential-ops.test.ts | 110 ++++++++++++++++++ .../init/__tests__/init-git.test.ts | 87 ++++++++++++++ 4 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 src/cli/operations/dev/__tests__/load-project-config.test.ts create mode 100644 src/cli/operations/identity/__tests__/credential-ops.test.ts create mode 100644 src/cli/operations/init/__tests__/init-git.test.ts diff --git a/src/cli/operations/deploy/__tests__/preflight-utils.test.ts b/src/cli/operations/deploy/__tests__/preflight-utils.test.ts index 730a3246..423fd772 100644 --- a/src/cli/operations/deploy/__tests__/preflight-utils.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight-utils.test.ts @@ -1,5 +1,14 @@ -import { formatError } from '../preflight.js'; -import { describe, expect, it } from 'vitest'; +import { checkBootstrapNeeded, checkStackDeployability, formatError } from '../preflight.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockCheckStacksStatus = vi.fn(); +const mockCheckBootstrapStatus = vi.fn(); + +vi.mock('../../../cloudformation', () => ({ + checkStacksStatus: (...args: unknown[]) => mockCheckStacksStatus(...args), + checkBootstrapStatus: (...args: unknown[]) => mockCheckBootstrapStatus(...args), + formatCdkEnvironment: vi.fn(), +})); describe('formatError', () => { it('formats Error with message only', () => { @@ -37,3 +46,70 @@ describe('formatError', () => { expect(formatError(null)).toBe('null'); }); }); + +describe('checkStackDeployability', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns canDeploy true when no blocking stacks', async () => { + mockCheckStacksStatus.mockResolvedValue(null); + + const result = await checkStackDeployability('us-east-1', ['stack1']); + + expect(result.canDeploy).toBe(true); + expect(result.blockingStack).toBeUndefined(); + }); + + it('returns canDeploy false with blocking stack info', async () => { + mockCheckStacksStatus.mockResolvedValue({ + stackName: 'stack1', + result: { message: 'Stack is in ROLLBACK_COMPLETE' }, + }); + + const result = await checkStackDeployability('us-east-1', ['stack1']); + + expect(result.canDeploy).toBe(false); + expect(result.blockingStack).toBe('stack1'); + expect(result.message).toContain('ROLLBACK_COMPLETE'); + }); +}); + +describe('checkBootstrapNeeded', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns not needed when no targets', async () => { + const result = await checkBootstrapNeeded([]); + + expect(result.needsBootstrap).toBe(false); + expect(result.target).toBeNull(); + }); + + it('returns needed when not bootstrapped', async () => { + mockCheckBootstrapStatus.mockResolvedValue({ isBootstrapped: false }); + const target = { name: 'dev', region: 'us-east-1', account: '123456789012' } as any; + + const result = await checkBootstrapNeeded([target]); + + expect(result.needsBootstrap).toBe(true); + expect(result.target).toBe(target); + }); + + it('returns not needed when already bootstrapped', async () => { + mockCheckBootstrapStatus.mockResolvedValue({ isBootstrapped: true }); + const target = { name: 'dev', region: 'us-east-1', account: '123456789012' } as any; + + const result = await checkBootstrapNeeded([target]); + + expect(result.needsBootstrap).toBe(false); + expect(result.target).toBeNull(); + }); + + it('returns not needed when check throws', async () => { + mockCheckBootstrapStatus.mockRejectedValue(new Error('network error')); + const target = { name: 'dev', region: 'us-east-1', account: '123456789012' } as any; + + const result = await checkBootstrapNeeded([target]); + + expect(result.needsBootstrap).toBe(false); + expect(result.target).toBeNull(); + }); +}); diff --git a/src/cli/operations/dev/__tests__/load-project-config.test.ts b/src/cli/operations/dev/__tests__/load-project-config.test.ts new file mode 100644 index 00000000..4e8f94c4 --- /dev/null +++ b/src/cli/operations/dev/__tests__/load-project-config.test.ts @@ -0,0 +1,43 @@ +import { loadProjectConfig } from '../config.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockConfigExists = vi.fn(); +const mockFindConfigRoot = vi.fn(); + +vi.mock('../../../../lib', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + configExists = mockConfigExists; + }, + findConfigRoot: (...args: unknown[]) => mockFindConfigRoot(...args), +})); + +describe('loadProjectConfig', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns null when no config root found', async () => { + mockFindConfigRoot.mockReturnValue(null); + expect(await loadProjectConfig('/work')).toBeNull(); + }); + + it('returns null when project config does not exist', async () => { + mockFindConfigRoot.mockReturnValue('/work/agentcore'); + mockConfigExists.mockReturnValue(false); + expect(await loadProjectConfig('/work')).toBeNull(); + }); + + it('returns project spec when valid', async () => { + mockFindConfigRoot.mockReturnValue('/work/agentcore'); + mockConfigExists.mockReturnValue(true); + mockReadProjectSpec.mockResolvedValue({ name: 'Test', agents: [] }); + expect(await loadProjectConfig('/work')).toEqual({ name: 'Test', agents: [] }); + }); + + it('returns null when readProjectSpec throws', async () => { + mockFindConfigRoot.mockReturnValue('/work/agentcore'); + mockConfigExists.mockReturnValue(true); + mockReadProjectSpec.mockRejectedValue(new Error('bad config')); + expect(await loadProjectConfig('/work')).toBeNull(); + }); +}); diff --git a/src/cli/operations/identity/__tests__/credential-ops.test.ts b/src/cli/operations/identity/__tests__/credential-ops.test.ts new file mode 100644 index 00000000..97e416bd --- /dev/null +++ b/src/cli/operations/identity/__tests__/credential-ops.test.ts @@ -0,0 +1,110 @@ +import { + createCredential, + getAllCredentialNames, + resolveCredentialStrategy, +} from '../create-identity.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockWriteProjectSpec = vi.fn(); +const mockGetEnvVar = vi.fn(); +const mockSetEnvVar = vi.fn(); + +vi.mock('../../../../lib', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + }, + getEnvVar: (...args: unknown[]) => mockGetEnvVar(...args), + setEnvVar: (...args: unknown[]) => mockSetEnvVar(...args), +})); + +describe('getAllCredentialNames', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns credential names', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'Cred1' }, { name: 'Cred2' }], + }); + expect(await getAllCredentialNames()).toEqual(['Cred1', 'Cred2']); + }); + + it('returns empty on error', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('fail')); + expect(await getAllCredentialNames()).toEqual([]); + }); +}); + +describe('createCredential', () => { + afterEach(() => vi.clearAllMocks()); + + it('creates new credential and writes to project', async () => { + const project = { credentials: [] as any[] }; + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockSetEnvVar.mockResolvedValue(undefined); + + const result = await createCredential({ name: 'NewCred', apiKey: 'key123' }); + + expect(result.name).toBe('NewCred'); + expect(result.type).toBe('ApiKeyCredentialProvider'); + expect(mockWriteProjectSpec).toHaveBeenCalled(); + expect(mockSetEnvVar).toHaveBeenCalledWith('AGENTCORE_CREDENTIAL_NEWCRED', 'key123'); + }); + + it('reuses existing credential without writing project', async () => { + const existing = { name: 'ExistCred', type: 'ApiKeyCredentialProvider' }; + mockReadProjectSpec.mockResolvedValue({ credentials: [existing] }); + mockSetEnvVar.mockResolvedValue(undefined); + + const result = await createCredential({ name: 'ExistCred', apiKey: 'newkey' }); + + expect(result).toBe(existing); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + expect(mockSetEnvVar).toHaveBeenCalledWith('AGENTCORE_CREDENTIAL_EXISTCRED', 'newkey'); + }); +}); + +describe('resolveCredentialStrategy', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns no credential for Bedrock provider', async () => { + const result = await resolveCredentialStrategy('Proj', 'Agent', 'Bedrock', 'key', '/base', []); + expect(result.credentialName).toBe(''); + expect(result.reuse).toBe(true); + }); + + it('returns no credential when no API key', async () => { + const result = await resolveCredentialStrategy('Proj', 'Agent', 'Anthropic' as any, undefined, '/base', []); + expect(result.credentialName).toBe(''); + }); + + it('reuses existing credential with matching key', async () => { + mockGetEnvVar.mockResolvedValue('my-api-key'); + const creds = [{ name: 'ProjAnthropic', type: 'ApiKeyCredentialProvider' as const }]; + + const result = await resolveCredentialStrategy('Proj', 'Agent', 'Anthropic' as any, 'my-api-key', '/base', creds); + + expect(result.reuse).toBe(true); + expect(result.credentialName).toBe('ProjAnthropic'); + }); + + it('creates project-scoped credential when no existing', async () => { + const result = await resolveCredentialStrategy('Proj', 'Agent', 'Anthropic' as any, 'new-key', '/base', []); + + expect(result.reuse).toBe(false); + expect(result.credentialName).toBe('ProjAnthropic'); + expect(result.isAgentScoped).toBe(false); + }); + + it('creates agent-scoped credential when project-scoped exists with different key', async () => { + mockGetEnvVar.mockResolvedValue('different-key'); + const creds = [{ name: 'ProjAnthropic', type: 'ApiKeyCredentialProvider' as const }]; + + const result = await resolveCredentialStrategy('Proj', 'Agent', 'Anthropic' as any, 'new-key', '/base', creds); + + expect(result.reuse).toBe(false); + expect(result.credentialName).toBe('ProjAgentAnthropic'); + expect(result.isAgentScoped).toBe(true); + }); +}); diff --git a/src/cli/operations/init/__tests__/init-git.test.ts b/src/cli/operations/init/__tests__/init-git.test.ts new file mode 100644 index 00000000..a30abc27 --- /dev/null +++ b/src/cli/operations/init/__tests__/init-git.test.ts @@ -0,0 +1,87 @@ +import { initGitRepo } from '../files.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockRunSubprocessCapture = vi.fn(); + +vi.mock('../../../../lib', () => ({ + runSubprocessCapture: (...args: unknown[]) => mockRunSubprocessCapture(...args), +})); + +const ok = { code: 0, stdout: '', stderr: '', signal: null }; +const fail = (stderr = '') => ({ code: 1, stdout: '', stderr, signal: null }); + +describe('initGitRepo', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns skipped when git is not available', async () => { + mockRunSubprocessCapture.mockResolvedValue(fail()); + + const result = await initGitRepo('/project'); + + expect(result.status).toBe('skipped'); + expect(result.message).toContain('git not available'); + }); + + it('returns skipped when already in a git repo', async () => { + mockRunSubprocessCapture + .mockResolvedValueOnce(ok) // git --version + .mockResolvedValueOnce(ok); // git rev-parse + + const result = await initGitRepo('/project'); + + expect(result.status).toBe('skipped'); + expect(result.message).toContain('already in a git repository'); + }); + + it('returns error when git init fails', async () => { + mockRunSubprocessCapture + .mockResolvedValueOnce(ok) // git --version + .mockResolvedValueOnce(fail()) // git rev-parse (not in repo) + .mockResolvedValueOnce(fail('init error')); // git init + + const result = await initGitRepo('/project'); + + expect(result.status).toBe('error'); + expect(result.message).toContain('init error'); + }); + + it('returns error when git add fails', async () => { + mockRunSubprocessCapture + .mockResolvedValueOnce(ok) // git --version + .mockResolvedValueOnce(fail()) // git rev-parse + .mockResolvedValueOnce(ok) // git init + .mockResolvedValueOnce(fail('add error')); // git add + + const result = await initGitRepo('/project'); + + expect(result.status).toBe('error'); + expect(result.message).toContain('add error'); + }); + + it('returns error when git commit fails', async () => { + mockRunSubprocessCapture + .mockResolvedValueOnce(ok) // git --version + .mockResolvedValueOnce(fail()) // git rev-parse + .mockResolvedValueOnce(ok) // git init + .mockResolvedValueOnce(ok) // git add + .mockResolvedValueOnce(fail('commit error')); // git commit + + const result = await initGitRepo('/project'); + + expect(result.status).toBe('error'); + expect(result.message).toContain('commit error'); + }); + + it('returns success on full flow', async () => { + mockRunSubprocessCapture + .mockResolvedValueOnce(ok) // git --version + .mockResolvedValueOnce(fail()) // git rev-parse (not in repo) + .mockResolvedValueOnce(ok) // git init + .mockResolvedValueOnce(ok) // git add + .mockResolvedValueOnce(ok); // git commit + + const result = await initGitRepo('/project'); + + expect(result.status).toBe('success'); + }); +}); From 71f49cdb332959b128c694e5f91e544418fead8f Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 16 Feb 2026 10:42:22 -0500 Subject: [PATCH 6/7] run npm run format --- .../operations/identity/__tests__/credential-ops.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/cli/operations/identity/__tests__/credential-ops.test.ts b/src/cli/operations/identity/__tests__/credential-ops.test.ts index 97e416bd..c2817483 100644 --- a/src/cli/operations/identity/__tests__/credential-ops.test.ts +++ b/src/cli/operations/identity/__tests__/credential-ops.test.ts @@ -1,8 +1,4 @@ -import { - createCredential, - getAllCredentialNames, - resolveCredentialStrategy, -} from '../create-identity.js'; +import { createCredential, getAllCredentialNames, resolveCredentialStrategy } from '../create-identity.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; const mockReadProjectSpec = vi.fn(); From b9fd58846e76e6d668e6c99e75ace15cbcf3f67a Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 16 Feb 2026 11:34:01 -0500 Subject: [PATCH 7/7] test: add tests for update action, validate formatting, MCP operations, and teardown --- .../update/__tests__/update-action.test.ts | 116 ++++++++- .../validate/__tests__/action.test.ts | 93 +++++++- .../__tests__/write-agent-to-project.test.ts | 26 ++ .../deploy/__tests__/teardown-utils.test.ts | 161 ++++++++++++- .../mcp/__tests__/create-mcp-utils.test.ts | 223 +++++++++++++++++- src/cli/schema/__tests__/document.test.ts | 11 + 6 files changed, 620 insertions(+), 10 deletions(-) diff --git a/src/cli/commands/update/__tests__/update-action.test.ts b/src/cli/commands/update/__tests__/update-action.test.ts index ebf41693..a68705d4 100644 --- a/src/cli/commands/update/__tests__/update-action.test.ts +++ b/src/cli/commands/update/__tests__/update-action.test.ts @@ -1,5 +1,22 @@ -import { compareVersions } from '../action.js'; -import { describe, expect, it } from 'vitest'; +import { compareVersions, fetchLatestVersion, handleUpdate } from '../action.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockExecSync } = vi.hoisted(() => ({ + mockExecSync: vi.fn(), +})); + +vi.mock('child_process', () => ({ + execSync: mockExecSync, +})); + +vi.mock('../../../constants.js', () => ({ + PACKAGE_VERSION: '1.2.3', + getDistroConfig: () => ({ + packageName: '@aws/agentcore', + registryUrl: 'https://registry.npmjs.org', + installCommand: 'npm install -g @aws/agentcore@latest', + }), +})); describe('compareVersions', () => { it('returns 0 for equal versions', () => { @@ -34,3 +51,98 @@ describe('compareVersions', () => { expect(compareVersions('1.0', '1.0.0')).toBe(0); }); }); + +describe('fetchLatestVersion', () => { + afterEach(() => vi.restoreAllMocks()); + + it('returns version from registry', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ version: '2.0.0' }), + } as Response); + + const version = await fetchLatestVersion(); + + expect(version).toBe('2.0.0'); + expect(fetch).toHaveBeenCalledWith('https://registry.npmjs.org/@aws/agentcore/latest'); + }); + + it('throws when response is not ok', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + statusText: 'Not Found', + } as Response); + + await expect(fetchLatestVersion()).rejects.toThrow('Failed to fetch latest version: Not Found'); + }); +}); + +describe('handleUpdate', () => { + afterEach(() => { + vi.restoreAllMocks(); + mockExecSync.mockReset(); + }); + + it('returns up-to-date when versions match', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ version: '1.2.3' }), + } as Response); + + const result = await handleUpdate(false); + + expect(result.status).toBe('up-to-date'); + expect(result.currentVersion).toBe('1.2.3'); + expect(result.latestVersion).toBe('1.2.3'); + }); + + it('returns newer-local when current is ahead', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ version: '1.0.0' }), + } as Response); + + const result = await handleUpdate(false); + + expect(result.status).toBe('newer-local'); + }); + + it('returns update-available when checkOnly is true', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ version: '2.0.0' }), + } as Response); + + const result = await handleUpdate(true); + + expect(result.status).toBe('update-available'); + expect(mockExecSync).not.toHaveBeenCalled(); + }); + + it('returns updated after successful install', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ version: '2.0.0' }), + } as Response); + mockExecSync.mockReturnValue(undefined); + + const result = await handleUpdate(false); + + expect(result.status).toBe('updated'); + expect(mockExecSync).toHaveBeenCalledWith('npm install -g @aws/agentcore@latest', { stdio: 'inherit' }); + }); + + it('returns update-failed when install throws', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ version: '2.0.0' }), + } as Response); + mockExecSync.mockImplementation(() => { + throw new Error('install failed'); + }); + + const result = await handleUpdate(false); + + expect(result.status).toBe('update-failed'); + }); +}); diff --git a/src/cli/commands/validate/__tests__/action.test.ts b/src/cli/commands/validate/__tests__/action.test.ts index bb1f09fc..c748662a 100644 --- a/src/cli/commands/validate/__tests__/action.test.ts +++ b/src/cli/commands/validate/__tests__/action.test.ts @@ -23,6 +23,32 @@ vi.mock('../../../../lib/index.js', () => { } } + class ConfigValidationError extends Error {} + class ConfigParseError extends Error { + constructor( + public readonly filePath: string, + public override readonly cause: unknown + ) { + super(`Parse error at ${filePath}`); + } + } + class ConfigReadError extends Error { + constructor( + public readonly filePath: string, + public override readonly cause: unknown + ) { + super(`Read error at ${filePath}`); + } + } + class ConfigNotFoundError extends Error { + constructor( + public readonly filePath: string, + public readonly fileType: string + ) { + super(`${fileType} not found at ${filePath}`); + } + } + return { ConfigIO: class { readProjectSpec = mockReadProjectSpec; @@ -30,10 +56,10 @@ vi.mock('../../../../lib/index.js', () => { readDeployedState = mockReadDeployedState; configExists = mockConfigExists; }, - ConfigValidationError: class extends Error {}, - ConfigParseError: class extends Error {}, - ConfigReadError: class extends Error {}, - ConfigNotFoundError: class extends Error {}, + ConfigValidationError, + ConfigParseError, + ConfigReadError, + ConfigNotFoundError, NoProjectError, findConfigRoot: mockFindConfigRoot, }; @@ -120,4 +146,63 @@ describe('handleValidate', () => { expect(result.success).toBe(true); expect(mockFindConfigRoot).toHaveBeenCalledWith('/custom'); }); + + it('formats ConfigValidationError with its message', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + const { ConfigValidationError } = await import('../../../../lib/index.js'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + mockReadProjectSpec.mockRejectedValue(new (ConfigValidationError as any)('field "name" is required')); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + expect(result.error).toBe('field "name" is required'); + }); + + it('formats ConfigParseError with cause', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + const { ConfigParseError } = await import('../../../../lib/index.js'); + mockReadProjectSpec.mockRejectedValue(new ConfigParseError('agentcore.json', new Error('Unexpected token'))); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid JSON in agentcore.json'); + expect(result.error).toContain('Unexpected token'); + }); + + it('formats ConfigReadError with cause', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + const { ConfigReadError } = await import('../../../../lib/index.js'); + mockReadProjectSpec.mockRejectedValue( + new ConfigReadError('agentcore.json', new Error('EACCES: permission denied')) + ); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to read agentcore.json'); + expect(result.error).toContain('EACCES'); + }); + + it('formats ConfigNotFoundError with file name', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + const { ConfigNotFoundError } = await import('../../../../lib/index.js'); + mockReadProjectSpec.mockRejectedValue(new ConfigNotFoundError('/path/agentcore.json', 'project')); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + expect(result.error).toBe('Required file not found: agentcore.json'); + }); + + it('formats non-Error values as strings', async () => { + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockReadProjectSpec.mockRejectedValue('string error'); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + expect(result.error).toBe('string error'); + }); }); diff --git a/src/cli/operations/agent/generate/__tests__/write-agent-to-project.test.ts b/src/cli/operations/agent/generate/__tests__/write-agent-to-project.test.ts index 31c066ad..cea461e3 100644 --- a/src/cli/operations/agent/generate/__tests__/write-agent-to-project.test.ts +++ b/src/cli/operations/agent/generate/__tests__/write-agent-to-project.test.ts @@ -110,6 +110,32 @@ describe('writeAgentToProject with credentialStrategy', () => { }); }); + describe('duplicate agent detection', () => { + it('throws AgentAlreadyExistsError for duplicate name', async () => { + const writeAgentToProject = await getWriteAgentToProject(); + // First write an agent, then try to write the same one again + await writeAgentToProject(baseConfig, { configBaseDir }); + + await expect(writeAgentToProject(baseConfig, { configBaseDir })).rejects.toThrow('TestAgent'); + }); + }); + + describe('new project creation (no existing config)', () => { + it('creates project spec when config does not exist', async () => { + const writeAgentToProject = await getWriteAgentToProject(); + // Remove the existing config so configExists('project') returns false + const { rm: rmFile } = await import('node:fs/promises'); + await rmFile(join(configBaseDir, 'agentcore.json')); + + await writeAgentToProject(baseConfig, { configBaseDir }); + + const project = await readProject(); + expect(project.name).toBe('TestAgent'); + expect(project.agents).toHaveLength(1); + expect(project.agents[0].name).toBe('TestAgent'); + }); + }); + describe('without credentialStrategy (backward compatibility)', () => { it('uses mapModelProviderToCredentials behavior', async () => { const writeAgentToProject = await getWriteAgentToProject(); diff --git a/src/cli/operations/deploy/__tests__/teardown-utils.test.ts b/src/cli/operations/deploy/__tests__/teardown-utils.test.ts index 8d2d2168..6e9e53ae 100644 --- a/src/cli/operations/deploy/__tests__/teardown-utils.test.ts +++ b/src/cli/operations/deploy/__tests__/teardown-utils.test.ts @@ -1,6 +1,56 @@ -import { getCdkProjectDir } from '../teardown.js'; +import { type DeployedTarget, destroyTarget, discoverDeployedTargets, getCdkProjectDir } from '../teardown.js'; import { join } from 'path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { + mockReadProjectSpec, + mockReadAWSDeploymentTargets, + mockReadDeployedState, + mockWriteDeployedState, + mockFindStack, + mockExistsSync, + mockInitialize, + mockDestroy, +} = vi.hoisted(() => ({ + mockReadProjectSpec: vi.fn(), + mockReadAWSDeploymentTargets: vi.fn(), + mockReadDeployedState: vi.fn(), + mockWriteDeployedState: vi.fn(), + mockFindStack: vi.fn(), + mockExistsSync: vi.fn(), + mockInitialize: vi.fn(), + mockDestroy: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + CONFIG_DIR: 'agentcore', + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + readAWSDeploymentTargets = mockReadAWSDeploymentTargets; + readDeployedState = mockReadDeployedState; + writeDeployedState = mockWriteDeployedState; + }, +})); + +vi.mock('../../../cloudformation/stack-discovery.js', () => ({ + findStack: mockFindStack, +})); + +vi.mock('fs', () => ({ + existsSync: mockExistsSync, +})); + +vi.mock('../../../cdk/toolkit-lib/index.js', () => ({ + CdkToolkitWrapper: class { + initialize = mockInitialize; + destroy = mockDestroy; + }, + silentIoHost: {}, +})); + +vi.mock('@aws-cdk/toolkit-lib', () => ({ + StackSelectionStrategy: { PATTERN_MUST_MATCH: 'PATTERN_MUST_MATCH' }, +})); describe('getCdkProjectDir', () => { it('returns agentcore/cdk under cwd by default', () => { @@ -15,3 +65,110 @@ describe('getCdkProjectDir', () => { expect(result).toBe(join('/custom/path', 'agentcore', 'cdk')); }); }); + +describe('discoverDeployedTargets', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns deployed targets with matching stacks', async () => { + mockReadProjectSpec.mockResolvedValue({ name: 'my-project' }); + mockReadAWSDeploymentTargets.mockResolvedValue([ + { name: 'target-1', region: 'us-east-1' }, + { name: 'target-2', region: 'us-west-2' }, + ]); + mockFindStack + .mockResolvedValueOnce({ + stackName: 'my-project-target-1', + stackArn: 'arn:aws:cf:us-east-1:123:stack/my-project-target-1/id', + targetName: 'target-1', + }) + .mockResolvedValueOnce(null); + + const result = await discoverDeployedTargets(); + + expect(result.projectName).toBe('my-project'); + expect(result.deployedTargets).toHaveLength(1); + expect(result.deployedTargets[0]!.target.name).toBe('target-1'); + expect(mockFindStack).toHaveBeenCalledTimes(2); + }); + + it('ignores errors when checking individual targets', async () => { + mockReadProjectSpec.mockResolvedValue({ name: 'my-project' }); + mockReadAWSDeploymentTargets.mockResolvedValue([{ name: 'target-1', region: 'us-east-1' }]); + mockFindStack.mockRejectedValue(new Error('no credentials')); + + const result = await discoverDeployedTargets(); + + expect(result.deployedTargets).toEqual([]); + }); + + it('uses custom base dir when provided', async () => { + mockReadProjectSpec.mockResolvedValue({ name: 'proj' }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + + const result = await discoverDeployedTargets('/custom/dir'); + + expect(result.projectName).toBe('proj'); + expect(result.deployedTargets).toEqual([]); + }); +}); + +describe('destroyTarget', () => { + afterEach(() => vi.clearAllMocks()); + + const makeTarget = (name: string, stackName: string): DeployedTarget => + ({ + target: { name, account: '123456789012', region: 'us-east-1' }, + stack: { stackName, stackArn: `arn:aws:cf:us-east-1:123:stack/${stackName}/id`, targetName: name }, + }) as DeployedTarget; + + it('throws when CDK project dir does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + await expect( + destroyTarget({ + target: makeTarget('tgt', 'stack-1'), + cdkProjectDir: '/project/agentcore/cdk', + }) + ).rejects.toThrow('CDK project not found'); + }); + + it('destroys stack and cleans up deployed state', async () => { + mockExistsSync.mockReturnValue(true); + mockInitialize.mockResolvedValue(undefined); + mockDestroy.mockResolvedValue(undefined); + mockReadDeployedState.mockResolvedValue({ + targets: { 'tgt-1': { status: 'deployed' } }, + }); + mockWriteDeployedState.mockResolvedValue(undefined); + + await destroyTarget({ + target: makeTarget('tgt-1', 'stack-tgt-1'), + cdkProjectDir: '/project/agentcore/cdk', + }); + + expect(mockInitialize).toHaveBeenCalled(); + expect(mockDestroy).toHaveBeenCalledWith( + expect.objectContaining({ + stacks: { + strategy: 'PATTERN_MUST_MATCH', + patterns: ['stack-tgt-1'], + }, + }) + ); + expect(mockWriteDeployedState).toHaveBeenCalledWith({ targets: {} }); + }); + + it('ignores errors reading deployed state after destroy', async () => { + mockExistsSync.mockReturnValue(true); + mockInitialize.mockResolvedValue(undefined); + mockDestroy.mockResolvedValue(undefined); + mockReadDeployedState.mockRejectedValue(new Error('no state file')); + + await expect( + destroyTarget({ + target: makeTarget('tgt-1', 'stack-1'), + cdkProjectDir: '/project/agentcore/cdk', + }) + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/cli/operations/mcp/__tests__/create-mcp-utils.test.ts b/src/cli/operations/mcp/__tests__/create-mcp-utils.test.ts index 850f1f56..5468cbd4 100644 --- a/src/cli/operations/mcp/__tests__/create-mcp-utils.test.ts +++ b/src/cli/operations/mcp/__tests__/create-mcp-utils.test.ts @@ -1,5 +1,29 @@ -import { computeDefaultGatewayEnvVarName, computeDefaultMcpRuntimeEnvVarName } from '../create-mcp.js'; -import { describe, expect, it } from 'vitest'; +import { + computeDefaultGatewayEnvVarName, + computeDefaultMcpRuntimeEnvVarName, + createGatewayFromWizard, + getAvailableAgents, + getExistingGateways, + getExistingToolNames, +} from '../create-mcp.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockReadMcpSpec, mockWriteMcpSpec, mockReadProjectSpec, mockConfigExists } = vi.hoisted(() => ({ + mockReadMcpSpec: vi.fn(), + mockWriteMcpSpec: vi.fn(), + mockReadProjectSpec: vi.fn(), + mockConfigExists: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readMcpSpec = mockReadMcpSpec; + writeMcpSpec = mockWriteMcpSpec; + readProjectSpec = mockReadProjectSpec; + configExists = mockConfigExists; + }, + requireConfigRoot: () => '/project/agentcore', +})); describe('computeDefaultGatewayEnvVarName', () => { it('uppercases and wraps gateway name', () => { @@ -28,3 +52,198 @@ describe('computeDefaultMcpRuntimeEnvVarName', () => { expect(computeDefaultMcpRuntimeEnvVarName('runtime')).toBe('AGENTCORE_MCPRUNTIME_RUNTIME_URL'); }); }); + +describe('getExistingGateways', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns empty array when mcp config does not exist', async () => { + mockConfigExists.mockReturnValue(false); + + const result = await getExistingGateways(); + + expect(result).toEqual([]); + }); + + it('returns gateway names from mcp spec', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'gw-1' }, { name: 'gw-2' }], + }); + + const result = await getExistingGateways(); + + expect(result).toEqual(['gw-1', 'gw-2']); + }); + + it('returns empty array on error', async () => { + mockConfigExists.mockImplementation(() => { + throw new Error('read error'); + }); + + const result = await getExistingGateways(); + + expect(result).toEqual([]); + }); +}); + +describe('getAvailableAgents', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns agent names from project spec', async () => { + mockReadProjectSpec.mockResolvedValue({ + agents: [{ name: 'agent-a' }, { name: 'agent-b' }], + }); + + const result = await getAvailableAgents(); + + expect(result).toEqual(['agent-a', 'agent-b']); + }); + + it('returns empty array on error', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('no project')); + + const result = await getAvailableAgents(); + + expect(result).toEqual([]); + }); +}); + +describe('getExistingToolNames', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns empty array when mcp config does not exist', async () => { + mockConfigExists.mockReturnValue(false); + + const result = await getExistingToolNames(); + + expect(result).toEqual([]); + }); + + it('returns tool names from runtime tools and gateway targets', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + mcpRuntimeTools: [{ name: 'rt-tool-1' }], + agentCoreGateways: [ + { + name: 'gw-1', + targets: [ + { + name: 'target-1', + toolDefinitions: [{ name: 'gw-tool-1' }, { name: 'gw-tool-2' }], + }, + ], + }, + ], + }); + + const result = await getExistingToolNames(); + + expect(result).toEqual(['rt-tool-1', 'gw-tool-1', 'gw-tool-2']); + }); + + it('returns empty array when no runtime tools defined', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'gw', targets: [] }], + }); + + const result = await getExistingToolNames(); + + expect(result).toEqual([]); + }); + + it('returns empty array on error', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockRejectedValue(new Error('corrupt')); + + const result = await getExistingToolNames(); + + expect(result).toEqual([]); + }); +}); + +describe('createGatewayFromWizard', () => { + afterEach(() => vi.clearAllMocks()); + + it('creates gateway when mcp config does not exist', async () => { + mockConfigExists.mockReturnValue(false); + mockWriteMcpSpec.mockResolvedValue(undefined); + + const result = await createGatewayFromWizard({ + name: 'new-gw', + description: 'A gateway', + authorizerType: 'NONE', + } as Parameters[0]); + + expect(result.name).toBe('new-gw'); + expect(mockWriteMcpSpec).toHaveBeenCalledWith( + expect.objectContaining({ + agentCoreGateways: [ + expect.objectContaining({ + name: 'new-gw', + description: 'A gateway', + authorizerType: 'NONE', + }), + ], + }) + ); + }); + + it('appends to existing gateways', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'existing-gw', targets: [] }], + }); + mockWriteMcpSpec.mockResolvedValue(undefined); + + const result = await createGatewayFromWizard({ + name: 'new-gw', + description: 'Another', + authorizerType: 'NONE', + } as Parameters[0]); + + expect(result.name).toBe('new-gw'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(mockWriteMcpSpec.mock.calls[0]![0].agentCoreGateways).toHaveLength(2); + }); + + it('throws when gateway name already exists', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'dup-gw', targets: [] }], + }); + + await expect( + createGatewayFromWizard({ + name: 'dup-gw', + description: 'Duplicate', + authorizerType: 'NONE', + } as Parameters[0]) + ).rejects.toThrow('Gateway "dup-gw" already exists'); + }); + + it('includes JWT authorizer config when CUSTOM_JWT', async () => { + mockConfigExists.mockReturnValue(false); + mockWriteMcpSpec.mockResolvedValue(undefined); + + await createGatewayFromWizard({ + name: 'jwt-gw', + description: 'JWT gateway', + authorizerType: 'CUSTOM_JWT', + jwtConfig: { + discoveryUrl: 'https://example.com/.well-known/openid', + allowedAudience: ['aud1'], + allowedClients: ['client1'], + }, + } as Parameters[0]); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(mockWriteMcpSpec.mock.calls[0]![0].agentCoreGateways[0].authorizerConfiguration).toEqual({ + customJwtAuthorizer: { + discoveryUrl: 'https://example.com/.well-known/openid', + allowedAudience: ['aud1'], + allowedClients: ['client1'], + }, + }); + }); +}); diff --git a/src/cli/schema/__tests__/document.test.ts b/src/cli/schema/__tests__/document.test.ts index c6e0eee0..a2006091 100644 --- a/src/cli/schema/__tests__/document.test.ts +++ b/src/cli/schema/__tests__/document.test.ts @@ -95,4 +95,15 @@ describe('saveSchemaDocument', () => { expect(result.ok).toBe(false); expect(result.error).toBeDefined(); }); + + it('returns error when write fails', async () => { + // Try to write to a path under a non-existent directory + const filePath = join(dir, 'no-such-dir', 'nested', 'file.json'); + const content = JSON.stringify({ name: 'test', value: 1 }); + + const result = await saveSchemaDocument(filePath, content, TestSchema); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); });