Path: blob/main/test/automation/src/playwrightDriver.ts
5238 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*--------------------------------------------------------------------------------------------*/45import * as playwright from '@playwright/test';6import type { Protocol } from 'playwright-core/types/protocol';7import { dirname, join } from 'path';8import { promises, readFileSync } from 'fs';9import { IWindowDriver } from './driver';10import { measureAndLog } from './logger';11import { LaunchOptions } from './code';12import { teardown } from './processes';13import { ChildProcess } from 'child_process';14import type { AxeResults, RunOptions } from 'axe-core';1516// Load axe-core source for injection into pages (works with Electron)17let axeSource = '';18try {19const axePath = require.resolve('axe-core/axe.min.js');20axeSource = readFileSync(axePath, 'utf-8');21} catch {22// axe-core may not be installed; keep axeSource empty to avoid failing module initialization23axeSource = '';24}2526type PageFunction<Arg, T> = (arg: Arg) => T | Promise<T>;2728export interface AccessibilityScanOptions {29/** Specific selector to scan. If not provided, scans the entire page. */30selector?: string;31/** WCAG tags to include (e.g., 'wcag2a', 'wcag2aa', 'wcag21aa'). Defaults to WCAG 2.1 AA. */32tags?: string[];33/** Rule IDs to disable for this scan. */34disableRules?: string[];35/**36* Patterns to exclude from specific rules. Keys are rule IDs, values are strings to match against element target or HTML.37*38* **IMPORTANT**: Adding exclusions here bypasses accessibility checks. Before adding an exclusion:39* 1. File an issue to track the accessibility problem40* 2. Ensure there's a plan to fix the underlying issue (e.g., hover/focus states that axe can't detect)41* 3. Get approval from @anthropics/accessibility team42*/43excludeRules?: { [ruleId: string]: string[] };44}4546export class PlaywrightDriver {4748private static traceCounter = 1;49private static screenShotCounter = 1;5051private static readonly vscodeToPlaywrightKey: { [key: string]: string } = {52cmd: 'Meta',53ctrl: 'Control',54shift: 'Shift',55enter: 'Enter',56escape: 'Escape',57right: 'ArrowRight',58up: 'ArrowUp',59down: 'ArrowDown',60left: 'ArrowLeft',61home: 'Home',62esc: 'Escape'63};6465constructor(66private readonly application: playwright.Browser | playwright.ElectronApplication,67private readonly context: playwright.BrowserContext,68private _currentPage: playwright.Page,69private readonly serverProcess: ChildProcess | undefined,70private readonly whenLoaded: Promise<unknown>,71private readonly options: LaunchOptions72) {73}7475get browserContext(): playwright.BrowserContext {76return this.context;77}7879private get page(): playwright.Page {80return this._currentPage;81}8283get currentPage(): playwright.Page {84return this._currentPage;85}8687/**88* Get all open windows/pages.89* For Electron apps, returns all Electron windows.90* For browser contexts, returns all pages.91*/92getAllWindows(): playwright.Page[] {93if ('windows' in this.application) {94return (this.application as playwright.ElectronApplication).windows();95}96return this.context.pages();97}9899/**100* Switch to a different window by index or URL pattern.101* @param indexOrUrl - Window index (0-based) or a string to match against the URL.102* When using a string, it first tries to find an exact URL match,103* then falls back to finding the first URL that contains the pattern.104* @returns The switched-to page, or undefined if not found105* @note When switching windows, any existing CDP session will be cleared since it106* remains attached to the previous page and cannot be used with the new page.107*/108switchToWindow(indexOrUrl: number | string): playwright.Page | undefined {109const windows = this.getAllWindows();110if (typeof indexOrUrl === 'number') {111if (indexOrUrl >= 0 && indexOrUrl < windows.length) {112this._currentPage = windows[indexOrUrl];113// Clear CDP session as it's attached to the previous page114this._cdpSession = undefined;115return this._currentPage;116}117} else {118// First try exact match, then fall back to substring match119let found = windows.find(w => w.url() === indexOrUrl);120if (!found) {121found = windows.find(w => w.url().includes(indexOrUrl));122}123if (found) {124this._currentPage = found;125// Clear CDP session as it's attached to the previous page126this._cdpSession = undefined;127return this._currentPage;128}129}130return undefined;131}132133/**134* Get information about all windows.135*/136getWindowsInfo(): { index: number; url: string; isCurrent: boolean }[] {137const windows = this.getAllWindows();138return windows.map((p, index) => ({139index,140url: p.url(),141isCurrent: p === this._currentPage142}));143}144145/**146* Take a screenshot of the current window.147* @param fullPage - Whether to capture the full scrollable page148* @returns Screenshot as a Buffer149*/150async screenshotBuffer(fullPage: boolean = false): Promise<Buffer> {151return await this.page.screenshot({152type: 'png',153fullPage154});155}156157/**158* Get the accessibility snapshot of the current window.159*/160async getAccessibilitySnapshot(): Promise<playwright.Accessibility['snapshot'] extends () => Promise<infer T> ? T : never> {161return await this.page.accessibility.snapshot();162}163164/**165* Click on an element using CSS selector with options.166*/167async clickSelector(selector: string, options?: { button?: 'left' | 'right' | 'middle'; clickCount?: number }): Promise<void> {168await this.page.click(selector, {169button: options?.button ?? 'left',170clickCount: options?.clickCount ?? 1171});172}173174/**175* Type text into an element.176* @param selector - CSS selector for the element177* @param text - Text to type178* @param slowly - Whether to type character by character (triggers key events)179*/180async typeText(selector: string, text: string, slowly: boolean = false): Promise<void> {181if (slowly) {182await this.page.type(selector, text, { delay: 50 });183} else {184await this.page.fill(selector, text);185}186}187188/**189* Evaluate a JavaScript expression in the current window.190*/191async evaluateExpression<T = unknown>(expression: string): Promise<T> {192return await this.page.evaluate(expression) as T;193}194195/**196* Get information about elements matching a selector.197*/198async getLocatorInfo(selector: string, action?: 'count' | 'textContent' | 'innerHTML' | 'boundingBox' | 'isVisible'): Promise<199number | string[] | { x: number; y: number; width: number; height: number } | null | boolean | { count: number; firstVisible: boolean }200> {201const locator = this.page.locator(selector);202203switch (action) {204case 'count':205return await locator.count();206case 'textContent':207return await locator.allTextContents();208case 'innerHTML':209return await locator.allInnerTexts();210case 'boundingBox':211return await locator.first().boundingBox();212case 'isVisible':213return await locator.first().isVisible();214default:215return {216count: await locator.count(),217firstVisible: await locator.first().isVisible().catch(() => false)218};219}220}221222/**223* Wait for an element to reach a specific state.224*/225async waitForElement(selector: string, options?: { state?: 'attached' | 'detached' | 'visible' | 'hidden'; timeout?: number }): Promise<void> {226await this.page.waitForSelector(selector, {227state: options?.state ?? 'visible',228timeout: options?.timeout ?? 30000229});230}231232/**233* Hover over an element.234*/235async hoverSelector(selector: string): Promise<void> {236await this.page.hover(selector);237}238239/**240* Drag from one element to another.241*/242async dragSelector(sourceSelector: string, targetSelector: string): Promise<void> {243await this.page.dragAndDrop(sourceSelector, targetSelector);244}245246/**247* Press a key or key combination.248*/249async pressKey(key: string): Promise<void> {250await this.page.keyboard.press(key);251}252253/**254* Move mouse to a specific position.255*/256async mouseMove(x: number, y: number): Promise<void> {257await this.page.mouse.move(x, y);258}259260/**261* Click at a specific position.262*/263async mouseClick(x: number, y: number, options?: { button?: 'left' | 'right' | 'middle'; clickCount?: number }): Promise<void> {264await this.page.mouse.click(x, y, {265button: options?.button ?? 'left',266clickCount: options?.clickCount ?? 1267});268}269270/**271* Drag from one position to another.272*/273async mouseDrag(startX: number, startY: number, endX: number, endY: number): Promise<void> {274await this.page.mouse.move(startX, startY);275await this.page.mouse.down();276await this.page.mouse.move(endX, endY);277await this.page.mouse.up();278}279280/**281* Select an option in a dropdown.282*/283async selectOption(selector: string, value: string | string[]): Promise<string[]> {284return await this.page.selectOption(selector, value);285}286287/**288* Fill multiple form fields at once.289*/290async fillForm(fields: { selector: string; value: string }[]): Promise<void> {291for (const field of fields) {292await this.page.fill(field.selector, field.value);293}294}295296/**297* Get console messages from the current window.298*/299async getConsoleMessages(): Promise<{ type: string; text: string }[]> {300const messages = await this.page.consoleMessages();301return messages.map(m => ({302type: m.type(),303text: m.text()304}));305}306307/**308* Wait for text to appear, disappear, or a specified time to pass.309*/310async waitForText(options: { text?: string; textGone?: string; timeout?: number }): Promise<void> {311const { text, textGone, timeout = 30000 } = options;312313if (text) {314await this.page.getByText(text).first().waitFor({ state: 'visible', timeout });315}316if (textGone) {317await this.page.getByText(textGone).first().waitFor({ state: 'hidden', timeout });318}319}320321/**322* Wait for a specified time in milliseconds.323*/324async waitForTime(ms: number): Promise<void> {325await new Promise(resolve => setTimeout(resolve, ms));326}327328/**329* Verify an element is visible.330*/331async verifyElementVisible(selector: string): Promise<boolean> {332try {333await this.page.locator(selector).first().waitFor({ state: 'visible', timeout: 5000 });334return true;335} catch {336return false;337}338}339340/**341* Verify text is visible on the page.342*/343async verifyTextVisible(text: string): Promise<boolean> {344try {345await this.page.getByText(text).first().waitFor({ state: 'visible', timeout: 5000 });346return true;347} catch {348return false;349}350}351352/**353* Get the value of an input element.354*/355async getInputValue(selector: string): Promise<string> {356return await this.page.inputValue(selector);357}358359async startTracing(name?: string): Promise<void> {360if (!this.options.tracing) {361return; // tracing disabled362}363364try {365await measureAndLog(() => this.context.tracing.startChunk({ title: name }), `startTracing${name ? ` for ${name}` : ''}`, this.options.logger);366} catch (error) {367// Ignore368}369}370371async stopTracing(name?: string, persist: boolean = false): Promise<void> {372if (!this.options.tracing) {373return; // tracing disabled374}375376try {377let persistPath: string | undefined = undefined;378if (persist) {379const nameSuffix = name ? `-${name.replace(/\s+/g, '-')}` : '';380persistPath = join(this.options.logsPath, `playwright-trace-${PlaywrightDriver.traceCounter++}${nameSuffix}.zip`);381}382383await measureAndLog(() => this.context.tracing.stopChunk({ path: persistPath }), `stopTracing${name ? ` for ${name}` : ''}`, this.options.logger);384385// To ensure we have a screenshot at the end where386// it failed, also trigger one explicitly. Tracing387// does not guarantee to give us a screenshot unless388// some driver action ran before.389if (persist) {390await this.takeScreenshot(name);391}392} catch (error) {393// Ignore394}395}396397async didFinishLoad(): Promise<void> {398await this.whenLoaded;399}400401private _cdpSession: playwright.CDPSession | undefined;402403async startCDP() {404if (this._cdpSession) {405return;406}407408this._cdpSession = await this.page.context().newCDPSession(this.page);409}410411async collectGarbage() {412if (!this._cdpSession) {413throw new Error('CDP not started');414}415416await this._cdpSession.send('HeapProfiler.collectGarbage');417}418419async evaluate(options: Protocol.Runtime.evaluateParameters): Promise<Protocol.Runtime.evaluateReturnValue> {420if (!this._cdpSession) {421throw new Error('CDP not started');422}423424return await this._cdpSession.send('Runtime.evaluate', options);425}426427async releaseObjectGroup(parameters: Protocol.Runtime.releaseObjectGroupParameters): Promise<void> {428if (!this._cdpSession) {429throw new Error('CDP not started');430}431432await this._cdpSession.send('Runtime.releaseObjectGroup', parameters);433}434435async queryObjects(parameters: Protocol.Runtime.queryObjectsParameters): Promise<Protocol.Runtime.queryObjectsReturnValue> {436if (!this._cdpSession) {437throw new Error('CDP not started');438}439440return await this._cdpSession.send('Runtime.queryObjects', parameters);441}442443async callFunctionOn(parameters: Protocol.Runtime.callFunctionOnParameters): Promise<Protocol.Runtime.callFunctionOnReturnValue> {444if (!this._cdpSession) {445throw new Error('CDP not started');446}447448return await this._cdpSession.send('Runtime.callFunctionOn', parameters);449}450451async takeHeapSnapshot(): Promise<string> {452if (!this._cdpSession) {453throw new Error('CDP not started');454}455456let snapshot = '';457const listener = (c: { chunk: string }) => {458snapshot += c.chunk;459};460461this._cdpSession.addListener('HeapProfiler.addHeapSnapshotChunk', listener);462463await this._cdpSession.send('HeapProfiler.takeHeapSnapshot');464465this._cdpSession.removeListener('HeapProfiler.addHeapSnapshotChunk', listener);466return snapshot;467}468469async getProperties(parameters: Protocol.Runtime.getPropertiesParameters): Promise<Protocol.Runtime.getPropertiesReturnValue> {470if (!this._cdpSession) {471throw new Error('CDP not started');472}473474return await this._cdpSession.send('Runtime.getProperties', parameters);475}476477private async takeScreenshot(name?: string): Promise<void> {478try {479const nameSuffix = name ? `-${name.replace(/\s+/g, '-')}` : '';480const persistPath = join(this.options.logsPath, `playwright-screenshot-${PlaywrightDriver.screenShotCounter++}${nameSuffix}.png`);481482await measureAndLog(() => this.page.screenshot({ path: persistPath, type: 'png' }), 'takeScreenshot', this.options.logger);483} catch (error) {484// Ignore485}486}487488async reload() {489await this.page.reload();490}491492async close() {493494// Stop tracing495try {496if (this.options.tracing) {497await measureAndLog(() => this.context.tracing.stop(), 'stop tracing', this.options.logger);498}499} catch (error) {500// Ignore501}502503// Web: Extract client logs504if (this.options.web) {505try {506await measureAndLog(() => this.saveWebClientLogs(), 'saveWebClientLogs()', this.options.logger);507} catch (error) {508this.options.logger.log(`Error saving web client logs (${error})`);509}510}511512// exit via `close` method513try {514await measureAndLog(() => this.application.close(), 'playwright.close()', this.options.logger);515} catch (error) {516this.options.logger.log(`Error closing application (${error})`);517}518519// Server: via `teardown`520if (this.serverProcess) {521await measureAndLog(() => teardown(this.serverProcess!, this.options.logger), 'teardown server process', this.options.logger);522}523}524525private async saveWebClientLogs(): Promise<void> {526const logs = await this.getLogs();527528for (const log of logs) {529const absoluteLogsPath = join(this.options.logsPath, log.relativePath);530531await promises.mkdir(dirname(absoluteLogsPath), { recursive: true });532await promises.writeFile(absoluteLogsPath, log.contents);533}534}535536async sendKeybinding(keybinding: string, accept?: () => Promise<void> | void) {537const chords = keybinding.split(' ');538for (let i = 0; i < chords.length; i++) {539const chord = chords[i];540if (i > 0) {541await this.wait(100);542}543544if (keybinding.startsWith('Alt') || keybinding.startsWith('Control') || keybinding.startsWith('Backspace')) {545await this.page.keyboard.press(keybinding);546return;547}548549const keys = chord.split('+');550const keysDown: string[] = [];551for (let i = 0; i < keys.length; i++) {552if (keys[i] in PlaywrightDriver.vscodeToPlaywrightKey) {553keys[i] = PlaywrightDriver.vscodeToPlaywrightKey[keys[i]];554}555await this.page.keyboard.down(keys[i]);556keysDown.push(keys[i]);557}558while (keysDown.length > 0) {559await this.page.keyboard.up(keysDown.pop()!);560}561}562563await accept?.();564}565566async click(selector: string, xoffset?: number | undefined, yoffset?: number | undefined) {567const { x, y } = await this.getElementXY(selector, xoffset, yoffset);568await this.page.mouse.click(x + (xoffset ? xoffset : 0), y + (yoffset ? yoffset : 0));569}570571async setValue(selector: string, text: string) {572return this.page.evaluate(([driver, selector, text]) => driver.setValue(selector, text), [await this.getDriverHandle(), selector, text] as const);573}574575async getTitle() {576return this.page.title();577}578579async isActiveElement(selector: string) {580return this.page.evaluate(([driver, selector]) => driver.isActiveElement(selector), [await this.getDriverHandle(), selector] as const);581}582583async getElements(selector: string, recursive: boolean = false) {584return this.page.evaluate(([driver, selector, recursive]) => driver.getElements(selector, recursive), [await this.getDriverHandle(), selector, recursive] as const);585}586587async getElementXY(selector: string, xoffset?: number, yoffset?: number) {588return this.page.evaluate(([driver, selector, xoffset, yoffset]) => driver.getElementXY(selector, xoffset, yoffset), [await this.getDriverHandle(), selector, xoffset, yoffset] as const);589}590591async typeInEditor(selector: string, text: string) {592return this.page.evaluate(([driver, selector, text]) => driver.typeInEditor(selector, text), [await this.getDriverHandle(), selector, text] as const);593}594595async getEditorSelection(selector: string) {596return this.page.evaluate(([driver, selector]) => driver.getEditorSelection(selector), [await this.getDriverHandle(), selector] as const);597}598599async getTerminalBuffer(selector: string) {600return this.page.evaluate(([driver, selector]) => driver.getTerminalBuffer(selector), [await this.getDriverHandle(), selector] as const);601}602603async writeInTerminal(selector: string, text: string) {604return this.page.evaluate(([driver, selector, text]) => driver.writeInTerminal(selector, text), [await this.getDriverHandle(), selector, text] as const);605}606607async getLocaleInfo() {608return this.evaluateWithDriver(([driver]) => driver.getLocaleInfo());609}610611async getLocalizedStrings() {612return this.evaluateWithDriver(([driver]) => driver.getLocalizedStrings());613}614615async getLogs() {616return this.page.evaluate(([driver]) => driver.getLogs(), [await this.getDriverHandle()] as const);617}618619private async evaluateWithDriver<T>(pageFunction: PageFunction<IWindowDriver[], T>) {620return this.page.evaluate(pageFunction, [await this.getDriverHandle()]);621}622623wait(ms: number): Promise<void> {624return wait(ms);625}626627whenWorkbenchRestored(): Promise<void> {628return this.evaluateWithDriver(([driver]) => driver.whenWorkbenchRestored());629}630631private async getDriverHandle(): Promise<playwright.JSHandle<IWindowDriver>> {632return this.page.evaluateHandle('window.driver');633}634635async isAlive(): Promise<boolean> {636try {637await this.getDriverHandle();638return true;639} catch (error) {640return false;641}642}643644/**645* Run an accessibility scan on the current page using axe-core.646* Uses direct script injection to work with Electron.647* @param options Configuration options for the accessibility scan.648* @returns The axe-core scan results including any violations found.649*/650async runAccessibilityScan(options?: AccessibilityScanOptions): Promise<AxeResults> {651// Inject axe-core into the page if not already present652await this.page.evaluate(axeSource);653654// Build axe-core run options655const runOptions: RunOptions = {656runOnly: {657type: 'tag',658values: options?.tags ?? ['wcag2a', 'wcag2aa', 'wcag21aa']659}660};661662// Disable specific rules if requested663if (options?.disableRules && options.disableRules.length > 0) {664runOptions.rules = {};665for (const ruleId of options.disableRules) {666runOptions.rules[ruleId] = { enabled: false };667}668}669670// Build context for axe.run671const context: { include?: string[]; exclude?: string[][] } = {};672673if (options?.selector) {674context.include = [options.selector];675}676677// Exclude known problematic areas678context.exclude = [679['.monaco-editor .view-lines'],680['.xterm-screen canvas']681];682683// Run axe-core analysis684const results = await measureAndLog(685() => this.page.evaluate(686([ctx, opts]) => {687// @ts-expect-error axe is injected globally688return window.axe.run(ctx, opts);689},690[context, runOptions] as const691),692'runAccessibilityScan',693this.options.logger694);695696return results as AxeResults;697}698699/**700* Run an accessibility scan and throw an error if any violations are found.701* @param options Configuration options for the accessibility scan.702* @throws Error if accessibility violations are detected.703*/704async assertNoAccessibilityViolations(options?: AccessibilityScanOptions): Promise<void> {705const results = await this.runAccessibilityScan(options);706707// Filter out violations for specific elements based on excludeRules708let filteredViolations = results.violations;709if (options?.excludeRules) {710filteredViolations = results.violations.map((violation: AxeResults['violations'][number]) => {711const excludePatterns = options.excludeRules![violation.id];712if (!excludePatterns) {713return violation;714}715// Filter out nodes that match any of the exclude patterns716const filteredNodes = violation.nodes.filter((node: AxeResults['violations'][number]['nodes'][number]) => {717const target = node.target.join(' ');718const html = node.html || '';719// Check if any exclude pattern appears in target or HTML720return !excludePatterns.some(pattern => target.includes(pattern) || html.includes(pattern));721});722return { ...violation, nodes: filteredNodes };723}).filter((violation: AxeResults['violations'][number]) => violation.nodes.length > 0);724}725726if (filteredViolations.length > 0) {727const violationMessages = filteredViolations.map((violation: AxeResults['violations'][number]) => {728const nodes = violation.nodes.map((node: AxeResults['violations'][number]['nodes'][number]) => {729const target = node.target.join(' > ');730const html = node.html || 'N/A';731// Extract class from HTML for easier identification732const classMatch = html.match(/class="([^"]+)"/);733const className = classMatch ? classMatch[1] : 'no class';734return [735` Element: ${target}`,736` Class: ${className}`,737` HTML: ${html}`,738` Issue: ${node.failureSummary}`739].join('\n');740}).join('\n\n');741return [742`[${violation.id}] ${violation.help} (${violation.impact})`,743` Help URL: ${violation.helpUrl}`,744nodes745].join('\n');746}).join('\n\n---\n\n');747748throw new Error(749`Accessibility violations found:\n\n${violationMessages}\n\n` +750`Total: ${filteredViolations.length} violation(s) affecting ${filteredViolations.reduce((sum: number, v: AxeResults['violations'][number]) => sum + v.nodes.length, 0)} element(s)`751);752}753}754}755756export function wait(ms: number): Promise<void> {757return new Promise<void>(resolve => setTimeout(resolve, ms));758}759760export type { AxeResults };761762763