From d861a33b948e4dc88bcd611cafebc3b4fd044d64 Mon Sep 17 00:00:00 2001 From: yashwanth195 <59129034+yashwanth195@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:08:48 -0800 Subject: [PATCH] heft-playwright-plugin --- heft-plugins/heft-playwright-plugin/README.md | 12 + .../config/api-extractor.json | 16 + .../heft-playwright-plugin/config/rig.json | 4 + .../heft-playwright-plugin/heft-plugin.json | 137 +++++++ .../heft-playwright-plugin/package.json | 53 +++ .../src/RunPlaywrightPlugin.ts | 352 ++++++++++++++++++ .../heft-playwright-plugin/src/index.ts | 5 + .../src/schemas/playwright.schema.json | 13 + .../heft-playwright-plugin/tsconfig.json | 8 + 9 files changed, 600 insertions(+) create mode 100644 heft-plugins/heft-playwright-plugin/README.md create mode 100644 heft-plugins/heft-playwright-plugin/config/api-extractor.json create mode 100644 heft-plugins/heft-playwright-plugin/config/rig.json create mode 100644 heft-plugins/heft-playwright-plugin/heft-plugin.json create mode 100644 heft-plugins/heft-playwright-plugin/package.json create mode 100644 heft-plugins/heft-playwright-plugin/src/RunPlaywrightPlugin.ts create mode 100644 heft-plugins/heft-playwright-plugin/src/index.ts create mode 100644 heft-plugins/heft-playwright-plugin/src/schemas/playwright.schema.json create mode 100644 heft-plugins/heft-playwright-plugin/tsconfig.json diff --git a/heft-plugins/heft-playwright-plugin/README.md b/heft-plugins/heft-playwright-plugin/README.md new file mode 100644 index 00000000000..b35db41e3ac --- /dev/null +++ b/heft-plugins/heft-playwright-plugin/README.md @@ -0,0 +1,12 @@ +# @rushstack/heft-playwright-plugin + +This is a Heft plugin for running Playwright browser tests. + +## Links + +- [CHANGELOG.md]( + https://github.com/microsoft/rushstack/blob/main/heft-plugins/heft-playwright-plugin/CHANGELOG.md) - Find + out what's new in the latest version +- [@rushstack/heft](https://www.npmjs.com/package/@rushstack/heft) - Heft is a config-driven toolchain that invokes popular tools such as TypeScript, ESLint, Jest, Webpack, and API Extractor. + +Heft is part of the [Rush Stack](https://rushstack.io/) family of projects. diff --git a/heft-plugins/heft-playwright-plugin/config/api-extractor.json b/heft-plugins/heft-playwright-plugin/config/api-extractor.json new file mode 100644 index 00000000000..05559c8de5e --- /dev/null +++ b/heft-plugins/heft-playwright-plugin/config/api-extractor.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + "mainEntryPointFilePath": "/lib-dts/index.d.ts", + "apiReport": { + "enabled": true, + "reportFolder": "../../../common/reviews/api" + }, + "docModel": { + "enabled": false + }, + "dtsRollup": { + "enabled": true, + "betaTrimmedFilePath": "/dist/.d.ts" + } +} diff --git a/heft-plugins/heft-playwright-plugin/config/rig.json b/heft-plugins/heft-playwright-plugin/config/rig.json new file mode 100644 index 00000000000..2d0afa0e6c9 --- /dev/null +++ b/heft-plugins/heft-playwright-plugin/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@rushstack/heft-node-rig" +} diff --git a/heft-plugins/heft-playwright-plugin/heft-plugin.json b/heft-plugins/heft-playwright-plugin/heft-plugin.json new file mode 100644 index 00000000000..4f5add881fe --- /dev/null +++ b/heft-plugins/heft-playwright-plugin/heft-plugin.json @@ -0,0 +1,137 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft-plugin.schema.json", + + "taskPlugins": [ + { + "pluginName": "run-playwright-plugin", + "entryPoint": "./lib/RunPlaywrightPlugin", + "optionsSchema": "./lib/schemas/playwright.schema.json", + "parameterScope": "playwright", + "parameters": [ + { + "longName": "--debug-mode", + "parameterKind": "flag", + "description": "Run Playwright in debug mode with a single worker and headed browser." + }, + { + "longName": "--headed", + "parameterKind": "flag", + "description": "Run tests in headed mode (show browser UI)." + }, + { + "longName": "--ui", + "parameterKind": "flag", + "description": "Run tests in UI mode." + }, + { + "longName": "--project", + "parameterKind": "string", + "argumentName": "PROJECT", + "description": "Run tests only in the specified project (e.g., chromium, firefox, webkit)." + }, + { + "longName": "--config", + "parameterKind": "string", + "argumentName": "PATH", + "description": "Path to Playwright configuration file." + }, + { + "longName": "--grep", + "parameterKind": "string", + "argumentName": "PATTERN", + "description": "Run only tests matching the specified pattern." + }, + { + "longName": "--workers", + "parameterKind": "string", + "argumentName": "NUMBER", + "description": "Number of parallel workers to use for running tests." + }, + { + "longName": "--list", + "parameterKind": "flag", + "description": "List all tests without running them." + }, + { + "longName": "--trace", + "parameterKind": "flag", + "description": "Record execution trace for each test." + }, + { + "longName": "--grep-invert", + "parameterKind": "string", + "argumentName": "PATTERN", + "description": "Run only tests NOT matching the specified pattern." + }, + { + "longName": "--repeat-each", + "parameterKind": "integer", + "argumentName": "COUNT", + "description": "Run each test N times." + }, + { + "longName": "--retries", + "parameterKind": "integer", + "argumentName": "COUNT", + "description": "Maximum number of retry attempts per test." + }, + { + "longName": "--timeout", + "parameterKind": "integer", + "argumentName": "MILLISECONDS", + "description": "Timeout for each test in milliseconds." + }, + { + "longName": "--ui-host", + "parameterKind": "string", + "argumentName": "HOST", + "description": "Host to serve UI mode on (default: localhost)." + }, + { + "longName": "--ui-port", + "parameterKind": "string", + "argumentName": "PORT", + "description": "Port to serve UI mode on (default: auto)." + }, + { + "longName": "--test-path", + "parameterKind": "stringList", + "argumentName": "PATH", + "description": "Specify test file paths to run." + }, + { + "longName": "--install-browser", + "parameterKind": "choiceList", + "description": "Install the specified browser(s), but do not run the tests. The browser will be forcibly installed with dependencies.", + "alternatives": [ + { + "name": "chromium", + "description": "The Chromium browser" + }, + { + "name": "firefox", + "description": "The Mozilla Firefox browser" + }, + { + "name": "webkit", + "description": "The WebKit browser" + }, + { + "name": "chrome", + "description": "The Google Chrome browser" + }, + { + "name": "msedge", + "description": "The Microsoft Edge browser" + } + ] + }, + { + "parameterKind": "flag", + "longName": "--install-browsers", + "description": "Install all Playwright browsers (chromium, firefox, webkit, chrome, msedge), but do not run the tests." + } + ] + } + ] +} diff --git a/heft-plugins/heft-playwright-plugin/package.json b/heft-plugins/heft-playwright-plugin/package.json new file mode 100644 index 00000000000..b1201e655ad --- /dev/null +++ b/heft-plugins/heft-playwright-plugin/package.json @@ -0,0 +1,53 @@ +{ + "name": "@rushstack/heft-playwright-plugin", + "version": "0.0.0", + "description": "Heft plugin for running Playwright browser tests", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/rushstack.git", + "directory": "packages/heft-playwright-plugin" + }, + "homepage": "https://rushstack.io/pages/heft/overview/", + "main": "lib/index.js", + "types": "dist/heft-playwright-plugin.d.ts", + "license": "MIT", + "scripts": { + "build": "heft build --clean", + "install-browsers": "node ./node_modules/playwright-core/cli.js install chromium firefox webkit chrome msedge --with-deps --force", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "exports": { + ".": { + "import": "./lib/index.js", + "types": "./dist/heft-playwright-plugin.d.ts" + }, + "./lib/*": { + "import": "./lib/*.js", + "types": "./lib-dts/*.d.ts" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "lib/*": [ + "lib-dts/*" + ] + } + }, + "peerDependencies": { + "@rushstack/heft": "^0.75.0" + }, + "dependencies": { + "@rushstack/node-core-library": "workspace:*", + "@rushstack/terminal": "workspace:*", + "@playwright/test": "~1.56.1", + "playwright-core": "~1.56.1" + }, + "devDependencies": { + "@rushstack/heft-node-rig": "workspace:*", + "@rushstack/heft": "workspace:*", + "@types/node": "20.17.19", + "typescript": "~5.8.2" + } +} diff --git a/heft-plugins/heft-playwright-plugin/src/RunPlaywrightPlugin.ts b/heft-plugins/heft-playwright-plugin/src/RunPlaywrightPlugin.ts new file mode 100644 index 00000000000..945f7970b4b --- /dev/null +++ b/heft-plugins/heft-playwright-plugin/src/RunPlaywrightPlugin.ts @@ -0,0 +1,352 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import child_process from 'node:child_process'; +import path from 'node:path'; +import { FileSystem, JsonFile, SubprocessTerminator } from '@rushstack/node-core-library'; +import { TerminalProviderSeverity, TerminalStreamWritable, type ITerminal } from '@rushstack/terminal'; +import type { + HeftConfiguration, + IHeftTaskPlugin, + IHeftTaskRunHookOptions, + IHeftTaskSession +} from '@rushstack/heft'; + +/** @beta */ +export interface IRunPlaywrightPluginOptions { + configPath?: string; +} + +/** @beta */ +export interface IPlaywrightParameters { + debugMode: boolean; + headed: boolean; + list: boolean; + trace: boolean; + configPath: string; + project: string | undefined; + testPaths: string[]; + grep: string | undefined; + grepInvert: string | undefined; + repeatEach: number | undefined; + retries: number | undefined; + timeout: number | undefined; + workers: string | number | undefined; + ui: boolean; + uiHost: string | undefined; + uiPort: string | undefined; +} + +const PLUGIN_NAME: 'run-playwright-plugin' = 'run-playwright-plugin'; +const DEFAULT_PLAYWRIGHT_BROWSERS: string[] = ['chromium', 'firefox', 'webkit', 'chrome', 'msedge']; + +/** + * Get test file paths by resolving them relative to the build folder + */ +function getTestFilePaths(buildFolderPath: string, testPaths: readonly string[]): string[] { + return testPaths.map((testPath: string) => path.resolve(buildFolderPath, testPath)); +} + +/** + * Create Playwright CLI arguments from parameters + */ +function createPlaywriteCliArgs(parameters: IPlaywrightParameters): string[] { + const { + configPath, + workers, + debugMode, + headed, + timeout, + list, + project, + testPaths, + grep, + grepInvert, + repeatEach, + retries, + ui, + uiHost, + uiPort + } = parameters; + const cliArgs: string[] = []; + + // Always set the config path and the workers directly + cliArgs.push(`--config=${configPath}`); + + if (workers) { + cliArgs.push(`--workers=${workers}`); + } + + if (debugMode) { + cliArgs.push('--debug'); + } + if (headed) { + cliArgs.push('--headed'); + } + if (timeout) { + cliArgs.push(`--timeout=${timeout}`); + } + if (list) { + cliArgs.push('--list'); + } + if (project) { + cliArgs.push(`--project=${project}`); + } + if (grep) { + cliArgs.push(`--grep=${grep}`); + } + if (grepInvert) { + cliArgs.push(`--grep-invert=${grepInvert}`); + } + if (repeatEach) { + cliArgs.push(`--repeat-each=${repeatEach}`); + } + if (retries) { + cliArgs.push(`--retries=${retries}`); + } + if (ui) { + cliArgs.push('--ui'); + } + if (uiHost) { + cliArgs.push(`--ui-host=${uiHost}`); + } + if (uiPort) { + cliArgs.push(`--ui-port=${uiPort}`); + } + + // Do this after specifying all the other arguments + if (testPaths.length) { + cliArgs.push('--'); + for (const testPath of testPaths) { + cliArgs.push(testPath); + } + } + + return cliArgs; +} + +/** + * Install Playwright browsers using the Playwright CLI + */ +async function installPlaywrightBrowsersAsync( + terminal: ITerminal, + browsers: readonly string[] +): Promise { + // Resolve to playwright-core/cli.js for browser installation + const playwrightCliPath: string = `${path.dirname(require.resolve('playwright-core'))}/cli.js`; + const installArgs: string[] = [...browsers, '--with-deps', '--force']; + + await new Promise((resolve: () => void, reject: (error: Error) => void) => { + const forkedProcess: child_process.ChildProcess = child_process.fork( + playwrightCliPath, + ['install'].concat(installArgs), + { + execArgv: process.execArgv, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + ...SubprocessTerminator.RECOMMENDED_OPTIONS + } + ); + + SubprocessTerminator.killProcessTreeOnExit(forkedProcess, SubprocessTerminator.RECOMMENDED_OPTIONS); + + // Pipe stdout and stderr to terminal + const terminalOutStream: TerminalStreamWritable = new TerminalStreamWritable({ + terminal, + severity: TerminalProviderSeverity.log + }); + const terminalErrorStream: TerminalStreamWritable = new TerminalStreamWritable({ + terminal, + severity: TerminalProviderSeverity.error + }); + + if (forkedProcess.stdout) { + forkedProcess.stdout.pipe(terminalOutStream); + } + if (forkedProcess.stderr) { + forkedProcess.stderr.pipe(terminalErrorStream); + } + + let processFinished: boolean = false; + forkedProcess.on('error', (error: Error) => { + processFinished = true; + reject(error); + }); + + forkedProcess.on('exit', (code: number | null) => { + if (processFinished) { + return; + } + processFinished = true; + + if (code !== 0) { + reject(new Error(`Browser installation failed with exit code ${code}`)); + } else { + resolve(); + } + }); + }); +} + +/** + * Run Playwright tests + */ +async function runPlaywrightAsync(options: { + terminal: ITerminal; + buildFolderPath: string; + parameters: IPlaywrightParameters; + abortSignal: AbortSignal; +}): Promise { + const { terminal, buildFolderPath, parameters, abortSignal } = options; + + // Check if config exists + if (!(await FileSystem.existsAsync(parameters.configPath))) { + throw new Error( + `Playwright configuration file not found: ${parameters.configPath}\n` + + `Create a config/playwright.config.js file or specify --config parameter.` + ); + } + + // Build Playwright CLI arguments using helper function + const args: string[] = createPlaywriteCliArgs(parameters); + + // Resolve the Playwright CLI path from the consumer's dependencies + let playwrightTestCliPath: string; + try { + playwrightTestCliPath = require.resolve('@playwright/test/cli'); + } catch (error) { + throw new Error( + `Cannot find @playwright/test. Make sure it is installed in your project.\n` + + `Run: npm install --save-dev @playwright/test` + ); + } + + await new Promise((resolve, reject) => { + const forkedProcess = child_process.fork(playwrightTestCliPath, ['test', ...args], { + execArgv: process.execArgv, + cwd: buildFolderPath, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env: process.env, + ...SubprocessTerminator.RECOMMENDED_OPTIONS + }); + + SubprocessTerminator.killProcessTreeOnExit(forkedProcess, SubprocessTerminator.RECOMMENDED_OPTIONS); + + // Pipe stdout/stderr through Terminal streams + const terminalOutStream = new TerminalStreamWritable({ + terminal, + severity: TerminalProviderSeverity.log + }); + const terminalErrorStream = new TerminalStreamWritable({ + terminal, + severity: TerminalProviderSeverity.error + }); + + if (forkedProcess.stdout) { + forkedProcess.stdout.pipe(terminalOutStream); + } + if (forkedProcess.stderr) { + forkedProcess.stderr.pipe(terminalErrorStream); + } + + // Handle abort signal + const abortHandler = (): void => { + forkedProcess.kill('SIGTERM'); + }; + + if (abortSignal.aborted) { + forkedProcess.kill('SIGTERM'); + reject(new Error('Operation aborted')); + return; + } + + abortSignal.addEventListener('abort', abortHandler); + + let processFinished = false; + + forkedProcess.on('error', (error) => { + processFinished = true; + abortSignal.removeEventListener('abort', abortHandler); + reject(new Error(`Playwright returned error: ${error.message}`)); + }); + + forkedProcess.on('exit', (code, signal) => { + if (processFinished) { + return; + } + processFinished = true; + abortSignal.removeEventListener('abort', abortHandler); + + if (signal === 'SIGTERM' && abortSignal.aborted) { + reject(new Error('Operation aborted')); + } else if (code === 0) { + terminal.writeLine('Playwright tests completed successfully.'); + resolve(); + } else { + reject(new Error(`Playwright test process exited with code ${code}`)); + } + }); + }); +} + +/** + * Heft plugin for running Playwright browser tests + * + * This plugin executes Playwright tests using the \@playwright/test CLI. + * It supports common Playwright options like debug mode, headed mode, UI mode, + * and project selection. + * + * @beta + */ +export default class RunPlaywrightPlugin implements IHeftTaskPlugin { + public apply(taskSession: IHeftTaskSession, heftConfiguration: HeftConfiguration): void { + const terminal: ITerminal = taskSession.logger.terminal; + const { buildFolderPath } = heftConfiguration; + const parameters = taskSession.parameters; + + taskSession.hooks.run.tapPromise(PLUGIN_NAME, async (runOptions: IHeftTaskRunHookOptions) => { + const installBrowsers: readonly string[] = parameters.getFlagParameter('--install-browsers').value + ? DEFAULT_PLAYWRIGHT_BROWSERS + : parameters.getChoiceListParameter('--install-browser').values; + + if (installBrowsers.length) { + await installPlaywrightBrowsersAsync(terminal, installBrowsers); + terminal.writeLine(`Installed Playwright browsers: ${installBrowsers.join(', ')}`); + } else { + // Determine config path + const configParam = parameters.getStringParameter('--config').value; + const playwrightConfigPath = configParam + ? path.resolve(buildFolderPath, configParam) + : path.join(buildFolderPath, 'config', 'playwright.config.js'); + + const playwrightParameters: IPlaywrightParameters = { + debugMode: parameters.getFlagParameter('--debug-mode').value, + headed: parameters.getFlagParameter('--headed').value, + list: parameters.getFlagParameter('--list').value, + trace: parameters.getFlagParameter('--trace').value, + configPath: playwrightConfigPath, + timeout: parameters.getIntegerParameter('--timeout').value, + project: parameters.getStringParameter('--project').value, + testPaths: getTestFilePaths( + buildFolderPath, + parameters.getStringListParameter('--test-path').values + ), + grep: parameters.getStringParameter('--grep').value, + grepInvert: parameters.getStringParameter('--grep-invert').value, + repeatEach: parameters.getIntegerParameter('--repeat-each').value, + retries: parameters.getIntegerParameter('--retries').value, + workers: parameters.getStringParameter('--workers').value, + ui: parameters.getFlagParameter('--ui').value, + uiHost: parameters.getStringParameter('--ui-host').value, + uiPort: parameters.getStringParameter('--ui-port').value + }; + + await runPlaywrightAsync({ + terminal, + buildFolderPath, + parameters: playwrightParameters, + abortSignal: runOptions.abortSignal + }); + } + }); + } +} diff --git a/heft-plugins/heft-playwright-plugin/src/index.ts b/heft-plugins/heft-playwright-plugin/src/index.ts new file mode 100644 index 00000000000..38663e52fcd --- /dev/null +++ b/heft-plugins/heft-playwright-plugin/src/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +export { default as RunPlaywrightPlugin } from './RunPlaywrightPlugin'; +export type { IRunPlaywrightPluginOptions } from './RunPlaywrightPlugin'; diff --git a/heft-plugins/heft-playwright-plugin/src/schemas/playwright.schema.json b/heft-plugins/heft-playwright-plugin/src/schemas/playwright.schema.json new file mode 100644 index 00000000000..6a39e02d393 --- /dev/null +++ b/heft-plugins/heft-playwright-plugin/src/schemas/playwright.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Playwright Plugin Configuration", + "description": "Configuration for the Heft Playwright plugin", + "type": "object", + "properties": { + "configPath": { + "type": "string", + "description": "Path to the Playwright configuration file (relative to project root). Defaults to config/playwright.config.js" + } + }, + "additionalProperties": false +} diff --git a/heft-plugins/heft-playwright-plugin/tsconfig.json b/heft-plugins/heft-playwright-plugin/tsconfig.json new file mode 100644 index 00000000000..ae98a1e1c1f --- /dev/null +++ b/heft-plugins/heft-playwright-plugin/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./node_modules/@rushstack/heft-node-rig/profiles/default/tsconfig-base.json", + "compilerOptions": { + "types": ["node"], + "declarationDir": "lib-dts", + "outDir": "lib" + } +}