diff --git a/.changeset/pr-231.md b/.changeset/pr-231.md new file mode 100644 index 00000000..247f4603 --- /dev/null +++ b/.changeset/pr-231.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": patch +--- + +- Fixed Percy visual testing on WebdriverIO: screenshots (Percy on Automate) and web snapshots are captured again. No config or code changes needed. diff --git a/package-lock.json b/package-lock.json index c621b18b..383349fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1245,10 +1245,9 @@ } }, "node_modules/@percy/sdk-utils": { - "version": "1.31.14", - "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.31.14.tgz", - "integrity": "sha512-I31GM+aCHiME12jX9ac5COThZWOpTBONkR9J6D059wqiFhHtRenA2mWFx6rC+zUpG5un0imkal7tN9y1D4hPBg==", - "license": "MIT", + "version": "1.32.10", + "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.32.10.tgz", + "integrity": "sha512-AO6gLgwS3OzleOi5A3BFwQATpkR56rYGji78pXLas9dhN4WjB8C/SfWESBloLk6Yz9eHGy+24z4nhDmDJ/Q5Dw==", "dependencies": { "pac-proxy-agent": "^7.0.2" }, @@ -1256,17 +1255,18 @@ "node": ">=14" } }, - "node_modules/@percy/selenium-webdriver": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@percy/selenium-webdriver/-/selenium-webdriver-2.2.6.tgz", - "integrity": "sha512-5aeJh3ncYQl1Ug8/eazae8Ux281cvUX87e5YvHTFnLKtELaqJEb2k0NoNZbnWpPgAdz7yxeZZgTEbDn9HTOSYA==", - "license": "MIT", + "node_modules/@percy/webdriverio": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@percy/webdriverio/-/webdriverio-3.3.4.tgz", + "integrity": "sha512-SoRkeZO2cUzgvjtf5uWULqW2JXgUhZL2tEgAKqpccfu2hBYw4CerR/SqdZBtgXiP5n/5CeHv5cPbU3hDpP4jVA==", "dependencies": { - "@percy/sdk-utils": "^1.31.10", - "node-request-interceptor": "^0.6.3" + "@percy/sdk-utils": "^1.32.0" }, "engines": { "node": ">=14" + }, + "peerDependencies": { + "webdriverio": "~6 || ~7 || ~8 || ~ 9" } }, "node_modules/@pkgjs/parseargs": { @@ -8923,14 +8923,15 @@ }, "packages/browserstack-service": { "name": "@wdio/browserstack-service", - "version": "8.48.0", + "version": "8.51.1", "license": "MIT", "dependencies": { "@browserstack/ai-sdk-node": "1.5.17", "@bufbuild/protobuf": "^2.5.2", "@grpc/grpc-js": "1.13.3", "@percy/appium-app": "^2.0.1", - "@percy/selenium-webdriver": "^2.0.3", + "@percy/selenium-webdriver": "^2.2.8", + "@percy/webdriverio": "^3.3.4", "@types/gitconfiglocal": "^2.0.1", "@wdio/logger": "8.38.0", "@wdio/reporter": "8.43.0", @@ -8972,6 +8973,29 @@ "peerDependencies": { "@wdio/cli": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } + }, + "packages/browserstack-service/node_modules/@percy/sdk-utils": { + "version": "1.32.3-beta.1", + "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.32.3-beta.1.tgz", + "integrity": "sha512-/oD+82slu1tuV3nFkwrdBWUIZ3JdmPuh2ajlcWbumTxu0lu2hhZLKmCTE72Fc5N6UXoHl9tQzJPrkT0/W/aigQ==", + "dependencies": { + "pac-proxy-agent": "^7.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "packages/browserstack-service/node_modules/@percy/selenium-webdriver": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@percy/selenium-webdriver/-/selenium-webdriver-2.2.8.tgz", + "integrity": "sha512-DGi23l8EnLqSwkluyqJCUlW58EZpOL5gFMKdlRDTp+0Ukg/v7JXZ6hcaOq//12Nio62Dt0Df+tH09e0U2xU69Q==", + "dependencies": { + "@percy/sdk-utils": "1.32.3-beta.1", + "node-request-interceptor": "^0.6.3" + }, + "engines": { + "node": ">=14" + } } } } diff --git a/packages/browserstack-service/README.md b/packages/browserstack-service/README.md index 0ef7d661..19b75c89 100644 --- a/packages/browserstack-service/README.md +++ b/packages/browserstack-service/README.md @@ -249,6 +249,38 @@ Automatically set the BrowserStack Automate session status (passed/failed). Type: `Boolean`
Default: `true` +### percy + +Enable Percy visual testing. + +Type: `Boolean`
+Default: `false` — except for App Automate runs, where Percy is enabled automatically when `percy` is left unset and `app` is provided. + +This service runs Percy in **Percy on Automate** mode: it provisions the Percy project for you and captures screenshots server-side from the Automate session. Use `percyCaptureMode` to control when captures happen — no code changes are needed. + +**Percy web projects.** This service provisions a Percy on Automate project; it does not create web-type Percy projects. To use a Percy **web** project with WebdriverIO, leave `percy` unset in the service options and drive Percy yourself: + +```bash +PERCY_TOKEN= npx percy exec -- npx wdio run wdio.conf.js +``` + +calling [`@percy/webdriverio`](https://github.com/percy/percy-webdriverio)'s `percySnapshot` in your specs. BrowserStack Automate and Test Observability continue to work through this service alongside it. + +### percyCaptureMode + +When to capture Percy screenshots automatically. + +Type: `String`
+Default: `auto` + +* `auto` — capture on clicks, screenshots, actions and input changes +* `click` — capture on clicks only +* `screenshot` — capture on screenshot commands only +* `testcase` — capture once at the end of each test +* `manual` — never capture automatically + +Your Percy project's own capture-mode setting takes precedence over this option when one is configured. + ### buildIdentifier **buildIdentifier** is a unique id to differentiate every execution that gets appended to buildName. Choose your buildIdentifier format from the available expressions: diff --git a/packages/browserstack-service/package.json b/packages/browserstack-service/package.json index b8e89509..0e305e38 100644 --- a/packages/browserstack-service/package.json +++ b/packages/browserstack-service/package.json @@ -50,7 +50,8 @@ "@bufbuild/protobuf": "^2.5.2", "@grpc/grpc-js": "1.13.3", "@percy/appium-app": "^2.0.1", - "@percy/selenium-webdriver": "^2.0.3", + "@percy/selenium-webdriver": "^2.2.8", + "@percy/webdriverio": "^3.3.4", "@types/gitconfiglocal": "^2.0.1", "browserstack-local": "^1.5.1", "chalk": "^5.3.0", diff --git a/packages/browserstack-service/src/Percy/PercySDK.ts b/packages/browserstack-service/src/Percy/PercySDK.ts index 43ac6aa6..da5b51ed 100644 --- a/packages/browserstack-service/src/Percy/PercySDK.ts +++ b/packages/browserstack-service/src/Percy/PercySDK.ts @@ -2,25 +2,67 @@ import InsightsHandler from '../insights-handler.js' import TestReporter from '../reporter.js' import { PercyLogger } from './PercyLogger.js' import { isUndefined } from '../util.js' +import { createRequire } from 'node:module' -const tryRequire = async function (pkg: string, fallback: any) { +const require = createRequire(import.meta.url) + +const tryRequire = function (pkg: string, fallback: unknown) { try { - return (await import(pkg)).default - } catch { + const mod = require(pkg) + if (mod && typeof mod === 'object' && 'default' in mod) { + return (mod as { default: unknown }).default + } + return mod + } catch (err) { + PercyLogger.debug(`Percy: could not load ${pkg} - ${(err as Error)?.message}`) return fallback } } -const percySnapshot = await tryRequire('@percy/selenium-webdriver', null) +const percySnapshot = tryRequire('@percy/selenium-webdriver', null) + +/* +Percy ships two disjoint web SDKs, and the correct one depends on the driver, not the +product. percySnapshot from @percy/selenium-webdriver drives the browser through Selenium +client APIs - executeScript(script) with a single argument, By, switchTo() - none of which +a WebdriverIO browser provides, so it captures nothing and swallows the failure. The +WebdriverIO-native port lives in @percy/webdriverio and is what `snapshot` binds to. + +percyScreenshot (Percy on Automate) deliberately stays on @percy/selenium-webdriver: it is +driver-agnostic - it reads session metadata and posts, capturing server-side - and carries +an explicit wdio branch in its DriverMetadata. +*/ +const percyWebdriverioSnapshot = tryRequire('@percy/webdriverio', null) + +const webSnapshot = percyWebdriverioSnapshot || percySnapshot -const percyAppScreenshot = await tryRequire('@percy/appium-app', {}) +const percyAppScreenshot = tryRequire('@percy/appium-app', {}) + +/* +Percy's SDKs raise their misuse guards - percySnapshot against a Percy-on-Automate build, +percyScreenshot against anything else - before their own try/catch, so those rejections +reach the caller. Every PercySDK entry point is publicly exported, so an unguarded one +fails the user's test rather than their visual coverage. PERCY_RAISE_ERROR is Percy's own +opt-in for the opposite behaviour and is honoured. +*/ +const runPercy = async (label: string, call: () => unknown) => { + try { + return await call() + } catch (err) { + if (process.env.PERCY_RAISE_ERROR === 'true') { + throw err + } + PercyLogger.error(`Percy ${label} failed: ${(err as Error)?.message}`) + } +} /* eslint-disable @typescript-eslint/no-unused-vars */ -let snapshotHandler = (...args: any[]) => { +let snapshotHandler = async (...args: unknown[]): Promise => { PercyLogger.error('Unsupported driver for percy') + return undefined } -if (percySnapshot) { - snapshotHandler = (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, snapshotName: string, options?: { [key: string]: any }) => { +if (webSnapshot) { + snapshotHandler = async (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, snapshotName: string, options?: { [key: string]: unknown }) => { if (process.env.PERCY_SNAPSHOT === 'true') { let { name, uuid } = InsightsHandler.currentTest if (isUndefined(name)) { @@ -31,7 +73,7 @@ if (percySnapshot) { ...options, testCase: name || '' } - return percySnapshot(browser, snapshotName, options) + return await runPercy(`snapshot "${snapshotName}"`, () => webSnapshot(browser, snapshotName, options)) } } } @@ -41,7 +83,7 @@ export const snapshot = snapshotHandler This is a helper method which appends some internal fields to the options object being sent to Percy methods */ -const screenshotHelper = (type: string, driverOrName: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser | string, nameOrOptions?: string | { [key: string]: any }, options?: { [key: string]: any }) => { +const screenshotHelper = (type: string, driverOrName: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser | string, nameOrOptions?: string | { [key: string]: unknown }, options?: { [key: string]: unknown }) => { let { name, uuid } = InsightsHandler.currentTest if (isUndefined(name)) { ({ name, uuid } = TestReporter.currentTest) @@ -68,23 +110,25 @@ const screenshotHelper = (type: string, driverOrName: WebdriverIO.Browser | Webd } /* eslint-disable @typescript-eslint/no-unused-vars */ -let screenshotHandler = async (...args: any[]) => { +let screenshotHandler = async (...args: unknown[]): Promise => { PercyLogger.error('Unsupported driver for percy') + return undefined } if (percySnapshot && percySnapshot.percyScreenshot) { - screenshotHandler = (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser | string, screenshotName?: string | { [key: string]: any }, options?: { [key: string]: any }) => { - return screenshotHelper('web', browser, screenshotName, options) + screenshotHandler = async (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser | string, screenshotName?: string | { [key: string]: unknown }, options?: { [key: string]: unknown }) => { + return await runPercy('screenshot', () => screenshotHelper('web', browser, screenshotName, options)) } } export const screenshot = screenshotHandler /* eslint-disable @typescript-eslint/no-unused-vars */ -let screenshotAppHandler = async (...args: any[]) => { +let screenshotAppHandler = async (...args: unknown[]): Promise => { PercyLogger.error('Unsupported driver for percy') + return undefined } if (percyAppScreenshot) { - screenshotAppHandler = (driverOrName: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser | string, nameOrOptions?: string | { [key: string]: any }, options?: { [key: string]: any }) => { - return screenshotHelper('app', driverOrName, nameOrOptions, options) + screenshotAppHandler = async (driverOrName: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser | string, nameOrOptions?: string | { [key: string]: unknown }, options?: { [key: string]: unknown }) => { + return await runPercy('app screenshot', () => screenshotHelper('app', driverOrName, nameOrOptions, options)) } } -export const screenshotApp = screenshotAppHandler +export const screenshotApp = screenshotAppHandler \ No newline at end of file