takt/src/__tests__/copilot-provider.test.ts
Tomohisa Takaoka 17232f9940
feat: add GitHub Copilot CLI as a new provider (#425)
* feat: add GitHub Copilot CLI as a new provider

Add support for GitHub Copilot CLI (@github/copilot) as a takt provider,
enabling the 'copilot' command to be used for AI-driven task execution.

New files:
- src/infra/copilot/client.ts: CLI client with streaming, session ID
  extraction via --share, and permission mode mapping
- src/infra/copilot/types.ts: CopilotCallOptions type definitions
- src/infra/copilot/index.ts: barrel exports
- src/infra/providers/copilot.ts: CopilotProvider implementing Provider
- src/__tests__/copilot-client.test.ts: 20 unit tests for client
- src/__tests__/copilot-provider.test.ts: 8 unit tests for provider

Key features:
- Spawns 'copilot -p' in non-interactive mode with --silent --no-color
- Permission modes: full (--yolo), edit (--allow-all-tools --no-ask-user),
  readonly (no permission flags)
- Session ID extraction from --share transcript files
- Real-time stdout streaming via onStream callbacks
- Configurable via COPILOT_CLI_PATH and COPILOT_GITHUB_TOKEN env vars

* fix: remove unused COPILOT_DEFAULT_MAX_AUTOPILOT_CONTINUES constant

* fix: address review feedback for copilot provider

- Remove excess maxAutopilotContinues property from test (#1 High)
- Extract cleanupTmpDir() helper to eliminate DRY violation (#2 Medium)
- Deduplicate chunk string conversion in stdout handler (#3 Medium)
- Remove 5 what/how comments that restate code (#4 Medium)
- Log readFile failure instead of silently swallowing (#5 Medium)
- Add credential scrubbing (ghp_/ghs_/gho_/github_pat_) for stderr (#6 Medium)
- Add buffer overflow tests for stdout and stderr (#7 Medium)
- Add pre-aborted AbortSignal test (#8 Low)
- Add mkdtemp failure fallback test (#9 Low)
- Add rm cleanup verification to fallback test (#10 Low)
- Log mkdtemp failure with debug level (#11 Persist)
- Add createLogger('copilot-client') for structured logging
2026-02-28 20:28:56 +09:00

185 lines
5.2 KiB
TypeScript

/**
* Tests for Copilot provider implementation
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
mockCallCopilot,
mockCallCopilotCustom,
} = vi.hoisted(() => ({
mockCallCopilot: vi.fn(),
mockCallCopilotCustom: vi.fn(),
}));
const {
mockResolveCopilotGithubToken,
mockResolveCopilotCliPath,
mockLoadProjectConfig,
} = vi.hoisted(() => ({
mockResolveCopilotGithubToken: vi.fn(() => undefined),
mockResolveCopilotCliPath: vi.fn(() => undefined),
mockLoadProjectConfig: vi.fn(() => ({})),
}));
vi.mock('../infra/copilot/index.js', () => ({
callCopilot: mockCallCopilot,
callCopilotCustom: mockCallCopilotCustom,
}));
vi.mock('../infra/config/index.js', () => ({
resolveCopilotGithubToken: mockResolveCopilotGithubToken,
resolveCopilotCliPath: mockResolveCopilotCliPath,
loadProjectConfig: mockLoadProjectConfig,
}));
import { CopilotProvider } from '../infra/providers/copilot.js';
import { ProviderRegistry } from '../infra/providers/index.js';
function doneResponse(persona: string) {
return {
persona,
status: 'done' as const,
content: 'ok',
timestamp: new Date(),
};
}
describe('CopilotProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveCopilotGithubToken.mockReturnValue(undefined);
mockResolveCopilotCliPath.mockReturnValue(undefined);
mockLoadProjectConfig.mockReturnValue({});
});
it('should throw when claudeAgent is specified', () => {
const provider = new CopilotProvider();
expect(() => provider.setup({
name: 'test',
claudeAgent: 'some-agent',
})).toThrow('Claude Code agent calls are not supported by the Copilot provider');
});
it('should throw when claudeSkill is specified', () => {
const provider = new CopilotProvider();
expect(() => provider.setup({
name: 'test',
claudeSkill: 'some-skill',
})).toThrow('Claude Code skill calls are not supported by the Copilot provider');
});
it('should pass model/session/permission and resolved token to callCopilot', async () => {
mockResolveCopilotGithubToken.mockReturnValue('resolved-token');
mockCallCopilot.mockResolvedValue(doneResponse('coder'));
const provider = new CopilotProvider();
const agent = provider.setup({ name: 'coder' });
await agent.call('implement', {
cwd: '/tmp/work',
model: 'claude-sonnet-4.6',
sessionId: 'sess-1',
permissionMode: 'full',
});
expect(mockCallCopilot).toHaveBeenCalledWith(
'coder',
'implement',
expect.objectContaining({
cwd: '/tmp/work',
model: 'claude-sonnet-4.6',
sessionId: 'sess-1',
permissionMode: 'full',
copilotGithubToken: 'resolved-token',
}),
);
});
it('should prefer explicit copilotGithubToken over resolver', async () => {
mockResolveCopilotGithubToken.mockReturnValue('resolved-token');
mockCallCopilot.mockResolvedValue(doneResponse('coder'));
const provider = new CopilotProvider();
const agent = provider.setup({ name: 'coder' });
await agent.call('implement', {
cwd: '/tmp/work',
copilotGithubToken: 'explicit-token',
});
expect(mockCallCopilot).toHaveBeenCalledWith(
'coder',
'implement',
expect.objectContaining({
copilotGithubToken: 'explicit-token',
}),
);
});
it('should delegate to callCopilotCustom when systemPrompt is specified', async () => {
mockCallCopilotCustom.mockResolvedValue(doneResponse('reviewer'));
const provider = new CopilotProvider();
const agent = provider.setup({
name: 'reviewer',
systemPrompt: 'You are a strict reviewer.',
});
await agent.call('review this', {
cwd: '/tmp/work',
});
expect(mockCallCopilotCustom).toHaveBeenCalledWith(
'reviewer',
'review this',
'You are a strict reviewer.',
expect.objectContaining({ cwd: '/tmp/work' }),
);
});
it('should pass resolved copilotCliPath to callCopilot', async () => {
mockResolveCopilotCliPath.mockReturnValue('/custom/bin/copilot');
mockCallCopilot.mockResolvedValue(doneResponse('coder'));
const provider = new CopilotProvider();
const agent = provider.setup({ name: 'coder' });
await agent.call('implement', { cwd: '/tmp/work' });
expect(mockCallCopilot).toHaveBeenCalledWith(
'coder',
'implement',
expect.objectContaining({
copilotCliPath: '/custom/bin/copilot',
}),
);
});
it('should pass undefined copilotCliPath when resolver returns undefined', async () => {
mockResolveCopilotCliPath.mockReturnValue(undefined);
mockCallCopilot.mockResolvedValue(doneResponse('coder'));
const provider = new CopilotProvider();
const agent = provider.setup({ name: 'coder' });
await agent.call('implement', { cwd: '/tmp/work' });
const opts = mockCallCopilot.mock.calls[0]?.[2];
expect(opts.copilotCliPath).toBeUndefined();
});
});
describe('ProviderRegistry with Copilot', () => {
it('should return Copilot provider from registry', () => {
ProviderRegistry.resetInstance();
const registry = ProviderRegistry.getInstance();
const provider = registry.get('copilot');
expect(provider).toBeDefined();
expect(provider).toBeInstanceOf(CopilotProvider);
});
});