From 7d6fc53c6d54ec19f123afb71d1455cb01523ada Mon Sep 17 00:00:00 2001 From: Harshit Date: Mon, 21 Sep 2026 22:00:45 +0530 Subject: [PATCH 1/5] fix(percy): restore Percy capture on WebdriverIO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Percy capture has been silently disabled for any install resolving @percy/selenium-webdriver 2.2.6 or newer (published 2026-04-01). Tests still passed, so the only symptoms were a log line and missing visual coverage. 2.2.6 added a top-level require('selenium-webdriver') for a Selenium-only iframe helper, but that package is shipped as a devDependency only and is never present in a WebdriverIO project, so the module throws MODULE_NOT_FOUND on load. tryRequire swallowed the throw and left the Percy entry points as no-op stubs logging "Unsupported driver for percy" — a message about the driver for what was actually a failed module load. Our floating "^2.2.2" range is what admitted the broken version, on the v9 and v8 lines alike. - Cap @percy/selenium-webdriver below 2.2.6. - Log the underlying load failure instead of discarding it, so a future packaging break surfaces as itself. - Route all three Percy entry points through a shared helper that logs errors rather than propagating them into the user's test. Percy raises its misuse guards before its own try/catch, so capping alone would have started throwing from snapshot/screenshot calls. PERCY_RAISE_ERROR is honoured for users who want failures to fail the build. - Document Percy in the README for the first time: the percy and percyCaptureMode options, that this service runs Percy on Automate, and how to drive a Percy web project alongside it. App Automate is unaffected — it uses @percy/appium-app, a separate package. The v8 line declares the same floating range and needs the equivalent change on the v8 branch; this covers v9 only. Co-Authored-By: Claude Opus 5 --- package-lock.json | 29 +++++++------- packages/browserstack-service/README.md | 34 ++++++++++++++++ packages/browserstack-service/package.json | 2 +- .../src/Percy/PercySDK.ts | 39 ++++++++++++++----- 4 files changed, 79 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index ad1443cc..a6f1a319 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1762,19 +1762,6 @@ "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", - "dependencies": { - "@percy/sdk-utils": "^1.31.10", - "node-request-interceptor": "^0.6.3" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -12895,14 +12882,14 @@ }, "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", "@bufbuild/protobuf": "^2.5.2", "@grpc/grpc-js": "~1.13.5", "@percy/appium-app": "^2.0.9", - "@percy/selenium-webdriver": "^2.2.2", + "@percy/selenium-webdriver": ">=2.2.2 <2.2.6", "@types/gitconfiglocal": "^2.0.1", "@wdio/logger": "^9.0.0", "@wdio/reporter": "^9.0.0", @@ -13384,6 +13371,18 @@ "node": ">=18" } }, + "packages/browserstack-service/node_modules/@percy/selenium-webdriver": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@percy/selenium-webdriver/-/selenium-webdriver-2.2.5.tgz", + "integrity": "sha512-Bb8PtXwkE7Fu2oQAKBUMxejsC5+BOyo08vVM13NgdjJooNr7JeqbfZ6wbpzkG34HRjqu2C+ihXj8naYJE1OKlA==", + "dependencies": { + "@percy/sdk-utils": "^1.30.9", + "node-request-interceptor": "^0.6.3" + }, + "engines": { + "node": ">=14" + } + }, "packages/browserstack-service/node_modules/@vitest/pretty-format": { "version": "3.2.6", "dev": true, 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..c576e8f0 100644 --- a/packages/browserstack-service/package.json +++ b/packages/browserstack-service/package.json @@ -57,7 +57,7 @@ "@bufbuild/protobuf": "^2.5.2", "@grpc/grpc-js": "~1.13.5", "@percy/appium-app": "^2.0.9", - "@percy/selenium-webdriver": "^2.2.2", + "@percy/selenium-webdriver": ">=2.2.2 <2.2.6", "@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 9f91013c..b7d1cedf 100644 --- a/packages/browserstack-service/src/Percy/PercySDK.ts +++ b/packages/browserstack-service/src/Percy/PercySDK.ts @@ -13,7 +13,8 @@ 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 } } @@ -22,12 +23,30 @@ const percySnapshot = tryRequire('@percy/selenium-webdriver', null) 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[]) => { PercyLogger.error('Unsupported driver for percy') } if (percySnapshot) { - snapshotHandler = (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, snapshotName: string, options?: { [key: string]: unknown }) => { + 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 +57,7 @@ if (percySnapshot) { ...options, testCase: name || '' } - return percySnapshot(browser, snapshotName, options) + return await runPercy(`snapshot "${snapshotName}"`, () => percySnapshot(browser, snapshotName, options)) } } } @@ -75,23 +94,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 From e016e7d4136e004f482a4a3428ae70af3b2339a6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:35:47 +0000 Subject: [PATCH 2/5] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-210.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/pr-210.md diff --git a/.changeset/pr-210.md b/.changeset/pr-210.md new file mode 100644 index 00000000..be26fbac --- /dev/null +++ b/.changeset/pr-210.md @@ -0,0 +1,7 @@ +--- +"@wdio/browserstack-service": patch +--- + +- Fixed Percy screenshots not being captured on WebdriverIO. Runs with `percy: true` logged "Unsupported driver for percy" and produced no snapshots, while the tests themselves continued to pass. +- 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. From 85708af78b2d5b7f870952040c2f7b6157e268af Mon Sep 17 00:00:00 2001 From: Harshit Date: Tue, 22 Sep 2026 14:05:32 +0530 Subject: [PATCH 3/5] fix(percy): route snapshot to @percy/webdriverio; drop the version cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Percy released 2.2.8, which guards the require that broke module load, so the cap added in the previous commit is no longer needed and the range returns to ^2.2.2. The lockfile is refreshed onto 2.2.8. That leaves the second, older defect. The service routes its `snapshot` (Percy web) export to @percy/selenium-webdriver's percySnapshot, which drives the browser through Selenium client APIs — executeScript(script) with a single argument, By, switchTo(). A WebdriverIO browser provides none of those, so the call fails on the first driver interaction, the SDK swallows the failure, and zero snapshots are posted. That path has never worked on WebdriverIO at any version. @percy/webdriverio is the WebdriverIO-native port and is what `snapshot` now binds to. percyScreenshot (Percy on Automate) deliberately stays on @percy/selenium-webdriver: it is driver-agnostic — it reads session metadata and posts, with capture happening server-side — and carries an explicit wdio branch in its DriverMetadata. @percy/webdriverio is pinned below 3.3.3; that release omits a file its own entry point requires and fails to load. Verified on a live BrowserStack WebdriverIO v9 session against a web-typed Percy runtime: through the service's own export, snapshot posts one snapshot carrying a 594-byte serialized DOM, where the Selenium SDK on the identical browser posts none and logs "Wrong parameters applied for executeScript". Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 52 ++++++++++++++----- packages/browserstack-service/package.json | 13 ++--- .../src/Percy/PercySDK.ts | 19 ++++++- 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index a6f1a319..18f0a289 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1762,6 +1762,43 @@ "node": ">=14" } }, + "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" + } + }, + "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", @@ -12889,7 +12926,8 @@ "@bufbuild/protobuf": "^2.5.2", "@grpc/grpc-js": "~1.13.5", "@percy/appium-app": "^2.0.9", - "@percy/selenium-webdriver": ">=2.2.2 <2.2.6", + "@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", @@ -13371,18 +13409,6 @@ "node": ">=18" } }, - "packages/browserstack-service/node_modules/@percy/selenium-webdriver": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@percy/selenium-webdriver/-/selenium-webdriver-2.2.5.tgz", - "integrity": "sha512-Bb8PtXwkE7Fu2oQAKBUMxejsC5+BOyo08vVM13NgdjJooNr7JeqbfZ6wbpzkG34HRjqu2C+ihXj8naYJE1OKlA==", - "dependencies": { - "@percy/sdk-utils": "^1.30.9", - "node-request-interceptor": "^0.6.3" - }, - "engines": { - "node": ">=14" - } - }, "packages/browserstack-service/node_modules/@vitest/pretty-format": { "version": "3.2.6", "dev": true, diff --git a/packages/browserstack-service/package.json b/packages/browserstack-service/package.json index c576e8f0..4b06d97c 100644 --- a/packages/browserstack-service/package.json +++ b/packages/browserstack-service/package.json @@ -57,8 +57,12 @@ "@bufbuild/protobuf": "^2.5.2", "@grpc/grpc-js": "~1.13.5", "@percy/appium-app": "^2.0.9", - "@percy/selenium-webdriver": ">=2.2.2 <2.2.6", + "@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 b7d1cedf..52630328 100644 --- a/packages/browserstack-service/src/Percy/PercySDK.ts +++ b/packages/browserstack-service/src/Percy/PercySDK.ts @@ -21,6 +21,21 @@ const tryRequire = function (pkg: string, fallback: unknown) { 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', {}) /* @@ -45,7 +60,7 @@ const runPercy = async (label: string, call: () => unknown) => { let snapshotHandler = (...args: unknown[]) => { PercyLogger.error('Unsupported driver for percy') } -if (percySnapshot) { +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 @@ -57,7 +72,7 @@ if (percySnapshot) { ...options, testCase: name || '' } - return await runPercy(`snapshot "${snapshotName}"`, () => percySnapshot(browser, snapshotName, options)) + return await runPercy(`snapshot "${snapshotName}"`, () => webSnapshot(browser, snapshotName, options)) } } } From e9cfe6e41ae725a865048b38b4a1e48d7c310027 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:30:25 +0000 Subject: [PATCH 4/5] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-210.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/pr-210.md b/.changeset/pr-210.md index be26fbac..17993b5a 100644 --- a/.changeset/pr-210.md +++ b/.changeset/pr-210.md @@ -2,6 +2,7 @@ "@wdio/browserstack-service": patch --- -- Fixed Percy screenshots not being captured on WebdriverIO. Runs with `percy: true` logged "Unsupported driver for percy" and produced no snapshots, while the tests themselves continued to pass. +- 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. From 0939a85857cfd19cbc2f34378caf1fdac2354c8e Mon Sep 17 00:00:00 2001 From: Harshit Suthar Date: Tue, 22 Sep 2026 19:32:31 +0530 Subject: [PATCH 5/5] fix(percy): type the snapshot fallback stub as async The disabled-path stub stayed synchronous while its enabled branch became async, so TypeScript inferred the export from the initial assignment and published `snapshot` as `=> void` while `screenshot` and `screenshotApp` were `Promise`. Consumers awaiting it awaited a void. Co-Authored-By: Claude Opus 5 (1M context) --- packages/browserstack-service/src/Percy/PercySDK.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/browserstack-service/src/Percy/PercySDK.ts b/packages/browserstack-service/src/Percy/PercySDK.ts index 52630328..da5b51ed 100644 --- a/packages/browserstack-service/src/Percy/PercySDK.ts +++ b/packages/browserstack-service/src/Percy/PercySDK.ts @@ -57,8 +57,9 @@ const runPercy = async (label: string, call: () => unknown) => { } /* 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 (webSnapshot) { snapshotHandler = async (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, snapshotName: string, options?: { [key: string]: unknown }) => {