From 54bf956ac86b1f18c12b4b02cd57489a25bcfca5 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Tue, 6 Oct 2026 22:05:36 +0530 Subject: [PATCH] fix(webdriver-utils): fail fast with a clear error for native app sessions on Percy on Automate (PPLT-6148) Automate capture builds the comparison tag and regions through JavaScript (window.screen size, devicePixelRatio, DOM lookups). A native App Automate session has no JS context, so every snapshot failed with an opaque "405 Method Not Allowed" on every Appium version (1.22.0, 2.19.0 and 3.5.2), which most SDKs then swallow. Detect a native session in createDriver (no browserName but an app identifier) and throw an actionable error pointing at App Percy instead. Verified live on BrowserStack with Appium 2.19.0: native Android and iOS sessions now report the new error; Android Chrome Percy on Automate still captures 3/3 snapshots. Co-Authored-By: Claude Opus 5.5 --- .../src/providers/automateProvider.js | 14 ++++++++ .../test/providers/automateProvider.test.js | 35 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/webdriver-utils/src/providers/automateProvider.js b/packages/webdriver-utils/src/providers/automateProvider.js index 10f980624..fbb8fa85b 100644 --- a/packages/webdriver-utils/src/providers/automateProvider.js +++ b/packages/webdriver-utils/src/providers/automateProvider.js @@ -13,11 +13,25 @@ export default class AutomateProvider extends GenericProvider { return commandExecutorUrl.includes(process.env.AA_DOMAIN || 'browserstack'); } + // Automate capture reads the screen size, DPR and regions through JavaScript, which a + // native app context cannot run (App Automate answers 405 Method Not Allowed). Browser + // sessions always carry a browserName; native ones carry an app identifier instead. + static isNativeAppSession(caps) { + if (!caps || caps.browserName) return false; + const desired = caps.desired || {}; + return ['app', 'appium:app', 'appPackage', 'bundleId', 'bundleID'] + .some(key => caps[key] || desired[key]); + } + async createDriver() { this.driver = new Driver(this.sessionId, this.commandExecutorUrl, this.capabilities); log.debug(`Passed capabilities -> ${JSON.stringify(this.capabilities)}`); const caps = await this.driver.getCapabilites(); log.debug(`Fetched capabilities -> ${JSON.stringify(caps)}`); + if (AutomateProvider.isNativeAppSession(caps)) { + throw new Error('Percy on Automate captures browser sessions; this is a native app session. ' + + 'Use App Percy for native apps (an app project token with `percy app:exec`).'); + } this.metaData = MetaDataResolver.resolve(this.driver, caps, this.capabilities); } diff --git a/packages/webdriver-utils/test/providers/automateProvider.test.js b/packages/webdriver-utils/test/providers/automateProvider.test.js index ba25977d3..0c0ee2020 100644 --- a/packages/webdriver-utils/test/providers/automateProvider.test.js +++ b/packages/webdriver-utils/test/providers/automateProvider.test.js @@ -70,6 +70,41 @@ describe('AutomateProvider', () => { }); }); + describe('createDriver on a native app session', () => { + const nativeError = 'Percy on Automate captures browser sessions; this is a native app session. ' + + 'Use App Percy for native apps (an app project token with `percy app:exec`).'; + + it('rejects an Android native session', async () => { + spyOn(Driver.prototype, 'getCapabilites').and.returnValue(Promise.resolve({ + platformName: 'Android', appPackage: 'org.wikipedia.alpha', automationName: 'uiautomator2' + })); + await expectAsync(new AutomateProvider(args).createDriver()).toBeRejectedWithError(nativeError); + }); + + it('rejects an iOS native session reporting an empty browserName', async () => { + spyOn(Driver.prototype, 'getCapabilites').and.returnValue(Promise.resolve({ + platformName: 'iOS', browserName: '', desired: { bundleId: 'com.example.app' } + })); + await expectAsync(new AutomateProvider(args).createDriver()).toBeRejectedWithError(nativeError); + }); + + it('accepts a mobile browser session', async () => { + spyOn(Driver.prototype, 'getCapabilites').and.returnValue(Promise.resolve({ + platformName: 'Android', browserName: 'chrome', appPackage: 'com.android.chrome' + })); + const automateProvider = new AutomateProvider(args); + await automateProvider.createDriver(); + expect(automateProvider.metaData).toBeDefined(); + }); + + it('accepts a session without browserName or an app identifier', async () => { + spyOn(Driver.prototype, 'getCapabilites').and.returnValue(Promise.resolve({ platformName: 'Android' })); + const automateProvider = new AutomateProvider(args); + await automateProvider.createDriver(); + expect(automateProvider.metaData).toBeDefined(); + }); + }); + describe('supports', () => { it('returns true for browserstack automate', () => { expect(AutomateProvider.supports('http:browserstack')).toEqual(true);