Path: blob/main/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.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 { VSBuffer } from '../../../../../base/common/buffer.js';8import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';9import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';10import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js';11import { NullFilesConfigurationService, createFileStat } from '../../../../test/common/workbenchTestServices.js';12import { IExplorerService } from '../../../files/browser/files.js';13import { ExplorerItem } from '../../../files/common/explorerModel.js';14import { IFileService, IFileStat, IFileContent } from '../../../../../platform/files/common/files.js';15import { IEditorService, MODAL_GROUP } from '../../../../services/editor/common/editorService.js';16import { ImageCarouselEditorInput } from '../../browser/imageCarouselEditorInput.js';17import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';18import { INotificationService } from '../../../../../platform/notification/common/notification.js';19import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';2021// Importing the contribution registers the actions22import '../../browser/imageCarousel.contribution.js';2324function createExplorerItem(25path: string,26isFolder: boolean,27fileService: IFileService,28configService: TestConfigurationService,29parent?: ExplorerItem,30): ExplorerItem {31return new ExplorerItem(32URI.file(path),33fileService,34configService,35NullFilesConfigurationService,36parent,37isFolder,38);39}4041suite('OpenImagesInCarouselFromExplorerAction', () => {42const disposables = ensureNoDisposablesAreLeakedInTestSuite();4344let instantiationService: TestInstantiationService;45let configService: TestConfigurationService;46let openedInputs: { input: ImageCarouselEditorInput; group: typeof MODAL_GROUP }[];47let infoMessages: string[];48let errorMessages: string[];4950setup(() => {51openedInputs = [];52infoMessages = [];53errorMessages = [];54configService = new TestConfigurationService();55instantiationService = workbenchInstantiationService(undefined, disposables);56});5758function stubFileService(resolveMap: Map<string, IFileStat>, fileContents: Map<string, VSBuffer>): void {59instantiationService.stub(IFileService, 'resolve', async (resource: URI) => {60const stat = resolveMap.get(resource.path);61if (!stat) {62throw new Error(`File not found: ${resource.path}`);63}64return stat;65});6667instantiationService.stub(IFileService, 'readFile', async (resource: URI) => {68const content = fileContents.get(resource.path);69if (!content) {70throw new Error(`Cannot read: ${resource.path}`);71}72return { resource, value: content } as IFileContent;73});74}7576function stubExplorerService(items: ExplorerItem[]): void {77instantiationService.stub(IExplorerService, {78getContext: () => items,79});80}8182function stubEditorService(): void {83instantiationService.stub(IEditorService, 'openEditor', async (input: unknown, _options: unknown, group: unknown) => {84if (input instanceof ImageCarouselEditorInput) {85openedInputs.push({ input, group: group as typeof MODAL_GROUP });86disposables.add(input);87}88return undefined;89});90}9192function stubNotificationService(): void {93instantiationService.stub(INotificationService, 'info', (message: string) => {94infoMessages.push(message);95});96instantiationService.stub(INotificationService, 'error', (message: string) => {97errorMessages.push(message);98});99}100101test('single image file opens carousel with sibling images', async () => {102const fileService = instantiationService.get(IFileService);103const parent = createExplorerItem('/workspace/images', true, fileService, configService);104const imageItem = createExplorerItem('/workspace/images/photo.png', false, fileService, configService, parent);105106const pngData = VSBuffer.fromString('fake-png');107const jpgData = VSBuffer.fromString('fake-jpg');108const txtData = VSBuffer.fromString('text file');109110const resolveMap = new Map<string, IFileStat>();111resolveMap.set('/workspace/images', createFileStat(112URI.file('/workspace/images'), false, false, true, false, [113{ resource: URI.file('/workspace/images/photo.png'), isFile: true },114{ resource: URI.file('/workspace/images/other.jpg'), isFile: true },115{ resource: URI.file('/workspace/images/readme.txt'), isFile: true },116{ resource: URI.file('/workspace/images/subfolder'), isDirectory: true, isFile: false },117]118));119120const fileContents = new Map<string, VSBuffer>();121fileContents.set('/workspace/images/photo.png', pngData);122fileContents.set('/workspace/images/other.jpg', jpgData);123fileContents.set('/workspace/images/readme.txt', txtData);124125stubFileService(resolveMap, fileContents);126stubExplorerService([imageItem]);127stubEditorService();128129const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js');130const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel');131assert.ok(command, 'Command should be registered');132133await instantiationService.invokeFunction(command.handler);134135assert.strictEqual(openedInputs.length, 1, 'Should open one editor');136const input = openedInputs[0].input;137assert.strictEqual(input.collection.sections.length, 1);138139const images = input.collection.sections[0].images;140assert.strictEqual(images.length, 2, 'Should include 2 image siblings (png + jpg), not txt');141// Images are sorted by basename: other.jpg before photo.png142assert.strictEqual(images[0].name, 'other.jpg');143assert.strictEqual(images[1].name, 'photo.png');144145// Start index should be the selected image (photo.png = index 1 after sorting)146assert.strictEqual(input.startIndex, 1);147});148149test('folder opens carousel with all contained images', async () => {150const fileService = instantiationService.get(IFileService);151const folderItem = createExplorerItem('/workspace/images', true, fileService, configService);152153const gifData = VSBuffer.fromString('fake-gif');154const webpData = VSBuffer.fromString('fake-webp');155156const resolveMap = new Map<string, IFileStat>();157resolveMap.set('/workspace/images', createFileStat(158URI.file('/workspace/images'), false, false, true, false, [159{ resource: URI.file('/workspace/images/anim.gif'), isFile: true },160{ resource: URI.file('/workspace/images/photo.webp'), isFile: true },161{ resource: URI.file('/workspace/images/script.js'), isFile: true },162]163));164165const fileContents = new Map<string, VSBuffer>();166fileContents.set('/workspace/images/anim.gif', gifData);167fileContents.set('/workspace/images/photo.webp', webpData);168169stubFileService(resolveMap, fileContents);170stubExplorerService([folderItem]);171stubEditorService();172173const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js');174const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel');175assert.ok(command);176177await instantiationService.invokeFunction(command.handler);178179assert.strictEqual(openedInputs.length, 1);180const images = openedInputs[0].input.collection.sections[0].images;181assert.strictEqual(images.length, 2, 'Should include 2 images (gif + webp), not js');182assert.strictEqual(images[0].name, 'anim.gif');183assert.strictEqual(images[1].name, 'photo.webp');184});185186test('multiple selected images open in carousel', async () => {187const fileService = instantiationService.get(IFileService);188const img1 = createExplorerItem('/workspace/a.png', false, fileService, configService);189const img2 = createExplorerItem('/workspace/b.svg', false, fileService, configService);190const txtFile = createExplorerItem('/workspace/notes.txt', false, fileService, configService);191192const pngData = VSBuffer.fromString('fake-png');193const svgData = VSBuffer.fromString('<svg></svg>');194195const resolveMap = new Map<string, IFileStat>();196197const fileContents = new Map<string, VSBuffer>();198fileContents.set('/workspace/a.png', pngData);199fileContents.set('/workspace/b.svg', svgData);200201stubFileService(resolveMap, fileContents);202stubExplorerService([img1, img2, txtFile]);203stubEditorService();204205const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js');206const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel');207assert.ok(command);208209await instantiationService.invokeFunction(command.handler);210211assert.strictEqual(openedInputs.length, 1);212const images = openedInputs[0].input.collection.sections[0].images;213assert.strictEqual(images.length, 2, 'Should include only image files');214assert.strictEqual(images[0].name, 'a.png');215assert.strictEqual(images[1].name, 'b.svg');216});217218test('empty selection with resource argument opens carousel from that folder', async () => {219const pngData = VSBuffer.fromString('fake-png');220const jpgData = VSBuffer.fromString('fake-jpg');221222const folderUri = URI.file('/workspace/photos');223const resolveMap = new Map<string, IFileStat>();224resolveMap.set('/workspace/photos', createFileStat(225folderUri, false, false, true, false, [226{ resource: URI.file('/workspace/photos/sunset.png'), isFile: true },227{ resource: URI.file('/workspace/photos/mountain.jpg'), isFile: true },228{ resource: URI.file('/workspace/photos/notes.txt'), isFile: true },229]230));231232const fileContents = new Map<string, VSBuffer>();233fileContents.set('/workspace/photos/sunset.png', pngData);234fileContents.set('/workspace/photos/mountain.jpg', jpgData);235236stubFileService(resolveMap, fileContents);237stubExplorerService([]);238stubEditorService();239240const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js');241const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel');242assert.ok(command);243244// Pass the folder URI as the resource argument (as explorer does for empty-space click)245await instantiationService.invokeFunction(command.handler, folderUri);246247assert.strictEqual(openedInputs.length, 1, 'Should open carousel using resource argument fallback');248const images = openedInputs[0].input.collection.sections[0].images;249assert.strictEqual(images.length, 2, 'Should include 2 images from the folder');250});251252test('empty selection without resource falls back to first workspace folder', async () => {253const pngData = VSBuffer.fromString('fake-png');254255// Derive the workspace root from IWorkspaceContextService so the test256// works on all platforms (the path differs on Windows vs Unix).257const contextService = instantiationService.get(IWorkspaceContextService);258const wsRoot = contextService.getWorkspace().folders[0].uri;259const logoUri = URI.joinPath(wsRoot, 'logo.png');260const readmeUri = URI.joinPath(wsRoot, 'readme.md');261262const resolveMap = new Map<string, IFileStat>();263resolveMap.set(wsRoot.path, createFileStat(264wsRoot, false, false, true, false, [265{ resource: logoUri, isFile: true },266{ resource: readmeUri, isFile: true },267]268));269270const fileContents = new Map<string, VSBuffer>();271fileContents.set(logoUri.path, pngData);272273stubFileService(resolveMap, fileContents);274stubExplorerService([]);275stubEditorService();276277const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js');278const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel');279assert.ok(command);280281// No resource argument — should fall back to workspace root282await instantiationService.invokeFunction(command.handler);283284assert.strictEqual(openedInputs.length, 1, 'Should open carousel using workspace root fallback');285const images = openedInputs[0].input.collection.sections[0].images;286assert.strictEqual(images.length, 1, 'Should include image from workspace root');287assert.strictEqual(images[0].name, 'logo.png');288});289290test('empty selection with no images shows notification', async () => {291const folderUri = URI.file('/workspace/docs');292const resolveMap = new Map<string, IFileStat>();293resolveMap.set('/workspace/docs', createFileStat(294folderUri, false, false, true, false, [295{ resource: URI.file('/workspace/docs/readme.md'), isFile: true },296]297));298299stubFileService(resolveMap, new Map());300stubExplorerService([]);301stubEditorService();302stubNotificationService();303304const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js');305const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel');306assert.ok(command);307308await instantiationService.invokeFunction(command.handler, folderUri);309310assert.strictEqual(openedInputs.length, 0, 'Should not open carousel when folder has no images');311assert.strictEqual(infoMessages.length, 1, 'Should show notification');312});313314test('folder with no images shows notification', async () => {315const fileService = instantiationService.get(IFileService);316const folderItem = createExplorerItem('/workspace/docs', true, fileService, configService);317318const resolveMap = new Map<string, IFileStat>();319resolveMap.set('/workspace/docs', createFileStat(320URI.file('/workspace/docs'), false, false, true, false, [321{ resource: URI.file('/workspace/docs/readme.md'), isFile: true },322{ resource: URI.file('/workspace/docs/notes.txt'), isFile: true },323]324));325326stubFileService(resolveMap, new Map());327stubExplorerService([folderItem]);328stubEditorService();329stubNotificationService();330331const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js');332const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel');333assert.ok(command);334335await instantiationService.invokeFunction(command.handler);336337assert.strictEqual(openedInputs.length, 0, 'Should not open carousel when folder has no images');338assert.strictEqual(infoMessages.length, 1, 'Should show notification about no images');339});340341test('folder read error shows error notification', async () => {342const fileService = instantiationService.get(IFileService);343const folderItem = createExplorerItem('/workspace/restricted', true, fileService, configService);344345// resolve throws to simulate a permission error346const resolveMap = new Map<string, IFileStat>();347stubFileService(resolveMap, new Map());348stubExplorerService([folderItem]);349stubEditorService();350stubNotificationService();351352const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js');353const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel');354assert.ok(command);355356await instantiationService.invokeFunction(command.handler);357358assert.strictEqual(openedInputs.length, 0, 'Should not open carousel on folder read error');359assert.strictEqual(errorMessages.length, 1, 'Should show error notification');360assert.strictEqual(infoMessages.length, 0, 'Should not show info notification');361});362363test('images with URIs are passed lazily without reading file contents', async () => {364const folderUri = URI.file('/workspace/broken');365366const resolveMap = new Map<string, IFileStat>();367resolveMap.set('/workspace/broken', createFileStat(368folderUri, false, false, true, false, [369{ resource: URI.file('/workspace/broken/corrupt.png'), isFile: true },370{ resource: URI.file('/workspace/broken/missing.jpg'), isFile: true },371]372));373374// No file contents — with lazy loading, no readFile should be called at action time375let readFileCallCount = 0;376stubFileService(resolveMap, new Map());377instantiationService.stub(IFileService, 'readFile', async () => {378readFileCallCount++;379throw new Error('readFile should not be called');380});381stubExplorerService([]);382stubEditorService();383stubNotificationService();384385const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js');386const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel');387assert.ok(command);388389await instantiationService.invokeFunction(command.handler, folderUri);390391assert.strictEqual(readFileCallCount, 0, 'readFile should not be called during action');392assert.strictEqual(openedInputs.length, 1, 'Should open carousel with lazy image entries');393const images = openedInputs[0].input.collection.sections[0].images;394assert.strictEqual(images.length, 2, 'Should include 2 lazy image entries');395assert.strictEqual(images[0].data, undefined, 'Image data should not be loaded eagerly');396assert.ok(images[0].uri, 'Image should have a URI for lazy loading');397});398399test('folder includes video files alongside images', async () => {400const fileService = instantiationService.get(IFileService);401const folderItem = createExplorerItem('/workspace/media', true, fileService, configService);402403const resolveMap = new Map<string, IFileStat>();404resolveMap.set('/workspace/media', createFileStat(405URI.file('/workspace/media'), false, false, true, false, [406{ resource: URI.file('/workspace/media/clip.mp4'), isFile: true },407{ resource: URI.file('/workspace/media/photo.png'), isFile: true },408{ resource: URI.file('/workspace/media/demo.webm'), isFile: true },409{ resource: URI.file('/workspace/media/intro.mov'), isFile: true },410{ resource: URI.file('/workspace/media/readme.txt'), isFile: true },411]412));413414stubFileService(resolveMap, new Map());415stubExplorerService([folderItem]);416stubEditorService();417418const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js');419const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel');420assert.ok(command);421422await instantiationService.invokeFunction(command.handler);423424assert.strictEqual(openedInputs.length, 1);425const images = openedInputs[0].input.collection.sections[0].images;426assert.strictEqual(images.length, 4, 'Should include mp4 + webm + mov + png, not txt');427assert.strictEqual(images[0].name, 'clip.mp4');428assert.strictEqual(images[1].name, 'demo.webm');429assert.strictEqual(images[2].name, 'intro.mov');430assert.strictEqual(images[3].name, 'photo.png');431});432433test('single video file opens carousel with sibling media', async () => {434const fileService = instantiationService.get(IFileService);435const parent = createExplorerItem('/workspace/media', true, fileService, configService);436const videoItem = createExplorerItem('/workspace/media/clip.mp4', false, fileService, configService, parent);437438const resolveMap = new Map<string, IFileStat>();439resolveMap.set('/workspace/media', createFileStat(440URI.file('/workspace/media'), false, false, true, false, [441{ resource: URI.file('/workspace/media/clip.mp4'), isFile: true },442{ resource: URI.file('/workspace/media/photo.png'), isFile: true },443{ resource: URI.file('/workspace/media/notes.txt'), isFile: true },444]445));446447stubFileService(resolveMap, new Map());448stubExplorerService([videoItem]);449stubEditorService();450451const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js');452const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel');453assert.ok(command);454455await instantiationService.invokeFunction(command.handler);456457assert.strictEqual(openedInputs.length, 1);458const input = openedInputs[0].input;459const images = input.collection.sections[0].images;460assert.strictEqual(images.length, 2, 'Should include mp4 + png siblings');461assert.strictEqual(images[0].name, 'clip.mp4');462assert.strictEqual(images[1].name, 'photo.png');463assert.strictEqual(input.startIndex, 0, 'Start index should point to the selected video');464});465});466467468