diff --git a/.changeset/pr-210.md b/.changeset/pr-210.md
new file mode 100644
index 00000000..17993b5a
--- /dev/null
+++ b/.changeset/pr-210.md
@@ -0,0 +1,8 @@
+---
+"@wdio/browserstack-service": patch
+---
+
+- Fixed Percy capture on WebdriverIO. Runs with `percy: true` logged "Unsupported driver for percy" and produced no screenshots, while the tests themselves continued to pass.
+- Fixed Percy web snapshots, which previously captured nothing on WebdriverIO.
+- Percy errors are now logged instead of failing the test. Set `PERCY_RAISE_ERROR=true` to fail the build on Percy errors instead.
+- Added Percy documentation to the README, including how to use a Percy web project alongside this service.
diff --git a/package-lock.json b/package-lock.json
index ad1443cc..18f0a289 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1763,18 +1763,42 @@
}
},
"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",
+ "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.31.10",
+ "@percy/sdk-utils": "1.32.3-beta.1",
"node-request-interceptor": "^0.6.3"
},
"engines": {
"node": ">=14"
}
},
+ "node_modules/@percy/selenium-webdriver/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"
+ }
+ },
+ "node_modules/@percy/webdriverio": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/@percy/webdriverio/-/webdriverio-3.3.2.tgz",
+ "integrity": "sha512-YpnrUv6tRvITqSo3fNz2jLh1qy1Rmd5QfblOPsthbXNPQ1quMScuZwM8HGPTGiiqouZXByBzkUCIss/mGgJlkQ==",
+ "dependencies": {
+ "@percy/sdk-utils": "^1.30.3"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "webdriverio": "~6 || ~7 || ~8 || ~ 9"
+ }
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -12895,7 +12919,7 @@
},
"packages/browserstack-service": {
"name": "@wdio/browserstack-service",
- "version": "9.29.1",
+ "version": "9.36.2",
"license": "MIT",
"dependencies": {
"@browserstack/ai-sdk-node": "1.5.17",
@@ -12903,6 +12927,7 @@
"@grpc/grpc-js": "~1.13.5",
"@percy/appium-app": "^2.0.9",
"@percy/selenium-webdriver": "^2.2.2",
+ "@percy/webdriverio": ">=3.3.0 <3.3.3",
"@types/gitconfiglocal": "^2.0.1",
"@wdio/logger": "^9.0.0",
"@wdio/reporter": "^9.0.0",
diff --git a/packages/browserstack-service/README.md b/packages/browserstack-service/README.md
index 17f32161..1c0f8d5e 100644
--- a/packages/browserstack-service/README.md
+++ b/packages/browserstack-service/README.md
@@ -224,6 +224,40 @@ 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.
+
+> Pin `@percy/webdriverio` below `3.3.3` — that release omits a file its entry point requires and fails to load.
+
+### 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 8689a732..4b06d97c 100644
--- a/packages/browserstack-service/package.json
+++ b/packages/browserstack-service/package.json
@@ -58,7 +58,11 @@
"@grpc/grpc-js": "~1.13.5",
"@percy/appium-app": "^2.0.9",
"@percy/selenium-webdriver": "^2.2.2",
+ "@percy/webdriverio": ">=3.3.0 <3.3.3",
"@types/gitconfiglocal": "^2.0.1",
+ "@wdio/logger": "^9.0.0",
+ "@wdio/reporter": "^9.0.0",
+ "@wdio/types": "^9.0.0",
"browserstack-local": "^1.5.1",
"chalk": "^5.3.0",
"csv-writer": "^1.6.0",
@@ -68,12 +72,9 @@
"tar": "^7.5.11",
"undici": "^6.24.0",
"uuid": "^11.1.0",
+ "webdriverio": "^9.0.0",
"winston-transport": "^4.5.0",
- "yauzl": "^3.4.0",
- "@wdio/logger": "^9.0.0",
- "@wdio/reporter": "^9.0.0",
- "@wdio/types": "^9.0.0",
- "webdriverio": "^9.0.0"
+ "yauzl": "^3.4.0"
},
"peerDependencies": {
"@wdio/cli": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0"
diff --git a/packages/browserstack-service/src/Percy/PercySDK.ts b/packages/browserstack-service/src/Percy/PercySDK.ts
index 9f91013c..da5b51ed 100644
--- a/packages/browserstack-service/src/Percy/PercySDK.ts
+++ b/packages/browserstack-service/src/Percy/PercySDK.ts
@@ -13,21 +13,56 @@ const tryRequire = function (pkg: string, fallback: unknown) {
return (mod as { default: unknown }).default
}
return mod
- } catch {
+ } catch (err) {
+ PercyLogger.debug(`Percy: could not load ${pkg} - ${(err as Error)?.message}`)
return fallback
}
}
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 = 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: unknown[]) => {
+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]: unknown }) => {
+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)) {
@@ -38,7 +73,7 @@ if (percySnapshot) {
...options,
testCase: name || ''
}
- return percySnapshot(browser, snapshotName, options)
+ return await runPercy(`snapshot "${snapshotName}"`, () => webSnapshot(browser, snapshotName, options))
}
}
}
@@ -75,23 +110,25 @@ const screenshotHelper = (type: string, driverOrName: WebdriverIO.Browser | Webd
}
/* eslint-disable @typescript-eslint/no-unused-vars */
-let screenshotHandler = async (...args: unknown[]) => {
+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]: unknown }, options?: { [key: string]: unknown }) => {
- 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: unknown[]) => {
+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]: unknown }, options?: { [key: string]: unknown }) => {
- 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
\ No newline at end of file