Path: blob/main/src/vs/workbench/contrib/chat/test/browser/chatAttachmentResolveService.test.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { URI } from '../../../../../base/common/uri.js';7import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';8import { IFileService, IFileStatWithMetadata } from '../../../../../platform/files/common/files.js';9import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';10import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';11import { IEditorService } from '../../../../services/editor/common/editorService.js';12import { IExtensionService } from '../../../../services/extensions/common/extensions.js';13import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';14import { BrowserViewSharingState, IBrowserViewWorkbenchService, IBrowserViewModel } from '../../../browserView/common/browserView.js';15import { BrowserEditorInput } from '../../../browserView/common/browserEditorInput.js';16import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js';17import { ChatAttachmentResolveService } from '../../browser/attachments/chatAttachmentResolveService.js';18import { createFileStat } from '../../../../test/common/workbenchTestServices.js';19import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js';2021suite('ChatAttachmentResolveService', () => {22const testDisposables = ensureNoDisposablesAreLeakedInTestSuite();2324let instantiationService: TestInstantiationService;25let service: ChatAttachmentResolveService;2627/**28* Map from directory URI string to children, simulating a file tree.29* Populated per-test to control the mock directory structure.30*/31let directoryTree: Map<string, { resource: URI; isFile: boolean; isDirectory: boolean }[]>;3233/**34* Set of file URI strings that should be treated as valid images35* by the mocked resolveImageEditorAttachContext.36*/37let imageFileUris: Set<string>;3839setup(() => {40instantiationService = testDisposables.add(new TestInstantiationService());41directoryTree = new Map();42imageFileUris = new Set();4344// Stub IFileService with resolve() that uses the directoryTree map45instantiationService.stub(IFileService, {46resolve: async (resource: URI): Promise<IFileStatWithMetadata> => {47const children = directoryTree.get(resource.toString());48if (children !== undefined) {49return createFileStat(resource, false, false, true, false, children);50}51// Treat as a file52return createFileStat(resource, false, true, false);53}54});5556instantiationService.stub(IEditorService, {});57instantiationService.stub(ITextModelService, {});58instantiationService.stub(IExtensionService, {});59instantiationService.stub(IDialogService, {});60instantiationService.stub(IBrowserViewWorkbenchService, { getKnownBrowserViews: () => new Map() });6162service = instantiationService.createInstance(ChatAttachmentResolveService);6364// Override resolveImageEditorAttachContext to avoid DOM dependencies (canvas, Image, etc.)65// and return a predictable image entry for files in the imageFileUris set.66service.resolveImageEditorAttachContext = async (resource: URI): Promise<IChatRequestVariableEntry | undefined> => {67if (imageFileUris.has(resource.toString())) {68return {69id: resource.toString(),70name: resource.path.split('/').pop()!,71value: new Uint8Array([1, 2, 3]),72kind: 'image',73};74}75return undefined;76};77});7879test('returns empty array for empty directory', async () => {80const dirUri = URI.file('/test/empty-dir');81directoryTree.set(dirUri.toString(), []);8283const result = await service.resolveDirectoryImages(dirUri);84assert.deepStrictEqual(result, []);85});8687test('returns image entries for image files in directory', async () => {88const dirUri = URI.file('/test/images-dir');89const pngUri = URI.file('/test/images-dir/photo.png');90const jpgUri = URI.file('/test/images-dir/photo.jpg');91const txtUri = URI.file('/test/images-dir/readme.txt');9293directoryTree.set(dirUri.toString(), [94{ resource: pngUri, isFile: true, isDirectory: false },95{ resource: jpgUri, isFile: true, isDirectory: false },96{ resource: txtUri, isFile: true, isDirectory: false },97]);98imageFileUris.add(pngUri.toString());99imageFileUris.add(jpgUri.toString());100101const result = await service.resolveDirectoryImages(dirUri);102assert.strictEqual(result.length, 2);103assert.ok(result.every(e => e.kind === 'image'));104const names = result.map(e => e.name).sort();105assert.deepStrictEqual(names, ['photo.jpg', 'photo.png']);106});107108test('ignores non-image files', async () => {109const dirUri = URI.file('/test/text-dir');110const txtUri = URI.file('/test/text-dir/file.txt');111const tsUri = URI.file('/test/text-dir/index.ts');112113directoryTree.set(dirUri.toString(), [114{ resource: txtUri, isFile: true, isDirectory: false },115{ resource: tsUri, isFile: true, isDirectory: false },116]);117118const result = await service.resolveDirectoryImages(dirUri);119assert.deepStrictEqual(result, []);120});121122test('recursively discovers images in subdirectories', async () => {123const rootUri = URI.file('/test/root');124const subDirUri = URI.file('/test/root/subdir');125const deepDirUri = URI.file('/test/root/subdir/deep');126127const rootPng = URI.file('/test/root/logo.png');128const subPng = URI.file('/test/root/subdir/banner.webp');129const deepJpg = URI.file('/test/root/subdir/deep/photo.jpeg');130const deepTxt = URI.file('/test/root/subdir/deep/notes.txt');131132directoryTree.set(rootUri.toString(), [133{ resource: rootPng, isFile: true, isDirectory: false },134{ resource: subDirUri, isFile: false, isDirectory: true },135]);136directoryTree.set(subDirUri.toString(), [137{ resource: subPng, isFile: true, isDirectory: false },138{ resource: deepDirUri, isFile: false, isDirectory: true },139]);140directoryTree.set(deepDirUri.toString(), [141{ resource: deepJpg, isFile: true, isDirectory: false },142{ resource: deepTxt, isFile: true, isDirectory: false },143]);144145imageFileUris.add(rootPng.toString());146imageFileUris.add(subPng.toString());147imageFileUris.add(deepJpg.toString());148149const result = await service.resolveDirectoryImages(rootUri);150assert.strictEqual(result.length, 3);151assert.ok(result.every(e => e.kind === 'image'));152const names = result.map(e => e.name).sort();153assert.deepStrictEqual(names, ['banner.webp', 'logo.png', 'photo.jpeg']);154});155156test('handles unreadable directory gracefully', async () => {157const dirUri = URI.file('/test/unreadable');158// Override resolve to throw for this URI159instantiationService.stub(IFileService, {160resolve: async (resource: URI): Promise<IFileStatWithMetadata> => {161if (resource.toString() === dirUri.toString()) {162throw new Error('Permission denied');163}164return createFileStat(resource, false, true, false);165}166});167// Re-create service with the new stub168service = instantiationService.createInstance(ChatAttachmentResolveService);169service.resolveImageEditorAttachContext = async (resource: URI): Promise<IChatRequestVariableEntry | undefined> => {170if (imageFileUris.has(resource.toString())) {171return {172id: resource.toString(),173name: resource.path.split('/').pop()!,174value: new Uint8Array([1, 2, 3]),175kind: 'image',176};177}178return undefined;179};180181const result = await service.resolveDirectoryImages(dirUri);182assert.deepStrictEqual(result, []);183});184185test('handles mixed directory with images and non-images', async () => {186const dirUri = URI.file('/test/mixed');187const gifUri = URI.file('/test/mixed/animation.gif');188const jsUri = URI.file('/test/mixed/script.js');189const bmpUri = URI.file('/test/mixed/icon.bmp');190191directoryTree.set(dirUri.toString(), [192{ resource: gifUri, isFile: true, isDirectory: false },193{ resource: jsUri, isFile: true, isDirectory: false },194{ resource: bmpUri, isFile: true, isDirectory: false },195]);196imageFileUris.add(gifUri.toString());197imageFileUris.add(bmpUri.toString());198// bmp is NOT in CHAT_ATTACHABLE_IMAGE_MIME_TYPES (only png, jpg, jpeg, gif, webp)199// so it should be skipped by the regex even though it would resolve successfully200201const result = await service.resolveDirectoryImages(dirUri);202assert.strictEqual(result.length, 1);203assert.strictEqual(result[0].name, 'animation.gif');204});205});206207suite('ChatAttachmentResolveService - resolveBrowserViewAttachContext', () => {208const testDisposables = ensureNoDisposablesAreLeakedInTestSuite();209210let instantiationService: TestInstantiationService;211let service: ChatAttachmentResolveService;212let browserViews: Map<string, Partial<BrowserEditorInput>>;213214setup(() => {215instantiationService = testDisposables.add(new TestInstantiationService());216browserViews = new Map();217218instantiationService.stub(IFileService, {219resolve: async (resource: URI) => createFileStat(resource, false, true, false),220});221instantiationService.stub(IEditorService, {});222instantiationService.stub(ITextModelService, {});223instantiationService.stub(IExtensionService, {});224instantiationService.stub(IDialogService, {});225instantiationService.stub(IBrowserViewWorkbenchService, {226getKnownBrowserViews: () => browserViews as Map<string, BrowserEditorInput>,227});228229service = instantiationService.createInstance(ChatAttachmentResolveService);230});231232function makeMockEditor(id: string, opts: { sharingState: BrowserViewSharingState; setSharedResult?: boolean }): Partial<BrowserEditorInput> {233const resource = BrowserViewUri.forId(id);234const model: Partial<IBrowserViewModel> = {235sharingState: opts.sharingState,236setSharedWithAgent: async () => opts.setSharedResult ?? true,237};238return {239id,240resource,241model: model as IBrowserViewModel,242getName: () => `Page ${id}`,243getTitle: () => `Title ${id}`,244resolve: async () => model as IBrowserViewModel,245};246}247248test('returns undefined for unknown browser id', async () => {249const result = await service.resolveBrowserViewAttachContext('nonexistent');250assert.strictEqual(result, undefined);251});252253test('returns entry when already shared', async () => {254const editor = makeMockEditor('b1', { sharingState: BrowserViewSharingState.Shared });255browserViews.set('b1', editor);256257const result = await service.resolveBrowserViewAttachContext('b1');258assert.ok(result);259assert.strictEqual(result.kind, 'browserView');260assert.strictEqual(result.browserId, 'b1');261assert.strictEqual(result.name, 'Page b1');262});263264test('prompts for sharing when NotShared and user accepts', async () => {265const editor = makeMockEditor('b2', { sharingState: BrowserViewSharingState.NotShared, setSharedResult: true });266browserViews.set('b2', editor);267268const result = await service.resolveBrowserViewAttachContext('b2');269assert.ok(result);270assert.strictEqual(result.kind, 'browserView');271assert.strictEqual(result.browserId, 'b2');272});273274test('returns undefined when NotShared and user denies', async () => {275const editor = makeMockEditor('b3', { sharingState: BrowserViewSharingState.NotShared, setSharedResult: false });276browserViews.set('b3', editor);277278const result = await service.resolveBrowserViewAttachContext('b3');279assert.strictEqual(result, undefined);280});281282test('resolves model if not yet resolved', async () => {283const resource = BrowserViewUri.forId('b4');284const model: Partial<IBrowserViewModel> = {285sharingState: BrowserViewSharingState.Shared,286setSharedWithAgent: async () => true,287};288let resolved = false;289const editor: Partial<BrowserEditorInput> = {290id: 'b4',291resource,292model: undefined, // model not yet resolved293getName: () => 'Unresolved Page',294getTitle: () => 'Unresolved Title',295resolve: async () => {296resolved = true;297(editor as Partial<BrowserEditorInput>).model = model as IBrowserViewModel;298return model as IBrowserViewModel;299},300};301browserViews.set('b4', editor);302303const result = await service.resolveBrowserViewAttachContext('b4');304assert.ok(resolved, 'resolve() should have been called');305assert.ok(result);306assert.strictEqual(result.kind, 'browserView');307});308});309310311