398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
/**
|
|
* @fileoverview Unit tests for CopilotAdapter.
|
|
*
|
|
* All network calls (GitHub token exchange, Copilot chat API) are mocked so
|
|
* no real GitHub account or Copilot subscription is required.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { CopilotAdapter } from '../src/llm/copilot.js'
|
|
import type { LLMMessage } from '../src/types.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const USER_CODE = 'ABCD-1234'
|
|
const VERIFICATION_URI = 'https://github.com/login/device'
|
|
const OAUTH_TOKEN = 'ghu_testOAuthToken'
|
|
const COPILOT_TOKEN = 'tid=test;exp=9999999999'
|
|
const COPILOT_EXPIRES_AT = Math.floor(Date.now() / 1000) + 3600
|
|
|
|
const userMsg = (text: string): LLMMessage => ({
|
|
role: 'user',
|
|
content: [{ type: 'text', text }],
|
|
})
|
|
|
|
function makeSSEStream(...chunks: string[]): ReadableStream<Uint8Array> {
|
|
const encoder = new TextEncoder()
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
for (const chunk of chunks) {
|
|
controller.enqueue(encoder.encode(chunk))
|
|
}
|
|
controller.close()
|
|
},
|
|
})
|
|
}
|
|
|
|
function copilotTokenResponse() {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ token: COPILOT_TOKEN, expires_at: COPILOT_EXPIRES_AT }),
|
|
text: () => Promise.resolve(''),
|
|
}
|
|
}
|
|
|
|
function completionResponse(content: string, model = 'gpt-4o') {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: () =>
|
|
Promise.resolve({
|
|
id: 'cmpl-1',
|
|
model,
|
|
choices: [{ message: { content, tool_calls: undefined }, finish_reason: 'stop' }],
|
|
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
|
}),
|
|
text: () => Promise.resolve(''),
|
|
body: null,
|
|
}
|
|
}
|
|
|
|
/** Build a mock fetch that sequences through multiple responses. */
|
|
function buildFetchSequence(responses: object[]): typeof fetch {
|
|
let idx = 0
|
|
return vi.fn().mockImplementation(() => {
|
|
const res = responses[idx] ?? responses[responses.length - 1]
|
|
idx++
|
|
return Promise.resolve(res)
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('CopilotAdapter', () => {
|
|
let originalFetch: typeof globalThis.fetch
|
|
|
|
beforeEach(() => {
|
|
originalFetch = globalThis.fetch
|
|
})
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch
|
|
vi.restoreAllMocks()
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Constructor / token resolution
|
|
// -------------------------------------------------------------------------
|
|
|
|
describe('constructor', () => {
|
|
it('accepts a token directly', () => {
|
|
const adapter = new CopilotAdapter(OAUTH_TOKEN)
|
|
expect(adapter.name).toBe('copilot')
|
|
})
|
|
|
|
it('reads GITHUB_COPILOT_TOKEN env var', () => {
|
|
vi.stubEnv('GITHUB_COPILOT_TOKEN', OAUTH_TOKEN)
|
|
vi.stubEnv('GITHUB_TOKEN', '')
|
|
expect(() => new CopilotAdapter()).not.toThrow()
|
|
})
|
|
|
|
it('reads GITHUB_TOKEN env var as fallback', () => {
|
|
vi.stubEnv('GITHUB_COPILOT_TOKEN', '')
|
|
vi.stubEnv('GITHUB_TOKEN', OAUTH_TOKEN)
|
|
expect(() => new CopilotAdapter()).not.toThrow()
|
|
})
|
|
|
|
it('throws when no token is available', () => {
|
|
vi.stubEnv('GITHUB_COPILOT_TOKEN', '')
|
|
vi.stubEnv('GITHUB_TOKEN', '')
|
|
// hosts.json is unlikely to exist in CI; if it does this test may pass by accident
|
|
// so we just verify the error message shape when it throws
|
|
try {
|
|
new CopilotAdapter()
|
|
} catch (e) {
|
|
expect((e as Error).message).toContain('No GitHub token found')
|
|
}
|
|
})
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// chat() — token exchange + text response
|
|
// -------------------------------------------------------------------------
|
|
|
|
describe('chat()', () => {
|
|
it('exchanges OAuth token for Copilot token then calls the chat API', async () => {
|
|
globalThis.fetch = buildFetchSequence([
|
|
copilotTokenResponse(),
|
|
completionResponse('The sky is blue.'),
|
|
])
|
|
|
|
const adapter = new CopilotAdapter(OAUTH_TOKEN)
|
|
const result = await adapter.chat([userMsg('Why is the sky blue?')], { model: 'gpt-4o' })
|
|
|
|
expect(result.content[0]).toMatchObject({ type: 'text', text: 'The sky is blue.' })
|
|
expect(result.model).toBe('gpt-4o')
|
|
expect(result.stop_reason).toBe('end_turn')
|
|
expect(result.usage).toEqual({ input_tokens: 10, output_tokens: 5 })
|
|
})
|
|
|
|
it('caches the Copilot token across multiple calls', async () => {
|
|
const fetcher = buildFetchSequence([
|
|
copilotTokenResponse(), // fetched once
|
|
completionResponse('first reply'),
|
|
completionResponse('second reply'), // token not re-fetched
|
|
])
|
|
globalThis.fetch = fetcher
|
|
|
|
const adapter = new CopilotAdapter(OAUTH_TOKEN)
|
|
await adapter.chat([userMsg('q1')], { model: 'gpt-4o' })
|
|
await adapter.chat([userMsg('q2')], { model: 'gpt-4o' })
|
|
|
|
// Only 3 fetch calls total: 1 token + 2 chat
|
|
expect((fetcher as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(3)
|
|
})
|
|
|
|
it('includes tools in the request body', async () => {
|
|
const fetcher = buildFetchSequence([
|
|
copilotTokenResponse(),
|
|
completionResponse('ok'),
|
|
])
|
|
globalThis.fetch = fetcher
|
|
|
|
const adapter = new CopilotAdapter(OAUTH_TOKEN)
|
|
await adapter.chat([userMsg('hi')], {
|
|
model: 'gpt-4o',
|
|
tools: [
|
|
{ name: 'search', description: 'Search', inputSchema: { type: 'object', properties: {} } },
|
|
],
|
|
})
|
|
|
|
const chatCall = (fetcher as ReturnType<typeof vi.fn>).mock.calls[1]
|
|
const sent = JSON.parse(chatCall[1].body as string)
|
|
expect(sent.tools).toHaveLength(1)
|
|
expect(sent.tools[0].function.name).toBe('search')
|
|
})
|
|
|
|
it('includes Authorization and Editor-Version headers', async () => {
|
|
const fetcher = buildFetchSequence([
|
|
copilotTokenResponse(),
|
|
completionResponse('ok'),
|
|
])
|
|
globalThis.fetch = fetcher
|
|
|
|
await new CopilotAdapter(OAUTH_TOKEN).chat([userMsg('hi')], { model: 'gpt-4o' })
|
|
|
|
const chatCall = (fetcher as ReturnType<typeof vi.fn>).mock.calls[1]
|
|
const headers: Record<string, string> = chatCall[1].headers as Record<string, string>
|
|
expect(headers['Authorization']).toBe(`Bearer ${COPILOT_TOKEN}`)
|
|
expect(headers['Editor-Version']).toBeDefined()
|
|
})
|
|
|
|
it('throws on non-2xx responses', async () => {
|
|
globalThis.fetch = buildFetchSequence([
|
|
copilotTokenResponse(),
|
|
{
|
|
ok: false,
|
|
status: 403,
|
|
statusText: 'Forbidden',
|
|
text: () => Promise.resolve('no access'),
|
|
body: null,
|
|
},
|
|
])
|
|
|
|
await expect(
|
|
new CopilotAdapter(OAUTH_TOKEN).chat([userMsg('hi')], { model: 'gpt-4o' }),
|
|
).rejects.toThrow('Copilot API error 403')
|
|
})
|
|
|
|
it('parses tool_calls in the response', async () => {
|
|
globalThis.fetch = buildFetchSequence([
|
|
copilotTokenResponse(),
|
|
{
|
|
ok: true,
|
|
status: 200,
|
|
json: () =>
|
|
Promise.resolve({
|
|
id: 'cmpl-2',
|
|
model: 'gpt-4o',
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: null,
|
|
tool_calls: [
|
|
{
|
|
id: 'call_1',
|
|
function: { name: 'get_weather', arguments: '{"city":"Paris"}' },
|
|
},
|
|
],
|
|
},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 20, completion_tokens: 10 },
|
|
}),
|
|
text: () => Promise.resolve(''),
|
|
body: null,
|
|
},
|
|
])
|
|
|
|
const result = await new CopilotAdapter(OAUTH_TOKEN).chat(
|
|
[userMsg('What is the weather in Paris?')],
|
|
{ model: 'gpt-4o' },
|
|
)
|
|
|
|
const toolBlock = result.content.find((b) => b.type === 'tool_use')
|
|
expect(toolBlock).toMatchObject({
|
|
type: 'tool_use',
|
|
id: 'call_1',
|
|
name: 'get_weather',
|
|
input: { city: 'Paris' },
|
|
})
|
|
expect(result.stop_reason).toBe('tool_use')
|
|
})
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// stream()
|
|
// -------------------------------------------------------------------------
|
|
|
|
describe('stream()', () => {
|
|
it('yields incremental text events and a done event', async () => {
|
|
const sseData =
|
|
'data: ' + JSON.stringify({
|
|
id: 'cmpl-1',
|
|
model: 'gpt-4o',
|
|
choices: [{ delta: { content: 'Hello' }, finish_reason: null }],
|
|
}) + '\n\n' +
|
|
'data: ' + JSON.stringify({
|
|
id: 'cmpl-1',
|
|
model: 'gpt-4o',
|
|
choices: [{ delta: { content: ' world' }, finish_reason: null }],
|
|
}) + '\n\n' +
|
|
'data: ' + JSON.stringify({
|
|
id: 'cmpl-1',
|
|
model: 'gpt-4o',
|
|
choices: [{ delta: {}, finish_reason: 'stop' }],
|
|
usage: { prompt_tokens: 5, completion_tokens: 2 },
|
|
}) + '\n\n' +
|
|
'data: [DONE]\n\n'
|
|
|
|
globalThis.fetch = buildFetchSequence([
|
|
copilotTokenResponse(),
|
|
{ ok: true, status: 200, body: makeSSEStream(sseData) },
|
|
])
|
|
|
|
const events = []
|
|
for await (const ev of new CopilotAdapter(OAUTH_TOKEN).stream([userMsg('hi')], { model: 'gpt-4o' })) {
|
|
events.push(ev)
|
|
}
|
|
|
|
const textEvents = events.filter((e) => e.type === 'text')
|
|
expect(textEvents).toEqual([
|
|
{ type: 'text', data: 'Hello' },
|
|
{ type: 'text', data: ' world' },
|
|
])
|
|
|
|
const doneEvent = events.find((e) => e.type === 'done')
|
|
expect(doneEvent).toBeDefined()
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((doneEvent as any).data.stop_reason).toBe('end_turn')
|
|
})
|
|
|
|
it('yields an error event on HTTP failure', async () => {
|
|
globalThis.fetch = buildFetchSequence([
|
|
copilotTokenResponse(),
|
|
{
|
|
ok: false,
|
|
status: 429,
|
|
statusText: 'Too Many Requests',
|
|
text: () => Promise.resolve('rate limited'),
|
|
},
|
|
])
|
|
|
|
const events = []
|
|
for await (const ev of new CopilotAdapter(OAUTH_TOKEN).stream([userMsg('hi')], { model: 'gpt-4o' })) {
|
|
events.push(ev)
|
|
}
|
|
|
|
expect(events[0]).toMatchObject({ type: 'error' })
|
|
})
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// authenticate() — Device Flow (mocked)
|
|
// -------------------------------------------------------------------------
|
|
|
|
describe('authenticate()', () => {
|
|
it('runs the device flow and returns an OAuth token', async () => {
|
|
globalThis.fetch = buildFetchSequence([
|
|
// 1. Device code request
|
|
{
|
|
ok: true,
|
|
status: 200,
|
|
json: () =>
|
|
Promise.resolve({
|
|
device_code: 'dc123',
|
|
user_code: USER_CODE,
|
|
verification_uri: VERIFICATION_URI,
|
|
expires_in: 900,
|
|
interval: 0, // no wait in tests
|
|
}),
|
|
},
|
|
// 2. First poll — pending
|
|
{
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ error: 'authorization_pending' }),
|
|
},
|
|
// 3. Second poll — success
|
|
{
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ access_token: OAUTH_TOKEN }),
|
|
},
|
|
// 4. User info
|
|
{
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ login: 'testuser' }),
|
|
},
|
|
])
|
|
|
|
const prompted = { userCode: '', uri: '' }
|
|
const token = await CopilotAdapter.authenticate((userCode, uri) => {
|
|
prompted.userCode = userCode
|
|
prompted.uri = uri
|
|
})
|
|
|
|
expect(token).toBe(OAUTH_TOKEN)
|
|
expect(prompted.userCode).toBe(USER_CODE)
|
|
expect(prompted.uri).toBe(VERIFICATION_URI)
|
|
})
|
|
|
|
it('throws when the device flow times out', async () => {
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () =>
|
|
Promise.resolve({
|
|
device_code: 'dc123',
|
|
user_code: USER_CODE,
|
|
verification_uri: VERIFICATION_URI,
|
|
expires_in: 0, // already expired
|
|
interval: 0,
|
|
}),
|
|
})
|
|
|
|
await expect(CopilotAdapter.authenticate(() => {})).rejects.toThrow('timed out')
|
|
})
|
|
})
|
|
})
|