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', () => ({ 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([[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((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) => void const pending = new Promise>((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() }) })