🐛 fix(vscode): 修复看板缓存并增加问答默认模型

This commit is contained in:
cruldra committed 2026-10-09 17:00:43 -04:00
1 parent c38f1dc468
commit 012daf95b5
25 files changed
+1753 -164

No files matched your search

@@ -0,0 +1,198 @@
import type { ExtensionContext } from 'vscode'
import type { KanbanWebviewPanel } from '../panel/KanbanPanel'
import type { WebhookEvent } from './eventParser'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { KANBAN_SNAPSHOT_KEY } from '../panel/kanbanSnapshot'
import { getKanbanState } from '../panel/kanbanState'
import { webhookCoordinator } from './coordinator'
const mocks = vi.hoisted(() => ({
receive: undefined as ((event: WebhookEvent) => void) | undefined,
token: vi.fn(),
loadSingle: vi.fn(),
readState: vi.fn(),
log: vi.fn(),
}))
vi.mock('vscode', () => ({ env: {}, Uri: {}, window: { showInformationMessage: async () => undefined }, workspace: { workspaceFolders: [{ uri: { fsPath: '/repo' } }] } }))
vi.mock('../logging/logger', () => ({ logger: { add: mocks.log } }))
vi.mock('../auth/secrets', () => ({ getToken: mocks.token }))
vi.mock('../auth/identity', () => ({ loginForToken: async () => 'me' }))
vi.mock('../cc/prompts', () => ({ renderReviewPrompt: () => 'review' }))
vi.mock('../git/remote', () => ({ detectRepo: async () => ({ host: 'host', owner: 'owner', repo: 'repo' }) }))
vi.mock('../gitea/api', () => ({ getIssue: async () => ({ assignees: [{ login: 'me' }] }) }))
vi.mock('../gitea/issueLoader', () => ({ loadSingleIssue: mocks.loadSingle }))
vi.mock('../gitea/stateJson', async importOriginal => ({
hasStateColumn: (await importOriginal<typeof import('../gitea/stateJson')>()).hasStateColumn,
readStateJsonComment: mocks.readState,
mergeStateJsonComment: async () => undefined,
}))
vi.mock('../issues/localState', () => ({ overlayLocalIssueState: (_ctx: unknown, issues: unknown[]) => issues, getLocalIssueState: () => ({}) }))
vi.mock('../panel/handlers/issueChat', () => ({}))
vi.mock('../panel/handlers/terminals', () => ({ hasLiveIssueSessionTerminal: () => false }))
vi.mock('../sessions/sessionFiles', () => ({ annotateMissingSessionIds: async (issues: unknown[]) => issues }))
vi.mock('../settings/store', () => ({ getSettings: () => ({ webhookSecret: '', webhookPort: 12345, devBranch: 'main', releaseBranchPrefix: 'release/', autoReview: true }) }))
vi.mock('./server', () => ({
WebhookServer: class {
setSecret() {}
start() { return Promise.resolve() }
stop() { return Promise.resolve() }
onEvent(receive: (event: WebhookEvent) => void) {
mocks.receive = receive
return { dispose() {} }
}
},
}))
const cached = { issues: [], scope: 'all', globalAutoReview: false, youtrackConfigured: false }
const events: WebhookEvent[] = [
{ kind: 'issue', action: 'closed', issueNumber: 462, title: '', body: '', htmlUrl: '', raw: {} },
{ kind: 'issue_comment', action: 'created', issueNumber: 462, prNumber: undefined, commentBody: '{"column":"done"}', commentAuthor: 'me', commentHtmlUrl: '', raw: {} },
{ kind: 'pr', action: 'closed', issueNumber: 462, pr: '470', branch: '', title: '', body: '', htmlUrl: '', raw: {} },
]
function fixture() {
const disk = new Map<string, unknown>([[KANBAN_SNAPSHOT_KEY, cached]])
const update = vi.fn(async (key: string, value: unknown) => {
if (value === undefined)
disk.delete(key)
else
disk.set(key, value)
})
const context = { workspaceState: { get: (key: string) => disk.get(key), update } } as unknown as ExtensionContext
webhookCoordinator.init(context)
return { disk, update, context }
}
beforeEach(() => {
vi.clearAllMocks()
mocks.token.mockReset().mockResolvedValue(undefined)
mocks.loadSingle.mockReset()
mocks.readState.mockReset().mockResolvedValue({ column: 'review', autoReview: true })
})
afterEach(async () => {
await webhookCoordinator.dispose()
})
describe('coordinator cache invalidation', () => {
it.each(events)('无面板收到 $kind 后删除持久快照', async (event) => {
const f = fixture()
mocks.receive!(event)
await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false))
expect(f.update).toHaveBeenCalledWith(KANBAN_SNAPSHOT_KEY, undefined)
})
it('无面板事件立即清共享内存,删除必须等待正在保存的快照', async () => {
const f = fixture()
let release!: () => void
const gate = new Promise<void>((resolve) => {
release = resolve
})
f.update.mockImplementationOnce(async (key, value) => {
await gate
f.disk.set(key, value)
})
const state = getKanbanState(f.context.workspaceState)
const saving = state.apply({ type: 'issues/update', issues: [], scope: 'mine', globalAutoReview: true, youtrackConfigured: false })
await vi.waitFor(() => expect(f.update).toHaveBeenCalledOnce())
mocks.receive!(events[0])
expect(getKanbanState(f.context.workspaceState).snapshot).toBeUndefined()
expect(f.update).toHaveBeenCalledOnce()
release()
await saving
await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false))
expect(f.update).toHaveBeenNthCalledWith(2, KANBAN_SNAPSHOT_KEY, undefined)
})
it.each(['edited', 'deleted'])('无面板收到状态评论 %s 清除缓存', async (action) => {
const f = fixture()
const state = getKanbanState(f.context.workspaceState)
mocks.receive!({ kind: 'issue_comment', action, issueNumber: 462, prNumber: undefined, commentBody: '{"column":"done"}', commentAuthor: 'me', commentHtmlUrl: '', raw: {} })
expect(state.snapshot).toBeUndefined()
await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false))
})
it.each(['created', 'edited', 'deleted'])('无面板收到普通讨论 %s 保留有效缓存', async (action) => {
const f = fixture()
const state = getKanbanState(f.context.workspaceState)
const snapshot = state.snapshot
mocks.receive!({ kind: 'issue_comment', action, issueNumber: 462, prNumber: undefined, commentBody: '谢谢', commentAuthor: 'other', commentHtmlUrl: '', raw: {} })
await Promise.resolve()
expect(state.snapshot).toBe(snapshot)
expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached)
expect(f.update).not.toHaveBeenCalled()
})
it('读取单卡期间关闭面板,事件结束后不保留过期快照', async () => {
const f = fixture()
const state = getKanbanState(f.context.workspaceState)
let release!: (value: unknown) => void
mocks.token.mockResolvedValue('token')
mocks.loadSingle.mockReturnValue(new Promise((resolve) => {
release = resolve
}))
const panel = { noteIssueOwnership: vi.fn(), postMessage: vi.fn() } as unknown as KanbanWebviewPanel
webhookCoordinator.setActivePanel(panel)
mocks.receive!(events[0])
await vi.waitFor(() => expect(mocks.loadSingle).toHaveBeenCalledOnce())
webhookCoordinator.setActivePanel(undefined)
release({ id: 'repo#462', number: 462, title: 'merged', column: 'done', htmlUrl: '' })
await vi.waitFor(() => expect(state.snapshot).toBeUndefined())
await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false))
})
it('同一面板持续打开时仍可启动自动审查', async () => {
fixture()
mocks.token.mockResolvedValue('token')
const panel = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined) }
webhookCoordinator.setActivePanel(panel as unknown as KanbanWebviewPanel)
mocks.receive!({ kind: 'pr', action: 'opened', issueNumber: 462, pr: '470', branch: 'feature', title: '', body: 'Closes #462', htmlUrl: '', raw: {} })
await vi.waitFor(() => expect(panel.triggerAutoReviewTab).toHaveBeenCalledOnce())
})
it.each([
{ pendingRead: 1, reopen: false },
{ pendingRead: 1, reopen: true },
{ pendingRead: 2, reopen: false },
{ pendingRead: 2, reopen: true },
{ pendingRead: 3, reopen: false },
{ pendingRead: 3, reopen: true },
])('自动审查第 $pendingRead 次状态读取期间关闭,重开=$reopen,不启动或转移旧任务', async ({ pendingRead, reopen }) => {
fixture()
let release!: (value: Record<string, unknown>) => void
const pending = new Promise<Record<string, unknown>>((resolve) => {
release = resolve
})
let reads = 0
mocks.token.mockResolvedValue('token')
mocks.readState.mockImplementation(() => ++reads === pendingRead ? pending : Promise.resolve({ column: 'review', autoReview: true }))
const first = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined) }
const second = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined), reloadAfterInvalidation: vi.fn().mockResolvedValue(undefined) }
webhookCoordinator.setActivePanel(first as unknown as KanbanWebviewPanel)
mocks.receive!({ kind: 'pr', action: 'opened', issueNumber: 462, pr: '470', branch: 'feature', title: '', body: 'Closes #462', htmlUrl: '', raw: {} })
await vi.waitFor(() => expect(mocks.readState).toHaveBeenCalledTimes(pendingRead))
webhookCoordinator.setActivePanel(undefined)
if (reopen)
webhookCoordinator.setActivePanel(second as unknown as KanbanWebviewPanel)
release({ column: 'review', autoReview: true })
await vi.waitFor(() => expect(mocks.log.mock.calls.some(([entry]) => entry.message.includes('跳过审查 #462') || entry.message.includes('审查 tab 已就绪 #462'))).toBe(true))
expect(first.triggerAutoReviewTab).not.toHaveBeenCalled()
expect(second.triggerAutoReviewTab).not.toHaveBeenCalled()
})
it('普通 push 不清看板', async () => {
const f = fixture()
mocks.receive!({ kind: 'push', branch: 'main', raw: {} })
await Promise.resolve()
expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached)
expect(f.update).not.toHaveBeenCalled()
})
it('有面板时保留快照,由现有事件处理路径更新', async () => {
const f = fixture()
webhookCoordinator.setActivePanel({} as KanbanWebviewPanel)
mocks.receive!(events[2])
await Promise.resolve()
expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached)
expect(f.update).not.toHaveBeenCalled()
})
})