Use playwright for desktop smoke tests (#146692)

* Use `playwright` for desktop smoke tests

* fix distro issues

* tests - enable prefs tests for web
This commit is contained in:
Benjamin Pasero
2022-04-04 10:13:08 +02:00
committed by GitHub
parent 0bc095362e
commit 2ada17080c
34 changed files with 593 additions and 479 deletions

View File

@@ -236,7 +236,7 @@ steps:
- script: |
set -e
VSCODE_REMOTE_SERVER_PATH="$(agent.builddirectory)/vscode-reh-web-darwin-$(VSCODE_ARCH)" \
yarn smoketest-no-compile --web --headless
yarn smoketest-no-compile --web --tracing --headless
timeoutInMinutes: 10
displayName: Run smoke tests (Browser, Chromium)
condition: and(succeeded(), eq(variables['VSCODE_ARCH'], 'x64'), eq(variables['VSCODE_STEP_ON_IT'], 'false'))
@@ -245,7 +245,7 @@ steps:
set -e
APP_ROOT=$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH)
APP_NAME="`ls $APP_ROOT | head -n 1`"
yarn smoketest-no-compile --build "$APP_ROOT/$APP_NAME"
yarn smoketest-no-compile --tracing --build "$APP_ROOT/$APP_NAME"
# Increased timeout because this test downloads stable code
timeoutInMinutes: 20
displayName: Run smoke tests (Electron)
@@ -256,7 +256,7 @@ steps:
APP_ROOT=$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH)
APP_NAME="`ls $APP_ROOT | head -n 1`"
VSCODE_REMOTE_SERVER_PATH="$(agent.builddirectory)/vscode-reh-darwin-$(VSCODE_ARCH)" \
yarn smoketest-no-compile --build "$APP_ROOT/$APP_NAME" --remote
yarn smoketest-no-compile --tracing --remote --build "$APP_ROOT/$APP_NAME"
timeoutInMinutes: 10
displayName: Run smoke tests (Remote)
condition: and(succeeded(), eq(variables['VSCODE_ARCH'], 'x64'), eq(variables['VSCODE_STEP_ON_IT'], 'false'))

View File

@@ -259,7 +259,7 @@ steps:
- script: |
set -e
VSCODE_REMOTE_SERVER_PATH="$(agent.builddirectory)/vscode-reh-web-linux-$(VSCODE_ARCH)" \
yarn smoketest-no-compile --web --headless --electronArgs="--disable-dev-shm-usage"
yarn smoketest-no-compile --web --tracing --headless --electronArgs="--disable-dev-shm-usage"
timeoutInMinutes: 10
displayName: Run smoke tests (Browser, Chromium)
condition: and(succeeded(), eq(variables['VSCODE_ARCH'], 'x64'), eq(variables['VSCODE_STEP_ON_IT'], 'false'))
@@ -267,7 +267,7 @@ steps:
- script: |
set -e
APP_PATH=$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)
yarn smoketest-no-compile --build "$APP_PATH" --electronArgs="--disable-dev-shm-usage"
yarn smoketest-no-compile --tracing --build "$APP_PATH"
# Increased timeout because this test downloads stable code
timeoutInMinutes: 20
displayName: Run smoke tests (Electron)
@@ -277,7 +277,7 @@ steps:
set -e
APP_PATH=$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)
VSCODE_REMOTE_SERVER_PATH="$(agent.builddirectory)/vscode-reh-linux-$(VSCODE_ARCH)" \
yarn smoketest-no-compile --build "$APP_PATH" --remote --electronArgs="--disable-dev-shm-usage"
yarn smoketest-no-compile --tracing --remote --build "$APP_PATH"
timeoutInMinutes: 10
displayName: Run smoke tests (Remote)
condition: and(succeeded(), eq(variables['VSCODE_ARCH'], 'x64'), eq(variables['VSCODE_STEP_ON_IT'], 'false'))

View File

@@ -220,7 +220,7 @@ steps:
. build/azure-pipelines/win32/exec.ps1
$ErrorActionPreference = "Stop"
$env:VSCODE_REMOTE_SERVER_PATH = "$(agent.builddirectory)\vscode-reh-web-win32-$(VSCODE_ARCH)"
exec { yarn smoketest-no-compile --web --headless }
exec { yarn smoketest-no-compile --web --tracing --headless }
displayName: Run smoke tests (Browser, Chromium)
timeoutInMinutes: 10
condition: and(succeeded(), eq(variables['VSCODE_STEP_ON_IT'], 'false'), ne(variables['VSCODE_ARCH'], 'arm64'))
@@ -229,7 +229,7 @@ steps:
. build/azure-pipelines/win32/exec.ps1
$ErrorActionPreference = "Stop"
$AppRoot = "$(agent.builddirectory)\VSCode-win32-$(VSCODE_ARCH)"
exec { yarn smoketest-no-compile --build "$AppRoot" }
exec { yarn smoketest-no-compile --tracing --build "$AppRoot" }
displayName: Run smoke tests (Electron)
# Increased timeout because this test downloads stable code
timeoutInMinutes: 20
@@ -240,7 +240,7 @@ steps:
$ErrorActionPreference = "Stop"
$AppRoot = "$(agent.builddirectory)\VSCode-win32-$(VSCODE_ARCH)"
$env:VSCODE_REMOTE_SERVER_PATH = "$(agent.builddirectory)\vscode-reh-win32-$(VSCODE_ARCH)"
exec { yarn smoketest-no-compile --build "$AppRoot" --remote }
exec { yarn smoketest-no-compile --tracing --remote --build "$AppRoot" }
displayName: Run smoke tests (Remote)
timeoutInMinutes: 10
condition: and(succeeded(), eq(variables['VSCODE_STEP_ON_IT'], 'false'), ne(variables['VSCODE_ARCH'], 'arm64'))

View File

@@ -524,7 +524,7 @@ export class CodeApplication extends Disposable {
// Create driver
if (this.environmentMainService.driverHandle) {
const server = await serveDriver(mainProcessElectronServer, this.environmentMainService.driverHandle, this.environmentMainService, appInstantiationService);
const server = await serveDriver(mainProcessElectronServer, this.environmentMainService.driverHandle, appInstantiationService);
this.logService.info('Driver started at:', this.environmentMainService.driverHandle);
this._register(server);

View File

@@ -1,205 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { getClientArea, getTopLeftOffset } from 'vs/base/browser/dom';
import { coalesce } from 'vs/base/common/arrays';
import { language, locale } from 'vs/base/common/platform';
import { IElement, ILocaleInfo, ILocalizedStrings, IWindowDriver } from 'vs/platform/driver/common/driver';
import localizedStrings from 'vs/platform/localizations/common/localizedStrings';
export abstract class BaseWindowDriver implements IWindowDriver {
abstract click(selector: string, xoffset?: number, yoffset?: number): Promise<void>;
abstract doubleClick(selector: string): Promise<void>;
async setValue(selector: string, text: string): Promise<void> {
const element = document.querySelector(selector);
if (!element) {
return Promise.reject(new Error(`Element not found: ${selector}`));
}
const inputElement = element as HTMLInputElement;
inputElement.value = text;
const event = new Event('input', { bubbles: true, cancelable: true });
inputElement.dispatchEvent(event);
}
async getTitle(): Promise<string> {
return document.title;
}
async isActiveElement(selector: string): Promise<boolean> {
const element = document.querySelector(selector);
if (element !== document.activeElement) {
const chain: string[] = [];
let el = document.activeElement;
while (el) {
const tagName = el.tagName;
const id = el.id ? `#${el.id}` : '';
const classes = coalesce(el.className.split(/\s+/g).map(c => c.trim())).map(c => `.${c}`).join('');
chain.unshift(`${tagName}${id}${classes}`);
el = el.parentElement;
}
throw new Error(`Active element not found. Current active element is '${chain.join(' > ')}'. Looking for ${selector}`);
}
return true;
}
async getElements(selector: string, recursive: boolean): Promise<IElement[]> {
const query = document.querySelectorAll(selector);
const result: IElement[] = [];
for (let i = 0; i < query.length; i++) {
const element = query.item(i);
result.push(this.serializeElement(element, recursive));
}
return result;
}
private serializeElement(element: Element, recursive: boolean): IElement {
const attributes = Object.create(null);
for (let j = 0; j < element.attributes.length; j++) {
const attr = element.attributes.item(j);
if (attr) {
attributes[attr.name] = attr.value;
}
}
const children: IElement[] = [];
if (recursive) {
for (let i = 0; i < element.children.length; i++) {
const child = element.children.item(i);
if (child) {
children.push(this.serializeElement(child, true));
}
}
}
const { left, top } = getTopLeftOffset(element as HTMLElement);
return {
tagName: element.tagName,
className: element.className,
textContent: element.textContent || '',
attributes,
children,
left,
top
};
}
async getElementXY(selector: string, xoffset?: number, yoffset?: number): Promise<{ x: number; y: number }> {
const offset = typeof xoffset === 'number' && typeof yoffset === 'number' ? { x: xoffset, y: yoffset } : undefined;
return this._getElementXY(selector, offset);
}
async typeInEditor(selector: string, text: string): Promise<void> {
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Editor not found: ${selector}`);
}
const textarea = element as HTMLTextAreaElement;
const start = textarea.selectionStart;
const newStart = start + text.length;
const value = textarea.value;
const newValue = value.substr(0, start) + text + value.substr(start);
textarea.value = newValue;
textarea.setSelectionRange(newStart, newStart);
const event = new Event('input', { 'bubbles': true, 'cancelable': true });
textarea.dispatchEvent(event);
}
async getTerminalBuffer(selector: string): Promise<string[]> {
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Terminal not found: ${selector}`);
}
const xterm = (element as any).xterm;
if (!xterm) {
throw new Error(`Xterm not found: ${selector}`);
}
const lines: string[] = [];
for (let i = 0; i < xterm.buffer.active.length; i++) {
lines.push(xterm.buffer.active.getLine(i)!.translateToString(true));
}
return lines;
}
async writeInTerminal(selector: string, text: string): Promise<void> {
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Element not found: ${selector}`);
}
const xterm = (element as any).xterm;
if (!xterm) {
throw new Error(`Xterm not found: ${selector}`);
}
xterm._core.coreService.triggerDataEvent(text);
}
getLocaleInfo(): Promise<ILocaleInfo> {
return Promise.resolve({
language: language,
locale: locale
});
}
getLocalizedStrings(): Promise<ILocalizedStrings> {
return Promise.resolve({
open: localizedStrings.open,
close: localizedStrings.close,
find: localizedStrings.find
});
}
protected async _getElementXY(selector: string, offset?: { x: number; y: number }): Promise<{ x: number; y: number }> {
const element = document.querySelector(selector);
if (!element) {
return Promise.reject(new Error(`Element not found: ${selector}`));
}
const { left, top } = getTopLeftOffset(element as HTMLElement);
const { width, height } = getClientArea(element as HTMLElement);
let x: number, y: number;
if (offset) {
x = left + offset.x;
y = top + offset.y;
} else {
x = left + (width / 2);
y = top + (height / 2);
}
x = Math.round(x);
y = Math.round(y);
return { x, y };
}
abstract openDevTools(): Promise<void>;
}

View File

@@ -3,23 +3,210 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
import { BaseWindowDriver } from 'vs/platform/driver/browser/baseDriver';
import { getClientArea, getTopLeftOffset } from 'vs/base/browser/dom';
import { coalesce } from 'vs/base/common/arrays';
import { language, locale } from 'vs/base/common/platform';
import { IElement, ILocaleInfo, ILocalizedStrings, IWindowDriver } from 'vs/platform/driver/common/driver';
import localizedStrings from 'vs/platform/localizations/common/localizedStrings';
class BrowserWindowDriver extends BaseWindowDriver {
click(selector: string, xoffset?: number | undefined, yoffset?: number | undefined): Promise<void> {
throw new Error('Method not implemented.');
export class BrowserWindowDriver implements IWindowDriver {
async setValue(selector: string, text: string): Promise<void> {
const element = document.querySelector(selector);
if (!element) {
return Promise.reject(new Error(`Element not found: ${selector}`));
}
const inputElement = element as HTMLInputElement;
inputElement.value = text;
const event = new Event('input', { bubbles: true, cancelable: true });
inputElement.dispatchEvent(event);
}
doubleClick(selector: string): Promise<void> {
throw new Error('Method not implemented.');
async getTitle(): Promise<string> {
return document.title;
}
openDevTools(): Promise<void> {
async isActiveElement(selector: string): Promise<boolean> {
const element = document.querySelector(selector);
if (element !== document.activeElement) {
const chain: string[] = [];
let el = document.activeElement;
while (el) {
const tagName = el.tagName;
const id = el.id ? `#${el.id}` : '';
const classes = coalesce(el.className.split(/\s+/g).map(c => c.trim())).map(c => `.${c}`).join('');
chain.unshift(`${tagName}${id}${classes}`);
el = el.parentElement;
}
throw new Error(`Active element not found. Current active element is '${chain.join(' > ')}'. Looking for ${selector}`);
}
return true;
}
async getElements(selector: string, recursive: boolean): Promise<IElement[]> {
const query = document.querySelectorAll(selector);
const result: IElement[] = [];
for (let i = 0; i < query.length; i++) {
const element = query.item(i);
result.push(this.serializeElement(element, recursive));
}
return result;
}
private serializeElement(element: Element, recursive: boolean): IElement {
const attributes = Object.create(null);
for (let j = 0; j < element.attributes.length; j++) {
const attr = element.attributes.item(j);
if (attr) {
attributes[attr.name] = attr.value;
}
}
const children: IElement[] = [];
if (recursive) {
for (let i = 0; i < element.children.length; i++) {
const child = element.children.item(i);
if (child) {
children.push(this.serializeElement(child, true));
}
}
}
const { left, top } = getTopLeftOffset(element as HTMLElement);
return {
tagName: element.tagName,
className: element.className,
textContent: element.textContent || '',
attributes,
children,
left,
top
};
}
async getElementXY(selector: string, xoffset?: number, yoffset?: number): Promise<{ x: number; y: number }> {
const offset = typeof xoffset === 'number' && typeof yoffset === 'number' ? { x: xoffset, y: yoffset } : undefined;
return this._getElementXY(selector, offset);
}
async typeInEditor(selector: string, text: string): Promise<void> {
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Editor not found: ${selector}`);
}
const textarea = element as HTMLTextAreaElement;
const start = textarea.selectionStart;
const newStart = start + text.length;
const value = textarea.value;
const newValue = value.substr(0, start) + text + value.substr(start);
textarea.value = newValue;
textarea.setSelectionRange(newStart, newStart);
const event = new Event('input', { 'bubbles': true, 'cancelable': true });
textarea.dispatchEvent(event);
}
async getTerminalBuffer(selector: string): Promise<string[]> {
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Terminal not found: ${selector}`);
}
const xterm = (element as any).xterm;
if (!xterm) {
throw new Error(`Xterm not found: ${selector}`);
}
const lines: string[] = [];
for (let i = 0; i < xterm.buffer.active.length; i++) {
lines.push(xterm.buffer.active.getLine(i)!.translateToString(true));
}
return lines;
}
async writeInTerminal(selector: string, text: string): Promise<void> {
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Element not found: ${selector}`);
}
const xterm = (element as any).xterm;
if (!xterm) {
throw new Error(`Xterm not found: ${selector}`);
}
xterm._core.coreService.triggerDataEvent(text);
}
getLocaleInfo(): Promise<ILocaleInfo> {
return Promise.resolve({
language: language,
locale: locale
});
}
getLocalizedStrings(): Promise<ILocalizedStrings> {
return Promise.resolve({
open: localizedStrings.open,
close: localizedStrings.close,
find: localizedStrings.find
});
}
protected async _getElementXY(selector: string, offset?: { x: number; y: number }): Promise<{ x: number; y: number }> {
const element = document.querySelector(selector);
if (!element) {
return Promise.reject(new Error(`Element not found: ${selector}`));
}
const { left, top } = getTopLeftOffset(element as HTMLElement);
const { width, height } = getClientArea(element as HTMLElement);
let x: number, y: number;
if (offset) {
x = left + offset.x;
y = top + offset.y;
} else {
x = left + (width / 2);
y = top + (height / 2);
}
x = Math.round(x);
y = Math.round(y);
return { x, y };
}
click(selector: string, xoffset?: number, yoffset?: number): Promise<void> {
// This is actually not used in the playwright drivers
// that can implement `click` natively via the driver
throw new Error('Method not implemented.');
}
}
export async function registerWindowDriver(): Promise<IDisposable> {
(<any>window).driver = new BrowserWindowDriver();
return Disposable.None;
export function registerWindowDriver(): void {
Object.assign(window, { driver: new BrowserWindowDriver() });
}

View File

@@ -44,10 +44,9 @@ export interface IDriver {
startTracing(windowId: number, name: string): Promise<void>;
stopTracing(windowId: number, name: string, persist: boolean): Promise<void>;
reloadWindow(windowId: number): Promise<void>;
exitApplication(): Promise<boolean>;
exitApplication(): Promise<number /* main PID */>;
dispatchKeybinding(windowId: number, keybinding: string): Promise<void>;
click(windowId: number, selector: string, xoffset?: number | undefined, yoffset?: number | undefined): Promise<void>;
doubleClick(windowId: number, selector: string): Promise<void>;
setValue(windowId: number, selector: string, text: string): Promise<void>;
getTitle(windowId: number): Promise<string>;
isActiveElement(windowId: number, selector: string): Promise<boolean>;
@@ -62,7 +61,6 @@ export interface IDriver {
export interface IWindowDriver {
click(selector: string, xoffset?: number | undefined, yoffset?: number | undefined): Promise<void>;
doubleClick(selector: string): Promise<void>;
setValue(selector: string, text: string): Promise<void>;
getTitle(): Promise<string>;
isActiveElement(selector: string): Promise<boolean>;
@@ -79,11 +77,7 @@ export interface IWindowDriver {
export const ID = 'driverService';
export const IDriver = createDecorator<IDriver>(ID);
export interface IDriverOptions {
verbose: boolean;
}
export interface IWindowDriverRegistry {
registerWindowDriver(windowId: number): Promise<IDriverOptions>;
registerWindowDriver(windowId: number): Promise<void>;
reloadWindowDriver(windowId: number): Promise<void>;
}

View File

@@ -5,7 +5,7 @@
import { Event } from 'vs/base/common/event';
import { IChannel, IServerChannel } from 'vs/base/parts/ipc/common/ipc';
import { IDriverOptions, IElement, ILocaleInfo, ILocalizedStrings as ILocalizedStrings, IWindowDriver, IWindowDriverRegistry } from 'vs/platform/driver/common/driver';
import { IElement, ILocaleInfo, ILocalizedStrings as ILocalizedStrings, IWindowDriver, IWindowDriverRegistry } from 'vs/platform/driver/common/driver';
export class WindowDriverChannel implements IServerChannel {
@@ -18,7 +18,6 @@ export class WindowDriverChannel implements IServerChannel {
call(_: unknown, command: string, arg?: any): Promise<any> {
switch (command) {
case 'click': return this.driver.click(arg[0], arg[1], arg[2]);
case 'doubleClick': return this.driver.doubleClick(arg);
case 'setValue': return this.driver.setValue(arg[0], arg[1]);
case 'getTitle': return this.driver.getTitle();
case 'isActiveElement': return this.driver.isActiveElement(arg);
@@ -45,10 +44,6 @@ export class WindowDriverChannelClient implements IWindowDriver {
return this.channel.call('click', [selector, xoffset, yoffset]);
}
doubleClick(selector: string): Promise<void> {
return this.channel.call('doubleClick', selector);
}
setValue(selector: string, text: string): Promise<void> {
return this.channel.call('setValue', [selector, text]);
}
@@ -96,7 +91,7 @@ export class WindowDriverRegistryChannelClient implements IWindowDriverRegistry
constructor(private channel: IChannel) { }
registerWindowDriver(windowId: number): Promise<IDriverOptions> {
registerWindowDriver(windowId: number): Promise<void> {
return this.channel.call('registerWindowDriver', windowId);
}

View File

@@ -12,7 +12,7 @@ import { combinedDisposable, IDisposable } from 'vs/base/common/lifecycle';
import { OS } from 'vs/base/common/platform';
import { IPCServer, StaticRouter } from 'vs/base/parts/ipc/common/ipc';
import { serve as serveNet } from 'vs/base/parts/ipc/node/ipc.net';
import { IDriver, IDriverOptions, IElement, ILocaleInfo, ILocalizedStrings, IWindowDriver, IWindowDriverRegistry } from 'vs/platform/driver/common/driver';
import { IDriver, IElement, ILocaleInfo, ILocalizedStrings, IWindowDriver, IWindowDriverRegistry } from 'vs/platform/driver/common/driver';
import { WindowDriverChannelClient } from 'vs/platform/driver/common/driverIpc';
import { DriverChannel, WindowDriverRegistryChannel } from 'vs/platform/driver/node/driver';
import { IEnvironmentMainService } from 'vs/platform/environment/electron-main/environmentMainService';
@@ -40,7 +40,6 @@ export class Driver implements IDriver, IWindowDriverRegistry {
constructor(
private windowServer: IPCServer,
private options: IDriverOptions,
@IWindowsMainService private readonly windowsMainService: IWindowsMainService,
@ILifecycleMainService private readonly lifecycleMainService: ILifecycleMainService,
@IFileService private readonly fileService: IFileService,
@@ -48,13 +47,12 @@ export class Driver implements IDriver, IWindowDriverRegistry {
@ILogService private readonly logService: ILogService
) { }
async registerWindowDriver(windowId: number): Promise<IDriverOptions> {
async registerWindowDriver(windowId: number): Promise<void> {
this.logService.info(`[driver] registerWindowDriver(${windowId})`);
this.registeredWindowIds.add(windowId);
this.reloadingWindowIds.delete(windowId);
this.onDidReloadingChange.fire();
return this.options;
}
async reloadWindowDriver(windowId: number): Promise<void> {
@@ -111,10 +109,12 @@ export class Driver implements IDriver, IWindowDriverRegistry {
this.lifecycleMainService.reload(window);
}
exitApplication(): Promise<boolean> {
async exitApplication(): Promise<number> {
this.logService.info(`[driver] exitApplication()`);
return this.lifecycleMainService.quit();
this.lifecycleMainService.quit();
return process.pid;
}
async dispatchKeybinding(windowId: number, keybinding: string): Promise<void> {
@@ -175,11 +175,6 @@ export class Driver implements IDriver, IWindowDriverRegistry {
await windowDriver.click(selector, xoffset, yoffset);
}
async doubleClick(windowId: number, selector: string): Promise<void> {
const windowDriver = await this.getWindowDriver(windowId);
await windowDriver.doubleClick(selector);
}
async setValue(windowId: number, selector: string, text: string): Promise<void> {
const windowDriver = await this.getWindowDriver(windowId);
await windowDriver.setValue(selector, text);
@@ -249,11 +244,9 @@ export class Driver implements IDriver, IWindowDriverRegistry {
export async function serve(
windowServer: IPCServer,
handle: string,
environmentMainService: IEnvironmentMainService,
instantiationService: IInstantiationService
): Promise<IDisposable> {
const verbose = environmentMainService.driverVerbose;
const driver = instantiationService.createInstance(Driver, windowServer, { verbose });
const driver = instantiationService.createInstance(Driver, windowServer);
const windowDriverRegistryChannel = new WindowDriverRegistryChannel(driver);
windowServer.registerChannel('windowDriverRegistry', windowDriverRegistryChannel);

View File

@@ -5,13 +5,32 @@
import { timeout } from 'vs/base/common/async';
import { IDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { BaseWindowDriver } from 'vs/platform/driver/browser/baseDriver';
import { BrowserWindowDriver } from 'vs/platform/driver/browser/driver';
import { WindowDriverChannel, WindowDriverRegistryChannelClient } from 'vs/platform/driver/common/driverIpc';
import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { IMainProcessService } from 'vs/platform/ipc/electron-sandbox/services';
import { INativeHostService } from 'vs/platform/native/electron-sandbox/native';
class WindowDriver extends BaseWindowDriver {
interface INativeWindowDriverHelper {
exitApplication(): Promise<number /* main process PID */>;
}
class NativeWindowDriver extends BrowserWindowDriver {
constructor(private readonly helper: INativeWindowDriverHelper) {
super();
}
exitApplication(): Promise<number> {
return this.helper.exitApplication();
}
}
export function registerWindowDriver(helper: INativeWindowDriverHelper): void {
Object.assign(window, { driver: new NativeWindowDriver(helper) });
}
class LegacyNativeWindowDriver extends BrowserWindowDriver {
constructor(
@INativeHostService private readonly nativeHostService: INativeHostService
@@ -19,16 +38,13 @@ class WindowDriver extends BaseWindowDriver {
super();
}
click(selector: string, xoffset?: number, yoffset?: number): Promise<void> {
override click(selector: string, xoffset?: number, yoffset?: number): Promise<void> {
const offset = typeof xoffset === 'number' && typeof yoffset === 'number' ? { x: xoffset, y: yoffset } : undefined;
return this._click(selector, 1, offset);
return this.doClick(selector, 1, offset);
}
doubleClick(selector: string): Promise<void> {
return this._click(selector, 2);
}
private async _click(selector: string, clickCount: number, offset?: { x: number; y: number }): Promise<void> {
private async doClick(selector: string, clickCount: number, offset?: { x: number; y: number }): Promise<void> {
const { x, y } = await this._getElementXY(selector, offset);
await this.nativeHostService.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount } as any);
@@ -37,17 +53,19 @@ class WindowDriver extends BaseWindowDriver {
await this.nativeHostService.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount } as any);
await timeout(100);
}
async openDevTools(): Promise<void> {
await this.nativeHostService.openDevTools({ mode: 'detach' });
}
}
export async function registerWindowDriver(accessor: ServicesAccessor, windowId: number): Promise<IDisposable> {
/**
* Old school window driver that is implemented by us
* from the main process.
*
* @deprecated
*/
export async function registerLegacyWindowDriver(accessor: ServicesAccessor, windowId: number): Promise<IDisposable> {
const instantiationService = accessor.get(IInstantiationService);
const mainProcessService = accessor.get(IMainProcessService);
const windowDriver = instantiationService.createInstance(WindowDriver);
const windowDriver = instantiationService.createInstance(LegacyNativeWindowDriver);
const windowDriverChannel = new WindowDriverChannel(windowDriver);
mainProcessService.registerChannel('windowDriver', windowDriverChannel);

View File

@@ -27,7 +27,6 @@ export class DriverChannel implements IServerChannel {
case 'exitApplication': return this.driver.exitApplication();
case 'dispatchKeybinding': return this.driver.dispatchKeybinding(arg[0], arg[1]);
case 'click': return this.driver.click(arg[0], arg[1], arg[2], arg[3]);
case 'doubleClick': return this.driver.doubleClick(arg[0], arg[1]);
case 'setValue': return this.driver.setValue(arg[0], arg[1], arg[2]);
case 'getTitle': return this.driver.getTitle(arg[0]);
case 'isActiveElement': return this.driver.isActiveElement(arg[0], arg[1]);
@@ -70,7 +69,7 @@ export class DriverChannelClient implements IDriver {
return this.channel.call('reloadWindow', windowId);
}
exitApplication(): Promise<boolean> {
exitApplication(): Promise<number> {
return this.channel.call('exitApplication');
}
@@ -82,10 +81,6 @@ export class DriverChannelClient implements IDriver {
return this.channel.call('click', [windowId, selector, xoffset, yoffset]);
}
doubleClick(windowId: number, selector: string): Promise<void> {
return this.channel.call('doubleClick', [windowId, selector]);
}
setValue(windowId: number, selector: string, text: string): Promise<void> {
return this.channel.call('setValue', [windowId, selector, text]);
}

View File

@@ -79,8 +79,11 @@ export interface NativeParsedArgs {
'max-memory'?: string;
'file-write'?: boolean;
'file-chmod'?: boolean;
/**
* @deprecated use `enable-smoke-test-driver`
*/
'driver'?: string;
'driver-verbose'?: boolean;
'enable-smoke-test-driver'?: boolean;
'remote'?: string;
'force'?: boolean;
'do-not-sync'?: boolean;

View File

@@ -35,7 +35,6 @@ export interface IEnvironmentMainService extends INativeEnvironmentService {
// --- config
sandbox: boolean;
driverVerbose: boolean;
disableUpdates: boolean;
}
@@ -59,9 +58,6 @@ export class EnvironmentMainService extends NativeEnvironmentService implements
@memoize
get sandbox(): boolean { return !!this.args['__sandbox']; }
@memoize
get driverVerbose(): boolean { return !!this.args['driver-verbose']; }
@memoize
get disableUpdates(): boolean { return !!this.args['disable-updates']; }

View File

@@ -99,6 +99,7 @@ export const OPTIONS: OptionDescriptions<Required<NativeParsedArgs>> = {
'export-default-configuration': { type: 'string' },
'install-source': { type: 'string' },
'driver': { type: 'string' },
'enable-smoke-test-driver': { type: 'boolean' },
'logExtensionHostCommunication': { type: 'boolean' },
'skip-release-notes': { type: 'boolean' },
'skip-welcome': { type: 'boolean' },
@@ -114,7 +115,6 @@ export const OPTIONS: OptionDescriptions<Required<NativeParsedArgs>> = {
'open-url': { type: 'boolean' },
'file-write': { type: 'boolean' },
'file-chmod': { type: 'boolean' },
'driver-verbose': { type: 'boolean' },
'install-builtin-extension': { type: 'string[]' },
'force': { type: 'boolean' },
'do-not-sync': { type: 'boolean' },

View File

@@ -112,16 +112,20 @@ export class BrowserWindow extends Disposable {
private create(): void {
// Driver
if (this.environmentService.options?.developmentOptions?.enableSmokeTestDriver) {
(async () => this._register(await registerWindowDriver()))();
}
// Handle open calls
this.setupOpenHandlers();
// Label formatting
this.registerLabelFormatters();
// Smoke Test Driver
this.setupDriver();
}
private setupDriver(): void {
if (this.environmentService.options?.developmentOptions?.enableSmokeTestDriver) {
registerWindowDriver();
}
}
private setupOpenHandlers(): void {

View File

@@ -43,7 +43,6 @@ import { ProxyChannel } from 'vs/base/parts/ipc/common/ipc';
import { NativeLogService } from 'vs/workbench/services/log/electron-sandbox/logService';
import { WorkspaceTrustEnablementService, WorkspaceTrustManagementService } from 'vs/workbench/services/workspaces/common/workspaceTrust';
import { IWorkspaceTrustEnablementService, IWorkspaceTrustManagementService } from 'vs/platform/workspace/common/workspaceTrust';
import { registerWindowDriver } from 'vs/platform/driver/electron-sandbox/driver';
import { safeStringify } from 'vs/base/common/objects';
import { ISharedProcessWorkerWorkbenchService, SharedProcessWorkerWorkbenchService } from 'vs/workbench/services/sharedProcess/electron-sandbox/sharedProcessWorkerWorkbenchService';
import { isCI, isMacintosh } from 'vs/base/common/platform';
@@ -115,11 +114,6 @@ export class DesktopMain extends Disposable {
// Window
this._register(instantiationService.createInstance(NativeWindow));
// Driver
if (this.configuration.driver) {
instantiationService.invokeFunction(async accessor => this._register(await registerWindowDriver(accessor, this.configuration.windowId)));
}
}
private getExtraClasses(): string[] {

View File

@@ -63,6 +63,7 @@ import { whenEditorClosed } from 'vs/workbench/browser/editor';
import { ISharedProcessService } from 'vs/platform/ipc/electron-sandbox/services';
import { IProgressService, ProgressLocation } from 'vs/platform/progress/common/progress';
import { toErrorMessage } from 'vs/base/common/errorMessage';
import { registerLegacyWindowDriver, registerWindowDriver } from 'vs/platform/driver/electron-sandbox/driver';
export class NativeWindow extends Disposable {
@@ -570,6 +571,29 @@ export class NativeWindow extends Disposable {
this.nativeHostService.openDevTools();
}
}
// Smoke Test Driver
this.setupDriver();
}
private setupDriver(): void {
// Browser Driver
if (this.environmentService.args['enable-smoke-test-driver']) {
const that = this;
registerWindowDriver({
async exitApplication(): Promise<number> {
that.nativeHostService.quit();
return that.environmentService.mainPid;
}
});
}
// Legacy Driver (TODO@bpasero remove me eventually)
else if (this.environmentService.args.driver) {
this.instantiationService.invokeFunction(async accessor => this._register(await registerLegacyWindowDriver(accessor, this.nativeHostService.windowId)));
}
}
private setupOpenHandlers(): void {

View File

@@ -52,6 +52,10 @@ export class Application {
return !!this.options.web;
}
get legacy(): boolean {
return !!this.options.legacy;
}
private _workspacePathOrFolder: string;
get workspacePathOrFolder(): string {
return this._workspacePathOrFolder;
@@ -115,11 +119,11 @@ export class Application {
}
private async takeScreenshot(name: string): Promise<void> {
if (this.web) {
return; // supported only on desktop
if (this.web || !this.legacy) {
return; // supported only on desktop (legacy)
}
// Desktop: call `stopTracing` to take a screenshot
// Desktop (legacy): call `stopTracing` to take a screenshot
return this._code?.stopTracing(name, true);
}

View File

@@ -3,17 +3,18 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import { join } from 'path';
import * as os from 'os';
import * as cp from 'child_process';
import { IDriver, IDisposable, IElement, Thenable, ILocalizedStrings, ILocaleInfo } from './driver';
import { launch as launchElectron } from './electronDriver';
import { launch as launchPlaywright } from './playwrightDriver';
import { launch as launchPlaywrightBrowser } from './playwrightBrowserDriver';
import { launch as launchPlaywrightElectron } from './playwrightElectronDriver';
import { Logger, measureAndLog } from './logger';
import { copyExtension } from './extensions';
import * as treekill from 'tree-kill';
const repoPath = path.join(__dirname, '../../..');
const rootPath = join(__dirname, '../../..');
export interface LaunchOptions {
codePath?: string;
@@ -21,10 +22,13 @@ export interface LaunchOptions {
userDataDir: string;
extensionsPath: string;
logger: Logger;
logsPath: string;
verbose?: boolean;
extraArgs?: string[];
remote?: boolean;
web?: boolean;
legacy?: boolean;
tracing?: boolean;
headless?: boolean;
browser?: 'chromium' | 'webkit' | 'firefox';
}
@@ -71,22 +75,29 @@ export async function launch(options: LaunchOptions): Promise<Code> {
throw new Error('Smoke test process has terminated, refusing to spawn Code');
}
await measureAndLog(copyExtension(repoPath, options.extensionsPath, 'vscode-notebook-tests'), 'copyExtension(vscode-notebook-tests)', options.logger);
await measureAndLog(copyExtension(rootPath, options.extensionsPath, 'vscode-notebook-tests'), 'copyExtension(vscode-notebook-tests)', options.logger);
// Browser smoke tests
if (options.web) {
const { serverProcess, client, driver, kill } = await measureAndLog(launchPlaywright(options), 'launch playwright', options.logger);
const { serverProcess, client, driver, kill } = await measureAndLog(launchPlaywrightBrowser(options), 'launch playwright (browser)', options.logger);
registerInstance(serverProcess, options.logger, 'server', kill);
return new Code(client, driver, options.logger, serverProcess);
return new Code(client, driver, options.logger);
}
// Electron smoke tests
// Electron smoke tests (playwright)
else if (!options.legacy) {
const { client, driver } = await measureAndLog(launchPlaywrightElectron(options), 'launch playwright (electron)', options.logger);
return new Code(client, driver, options.logger);
}
// Electron smoke tests (legacy driver)
else {
const { electronProcess, client, driver, kill } = await measureAndLog(launchElectron(options), 'launch electron', options.logger);
registerInstance(electronProcess, options.logger, 'electron', kill);
return new Code(client, driver, options.logger, electronProcess);
return new Code(client, driver, options.logger);
}
}
@@ -134,8 +145,7 @@ export class Code {
constructor(
private client: IDisposable,
driver: IDriver,
readonly logger: Logger,
private readonly mainProcess: cp.ChildProcess
readonly logger: Logger
) {
this.driver = new Proxy(driver, {
get(target, prop) {
@@ -185,18 +195,13 @@ export class Code {
}
async exit(): Promise<void> {
// Start the exit flow via driver
const pid = await measureAndLog(this.driver.exitApplication(), 'driver.exitApplication()', this.logger);
return measureAndLog(new Promise<void>((resolve, reject) => {
let done = false;
// Start the exit flow via driver
this.driver.exitApplication().then(veto => {
if (veto) {
done = true;
reject(new Error('Smoke test exit call resulted in unexpected veto'));
}
});
// Await the exit of the application
(async () => {
let retries = 0;
while (!done) {
@@ -206,7 +211,7 @@ export class Code {
this.logger.log('Smoke test exit call did not terminate process after 10s, forcefully exiting the application...');
// no need to await since we're polling for the process to die anyways
treekill(this.mainProcess.pid!, err => {
treekill(pid, err => {
this.logger.log('Failed to kill Electron process tree:', err?.message);
});
}
@@ -217,7 +222,7 @@ export class Code {
}
try {
process.kill(this.mainProcess.pid!, 0); // throws an exception if the process doesn't exist anymore.
process.kill(pid, 0); // throws an exception if the process doesn't exist anymore.
await new Promise(resolve => setTimeout(resolve, 500));
} catch (error) {
done = true;

View File

@@ -3,10 +3,14 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
//@ts-check
'use strict';
const path = require('path');
exports.connect = function (outPath, handle) {
const bootstrapPath = path.join(outPath, 'bootstrap-amd.js');
const { load } = require(bootstrapPath);
return new Promise((c, e) => load('vs/platform/driver/node/driver', ({ connect }) => connect(handle).then(c, e), e));
};
return new Promise((resolve, reject) => load('vs/platform/driver/node/driver', ({ connect }) => connect(handle).then(resolve, reject), reject));
};

View File

@@ -3,8 +3,8 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import * as os from 'os';
import { join } from 'path';
import { platform } from 'os';
import { tmpName } from 'tmp';
import { connect as connectElectronDriver, IDisposable, IDriver } from './driver';
import { ChildProcess, spawn, SpawnOptions } from 'child_process';
@@ -16,15 +16,17 @@ import { URI } from 'vscode-uri';
import { Logger, measureAndLog } from './logger';
import type { LaunchOptions } from './code';
const repoPath = path.join(__dirname, '../../..');
const root = join(__dirname, '..', '..', '..');
export async function launch(options: LaunchOptions): Promise<{ electronProcess: ChildProcess; client: IDisposable; driver: IDriver; kill: () => Promise<void> }> {
const { codePath, workspacePath, extensionsPath, userDataDir, remote, logger, verbose, extraArgs } = options;
export interface IElectronConfiguration {
readonly electronPath: string;
readonly args: string[];
readonly env?: NodeJS.ProcessEnv;
}
export async function resolveElectronConfiguration(options: LaunchOptions): Promise<IElectronConfiguration> {
const { codePath, workspacePath, extensionsPath, userDataDir, remote, logger, logsPath, extraArgs } = options;
const env = { ...process.env };
const logsPath = path.join(repoPath, '.build', 'logs', remote ? 'smoke-tests-remote' : 'smoke-tests');
const outPath = codePath ? getBuildOutPath(codePath) : getDevOutPath();
const driverIPCHandle = await measureAndLog(createDriverHandle(), 'createDriverHandle', logger);
const args = [
workspacePath,
@@ -38,8 +40,7 @@ export async function launch(options: LaunchOptions): Promise<{ electronProcess:
'--disable-workspace-trust',
`--extensions-dir=${extensionsPath}`,
`--user-data-dir=${userDataDir}`,
`--logsPath=${logsPath}`,
'--driver', driverIPCHandle
`--logsPath=${logsPath}`
];
if (process.platform === 'linux') {
@@ -52,7 +53,7 @@ export async function launch(options: LaunchOptions): Promise<{ electronProcess:
if (codePath) {
// running against a build: copy the test resolver extension
await measureAndLog(copyExtension(repoPath, extensionsPath, 'vscode-test-resolver'), 'copyExtension(vscode-test-resolver)', logger);
await measureAndLog(copyExtension(root, extensionsPath, 'vscode-test-resolver'), 'copyExtension(vscode-test-resolver)', logger);
}
args.push('--enable-proposed-api=vscode.vscode-test-resolver');
const remoteDataDir = `${userDataDir}-server`;
@@ -60,26 +61,19 @@ export async function launch(options: LaunchOptions): Promise<{ electronProcess:
if (codePath) {
// running against a build: copy the test resolver extension into remote extensions dir
const remoteExtensionsDir = path.join(remoteDataDir, 'extensions');
const remoteExtensionsDir = join(remoteDataDir, 'extensions');
mkdirp.sync(remoteExtensionsDir);
await measureAndLog(copyExtension(repoPath, remoteExtensionsDir, 'vscode-notebook-tests'), 'copyExtension(vscode-notebook-tests)', logger);
await measureAndLog(copyExtension(root, remoteExtensionsDir, 'vscode-notebook-tests'), 'copyExtension(vscode-notebook-tests)', logger);
}
env['TESTRESOLVER_DATA_FOLDER'] = remoteDataDir;
env['TESTRESOLVER_LOGS_FOLDER'] = path.join(logsPath, 'server');
env['TESTRESOLVER_LOGS_FOLDER'] = join(logsPath, 'server');
}
const spawnOptions: SpawnOptions = { env };
args.push('--enable-proposed-api=vscode.vscode-notebook-tests');
if (!codePath) {
args.unshift(repoPath);
}
if (verbose) {
args.push('--driver-verbose');
spawnOptions.stdio = ['ignore', 'inherit', 'inherit'];
args.unshift(root);
}
if (extraArgs) {
@@ -87,6 +81,32 @@ export async function launch(options: LaunchOptions): Promise<{ electronProcess:
}
const electronPath = codePath ? getBuildElectronPath(codePath) : getDevElectronPath();
return {
env,
args,
electronPath
};
}
/**
* @deprecated should use the playwright based electron support instead
*/
export async function launch(options: LaunchOptions): Promise<{ electronProcess: ChildProcess; client: IDisposable; driver: IDriver; kill: () => Promise<void> }> {
const { codePath, logger, verbose } = options;
const { env, args, electronPath } = await resolveElectronConfiguration(options);
const driverIPCHandle = await measureAndLog(createDriverHandle(), 'createDriverHandle', logger);
args.push('--driver', driverIPCHandle);
const outPath = codePath ? getBuildOutPath(codePath) : getDevOutPath();
const spawnOptions: SpawnOptions = { env };
if (verbose) {
spawnOptions.stdio = ['ignore', 'inherit', 'inherit'];
}
const electronProcess = spawn(electronPath, args, spawnOptions);
logger.log(`Started electron for desktop smoke tests on pid ${electronProcess.pid}`);
@@ -150,56 +170,65 @@ async function teardown(electronProcess: ChildProcess, logger: Logger): Promise<
logger.log(`Gave up tearing down electron client after ${retries} attempts...`);
}
function getDevElectronPath(): string {
const buildPath = path.join(repoPath, '.build');
const product = require(path.join(repoPath, 'product.json'));
export function getDevElectronPath(): string {
const buildPath = join(root, '.build');
const product = require(join(root, 'product.json'));
switch (process.platform) {
case 'darwin':
return path.join(buildPath, 'electron', `${product.nameLong}.app`, 'Contents', 'MacOS', 'Electron');
return join(buildPath, 'electron', `${product.nameLong}.app`, 'Contents', 'MacOS', 'Electron');
case 'linux':
return path.join(buildPath, 'electron', `${product.applicationName}`);
return join(buildPath, 'electron', `${product.applicationName}`);
case 'win32':
return path.join(buildPath, 'electron', `${product.nameShort}.exe`);
return join(buildPath, 'electron', `${product.nameShort}.exe`);
default:
throw new Error('Unsupported platform.');
}
}
function getBuildElectronPath(root: string): string {
export function getBuildElectronPath(root: string): string {
switch (process.platform) {
case 'darwin':
return path.join(root, 'Contents', 'MacOS', 'Electron');
return join(root, 'Contents', 'MacOS', 'Electron');
case 'linux': {
const product = require(path.join(root, 'resources', 'app', 'product.json'));
return path.join(root, product.applicationName);
const product = require(join(root, 'resources', 'app', 'product.json'));
return join(root, product.applicationName);
}
case 'win32': {
const product = require(path.join(root, 'resources', 'app', 'product.json'));
return path.join(root, `${product.nameShort}.exe`);
const product = require(join(root, 'resources', 'app', 'product.json'));
return join(root, `${product.nameShort}.exe`);
}
default:
throw new Error('Unsupported platform.');
}
}
export function getBuildVersion(root: string): string {
switch (process.platform) {
case 'darwin':
return require(join(root, 'Contents', 'Resources', 'app', 'package.json')).version;
default:
return require(join(root, 'resources', 'app', 'package.json')).version;
}
}
function getDevOutPath(): string {
return path.join(repoPath, 'out');
return join(root, 'out');
}
function getBuildOutPath(root: string): string {
switch (process.platform) {
case 'darwin':
return path.join(root, 'Contents', 'Resources', 'app', 'out');
return join(root, 'Contents', 'Resources', 'app', 'out');
default:
return path.join(root, 'resources', 'app', 'out');
return join(root, 'resources', 'app', 'out');
}
}
async function createDriverHandle(): Promise<string> {
// Windows
if ('win32' === os.platform()) {
if ('win32' === platform()) {
const name = [...Array(15)].map(() => Math.random().toString(36)[3]).join('');
return `\\\\.\\pipe\\${name}`;
}

View File

@@ -26,3 +26,4 @@ export * from './viewlet';
export * from './localization';
export * from './workbench';
export * from './driver';
export { getDevElectronPath, getBuildElectronPath, getBuildVersion } from './electronDriver';

View File

@@ -15,38 +15,32 @@ import { PageFunction } from 'playwright-core/types/structs';
import { Logger, measureAndLog } from './logger';
import type { LaunchOptions } from './code';
const width = 1200;
const height = 800;
export class PlaywrightDriver implements IDriver {
const root = join(__dirname, '..', '..', '..');
const logsPath = join(root, '.build', 'logs', 'smoke-tests-browser');
private static traceCounter = 1;
const vscodeToPlaywrightKey: { [key: string]: string } = {
cmd: 'Meta',
ctrl: 'Control',
shift: 'Shift',
enter: 'Enter',
escape: 'Escape',
right: 'ArrowRight',
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
home: 'Home',
esc: 'Escape'
};
let traceCounter = 1;
class PlaywrightDriver implements IDriver {
private static readonly vscodeToPlaywrightKey: { [key: string]: string } = {
cmd: 'Meta',
ctrl: 'Control',
shift: 'Shift',
enter: 'Enter',
escape: 'Escape',
right: 'ArrowRight',
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
home: 'Home',
esc: 'Escape'
};
_serviceBrand: undefined;
constructor(
private readonly server: ChildProcess,
private readonly browser: playwright.Browser,
private readonly application: playwright.Browser | playwright.ElectronApplication,
private readonly context: playwright.BrowserContext,
private readonly page: playwright.Page,
private readonly logger: Logger
private readonly serverPid: number | undefined,
private readonly options: LaunchOptions
) {
}
@@ -59,46 +53,72 @@ class PlaywrightDriver implements IDriver {
}
async startTracing(windowId: number, name: string): Promise<void> {
if (!this.options.tracing) {
return; // tracing disabled
}
try {
await measureAndLog(this.context.tracing.startChunk({ title: name }), `startTracing for ${name}`, this.logger);
await measureAndLog(this.context.tracing.startChunk({ title: name }), `startTracing for ${name}`, this.options.logger);
} catch (error) {
// Ignore
}
}
async stopTracing(windowId: number, name: string, persist: boolean): Promise<void> {
if (!this.options.tracing) {
return; // tracing disabled
}
try {
let persistPath: string | undefined = undefined;
if (persist) {
persistPath = join(logsPath, `playwright-trace-${traceCounter++}-${name.replace(/\s+/g, '-')}.zip`);
persistPath = join(this.options.logsPath, `playwright-trace-${PlaywrightDriver.traceCounter++}-${name.replace(/\s+/g, '-')}.zip`);
}
await measureAndLog(this.context.tracing.stopChunk({ path: persistPath }), `stopTracing for ${name}`, this.logger);
await measureAndLog(this.context.tracing.stopChunk({ path: persistPath }), `stopTracing for ${name}`, this.options.logger);
} catch (error) {
// Ignore
}
}
async reloadWindow(windowId: number) {
throw new Error('Unsupported');
await this.page.reload();
}
async exitApplication() {
// Stop tracing
try {
await measureAndLog(this.context.tracing.stop(), 'stop tracing', this.logger);
if (this.options.tracing) {
await measureAndLog(this.context.tracing.stop(), 'stop tracing', this.options.logger);
}
} catch (error) {
// Ignore
}
try {
await measureAndLog(this.browser.close(), 'Browser.close()', this.logger);
} catch (error) {
// Ignore
// VSCode shutdown (desktop only)
let mainPid: number | undefined = undefined;
if (!this.options.web) {
try {
mainPid = await measureAndLog(this._evaluateWithDriver(([driver]) => (driver as unknown as IDriver).exitApplication()), 'driver.exitApplication()', this.options.logger);
} catch (error) {
this.options.logger.log(`Error exiting appliction (${error})`);
}
}
await measureAndLog(teardown(this.server, this.logger), 'teardown server', this.logger);
// Playwright shutdown
try {
await measureAndLog(this.application.close(), 'playwright.close()', this.options.logger);
} catch (error) {
this.options.logger.log(`Error closing appliction (${error})`);
}
return false;
// Server shutdown
if (typeof this.serverPid === 'number') {
await measureAndLog(teardown(this.serverPid, this.options.logger), 'teardown server', this.options.logger);
}
return mainPid ?? this.serverPid! /* when running web we must have a server Pid */;
}
async dispatchKeybinding(windowId: number, keybinding: string) {
@@ -117,8 +137,8 @@ class PlaywrightDriver implements IDriver {
const keys = chord.split('+');
const keysDown: string[] = [];
for (let i = 0; i < keys.length; i++) {
if (keys[i] in vscodeToPlaywrightKey) {
keys[i] = vscodeToPlaywrightKey[keys[i]];
if (keys[i] in PlaywrightDriver.vscodeToPlaywrightKey) {
keys[i] = PlaywrightDriver.vscodeToPlaywrightKey[keys[i]];
}
await this.page.keyboard.down(keys[i]);
keysDown.push(keys[i]);
@@ -136,10 +156,6 @@ class PlaywrightDriver implements IDriver {
await this.page.mouse.click(x + (xoffset ? xoffset : 0), y + (yoffset ? yoffset : 0));
}
async doubleClick(windowId: number, selector: string) {
throw new Error('Unsupported');
}
async setValue(windowId: number, selector: string, text: string) {
return this.page.evaluate(([driver, selector, text]) => driver.setValue(selector, text), [await this._getDriverHandle(), selector, text] as const);
}
@@ -188,12 +204,13 @@ class PlaywrightDriver implements IDriver {
return new Promise<void>(resolve => setTimeout(resolve, ms));
}
// TODO: Cache
private async _getDriverHandle(): Promise<playwright.JSHandle<IWindowDriver>> {
return this.page.evaluateHandle('window.driver');
}
}
const root = join(__dirname, '..', '..', '..');
let port = 9000;
export async function launch(options: LaunchOptions): Promise<{ serverProcess: ChildProcess; client: IDisposable; driver: IDriver; kill: () => Promise<void> }> {
@@ -209,13 +226,13 @@ export async function launch(options: LaunchOptions): Promise<{ serverProcess: C
client: {
dispose: () => { /* there is no client to dispose for browser, teardown is triggered via exitApplication call */ }
},
driver: new PlaywrightDriver(serverProcess, browser, context, page, options.logger),
kill: () => teardown(serverProcess, options.logger)
driver: new PlaywrightDriver(browser, context, page, serverProcess.pid, options),
kill: () => teardown(serverProcess.pid, options.logger)
};
}
async function launchServer(options: LaunchOptions) {
const { userDataDir, codePath, extensionsPath, logger } = options;
const { userDataDir, codePath, extensionsPath, logger, logsPath } = options;
const codeServerPath = codePath ?? process.env.VSCODE_REMOTE_SERVER_PATH;
const agentFolder = userDataDir;
await measureAndLog(promisify(mkdir)(agentFolder), `mkdir(${agentFolder})`, logger);
@@ -257,21 +274,23 @@ async function launchServer(options: LaunchOptions) {
}
async function launchBrowser(options: LaunchOptions, endpoint: string) {
const { logger, workspacePath } = options;
const { logger, workspacePath, tracing, headless } = options;
const browser = await measureAndLog(playwright[options.browser ?? 'chromium'].launch({ headless: options.headless ?? false }), 'playwright#launch', logger);
const browser = await measureAndLog(playwright[options.browser ?? 'chromium'].launch({ headless: headless ?? false }), 'playwright#launch', logger);
browser.on('disconnected', () => logger.log(`Playwright: browser disconnected`));
const context = await measureAndLog(browser.newContext(), 'browser.newContext', logger);
try {
await measureAndLog(context.tracing.start({ screenshots: true, snapshots: true, sources: true }), 'context.tracing.start()', logger);
} catch (error) {
logger.log(`Failed to start playwright tracing: ${error}`); // do not fail the build when this fails
if (tracing) {
try {
await measureAndLog(context.tracing.start({ screenshots: true, /* remaining options are off for perf reasons */ }), 'context.tracing.start()', logger);
} catch (error) {
logger.log(`Failed to start playwright tracing: ${error}`); // do not fail the build when this fails
}
}
const page = await measureAndLog(context.newPage(), 'context.newPage()', logger);
await measureAndLog(page.setViewportSize({ width, height }), 'page.setViewportSize', logger);
await measureAndLog(page.setViewportSize({ width: 1200, height: 800 }), 'page.setViewportSize', logger);
page.on('pageerror', async (error) => logger.log(`Playwright ERROR: page error: ${error}`));
page.on('crash', () => logger.log('Playwright ERROR: page crash'));
@@ -288,8 +307,7 @@ async function launchBrowser(options: LaunchOptions, endpoint: string) {
return { browser, context, page };
}
async function teardown(server: ChildProcess, logger: Logger): Promise<void> {
const serverPid = server.pid;
async function teardown(serverPid: number | undefined, logger: Logger): Promise<void> {
if (typeof serverPid !== 'number') {
return;
}

View File

@@ -0,0 +1,61 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as playwright from '@playwright/test';
import { IDriver, IDisposable } from './driver';
import type { LaunchOptions } from './code';
import { PlaywrightDriver } from './playwrightBrowserDriver';
import { IElectronConfiguration, resolveElectronConfiguration } from './electronDriver';
import { measureAndLog } from './logger';
export async function launch(options: LaunchOptions): Promise<{ client: IDisposable; driver: IDriver }> {
// Resolve electron config and update
const { electronPath, args, env } = await resolveElectronConfiguration(options);
args.push('--enable-smoke-test-driver', 'true');
// Launch electron via playwright
const { electron, context, page } = await launchElectron({ electronPath, args, env }, options);
return {
client: {
dispose: () => { /* there is no client to dispose for electron, teardown is triggered via exitApplication call */ }
},
driver: new PlaywrightDriver(electron, context, page, undefined /* no server */, options)
};
}
async function launchElectron(configuration: IElectronConfiguration, options: LaunchOptions) {
const { logger, tracing } = options;
const electron = await measureAndLog(playwright._electron.launch({
executablePath: configuration.electronPath,
args: configuration.args,
env: configuration.env as { [key: string]: string }
}), 'playwright-electron#launch', logger);
const window = await measureAndLog(electron.firstWindow(), 'playwright-electron#firstWindow', logger);
const context = window.context();
if (tracing) {
try {
await measureAndLog(context.tracing.start({ screenshots: true, /* remaining options are off for perf reasons */ }), 'context.tracing.start()', logger);
} catch (error) {
logger.log(`Failed to start playwright tracing: ${error}`); // do not fail the build when this fails
}
}
window.on('pageerror', async (error) => logger.log(`Playwright ERROR: page error: ${error}`));
window.on('crash', () => logger.log('Playwright ERROR: page crash'));
window.on('close', () => logger.log('Playwright: page close'));
window.on('response', async (response) => {
if (response.status() >= 400) {
logger.log(`Playwright ERROR: HTTP status ${response.status()} for ${response.url()}`);
}
});
return { electron, context, page: window };
}

View File

@@ -3,6 +3,9 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
//@ts-check
'use strict';
const fs = require('fs');
const path = require('path');

View File

@@ -3,6 +3,9 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
//@ts-check
'use strict';
const fs = require('fs');
const path = require('path');

View File

@@ -125,7 +125,7 @@ async function launchServer(browserType: BrowserType): Promise<{ endpoint: url.U
const root = path.join(__dirname, '..', '..', '..', '..');
const logsPath = path.join(root, '.build', 'logs', 'integration-tests-browser');
const serverArgs = ['--driver', 'web', '--enable-proposed-api', '--disable-telemetry', '--server-data-dir', userDataDir, '--accept-server-license-terms', '--disable-workspace-trust'];
const serverArgs = ['--enable-proposed-api', '--disable-telemetry', '--server-data-dir', userDataDir, '--accept-server-license-terms', '--disable-workspace-trust'];
let serverLocation: string;
if (process.env.VSCODE_REMOTE_SERVER_PATH) {

View File

@@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
//@ts-check
'use strict';
const paths = require('path');
@@ -10,7 +11,9 @@ const glob = require('glob');
// Linux: prevent a weird NPE when mocha on Linux requires the window size from the TTY
// Since we are not running in a tty environment, we just implementt he method statically
const tty = require('tty');
// @ts-ignore
if (!tty.getWindowSize) {
// @ts-ignore
tty.getWindowSize = function () { return [80, 75]; };
}
const Mocha = require('mocha');

View File

@@ -7,7 +7,7 @@ import { join } from 'path';
import { Application, Quality, StatusBarElement, Logger } from '../../../../automation';
import { installAllHandlers } from '../../utils';
export function setup(isWeb: boolean, logger: Logger) {
export function setup(logger: Logger) {
describe('Statusbar', () => {
// Shared before/after handling

View File

@@ -118,7 +118,7 @@ export function setup(ensureStableCode: () => string | undefined, logger: Logger
}
});
describe('Data Loss (stable -> insiders)', () => {
describe.skip('Data Loss (stable -> insiders)', () => { //TODO@bpasero enable again once we shipped 1.67.x
let insidersApp: Application | undefined = undefined;
let stableApp: Application | undefined = undefined;

View File

@@ -13,7 +13,7 @@ import * as rimraf from 'rimraf';
import * as mkdirp from 'mkdirp';
import * as vscodetest from '@vscode/test-electron';
import fetch from 'node-fetch';
import { Quality, MultiLogger, Logger, ConsoleLogger, FileLogger, measureAndLog } from '../../automation';
import { Quality, MultiLogger, Logger, ConsoleLogger, FileLogger, measureAndLog, getDevElectronPath, getBuildElectronPath, getBuildVersion } from '../../automation';
import { retry, timeout } from './utils';
import { setup as setupDataLossTests } from './areas/workbench/data-loss.test';
@@ -28,7 +28,7 @@ import { setup as setupLocalizationTests } from './areas/workbench/localization.
import { setup as setupLaunchTests } from './areas/workbench/launch.test';
import { setup as setupTerminalTests } from './areas/terminal/terminal.test';
const repoPath = path.join(__dirname, '..', '..', '..');
const rootPath = path.join(__dirname, '..', '..', '..');
const [, , ...args] = process.argv;
const opts = minimist(args, {
@@ -44,7 +44,9 @@ const opts = minimist(args, {
'verbose',
'remote',
'web',
'headless'
'headless',
'legacy',
'tracing'
],
default: {
verbose: false
@@ -54,12 +56,29 @@ const opts = minimist(args, {
remote?: boolean;
headless?: boolean;
web?: boolean;
legacy?: boolean;
tracing?: boolean;
build?: string;
'stable-build'?: string;
browser?: string;
electronArgs?: string;
};
const logsPath = (() => {
const logsParentPath = path.join(rootPath, '.build', 'logs');
let logsName: string;
if (opts.web) {
logsName = 'smoke-tests-browser';
} else if (opts.remote) {
logsName = opts.legacy ? 'smoke-tests-remote-legacy' : 'smoke-tests-remote';
} else {
logsName = opts.legacy ? 'smoke-tests-electron-legacy' : 'smoke-tests-electron';
}
return path.join(logsParentPath, logsName);
})();
const logger = createLogger();
function createLogger(): Logger {
@@ -70,10 +89,12 @@ function createLogger(): Logger {
loggers.push(new ConsoleLogger());
}
// Prepare logs path
fs.rmSync(logsPath, { recursive: true, force: true, maxRetries: 3 });
mkdirp.sync(logsPath);
// Always log to log file
const logPath = path.join(repoPath, '.build', 'logs', opts.web ? 'smoke-tests-browser' : opts.remote ? 'smoke-tests-remote' : 'smoke-tests');
mkdirp.sync(logPath);
loggers.push(new FileLogger(path.join(logPath, 'smoke-test-runner.log')));
loggers.push(new FileLogger(path.join(logsPath, 'smoke-test-runner.log')));
return new MultiLogger(loggers);
}
@@ -122,49 +143,6 @@ function parseVersion(version: string): { major: number; minor: number; patch: n
// #### Electron Smoke Tests ####
//
if (!opts.web) {
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.');
}
}
function getBuildVersion(root: string): string {
switch (process.platform) {
case 'darwin':
return require(path.join(root, 'Contents', 'Resources', 'app', 'package.json')).version;
default:
return require(path.join(root, 'resources', 'app', 'package.json')).version;
}
}
let testCodePath = opts.build;
let electronPath: string;
@@ -174,7 +152,7 @@ if (!opts.web) {
} else {
testCodePath = getDevElectronPath();
electronPath = testCodePath;
process.env.VSCODE_REPOSITORY = repoPath;
process.env.VSCODE_REPOSITORY = rootPath;
process.env.VSCODE_DEV = '1';
process.env.VSCODE_CLI = '1';
}
@@ -213,7 +191,7 @@ else {
}
if (!testCodeServerPath) {
process.env.VSCODE_REPOSITORY = repoPath;
process.env.VSCODE_REPOSITORY = rootPath;
process.env.VSCODE_DEV = '1';
process.env.VSCODE_CLI = '1';
@@ -355,9 +333,12 @@ before(async function () {
extensionsPath,
waitTime: parseInt(opts['wait-time'] || '0') || 20,
logger,
logsPath,
verbose: opts.verbose,
remote: opts.remote,
web: opts.web,
legacy: opts.legacy,
tracing: opts.tracing,
headless: opts.headless,
browser: opts.browser,
extraArgs: (opts.electronArgs || '').split(' ').map(a => a.trim()).filter(a => !!a)
@@ -390,14 +371,14 @@ after(async function () {
}
});
describe(`VSCode Smoke Tests (${opts.web ? 'Web' : 'Electron'})`, () => {
describe(`VSCode Smoke Tests (${opts.web ? 'Web' : opts.legacy ? 'Electron (legacy)' : 'Electron'})`, () => {
if (!opts.web) { setupDataLossTests(() => opts['stable-build'] /* Do not change, deferred for a reason! */, logger); }
if (!opts.web) { setupPreferencesTests(logger); }
setupPreferencesTests(logger);
setupSearchTests(logger);
setupNotebookTests(logger);
setupLanguagesTests(logger);
if (opts.web) { setupTerminalTests(logger); } // TODO@daniel TODO@meggan: Enable terminal tests for non-web when the desktop driver is moved to playwright
setupStatusbarTests(!!opts.web, logger);
setupTerminalTests(logger);
setupStatusbarTests(logger);
if (quality !== Quality.Dev) { setupExtensionTests(logger); }
if (!opts.web) { setupMultirootTests(logger); }
if (!opts.web && !opts.remote && quality !== Quality.Dev) { setupLocalizationTests(logger); }

View File

@@ -3,17 +3,20 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
const path = require('path');
//@ts-check
'use strict';
const { join } = require('path');
const Mocha = require('mocha');
const minimist = require('minimist');
const [, , ...args] = process.argv;
const opts = minimist(args, {
boolean: 'web',
boolean: ['web', 'legacy'],
string: ['f', 'g']
});
const suite = opts['web'] ? 'Browser Smoke Tests' : 'Desktop Smoke Tests';
const suite = opts['web'] ? 'Browser Smoke Tests' : opts['legacy'] ? 'Desktop Smoke Tests (Legacy)' : 'Desktop Smoke Tests';
const options = {
color: true,
@@ -28,7 +31,7 @@ if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) {
reporterEnabled: 'spec, mocha-junit-reporter',
mochaJunitReporterReporterOptions: {
testsuitesTitle: `${suite} ${process.platform}`,
mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`)
mochaFile: join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`)
}
};
}
@@ -39,9 +42,8 @@ mocha.run(failures => {
// Indicate location of log files for further diagnosis
if (failures) {
const repoPath = path.join(__dirname, '..', '..', '..');
const logPath = path.join(repoPath, '.build', 'logs', opts.web ? 'smoke-tests-browser' : opts.remote ? 'smoke-tests-remote' : 'smoke-tests');
const logFile = path.join(logPath, 'smoke-test-runner.log');
const rootPath = join(__dirname, '..', '..', '..');
const logPath = join(rootPath, '.build', 'logs');
if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) {
console.log(`
@@ -62,7 +64,7 @@ mocha.run(failures => {
# '${logPath}'.
#
# Logs of the smoke test runner are stored into
# '${logFile}'.
# 'smoke-test-runner.log' in respective folder.
#
#############################################
`);

View File

@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
//@ts-check
'use strict';
const path = require('path');
const glob = require('glob');

View File

@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
//@ts-check
'use strict';
process.env.MOCHA_COLORS = '1'; // Force colors (note that this must come before any mocha imports)