import type { BlockView } from '../blockView' import type { DiffBase, OpenedFrom } from '../diff/diffBase' import type { ProfileChoice } from '../qa/askProfiles' import type { RewriteRequest } from '../qa/codeQa' import type { SymbolNode } from '../symbolLinks' import type { CodeColors } from '../theme/codeColors' import type { ExtensionToWebview, WebviewToExtension } from './messages' import { describe, expect, it, vi } from 'vitest' import { commands, Range, Uri, window, workspace } from 'vscode' import { languageDefinitions } from './definitions' import { documentMessage } from './documentMessage' import { documentSymbolNodes } from './documentSymbols' import { FIND_CONTEXT } from './findCommands' import { OCCURRENCES_CONTEXT } from './occurrenceNavigation' import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './previewEditor' vi.mock('vscode', () => ({ commands: { executeCommand: vi.fn() }, window: { showErrorMessage: vi.fn(), registerCustomEditorProvider: vi.fn() }, workspace: { onDidChangeTextDocument: vi.fn(() => ({ dispose: () => {} })), onDidChangeConfiguration: () => ({ dispose: () => {} }), getConfiguration: () => ({ get: (_key: string, fallback: unknown) => fallback }), openTextDocument: vi.fn(), applyEdit: vi.fn(async () => true), textDocuments: [], asRelativePath: (uri: { fsPath: string }) => uri.fsPath, }, /** 记下换了哪儿、换成什么 */ WorkspaceEdit: class { readonly replaced: { range: unknown, text: string }[] = [] replace(_uri: unknown, range: unknown, text: string) { this.replaced.push({ range, text }) } }, Range: class { constructor(readonly startLine: number, readonly startCharacter: number, readonly endLine: number, readonly endCharacter: number) {} get start() { return { line: this.startLine, character: this.startCharacter } } get end() { return { line: this.endLine, character: this.endCharacter } } }, Uri: { file: (fsPath: string) => ({ fsPath, scheme: 'file', toString: () => `file://${fsPath}` }), parse: (value: string) => ({ fsPath: value.replace('file://', ''), scheme: 'file', toString: () => value }), }, EndOfLine: { LF: 1, CRLF: 2 }, })) vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } })) vi.mock('../../webview/html', () => ({ webviewHtml: () => '', webviewUiRoot: () => ({}) })) vi.mock('../folderStore', () => ({ locate: () => ({ folder: { uri: { fsPath: '/workspace' } }, key: 'web/a.ts' }) })) /** 顶部问答按文件算;仓库链接从哪儿算照真的来(功能对话页按工作区文件夹,别的按仓库根) */ vi.mock('./pathPreview', async importOriginal => ({ ...await importOriginal(), topSubjectOf: (uri: unknown) => ({ uri, kind: 'file' }), })) vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() })) /** 排视图数据不真的解析:一份没有块的文档 */ vi.mock('./documentMessage', async importOriginal => ({ ...await importOriginal(), documentMessage: vi.fn(async (input: { languageId: string, version: number }) => ({ message: { type: 'document', version: input.version, languageId: input.languageId, blocks: [], source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] }, displayNames: [], symbols: [], imports: [], kinds: [], bindings: [], })), })) /** 文档符号由各个测试给 */ vi.mock('./documentSymbols', () => ({ documentSymbolNodes: vi.fn(async () => []) })) /** 语言扩展答的定义由各个测试给,默认没答 */ vi.mock('./definitions', async importOriginal => ({ ...await importOriginal(), languageDefinitions: vi.fn(async () => []), })) /** 仓库在 /workspace;磁盘上只有这几个文件(另两个在「了解一块代码」用的工作区文件夹 /ws/web、/srv/app 里) */ vi.mock('./repoPath', async importOriginal => ({ ...await importOriginal(), repoRootOf: async () => '/workspace', resolveRepoEntry: async (root: string, rel: string) => { const fsPath = `${root}/${rel}` const files = ['/workspace/web/a.ts', '/workspace/web/b.ts', '/workspace/docs/notes.txt', '/workspace/docs/home.png', '/ws/web/src/app/(main)/page.tsx', '/srv/app/app/routes.py'] return files.includes(fsPath) ? { fsPath, isDirectory: false } : undefined }, })) const SOURCE = ['// 旧说明', 'export function load() {}'] function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise, dirty = false) { const subscribe = () => ({ dispose: () => {} }) const receive = vi.fn<(listener: (message: WebviewToExtension) => Promise) => { dispose: () => void }>(subscribe) const post = vi.fn<(message: ExtensionToWebview) => void>() const uri = { fsPath: '/workspace/web/a.ts', scheme: 'file', toString: () => 'file:///workspace/web/a.ts' } const document = { uri, isDirty: dirty, lineAt: (line: number) => ({ text: SOURCE[line] }), lineCount: SOURCE.length, eol: 1 } vi.mocked(workspace.openTextDocument).mockResolvedValue(document as never) const qa = { imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles: { ask: { subscribe }, rewrite: { subscribe } }, rewrite: vi.fn(rewrite) } const provider = new PreviewEditorProvider(...[ {}, { onDidChange: subscribe }, {}, { subscribe }, { subscribe }, async () => [], qa, async () => [], async () => [], { onDidChange: subscribe }, { onDidChange: subscribe }, async () => false, async () => undefined, ] as unknown as ConstructorParameters) provider.resolveCustomTextEditor(...[ document, { webview: { onDidReceiveMessage: receive, postMessage: post }, onDidChangeViewState: subscribe, onDidDispose: subscribe }, ] as unknown as Parameters) const opened = (provider as unknown as { previews: Set<{ sent: unknown, dispose: () => void }> }).previews.values().next().value! opened.sent = { blocks: [{ anchor: 'block', children: [], commentTarget: { kind: 'replace', startLine: 0, endLine: 0 }, startLine: 0, endLine: 1 }] } return { send: receive.mock.calls[0]![0], post, qa, dispose: () => opened.dispose() } } describe('预览里重写注释', () => { it('点按钮:发技能命令、开新会话,记在这一轮上的就是这句命令;开不了时回一条带 askId 的失败,能再试', async () => { const error = '重写注释模型不可用:official。请重新选择。' const editor = preview(async () => error) try { await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] }) expect(editor.qa.rewrite).toHaveBeenCalledWith(expect.objectContaining({ fsPath: '/workspace/web/a.ts' }), expect.objectContaining({ askId: 'r1', anchor: 'block', question: '/ecc:writing-code-comments web/a.ts:1-2', prompt: '/ecc:writing-code-comments web/a.ts:1-2', images: [], })) expect(editor.qa.rewrite.mock.calls[0][1]).not.toHaveProperty('followUp') expect(editor.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', askId: 'r1', status: 'failed', error }) await editor.send({ type: 'rewriteComment', askId: 'r2', anchor: 'block', images: [] }) expect(editor.qa.rewrite).toHaveBeenCalledTimes(2) } finally { editor.dispose() } }) it('输入框里说的话:接着这块的重写会话说,带上块现在在哪;会话找不到时开新会话用的是技能命令加这句话', async () => { const editor = preview(async () => undefined) try { const image = { mediaType: 'image/png', base64: 'iVBORw0KGgo=' } await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', request: '加上原型图', images: [image] }) expect(editor.qa.rewrite.mock.calls[0][1]).toMatchObject({ question: '加上原型图', prompt: '/ecc:writing-code-comments web/a.ts:1-2\n\n加上原型图', followUp: 'web/a.ts:1-2\n\n加上原型图', images: [image], }) } finally { editor.dispose() } }) it('模型答完写回:套回原来的 // 换掉那几行;文件还有没保存的修改时不开始', async () => { const editor = preview(async () => undefined) try { await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] }) expect(await editor.qa.rewrite.mock.calls[0][1].writeBack('新说明')).toBeUndefined() const [edit] = vi.mocked(workspace.applyEdit).mock.calls.at(-1)! as unknown as [{ replaced: { text: string }[] }] expect(edit.replaced.map(item => item.text)).toEqual(['// 新说明']) expect(await editor.qa.rewrite.mock.calls[0][1].writeBack(undefined)).toBe('模型没有按要求交回注释正文,没有改文件。') } finally { editor.dispose() } const dirty = preview(async () => undefined, true) try { await dirty.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] }) expect(dirty.qa.rewrite).not.toHaveBeenCalled() expect(dirty.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', askId: 'r1', status: 'failed', error: '文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。' }) } finally { dirty.dispose() } }) }) const HEAD_BASE: DiffBase = { label: '上一次提交', key: 'head:web/a.ts', read: async () => '// 改之前', missing: '' } const OPTIONS = [{ id: 'offical', label: 'offical' }, { id: 'sonnet', label: 'sonnet' }, { id: 'dsflash', label: 'dsflash' }] /** 一套 profile 选择:眼下选的是 selected,publish 模拟它变了、通知订阅的预览 */ function profileSet(selected: string) { const listeners = new Set<(choice: ProfileChoice) => void>() return { subscribe: (listener: (choice: ProfileChoice) => void) => { listeners.add(listener) return { dispose: () => listeners.delete(listener) } }, choice: async (): Promise => ({ options: OPTIONS, selected }), select: vi.fn(async (_id: string) => {}), publish: (choice: ProfileChoice) => listeners.forEach(listener => listener(choice)), } } const COLORS = { roles: { keyword: '#C586C0' }, kinds: { loop: '#C586C0' } } as unknown as CodeColors /** 共用的代码配色:眼下是 colors,publish 模拟换了颜色主题、通知订阅的预览 */ function themeColors(colors: CodeColors) { const listeners = new Set<(value: CodeColors) => void>() let current = colors return { current: async () => current, subscribe: (listener: (value: CodeColors) => void) => { listeners.add(listener) return { dispose: () => listeners.delete(listener) } }, publish: (value: CodeColors) => { current = value listeners.forEach(listener => listener(value)) }, } } /** 对比基准只认用「未提交的更改」文件行上的「打开更改」打开的:这样打开的对比上一次提交,别处打开的不比 */ function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }, colors = themeColors(COLORS), languageFor: (languageId: string) => unknown = () => undefined) { const subscribe = () => ({ dispose: () => {} }) const diffBase = vi.fn((_uri: unknown, from: OpenedFrom): DiffBase | undefined => from === 'uncommitted' ? HEAD_BASE : undefined) const qa = { imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles, view: () => ({ type: 'qa' }), } const provider = new PreviewEditorProvider(...[ {}, { selectedTranslator: () => undefined, languageFor, onDidChange: subscribe }, {}, { subscribe, current: { excluded: {}, onlyChanges: false } }, colors, async () => [], qa, async () => [], async () => [], { diffBase, onDidChange: subscribe }, { namesOf: () => [], onDidChange: subscribe }, async () => false, ] as unknown as ConstructorParameters) const uri = { fsPath: '/workspace/web/a.ts', scheme: 'file', toString: () => 'file:///workspace/web/a.ts' } const document = { uri, version: 1, languageId: 'typescript', fileName: '/workspace/web/a.ts', getText: () => SOURCE.join('\n') } return { provider, document, diffBase } } /** 一个标签页:收发消息;切走、切回来时页面留着,VS Code 只报一声可见性变了 */ function tab() { let receive: ((message: WebviewToExtension) => Promise) | undefined const viewStateListeners: (() => void)[] = [] const post = vi.fn<(message: ExtensionToWebview) => void>() const panel = { active: true, visible: true, webview: { options: {}, postMessage: post, asWebviewUri: (file: unknown) => file, onDidReceiveMessage: (listener: typeof receive) => { receive = listener return { dispose: () => {} } }, }, onDidChangeViewState: (listener: () => void) => { viewStateListeners.push(listener) return { dispose: () => {} } }, onDidDispose: () => ({ dispose: () => {} }), } const setVisible = (visible: boolean): void => { panel.visible = visible panel.active = visible viewStateListeners.forEach(listener => listener()) } return { panel: panel as unknown as Parameters[1], send: (message: WebviewToExtension) => receive!(message), hide: () => setVisible(false), show: () => setVisible(true), posted: () => post.mock.calls.map(([message]) => message), clear: () => post.mockClear(), } } /** 别处改了这份文件,文档变成 version 版:交给最近打开的预览听着的那个回调 */ function edit(document: { version: number }, version: number): void { document.version = version const changed = vi.mocked(workspace.onDidChangeTextDocument).mock.calls.at(-1)![0] changed({ document, contentChanges: [{ rangeOffset: 0, rangeLength: 0, text: '// ' }] } as never) } const comparedWithHead = expect.objectContaining({ type: 'diff', diff: expect.objectContaining({ base: '上一次提交' }) }) describe('预览对比什么看这个标签页是从哪儿打开的', () => { it('用「打开更改」打开的新预览对比上一次提交,页面记下是从哪儿打开的', async () => { const { provider, document } = opening() const page = tab() await provider.open(document.uri as never, { from: 'uncommitted' }, async () => provider.resolveCustomTextEditor(document as never, page.panel)) await page.send({ type: 'ready' }) expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'uncommitted' }) await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead)) }) it('跳定义、点链接打到开着的预览不改它对比什么、只滚过去;要新开的预览不比', async () => { const { provider, document, diffBase } = opening() const page = tab() await provider.open(document.uri as never, { from: 'uncommitted' }, async () => provider.resolveCustomTextEditor(document as never, page.panel)) await page.send({ type: 'ready' }) await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead)) page.clear() await provider.open(document.uri as never, { reveal: { line: 1 } }, async () => {}) expect(page.posted()).toEqual([{ type: 'reveal', line: 1 }]) expect(provider.openedFrom(document.uri as never)).toBe('uncommitted') diffBase.mockClear() const fresh = tab() await provider.open(document.uri as never, { reveal: { line: 1 } }, async () => provider.resolveCustomTextEditor(document as never, fresh.panel)) await fresh.send({ type: 'ready' }) expect(fresh.posted()).toContainEqual({ type: 'openedFrom', from: 'other' }) expect(diffBase).toHaveBeenCalledWith(document.uri, 'other') expect(diffBase).not.toHaveBeenCalledWith(document.uri, 'uncommitted') }) it('开着的预览再点文件行打开就不比,再点「打开更改」又比;页面重新加载后按它记着的照旧比', async () => { const { provider, document } = opening() const page = tab() await provider.open(document.uri as never, { from: 'uncommitted' }, async () => provider.resolveCustomTextEditor(document as never, page.panel)) await page.send({ type: 'ready' }) await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead)) page.clear() await provider.open(document.uri as never, { from: 'other' }, async () => {}) expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'other' }) await vi.waitFor(() => expect(page.posted()).toContainEqual({ type: 'diff', diff: null })) page.clear() await provider.open(document.uri as never, { from: 'uncommitted' }, async () => {}) expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'uncommitted' }) await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead)) page.clear() await page.send({ type: 'ready', openedFrom: 'uncommitted' }) expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'uncommitted' }) await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead)) }) it('没带打开请求的(VS Code 重启后恢复的)按页面记着的来,页面没记过的不比', async () => { const { provider, document, diffBase } = opening() const restored = tab() provider.resolveCustomTextEditor(document as never, restored.panel) await restored.send({ type: 'ready', openedFrom: 'uncommitted' }) await vi.waitFor(() => expect(restored.posted()).toContainEqual(comparedWithHead)) diffBase.mockClear() const fresh = tab() provider.resolveCustomTextEditor(document as never, fresh.panel) await fresh.send({ type: 'ready' }) expect(fresh.posted()).toContainEqual({ type: 'openedFrom', from: 'other' }) expect(diffBase).toHaveBeenCalledWith(document.uri, 'other') expect(diffBase).not.toHaveBeenCalledWith(document.uri, 'uncommitted') }) it('打开请求说的从哪儿打开比页面记着的优先', async () => { const { provider, document, diffBase } = opening() const page = tab() await provider.open(document.uri as never, { from: 'other' }, async () => provider.resolveCustomTextEditor(document as never, page.panel)) await page.send({ type: 'ready', openedFrom: 'uncommitted' }) expect(page.posted()).toContainEqual({ type: 'openedFrom', from: 'other' }) expect(diffBase).toHaveBeenCalledWith(document.uri, 'other') expect(diffBase).not.toHaveBeenCalledWith(document.uri, 'uncommitted') }) }) /** 页面眼下能读的本地目录 */ function readableRoots(page: ReturnType): unknown[] { return (page.panel.webview.options.localResourceRoots ?? []).map(root => root.fsPath) } describe('页面能读哪些本地目录', () => { it('新开的预览装页面之前就放行仓库根:注释里的图片问到时不再改页面选项,打开时要滚到的那处还在', async () => { const { provider, document } = opening() const page = tab() await provider.open(document.uri as never, { reveal: { line: 1 } }, async () => provider.resolveCustomTextEditor(document as never, page.panel)) expect(readableRoots(page)).toContain('/workspace') const options = page.panel.webview.options await page.send({ type: 'ready' }) await page.send({ type: 'checkRepoImages', id: 1, paths: ['docs/home.png'] }) expect(page.panel.webview.options).toBe(options) expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'document', reveal: { line: 1 } })) expect(page.posted()).toContainEqual({ type: 'repoImagesChecked', id: 1, images: [{ path: 'docs/home.png', src: 'file:///workspace/docs/home.png' }] }) }) }) describe('标签页切走时页面留着', () => { it('注册时就让页面在标签页切走后留着', () => { const { provider } = opening() provider.register() expect(window.registerCustomEditorProvider).toHaveBeenCalledWith(PREVIEW_VIEW_TYPE, provider, { webviewOptions: { retainContextWhenHidden: true } }) }) it('切走再切回来:不重新生成文档、什么都不重发,高亮照旧能用键盘跳', async () => { const { provider, document } = opening() const page = tab() await provider.open(document.uri as never, { from: 'uncommitted' }, async () => provider.resolveCustomTextEditor(document as never, page.panel)) await page.send({ type: 'ready' }) await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead)) await page.send({ type: 'occurrenceNavigation', available: true }) page.clear() vi.mocked(documentMessage).mockClear() vi.mocked(commands.executeCommand).mockClear() page.hide() page.show() // 比连续输入合并重新解析的那一下(150ms)等得久 await new Promise(resolve => setTimeout(resolve, 200)) expect(documentMessage).not.toHaveBeenCalled() expect(page.posted()).toEqual([]) expect(commands.executeCommand).not.toHaveBeenCalledWith('setContext', OCCURRENCES_CONTEXT, false) }) it('切走期间文件在别处改了:不生成、不发文档,切回来生成一次发出去', async () => { const { provider, document } = opening() const page = tab() provider.resolveCustomTextEditor(document as never, page.panel) await page.send({ type: 'ready' }) page.hide() page.clear() vi.mocked(documentMessage).mockClear() edit(document, 2) edit(document, 3) await new Promise(resolve => setTimeout(resolve, 200)) expect(documentMessage).not.toHaveBeenCalled() expect(page.posted()).toEqual([]) page.show() await vi.waitFor(() => expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'document', version: 3 }))) expect(documentMessage).toHaveBeenCalledTimes(1) }) it('改完还没来得及生成就切走了:排着的那次不在后台跑,切回来再生成', async () => { const { provider, document } = opening() const page = tab() provider.resolveCustomTextEditor(document as never, page.panel) await page.send({ type: 'ready' }) page.clear() vi.mocked(documentMessage).mockClear() edit(document, 2) page.hide() await new Promise(resolve => setTimeout(resolve, 200)) expect(documentMessage).not.toHaveBeenCalled() page.show() await vi.waitFor(() => expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'document', version: 2 }))) expect(documentMessage).toHaveBeenCalledTimes(1) }) it('切回来时还要滚到别处(跳定义打到它):等补发的文档一起带过去,不先滚旧的', async () => { const { provider, document } = opening() const page = tab() provider.resolveCustomTextEditor(document as never, page.panel) await page.send({ type: 'ready' }) page.hide() edit(document, 2) await new Promise(resolve => setTimeout(resolve, 200)) page.clear() // 补生成要一会儿:跳过来的位置在生成完之前就到了 const generate = vi.mocked(documentMessage).getMockImplementation()! vi.mocked(documentMessage).mockImplementationOnce(async (...args) => { await new Promise(resolve => setTimeout(resolve, 50)) return generate(...args) }) await provider.open(document.uri as never, { reveal: { line: 1 } }, async () => page.show()) await vi.waitFor(() => expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'document', version: 2, reveal: { line: 1 } }))) expect(page.posted()).not.toContainEqual(expect.objectContaining({ type: 'reveal' })) }) it('分屏同时看着:文件改了照常马上更新', async () => { const { provider, document } = opening() const page = tab() provider.resolveCustomTextEditor(document as never, page.panel) await page.send({ type: 'ready' }) page.clear() edit(document, 2) await vi.waitFor(() => expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'document', version: 2 }))) }) it('页面真的重新加载了(webview 进程崩了重建、开发者重载):再发 ready 时照旧整份发一遍', async () => { const { provider, document } = opening() const page = tab() await provider.open(document.uri as never, { from: 'uncommitted' }, async () => provider.resolveCustomTextEditor(document as never, page.panel)) await page.send({ type: 'ready' }) await vi.waitFor(() => expect(page.posted()).toContainEqual(comparedWithHead)) page.clear() vi.mocked(documentMessage).mockClear() await page.send({ type: 'ready', openedFrom: 'uncommitted' }) expect(documentMessage).toHaveBeenCalled() expect(page.posted().map(message => message.type)).toEqual(expect.arrayContaining(['openedFrom', 'filter', 'qa', 'codeColors', 'document'])) await vi.waitFor(() => { expect(page.posted()).toContainEqual(comparedWithHead) expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'profiles', kind: 'ask' })) expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'profiles', kind: 'rewrite' })) }) }) }) describe('代码配色跟着当前颜色主题', () => { it('ready 后先于第一份文档发;换了主题发给 ready 了的预览,还没 ready 的等 ready 时发眼下的', async () => { const colors = themeColors(COLORS) const { provider, document } = opening(undefined, colors) const page = tab() provider.resolveCustomTextEditor(document as never, page.panel) const light = { roles: { keyword: '#AF00DB' }, kinds: { loop: '#AF00DB' } } as unknown as CodeColors colors.publish(light) expect(page.posted()).toEqual([]) await page.send({ type: 'ready' }) const types = page.posted().map(message => message.type) expect(page.posted()).toContainEqual({ type: 'codeColors', ...light }) expect(types.indexOf('codeColors')).toBeGreaterThanOrEqual(0) expect(types.indexOf('codeColors')).toBeLessThan(types.indexOf('document')) page.clear() colors.publish(COLORS) expect(page.posted()).toEqual([{ type: 'codeColors', ...COLORS }]) }) }) describe('问 AI 和重写注释的 profile 下拉各管各的', () => { it('ready 后两套各发一次;哪一套变了只发哪一套', async () => { const profiles = { ask: profileSet('sonnet'), rewrite: profileSet('dsflash') } const { provider, document } = opening(profiles) const page = tab() provider.resolveCustomTextEditor(document as never, page.panel) await page.send({ type: 'ready' }) await vi.waitFor(() => { expect(page.posted()).toContainEqual({ type: 'profiles', kind: 'ask', options: OPTIONS, selected: 'sonnet' }) expect(page.posted()).toContainEqual({ type: 'profiles', kind: 'rewrite', options: OPTIONS, selected: 'dsflash' }) }) page.clear() profiles.rewrite.publish({ options: OPTIONS, selected: 'offical' }) expect(page.posted()).toContainEqual({ type: 'profiles', kind: 'rewrite', options: OPTIONS, selected: 'offical' }) expect(page.posted()).not.toContainEqual(expect.objectContaining({ type: 'profiles', kind: 'ask' })) }) it('下拉换了哪一套就只改哪一套;换不成时报错,并把那一套眼下的选择发回去', async () => { const profiles = { ask: profileSet('sonnet'), rewrite: profileSet('dsflash') } const { provider, document } = opening(profiles) const page = tab() provider.resolveCustomTextEditor(document as never, page.panel) await page.send({ type: 'ready' }) await page.send({ type: 'selectProfile', kind: 'rewrite', id: 'offical' }) expect(profiles.rewrite.select).toHaveBeenCalledWith('offical') expect(profiles.ask.select).not.toHaveBeenCalled() profiles.ask.select.mockRejectedValueOnce(new Error('问答模型不可用:gone。请重新选择。')) page.clear() await page.send({ type: 'selectProfile', kind: 'ask', id: 'gone' }) expect(vi.mocked(window.showErrorMessage)).toHaveBeenCalledWith('问答模型不可用:gone。请重新选择。') expect(page.posted()).toContainEqual({ type: 'profiles', kind: 'ask', options: OPTIONS, selected: 'sonnet' }) expect(page.posted()).not.toContainEqual(expect.objectContaining({ type: 'profiles', kind: 'rewrite' })) }) }) describe('注释里不带路径的引用按它写在哪块的注释里找', () => { const METHOD = 'class AgentService > function get_default_agent' const HELPER = 'function helper' /** 一块有名字的代码:名字在源码里的下标是 at */ function declared(anchor: string, at: number, children: BlockView[] = []): BlockView { return { id: anchor, anchor, kind: 'function', startLine: 0, endLine: 0, headerEndLine: 0, defines: { startIndex: at, endIndex: at + 1 }, attributes: { kind: 'function' }, concepts: [], children, translation: [] } } function symbol(name: string, start: number, end: number, children: SymbolNode[] = []): SymbolNode { return { name, range: { startIndex: start, endIndex: end }, selection: { startIndex: start, endIndex: start + name.length }, children } } it('同一个名字:方法说明里的找得到同类的另一个方法,顶层函数说明里的找不到;标坏链接、点了打开都按各自那块找', async () => { const blocks = [declared('class AgentService', 100, [declared(METHOD, 400)]), declared(HELPER, 950)] vi.mocked(documentMessage).mockImplementationOnce(async input => ({ message: { type: 'document', version: input.version, languageId: input.languageId, blocks, source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] }, displayNames: [], symbols: [], imports: [], kinds: [], bindings: [], }) as never) vi.mocked(documentSymbolNodes).mockResolvedValue([ symbol('AgentService', 100, 900, [symbol('get_agent_admitted', 200, 300), symbol('get_default_agent', 400, 500)]), symbol('helper', 950, 990), ]) const { provider, document } = opening() const page = tab() provider.resolveCustomTextEditor(document as never, page.panel) await page.send({ type: 'ready' }) await page.send({ type: 'checkRepoLinks', id: 1, paths: [], references: [{ symbol: 'get_agent_admitted', block: METHOD }, { symbol: 'get_agent_admitted', block: HELPER }] }) expect(page.posted()).toContainEqual({ type: 'repoLinksChecked', id: 1, missing: [], unresolved: [{ symbol: 'get_agent_admitted', block: HELPER }], builtin: [] }) await page.send({ type: 'openRepoLink', symbol: 'get_agent_admitted', block: HELPER }) expect(page.posted()).toContainEqual({ type: 'repoLinkBroken', symbol: 'get_agent_admitted', block: HELPER }) }) it('语言自带、代码里没写到的名字:检查时报成 builtin,点了报 repoLinkBuiltin,不标坏', async () => { vi.mocked(documentSymbolNodes).mockResolvedValue([]) const { provider, document } = opening(undefined, undefined, () => ({ builtins: new Set(['Error']) })) const page = tab() provider.resolveCustomTextEditor(document as never, page.panel) await page.send({ type: 'ready' }) await page.send({ type: 'checkRepoLinks', id: 1, paths: [], references: [{ symbol: 'Error', block: HELPER }, { symbol: 'Missing', block: HELPER }] }) expect(page.posted()).toContainEqual({ type: 'repoLinksChecked', id: 1, missing: [], unresolved: [{ symbol: 'Missing', block: HELPER }], builtin: [{ symbol: 'Error', block: HELPER }] }) await page.send({ type: 'openRepoLink', symbol: 'Error', block: HELPER }) expect(page.posted()).toContainEqual({ type: 'repoLinkBuiltin', symbol: 'Error', block: HELPER }) expect(page.posted()).not.toContainEqual(expect.objectContaining({ type: 'repoLinkBroken' })) }) it('检查过的引用再悬停、再点:文档符号、定义都只问一次;悬停两次,定义所在的别的文件也只读一次', async () => { const BUILTINS = '/typeshed/builtins.pyi' const builtins = { uri: Uri.file(BUILTINS), version: 1, languageId: 'python', fileName: BUILTINS, getText: () => 'class RuntimeError: ...', positionAt: () => ({ line: 0, character: 6 }), offsetAt: () => 6 } vi.mocked(documentMessage).mockImplementationOnce(async input => ({ message: { type: 'document', version: input.version, languageId: input.languageId, blocks: [declared(HELPER, 950)], source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] }, displayNames: [], symbols: [], imports: [{ name: 'RuntimeError', offset: 7, range: { startIndex: 0, endIndex: 20 } }], kinds: [], bindings: [], }) as never) vi.mocked(documentSymbolNodes).mockReset().mockResolvedValue([symbol('helper', 950, 990)]) vi.mocked(languageDefinitions).mockClear().mockResolvedValue([{ uri: builtins.uri, range: new Range(0, 6, 0, 18) }] as never) const { provider, document } = opening() Object.assign(document, { positionAt: (offset: number) => ({ line: 0, character: offset }) }) Object.assign(workspace, { textDocuments: [document, builtins] }) vi.mocked(workspace.openTextDocument).mockImplementation((async (uri: { toString: () => string }) => uri.toString() === builtins.uri.toString() ? builtins : document) as never) try { const page = tab() provider.resolveCustomTextEditor(document as never, page.panel) await page.send({ type: 'ready' }) await vi.waitFor(() => expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'document' }))) vi.mocked(documentMessage).mockClear() const reference = { symbol: 'RuntimeError', block: HELPER } await page.send({ type: 'checkRepoLinks', id: 1, paths: [], references: [reference] }) expect(page.posted()).toContainEqual({ type: 'repoLinksChecked', id: 1, missing: [], unresolved: [], builtin: [] }) await page.send({ type: 'peekLink', id: 2, ...reference }) await page.send({ type: 'peekLink', id: 3, ...reference }) await page.send({ type: 'openRepoLink', ...reference }) expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'repoLinkOpened', ...reference })) expect(commands.executeCommand).toHaveBeenCalledWith('vscode.open', expect.objectContaining({ fsPath: BUILTINS }), expect.anything()) expect(documentSymbolNodes).toHaveBeenCalledTimes(1) expect(languageDefinitions).toHaveBeenCalledTimes(1) expect(documentMessage).toHaveBeenCalledTimes(1) expect(documentMessage).toHaveBeenCalledWith(expect.objectContaining({ fileName: BUILTINS }), expect.anything(), expect.anything(), expect.any(Function)) } finally { Object.assign(workspace, { textDocuments: [] }) vi.mocked(workspace.openTextDocument).mockReset() } }) }) describe('点了带几行的仓库链接', () => { /** 文件按名字认语言,第 n 行(0 起)有 n 个字,一共 20 行 */ function textDocument(fsPath: string) { const languageId = fsPath.endsWith('.ts') ? 'typescript' : 'plaintext' return { uri: { fsPath, scheme: 'file', toString: () => `file://${fsPath}` }, languageId, lineCount: 20, lineAt: (line: number) => ({ text: 'x'.repeat(line) }) } } async function linked() { vi.mocked(workspace.openTextDocument).mockImplementation((async (uri: { fsPath: string }) => textDocument(uri.fsPath)) as never) vi.mocked(commands.executeCommand).mockClear() const { provider, document } = opening(undefined, undefined, languageId => languageId === 'typescript' ? {} : undefined) const page = tab() provider.resolveCustomTextEditor(document as never, page.panel) await page.send({ type: 'ready' }) await vi.waitFor(() => expect(page.posted()).toContainEqual(expect.objectContaining({ type: 'document' }))) page.clear() return { provider, page } } it('指到这份文件:就在本预览里滚到包住这几行的块', async () => { const { page } = await linked() await page.send({ type: 'openRepoLink', path: 'web/a.ts', lines: { start: 2, end: 3 } }) expect(page.posted()).toEqual([{ type: 'reveal', line: 1, endLine: 2 }, { type: 'repoLinkOpened', path: 'web/a.ts' }]) }) it('指到别的有代码阅读的文件:用预览打开,滚到包住这几行的块', async () => { const { provider, page } = await linked() const open = vi.spyOn(provider, 'open').mockResolvedValue() await page.send({ type: 'openRepoLink', path: 'web/b.ts', lines: { start: 5, end: 9 } }) expect(open).toHaveBeenCalledWith(expect.objectContaining({ fsPath: '/workspace/web/b.ts' }), { reveal: { line: 4, endLine: 8 } }, expect.any(Function)) expect(page.posted()).toEqual([{ type: 'repoLinkOpened', path: 'web/b.ts' }]) }) it('指到没有代码阅读的文件:文本编辑器打开,选中这几行', async () => { const { page } = await linked() await page.send({ type: 'openRepoLink', path: 'docs/notes.txt', lines: { start: 3, end: 4 } }) expect(commands.executeCommand).toHaveBeenCalledWith('vscode.open', expect.objectContaining({ fsPath: '/workspace/docs/notes.txt' }), expect.objectContaining({ selection: expect.objectContaining({ startLine: 2, startCharacter: 0, endLine: 3, endCharacter: 3 }) })) expect(page.posted()).toEqual([{ type: 'repoLinkOpened', path: 'docs/notes.txt' }]) }) it('文件没有:标坏,不打开', async () => { const { page } = await linked() await page.send({ type: 'openRepoLink', path: 'web/gone.ts', lines: { start: 1, end: 1 } }) expect(page.posted()).toEqual([{ type: 'repoLinkBroken', path: 'web/gone.ts' }]) expect(commands.executeCommand).not.toHaveBeenCalledWith('vscode.open', expect.anything(), expect.anything()) }) it('问哪些路径没有时,带几行的链接按文件问', async () => { const { page } = await linked() await page.send({ type: 'checkRepoLinks', id: 3, paths: ['web/a.ts', 'web/gone.ts'], references: [] }) expect(page.posted()).toEqual([{ type: 'repoLinksChecked', id: 3, missing: ['web/gone.ts'], unresolved: [], builtin: [] }]) }) }) describe('「了解一块代码」对话页上的仓库链接按这个功能找代码用的工作区文件夹算', () => { /** 功能对话页:路径是问的那句话,不在磁盘上,也不在哪个 git 仓库里 */ const FEATURE_PAGE = { scheme: 'code-reader-path', query: 'feature=f1', path: '/用户如何聊天', fsPath: '/用户如何聊天', toString: () => 'code-reader-path:/用户如何聊天?feature=f1' } const WEB = { name: 'web', fsPath: '/ws/web' } const SERVER = { name: 'server', fsPath: '/srv/app' } async function featurePage(folders: { name: string, fsPath: string }[]) { Object.assign(workspace, { workspaceFolders: folders.map(({ name, fsPath }) => ({ name, uri: { fsPath } })) }) vi.mocked(commands.executeCommand).mockClear() vi.mocked(workspace.openTextDocument).mockImplementation((async (uri: { fsPath: string }) => ({ uri, languageId: 'python', lineCount: 20, lineAt: () => ({ text: '' }), positionAt: (offset: number) => ({ line: 0, character: offset }) })) as never) const { provider, document } = opening() Object.assign(document, { uri: FEATURE_PAGE, fileName: FEATURE_PAGE.fsPath, languageId: 'plaintext', getText: () => '' }) const page = tab() await provider.resolveCustomTextEditor(document as never, page.panel) await page.send({ type: 'ready' }) page.clear() return page } function reset(): void { Object.assign(workspace, { workspaceFolders: undefined }) vi.mocked(workspace.openTextDocument).mockReset() } it('几个工作区文件夹:路径第一段是文件夹名,按那个文件夹查哪些不在;不再全判成找不到', async () => { try { const page = await featurePage([WEB, SERVER]) await page.send({ type: 'checkRepoLinks', id: 1, paths: ['web/src/app/(main)/page.tsx', 'server/app/routes.py', 'server/app/gone.py', 'other/a.py'], references: [] }) expect(page.posted()).toEqual([{ type: 'repoLinksChecked', id: 1, missing: ['server/app/gone.py', 'other/a.py'], unresolved: [], builtin: [] }]) } finally { reset() } }) it('只有一个工作区文件夹:路径相对它,带文件夹名的反倒不认', async () => { try { const page = await featurePage([WEB]) await page.send({ type: 'checkRepoLinks', id: 1, paths: ['src/app/(main)/page.tsx', 'web/src/app/(main)/page.tsx'], references: [] }) expect(page.posted()).toEqual([{ type: 'repoLinksChecked', id: 1, missing: ['web/src/app/(main)/page.tsx'], unresolved: [], builtin: [] }]) } finally { reset() } }) it('页面装好之前就放行这个功能找代码用的各个工作区文件夹', async () => { try { const page = await featurePage([WEB, SERVER]) expect(readableRoots(page)).toEqual(expect.arrayContaining(['/ws/web', '/srv/app'])) } finally { reset() } }) it('点链接按同一个算法打开文件;带符号的点了、悬停都在那个文件的文档符号里找', async () => { try { const page = await featurePage([WEB, SERVER]) await page.send({ type: 'openRepoLink', path: 'server/app/routes.py' }) expect(commands.executeCommand).toHaveBeenCalledWith('vscode.open', expect.objectContaining({ fsPath: '/srv/app/app/routes.py' }), expect.anything()) expect(page.posted()).toEqual([{ type: 'repoLinkOpened', path: 'server/app/routes.py' }]) vi.mocked(documentSymbolNodes).mockReset().mockResolvedValue([ { name: 'HomePage', range: { startIndex: 0, endIndex: 40 }, selection: { startIndex: 16, endIndex: 24 }, children: [] }, ]) page.clear() await page.send({ type: 'peekLink', id: 2, path: 'web/src/app/(main)/page.tsx', symbol: 'HomePage' }) expect(documentSymbolNodes).toHaveBeenCalledWith('file:///ws/web/src/app/(main)/page.tsx') await page.send({ type: 'openRepoLink', path: 'web/src/app/(main)/page.tsx', symbol: 'HomePage' }) expect(page.posted()).toContainEqual({ type: 'repoLinkOpened', path: 'web/src/app/(main)/page.tsx', symbol: 'HomePage' }) await page.send({ type: 'openRepoLink', path: 'web/src/app/(main)/page.tsx', symbol: 'Gone' }) expect(page.posted()).toContainEqual({ type: 'repoLinkBroken', path: 'web/src/app/(main)/page.tsx', symbol: 'Gone' }) } finally { vi.mocked(documentSymbolNodes).mockReset().mockResolvedValue([]) reset() } }) }) describe('预览里的查找栏', () => { const sent = (page: ReturnType): ExtensionToWebview[] => page.posted().filter(msg => msg.type === 'openFind' || msg.type === 'moveFind') it('「查找」发给活动的预览;「查找下一处 / 上一处」发给查找栏开着、有焦点的那个,都没有时发给活动的;还没 ready 的不发', async () => { const { provider, document } = opening() const first = tab() const second = tab() await provider.open(document.uri as never, {}, async () => provider.resolveCustomTextEditor(document as never, first.panel)) await provider.open(document.uri as never, {}, async () => provider.resolveCustomTextEditor(document as never, second.panel)) provider.find() expect([...sent(first), ...sent(second)]).toEqual([]) await first.send({ type: 'ready' }) await second.send({ type: 'ready' }) first.hide() provider.find() expect(sent(first)).toEqual([]) expect(sent(second)).toEqual([{ type: 'openFind' }]) second.clear() await first.send({ type: 'findNavigation', available: true }) provider.moveFind(1) expect(sent(first)).toEqual([{ type: 'moveFind', step: 1 }]) expect(sent(second)).toEqual([]) await first.send({ type: 'findNavigation', available: false }) provider.moveFind(-1) expect(sent(second)).toEqual([{ type: 'moveFind', step: -1 }]) }) it('页面报来查找栏开着、有焦点:设上下文键,⌘G 这时才归查找栏;变了才设', async () => { const { provider, document } = opening() const page = tab() await provider.open(document.uri as never, {}, async () => provider.resolveCustomTextEditor(document as never, page.panel)) vi.mocked(commands.executeCommand).mockClear() await page.send({ type: 'findNavigation', available: true }) await page.send({ type: 'findNavigation', available: true }) await page.send({ type: 'findNavigation', available: false }) expect(vi.mocked(commands.executeCommand).mock.calls.filter(([command, key]) => command === 'setContext' && key === FIND_CONTEXT)) .toEqual([['setContext', FIND_CONTEXT, true], ['setContext', FIND_CONTEXT, false]]) }) })