Path: blob/main/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.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 { Emitter, Event } from '../../../../base/common/event.js';7import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';8import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';9import { ILogService, NullLogService } from '../../../log/common/log.js';10import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js';11import { IConfigurationService, type IConfigurationChangeEvent } from '../../../configuration/common/configuration.js';12import { IInstantiationService } from '../../../instantiation/common/instantiation.js';13import { RemoteAgentHostService } from '../../browser/remoteAgentHostServiceImpl.js';14import { parseRemoteAgentHostInput, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, entryToRawEntry, type IRawRemoteAgentHostEntry, type IRemoteAgentHostEntry } from '../../common/remoteAgentHostService.js';15import { DeferredPromise } from '../../../../base/common/async.js';1617// ---- Mock protocol client ---------------------------------------------------1819class MockProtocolClient extends Disposable {20private static _nextId = 1;21readonly clientId = `mock-client-${MockProtocolClient._nextId++}`;2223private readonly _onDidClose = this._register(new Emitter<void>());24readonly onDidClose = this._onDidClose.event;25readonly onDidAction = Event.None;26readonly onDidNotification = Event.None;2728public connectDeferred = new DeferredPromise<void>();2930constructor(public readonly mockAddress: string) {31super();32}3334async connect(): Promise<void> {35return this.connectDeferred.p;36}3738fireClose(): void {39this._onDidClose.fire();40}41}4243// ---- Test configuration service ---------------------------------------------4445class TestConfigurationService {46private readonly _onDidChangeConfiguration = new Emitter<Partial<IConfigurationChangeEvent>>();47readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event;4849private _entries: IRawRemoteAgentHostEntry[] = [];50private _enabled = true;5152getValue(key?: string): unknown {53if (key === RemoteAgentHostsEnabledSettingId) {54return this._enabled;55}56return this._entries;57}5859inspect(_key: string) {60return {61userValue: this._entries,62};63}6465async updateValue(_key: string, value: unknown): Promise<void> {66this._entries = (value as IRawRemoteAgentHostEntry[] | undefined) ?? [];67this._onDidChangeConfiguration.fire({68affectsConfiguration: (key: string) => key === RemoteAgentHostsSettingId || key === RemoteAgentHostsEnabledSettingId,69});70}7172get entries(): readonly IRawRemoteAgentHostEntry[] {73return this._entries;74}7576setEntries(entries: IRemoteAgentHostEntry[]): void {77this._entries = entries.map(entryToRawEntry).filter((e): e is IRawRemoteAgentHostEntry => e !== undefined);78this._onDidChangeConfiguration.fire({79affectsConfiguration: (key: string) => key === RemoteAgentHostsSettingId || key === RemoteAgentHostsEnabledSettingId,80});81}8283setEnabled(enabled: boolean): void {84this._enabled = enabled;85this._onDidChangeConfiguration.fire({86affectsConfiguration: (key: string) => key === RemoteAgentHostsEnabledSettingId,87});88}8990dispose(): void {91this._onDidChangeConfiguration.dispose();92}93}9495suite('RemoteAgentHostService', () => {9697const disposables = new DisposableStore();98let configService: TestConfigurationService;99let createdClients: MockProtocolClient[];100let service: RemoteAgentHostService;101102setup(() => {103configService = new TestConfigurationService();104disposables.add(toDisposable(() => configService.dispose()));105106createdClients = [];107108const instantiationService = disposables.add(new TestInstantiationService());109instantiationService.stub(ILogService, new NullLogService());110instantiationService.stub(IConfigurationService, configService as Partial<IConfigurationService>);111112// Mock the instantiation service to capture created protocol clients113const mockInstantiationService: Partial<IInstantiationService> = {114createInstance: (_ctor: unknown, ...args: unknown[]) => {115const client = new MockProtocolClient(args[0] as string);116disposables.add(client);117createdClients.push(client);118return client;119},120};121instantiationService.stub(IInstantiationService, mockInstantiationService as Partial<IInstantiationService>);122123service = disposables.add(instantiationService.createInstance(RemoteAgentHostService));124});125126teardown(() => disposables.clear());127ensureNoDisposablesAreLeakedInTestSuite();128129/** Wait for a connection to reach Connected status. */130async function waitForConnected(): Promise<void> {131while (!service.connections.some(c => c.status === RemoteAgentHostConnectionStatus.Connected)) {132await Event.toPromise(service.onDidChangeConnections);133}134}135136test('starts with no connections when setting is empty', () => {137assert.deepStrictEqual(service.connections, []);138});139140test('parses supported remote host inputs', () => {141assert.deepStrictEqual([142parseRemoteAgentHostInput('Listening on ws://127.0.0.1:8089'),143parseRemoteAgentHostInput('Agent host proxy listening on ws://127.0.0.1:8089'),144parseRemoteAgentHostInput('127.0.0.1:8089'),145parseRemoteAgentHostInput('ws://127.0.0.1:8089'),146parseRemoteAgentHostInput('ws://127.0.0.1:40147?tkn=c9d12867-da33-425e-8d39-0d071e851597'),147parseRemoteAgentHostInput('wss://secure.example.com:443'),148], [149{ parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } },150{ parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } },151{ parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } },152{ parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } },153{ parsed: { address: '127.0.0.1:40147', connectionToken: 'c9d12867-da33-425e-8d39-0d071e851597', suggestedName: '127.0.0.1:40147' } },154{ parsed: { address: 'wss://secure.example.com', connectionToken: undefined, suggestedName: 'secure.example.com' } },155]);156});157158test('getConnection returns undefined for unknown address', () => {159assert.strictEqual(service.getConnection('ws://unknown:1234'), undefined);160});161162test('creates connection when setting is updated', async () => {163configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);164165// Resolve the connect promise166assert.strictEqual(createdClients.length, 1);167createdClients[0].connectDeferred.complete();168await waitForConnected();169170const connected = service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected);171assert.strictEqual(connected.length, 1);172assert.strictEqual(connected[0].address, 'host1:8080');173assert.strictEqual(connected[0].name, 'Host 1');174});175176test('getConnection returns client after successful connect', async () => {177configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);178createdClients[0].connectDeferred.complete();179await waitForConnected();180181const connection = service.getConnection('ws://host1:8080');182assert.ok(connection);183assert.strictEqual(connection.clientId, createdClients[0].clientId);184});185186test('removes connection when setting entry is removed', async () => {187// Add a connection188configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);189createdClients[0].connectDeferred.complete();190await waitForConnected();191192// Remove it193const removedEvent = Event.toPromise(service.onDidChangeConnections);194configService.setEntries([]);195await removedEvent;196197assert.strictEqual(service.connections.length, 0);198assert.strictEqual(service.getConnection('ws://host1:8080'), undefined);199});200201test('fires onDidChangeConnections when connection closes', async () => {202configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);203createdClients[0].connectDeferred.complete();204await waitForConnected();205206// Simulate connection close — entry transitions to Disconnected207const closedEvent = Event.toPromise(service.onDidChangeConnections);208createdClients[0].fireClose();209await closedEvent;210211// Connection is still tracked (for reconnect) but getConnection returns undefined212assert.strictEqual(service.getConnection('ws://host1:8080'), undefined);213const entry = service.connections.find(c => c.address === 'host1:8080');214assert.ok(entry);215assert.strictEqual(entry.status, RemoteAgentHostConnectionStatus.Disconnected);216});217218test('removes connection on connect failure', async () => {219configService.setEntries([{ name: 'Bad', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://bad:9999' } }]);220assert.strictEqual(createdClients.length, 1);221222// Fail the connection and wait for the service to react223const connectionChanged = Event.toPromise(service.onDidChangeConnections);224createdClients[0].connectDeferred.error(new Error('Connection refused'));225await connectionChanged;226227assert.strictEqual(service.connections.length, 0);228assert.strictEqual(service.getConnection('ws://bad:9999'), undefined);229});230231test('manages multiple connections independently', async () => {232configService.setEntries([233{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } },234{ name: 'Host 2', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host2:8080' } },235]);236237assert.strictEqual(createdClients.length, 2);238createdClients[0].connectDeferred.complete();239createdClients[1].connectDeferred.complete();240await waitForConnected();241242assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 2);243244const conn1 = service.getConnection('ws://host1:8080');245const conn2 = service.getConnection('ws://host2:8080');246assert.ok(conn1);247assert.ok(conn2);248assert.notStrictEqual(conn1.clientId, conn2.clientId);249});250251test('does not re-create existing connections on setting update', async () => {252configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);253createdClients[0].connectDeferred.complete();254await waitForConnected();255256const firstClientId = createdClients[0].clientId;257258// Update setting with same address (but different name)259configService.setEntries([{ name: 'Renamed', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);260261// Should NOT have created a second client262assert.strictEqual(createdClients.length, 1);263264// Connection should still work with same client265const conn = service.getConnection('ws://host1:8080');266assert.ok(conn);267assert.strictEqual(conn.clientId, firstClientId);268269// But name should be updated270const entry = service.connections.find(c => c.address === 'host1:8080');271assert.strictEqual(entry?.name, 'Renamed');272});273274test('addRemoteAgentHost stores the entry and waits for connection', async () => {275const connectionPromise = service.addRemoteAgentHost({276name: 'Host 1',277connectionToken: 'secret-token',278connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' },279});280281assert.deepStrictEqual(configService.entries, [{282address: 'host1:8080',283name: 'Host 1',284connectionToken: 'secret-token',285}]);286assert.strictEqual(createdClients.length, 1);287288createdClients[0].connectDeferred.complete();289const connection = await connectionPromise;290291assert.deepStrictEqual(connection, {292address: 'host1:8080',293name: 'Host 1',294clientId: createdClients[0].clientId,295defaultDirectory: undefined,296status: RemoteAgentHostConnectionStatus.Connected,297});298});299300test('addRemoteAgentHost updates existing configured entries without reconnecting', async () => {301configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);302createdClients[0].connectDeferred.complete();303await waitForConnected();304305const connection = await service.addRemoteAgentHost({306name: 'Updated Host',307connectionToken: 'new-token',308connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' },309});310311assert.strictEqual(createdClients.length, 1);312assert.deepStrictEqual(configService.entries, [{313address: 'host1:8080',314name: 'Updated Host',315connectionToken: 'new-token',316}]);317assert.deepStrictEqual(connection, {318address: 'host1:8080',319name: 'Updated Host',320clientId: createdClients[0].clientId,321defaultDirectory: undefined,322status: RemoteAgentHostConnectionStatus.Connected,323});324});325326test('addRemoteAgentHost appends when adding a second host', async () => {327// Add first host328const firstPromise = service.addRemoteAgentHost({329name: 'Host 1',330connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' },331});332createdClients[0].connectDeferred.complete();333await firstPromise;334335// Add second host336const secondPromise = service.addRemoteAgentHost({337name: 'Host 2',338connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host2:9090' },339});340createdClients[1].connectDeferred.complete();341await secondPromise;342343assert.strictEqual(createdClients.length, 2);344assert.deepStrictEqual(configService.entries, [345{ address: 'host1:8080', name: 'Host 1', connectionToken: undefined },346{ address: 'host2:9090', name: 'Host 2', connectionToken: undefined },347]);348assert.strictEqual(service.connections.length, 2);349});350351test('addRemoteAgentHost resolves when connection completes before wait is created', async () => {352// Simulate a fast connect: the mock client resolves synchronously353// during the config change handler, before addRemoteAgentHost has a354// chance to create its DeferredPromise wait.355const originalUpdateValue = configService.updateValue.bind(configService);356configService.updateValue = async (key: string, value: unknown) => {357await originalUpdateValue(key, value);358// Complete the connection synchronously inside the config change callback359if (createdClients.length > 0) {360createdClients[createdClients.length - 1].connectDeferred.complete();361}362};363364const connection = await service.addRemoteAgentHost({365name: 'Fast Host',366connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'fast-host:1234' },367});368369assert.strictEqual(connection.address, 'fast-host:1234');370assert.strictEqual(connection.name, 'Fast Host');371});372373test('disabling the enabled setting disconnects all remotes', async () => {374configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]);375createdClients[0].connectDeferred.complete();376await waitForConnected();377assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1);378379configService.setEnabled(false);380381assert.strictEqual(service.connections.length, 0);382});383384test('addRemoteAgentHost throws when disabled', async () => {385configService.setEnabled(false);386387await assert.rejects(388() => service.addRemoteAgentHost({ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }),389/not enabled/,390);391});392393test('re-enabling reconnects configured remotes', async () => {394configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]);395createdClients[0].connectDeferred.complete();396await waitForConnected();397assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1);398399configService.setEnabled(false);400assert.strictEqual(service.connections.length, 0);401402configService.setEnabled(true);403assert.strictEqual(createdClients.length, 2); // new client created404createdClients[1].connectDeferred.complete();405await waitForConnected();406assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1);407});408409test('removeRemoteAgentHost removes entry and disconnects', async () => {410configService.setEntries([411{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } },412{ name: 'Host 2', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host2:9090' } },413]);414createdClients[0].connectDeferred.complete();415createdClients[1].connectDeferred.complete();416await waitForConnected();417assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 2);418419await service.removeRemoteAgentHost('ws://host1:8080');420421assert.deepStrictEqual(configService.entries, [422{ address: 'ws://host2:9090', name: 'Host 2', connectionToken: undefined },423]);424assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1);425assert.strictEqual(service.getConnection('ws://host1:8080'), undefined);426assert.ok(service.getConnection('ws://host2:9090'));427});428429test('removeRemoteAgentHost normalizes address before removing', async () => {430configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]);431createdClients[0].connectDeferred.complete();432await waitForConnected();433434await service.removeRemoteAgentHost('ws://host1:8080');435436assert.deepStrictEqual(configService.entries, []);437assert.strictEqual(service.connections.length, 0);438});439440suite('addManagedConnection', () => {441442// Build a transport disposable that records when it ran.443function makeTransportDisposable(): { disposable: { dispose(): void }; disposed: () => boolean } {444let disposed = false;445return {446disposable: { dispose: () => { disposed = true; } },447disposed: () => disposed,448};449}450451// Inject a managed connection (mimicking the SSH/tunnel renderer flow).452async function addManaged(name: string, address: string, transport?: { dispose(): void }) {453const mockClient = disposables.add(new MockProtocolClient(`ws://${address}`));454return service.addManagedConnection(455{ name, connection: { type: RemoteAgentHostEntryType.WebSocket, address } },456mockClient as unknown as Parameters<typeof service.addManagedConnection>[1],457transport,458);459}460461test('disposes transportDisposable when entry is removed via removeRemoteAgentHost', async () => {462const t = makeTransportDisposable();463await addManaged('Managed', 'managed:1234', t.disposable);464assert.strictEqual(t.disposed(), false);465466await service.removeRemoteAgentHost('ws://managed:1234');467468assert.strictEqual(t.disposed(), true, 'transport disposable runs when entry is removed');469assert.strictEqual(service.getConnection('ws://managed:1234'), undefined);470});471472test('disposes previous transportDisposable when entry is replaced', async () => {473const t1 = makeTransportDisposable();474await addManaged('Managed', 'managed:1234', t1.disposable);475476const t2 = makeTransportDisposable();477await addManaged('Managed', 'managed:1234', t2.disposable);478479assert.strictEqual(t1.disposed(), true, 'first transport disposable runs when entry is replaced');480assert.strictEqual(t2.disposed(), false, 'second transport disposable is still alive');481});482483test('disposes transportDisposable when service itself is disposed', async () => {484const t = makeTransportDisposable();485await addManaged('Managed', 'managed:1234', t.disposable);486487service.dispose();488489assert.strictEqual(t.disposed(), true, 'transport disposable runs when service is disposed');490});491});492});493494495