Path: blob/main/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts
13399 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 { VSBuffer } from '../../../../base/common/buffer.js';7import { DisposableStore } from '../../../../base/common/lifecycle.js';8import { Schemas } from '../../../../base/common/network.js';9import { URI } from '../../../../base/common/uri.js';10import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';11import { FileService } from '../../../files/common/fileService.js';12import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';13import { NullLogService } from '../../../log/common/log.js';14import { AGENT_CLIENT_SCHEME, toAgentClientUri } from '../../common/agentClientUri.js';15import { CustomizationStatus, type CustomizationRef, type SessionCustomization } from '../../common/state/sessionState.js';16import { AgentPluginManager } from '../../node/agentPluginManager.js';1718suite('AgentPluginManager', () => {1920const disposables = new DisposableStore();21let fileService: FileService;22let manager: AgentPluginManager;23const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' });2425setup(() => {26fileService = disposables.add(new FileService(new NullLogService()));27disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider())));28disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, disposables.add(new InMemoryFileSystemProvider())));29manager = new AgentPluginManager(basePath, fileService, new NullLogService());30});3132teardown(() => disposables.clear());33ensureNoDisposablesAreLeakedInTestSuite();3435function pluginUri(name: string): string {36return URI.from({ scheme: Schemas.inMemory, path: `/plugins/${name}` }).toString();37}3839function makeRef(name: string, nonce?: string): CustomizationRef {40return { uri: pluginUri(name), displayName: `Plugin ${name}`, nonce };41}4243async function seedPluginDir(name: string, files: Record<string, string>): Promise<void> {44const originalUri = URI.from({ scheme: Schemas.inMemory, path: `/plugins/${name}` });45const agentClientDir = toAgentClientUri(originalUri, 'test-client');46await fileService.createFolder(agentClientDir);47for (const [fileName, content] of Object.entries(files)) {48await fileService.writeFile(URI.joinPath(agentClientDir, fileName), VSBuffer.fromString(content));49}50}5152// ---- syncCustomizations -------------------------------------------------5354suite('syncCustomizations', () => {5556test('returns loaded status and pluginDir for each synced plugin', async () => {57await seedPluginDir('alpha', { 'index.js': 'a' });58await seedPluginDir('beta', { 'index.js': 'b' });5960const results = await manager.syncCustomizations('test-client', [61makeRef('alpha', 'n1'),62makeRef('beta', 'n2'),63]);64assert.strictEqual(results[0].customization.status, CustomizationStatus.Loaded);65assert.ok(results[0].pluginDir, 'should have pluginDir');66assert.strictEqual(results[1].customization.status, CustomizationStatus.Loaded);67assert.ok(results[1].pluginDir, 'should have pluginDir');68});6970test('returns error status without pluginDir when source missing', async () => {71const results = await manager.syncCustomizations('test-client', [makeRef('nonexistent')]);7273assert.strictEqual(results.length, 1);74assert.strictEqual(results[0].customization.status, CustomizationStatus.Error);75assert.ok(results[0].customization.statusMessage);76assert.strictEqual(results[0].pluginDir, undefined);77});7879test('mixes loaded and error results', async () => {80await seedPluginDir('good', { 'index.js': 'ok' });8182const results = await manager.syncCustomizations('test-client', [83makeRef('good', 'n1'),84makeRef('missing'),85]);86assert.strictEqual(results[1].customization.status, CustomizationStatus.Error);87assert.strictEqual(results[1].pluginDir, undefined);88});8990test('fires progress callback with loading, then loaded', async () => {91await seedPluginDir('prog', { 'index.js': 'content' });9293const progressCalls: SessionCustomization[][] = [];94await manager.syncCustomizations('test-client', [makeRef('prog', 'n1')], statuses => {95progressCalls.push(statuses);96});9798// At least two calls: initial loading + final loaded99assert.ok(progressCalls.length >= 2, `expected at least 2 progress calls, got ${progressCalls.length}`);100assert.strictEqual(progressCalls[0][0].status, CustomizationStatus.Loading);101assert.strictEqual(progressCalls[progressCalls.length - 1][0].status, CustomizationStatus.Loaded);102});103104test('skips copy when nonce matches', async () => {105await seedPluginDir('cached', { 'index.js': 'v1' });106const ref = makeRef('cached', 'nonce-abc');107108const result1 = await manager.syncCustomizations('test-client', [ref]);109assert.ok(result1[0].pluginDir);110111// Second sync with same nonce should still succeed (from cache)112const result2 = await manager.syncCustomizations('test-client', [ref]);113assert.ok(result2[0].pluginDir);114assert.strictEqual(result1[0].pluginDir!.toString(), result2[0].pluginDir!.toString());115});116117test('serializes concurrent syncs of the same URI', async () => {118await seedPluginDir('concurrent', { 'index.js': 'v1' });119const ref = makeRef('concurrent', 'n1');120121// Fire two syncs concurrently122const [r1, r2] = await Promise.all([123manager.syncCustomizations('test-client', [ref]),124manager.syncCustomizations('test-client', [ref]),125]);126127// Both should succeed without error128assert.strictEqual(r1[0].customization.status, CustomizationStatus.Loaded);129assert.strictEqual(r2[0].customization.status, CustomizationStatus.Loaded);130});131});132133// ---- LRU eviction -------------------------------------------------------134135suite('LRU eviction', () => {136137test('evicts least recently used plugins when limit exceeded', async () => {138const smallManager = new AgentPluginManager(basePath, fileService, new NullLogService(), 3);139140for (let i = 1; i <= 4; i++) {141await seedPluginDir(`plugin-${i}`, { 'index.js': `p${i}` });142await smallManager.syncCustomizations('test-client', [makeRef(`plugin-${i}`, `n${i}`)]);143}144145// The evicted dir should no longer exist on disk (cache.json + 3 plugin dirs)146const evictedDir = URI.joinPath(basePath, 'agentPlugins');147const listing = await fileService.resolve(evictedDir);148assert.ok(listing.children);149const pluginDirs = listing.children.filter(c => c.isDirectory);150assert.strictEqual(pluginDirs.length, 3, 'should have exactly 3 plugin dirs after eviction');151});152});153154// ---- cache persistence --------------------------------------------------155156suite('cache persistence', () => {157158test('restores nonce cache from disk on new manager instance', async () => {159await seedPluginDir('persist1', { 'index.js': 'v1' });160const ref = makeRef('persist1', 'nonce-persist');161162// Sync with first manager163await manager.syncCustomizations('test-client', [ref]);164165// Create a new manager pointing to the same base path166const manager2 = new AgentPluginManager(basePath, fileService, new NullLogService());167const result = await manager2.syncCustomizations('test-client', [ref]);168169// Should be loaded from cache (nonce match), not error170assert.strictEqual(result[0].customization.status, CustomizationStatus.Loaded);171assert.ok(result[0].pluginDir);172});173});174});175176177