Path: blob/main/extensions/copilot/src/extension/agents/vscode-node/test/githubOrgChatResourcesService.spec.ts
13405 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 'chai';6import { afterEach, beforeEach, suite, test } from 'vitest';7import type { ExtensionContext } from 'vscode';8import { AGENT_FILE_EXTENSION, INSTRUCTION_FILE_EXTENSION, PromptsType } from '../../../../platform/customInstructions/common/promptTypes';9import { FileType } from '../../../../platform/filesystem/common/fileTypes';10import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService';11import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService';12import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService';13import { MockWorkspaceService } from '../../../../platform/ignore/node/test/mockWorkspaceService';14import { ILogService } from '../../../../platform/log/common/logService';15import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';16import { URI } from '../../../../util/vs/base/common/uri';17import { createExtensionUnitTestingServices } from '../../../test/node/services';18import { GitHubOrgChatResourcesService } from '../githubOrgChatResourcesService';19import { MockOctoKitService } from './mockOctoKitService';2021suite('GitHubOrgChatResourcesService', () => {22let disposables: DisposableStore;23let mockExtensionContext: Partial<ExtensionContext>;24let mockFileSystem: MockFileSystemService;25let mockGitService: MockGitService;26let mockOctoKitService: MockOctoKitService;27let mockWorkspaceService: MockWorkspaceService;28let mockAuthService: MockAuthenticationService;29let logService: ILogService;30let service: GitHubOrgChatResourcesService;3132const storagePath = '/test/storage';33const storageUri = URI.file(storagePath);3435beforeEach(() => {36disposables = new DisposableStore();3738// Create a simple mock extension context with only globalStorageUri39mockExtensionContext = {40globalStorageUri: storageUri,41};42mockFileSystem = new MockFileSystemService();43mockGitService = new MockGitService();44mockOctoKitService = new MockOctoKitService();45mockWorkspaceService = new MockWorkspaceService();46mockAuthService = new MockAuthenticationService();4748// Set up testing services to get log service49const testingServiceCollection = createExtensionUnitTestingServices(disposables);50const accessor = disposables.add(testingServiceCollection.createTestingAccessor());51logService = accessor.get(ILogService);52});5354afterEach(() => {55disposables.dispose();56mockOctoKitService?.reset();57});5859function createService(): GitHubOrgChatResourcesService {60service = new GitHubOrgChatResourcesService(61mockAuthService as any,62mockExtensionContext as any,63mockFileSystem,64mockGitService,65logService,66mockOctoKitService,67mockWorkspaceService,68);69disposables.add(service);70return service;71}7273suite('getPreferredOrganizationName', () => {7475test('returns organization from workspace repository when available', async () => {76mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace')]);77mockGitService.setRepositoryFetchUrls({78rootUri: URI.file('/workspace'),79remoteFetchUrls: ['https://github.com/myorg/myrepo.git']80});81mockOctoKitService.setUserOrganizations(['myorg']);8283const service = createService();84const orgName = await service.getPreferredOrganizationName();8586assert.equal(orgName, 'myorg');87});8889test('returns organization from SSH URL format', async () => {90mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace')]);91mockGitService.setRepositoryFetchUrls({92rootUri: URI.file('/workspace'),93remoteFetchUrls: ['[email protected]:sshorg/myrepo.git']94});95mockOctoKitService.setUserOrganizations(['sshorg']);9697const service = createService();98const orgName = await service.getPreferredOrganizationName();99100assert.equal(orgName, 'sshorg');101});102103test('falls back to user organizations when no workspace repo', async () => {104mockWorkspaceService.setWorkspaceFolders([]);105mockOctoKitService.setUserOrganizations(['fallbackorg', 'anotherorg']);106107const service = createService();108const orgName = await service.getPreferredOrganizationName();109110assert.equal(orgName, 'fallbackorg');111});112113test('falls back to user organizations when repo has no GitHub remote', async () => {114mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace')]);115mockGitService.setRepositoryFetchUrls({116rootUri: URI.file('/workspace'),117remoteFetchUrls: ['https://gitlab.com/someorg/repo.git']118});119mockOctoKitService.setUserOrganizations(['fallbackorg']);120121const service = createService();122const orgName = await service.getPreferredOrganizationName();123124assert.equal(orgName, 'fallbackorg');125});126127test('returns undefined when user has no organizations', async () => {128mockWorkspaceService.setWorkspaceFolders([]);129mockOctoKitService.setUserOrganizations([]);130131const service = createService();132const orgName = await service.getPreferredOrganizationName();133134assert.isUndefined(orgName);135});136137test('caches result on subsequent calls', async () => {138mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace')]);139mockGitService.setRepositoryFetchUrls({140rootUri: URI.file('/workspace'),141remoteFetchUrls: ['https://github.com/cachedorg/repo.git']142});143mockOctoKitService.setUserOrganizations(['cachedorg']);144145const service = createService();146147// First call148const orgName1 = await service.getPreferredOrganizationName();149assert.equal(orgName1, 'cachedorg');150151// Change the mock - should not affect cached result152mockGitService.setRepositoryFetchUrls({153rootUri: URI.file('/workspace'),154remoteFetchUrls: ['https://github.com/neworg/repo.git']155});156157// Second call should return cached value158const orgName2 = await service.getPreferredOrganizationName();159assert.equal(orgName2, 'cachedorg');160});161162test('handles error in getUserOrganizations gracefully', async () => {163mockWorkspaceService.setWorkspaceFolders([]);164mockOctoKitService.getUserOrganizations = async () => {165throw new Error('API Error');166};167168const service = createService();169const orgName = await service.getPreferredOrganizationName();170171assert.isUndefined(orgName);172});173174test('tries multiple remote URLs to find GitHub repo', async () => {175mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace')]);176mockGitService.setRepositoryFetchUrls({177rootUri: URI.file('/workspace'),178remoteFetchUrls: [179'https://gitlab.com/notgithub/repo.git',180undefined as any, // Skip undefined181'https://github.com/foundorg/repo.git'182]183});184mockOctoKitService.setUserOrganizations(['foundorg']);185186const service = createService();187const orgName = await service.getPreferredOrganizationName();188189assert.equal(orgName, 'foundorg');190});191192test('prefers Copilot sign-in org over arbitrary first org when no workspace repo', async () => {193mockWorkspaceService.setWorkspaceFolders([]);194mockOctoKitService.setUserOrganizations(['firstorg', 'copilotorg', 'thirdorg']);195// Set Copilot token with organization_login_list indicating Copilot access through 'copilotorg'196mockAuthService.copilotToken = {197organizationLoginList: ['copilotorg'],198} as any;199200const service = createService();201const orgName = await service.getPreferredOrganizationName();202203assert.equal(orgName, 'copilotorg');204});205206test('prefers workspace repo org over Copilot sign-in org', async () => {207mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace')]);208mockGitService.setRepositoryFetchUrls({209rootUri: URI.file('/workspace'),210remoteFetchUrls: ['https://github.com/workspaceorg/repo.git']211});212mockOctoKitService.setUserOrganizations(['workspaceorg', 'copilotorg']);213mockAuthService.copilotToken = {214organizationLoginList: ['copilotorg'],215} as any;216217const service = createService();218const orgName = await service.getPreferredOrganizationName();219220assert.equal(orgName, 'workspaceorg');221});222223test('uses Copilot org even when not in paginated user org list', async () => {224mockWorkspaceService.setWorkspaceFolders([]);225mockOctoKitService.setUserOrganizations(['firstorg', 'secondorg']);226// Copilot org may not appear in paginated user org list but is still valid227mockAuthService.copilotToken = {228organizationLoginList: ['copilotorg'],229} as any;230231const service = createService();232const orgName = await service.getPreferredOrganizationName();233234// Copilot token orgs are trusted since they represent validated membership235assert.equal(orgName, 'copilotorg');236});237238test('falls back to first org when no Copilot token available', async () => {239mockWorkspaceService.setWorkspaceFolders([]);240mockOctoKitService.setUserOrganizations(['firstorg', 'secondorg']);241mockAuthService.copilotToken = undefined;242243const service = createService();244const orgName = await service.getPreferredOrganizationName();245246assert.equal(orgName, 'firstorg');247});248249test('uses first matching Copilot org when multiple are available', async () => {250mockWorkspaceService.setWorkspaceFolders([]);251mockOctoKitService.setUserOrganizations(['thirdorg', 'secondcopilotorg', 'firstcopilotorg']);252mockAuthService.copilotToken = {253organizationLoginList: ['firstcopilotorg', 'secondcopilotorg'],254} as any;255256const service = createService();257const orgName = await service.getPreferredOrganizationName();258259// Should match 'firstcopilotorg' first in the copilot org list iteration260assert.equal(orgName, 'firstcopilotorg');261});262});263264suite.skip('startPolling', () => {265266test('invokes callback immediately with org name', async () => {267mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace')]);268mockGitService.setRepositoryFetchUrls({269rootUri: URI.file('/workspace'),270remoteFetchUrls: ['https://github.com/pollingorg/repo.git']271});272mockOctoKitService.setUserOrganizations(['pollingorg']);273274const service = createService();275276let capturedOrg: string | undefined;277const subscription = service.startPolling(10000, async (orgName) => {278capturedOrg = orgName;279});280disposables.add(subscription);281282// Wait for initial poll283await new Promise(resolve => setTimeout(resolve, 50));284285assert.equal(capturedOrg, 'pollingorg');286});287288test('does not invoke callback when no organization', async () => {289mockWorkspaceService.setWorkspaceFolders([]);290mockOctoKitService.setUserOrganizations([]);291292const service = createService();293294let callbackInvoked = false;295const subscription = service.startPolling(10000, async () => {296callbackInvoked = true;297});298disposables.add(subscription);299300await new Promise(resolve => setTimeout(resolve, 50));301302assert.isFalse(callbackInvoked);303});304305test('stops polling when subscription is disposed', async () => {306mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace')]);307mockGitService.setRepositoryFetchUrls({308rootUri: URI.file('/workspace'),309remoteFetchUrls: ['https://github.com/testorg/repo.git']310});311312const service = createService();313314let callCount = 0;315const subscription = service.startPolling(50, async () => {316callCount++;317});318319// Wait for initial poll320await new Promise(resolve => setTimeout(resolve, 30));321const initialCount = callCount;322323// Dispose subscription324subscription.dispose();325326// Wait longer than poll interval327await new Promise(resolve => setTimeout(resolve, 100));328329// Call count should not have increased significantly after disposal330assert.isAtMost(callCount - initialCount, 1);331});332333test('prevents concurrent polling', async () => {334mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace')]);335mockGitService.setRepositoryFetchUrls({336rootUri: URI.file('/workspace'),337remoteFetchUrls: ['https://github.com/concurrent/repo.git']338});339mockOctoKitService.setUserOrganizations(['concurrent']);340341const service = createService();342343let concurrentCalls = 0;344let maxConcurrentCalls = 0;345346const subscription = service.startPolling(10, async () => {347concurrentCalls++;348maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);349await new Promise(resolve => setTimeout(resolve, 50));350concurrentCalls--;351});352disposables.add(subscription);353354// Wait for multiple poll cycles355await new Promise(resolve => setTimeout(resolve, 100));356357// Should never have more than 1 concurrent call358assert.equal(maxConcurrentCalls, 1);359});360361test('handles callback errors gracefully', async () => {362mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace')]);363mockGitService.setRepositoryFetchUrls({364rootUri: URI.file('/workspace'),365remoteFetchUrls: ['https://github.com/errororg/repo.git']366});367mockOctoKitService.setUserOrganizations(['errororg']);368369const service = createService();370371let callCount = 0;372const subscription = service.startPolling(30, async () => {373callCount++;374if (callCount === 1) {375throw new Error('Callback error');376}377});378disposables.add(subscription);379380// Wait for multiple poll cycles381await new Promise(resolve => setTimeout(resolve, 100));382383// Should continue polling even after error384assert.isAtLeast(callCount, 2);385});386});387388suite('readCacheFile', () => {389390test('reads instruction file from cache', async () => {391const cacheUri = URI.file(`${storagePath}/github/testorg/instructions/default${INSTRUCTION_FILE_EXTENSION}`);392mockFileSystem.mockFile(cacheUri, '# Custom Instructions');393394const service = createService();395const content = await service.readCacheFile(PromptsType.instructions, 'testorg', `default${INSTRUCTION_FILE_EXTENSION}`);396397assert.equal(content, '# Custom Instructions');398});399400test('reads agent file from cache', async () => {401const cacheUri = URI.file(`${storagePath}/github/testorg/agents/myagent${AGENT_FILE_EXTENSION}`);402mockFileSystem.mockFile(cacheUri, '---\nname: My Agent\n---\nPrompt');403404const service = createService();405const content = await service.readCacheFile(PromptsType.agent, 'testorg', `myagent${AGENT_FILE_EXTENSION}`);406407assert.equal(content, '---\nname: My Agent\n---\nPrompt');408});409410test('returns undefined for missing file', async () => {411const service = createService();412const content = await service.readCacheFile(PromptsType.instructions, 'testorg', 'nonexistent.instructions.md');413414assert.isUndefined(content);415});416417test('sanitizes org name in path', async () => {418// dash is preserved, uppercase becomes lowercase419const cacheUri = URI.file(`${storagePath}/github/test-org/instructions/default${INSTRUCTION_FILE_EXTENSION}`);420mockFileSystem.mockFile(cacheUri, 'Sanitized content');421422const service = createService();423const content = await service.readCacheFile(PromptsType.instructions, 'Test-Org', `default${INSTRUCTION_FILE_EXTENSION}`);424425assert.equal(content, 'Sanitized content');426});427});428429suite('writeCacheFile', () => {430431test('writes instruction file to cache', async () => {432const service = createService();433434const result = await service.writeCacheFile(435PromptsType.instructions,436'testorg',437`default${INSTRUCTION_FILE_EXTENSION}`,438'# New Instructions'439);440441assert.isTrue(result);442443// Verify file was written444const cacheUri = URI.file(`${storagePath}/github/testorg/instructions/default${INSTRUCTION_FILE_EXTENSION}`);445const content = await mockFileSystem.readFile(cacheUri);446assert.equal(new TextDecoder().decode(content), '# New Instructions');447});448449test('writes agent file to cache', async () => {450const service = createService();451452const result = await service.writeCacheFile(453PromptsType.agent,454'testorg',455`myagent${AGENT_FILE_EXTENSION}`,456'---\nname: Agent\n---\nPrompt'457);458459assert.isTrue(result);460461const cacheUri = URI.file(`${storagePath}/github/testorg/agents/myagent${AGENT_FILE_EXTENSION}`);462const content = await mockFileSystem.readFile(cacheUri);463assert.equal(new TextDecoder().decode(content), '---\nname: Agent\n---\nPrompt');464});465466test('returns false when content unchanged with checkForChanges', async () => {467const cacheUri = URI.file(`${storagePath}/github/testorg/instructions/default${INSTRUCTION_FILE_EXTENSION}`);468mockFileSystem.mockFile(cacheUri, 'Same content');469470const service = createService();471472const result = await service.writeCacheFile(473PromptsType.instructions,474'testorg',475`default${INSTRUCTION_FILE_EXTENSION}`,476'Same content',477{ checkForChanges: true }478);479480assert.isFalse(result);481});482483test('returns true when content changed with checkForChanges', async () => {484const cacheUri = URI.file(`${storagePath}/github/testorg/instructions/default${INSTRUCTION_FILE_EXTENSION}`);485mockFileSystem.mockFile(cacheUri, 'Old content');486487const service = createService();488489const result = await service.writeCacheFile(490PromptsType.instructions,491'testorg',492`default${INSTRUCTION_FILE_EXTENSION}`,493'New content',494{ checkForChanges: true }495);496497assert.isTrue(result);498});499500test('returns true when file does not exist with checkForChanges', async () => {501const service = createService();502503const result = await service.writeCacheFile(504PromptsType.instructions,505'neworg',506`default${INSTRUCTION_FILE_EXTENSION}`,507'Content',508{ checkForChanges: true }509);510511assert.isTrue(result);512});513514test('returns true when file size differs with checkForChanges', async () => {515const cacheUri = URI.file(`${storagePath}/github/testorg/instructions/default${INSTRUCTION_FILE_EXTENSION}`);516mockFileSystem.mockFile(cacheUri, 'Short');517518const service = createService();519520const result = await service.writeCacheFile(521PromptsType.instructions,522'testorg',523`default${INSTRUCTION_FILE_EXTENSION}`,524'Much longer content that differs in size',525{ checkForChanges: true }526);527528assert.isTrue(result);529});530531test('creates directory structure if not exists', async () => {532const service = createService();533534await service.writeCacheFile(535PromptsType.agent,536'neworg',537`agent${AGENT_FILE_EXTENSION}`,538'Content'539);540541const cacheUri = URI.file(`${storagePath}/github/neworg/agents/agent${AGENT_FILE_EXTENSION}`);542const content = await mockFileSystem.readFile(cacheUri);543assert.equal(new TextDecoder().decode(content), 'Content');544});545546test('sanitizes org name before writing', async () => {547const service = createService();548549await service.writeCacheFile(550PromptsType.instructions,551'My-Org!@#',552`default${INSTRUCTION_FILE_EXTENSION}`,553'Content'554);555556// dash is preserved, special chars become underscore, uppercase becomes lowercase557const cacheUri = URI.file(`${storagePath}/github/my-org___/instructions/default${INSTRUCTION_FILE_EXTENSION}`);558const content = await mockFileSystem.readFile(cacheUri);559assert.equal(new TextDecoder().decode(content), 'Content');560});561});562563suite('clearCache', () => {564565test('deletes all instruction files for organization', async () => {566const cacheDir = URI.file(`${storagePath}/github/testorg/instructions`);567mockFileSystem.mockDirectory(cacheDir, [568[`file1${INSTRUCTION_FILE_EXTENSION}`, FileType.File],569[`file2${INSTRUCTION_FILE_EXTENSION}`, FileType.File],570]);571mockFileSystem.mockFile(URI.joinPath(cacheDir, `file1${INSTRUCTION_FILE_EXTENSION}`), 'Content 1');572mockFileSystem.mockFile(URI.joinPath(cacheDir, `file2${INSTRUCTION_FILE_EXTENSION}`), 'Content 2');573574const service = createService();575await service.clearCache(PromptsType.instructions, 'testorg');576577// Files should be deleted578let file1Exists = true;579let file2Exists = true;580try {581await mockFileSystem.readFile(URI.joinPath(cacheDir, `file1${INSTRUCTION_FILE_EXTENSION}`));582} catch {583file1Exists = false;584}585try {586await mockFileSystem.readFile(URI.joinPath(cacheDir, `file2${INSTRUCTION_FILE_EXTENSION}`));587} catch {588file2Exists = false;589}590591assert.isFalse(file1Exists);592assert.isFalse(file2Exists);593});594595test('excludes specified files from deletion', async () => {596const cacheDir = URI.file(`${storagePath}/github/testorg/instructions`);597mockFileSystem.mockDirectory(cacheDir, [598[`keep${INSTRUCTION_FILE_EXTENSION}`, FileType.File],599[`delete${INSTRUCTION_FILE_EXTENSION}`, FileType.File],600]);601mockFileSystem.mockFile(URI.joinPath(cacheDir, `keep${INSTRUCTION_FILE_EXTENSION}`), 'Keep this');602mockFileSystem.mockFile(URI.joinPath(cacheDir, `delete${INSTRUCTION_FILE_EXTENSION}`), 'Delete this');603604const service = createService();605await service.clearCache(PromptsType.instructions, 'testorg', new Set([`keep${INSTRUCTION_FILE_EXTENSION}`]));606607// Kept file should still exist608const keepContent = await mockFileSystem.readFile(URI.joinPath(cacheDir, `keep${INSTRUCTION_FILE_EXTENSION}`));609assert.equal(new TextDecoder().decode(keepContent), 'Keep this');610611// Deleted file should not exist612let deleteExists = true;613try {614await mockFileSystem.readFile(URI.joinPath(cacheDir, `delete${INSTRUCTION_FILE_EXTENSION}`));615} catch {616deleteExists = false;617}618assert.isFalse(deleteExists);619});620621test('skips non-matching file extensions', async () => {622const cacheDir = URI.file(`${storagePath}/github/testorg/instructions`);623mockFileSystem.mockDirectory(cacheDir, [624[`valid${INSTRUCTION_FILE_EXTENSION}`, FileType.File],625['invalid.txt', FileType.File],626]);627mockFileSystem.mockFile(URI.joinPath(cacheDir, `valid${INSTRUCTION_FILE_EXTENSION}`), 'Valid');628mockFileSystem.mockFile(URI.joinPath(cacheDir, 'invalid.txt'), 'Invalid');629630const service = createService();631await service.clearCache(PromptsType.instructions, 'testorg');632633// Valid file should be deleted634let validExists = true;635try {636await mockFileSystem.readFile(URI.joinPath(cacheDir, `valid${INSTRUCTION_FILE_EXTENSION}`));637} catch {638validExists = false;639}640assert.isFalse(validExists);641642// Invalid file should still exist643const invalidContent = await mockFileSystem.readFile(URI.joinPath(cacheDir, 'invalid.txt'));644assert.equal(new TextDecoder().decode(invalidContent), 'Invalid');645});646647test('handles non-existent cache directory gracefully', async () => {648const service = createService();649650// Should not throw651await service.clearCache(PromptsType.instructions, 'nonexistentorg');652});653654test('skips directories in cache folder', async () => {655const cacheDir = URI.file(`${storagePath}/github/testorg/instructions`);656mockFileSystem.mockDirectory(cacheDir, [657[`file${INSTRUCTION_FILE_EXTENSION}`, FileType.File],658['subfolder', FileType.Directory],659]);660mockFileSystem.mockFile(URI.joinPath(cacheDir, `file${INSTRUCTION_FILE_EXTENSION}`), 'Content');661mockFileSystem.mockDirectory(URI.joinPath(cacheDir, 'subfolder'), []);662663const service = createService();664await service.clearCache(PromptsType.instructions, 'testorg');665666// Directory should still exist667const dirStat = await mockFileSystem.stat(URI.joinPath(cacheDir, 'subfolder'));668assert.ok(dirStat);669});670});671672suite('listCachedFiles', () => {673674test('lists all instruction files for organization', async () => {675const cacheDir = URI.file(`${storagePath}/github/testorg/instructions`);676mockFileSystem.mockDirectory(cacheDir, [677[`file1${INSTRUCTION_FILE_EXTENSION}`, FileType.File],678[`file2${INSTRUCTION_FILE_EXTENSION}`, FileType.File],679]);680681const service = createService();682const files = await service.listCachedFiles(PromptsType.instructions, 'testorg');683684assert.equal(files.length, 2);685const fileNames = files.map(f => f.uri.path.split('/').pop());686assert.include(fileNames, `file1${INSTRUCTION_FILE_EXTENSION}`);687assert.include(fileNames, `file2${INSTRUCTION_FILE_EXTENSION}`);688});689690test('lists all agent files for organization', async () => {691const cacheDir = URI.file(`${storagePath}/github/testorg/agents`);692mockFileSystem.mockDirectory(cacheDir, [693[`agent1${AGENT_FILE_EXTENSION}`, FileType.File],694[`agent2${AGENT_FILE_EXTENSION}`, FileType.File],695]);696697const service = createService();698const files = await service.listCachedFiles(PromptsType.agent, 'testorg');699700assert.equal(files.length, 2);701const fileNames = files.map(f => f.uri.path.split('/').pop());702assert.include(fileNames, `agent1${AGENT_FILE_EXTENSION}`);703assert.include(fileNames, `agent2${AGENT_FILE_EXTENSION}`);704});705706test('returns empty array for non-existent directory', async () => {707const service = createService();708const files = await service.listCachedFiles(PromptsType.instructions, 'nonexistent');709710assert.deepEqual(files, []);711});712713test('filters out non-matching file extensions', async () => {714const cacheDir = URI.file(`${storagePath}/github/testorg/instructions`);715mockFileSystem.mockDirectory(cacheDir, [716[`valid${INSTRUCTION_FILE_EXTENSION}`, FileType.File],717['invalid.txt', FileType.File],718['readme.md', FileType.File],719]);720721const service = createService();722const files = await service.listCachedFiles(PromptsType.instructions, 'testorg');723724assert.equal(files.length, 1);725assert.ok(files[0].uri.path.endsWith(INSTRUCTION_FILE_EXTENSION));726});727728test('filters out directories', async () => {729const cacheDir = URI.file(`${storagePath}/github/testorg/agents`);730mockFileSystem.mockDirectory(cacheDir, [731[`agent${AGENT_FILE_EXTENSION}`, FileType.File],732['subfolder', FileType.Directory],733]);734735const service = createService();736const files = await service.listCachedFiles(PromptsType.agent, 'testorg');737738assert.equal(files.length, 1);739assert.ok(files[0].uri.path.endsWith(AGENT_FILE_EXTENSION));740});741742test('returns correct URI structure for files', async () => {743const cacheDir = URI.file(`${storagePath}/github/myorg/instructions`);744mockFileSystem.mockDirectory(cacheDir, [745[`custom${INSTRUCTION_FILE_EXTENSION}`, FileType.File],746]);747748const service = createService();749const files = await service.listCachedFiles(PromptsType.instructions, 'myorg');750751assert.equal(files.length, 1);752assert.ok(files[0].uri.path.includes('/github/'));753assert.ok(files[0].uri.path.includes('/myorg/'));754assert.ok(files[0].uri.path.includes('/instructions/'));755});756});757758suite('workspace folder change handling', () => {759760test('invalidates org cache when workspace folders change', async () => {761mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace1')]);762mockGitService.setRepositoryFetchUrls({763rootUri: URI.file('/workspace1'),764remoteFetchUrls: ['https://github.com/org1/repo.git']765});766mockOctoKitService.setUserOrganizations(['org1', 'org2']);767768const service = createService();769770// Get initial org name771const orgName1 = await service.getPreferredOrganizationName();772assert.equal(orgName1, 'org1');773774// Simulate workspace folder change by updating mocks775mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace2')]);776mockGitService.setRepositoryFetchUrls({777rootUri: URI.file('/workspace2'),778remoteFetchUrls: ['https://github.com/org2/repo.git']779});780781// The cache should be cleared on workspace change event782// Since we can't easily fire the event, we verify the subscription is set up783// by checking that disposal works784service.dispose();785});786});787788suite('getCacheSubdirectory helper', () => {789790test('uses instructions subdirectory for instructions type', async () => {791const service = createService();792793await service.writeCacheFile(794PromptsType.instructions,795'testorg',796`file${INSTRUCTION_FILE_EXTENSION}`,797'Content'798);799800const files = await service.listCachedFiles(PromptsType.instructions, 'testorg');801assert.ok(files[0].uri.path.includes('/instructions/'));802});803804test('uses agents subdirectory for agent type', async () => {805const service = createService();806807await service.writeCacheFile(808PromptsType.agent,809'testorg',810`file${AGENT_FILE_EXTENSION}`,811'Content'812);813814const files = await service.listCachedFiles(PromptsType.agent, 'testorg');815assert.ok(files[0].uri.path.includes('/agents/'));816});817});818819suite('file validation', () => {820821test('validates instruction file extension', async () => {822const cacheDir = URI.file(`${storagePath}/github/testorg/instructions`);823mockFileSystem.mockDirectory(cacheDir, [824[`valid${INSTRUCTION_FILE_EXTENSION}`, FileType.File],825['valid.agent.md', FileType.File], // Wrong extension for instructions826]);827828const service = createService();829const files = await service.listCachedFiles(PromptsType.instructions, 'testorg');830831assert.equal(files.length, 1);832assert.ok(files[0].uri.path.endsWith(INSTRUCTION_FILE_EXTENSION));833});834835test('validates agent file extension', async () => {836const cacheDir = URI.file(`${storagePath}/github/testorg/agents`);837mockFileSystem.mockDirectory(cacheDir, [838[`valid${AGENT_FILE_EXTENSION}`, FileType.File],839['valid.instructions.md', FileType.File], // Wrong extension for agents840]);841842const service = createService();843const files = await service.listCachedFiles(PromptsType.agent, 'testorg');844845assert.equal(files.length, 1);846assert.ok(files[0].uri.path.endsWith(AGENT_FILE_EXTENSION));847});848});849});850851852