feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('os', async () => {
|
||||
const actual = await vi.importActual<typeof import('os')>('os')
|
||||
return { ...actual, homedir: () => '/fake/home' }
|
||||
})
|
||||
|
||||
const { mockReadFile, mockWriteFile, mockMkdir, mockSaveEnvValue, mockReadConfigYaml, mockWriteConfigYaml, mockUpdateConfigYaml, mockResolveWithSource, mockInvalidate, mockReadAppConfig, mockWriteAppConfig } = vi.hoisted(() => ({
|
||||
mockReadFile: vi.fn(),
|
||||
mockWriteFile: vi.fn().mockResolvedValue(undefined),
|
||||
mockMkdir: vi.fn().mockResolvedValue(undefined),
|
||||
mockSaveEnvValue: vi.fn().mockResolvedValue(undefined),
|
||||
mockReadConfigYaml: vi.fn(),
|
||||
mockWriteConfigYaml: vi.fn().mockResolvedValue(undefined),
|
||||
mockUpdateConfigYaml: vi.fn(),
|
||||
mockResolveWithSource: vi.fn(),
|
||||
mockInvalidate: vi.fn(),
|
||||
mockReadAppConfig: vi.fn(),
|
||||
mockWriteAppConfig: vi.fn().mockResolvedValue({ copilotEnabled: true }),
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: mockReadFile,
|
||||
writeFile: mockWriteFile,
|
||||
mkdir: mockMkdir,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
||||
saveEnvValue: mockSaveEnvValue,
|
||||
readConfigYaml: mockReadConfigYaml,
|
||||
writeConfigYaml: mockWriteConfigYaml,
|
||||
updateConfigYaml: mockUpdateConfigYaml,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({
|
||||
resolveCopilotOAuthTokenWithSource: mockResolveWithSource,
|
||||
invalidateAllCaches: mockInvalidate,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveEnvPath: () => '/fake/home/.hermes/.env',
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/app-config', () => ({
|
||||
readAppConfig: mockReadAppConfig,
|
||||
writeAppConfig: mockWriteAppConfig,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/logger', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||||
}))
|
||||
|
||||
import * as ctrl from '../../packages/server/src/controllers/hermes/copilot-auth'
|
||||
|
||||
function makeCtx(): any {
|
||||
return { params: {}, request: { body: {} }, body: undefined, status: 200 }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockReadFile.mockResolvedValue('')
|
||||
mockReadConfigYaml.mockResolvedValue({})
|
||||
mockUpdateConfigYaml.mockImplementation(async (updater: any) => {
|
||||
const cfg = await mockReadConfigYaml()
|
||||
const updated = await updater(cfg)
|
||||
if (updated && typeof updated === 'object' && Object.hasOwn(updated, 'data')) {
|
||||
if (updated.write === false) return updated.result
|
||||
await mockWriteConfigYaml(updated.data)
|
||||
return updated.result
|
||||
}
|
||||
await mockWriteConfigYaml(updated)
|
||||
return undefined
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.COPILOT_GITHUB_TOKEN
|
||||
})
|
||||
|
||||
describe('copilot-auth controller — checkToken', () => {
|
||||
it('reports has_token=false / source=null / enabled=false when nothing resolves', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
mockReadAppConfig.mockResolvedValue({})
|
||||
const ctx = makeCtx()
|
||||
await ctrl.checkToken(ctx)
|
||||
expect(ctx.body).toEqual({ has_token: false, source: null, enabled: false })
|
||||
expect(mockInvalidate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports source and enabled flag', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'env' })
|
||||
mockReadAppConfig.mockResolvedValue({ copilotEnabled: true })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.checkToken(ctx)
|
||||
expect(ctx.body).toEqual({ has_token: true, source: 'env', enabled: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('copilot-auth controller — enable', () => {
|
||||
it('persists copilotEnabled=true and invalidates cache', async () => {
|
||||
const ctx = makeCtx()
|
||||
await ctrl.enable(ctx)
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: true })
|
||||
expect(mockInvalidate).toHaveBeenCalled()
|
||||
expect(ctx.body).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('copilot-auth controller — disable', () => {
|
||||
it('clears ~/.hermes/.env when token source is env', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'env' })
|
||||
process.env.COPILOT_GITHUB_TOKEN = 'gho_xxx'
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockSaveEnvValue).toHaveBeenCalledWith('COPILOT_GITHUB_TOKEN', '')
|
||||
expect(process.env.COPILOT_GITHUB_TOKEN).toBeUndefined()
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
|
||||
expect(ctx.body).toEqual({ ok: true, cleared_env: true, cleared_default: false })
|
||||
})
|
||||
|
||||
it('does NOT touch .env when token source is gh-cli (preserves gh CLI session)', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'gh-cli' })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockSaveEnvValue).not.toHaveBeenCalled()
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
|
||||
expect(ctx.body).toEqual({ ok: true, cleared_env: false, cleared_default: false })
|
||||
})
|
||||
|
||||
it('does NOT touch .env when token source is apps-json (preserves VS Code Copilot)', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'apps-json' })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockSaveEnvValue).not.toHaveBeenCalled()
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
|
||||
expect(ctx.body).toEqual({ ok: true, cleared_env: false, cleared_default: false })
|
||||
})
|
||||
|
||||
it('still flips enabled=false even when no token is resolvable', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockSaveEnvValue).not.toHaveBeenCalled()
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
|
||||
})
|
||||
|
||||
it('clears default model when it belongs to copilot', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'gpt-4o', provider: 'copilot' } })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockWriteConfigYaml).toHaveBeenCalledWith(expect.objectContaining({ model: {} }))
|
||||
expect(ctx.body).toEqual(expect.objectContaining({ cleared_default: true }))
|
||||
})
|
||||
|
||||
it('does NOT touch default model when it belongs to a different provider', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'glm-4', provider: 'zhipu' } })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockWriteConfigYaml).not.toHaveBeenCalled()
|
||||
expect(ctx.body).toEqual(expect.objectContaining({ cleared_default: false }))
|
||||
})
|
||||
|
||||
it('returns 500 and does NOT flip enabled flag when writeConfigYaml fails', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'env' })
|
||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'gpt-4o', provider: 'copilot' } })
|
||||
mockWriteConfigYaml.mockRejectedValueOnce(new Error('disk full'))
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(ctx.status).toBe(500)
|
||||
expect(mockSaveEnvValue).not.toHaveBeenCalled()
|
||||
expect(mockWriteAppConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not write process.env on persistToken / disable cleanup is defensive only', async () => {
|
||||
// disable 不依赖 process.env 被写入;只清理之前可能由外部 export 的覆盖。
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
process.env.COPILOT_GITHUB_TOKEN = 'leftover-from-shell'
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
// source=null → 不动 .env,也不清 process.env(因为不是 web-ui 自己的状态)
|
||||
expect(process.env.COPILOT_GITHUB_TOKEN).toBe('leftover-from-shell')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user