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