Path: blob/main/extensions/copilot/src/extension/agents/vscode-node/test/githubOrgInstructionsProvider.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, vi } from 'vitest';7import type { ExtensionContext } from 'vscode';8import { INSTRUCTION_FILE_EXTENSION, PromptsType } from '../../../../platform/customInstructions/common/promptTypes';9import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService';10import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService';11import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService';12import { MockWorkspaceService } from '../../../../platform/ignore/node/test/mockWorkspaceService';13import { ILogService } from '../../../../platform/log/common/logService';14import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';15import { URI } from '../../../../util/vs/base/common/uri';16import { createExtensionUnitTestingServices } from '../../../test/node/services';17import { GitHubOrgChatResourcesService } from '../githubOrgChatResourcesService';18import { GitHubOrgInstructionsProvider } from '../githubOrgInstructionsProvider';19import { MockOctoKitService } from './mockOctoKitService';2021suite('GitHubOrgInstructionsProvider', () => {22let disposables: DisposableStore;23let mockOctoKitService: MockOctoKitService;24let mockFileSystem: MockFileSystemService;25let mockGitService: MockGitService;26let mockWorkspaceService: MockWorkspaceService;27let mockExtensionContext: Partial<ExtensionContext>;28let mockAuthService: MockAuthenticationService;29let accessor: any;30let provider: GitHubOrgInstructionsProvider;31let resourcesService: GitHubOrgChatResourcesService;3233const storagePath = '/tmp/test-storage';34const storageUri = URI.file(storagePath);3536beforeEach(() => {37vi.useFakeTimers();38disposables = new DisposableStore();3940// Create mocks for real GitHubOrgChatResourcesService41mockOctoKitService = new MockOctoKitService();42mockFileSystem = new MockFileSystemService();43mockGitService = new MockGitService();44mockWorkspaceService = new MockWorkspaceService();45mockExtensionContext = {46globalStorageUri: storageUri,47};48mockAuthService = new MockAuthenticationService();4950// Default: user is in 'testorg' and workspace belongs to 'testorg'51mockOctoKitService.setUserOrganizations(['testorg']);52mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace')]);53mockGitService.setRepositoryFetchUrls({54rootUri: URI.file('/workspace'),55remoteFetchUrls: ['https://github.com/testorg/repo.git']56});5758// Set up testing services59const testingServiceCollection = createExtensionUnitTestingServices(disposables);60accessor = disposables.add(testingServiceCollection.createTestingAccessor());61});6263afterEach(() => {64vi.useRealTimers();65disposables.dispose();66mockOctoKitService.reset();67});6869function createProvider(): GitHubOrgInstructionsProvider {70// Create the real GitHubOrgChatResourcesService with mocked dependencies71resourcesService = new GitHubOrgChatResourcesService(72mockAuthService as any,73mockExtensionContext as any,74mockFileSystem,75mockGitService,76accessor.get(ILogService),77mockOctoKitService,78mockWorkspaceService,79);80disposables.add(resourcesService);8182// Create provider with real resources service83provider = new GitHubOrgInstructionsProvider(84accessor.get(ILogService),85mockOctoKitService,86resourcesService,87);88disposables.add(provider);89return provider;90}9192/**93* Advance timers and wait for polling callback to complete.94* Uses a small time advance to trigger the initial poll without infinite loops.95*/96async function waitForPolling(): Promise<void> {97await vi.advanceTimersByTimeAsync(10);98}99100/**101* Helper to pre-populate cache files in mock filesystem.102*/103function prepopulateCache(orgName: string, files: Map<string, string>): void {104const cacheDir = URI.file(`${storagePath}/github/${orgName}/instructions`);105const dirEntries: [string, import('../../../../platform/filesystem/common/fileTypes').FileType][] = [];106for (const [filename, content] of files) {107mockFileSystem.mockFile(URI.joinPath(cacheDir, filename), content);108dirEntries.push([filename, 1 /* FileType.File */]);109}110mockFileSystem.mockDirectory(cacheDir, dirEntries);111}112113test('returns empty array when no organization available', async () => {114mockOctoKitService.setUserOrganizations([]);115mockWorkspaceService.setWorkspaceFolders([]);116const provider = createProvider();117118const instructions = await provider.provideInstructions({}, {} as any);119120assert.deepEqual(instructions, []);121});122123test('returns cached instructions when available', async () => {124const orgId = 'testorg';125126// Pre-populate cache with instructions127const instructionContent = '# Custom Instructions\nThese are custom instructions for the organization.';128prepopulateCache(orgId, new Map([129[`default${INSTRUCTION_FILE_EXTENSION}`, instructionContent]130]));131132const provider = createProvider();133134const instructions = await provider.provideInstructions({}, {} as any);135136assert.equal(instructions.length, 1);137assert.ok(instructions[0].uri.path.endsWith(`default${INSTRUCTION_FILE_EXTENSION}`));138});139140test('returns empty array when cache is empty', async () => {141// No cache populated142const provider = createProvider();143144const instructions = await provider.provideInstructions({}, {} as any);145146assert.deepEqual(instructions, []);147});148149test.skip('pollInstructions writes instructions to cache when found', async () => {150const orgId = 'testorg';151const instructionContent = '# Organization Instructions\nBe helpful and concise.';152153mockOctoKitService.setOrgInstructions(orgId, instructionContent);154155createProvider();156await waitForPolling();157158// Verify the instructions were written to cache159const cachedContent = await resourcesService.readCacheFile(160PromptsType.instructions,161orgId,162`default${INSTRUCTION_FILE_EXTENSION}`163);164165// The implementation adds applyTo front matter to the cached content166const expectedContent = `---\napplyTo: '**'\n---\n${instructionContent}`;167assert.equal(cachedContent, expectedContent);168});169170test.skip('pollInstructions does nothing when no instructions found', async () => {171mockOctoKitService.setOrgInstructions('testorg', undefined);172173createProvider();174await waitForPolling();175176// Verify no instructions were written177const cachedContent = await resourcesService.readCacheFile(178PromptsType.instructions,179'testorg',180`default${INSTRUCTION_FILE_EXTENSION}`181);182183assert.isUndefined(cachedContent);184});185186test.skip('fires change event when instructions content changes', async () => {187const instructionContent = '# New Instructions\nUpdated content.';188189mockOctoKitService.setOrgInstructions('testorg', instructionContent);190191const provider = createProvider();192193let eventFired = false;194provider.onDidChangeInstructions(() => {195eventFired = true;196});197198await waitForPolling();199200assert.isTrue(eventFired, 'Change event should fire when instructions are updated');201});202203test.skip('fires change event on every successful poll with instructions', async () => {204// Note: The current implementation does not pass checkForChanges option to writeCacheFile,205// so change events fire on every poll even when content is unchanged206const instructionContent = '# Stable Instructions\nThis content will not change.';207208mockOctoKitService.setOrgInstructions('testorg', instructionContent);209210// Pre-populate cache with the same content211prepopulateCache('testorg', new Map([212[`default${INSTRUCTION_FILE_EXTENSION}`, instructionContent]213]));214215const provider = createProvider();216217let changeEventCount = 0;218provider.onDidChangeInstructions(() => {219changeEventCount++;220});221222await waitForPolling();223224assert.equal(changeEventCount, 1, 'Change event fires on every successful poll');225});226227test.skip('pollInstructions handles API errors gracefully without throwing', async () => {228// Make the API throw an error229mockOctoKitService.getOrgCustomInstructions = async () => {230throw new Error('API Error');231};232233createProvider();234235// pollInstructions has internal error handling - errors are logged but not thrown236// This is intentional to prevent polling failures from crashing the extension237let errorThrown = false;238try {239await waitForPolling();240} catch (e: any) {241errorThrown = true;242}243244assert.isFalse(errorThrown, 'API errors should be handled internally and not propagate');245});246247test('returns instructions from correct organization', async () => {248// Pre-populate different orgs with different instructions249prepopulateCache('org1', new Map([250[`default${INSTRUCTION_FILE_EXTENSION}`, 'Org1 instructions']251]));252prepopulateCache('org2', new Map([253[`default${INSTRUCTION_FILE_EXTENSION}`, 'Org2 instructions']254]));255256// Set preferred org to org2 by configuring workspace git remote257mockOctoKitService.setUserOrganizations(['org1', 'org2']);258mockGitService.setRepositoryFetchUrls({259rootUri: URI.file('/workspace'),260remoteFetchUrls: ['https://github.com/org2/repo.git']261});262263const provider = createProvider();264265const instructions = await provider.provideInstructions({}, {} as any);266267assert.equal(instructions.length, 1);268// The URI should contain 'org2', not 'org1'269assert.ok(instructions[0].uri.path.includes('org2'));270});271272test('handles cache read errors gracefully', async () => {273const provider = createProvider();274275// Override readDirectory to throw an error276const originalReadDirectory = mockFileSystem.readDirectory.bind(mockFileSystem);277mockFileSystem.readDirectory = async () => {278throw new Error('Cache read error');279};280281// Should not throw, should return empty array282const instructions = await provider.provideInstructions({}, {} as any);283284assert.deepEqual(instructions, []);285286// Restore original method287mockFileSystem.readDirectory = originalReadDirectory;288});289290test('respects cancellation token in provideInstructions', async () => {291prepopulateCache('testorg', new Map([292[`default${INSTRUCTION_FILE_EXTENSION}`, 'Some instructions']293]));294295const provider = createProvider();296297// Create a cancelled token298const cancelledToken = {299isCancellationRequested: true,300onCancellationRequested: () => ({ dispose: () => { } })301};302303const instructions = await provider.provideInstructions({}, cancelledToken as any);304305// Should return empty array when cancelled306assert.deepEqual(instructions, []);307});308309test('uses correct file extension for instruction files', async () => {310const instructionContent = '# Test Instructions';311312mockOctoKitService.setOrgInstructions('testorg', instructionContent);313314const provider = createProvider();315await waitForPolling();316317// Verify the file was written with the correct extension318const cachedContent = await resourcesService.readCacheFile(319PromptsType.instructions,320'testorg',321`default${INSTRUCTION_FILE_EXTENSION}`322);323324// The implementation adds applyTo front matter to the cached content325const expectedContent = `---\napplyTo: '**'\n---\n${instructionContent}`;326assert.equal(cachedContent, expectedContent);327328// Prepopulate so we can list it329prepopulateCache('testorg', new Map([330[`default${INSTRUCTION_FILE_EXTENSION}`, instructionContent]331]));332333const instructions = await provider.provideInstructions({}, {} as any);334assert.equal(instructions.length, 1);335assert.ok(instructions[0].uri.path.endsWith(INSTRUCTION_FILE_EXTENSION));336});337338test('disposes polling subscription when provider is disposed', () => {339const provider = createProvider();340341// Should not throw when disposed342provider.dispose();343344// Provider should be properly cleaned up345assert.ok(true, 'Provider disposed without errors');346});347348test('multiple instruction files are returned when present', async () => {349// Pre-populate cache with multiple instruction files350prepopulateCache('testorg', new Map([351[`default${INSTRUCTION_FILE_EXTENSION}`, 'Default instructions'],352[`custom${INSTRUCTION_FILE_EXTENSION}`, 'Custom instructions'],353[`team${INSTRUCTION_FILE_EXTENSION}`, 'Team instructions'],354]));355356const provider = createProvider();357358const instructions = await provider.provideInstructions({}, {} as any);359360assert.equal(instructions.length, 3);361});362});363364365