Path: blob/main/extensions/copilot/src/extension/prompts/node/test/chatDiskSessionResources.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 { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';6import { FileType } from '../../../../platform/filesystem/common/fileTypes';7import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService';8import { TestLogService } from '../../../../platform/testing/common/testLogService';9import { URI } from '../../../../util/vs/base/common/uri';10import { ChatDiskSessionResources } from '../chatDiskSessionResourcesImpl';1112/**13* Mock extension context with a storage URI configured.14*/15class MockExtensionContextWithStorage {16readonly storageUri = URI.file('/test-storage');17}1819// Constants matching the implementation20const RETENTION_PERIOD_MS = 8 * 60 * 60 * 1000; // 8 hours21const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour2223describe('ChatDiskSessionResources', () => {24let mockFs: MockFileSystemService;25let service: ChatDiskSessionResources;2627beforeEach(() => {28mockFs = new MockFileSystemService();29const mockContext = new MockExtensionContextWithStorage();30const logService = new TestLogService();3132// Mock the storage directory33mockFs.mockDirectory(mockContext.storageUri, []);3435// Create the service directly with mocked dependencies36service = new ChatDiskSessionResources(37mockContext as any,38mockFs,39logService40);41});4243afterEach(() => {44service.dispose();45vi.resetAllMocks();46});4748describe('ensure', () => {49test('creates file with string content', async () => {50const sessionId = 'session-123';51const subdir = 'tool-result-1';52const content = 'Hello, world!';5354const resultUri = await service.ensure(sessionId, subdir, content);5556expect(resultUri).toBeDefined();57expect(resultUri.path).toContain('session-123');58expect(resultUri.path).toContain('tool-result-1');59});6061test('sanitizes session ID and subdir with special characters', async () => {62const sessionId = 'session/with:special*chars';63const subdir = 'tool<result>';64const content = 'Test content';6566const resultUri = await service.ensure(sessionId, subdir, content);6768// The path should contain sanitized versions (special chars replaced with underscores)69expect(resultUri.path).toContain('session_with_special_chars');70expect(resultUri.path).toContain('tool_result_');71});7273test('creates file tree with nested structure', async () => {74const sessionId = 'session-456';75const subdir = 'complex-result';76const files = {77'readme.txt': 'This is a readme',78'src': {79'main.ts': 'console.log("hello")',80'utils': {81'helper.ts': 'export function help() {}'82}83}84};8586const resultUri = await service.ensure(sessionId, subdir, files);8788expect(resultUri).toBeDefined();89expect(resultUri.path).toContain('session-456');90});9192test('is idempotent for same content', async () => {93const sessionId = 'session-789';94const subdir = 'idempotent-test';95const content = 'Same content';9697const uri1 = await service.ensure(sessionId, subdir, content);98const uri2 = await service.ensure(sessionId, subdir, content);99100expect(uri1.toString()).toBe(uri2.toString());101});102});103104describe('isSessionResourceUri', () => {105test('returns true for URIs within storage directory', async () => {106const sessionId = 'session-abc';107const subdir = 'test-subdir';108const content = 'Test';109110const resultUri = await service.ensure(sessionId, subdir, content);111112expect(service.isSessionResourceUri(resultUri)).toBe(true);113});114115test('returns false for URIs outside storage directory', () => {116const externalUri = URI.file('/some/other/path');117118expect(service.isSessionResourceUri(externalUri)).toBe(false);119});120121test('returns false for workspace URIs', () => {122const workspaceUri = URI.file('/workspace/project/file.ts');123124expect(service.isSessionResourceUri(workspaceUri)).toBe(false);125});126});127128describe('path sanitization', () => {129test('preserves alphanumeric characters', async () => {130const sessionId = 'abc123XYZ';131const subdir = 'test456';132const content = 'Test';133134const resultUri = await service.ensure(sessionId, subdir, content);135136expect(resultUri.path).toContain('abc123XYZ');137expect(resultUri.path).toContain('test456');138});139140test('preserves underscores and hyphens', async () => {141const sessionId = 'session_with-dashes';142const subdir = 'tool_result-1';143const content = 'Test';144145const resultUri = await service.ensure(sessionId, subdir, content);146147expect(resultUri.path).toContain('session_with-dashes');148expect(resultUri.path).toContain('tool_result-1');149});150151test('handles empty strings after sanitization gracefully', async () => {152const sessionId = '';153const subdir = '';154const content = 'Test';155156const resultUri = await service.ensure(sessionId, subdir, content);157158// Should still create a valid path159expect(resultUri).toBeDefined();160expect(resultUri.path).toContain('chat-session-resources');161});162});163164describe('file content handling', () => {165test('handles empty string content', async () => {166const sessionId = 'session-empty';167const subdir = 'empty-content';168const content = '';169170const resultUri = await service.ensure(sessionId, subdir, content);171172expect(resultUri).toBeDefined();173});174175test('handles large content', async () => {176const sessionId = 'session-large';177const subdir = 'large-content';178const content = 'x'.repeat(100000); // 100KB of content179180const resultUri = await service.ensure(sessionId, subdir, content);181182expect(resultUri).toBeDefined();183});184185test('handles unicode content', async () => {186const sessionId = 'session-unicode';187const subdir = 'unicode-content';188const content = '你好世界 🌍 مرحبا';189190const resultUri = await service.ensure(sessionId, subdir, content);191192expect(resultUri).toBeDefined();193});194});195196describe('cleanup and expiration', () => {197let mockContext: MockExtensionContextWithStorage;198let logService: TestLogService;199200beforeEach(() => {201vi.useFakeTimers({ shouldAdvanceTime: false });202mockFs = new MockFileSystemService();203mockContext = new MockExtensionContextWithStorage();204logService = new TestLogService();205// Mock the storage directory AND the session resources subdirectory206mockFs.mockDirectory(mockContext.storageUri, [['chat-session-resources', FileType.Directory]]);207mockFs.mockDirectory(URI.joinPath(mockContext.storageUri, 'chat-session-resources'), []);208});209210afterEach(() => {211vi.useRealTimers();212});213214test('cleanup runs on scheduled interval', async () => {215// Set a specific start time216vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));217218const testService = new ChatDiskSessionResources(219mockContext as any,220mockFs,221logService222);223224// Create a resource at time T=0225const resultUri = await testService.ensure('session-1', 'tool-1', 'content');226227// Verify directory exists228const stat = await mockFs.stat(resultUri);229expect(stat.type).toBe(FileType.Directory);230231// Advance time past retention period AND cleanup interval232await vi.advanceTimersByTimeAsync(RETENTION_PERIOD_MS + CLEANUP_INTERVAL_MS + 1000);233await testService.currentCleanup;234235// The directory should be cleaned up now (cleanup deletes at directory level)236await expect(mockFs.stat(resultUri)).rejects.toThrow();237238testService.dispose();239});240241test('recently accessed resources are not cleaned up', async () => {242vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));243244const testService = new ChatDiskSessionResources(245mockContext as any,246mockFs,247logService248);249250// Create a resource251const resultUri = await testService.ensure('session-fresh', 'tool-fresh', 'fresh content');252253// Advance time but not past retention254await vi.advanceTimersByTimeAsync(RETENTION_PERIOD_MS / 2);255256// Trigger cleanup257await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS);258259// Fresh resource should still exist260const contentUri = URI.joinPath(resultUri, 'content.txt');261const stat = await mockFs.stat(contentUri);262expect(stat.type).toBe(FileType.File);263264testService.dispose();265});266267test('resources older than retention period are cleaned up', async () => {268vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));269270const testService = new ChatDiskSessionResources(271mockContext as any,272mockFs,273logService274);275276// Create a resource277const resultUri = await testService.ensure('session-old', 'tool-old', 'old content');278279// Verify directory exists initially280const stat = await mockFs.stat(resultUri);281expect(stat.type).toBe(FileType.Directory);282283// Advance time past retention period AND trigger cleanup284await vi.advanceTimersByTimeAsync(RETENTION_PERIOD_MS + CLEANUP_INTERVAL_MS + 1000);285await testService.currentCleanup;286287// Old resource directory should be cleaned up288await expect(mockFs.stat(resultUri)).rejects.toThrow();289290testService.dispose();291});292293test('empty session directories are removed during cleanup', async () => {294vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));295296const testService = new ChatDiskSessionResources(297mockContext as any,298mockFs,299logService300);301302// Create a resource303await testService.ensure('session-empty-dir', 'tool-1', 'content');304305// Advance time past retention to trigger cleanup of the tool306await vi.advanceTimersByTimeAsync(RETENTION_PERIOD_MS + CLEANUP_INTERVAL_MS + 1000);307308await testService.currentCleanup;309310// The session directory should be gone since the tool was cleaned up311const sessionUri = URI.joinPath(mockContext.storageUri, 'chat-session-resources', 'session-empty-dir');312await expect(mockFs.stat(sessionUri)).rejects.toThrow();313314testService.dispose();315});316317test('dispose cancels cleanup timer', async () => {318vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));319320const testService = new ChatDiskSessionResources(321mockContext as any,322mockFs,323logService324);325326// Create a resource327const resultUri = URI.joinPath(328mockContext.storageUri,329'chat-session-resources',330'session-dispose',331'tool-1',332'content.txt'333);334await testService.ensure('session-dispose', 'tool-1', 'content');335336// Dispose the service BEFORE advancing time337testService.dispose();338339// Advance time past retention + cleanup interval340await vi.advanceTimersByTimeAsync(RETENTION_PERIOD_MS + CLEANUP_INTERVAL_MS + 1000);341342await testService.currentCleanup;343344// Resource should still exist because cleanup was cancelled345const stat = await mockFs.stat(resultUri);346expect(stat.type).toBe(FileType.File);347});348349test('accessing resource resets its expiration timer', async () => {350vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));351352const testService = new ChatDiskSessionResources(353mockContext as any,354mockFs,355logService356);357358// Create a resource at T=0359const resultUri = await testService.ensure('session-refresh', 'tool-refresh', 'content v1');360361// Advance time to just before retention expires (7.9 hours)362await vi.advanceTimersByTimeAsync(RETENTION_PERIOD_MS - 1000);363364// Access/update the resource - this should reset the access timestamp365await testService.ensure('session-refresh', 'tool-refresh', 'content v2');366367// Advance time past what would have been the original expiration + cleanup368await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS + 2000);369370await testService.currentCleanup;371372// Resource should still exist because it was refreshed373const contentUri = URI.joinPath(resultUri, 'content.txt');374const stat = await mockFs.stat(contentUri);375expect(stat.type).toBe(FileType.File);376377testService.dispose();378});379});380});381382383384