Files
2026-07-13 12:26:02 +08:00

140 lines
5.0 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { cline, createClineProvider } from '../../src/providers/cline.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
async function writeTask(baseDir: string, taskId: string, opts?: {
tokensIn?: number
tokensOut?: number
model?: string
userMessage?: string
cost?: number
}): Promise<string> {
const taskDir = join(baseDir, 'tasks', taskId)
await mkdir(taskDir, { recursive: true })
const messages: unknown[] = []
if (opts?.userMessage) {
messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1700000000000 })
}
const usage: Record<string, unknown> = {
tokensIn: opts?.tokensIn ?? 100,
tokensOut: opts?.tokensOut ?? 50,
}
if (opts?.cost !== undefined) usage.cost = opts.cost
messages.push({ type: 'say', say: 'api_req_started', text: JSON.stringify(usage), ts: 1700000001000 })
const modelTag = opts?.model ? `<model>${opts.model}</model>` : ''
const history = [
{ role: 'user', content: [{ type: 'text', text: `hello\n<environment_details>\n${modelTag}\n</environment_details>` }] },
]
await writeFile(join(taskDir, 'ui_messages.json'), JSON.stringify(messages))
await writeFile(join(taskDir, 'api_conversation_history.json'), JSON.stringify(history))
return taskDir
}
describe('cline provider - discovery', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
it('discovers Cline tasks from VS Code globalStorage and home data roots', async () => {
const vscodeDir = join(tmpDir, 'globalStorage')
const homeDataDir = join(tmpDir, 'cline-data')
await writeTask(vscodeDir, 'task-vscode')
await writeTask(homeDataDir, 'task-home')
const provider = createClineProvider([vscodeDir, homeDataDir])
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(2)
expect(sessions.map(s => s.provider)).toEqual(['cline', 'cline'])
expect(sessions.map(s => s.project)).toEqual(['Cline', 'Cline'])
expect(sessions.map(s => s.path).sort()).toEqual([
join(homeDataDir, 'tasks', 'task-home'),
join(vscodeDir, 'tasks', 'task-vscode'),
].sort())
})
it('deduplicates the same task id across roots by keeping the newest task directory', async () => {
const vscodeDir = join(tmpDir, 'globalStorage')
const homeDataDir = join(tmpDir, 'cline-data')
const oldTask = await writeTask(vscodeDir, 'task-same')
const newTask = await writeTask(homeDataDir, 'task-same')
await utimes(join(oldTask, 'ui_messages.json'), new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z'))
await utimes(join(newTask, 'ui_messages.json'), new Date('2026-02-01T00:00:00Z'), new Date('2026-02-01T00:00:00Z'))
const provider = createClineProvider([vscodeDir, homeDataDir])
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.path).toBe(newTask)
})
it('skips task directories without ui_messages.json', async () => {
const vscodeDir = join(tmpDir, 'globalStorage')
await mkdir(join(vscodeDir, 'tasks', 'task-no-ui'), { recursive: true })
const provider = createClineProvider(vscodeDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(0)
})
})
describe('cline provider - parsing', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
it('parses Cline usage with cline provider identity', async () => {
const taskDir = await writeTask(tmpDir, 'task-parse', {
tokensIn: 200,
tokensOut: 100,
model: 'anthropic/claude-sonnet-4-5',
userMessage: 'build the feature',
cost: 0.07,
})
const source = { path: taskDir, project: 'Cline', provider: 'cline' }
const calls: ParsedProviderCall[] = []
for await (const call of cline.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]!.provider).toBe('cline')
expect(calls[0]!.model).toBe('claude-sonnet-4-5')
expect(calls[0]!.inputTokens).toBe(200)
expect(calls[0]!.outputTokens).toBe(100)
expect(calls[0]!.costUSD).toBe(0.07)
expect(calls[0]!.userMessage).toBe('build the feature')
expect(calls[0]!.deduplicationKey).toMatch(/^cline:task-parse:/)
})
})
describe('cline provider - metadata', () => {
it('has correct name and displayName', () => {
expect(cline.name).toBe('cline')
expect(cline.displayName).toBe('Cline')
})
it('passes through model and tool display names', () => {
expect(cline.modelDisplayName('claude-sonnet-4-5')).toBe('claude-sonnet-4-5')
expect(cline.toolDisplayName('read_file')).toBe('read_file')
})
})