// ***************************************************************************** // Copyright (C) 2025 EclipseSource GmbH. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; let disableJSDOM = enableJSDOM(); import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; FrontendApplicationConfigProvider.set({}); import { expect } from 'chai'; import { PreferenceService, ILogger } from '@theia/core'; import URI from '@theia/core/lib/common/uri'; import { Container } from '@theia/core/shared/inversify'; import { LaunchListProvider, LaunchRunnerProvider, LaunchStopProvider, } from './workspace-launch-provider'; import { DebugConfigurationManager } from '@theia/debug/lib/browser/debug-configuration-manager'; import { DebugSessionManager } from '@theia/debug/lib/browser/debug-session-manager'; import { DebugSessionOptions } from '@theia/debug/lib/browser/debug-session-options'; import { DebugConfiguration } from '@theia/debug/lib/common/debug-common'; import { DebugCompound } from '@theia/debug/lib/common/debug-compound'; import { DebugSession } from '@theia/debug/lib/browser/debug-session'; import { WorkspaceFunctionScope } from './workspace-functions'; import { AiConfigurationService } from '@theia/ai-core'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { MockLogger } from '@theia/core/lib/common/test/mock-logger'; const makeTrustAwareReader = (): AiConfigurationService => ({ get: (_name: string, fallback?: T) => fallback, ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { } }) } as unknown as AiConfigurationService); const makeEnvVariablesServer = (): EnvVariablesServer => ({ getHomeDirUri: async () => 'file:///home/test', getExecPath: async () => '', getVariables: async () => [], getValue: async () => undefined, getConfigDirUri: async () => 'file:///home/test/.config' } as unknown as EnvVariablesServer); disableJSDOM(); describe('Launch Management Tool Providers', () => { let container: Container; let launchListProvider: LaunchListProvider; let launchRunnerProvider: LaunchRunnerProvider; let launchStopProvider: LaunchStopProvider; let mockDebugConfigurationManager: Partial; let mockDebugSessionManager: Partial; before(() => { disableJSDOM = enableJSDOM(); }); after(() => { disableJSDOM(); }); beforeEach(() => { container = new Container(); container.bind(ILogger).to(MockLogger).inSingletonScope(); const mockWorkspaceService = { tryGetRoots: () => [ { resource: new URI('file:///workspace') } ], roots: Promise.resolve([ { resource: new URI('file:///workspace') } ]), onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false } as unknown as PreferenceService); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); const mockConfigs = createMockConfigurations(); mockDebugConfigurationManager = { load: () => Promise.resolve(), get all(): IterableIterator { function* configIterator(): IterableIterator { for (const config of mockConfigs) { yield config; } } return configIterator(); }, }; mockDebugSessionManager = { // eslint-disable-next-line @typescript-eslint/no-explicit-any start: async (options: DebugSessionOptions | string): Promise => { if ( typeof options === 'string' || DebugSessionOptions.isCompound(options) ) { return true; } return { id: 'test-session-id', configuration: { name: 'Test Config' }, } as DebugSession; }, terminateSession: () => Promise.resolve(), currentSession: undefined, sessions: [], }; container .bind(DebugConfigurationManager) .toConstantValue( mockDebugConfigurationManager as DebugConfigurationManager ); container .bind(DebugSessionManager) .toConstantValue(mockDebugSessionManager as DebugSessionManager); container.bind(LaunchListProvider).toSelf(); container.bind(LaunchRunnerProvider).toSelf(); container.bind(LaunchStopProvider).toSelf(); launchListProvider = container.get(LaunchListProvider); launchRunnerProvider = container.get(LaunchRunnerProvider); launchStopProvider = container.get(LaunchStopProvider); }); function createMockConfigurations(): DebugSessionOptions[] { const config1: DebugConfiguration = { name: 'Node.js Debug', type: 'node', request: 'launch', program: '${workspaceFolder}/app.js', }; const config2: DebugConfiguration = { name: 'Python Debug', type: 'python', request: 'launch', program: '${workspaceFolder}/main.py', }; const compound: DebugCompound = { name: 'Launch All', configurations: ['Node.js Debug', 'Python Debug'], }; return [ { name: 'Node.js Debug', configuration: config1, workspaceFolderUri: 'file:///workspace', }, { name: 'Python Debug', configuration: config2, workspaceFolderUri: 'file:///workspace', }, { name: 'Launch All', compound, workspaceFolderUri: 'file:///workspace' }, ]; } describe('LaunchListProvider', () => { it('should provide the correct tool metadata', () => { const tool = launchListProvider.getTool(); expect(tool.id).to.equal('listLaunchConfigurations'); expect(tool.name).to.equal('listLaunchConfigurations'); expect(tool.description).to.contain( 'Lists available launch configurations' ); expect(tool.parameters.required).to.deep.equal([]); }); it('should list all configurations when filter is omitted', async () => { const tool = launchListProvider.getTool(); const result = await tool.handler('{}'); expect(result).to.be.a('string'); const configurations = JSON.parse(result as string); expect(configurations).to.be.an('array'); expect(configurations).to.have.lengthOf(3); expect(configurations.map((c: { name: string }) => c.name)).to.include('Node.js Debug'); expect(configurations.map((c: { name: string }) => c.name)).to.include('Python Debug'); expect(configurations.map((c: { name: string }) => c.name)).to.include('Launch All'); // All configurations should show running: false since no sessions are active configurations.forEach((config: { name: string; running: boolean; workspaceRoot?: string }) => { expect(config.running).to.equal(false); }); }); it('should filter configurations by name', async () => { const tool = launchListProvider.getTool(); const result = await tool.handler('{"filter":"Node"}'); expect(result).to.be.a('string'); const configurations = JSON.parse(result as string); expect(configurations).to.be.an('array'); expect(configurations).to.have.lengthOf(1); expect(configurations[0].name).to.equal('Node.js Debug'); expect(configurations[0].running).to.equal(false); }); it('should handle case-insensitive filtering', async () => { const tool = launchListProvider.getTool(); const result = await tool.handler('{"filter":"python"}'); expect(result).to.be.a('string'); const configurations = JSON.parse(result as string); expect(configurations).to.be.an('array'); expect(configurations).to.have.lengthOf(1); expect(configurations[0].name).to.equal('Python Debug'); expect(configurations[0].running).to.equal(false); }); it('should include workspace root info in listed configurations', async () => { const tool = launchListProvider.getTool(); const result = await tool.handler('{"filter":""}'); expect(result).to.be.a('string'); const configurations = JSON.parse(result as string); // All configs in our mock are scoped to 'file:///workspace' whose basename is 'workspace' configurations.forEach((config: { name: string; running: boolean; workspaceRoot?: string }) => { expect(config.workspaceRoot).to.equal('workspace'); }); }); }); describe('LaunchRunnerProvider', () => { it('should provide the correct tool metadata', () => { const tool = launchRunnerProvider.getTool(); expect(tool.id).to.equal('runLaunchConfiguration'); expect(tool.name).to.equal('runLaunchConfiguration'); expect(tool.description).to.contain( 'Starts a launch configuration' ); expect(tool.parameters.required).to.deep.equal([ 'configurationName', ]); }); it('should start a valid configuration', async () => { const tool = launchRunnerProvider.getTool(); const result = await tool.handler( '{"configurationName":"Node.js Debug"}' ); expect(result).to.be.a('string'); expect(result).to.contain('Node.js Debug'); expect(result).to.contain('started with session ID'); }); it('should handle unknown configuration', async () => { const tool = launchRunnerProvider.getTool(); const result = await tool.handler( '{"configurationName":"Unknown Config"}' ); expect(result).to.be.a('string'); expect(result).to.contain('Did not find a launch configuration'); expect(result).to.contain('Unknown Config'); }); it('should handle compound configurations', async () => { const tool = launchRunnerProvider.getTool(); const result = await tool.handler( '{"configurationName":"Launch All"}' ); expect(result).to.be.a('string'); expect(result).to.contain('Compound launch configuration'); expect(result).to.contain('Launch All'); expect(result).to.contain('started successfully'); }); }); describe('LaunchStopProvider', () => { it('should provide the correct tool metadata', () => { const tool = launchStopProvider.getTool(); expect(tool.id).to.equal('stopLaunchConfiguration'); expect(tool.name).to.equal('stopLaunchConfiguration'); expect(tool.description).to.contain( 'Stops an active launch configuration' ); expect(tool.parameters.required).to.deep.equal([]); }); it('should stop current session when no configuration name provided', async () => { ( mockDebugSessionManager as { currentSession: unknown } ).currentSession = { id: 'current-session', configuration: { name: 'Current Config' }, }; const tool = launchStopProvider.getTool(); const result = await tool.handler('{}'); expect(result).to.be.a('string'); expect(result).to.contain( 'Successfully stopped current debug session' ); expect(result).to.contain('Current Config'); }); it('should handle no active session', async () => { ( mockDebugSessionManager as { currentSession: unknown } ).currentSession = undefined; const tool = launchStopProvider.getTool(); const result = await tool.handler('{}'); expect(result).to.be.a('string'); expect(result).to.contain('No active debug session to stop'); }); it('should stop specific session by name', async () => { Object.defineProperty(mockDebugSessionManager, 'sessions', { value: [ { id: 'session-1', configuration: { name: 'Node.js Debug' }, }, { id: 'session-2', configuration: { name: 'Python Debug' }, }, ], writable: true, configurable: true, }); const tool = launchStopProvider.getTool(); const result = await tool.handler( '{"configurationName":"Node.js Debug"}' ); expect(result).to.be.a('string'); expect(result).to.contain( 'Successfully stopped launch configuration' ); expect(result).to.contain('Node.js Debug'); }); it('should handle session not found by name', async () => { Object.defineProperty(mockDebugSessionManager, 'sessions', { value: [], writable: true, configurable: true, }); const tool = launchStopProvider.getTool(); const result = await tool.handler( '{"configurationName":"Unknown Config"}' ); expect(result).to.be.a('string'); expect(result).to.contain('No active session found'); expect(result).to.contain('Unknown Config'); }); }); describe('Multi-root disambiguation', () => { it('should report ambiguity when same config name exists in multiple roots', async () => { const multiRootContainer = new Container(); multiRootContainer.bind(ILogger).to(MockLogger).inSingletonScope(); const multiRootWorkspaceService = { tryGetRoots: () => [ { resource: new URI('file:///home/user/frontend') }, { resource: new URI('file:///home/user/backend') } ], roots: Promise.resolve([ { resource: new URI('file:///home/user/frontend') }, { resource: new URI('file:///home/user/backend') } ]), onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; multiRootContainer.bind(WorkspaceService).toConstantValue(multiRootWorkspaceService); multiRootContainer.bind(FileService).toConstantValue({} as FileService); multiRootContainer.bind(PreferenceService).toConstantValue({ get: () => false } as unknown as PreferenceService); multiRootContainer.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); multiRootContainer.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); multiRootContainer.bind(WorkspaceFunctionScope).toSelf(); const debugConfig: DebugConfiguration = { name: 'Start App', type: 'node', request: 'launch', program: 'app.js', }; const duplicateConfigs: DebugSessionOptions[] = [ { name: 'Start App', configuration: debugConfig, workspaceFolderUri: 'file:///home/user/frontend' }, { name: 'Start App', configuration: debugConfig, workspaceFolderUri: 'file:///home/user/backend' }, ]; const mockConfigManager = { load: () => Promise.resolve(), get all(): IterableIterator { return duplicateConfigs[Symbol.iterator](); }, }; const mockSessionManager = { start: async () => ({ id: 'test-session', configuration: { name: 'Start App' } }), terminateSession: () => Promise.resolve(), currentSession: undefined, sessions: [], }; multiRootContainer.bind(DebugConfigurationManager).toConstantValue(mockConfigManager as unknown as DebugConfigurationManager); multiRootContainer.bind(DebugSessionManager).toConstantValue(mockSessionManager as unknown as DebugSessionManager); multiRootContainer.bind(LaunchRunnerProvider).toSelf(); multiRootContainer.bind(LaunchListProvider).toSelf(); // Listing should show both configs with their respective roots const listTool = multiRootContainer.get(LaunchListProvider).getTool(); const listResult = await listTool.handler('{"filter":""}'); const configs = JSON.parse(listResult as string); expect(configs).to.have.lengthOf(2); expect(configs[0].workspaceRoot).to.equal('frontend'); expect(configs[1].workspaceRoot).to.equal('backend'); // Running without workspaceRoot should report ambiguity const runTool = multiRootContainer.get(LaunchRunnerProvider).getTool(); const ambiguousResult = await runTool.handler('{"configurationName":"Start App"}'); expect(ambiguousResult).to.be.a('string'); expect(ambiguousResult as string).to.include('Ambiguous'); expect(ambiguousResult as string).to.include('frontend'); expect(ambiguousResult as string).to.include('backend'); // Running with workspaceRoot should succeed const disambiguatedResult = await runTool.handler('{"configurationName":"Start App","workspaceRoot":"frontend"}'); expect(disambiguatedResult).to.be.a('string'); expect(disambiguatedResult as string).to.not.include('Ambiguous'); expect(disambiguatedResult as string).to.include('started'); }); }); });