Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/test/testExecutionInExtension.ts
13383 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
import { downloadAndUnzipVSCode } from '@vscode/test-electron';
6
import { createVSIX } from '@vscode/vsce';
7
import { ChildProcess, spawn } from 'child_process';
8
import { AddressInfo, createServer, Socket } from 'net';
9
import * as fs from 'node:fs/promises';
10
import { tmpdir } from 'os';
11
import path from 'path';
12
import type { Browser, BrowserContext, Page } from 'playwright';
13
import { SimpleRPC } from '../src/extension/onboardDebug/node/copilotDebugWorker/rpc';
14
import { deserializeWorkbenchState } from '../src/platform/test/node/promptContextModel';
15
import { createCancelablePromise, DeferredPromise, disposableTimeout, raceCancellablePromises, retry, timeout } from '../src/util/vs/base/common/async';
16
import { Emitter, Event } from '../src/util/vs/base/common/event';
17
import { Iterable } from '../src/util/vs/base/common/iterator';
18
import { Disposable, DisposableStore, toDisposable } from '../src/util/vs/base/common/lifecycle';
19
import { extUriBiasedIgnorePathCase } from '../src/util/vs/base/common/resources';
20
import { URI } from '../src/util/vs/base/common/uri';
21
import { generateUuid } from '../src/util/vs/base/common/uuid';
22
import { ProxiedSimulationEndpointHealth } from './base/simulationEndpointHealth';
23
import { ProxiedSimulationOutcome } from './base/simulationOutcome';
24
import { SimulationTest } from './base/stest';
25
import { ProxiedSONOutputPrinter } from './jsonOutputPrinter';
26
import { logger } from './simulationLogger';
27
import { ITestRunResult, SimulationTestContext } from './testExecutor';
28
import { findFreePortFaster } from '../src/util/vs/base/node/ports';
29
import { waitForListenerOnPort } from '../src/util/node/ports';
30
31
const MAX_CONCURRENT_SESSIONS = 10;
32
const HOST = '127.0.0.1';
33
const CONNECT_TIMEOUT = 60_000;
34
35
export interface IInitParams {
36
folder: string;
37
}
38
39
export interface IInitResult {
40
argv: readonly string[];
41
}
42
43
export interface IRunTestParams {
44
testName: string;
45
outcomeDirectory: string;
46
runNumber: number;
47
}
48
49
export interface IRunTestResult {
50
result: ITestRunResult;
51
}
52
53
export class TestExecutionInExtension {
54
public static async create(ctx: SimulationTestContext) {
55
const store = new DisposableStore();
56
const { chromium } = await import('playwright');
57
58
//@ts-ignore
59
const testConfig: { default: { version: string } } = await import('../.vscode-test.mjs');
60
const [serverBinary, browser] = await Promise.all([
61
downloadAndUnzipVSCode(testConfig.default.version, getServerPlatform()),
62
chromium.launch({ headless: ctx.opts.headless }),
63
]);
64
const browserContext = await browser.newContext();
65
const childPortNumber = await findFreePortFaster(40_000, 1_000, 10_000);
66
const connectionToken = generateUuid();
67
68
const controlServer = createServer(s => inst._onConnection(s));
69
await new Promise((resolve, reject) => {
70
controlServer.on('listening', resolve);
71
controlServer.on('error', reject);
72
controlServer.listen(0, HOST);
73
});
74
store.add(toDisposable(() => controlServer.close()));
75
76
const vsixFile = await TestExecutionInExtension._packExtension();
77
const child = spawn(serverBinary, [
78
'--server-data-dir', path.resolve(__dirname, '../.vscode-test/server-data'),
79
'--extensions-dir', path.resolve(__dirname, '../.vscode-test/server-extensions'),
80
...ctx.opts.installExtensions.flatMap(ext => ['--install-extension', ext]),
81
'--install-extension', vsixFile,
82
'--force',
83
'--accept-server-license-terms',
84
'--connection-token', connectionToken,
85
'--port', String(childPortNumber),
86
'--host', HOST,
87
'--disable-workspace-trust',
88
'--start-server'
89
], {
90
shell: process.platform === 'win32',
91
env: {
92
...process.env,
93
VSCODE_SIMULATION_EXTENSION_ENTRY: __filename,
94
VSCODE_SIMULATION_CONTROL_PORT: String((controlServer.address() as AddressInfo).port),
95
}
96
});
97
const output: Buffer[] = [];
98
await new Promise((resolve, reject) => {
99
const log = logger.tag('VSCodeServer');
100
const push = (data: Buffer) => {
101
log.trace(data.toString().trim());
102
output.push(data);
103
};
104
child.stdout.on('data', push);
105
child.stderr.on('data', push);
106
child.on('error', reject);
107
child.on('spawn', resolve);
108
});
109
store.add(toDisposable(() => child.kill()));
110
111
await raceCancellablePromises([
112
createCancelablePromise(tkn => waitForListenerOnPort(childPortNumber, HOST, tkn)),
113
createCancelablePromise(tkn => new Promise<void>((resolve, reject) => {
114
const listener = () => {
115
reject(new Error(`Child process exited unexpectedly. Output: ${Buffer.concat(output).toString()}`));
116
};
117
child.on('exit', listener);
118
const l = tkn.onCancellationRequested(() => {
119
l.dispose();
120
child.off('exit', listener);
121
resolve();
122
});
123
})),
124
createCancelablePromise(tkn => timeout(10_000, tkn).then(e => {
125
throw new Error(`Timeout waiting for server to start. Output: ${Buffer.concat(output).toString()}`);
126
})),
127
]);
128
129
const inst = new TestExecutionInExtension(ctx, output, browser, browserContext, child, childPortNumber, store, connectionToken);
130
return inst;
131
}
132
133
private static async _packExtension() {
134
const packageJsonPath = path.resolve(__dirname, '..', 'package.json');
135
136
const extensionDir = path.resolve(__dirname, '..', 'test', 'simulationExtension');
137
const existingVsix = (await fs.readdir(extensionDir)).map(e => path.join(extensionDir, e)).find(f => f.endsWith('.vsix'));
138
if (existingVsix) {
139
const vsixMtime = await fs.stat(existingVsix).then(s => s.mtimeMs);
140
const packageJsonMtime = await fs.stat(packageJsonPath).then(s => s.mtimeMs);
141
if (vsixMtime >= packageJsonMtime) {
142
return existingVsix;
143
}
144
145
await fs.rm(existingVsix, { force: true });
146
}
147
148
logger.info('Packing extension for simulation test run...');
149
const packageJsonContents = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
150
151
await fs.writeFile(path.join(extensionDir, 'package.json'), JSON.stringify({
152
name: packageJsonContents.name,
153
publisher: packageJsonContents.publisher,
154
engines: packageJsonContents.engines,
155
displayName: 'Simulation Extension',
156
description: 'An extension installed in the VS Code server for the simulation test runs',
157
enabledApiProposals: packageJsonContents.enabledApiProposals,
158
version: `0.0.${Date.now()}`,
159
activationEvents: ['*'],
160
main: './extension.js',
161
contributes: {
162
languageModelTools: packageJsonContents.contributes?.languageModelTools,
163
},
164
}));
165
166
const vsixPath = path.join(extensionDir, 'extension.vsix');
167
await createVSIX({
168
cwd: extensionDir,
169
dependencies: false,
170
packagePath: vsixPath,
171
172
allowStarActivation: true,
173
allowMissingRepository: true,
174
skipLicense: true,
175
allowUnusedFilesPattern: true,
176
});
177
178
logger.info('Simulation extension packed successfully.');
179
return vsixPath;
180
}
181
182
private _isDisposed = false;
183
private readonly _pending = new Set<{ dir: string; workspace: Promise<ProxiedWorkspace> }>();
184
private readonly _available = new Set<ProxiedWorkspaceWithConnection>();
185
private readonly _onDidChangeWorkspaces = new Emitter<void>();
186
187
constructor(
188
private readonly _ctx: SimulationTestContext,
189
output: Buffer[],
190
private readonly _browser: Browser,
191
private readonly _browserContext: BrowserContext,
192
private readonly _child: ChildProcess,
193
private readonly _serverPortNumber: number,
194
private readonly _store: DisposableStore,
195
private readonly _connectionToken: string,
196
) {
197
_store.add(this._onDidChangeWorkspaces);
198
this._child.on('exit', (code, signal) => {
199
if (this._isDisposed) {
200
return;
201
}
202
if (code !== 0) {
203
logger.error(`Child process exited with code ${code} and signal ${signal}. Output:`);
204
logger.error(Buffer.concat(output).toString());
205
}
206
});
207
}
208
209
public async executeTest(
210
ctx: SimulationTestContext,
211
_parallelism: number,
212
outcomeDirectory: string,
213
test: SimulationTest,
214
runNumber: number
215
): Promise<ITestRunResult> {
216
let workspace: ProxiedWorkspaceWithConnection | undefined;
217
218
const explicitWorkspaceFolder = test.options.scenarioFolderPath && test.options.stateFile ? deserializeWorkbenchState(test.options.scenarioFolderPath, path.join(test.options.scenarioFolderPath, test.options.stateFile)).workspaceFolderPath : undefined;
219
220
const beforeWorkspace = Date.now();
221
try {
222
workspace = await this._acquireWorkspace(ctx, explicitWorkspaceFolder);
223
const afterWorkspace = Date.now();
224
ProxiedSimulationOutcome.registerTo(ctx.simulationOutcome, workspace.connection);
225
ProxiedSONOutputPrinter.registerTo(ctx.jsonOutputPrinter, workspace.connection);
226
ProxiedSimulationEndpointHealth.registerTo(ctx.simulationEndpointHealth, workspace.connection);
227
228
const res: IRunTestResult = await workspace.connection.callMethod('runTest', {
229
testName: test.fullName,
230
outcomeDirectory,
231
runNumber,
232
} satisfies IRunTestParams);
233
234
// For running in an explicit folder, don't let other connections reuse it
235
if (explicitWorkspaceFolder) {
236
await workspace.dispose();
237
this._available.delete(workspace);
238
} else {
239
await workspace.clean();
240
}
241
242
this._onDidChangeWorkspaces.fire(); // wake up any tests waiting for a workspace
243
244
const afterTest = Date.now();
245
logger.trace(`[TestExecutionInExtension] Workspace acquired in ${afterWorkspace - beforeWorkspace}ms, test run in ${afterTest - afterWorkspace}ms`);
246
247
return res.result;
248
} catch (e) {
249
logger.error(`Error running test: ${e}`);
250
if (workspace) {
251
await this._disposeWorkspace(workspace);
252
}
253
throw e;
254
}
255
}
256
257
private async _disposeWorkspace(workspace: ProxiedWorkspaceWithConnection) {
258
await workspace.dispose().catch(() => { });
259
this._available.delete(workspace);
260
this._onDidChangeWorkspaces.fire();
261
}
262
263
private async _acquireWorkspace(ctx: SimulationTestContext, explicitWorkspaceFolder?: string) {
264
// Get a workspace if one is available. If not and there are no pending
265
// workspaces, make one. And then wait for a workspace to be available.
266
while (true) {
267
const available = Iterable.find(this._available, v => !v.busy && (!explicitWorkspaceFolder || v.dir === explicitWorkspaceFolder));
268
if (available) {
269
available.busy = true;
270
this._onDidChangeWorkspaces.fire();
271
return available;
272
}
273
274
if (explicitWorkspaceFolder || this._pending.size + this._available.size < MAX_CONCURRENT_SESSIONS) {
275
const dir = explicitWorkspaceFolder || path.join(tmpdir(), 'vscode-simulation-extension-test', generateUuid());
276
const workspace = ProxiedWorkspace.create(dir, this._browserContext, this._serverPortNumber, this._connectionToken);
277
const pending = { dir, workspace };
278
279
this._pending.add(pending);
280
workspace.then(w => w.onDidTimeout(() => {
281
logger.warn(`Pending workspace connection ${dir} timed out. Will retry...`);
282
this._pending.delete(pending);
283
this._onDidChangeWorkspaces.fire();
284
w.dispose();
285
}));
286
}
287
288
await Event.toPromise(this._onDidChangeWorkspaces.event);
289
}
290
}
291
292
private _onConnection(socket: Socket) {
293
const rpc = new SimpleRPC(socket);
294
295
rpc.registerMethod('deviceCodeCallback', ({ url }) => {
296
logger.warn(`⚠️ \x1b[31mAuth Required!\x1b[0m Please open the link: ${url}`);
297
});
298
299
rpc.registerMethod('init', async (params: IInitParams): Promise<IInitResult> => {
300
const record = [...this._pending].find(w => extUriBiasedIgnorePathCase.isEqual(URI.file(w.dir), URI.file(params.folder)));
301
if (!record) {
302
socket.end();
303
const err = new Error(`No workspace found for folder ${params.folder}`);
304
logger.error(err);
305
throw err;
306
}
307
308
const workspace = await record.workspace;
309
this._pending.delete(record);
310
this._available.add(workspace.onConnection(rpc));
311
this._onDidChangeWorkspaces.fire();
312
313
const argv = [...process.argv, '--in-extension-host', 'false'];
314
if (!argv.some(a => a.startsWith('--output'))) {
315
// Ensure output is stable otherwise it's regenerated
316
argv.push('--output', this._ctx.outputPath);
317
}
318
319
return { argv };
320
});
321
}
322
323
public async dispose() {
324
this._isDisposed = true;
325
326
await Promise.all([...this._pending].map(w => w.workspace.then(w => w.dispose())));
327
await Promise.all([...this._available].map(w => w.dispose()));
328
this._pending.clear();
329
this._available.clear();
330
331
await this._browserContext.close();
332
await this._browser.close();
333
334
this._store.dispose();
335
}
336
}
337
338
type ProxiedWorkspaceWithConnection = ProxiedWorkspace & { connection: SimpleRPC };
339
340
class ProxiedWorkspace extends Disposable {
341
public static async create(dir: string, context: BrowserContext, serverPort: number, connectionToken: string) {
342
// swebench runs run on the 'real' working directory and expect to be modified
343
// in-place. If it looks like this is happening, don't clear the directory
344
// afte each run.
345
346
let isReused = false;
347
try {
348
isReused = (await fs.readdir(dir)).length > 0;
349
} catch {
350
// ignore
351
}
352
await fs.mkdir(dir, { recursive: true });
353
354
const url = new URL('http://127.0.0.1');
355
url.port = String(serverPort);
356
url.searchParams.set('tkn', connectionToken);
357
url.searchParams.set('folder', URI.file(dir).path);
358
359
const page = await context.newPage();
360
await page.goto(url.toString());
361
362
return new ProxiedWorkspace(page, dir, isReused);
363
}
364
365
private readonly _connection = new DeferredPromise<SimpleRPC>();
366
public get connection() {
367
return this._connection.value;
368
}
369
370
private readonly _onDidTimeout = this._register(new Emitter<void>());
371
public get onDidTimeout(): Event<void> {
372
return this._onDidTimeout.event;
373
}
374
375
private readonly _connectionTimeout = this._register(disposableTimeout(() => {
376
this._onDidTimeout.fire();
377
}, CONNECT_TIMEOUT));
378
379
public busy = false;
380
381
constructor(
382
private readonly _page: Page,
383
public readonly dir: string,
384
private readonly _dirIsReused: boolean,
385
) {
386
super();
387
const log = logger.tag('ProxiedWorkspace');
388
_page.on('console', e => log.debug(`[ProxiedWorkspace] ${e.type().toUpperCase()}: ${e.text()}`));
389
}
390
391
public onConnection(rpc: SimpleRPC): ProxiedWorkspaceWithConnection {
392
this._connection.complete(rpc);
393
this._connectionTimeout.dispose();
394
return this as ProxiedWorkspaceWithConnection;
395
}
396
397
public async clean() {
398
if (!this._dirIsReused) {
399
const entries = await fs.readdir(this.dir);
400
for (const entry of entries) {
401
await fs.rm(path.join(this.dir, entry), { recursive: true, force: true });
402
}
403
}
404
this.busy = false;
405
}
406
407
public override async dispose() {
408
super.dispose();
409
410
await this._connection.value?.callMethod('close', {}).catch(() => { });
411
this._connection.value?.dispose();
412
await this._page.close();
413
// retry because the folder will be locked until the EH gets shut down
414
if (!this._dirIsReused) {
415
await retry(() => fs.rm(this.dir, { recursive: true, force: true }).catch(() => { }), 400, 10);
416
}
417
}
418
}
419
420
function getServerPlatform() {
421
switch (process.platform) {
422
case 'darwin':
423
return process.arch === 'arm64' ? 'server-darwin-arm64-web' : 'server-darwin-web';
424
case 'linux':
425
return process.arch === 'arm64' ? 'server-linux-arm64-web' : 'server-linux-x64-web';
426
case 'win32':
427
return process.arch === 'arm64' ? 'server-win32-arm64-web' : 'server-win32-x64-web';
428
default:
429
throw new Error(`Unsupported platform: ${process.platform}`);
430
}
431
}
432
433