vscode/test/automation/src/code.ts

459 lines
14 KiB
TypeScript
Raw Normal View History

2018-04-06 00:03:57 +02:00
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import * as cp from 'child_process';
2018-04-12 09:55:19 +02:00
import * as os from 'os';
2019-05-22 16:41:26 +02:00
import * as fs from 'fs';
import * as mkdirp from 'mkdirp';
2018-04-06 00:03:57 +02:00
import { tmpName } from 'tmp';
2021-07-06 13:15:43 +02:00
import { IDriver, connect as connectElectronDriver, IDisposable, IElement, Thenable, ILocalizedStrings, ILocaleInfo } from './driver';
import { connect as connectPlaywrightDriver, launch } from './playwrightDriver';
import { Logger } from './logger';
2019-05-22 16:41:26 +02:00
import { ncp } from 'ncp';
import { URI } from 'vscode-uri';
2018-04-06 00:03:57 +02:00
const repoPath = path.join(__dirname, '../../..');
2018-04-06 00:03:57 +02:00
2019-07-20 02:12:11 +02:00
function getDevElectronPath(): string {
const buildPath = path.join(repoPath, '.build');
const product = require(path.join(repoPath, 'product.json'));
switch (process.platform) {
case 'darwin':
return path.join(buildPath, 'electron', `${product.nameLong}.app`, 'Contents', 'MacOS', 'Electron');
case 'linux':
return path.join(buildPath, 'electron', `${product.applicationName}`);
case 'win32':
return path.join(buildPath, 'electron', `${product.nameShort}.exe`);
default:
throw new Error('Unsupported platform.');
}
}
function getBuildElectronPath(root: string): string {
switch (process.platform) {
case 'darwin':
return path.join(root, 'Contents', 'MacOS', 'Electron');
case 'linux': {
const product = require(path.join(root, 'resources', 'app', 'product.json'));
return path.join(root, product.applicationName);
}
case 'win32': {
const product = require(path.join(root, 'resources', 'app', 'product.json'));
return path.join(root, `${product.nameShort}.exe`);
}
default:
throw new Error('Unsupported platform.');
}
}
2018-04-06 00:03:57 +02:00
function getDevOutPath(): string {
return path.join(repoPath, 'out');
}
function getBuildOutPath(root: string): string {
switch (process.platform) {
case 'darwin':
return path.join(root, 'Contents', 'Resources', 'app', 'out');
default:
return path.join(root, 'resources', 'app', 'out');
}
}
2021-11-26 18:08:23 +01:00
async function connect(connectDriver: typeof connectElectronDriver | typeof connectPlaywrightDriver, child: cp.ChildProcess | undefined, outPath: string, handlePath: string, logger: Logger): Promise<Code> {
2018-04-06 00:03:57 +02:00
let errCount = 0;
while (true) {
try {
const { client, driver } = await connectDriver(outPath, handlePath);
2021-11-23 10:31:46 +01:00
return new Code(client, driver, logger, child?.pid);
2018-04-06 00:03:57 +02:00
} catch (err) {
if (++errCount > 50) {
if (child) {
child.kill();
}
2018-04-06 00:03:57 +02:00
throw err;
}
// retry
2021-11-26 18:08:23 +01:00
await new Promise(resolve => setTimeout(resolve, 100));
2018-04-06 00:03:57 +02:00
}
}
}
2018-04-06 00:06:56 +02:00
// Kill all running instances, when dead
const instances = new Set<cp.ChildProcess>();
process.once('exit', () => instances.forEach(code => code.kill()));
2018-04-11 12:20:37 +02:00
export interface SpawnOptions {
codePath?: string;
workspacePath: string;
userDataDir: string;
extensionsPath: string;
2018-04-18 15:45:27 +02:00
logger: Logger;
verbose?: boolean;
2018-04-11 12:20:37 +02:00
extraArgs?: string[];
2018-06-05 15:24:41 +02:00
log?: string;
2019-04-10 22:32:45 +02:00
remote?: boolean;
2019-07-20 02:12:11 +02:00
web?: boolean;
headless?: boolean;
browser?: 'chromium' | 'webkit' | 'firefox';
2018-04-11 12:20:37 +02:00
}
2018-04-12 09:55:19 +02:00
async function createDriverHandle(): Promise<string> {
if ('win32' === os.platform()) {
const name = [...Array(15)].map(() => Math.random().toString(36)[3]).join('');
return `\\\\.\\pipe\\${name}`;
} else {
return await new Promise<string>((c, e) => tmpName((err, handlePath) => err ? e(err) : c(handlePath)));
}
}
2018-04-06 00:03:57 +02:00
export async function spawn(options: SpawnOptions): Promise<Code> {
2018-04-12 09:55:19 +02:00
const handle = await createDriverHandle();
2018-04-06 00:03:57 +02:00
let child: cp.ChildProcess | undefined;
2021-02-03 19:20:44 +01:00
copyExtension(options.extensionsPath, 'vscode-notebook-tests');
if (options.web) {
await launch(options.userDataDir, options.workspacePath, options.codePath, options.extensionsPath, Boolean(options.verbose));
2021-11-26 18:08:23 +01:00
return connect(connectPlaywrightDriver.bind(connectPlaywrightDriver, options), child, '', handle, options.logger);
}
2018-04-06 00:03:57 +02:00
2021-06-11 12:19:17 +02:00
const env = { ...process.env };
const codePath = options.codePath;
const logsPath = path.join(repoPath, '.build', 'logs', options.remote ? 'smoke-tests-remote' : 'smoke-tests');
const outPath = codePath ? getBuildOutPath(codePath) : getDevOutPath();
const args = [
options.workspacePath,
'--skip-release-notes',
'--skip-welcome',
'--disable-telemetry',
'--no-cached-data',
'--disable-updates',
'--disable-keytar',
'--disable-crash-reporter',
2021-06-03 10:21:19 +02:00
'--disable-workspace-trust',
`--extensions-dir=${options.extensionsPath}`,
`--user-data-dir=${options.userDataDir}`,
`--logsPath=${logsPath}`,
'--driver', handle
];
if (process.platform === 'linux') {
args.push('--disable-gpu'); // Linux has trouble in VMs to render properly with GPU enabled
}
if (options.remote) {
// Replace workspace path with URI
args[0] = `--${options.workspacePath.endsWith('.code-workspace') ? 'file' : 'folder'}-uri=vscode-remote://test+test/${URI.file(options.workspacePath).path}`;
if (codePath) {
// running against a build: copy the test resolver extension
2021-02-03 19:20:44 +01:00
copyExtension(options.extensionsPath, 'vscode-test-resolver');
}
args.push('--enable-proposed-api=vscode.vscode-test-resolver');
const remoteDataDir = `${options.userDataDir}-server`;
mkdirp.sync(remoteDataDir);
2021-02-03 19:20:44 +01:00
if (codePath) {
// running against a build: copy the test resolver extension into remote extensions dir
const remoteExtensionsDir = path.join(remoteDataDir, 'extensions');
mkdirp.sync(remoteExtensionsDir);
copyExtension(remoteExtensionsDir, 'vscode-notebook-tests');
}
env['TESTRESOLVER_DATA_FOLDER'] = remoteDataDir;
env['TESTRESOLVER_LOGS_FOLDER'] = path.join(logsPath, 'server');
}
2018-06-05 15:24:41 +02:00
const spawnOptions: cp.SpawnOptions = { env };
2020-05-20 19:01:16 +02:00
args.push('--enable-proposed-api=vscode.vscode-notebook-tests');
if (!codePath) {
args.unshift(repoPath);
}
2018-04-11 12:20:37 +02:00
if (options.verbose) {
args.push('--driver-verbose');
spawnOptions.stdio = ['ignore', 'inherit', 'inherit'];
}
2018-04-06 18:07:29 +02:00
if (options.log) {
args.push('--log', options.log);
2019-07-20 02:12:11 +02:00
}
if (options.extraArgs) {
args.push(...options.extraArgs);
}
const electronPath = codePath ? getBuildElectronPath(codePath) : getDevElectronPath();
child = cp.spawn(electronPath, args, spawnOptions);
instances.add(child);
child.once('exit', () => instances.delete(child!));
2021-11-26 18:08:23 +01:00
return connect(connectElectronDriver, child, outPath, handle, options.logger);
2018-04-11 16:20:09 +02:00
}
2021-02-03 19:20:44 +01:00
async function copyExtension(extensionsPath: string, extId: string): Promise<void> {
const dest = path.join(extensionsPath, extId);
if (!fs.existsSync(dest)) {
2020-05-20 19:01:16 +02:00
const orig = path.join(repoPath, 'extensions', extId);
await new Promise<void>((c, e) => ncp(orig, dest, err => err ? e(err) : c()));
2020-05-20 19:01:16 +02:00
}
}
async function poll<T>(
fn: () => Thenable<T>,
acceptFn: (result: T) => boolean,
timeoutMessage: string,
retryCount: number = 200,
retryInterval: number = 100 // millis
): Promise<T> {
let trial = 1;
let lastError: string = '';
while (true) {
if (trial > retryCount) {
console.error('** Timeout!');
console.error(lastError);
console.error(`Timeout: ${timeoutMessage} after ${(retryCount * retryInterval) / 1000} seconds.`);
throw new Error(`Timeout: ${timeoutMessage} after ${(retryCount * retryInterval) / 1000} seconds.`);
}
let result;
try {
result = await fn();
if (acceptFn(result)) {
return result;
} else {
lastError = 'Did not pass accept function';
}
2021-06-11 12:19:17 +02:00
} catch (e: any) {
lastError = Array.isArray(e.stack) ? e.stack.join(os.EOL) : e.stack;
}
await new Promise(resolve => setTimeout(resolve, retryInterval));
trial++;
}
}
2018-04-11 16:20:09 +02:00
export class Code {
private _activeWindowId: number | undefined = undefined;
driver: IDriver;
2018-04-11 16:20:09 +02:00
constructor(
2019-08-15 16:52:23 +02:00
private client: IDisposable,
2018-04-11 17:25:37 +02:00
driver: IDriver,
2021-11-23 10:31:46 +01:00
readonly logger: Logger,
private readonly pid: number | undefined
2018-04-11 17:25:37 +02:00
) {
2018-04-18 15:45:27 +02:00
this.driver = new Proxy(driver, {
get(target, prop, receiver) {
2018-06-08 14:42:38 +02:00
if (typeof prop === 'symbol') {
throw new Error('Invalid usage');
}
const targetProp = (target as any)[prop];
if (typeof targetProp !== 'function') {
return targetProp;
2018-04-11 17:25:37 +02:00
}
2018-04-18 15:45:27 +02:00
return function (this: any, ...args: any[]) {
2018-04-18 15:45:27 +02:00
logger.log(`${prop}`, ...args.filter(a => typeof a === 'string'));
return targetProp.apply(this, args);
2018-04-18 15:45:27 +02:00
};
}
});
2018-04-11 17:25:37 +02:00
}
2018-04-11 16:20:09 +02:00
2018-04-18 08:14:38 +02:00
async capturePage(): Promise<string> {
const windowId = await this.getActiveWindowId();
return await this.driver.capturePage(windowId);
}
2018-04-11 17:25:51 +02:00
async waitForWindowIds(fn: (windowIds: number[]) => boolean): Promise<void> {
await poll(() => this.driver.getWindowIds(), fn, `get window ids`);
2018-04-11 17:25:51 +02:00
}
2018-04-11 17:25:37 +02:00
async dispatchKeybinding(keybinding: string): Promise<void> {
2018-04-11 16:20:09 +02:00
const windowId = await this.getActiveWindowId();
await this.driver.dispatchKeybinding(windowId, keybinding);
}
2019-01-03 02:28:38 +01:00
async exit(): Promise<void> {
return new Promise<void>((resolve, reject) => {
let done = false;
// Start the exit flow via driver
const exitPromise = this.driver.exitApplication().then(veto => {
if (veto) {
done = true;
reject(new Error('Smoke test exit call resulted in unexpected veto'));
}
});
// If we know the `pid` of the smoke tested application
// use that as way to detect the exit of the application
const pid = this.pid;
if (typeof pid === 'number') {
(async () => {
let killCounter = 0;
while (!done) {
killCounter++;
if (killCounter > 40) {
done = true;
reject(new Error('Smoke test exit call did not terminate main process after 20s, giving up'));
}
try {
process.kill(pid, 0); // throws an exception if the main process doesn't exist anymore.
await new Promise(resolve => setTimeout(resolve, 500));
} catch (error) {
done = true;
resolve();
}
}
})();
}
// Otherwise await the exit promise (web).
else {
(async () => {
2021-11-23 10:31:46 +01:00
try {
await exitPromise;
resolve();
2021-11-23 10:31:46 +01:00
} catch (error) {
reject(new Error(`Smoke test exit call resulted in error: ${error}`));
2021-11-23 10:31:46 +01:00
}
})();
}
});
2019-01-03 02:28:38 +01:00
}
async waitForTextContent(selector: string, textContent?: string, accept?: (result: string) => boolean, retryCount?: number): Promise<string> {
2018-04-11 16:20:09 +02:00
const windowId = await this.getActiveWindowId();
2018-12-28 22:15:41 +01:00
accept = accept || (result => textContent !== undefined ? textContent === result : !!result);
2018-05-03 14:39:57 +02:00
return await poll(
() => this.driver.getElements(windowId, selector).then(els => els.length > 0 ? Promise.resolve(els[0].textContent) : Promise.reject(new Error('Element not found for textContent'))),
s => accept!(typeof s === 'string' ? s : ''),
`get text content '${selector}'`,
retryCount
2018-05-03 14:39:57 +02:00
);
2018-04-11 16:20:09 +02:00
}
async waitAndClick(selector: string, xoffset?: number, yoffset?: number, retryCount: number = 200): Promise<void> {
2018-04-11 16:20:09 +02:00
const windowId = await this.getActiveWindowId();
await poll(() => this.driver.click(windowId, selector, xoffset, yoffset), () => true, `click '${selector}'`, retryCount);
2018-04-11 16:20:09 +02:00
}
async waitAndDoubleClick(selector: string): Promise<void> {
const windowId = await this.getActiveWindowId();
await poll(() => this.driver.doubleClick(windowId, selector), () => true, `double click '${selector}'`);
2018-04-11 16:20:09 +02:00
}
2018-04-11 17:25:37 +02:00
async waitForSetValue(selector: string, value: string): Promise<void> {
2018-04-11 16:20:09 +02:00
const windowId = await this.getActiveWindowId();
await poll(() => this.driver.setValue(windowId, selector, value), () => true, `set value '${selector}'`);
2018-04-11 16:20:09 +02:00
}
async waitForElements(selector: string, recursive: boolean, accept: (result: IElement[]) => boolean = result => result.length > 0): Promise<IElement[]> {
const windowId = await this.getActiveWindowId();
return await poll(() => this.driver.getElements(windowId, selector, recursive), accept, `get elements '${selector}'`);
2018-04-11 16:20:09 +02:00
}
2018-04-13 10:11:14 +02:00
async waitForElement(selector: string, accept: (result: IElement | undefined) => boolean = result => !!result, retryCount: number = 200): Promise<IElement> {
2018-04-11 16:20:09 +02:00
const windowId = await this.getActiveWindowId();
2018-04-13 10:11:14 +02:00
return await poll<IElement>(() => this.driver.getElements(windowId, selector).then(els => els[0]), accept, `get element '${selector}'`, retryCount);
2018-04-11 16:20:09 +02:00
}
async waitForActiveElement(selector: string, retryCount: number = 200): Promise<void> {
2018-04-11 16:20:09 +02:00
const windowId = await this.getActiveWindowId();
await poll(() => this.driver.isActiveElement(windowId, selector), r => r, `is active element '${selector}'`, retryCount);
2018-04-11 16:20:09 +02:00
}
2018-04-11 16:48:42 +02:00
async waitForTitle(fn: (title: string) => boolean): Promise<void> {
2018-04-11 16:20:09 +02:00
const windowId = await this.getActiveWindowId();
await poll(() => this.driver.getTitle(windowId), fn, `get title`);
2018-04-11 16:20:09 +02:00
}
2018-04-11 17:25:37 +02:00
async waitForTypeInEditor(selector: string, text: string): Promise<void> {
2018-04-11 16:20:09 +02:00
const windowId = await this.getActiveWindowId();
await poll(() => this.driver.typeInEditor(windowId, selector, text), () => true, `type in editor '${selector}'`);
2018-04-11 16:20:09 +02:00
}
2018-04-18 11:53:40 +02:00
async waitForTerminalBuffer(selector: string, accept: (result: string[]) => boolean): Promise<void> {
const windowId = await this.getActiveWindowId();
await poll(() => this.driver.getTerminalBuffer(windowId, selector), accept, `get terminal buffer '${selector}'`);
}
2018-05-02 12:29:39 +02:00
async writeInTerminal(selector: string, value: string): Promise<void> {
const windowId = await this.getActiveWindowId();
await poll(() => this.driver.writeInTerminal(windowId, selector, value), () => true, `writeInTerminal '${selector}'`);
}
2021-07-06 13:15:43 +02:00
async getLocaleInfo(): Promise<ILocaleInfo> {
const windowId = await this.getActiveWindowId();
return await this.driver.getLocaleInfo(windowId);
}
2021-06-21 13:54:16 +02:00
async getLocalizedStrings(): Promise<ILocalizedStrings> {
const windowId = await this.getActiveWindowId();
return await this.driver.getLocalizedStrings(windowId);
}
2018-04-11 16:20:09 +02:00
private async getActiveWindowId(): Promise<number> {
if (typeof this._activeWindowId !== 'number') {
const windows = await this.driver.getWindowIds();
this._activeWindowId = windows[0];
}
return this._activeWindowId;
}
dispose(): void {
2019-08-15 16:52:23 +02:00
this.client.dispose();
2018-04-11 16:20:09 +02:00
}
}
export function findElement(element: IElement, fn: (element: IElement) => boolean): IElement | null {
const queue = [element];
while (queue.length > 0) {
const element = queue.shift()!;
if (fn(element)) {
return element;
}
queue.push(...element.children);
}
return null;
}
export function findElements(element: IElement, fn: (element: IElement) => boolean): IElement[] {
const result: IElement[] = [];
const queue = [element];
while (queue.length > 0) {
const element = queue.shift()!;
if (fn(element)) {
result.push(element);
}
queue.push(...element.children);
}
return result;
}