Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { Emitter, Event } from '../../../../base/common/event.js';
8
import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
9
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
10
import { ILogService, NullLogService } from '../../../log/common/log.js';
11
import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js';
12
import { IConfigurationService, type IConfigurationChangeEvent } from '../../../configuration/common/configuration.js';
13
import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
14
import { RemoteAgentHostService } from '../../browser/remoteAgentHostServiceImpl.js';
15
import { parseRemoteAgentHostInput, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, entryToRawEntry, type IRawRemoteAgentHostEntry, type IRemoteAgentHostEntry } from '../../common/remoteAgentHostService.js';
16
import { DeferredPromise } from '../../../../base/common/async.js';
17
18
// ---- Mock protocol client ---------------------------------------------------
19
20
class MockProtocolClient extends Disposable {
21
private static _nextId = 1;
22
readonly clientId = `mock-client-${MockProtocolClient._nextId++}`;
23
24
private readonly _onDidClose = this._register(new Emitter<void>());
25
readonly onDidClose = this._onDidClose.event;
26
readonly onDidAction = Event.None;
27
readonly onDidNotification = Event.None;
28
29
public connectDeferred = new DeferredPromise<void>();
30
31
constructor(public readonly mockAddress: string) {
32
super();
33
}
34
35
async connect(): Promise<void> {
36
return this.connectDeferred.p;
37
}
38
39
fireClose(): void {
40
this._onDidClose.fire();
41
}
42
}
43
44
// ---- Test configuration service ---------------------------------------------
45
46
class TestConfigurationService {
47
private readonly _onDidChangeConfiguration = new Emitter<Partial<IConfigurationChangeEvent>>();
48
readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event;
49
50
private _entries: IRawRemoteAgentHostEntry[] = [];
51
private _enabled = true;
52
53
getValue(key?: string): unknown {
54
if (key === RemoteAgentHostsEnabledSettingId) {
55
return this._enabled;
56
}
57
return this._entries;
58
}
59
60
inspect(_key: string) {
61
return {
62
userValue: this._entries,
63
};
64
}
65
66
async updateValue(_key: string, value: unknown): Promise<void> {
67
this._entries = (value as IRawRemoteAgentHostEntry[] | undefined) ?? [];
68
this._onDidChangeConfiguration.fire({
69
affectsConfiguration: (key: string) => key === RemoteAgentHostsSettingId || key === RemoteAgentHostsEnabledSettingId,
70
});
71
}
72
73
get entries(): readonly IRawRemoteAgentHostEntry[] {
74
return this._entries;
75
}
76
77
setEntries(entries: IRemoteAgentHostEntry[]): void {
78
this._entries = entries.map(entryToRawEntry).filter((e): e is IRawRemoteAgentHostEntry => e !== undefined);
79
this._onDidChangeConfiguration.fire({
80
affectsConfiguration: (key: string) => key === RemoteAgentHostsSettingId || key === RemoteAgentHostsEnabledSettingId,
81
});
82
}
83
84
setEnabled(enabled: boolean): void {
85
this._enabled = enabled;
86
this._onDidChangeConfiguration.fire({
87
affectsConfiguration: (key: string) => key === RemoteAgentHostsEnabledSettingId,
88
});
89
}
90
91
dispose(): void {
92
this._onDidChangeConfiguration.dispose();
93
}
94
}
95
96
suite('RemoteAgentHostService', () => {
97
98
const disposables = new DisposableStore();
99
let configService: TestConfigurationService;
100
let createdClients: MockProtocolClient[];
101
let service: RemoteAgentHostService;
102
103
setup(() => {
104
configService = new TestConfigurationService();
105
disposables.add(toDisposable(() => configService.dispose()));
106
107
createdClients = [];
108
109
const instantiationService = disposables.add(new TestInstantiationService());
110
instantiationService.stub(ILogService, new NullLogService());
111
instantiationService.stub(IConfigurationService, configService as Partial<IConfigurationService>);
112
113
// Mock the instantiation service to capture created protocol clients
114
const mockInstantiationService: Partial<IInstantiationService> = {
115
createInstance: (_ctor: unknown, ...args: unknown[]) => {
116
const client = new MockProtocolClient(args[0] as string);
117
disposables.add(client);
118
createdClients.push(client);
119
return client;
120
},
121
};
122
instantiationService.stub(IInstantiationService, mockInstantiationService as Partial<IInstantiationService>);
123
124
service = disposables.add(instantiationService.createInstance(RemoteAgentHostService));
125
});
126
127
teardown(() => disposables.clear());
128
ensureNoDisposablesAreLeakedInTestSuite();
129
130
/** Wait for a connection to reach Connected status. */
131
async function waitForConnected(): Promise<void> {
132
while (!service.connections.some(c => c.status === RemoteAgentHostConnectionStatus.Connected)) {
133
await Event.toPromise(service.onDidChangeConnections);
134
}
135
}
136
137
test('starts with no connections when setting is empty', () => {
138
assert.deepStrictEqual(service.connections, []);
139
});
140
141
test('parses supported remote host inputs', () => {
142
assert.deepStrictEqual([
143
parseRemoteAgentHostInput('Listening on ws://127.0.0.1:8089'),
144
parseRemoteAgentHostInput('Agent host proxy listening on ws://127.0.0.1:8089'),
145
parseRemoteAgentHostInput('127.0.0.1:8089'),
146
parseRemoteAgentHostInput('ws://127.0.0.1:8089'),
147
parseRemoteAgentHostInput('ws://127.0.0.1:40147?tkn=c9d12867-da33-425e-8d39-0d071e851597'),
148
parseRemoteAgentHostInput('wss://secure.example.com:443'),
149
], [
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:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } },
154
{ parsed: { address: '127.0.0.1:40147', connectionToken: 'c9d12867-da33-425e-8d39-0d071e851597', suggestedName: '127.0.0.1:40147' } },
155
{ parsed: { address: 'wss://secure.example.com', connectionToken: undefined, suggestedName: 'secure.example.com' } },
156
]);
157
});
158
159
test('getConnection returns undefined for unknown address', () => {
160
assert.strictEqual(service.getConnection('ws://unknown:1234'), undefined);
161
});
162
163
test('creates connection when setting is updated', async () => {
164
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
165
166
// Resolve the connect promise
167
assert.strictEqual(createdClients.length, 1);
168
createdClients[0].connectDeferred.complete();
169
await waitForConnected();
170
171
const connected = service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected);
172
assert.strictEqual(connected.length, 1);
173
assert.strictEqual(connected[0].address, 'host1:8080');
174
assert.strictEqual(connected[0].name, 'Host 1');
175
});
176
177
test('getConnection returns client after successful connect', async () => {
178
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
179
createdClients[0].connectDeferred.complete();
180
await waitForConnected();
181
182
const connection = service.getConnection('ws://host1:8080');
183
assert.ok(connection);
184
assert.strictEqual(connection.clientId, createdClients[0].clientId);
185
});
186
187
test('removes connection when setting entry is removed', async () => {
188
// Add a connection
189
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
190
createdClients[0].connectDeferred.complete();
191
await waitForConnected();
192
193
// Remove it
194
const removedEvent = Event.toPromise(service.onDidChangeConnections);
195
configService.setEntries([]);
196
await removedEvent;
197
198
assert.strictEqual(service.connections.length, 0);
199
assert.strictEqual(service.getConnection('ws://host1:8080'), undefined);
200
});
201
202
test('fires onDidChangeConnections when connection closes', async () => {
203
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
204
createdClients[0].connectDeferred.complete();
205
await waitForConnected();
206
207
// Simulate connection close — entry transitions to Disconnected
208
const closedEvent = Event.toPromise(service.onDidChangeConnections);
209
createdClients[0].fireClose();
210
await closedEvent;
211
212
// Connection is still tracked (for reconnect) but getConnection returns undefined
213
assert.strictEqual(service.getConnection('ws://host1:8080'), undefined);
214
const entry = service.connections.find(c => c.address === 'host1:8080');
215
assert.ok(entry);
216
assert.strictEqual(entry.status, RemoteAgentHostConnectionStatus.Disconnected);
217
});
218
219
test('removes connection on connect failure', async () => {
220
configService.setEntries([{ name: 'Bad', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://bad:9999' } }]);
221
assert.strictEqual(createdClients.length, 1);
222
223
// Fail the connection and wait for the service to react
224
const connectionChanged = Event.toPromise(service.onDidChangeConnections);
225
createdClients[0].connectDeferred.error(new Error('Connection refused'));
226
await connectionChanged;
227
228
assert.strictEqual(service.connections.length, 0);
229
assert.strictEqual(service.getConnection('ws://bad:9999'), undefined);
230
});
231
232
test('manages multiple connections independently', async () => {
233
configService.setEntries([
234
{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } },
235
{ name: 'Host 2', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host2:8080' } },
236
]);
237
238
assert.strictEqual(createdClients.length, 2);
239
createdClients[0].connectDeferred.complete();
240
createdClients[1].connectDeferred.complete();
241
await waitForConnected();
242
243
assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 2);
244
245
const conn1 = service.getConnection('ws://host1:8080');
246
const conn2 = service.getConnection('ws://host2:8080');
247
assert.ok(conn1);
248
assert.ok(conn2);
249
assert.notStrictEqual(conn1.clientId, conn2.clientId);
250
});
251
252
test('does not re-create existing connections on setting update', async () => {
253
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
254
createdClients[0].connectDeferred.complete();
255
await waitForConnected();
256
257
const firstClientId = createdClients[0].clientId;
258
259
// Update setting with same address (but different name)
260
configService.setEntries([{ name: 'Renamed', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
261
262
// Should NOT have created a second client
263
assert.strictEqual(createdClients.length, 1);
264
265
// Connection should still work with same client
266
const conn = service.getConnection('ws://host1:8080');
267
assert.ok(conn);
268
assert.strictEqual(conn.clientId, firstClientId);
269
270
// But name should be updated
271
const entry = service.connections.find(c => c.address === 'host1:8080');
272
assert.strictEqual(entry?.name, 'Renamed');
273
});
274
275
test('addRemoteAgentHost stores the entry and waits for connection', async () => {
276
const connectionPromise = service.addRemoteAgentHost({
277
name: 'Host 1',
278
connectionToken: 'secret-token',
279
connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' },
280
});
281
282
assert.deepStrictEqual(configService.entries, [{
283
address: 'host1:8080',
284
name: 'Host 1',
285
connectionToken: 'secret-token',
286
}]);
287
assert.strictEqual(createdClients.length, 1);
288
289
createdClients[0].connectDeferred.complete();
290
const connection = await connectionPromise;
291
292
assert.deepStrictEqual(connection, {
293
address: 'host1:8080',
294
name: 'Host 1',
295
clientId: createdClients[0].clientId,
296
defaultDirectory: undefined,
297
status: RemoteAgentHostConnectionStatus.Connected,
298
});
299
});
300
301
test('addRemoteAgentHost updates existing configured entries without reconnecting', async () => {
302
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
303
createdClients[0].connectDeferred.complete();
304
await waitForConnected();
305
306
const connection = await service.addRemoteAgentHost({
307
name: 'Updated Host',
308
connectionToken: 'new-token',
309
connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' },
310
});
311
312
assert.strictEqual(createdClients.length, 1);
313
assert.deepStrictEqual(configService.entries, [{
314
address: 'host1:8080',
315
name: 'Updated Host',
316
connectionToken: 'new-token',
317
}]);
318
assert.deepStrictEqual(connection, {
319
address: 'host1:8080',
320
name: 'Updated Host',
321
clientId: createdClients[0].clientId,
322
defaultDirectory: undefined,
323
status: RemoteAgentHostConnectionStatus.Connected,
324
});
325
});
326
327
test('addRemoteAgentHost appends when adding a second host', async () => {
328
// Add first host
329
const firstPromise = service.addRemoteAgentHost({
330
name: 'Host 1',
331
connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' },
332
});
333
createdClients[0].connectDeferred.complete();
334
await firstPromise;
335
336
// Add second host
337
const secondPromise = service.addRemoteAgentHost({
338
name: 'Host 2',
339
connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host2:9090' },
340
});
341
createdClients[1].connectDeferred.complete();
342
await secondPromise;
343
344
assert.strictEqual(createdClients.length, 2);
345
assert.deepStrictEqual(configService.entries, [
346
{ address: 'host1:8080', name: 'Host 1', connectionToken: undefined },
347
{ address: 'host2:9090', name: 'Host 2', connectionToken: undefined },
348
]);
349
assert.strictEqual(service.connections.length, 2);
350
});
351
352
test('addRemoteAgentHost resolves when connection completes before wait is created', async () => {
353
// Simulate a fast connect: the mock client resolves synchronously
354
// during the config change handler, before addRemoteAgentHost has a
355
// chance to create its DeferredPromise wait.
356
const originalUpdateValue = configService.updateValue.bind(configService);
357
configService.updateValue = async (key: string, value: unknown) => {
358
await originalUpdateValue(key, value);
359
// Complete the connection synchronously inside the config change callback
360
if (createdClients.length > 0) {
361
createdClients[createdClients.length - 1].connectDeferred.complete();
362
}
363
};
364
365
const connection = await service.addRemoteAgentHost({
366
name: 'Fast Host',
367
connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'fast-host:1234' },
368
});
369
370
assert.strictEqual(connection.address, 'fast-host:1234');
371
assert.strictEqual(connection.name, 'Fast Host');
372
});
373
374
test('disabling the enabled setting disconnects all remotes', async () => {
375
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]);
376
createdClients[0].connectDeferred.complete();
377
await waitForConnected();
378
assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1);
379
380
configService.setEnabled(false);
381
382
assert.strictEqual(service.connections.length, 0);
383
});
384
385
test('addRemoteAgentHost throws when disabled', async () => {
386
configService.setEnabled(false);
387
388
await assert.rejects(
389
() => service.addRemoteAgentHost({ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }),
390
/not enabled/,
391
);
392
});
393
394
test('re-enabling reconnects configured remotes', async () => {
395
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]);
396
createdClients[0].connectDeferred.complete();
397
await waitForConnected();
398
assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1);
399
400
configService.setEnabled(false);
401
assert.strictEqual(service.connections.length, 0);
402
403
configService.setEnabled(true);
404
assert.strictEqual(createdClients.length, 2); // new client created
405
createdClients[1].connectDeferred.complete();
406
await waitForConnected();
407
assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1);
408
});
409
410
test('removeRemoteAgentHost removes entry and disconnects', async () => {
411
configService.setEntries([
412
{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } },
413
{ name: 'Host 2', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host2:9090' } },
414
]);
415
createdClients[0].connectDeferred.complete();
416
createdClients[1].connectDeferred.complete();
417
await waitForConnected();
418
assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 2);
419
420
await service.removeRemoteAgentHost('ws://host1:8080');
421
422
assert.deepStrictEqual(configService.entries, [
423
{ address: 'ws://host2:9090', name: 'Host 2', connectionToken: undefined },
424
]);
425
assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1);
426
assert.strictEqual(service.getConnection('ws://host1:8080'), undefined);
427
assert.ok(service.getConnection('ws://host2:9090'));
428
});
429
430
test('removeRemoteAgentHost normalizes address before removing', async () => {
431
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]);
432
createdClients[0].connectDeferred.complete();
433
await waitForConnected();
434
435
await service.removeRemoteAgentHost('ws://host1:8080');
436
437
assert.deepStrictEqual(configService.entries, []);
438
assert.strictEqual(service.connections.length, 0);
439
});
440
441
suite('addManagedConnection', () => {
442
443
// Build a transport disposable that records when it ran.
444
function makeTransportDisposable(): { disposable: { dispose(): void }; disposed: () => boolean } {
445
let disposed = false;
446
return {
447
disposable: { dispose: () => { disposed = true; } },
448
disposed: () => disposed,
449
};
450
}
451
452
// Inject a managed connection (mimicking the SSH/tunnel renderer flow).
453
async function addManaged(name: string, address: string, transport?: { dispose(): void }) {
454
const mockClient = disposables.add(new MockProtocolClient(`ws://${address}`));
455
return service.addManagedConnection(
456
{ name, connection: { type: RemoteAgentHostEntryType.WebSocket, address } },
457
mockClient as unknown as Parameters<typeof service.addManagedConnection>[1],
458
transport,
459
);
460
}
461
462
test('disposes transportDisposable when entry is removed via removeRemoteAgentHost', async () => {
463
const t = makeTransportDisposable();
464
await addManaged('Managed', 'managed:1234', t.disposable);
465
assert.strictEqual(t.disposed(), false);
466
467
await service.removeRemoteAgentHost('ws://managed:1234');
468
469
assert.strictEqual(t.disposed(), true, 'transport disposable runs when entry is removed');
470
assert.strictEqual(service.getConnection('ws://managed:1234'), undefined);
471
});
472
473
test('disposes previous transportDisposable when entry is replaced', async () => {
474
const t1 = makeTransportDisposable();
475
await addManaged('Managed', 'managed:1234', t1.disposable);
476
477
const t2 = makeTransportDisposable();
478
await addManaged('Managed', 'managed:1234', t2.disposable);
479
480
assert.strictEqual(t1.disposed(), true, 'first transport disposable runs when entry is replaced');
481
assert.strictEqual(t2.disposed(), false, 'second transport disposable is still alive');
482
});
483
484
test('disposes transportDisposable when service itself is disposed', async () => {
485
const t = makeTransportDisposable();
486
await addManaged('Managed', 'managed:1234', t.disposable);
487
488
service.dispose();
489
490
assert.strictEqual(t.disposed(), true, 'transport disposable runs when service is disposed');
491
});
492
});
493
});
494
495