Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/test/browser/chatAttachmentResolveService.test.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { URI } from '../../../../../base/common/uri.js';
8
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
9
import { IFileService, IFileStatWithMetadata } from '../../../../../platform/files/common/files.js';
10
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
11
import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';
12
import { IEditorService } from '../../../../services/editor/common/editorService.js';
13
import { IExtensionService } from '../../../../services/extensions/common/extensions.js';
14
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
15
import { BrowserViewSharingState, IBrowserViewWorkbenchService, IBrowserViewModel } from '../../../browserView/common/browserView.js';
16
import { BrowserEditorInput } from '../../../browserView/common/browserEditorInput.js';
17
import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js';
18
import { ChatAttachmentResolveService } from '../../browser/attachments/chatAttachmentResolveService.js';
19
import { createFileStat } from '../../../../test/common/workbenchTestServices.js';
20
import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js';
21
22
suite('ChatAttachmentResolveService', () => {
23
const testDisposables = ensureNoDisposablesAreLeakedInTestSuite();
24
25
let instantiationService: TestInstantiationService;
26
let service: ChatAttachmentResolveService;
27
28
/**
29
* Map from directory URI string to children, simulating a file tree.
30
* Populated per-test to control the mock directory structure.
31
*/
32
let directoryTree: Map<string, { resource: URI; isFile: boolean; isDirectory: boolean }[]>;
33
34
/**
35
* Set of file URI strings that should be treated as valid images
36
* by the mocked resolveImageEditorAttachContext.
37
*/
38
let imageFileUris: Set<string>;
39
40
setup(() => {
41
instantiationService = testDisposables.add(new TestInstantiationService());
42
directoryTree = new Map();
43
imageFileUris = new Set();
44
45
// Stub IFileService with resolve() that uses the directoryTree map
46
instantiationService.stub(IFileService, {
47
resolve: async (resource: URI): Promise<IFileStatWithMetadata> => {
48
const children = directoryTree.get(resource.toString());
49
if (children !== undefined) {
50
return createFileStat(resource, false, false, true, false, children);
51
}
52
// Treat as a file
53
return createFileStat(resource, false, true, false);
54
}
55
});
56
57
instantiationService.stub(IEditorService, {});
58
instantiationService.stub(ITextModelService, {});
59
instantiationService.stub(IExtensionService, {});
60
instantiationService.stub(IDialogService, {});
61
instantiationService.stub(IBrowserViewWorkbenchService, { getKnownBrowserViews: () => new Map() });
62
63
service = instantiationService.createInstance(ChatAttachmentResolveService);
64
65
// Override resolveImageEditorAttachContext to avoid DOM dependencies (canvas, Image, etc.)
66
// and return a predictable image entry for files in the imageFileUris set.
67
service.resolveImageEditorAttachContext = async (resource: URI): Promise<IChatRequestVariableEntry | undefined> => {
68
if (imageFileUris.has(resource.toString())) {
69
return {
70
id: resource.toString(),
71
name: resource.path.split('/').pop()!,
72
value: new Uint8Array([1, 2, 3]),
73
kind: 'image',
74
};
75
}
76
return undefined;
77
};
78
});
79
80
test('returns empty array for empty directory', async () => {
81
const dirUri = URI.file('/test/empty-dir');
82
directoryTree.set(dirUri.toString(), []);
83
84
const result = await service.resolveDirectoryImages(dirUri);
85
assert.deepStrictEqual(result, []);
86
});
87
88
test('returns image entries for image files in directory', async () => {
89
const dirUri = URI.file('/test/images-dir');
90
const pngUri = URI.file('/test/images-dir/photo.png');
91
const jpgUri = URI.file('/test/images-dir/photo.jpg');
92
const txtUri = URI.file('/test/images-dir/readme.txt');
93
94
directoryTree.set(dirUri.toString(), [
95
{ resource: pngUri, isFile: true, isDirectory: false },
96
{ resource: jpgUri, isFile: true, isDirectory: false },
97
{ resource: txtUri, isFile: true, isDirectory: false },
98
]);
99
imageFileUris.add(pngUri.toString());
100
imageFileUris.add(jpgUri.toString());
101
102
const result = await service.resolveDirectoryImages(dirUri);
103
assert.strictEqual(result.length, 2);
104
assert.ok(result.every(e => e.kind === 'image'));
105
const names = result.map(e => e.name).sort();
106
assert.deepStrictEqual(names, ['photo.jpg', 'photo.png']);
107
});
108
109
test('ignores non-image files', async () => {
110
const dirUri = URI.file('/test/text-dir');
111
const txtUri = URI.file('/test/text-dir/file.txt');
112
const tsUri = URI.file('/test/text-dir/index.ts');
113
114
directoryTree.set(dirUri.toString(), [
115
{ resource: txtUri, isFile: true, isDirectory: false },
116
{ resource: tsUri, isFile: true, isDirectory: false },
117
]);
118
119
const result = await service.resolveDirectoryImages(dirUri);
120
assert.deepStrictEqual(result, []);
121
});
122
123
test('recursively discovers images in subdirectories', async () => {
124
const rootUri = URI.file('/test/root');
125
const subDirUri = URI.file('/test/root/subdir');
126
const deepDirUri = URI.file('/test/root/subdir/deep');
127
128
const rootPng = URI.file('/test/root/logo.png');
129
const subPng = URI.file('/test/root/subdir/banner.webp');
130
const deepJpg = URI.file('/test/root/subdir/deep/photo.jpeg');
131
const deepTxt = URI.file('/test/root/subdir/deep/notes.txt');
132
133
directoryTree.set(rootUri.toString(), [
134
{ resource: rootPng, isFile: true, isDirectory: false },
135
{ resource: subDirUri, isFile: false, isDirectory: true },
136
]);
137
directoryTree.set(subDirUri.toString(), [
138
{ resource: subPng, isFile: true, isDirectory: false },
139
{ resource: deepDirUri, isFile: false, isDirectory: true },
140
]);
141
directoryTree.set(deepDirUri.toString(), [
142
{ resource: deepJpg, isFile: true, isDirectory: false },
143
{ resource: deepTxt, isFile: true, isDirectory: false },
144
]);
145
146
imageFileUris.add(rootPng.toString());
147
imageFileUris.add(subPng.toString());
148
imageFileUris.add(deepJpg.toString());
149
150
const result = await service.resolveDirectoryImages(rootUri);
151
assert.strictEqual(result.length, 3);
152
assert.ok(result.every(e => e.kind === 'image'));
153
const names = result.map(e => e.name).sort();
154
assert.deepStrictEqual(names, ['banner.webp', 'logo.png', 'photo.jpeg']);
155
});
156
157
test('handles unreadable directory gracefully', async () => {
158
const dirUri = URI.file('/test/unreadable');
159
// Override resolve to throw for this URI
160
instantiationService.stub(IFileService, {
161
resolve: async (resource: URI): Promise<IFileStatWithMetadata> => {
162
if (resource.toString() === dirUri.toString()) {
163
throw new Error('Permission denied');
164
}
165
return createFileStat(resource, false, true, false);
166
}
167
});
168
// Re-create service with the new stub
169
service = instantiationService.createInstance(ChatAttachmentResolveService);
170
service.resolveImageEditorAttachContext = async (resource: URI): Promise<IChatRequestVariableEntry | undefined> => {
171
if (imageFileUris.has(resource.toString())) {
172
return {
173
id: resource.toString(),
174
name: resource.path.split('/').pop()!,
175
value: new Uint8Array([1, 2, 3]),
176
kind: 'image',
177
};
178
}
179
return undefined;
180
};
181
182
const result = await service.resolveDirectoryImages(dirUri);
183
assert.deepStrictEqual(result, []);
184
});
185
186
test('handles mixed directory with images and non-images', async () => {
187
const dirUri = URI.file('/test/mixed');
188
const gifUri = URI.file('/test/mixed/animation.gif');
189
const jsUri = URI.file('/test/mixed/script.js');
190
const bmpUri = URI.file('/test/mixed/icon.bmp');
191
192
directoryTree.set(dirUri.toString(), [
193
{ resource: gifUri, isFile: true, isDirectory: false },
194
{ resource: jsUri, isFile: true, isDirectory: false },
195
{ resource: bmpUri, isFile: true, isDirectory: false },
196
]);
197
imageFileUris.add(gifUri.toString());
198
imageFileUris.add(bmpUri.toString());
199
// bmp is NOT in CHAT_ATTACHABLE_IMAGE_MIME_TYPES (only png, jpg, jpeg, gif, webp)
200
// so it should be skipped by the regex even though it would resolve successfully
201
202
const result = await service.resolveDirectoryImages(dirUri);
203
assert.strictEqual(result.length, 1);
204
assert.strictEqual(result[0].name, 'animation.gif');
205
});
206
});
207
208
suite('ChatAttachmentResolveService - resolveBrowserViewAttachContext', () => {
209
const testDisposables = ensureNoDisposablesAreLeakedInTestSuite();
210
211
let instantiationService: TestInstantiationService;
212
let service: ChatAttachmentResolveService;
213
let browserViews: Map<string, Partial<BrowserEditorInput>>;
214
215
setup(() => {
216
instantiationService = testDisposables.add(new TestInstantiationService());
217
browserViews = new Map();
218
219
instantiationService.stub(IFileService, {
220
resolve: async (resource: URI) => createFileStat(resource, false, true, false),
221
});
222
instantiationService.stub(IEditorService, {});
223
instantiationService.stub(ITextModelService, {});
224
instantiationService.stub(IExtensionService, {});
225
instantiationService.stub(IDialogService, {});
226
instantiationService.stub(IBrowserViewWorkbenchService, {
227
getKnownBrowserViews: () => browserViews as Map<string, BrowserEditorInput>,
228
});
229
230
service = instantiationService.createInstance(ChatAttachmentResolveService);
231
});
232
233
function makeMockEditor(id: string, opts: { sharingState: BrowserViewSharingState; setSharedResult?: boolean }): Partial<BrowserEditorInput> {
234
const resource = BrowserViewUri.forId(id);
235
const model: Partial<IBrowserViewModel> = {
236
sharingState: opts.sharingState,
237
setSharedWithAgent: async () => opts.setSharedResult ?? true,
238
};
239
return {
240
id,
241
resource,
242
model: model as IBrowserViewModel,
243
getName: () => `Page ${id}`,
244
getTitle: () => `Title ${id}`,
245
resolve: async () => model as IBrowserViewModel,
246
};
247
}
248
249
test('returns undefined for unknown browser id', async () => {
250
const result = await service.resolveBrowserViewAttachContext('nonexistent');
251
assert.strictEqual(result, undefined);
252
});
253
254
test('returns entry when already shared', async () => {
255
const editor = makeMockEditor('b1', { sharingState: BrowserViewSharingState.Shared });
256
browserViews.set('b1', editor);
257
258
const result = await service.resolveBrowserViewAttachContext('b1');
259
assert.ok(result);
260
assert.strictEqual(result.kind, 'browserView');
261
assert.strictEqual(result.browserId, 'b1');
262
assert.strictEqual(result.name, 'Page b1');
263
});
264
265
test('prompts for sharing when NotShared and user accepts', async () => {
266
const editor = makeMockEditor('b2', { sharingState: BrowserViewSharingState.NotShared, setSharedResult: true });
267
browserViews.set('b2', editor);
268
269
const result = await service.resolveBrowserViewAttachContext('b2');
270
assert.ok(result);
271
assert.strictEqual(result.kind, 'browserView');
272
assert.strictEqual(result.browserId, 'b2');
273
});
274
275
test('returns undefined when NotShared and user denies', async () => {
276
const editor = makeMockEditor('b3', { sharingState: BrowserViewSharingState.NotShared, setSharedResult: false });
277
browserViews.set('b3', editor);
278
279
const result = await service.resolveBrowserViewAttachContext('b3');
280
assert.strictEqual(result, undefined);
281
});
282
283
test('resolves model if not yet resolved', async () => {
284
const resource = BrowserViewUri.forId('b4');
285
const model: Partial<IBrowserViewModel> = {
286
sharingState: BrowserViewSharingState.Shared,
287
setSharedWithAgent: async () => true,
288
};
289
let resolved = false;
290
const editor: Partial<BrowserEditorInput> = {
291
id: 'b4',
292
resource,
293
model: undefined, // model not yet resolved
294
getName: () => 'Unresolved Page',
295
getTitle: () => 'Unresolved Title',
296
resolve: async () => {
297
resolved = true;
298
(editor as Partial<BrowserEditorInput>).model = model as IBrowserViewModel;
299
return model as IBrowserViewModel;
300
},
301
};
302
browserViews.set('b4', editor);
303
304
const result = await service.resolveBrowserViewAttachContext('b4');
305
assert.ok(resolved, 'resolve() should have been called');
306
assert.ok(result);
307
assert.strictEqual(result.kind, 'browserView');
308
});
309
});
310
311