Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 17 additions & 16 deletions agents-cli/src/__tests__/commands/init.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as p from '@clack/prompts';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initCommand } from '../../commands/init';
import { LOCAL_REMOTE } from '../../utils/profiles';

// Mock @clack/prompts
vi.mock('@clack/prompts');
Expand Down Expand Up @@ -70,7 +71,7 @@ describe('Init Command', () => {
vi.mocked(p.text)
.mockResolvedValueOnce('./inkeep.config.ts') // confirmedPath
.mockResolvedValueOnce('test-tenant-123') // tenantId
.mockResolvedValueOnce('http://localhost:3002'); // apiUrl
.mockResolvedValueOnce(LOCAL_REMOTE.api); // apiUrl
vi.mocked(p.isCancel).mockReturnValue(false);

await initCommand({ local: true });
Expand All @@ -84,7 +85,7 @@ describe('Init Command', () => {
// Verify all required parts are present
expect(writtenContent).toContain("tenantId: 'test-tenant-123'");
expect(writtenContent).toContain('agentsApi:');
expect(writtenContent).toContain("url: 'http://localhost:3002'");
expect(writtenContent).toContain(`url: '${LOCAL_REMOTE.api}'`);

// Verify it's using nested format (not flat)
expect(writtenContent).not.toContain('agentsApiUrl:');
Expand Down Expand Up @@ -157,7 +158,7 @@ describe('Init Command', () => {
return 'valid-tenant';
}
if (options.message.includes('Agents API')) {
return 'http://localhost:3002';
return LOCAL_REMOTE.api;
}
return './inkeep.config.ts';
});
Expand All @@ -184,7 +185,7 @@ describe('Init Command', () => {
vi.mocked(p.text).mockImplementation(async (options: any) => {
if (options.message.includes('Agents API URL')) {
validateFn = options.validate;
return 'http://localhost:3002';
return LOCAL_REMOTE.api;
}
if (options.message.includes('tenant')) {
return 'test-tenant';
Expand All @@ -198,7 +199,7 @@ describe('Init Command', () => {
// Test validation function
if (validateFn) {
expect(validateFn('not-a-url')).toBe('Please enter a valid URL');
expect(validateFn('http://localhost:3002')).toBe(undefined);
expect(validateFn(LOCAL_REMOTE.api)).toBe(undefined);
expect(validateFn('https://agents-api.example.com')).toBe(undefined);
}
});
Expand All @@ -211,7 +212,7 @@ describe('Init Command', () => {
// Mock clack prompts
vi.mocked(p.text)
.mockResolvedValueOnce('test-tenant') // tenantId
.mockResolvedValueOnce('http://localhost:3002'); // apiUrl
.mockResolvedValueOnce(LOCAL_REMOTE.api); // apiUrl
vi.mocked(p.isCancel).mockReturnValue(false);

await initCommand({ path: './custom/path', local: true });
Expand All @@ -232,7 +233,7 @@ describe('Init Command', () => {
vi.mocked(p.text)
.mockResolvedValueOnce('./inkeep.config.ts') // confirmedPath
.mockResolvedValueOnce('test-tenant') // tenantId
.mockResolvedValueOnce('http://localhost:3002'); // apiUrl
.mockResolvedValueOnce(LOCAL_REMOTE.api); // apiUrl
vi.mocked(p.isCancel).mockReturnValue(false);

vi.mocked(writeFileSync).mockImplementation(() => {
Expand Down Expand Up @@ -262,7 +263,7 @@ describe('Init Command', () => {
vi.mocked(p.text)
.mockResolvedValueOnce('./inkeep.config.ts') // confirmedPath
.mockResolvedValueOnce('test-tenant-123') // tenantId
.mockResolvedValueOnce('http://localhost:3002'); // apiUrl
.mockResolvedValueOnce(LOCAL_REMOTE.api); // apiUrl
vi.mocked(p.isCancel).mockReturnValue(false);

await initCommand({ local: true });
Expand All @@ -272,8 +273,8 @@ describe('Init Command', () => {
profiles: {
local: {
remote: {
api: 'http://localhost:3002',
manageUi: 'http://localhost:3001',
api: LOCAL_REMOTE.api,
manageUi: LOCAL_REMOTE.manageUi,
},
credential: 'none',
environment: 'development',
Expand Down Expand Up @@ -302,15 +303,15 @@ describe('Init Command', () => {
vi.mocked(p.text)
.mockResolvedValueOnce('./inkeep.config.ts') // confirmedPath
.mockResolvedValueOnce('test-tenant') // tenantId
.mockResolvedValueOnce('http://localhost:3002'); // apiUrl
.mockResolvedValueOnce(LOCAL_REMOTE.api); // apiUrl
vi.mocked(p.isCancel).mockReturnValue(false);

await initCommand({ local: true });

expect(mockProfileManager.addProfile).toHaveBeenCalledWith('local', {
remote: {
api: 'http://localhost:3002',
manageUi: 'http://localhost:3001',
api: LOCAL_REMOTE.api,
manageUi: LOCAL_REMOTE.manageUi,
},
credential: 'none',
environment: 'development',
Expand All @@ -333,8 +334,8 @@ describe('Init Command', () => {
cloud: { remote: 'cloud', credential: 'inkeep-cloud', environment: 'production' },
local: {
remote: {
api: 'http://localhost:3002',
manageUi: 'http://localhost:3001',
api: LOCAL_REMOTE.api,
manageUi: LOCAL_REMOTE.manageUi,
},
credential: 'none',
environment: 'development',
Expand All @@ -346,7 +347,7 @@ describe('Init Command', () => {
vi.mocked(p.text)
.mockResolvedValueOnce('./inkeep.config.ts') // confirmedPath
.mockResolvedValueOnce('test-tenant') // tenantId
.mockResolvedValueOnce('http://localhost:3002'); // apiUrl
.mockResolvedValueOnce(LOCAL_REMOTE.api); // apiUrl
vi.mocked(p.isCancel).mockReturnValue(false);

await initCommand({ local: true });
Expand Down
182 changes: 182 additions & 0 deletions agents-cli/src/__tests__/commands/profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import * as p from '@clack/prompts';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockProfileManager = vi.hoisted(() => ({
getProfile: vi.fn().mockReturnValue(undefined),
addProfile: vi.fn(),
setActiveProfile: vi.fn(),
checkCredentialExists: vi.fn().mockResolvedValue(false),
}));

vi.mock('@clack/prompts');

vi.mock('../../utils/profiles', async () => {
const actual = await vi.importActual('../../utils/profiles');
return {
...actual,
ProfileManager: vi.fn(() => mockProfileManager),
};
});

import { profileAddCommand } from '../../commands/profile';
import { LOCAL_REMOTE } from '../../utils/profiles';

describe('profileAddCommand', () => {
Comment on lines +22 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: Missing test for "profile already exists" error path

Issue: Production code at profile.ts lines 67-71 checks if a profile name is already taken and exits with error code 1. This critical validation has no test coverage.

Why: A regression that removes or breaks this check could allow users to accidentally overwrite existing profiles, losing their configuration.

Fix: Add test:

describe('profile name validation', () => {
  it('should reject profile creation when name already exists', async () => {
    mockProfileManager.getProfile.mockReturnValueOnce({
      remote: 'cloud',
      credential: 'existing',
      environment: 'production',
    });
    
    await expect(profileAddCommand('existing-profile')).rejects.toThrow('process.exit called');
    expect(mockProfileManager.addProfile).not.toHaveBeenCalled();
  });
});

Refs:

beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
vi.clearAllMocks();
mockProfileManager.checkCredentialExists.mockResolvedValue(false);
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('Local remote type', () => {
it('should create profile with LOCAL_REMOTE URLs without URL prompts', async () => {
vi.mocked(p.select).mockResolvedValueOnce('local');
Comment on lines +38 to +40
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: Missing test coverage for user cancellation flows

Issue: The production code in profile.ts has 6 separate p.isCancel() checks (lines 58-61, 83-86, 109-112, 128-131, 151-154, 173-176) that exit when users cancel input prompts. None of these cancellation paths are tested.

Why: If a regression causes p.isCancel() to be mishandled (e.g., continuing execution after cancel), users could experience partial profile creation or confusing errors instead of graceful exit.

Fix: Add at least one cancellation test per prompt type. Example:

it('should exit gracefully when user cancels remote type selection', async () => {
  vi.mocked(p.select).mockResolvedValueOnce(Symbol('cancel'));
  vi.mocked(p.isCancel).mockReturnValueOnce(true);
  
  await expect(profileAddCommand('test')).rejects.toThrow('process.exit called');
  expect(p.cancel).toHaveBeenCalledWith('Profile creation cancelled');
  expect(mockProfileManager.addProfile).not.toHaveBeenCalled();
});

Refs:

vi.mocked(p.text).mockResolvedValueOnce('development'); // environment
vi.mocked(p.confirm).mockResolvedValueOnce(false); // switch profile

await profileAddCommand('test-local');

expect(mockProfileManager.addProfile).toHaveBeenCalledWith('test-local', {
remote: { api: LOCAL_REMOTE.api, manageUi: LOCAL_REMOTE.manageUi },
credential: 'none',
environment: 'development',
});
});

it('should not prompt for credential when Local is selected', async () => {
vi.mocked(p.select).mockResolvedValueOnce('local');
vi.mocked(p.text).mockResolvedValueOnce('development'); // environment
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('test-local');

// p.text called once (environment only), not for API URL, Manage UI URL, or credential
expect(p.text).toHaveBeenCalledTimes(1);
expect(p.text).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Environment name:' })
);
});

it('should skip credential keychain warning for local profiles', async () => {
vi.mocked(p.select).mockResolvedValueOnce('local');
vi.mocked(p.text).mockResolvedValueOnce('development');
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('test-local');

expect(mockProfileManager.checkCredentialExists).not.toHaveBeenCalled();
});

it('should default environment to development for local', async () => {
vi.mocked(p.select).mockResolvedValueOnce('local');
vi.mocked(p.text).mockResolvedValueOnce('development');
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('my-local');

expect(p.text).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Environment name:',
initialValue: 'development',
})
);
});
});

describe('Cloud remote type', () => {
it('should create profile with cloud remote string', async () => {
vi.mocked(p.select).mockResolvedValueOnce('cloud');
vi.mocked(p.text)
.mockResolvedValueOnce('production') // environment
.mockResolvedValueOnce('inkeep-my-cloud'); // credential
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('my-cloud');

expect(mockProfileManager.addProfile).toHaveBeenCalledWith('my-cloud', {
remote: 'cloud',
credential: 'inkeep-my-cloud',
environment: 'production',
});
});

it('should default environment to production for cloud', async () => {
vi.mocked(p.select).mockResolvedValueOnce('cloud');
vi.mocked(p.text).mockResolvedValueOnce('production').mockResolvedValueOnce('inkeep-test');
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('test-cloud');

expect(p.text).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Environment name:',
initialValue: 'production',
})
);
});
});

describe('Custom remote type', () => {
it('should prompt for URLs with no initialValue', async () => {
vi.mocked(p.select).mockResolvedValueOnce('custom');
vi.mocked(p.text)
.mockResolvedValueOnce('https://api.staging.example.com') // api URL
.mockResolvedValueOnce('https://manage.staging.example.com') // manage UI URL
.mockResolvedValueOnce('staging') // environment
.mockResolvedValueOnce('inkeep-staging'); // credential
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('staging');

expect(mockProfileManager.addProfile).toHaveBeenCalledWith('staging', {
remote: {
api: 'https://api.staging.example.com',
manageUi: 'https://manage.staging.example.com',
},
credential: 'inkeep-staging',
environment: 'staging',
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Missing test for URL validation in Custom path

Issue: The production code at profile.ts lines 98-106 and 117-125 validates Custom URLs — rejecting empty input ('URL is required') and invalid formats ('Invalid URL format'). These validation branches have no test coverage.

Why: If URL validation regresses, users could create profiles with invalid/empty URLs that would fail at runtime during login or push operations.

Fix: Add validation tests to the Custom describe block:

it('should reject empty API URL with validation error', async () => {
  vi.mocked(p.select).mockResolvedValueOnce('custom');
  let apiValidate: ((v: string) => string | undefined) | undefined;
  vi.mocked(p.text).mockImplementation(async (opts: any) => {
    if (opts.message === 'Agents API URL:') {
      apiValidate = opts.validate;
      return 'https://api.example.com';
    }
    if (opts.message === 'Manage UI URL:') return 'https://manage.example.com';
    if (opts.message === 'Environment name:') return 'production';
    if (opts.message === 'Credential reference:') return 'inkeep-test';
    return '';
  });
  vi.mocked(p.confirm).mockResolvedValueOnce(false);

  await profileAddCommand('test');
  
  expect(apiValidate!('')).toBe('URL is required');
  expect(apiValidate!('   ')).toBe('URL is required');
  expect(apiValidate!('not-a-url')).toBe('Invalid URL format');
});

Refs:


it('should default environment to production for custom', async () => {
vi.mocked(p.select).mockResolvedValueOnce('custom');
vi.mocked(p.text)
.mockResolvedValueOnce('https://api.example.com')
.mockResolvedValueOnce('https://manage.example.com')
.mockResolvedValueOnce('production')
.mockResolvedValueOnce('inkeep-prod');
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('prod');

expect(p.text).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Environment name:',
initialValue: 'production',
})
);
});

it('should check keychain for credential and warn if missing', async () => {
mockProfileManager.checkCredentialExists.mockResolvedValueOnce(false);
vi.mocked(p.select).mockResolvedValueOnce('custom');
vi.mocked(p.text)
.mockResolvedValueOnce('https://api.example.com')
.mockResolvedValueOnce('https://manage.example.com')
.mockResolvedValueOnce('production')
.mockResolvedValueOnce('inkeep-prod');
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('prod');

expect(mockProfileManager.checkCredentialExists).toHaveBeenCalledWith('inkeep-prod');
});
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Missing test for profile switch confirmation

Issue: The production code at profile.ts lines 204-212 prompts the user to switch to the newly created profile. All 9 tests mock p.confirm to return false, so the setActiveProfile call after successful confirmation is never exercised.

Why: If the setActiveProfile call were removed or moved incorrectly, users who confirm switching would not actually have their active profile changed.

Fix: Add a test that confirms the switch:

it('should switch to new profile when user confirms', async () => {
  vi.mocked(p.select).mockResolvedValueOnce('local');
  vi.mocked(p.text).mockResolvedValueOnce('development');
  vi.mocked(p.confirm).mockResolvedValueOnce(true); // User wants to switch

  await profileAddCommand('new-local');

  expect(mockProfileManager.setActiveProfile).toHaveBeenCalledWith('new-local');
});

Refs:

3 changes: 2 additions & 1 deletion agents-cli/src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import chalk from 'chalk';
import { LOCAL_REMOTE } from '../utils/profiles';

export interface ConfigOptions {
config?: string;
Expand Down Expand Up @@ -87,7 +88,7 @@ export async function configSetCommand(

export default defineConfig({
tenantId: '${key === 'tenantId' ? value : ''}',
apiUrl: '${key === 'apiUrl' ? value : 'http://localhost:3002'}',
apiUrl: '${key === 'apiUrl' ? value : LOCAL_REMOTE.api}',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Config format inconsistency with init.ts

Issue: This template generates configs with flat apiUrl format, while init.ts generates the nested agentsApi: { url: ... } format (lines 441-443). Users running different CLI commands will see different config formats.

Why: A user who runs inkeep init --local then later uses inkeep config set will encounter two different config formats in their project, creating confusion about which is canonical.

Fix: Consider updating to use the nested format for consistency:

export default defineConfig({
    tenantId: '${key === 'tenantId' ? value : ''}',
    agentsApi: {
      url: '${key === 'apiUrl' ? value : LOCAL_REMOTE.api}',
    },
});

Refs:

});
`;

Expand Down
9 changes: 5 additions & 4 deletions agents-cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import chalk from 'chalk';
import { checkKeychainAvailability, loadCredentials } from '../utils/credentials';
import {
DEFAULT_PROFILES_CONFIG,
LOCAL_REMOTE,
type Profile,
ProfileManager,
type ProfilesConfig,
Expand Down Expand Up @@ -387,7 +388,7 @@ async function localInitCommand(options?: InitOptions): Promise<void> {

if (options?.interactive === false) {
tenantId = 'default';
apiUrl = 'http://localhost:3002';
apiUrl = LOCAL_REMOTE.api;
} else {
const tenantIdInput = await p.text({
message: 'Enter your tenant ID:',
Expand Down Expand Up @@ -420,8 +421,8 @@ async function localInitCommand(options?: InitOptions): Promise<void> {

const apiUrlInput = await p.text({
message: 'Enter the Agents API URL:',
placeholder: 'http://localhost:3002',
initialValue: 'http://localhost:3002',
placeholder: LOCAL_REMOTE.api,
initialValue: LOCAL_REMOTE.api,
validate: validateUrl,
});

Expand Down Expand Up @@ -453,7 +454,7 @@ export default defineConfig({
const localProfile: Profile = {
remote: {
api: apiUrl,
manageUi: 'http://localhost:3001',
manageUi: LOCAL_REMOTE.manageUi,
},
credential: 'none',
environment: 'development',
Expand Down
Loading
Loading