Path: blob/main/extensions/copilot/test/testExecutionInExtension.ts
13383 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*--------------------------------------------------------------------------------------------*/4import { downloadAndUnzipVSCode } from '@vscode/test-electron';5import { createVSIX } from '@vscode/vsce';6import { ChildProcess, spawn } from 'child_process';7import { AddressInfo, createServer, Socket } from 'net';8import * as fs from 'node:fs/promises';9import { tmpdir } from 'os';10import path from 'path';11import type { Browser, BrowserContext, Page } from 'playwright';12import { SimpleRPC } from '../src/extension/onboardDebug/node/copilotDebugWorker/rpc';13import { deserializeWorkbenchState } from '../src/platform/test/node/promptContextModel';14import { createCancelablePromise, DeferredPromise, disposableTimeout, raceCancellablePromises, retry, timeout } from '../src/util/vs/base/common/async';15import { Emitter, Event } from '../src/util/vs/base/common/event';16import { Iterable } from '../src/util/vs/base/common/iterator';17import { Disposable, DisposableStore, toDisposable } from '../src/util/vs/base/common/lifecycle';18import { extUriBiasedIgnorePathCase } from '../src/util/vs/base/common/resources';19import { URI } from '../src/util/vs/base/common/uri';20import { generateUuid } from '../src/util/vs/base/common/uuid';21import { ProxiedSimulationEndpointHealth } from './base/simulationEndpointHealth';22import { ProxiedSimulationOutcome } from './base/simulationOutcome';23import { SimulationTest } from './base/stest';24import { ProxiedSONOutputPrinter } from './jsonOutputPrinter';25import { logger } from './simulationLogger';26import { ITestRunResult, SimulationTestContext } from './testExecutor';27import { findFreePortFaster } from '../src/util/vs/base/node/ports';28import { waitForListenerOnPort } from '../src/util/node/ports';2930const MAX_CONCURRENT_SESSIONS = 10;31const HOST = '127.0.0.1';32const CONNECT_TIMEOUT = 60_000;3334export interface IInitParams {35folder: string;36}3738export interface IInitResult {39argv: readonly string[];40}4142export interface IRunTestParams {43testName: string;44outcomeDirectory: string;45runNumber: number;46}4748export interface IRunTestResult {49result: ITestRunResult;50}5152export class TestExecutionInExtension {53public static async create(ctx: SimulationTestContext) {54const store = new DisposableStore();55const { chromium } = await import('playwright');5657//@ts-ignore58const testConfig: { default: { version: string } } = await import('../.vscode-test.mjs');59const [serverBinary, browser] = await Promise.all([60downloadAndUnzipVSCode(testConfig.default.version, getServerPlatform()),61chromium.launch({ headless: ctx.opts.headless }),62]);63const browserContext = await browser.newContext();64const childPortNumber = await findFreePortFaster(40_000, 1_000, 10_000);65const connectionToken = generateUuid();6667const controlServer = createServer(s => inst._onConnection(s));68await new Promise((resolve, reject) => {69controlServer.on('listening', resolve);70controlServer.on('error', reject);71controlServer.listen(0, HOST);72});73store.add(toDisposable(() => controlServer.close()));7475const vsixFile = await TestExecutionInExtension._packExtension();76const child = spawn(serverBinary, [77'--server-data-dir', path.resolve(__dirname, '../.vscode-test/server-data'),78'--extensions-dir', path.resolve(__dirname, '../.vscode-test/server-extensions'),79...ctx.opts.installExtensions.flatMap(ext => ['--install-extension', ext]),80'--install-extension', vsixFile,81'--force',82'--accept-server-license-terms',83'--connection-token', connectionToken,84'--port', String(childPortNumber),85'--host', HOST,86'--disable-workspace-trust',87'--start-server'88], {89shell: process.platform === 'win32',90env: {91...process.env,92VSCODE_SIMULATION_EXTENSION_ENTRY: __filename,93VSCODE_SIMULATION_CONTROL_PORT: String((controlServer.address() as AddressInfo).port),94}95});96const output: Buffer[] = [];97await new Promise((resolve, reject) => {98const log = logger.tag('VSCodeServer');99const push = (data: Buffer) => {100log.trace(data.toString().trim());101output.push(data);102};103child.stdout.on('data', push);104child.stderr.on('data', push);105child.on('error', reject);106child.on('spawn', resolve);107});108store.add(toDisposable(() => child.kill()));109110await raceCancellablePromises([111createCancelablePromise(tkn => waitForListenerOnPort(childPortNumber, HOST, tkn)),112createCancelablePromise(tkn => new Promise<void>((resolve, reject) => {113const listener = () => {114reject(new Error(`Child process exited unexpectedly. Output: ${Buffer.concat(output).toString()}`));115};116child.on('exit', listener);117const l = tkn.onCancellationRequested(() => {118l.dispose();119child.off('exit', listener);120resolve();121});122})),123createCancelablePromise(tkn => timeout(10_000, tkn).then(e => {124throw new Error(`Timeout waiting for server to start. Output: ${Buffer.concat(output).toString()}`);125})),126]);127128const inst = new TestExecutionInExtension(ctx, output, browser, browserContext, child, childPortNumber, store, connectionToken);129return inst;130}131132private static async _packExtension() {133const packageJsonPath = path.resolve(__dirname, '..', 'package.json');134135const extensionDir = path.resolve(__dirname, '..', 'test', 'simulationExtension');136const existingVsix = (await fs.readdir(extensionDir)).map(e => path.join(extensionDir, e)).find(f => f.endsWith('.vsix'));137if (existingVsix) {138const vsixMtime = await fs.stat(existingVsix).then(s => s.mtimeMs);139const packageJsonMtime = await fs.stat(packageJsonPath).then(s => s.mtimeMs);140if (vsixMtime >= packageJsonMtime) {141return existingVsix;142}143144await fs.rm(existingVsix, { force: true });145}146147logger.info('Packing extension for simulation test run...');148const packageJsonContents = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));149150await fs.writeFile(path.join(extensionDir, 'package.json'), JSON.stringify({151name: packageJsonContents.name,152publisher: packageJsonContents.publisher,153engines: packageJsonContents.engines,154displayName: 'Simulation Extension',155description: 'An extension installed in the VS Code server for the simulation test runs',156enabledApiProposals: packageJsonContents.enabledApiProposals,157version: `0.0.${Date.now()}`,158activationEvents: ['*'],159main: './extension.js',160contributes: {161languageModelTools: packageJsonContents.contributes?.languageModelTools,162},163}));164165const vsixPath = path.join(extensionDir, 'extension.vsix');166await createVSIX({167cwd: extensionDir,168dependencies: false,169packagePath: vsixPath,170171allowStarActivation: true,172allowMissingRepository: true,173skipLicense: true,174allowUnusedFilesPattern: true,175});176177logger.info('Simulation extension packed successfully.');178return vsixPath;179}180181private _isDisposed = false;182private readonly _pending = new Set<{ dir: string; workspace: Promise<ProxiedWorkspace> }>();183private readonly _available = new Set<ProxiedWorkspaceWithConnection>();184private readonly _onDidChangeWorkspaces = new Emitter<void>();185186constructor(187private readonly _ctx: SimulationTestContext,188output: Buffer[],189private readonly _browser: Browser,190private readonly _browserContext: BrowserContext,191private readonly _child: ChildProcess,192private readonly _serverPortNumber: number,193private readonly _store: DisposableStore,194private readonly _connectionToken: string,195) {196_store.add(this._onDidChangeWorkspaces);197this._child.on('exit', (code, signal) => {198if (this._isDisposed) {199return;200}201if (code !== 0) {202logger.error(`Child process exited with code ${code} and signal ${signal}. Output:`);203logger.error(Buffer.concat(output).toString());204}205});206}207208public async executeTest(209ctx: SimulationTestContext,210_parallelism: number,211outcomeDirectory: string,212test: SimulationTest,213runNumber: number214): Promise<ITestRunResult> {215let workspace: ProxiedWorkspaceWithConnection | undefined;216217const explicitWorkspaceFolder = test.options.scenarioFolderPath && test.options.stateFile ? deserializeWorkbenchState(test.options.scenarioFolderPath, path.join(test.options.scenarioFolderPath, test.options.stateFile)).workspaceFolderPath : undefined;218219const beforeWorkspace = Date.now();220try {221workspace = await this._acquireWorkspace(ctx, explicitWorkspaceFolder);222const afterWorkspace = Date.now();223ProxiedSimulationOutcome.registerTo(ctx.simulationOutcome, workspace.connection);224ProxiedSONOutputPrinter.registerTo(ctx.jsonOutputPrinter, workspace.connection);225ProxiedSimulationEndpointHealth.registerTo(ctx.simulationEndpointHealth, workspace.connection);226227const res: IRunTestResult = await workspace.connection.callMethod('runTest', {228testName: test.fullName,229outcomeDirectory,230runNumber,231} satisfies IRunTestParams);232233// For running in an explicit folder, don't let other connections reuse it234if (explicitWorkspaceFolder) {235await workspace.dispose();236this._available.delete(workspace);237} else {238await workspace.clean();239}240241this._onDidChangeWorkspaces.fire(); // wake up any tests waiting for a workspace242243const afterTest = Date.now();244logger.trace(`[TestExecutionInExtension] Workspace acquired in ${afterWorkspace - beforeWorkspace}ms, test run in ${afterTest - afterWorkspace}ms`);245246return res.result;247} catch (e) {248logger.error(`Error running test: ${e}`);249if (workspace) {250await this._disposeWorkspace(workspace);251}252throw e;253}254}255256private async _disposeWorkspace(workspace: ProxiedWorkspaceWithConnection) {257await workspace.dispose().catch(() => { });258this._available.delete(workspace);259this._onDidChangeWorkspaces.fire();260}261262private async _acquireWorkspace(ctx: SimulationTestContext, explicitWorkspaceFolder?: string) {263// Get a workspace if one is available. If not and there are no pending264// workspaces, make one. And then wait for a workspace to be available.265while (true) {266const available = Iterable.find(this._available, v => !v.busy && (!explicitWorkspaceFolder || v.dir === explicitWorkspaceFolder));267if (available) {268available.busy = true;269this._onDidChangeWorkspaces.fire();270return available;271}272273if (explicitWorkspaceFolder || this._pending.size + this._available.size < MAX_CONCURRENT_SESSIONS) {274const dir = explicitWorkspaceFolder || path.join(tmpdir(), 'vscode-simulation-extension-test', generateUuid());275const workspace = ProxiedWorkspace.create(dir, this._browserContext, this._serverPortNumber, this._connectionToken);276const pending = { dir, workspace };277278this._pending.add(pending);279workspace.then(w => w.onDidTimeout(() => {280logger.warn(`Pending workspace connection ${dir} timed out. Will retry...`);281this._pending.delete(pending);282this._onDidChangeWorkspaces.fire();283w.dispose();284}));285}286287await Event.toPromise(this._onDidChangeWorkspaces.event);288}289}290291private _onConnection(socket: Socket) {292const rpc = new SimpleRPC(socket);293294rpc.registerMethod('deviceCodeCallback', ({ url }) => {295logger.warn(`⚠️ \x1b[31mAuth Required!\x1b[0m Please open the link: ${url}`);296});297298rpc.registerMethod('init', async (params: IInitParams): Promise<IInitResult> => {299const record = [...this._pending].find(w => extUriBiasedIgnorePathCase.isEqual(URI.file(w.dir), URI.file(params.folder)));300if (!record) {301socket.end();302const err = new Error(`No workspace found for folder ${params.folder}`);303logger.error(err);304throw err;305}306307const workspace = await record.workspace;308this._pending.delete(record);309this._available.add(workspace.onConnection(rpc));310this._onDidChangeWorkspaces.fire();311312const argv = [...process.argv, '--in-extension-host', 'false'];313if (!argv.some(a => a.startsWith('--output'))) {314// Ensure output is stable otherwise it's regenerated315argv.push('--output', this._ctx.outputPath);316}317318return { argv };319});320}321322public async dispose() {323this._isDisposed = true;324325await Promise.all([...this._pending].map(w => w.workspace.then(w => w.dispose())));326await Promise.all([...this._available].map(w => w.dispose()));327this._pending.clear();328this._available.clear();329330await this._browserContext.close();331await this._browser.close();332333this._store.dispose();334}335}336337type ProxiedWorkspaceWithConnection = ProxiedWorkspace & { connection: SimpleRPC };338339class ProxiedWorkspace extends Disposable {340public static async create(dir: string, context: BrowserContext, serverPort: number, connectionToken: string) {341// swebench runs run on the 'real' working directory and expect to be modified342// in-place. If it looks like this is happening, don't clear the directory343// afte each run.344345let isReused = false;346try {347isReused = (await fs.readdir(dir)).length > 0;348} catch {349// ignore350}351await fs.mkdir(dir, { recursive: true });352353const url = new URL('http://127.0.0.1');354url.port = String(serverPort);355url.searchParams.set('tkn', connectionToken);356url.searchParams.set('folder', URI.file(dir).path);357358const page = await context.newPage();359await page.goto(url.toString());360361return new ProxiedWorkspace(page, dir, isReused);362}363364private readonly _connection = new DeferredPromise<SimpleRPC>();365public get connection() {366return this._connection.value;367}368369private readonly _onDidTimeout = this._register(new Emitter<void>());370public get onDidTimeout(): Event<void> {371return this._onDidTimeout.event;372}373374private readonly _connectionTimeout = this._register(disposableTimeout(() => {375this._onDidTimeout.fire();376}, CONNECT_TIMEOUT));377378public busy = false;379380constructor(381private readonly _page: Page,382public readonly dir: string,383private readonly _dirIsReused: boolean,384) {385super();386const log = logger.tag('ProxiedWorkspace');387_page.on('console', e => log.debug(`[ProxiedWorkspace] ${e.type().toUpperCase()}: ${e.text()}`));388}389390public onConnection(rpc: SimpleRPC): ProxiedWorkspaceWithConnection {391this._connection.complete(rpc);392this._connectionTimeout.dispose();393return this as ProxiedWorkspaceWithConnection;394}395396public async clean() {397if (!this._dirIsReused) {398const entries = await fs.readdir(this.dir);399for (const entry of entries) {400await fs.rm(path.join(this.dir, entry), { recursive: true, force: true });401}402}403this.busy = false;404}405406public override async dispose() {407super.dispose();408409await this._connection.value?.callMethod('close', {}).catch(() => { });410this._connection.value?.dispose();411await this._page.close();412// retry because the folder will be locked until the EH gets shut down413if (!this._dirIsReused) {414await retry(() => fs.rm(this.dir, { recursive: true, force: true }).catch(() => { }), 400, 10);415}416}417}418419function getServerPlatform() {420switch (process.platform) {421case 'darwin':422return process.arch === 'arm64' ? 'server-darwin-arm64-web' : 'server-darwin-web';423case 'linux':424return process.arch === 'arm64' ? 'server-linux-arm64-web' : 'server-linux-x64-web';425case 'win32':426return process.arch === 'arm64' ? 'server-win32-arm64-web' : 'server-win32-x64-web';427default:428throw new Error(`Unsupported platform: ${process.platform}`);429}430}431432433