From f1b0b534aadc6b0551a498dcb208c9fffef22c49 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 25 Sep 2026 01:11:50 +0100 Subject: [PATCH 1/3] SDK-762 detect WebKit inside in-app iframes instead of trusting the Chrome user agent. Chrome on iOS was treated as desktop Chrome, so close controls were bound inside a sandboxed iframe where WebKit drops them. --- CHANGELOG.md | 2 ++ src/inapp/inapp.ts | 32 +++++++++++++++++++++++++++++--- src/inapp/tests/inapp.test.ts | 28 +++++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9afe6f41..087f9f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixes +- Detect WebKit from the in-app iframe instead of the user agent, so Chrome on iOS and other iOS browsers that do not identify as Safari can dismiss in-browser messages (SDK-762). ## [2.3.2] ### Fixes diff --git a/src/inapp/inapp.ts b/src/inapp/inapp.ts index 1c954925..dde3c7c1 100644 --- a/src/inapp/inapp.ts +++ b/src/inapp/inapp.ts @@ -43,6 +43,23 @@ import { wrapWithIFrame } from './utils'; +/** + * WebKit does not run listeners inside an iframe sandboxed without allow-scripts. + * Returns true when a click listener on that document does not run. + */ +export const webkitBlocksIframeHandlers = ( + doc: Document | null | undefined +): boolean => { + if (!doc) return true; + let fired = false; + const probe = doc.createElement('div'); + probe.addEventListener('click', () => { + fired = true; + }); + probe.dispatchEvent(new MouseEvent('click')); + return !fired; +}; + let parsedMessages: InAppMessage[] = []; let timer: NodeJS.Timeout | null = null; let messageIndex = 0; @@ -274,14 +291,23 @@ export function getInAppMessages( } const ua = navigator.userAgent; - const isSafari = + const uaSaysSafari = !!ua.match(/safari/i) && !ua.match(/chrome|chromium|crios/i); + /** + * Chrome on iOS (CriOS) and in-app browsers fail the check above but + * still run WebKit, which does not run listeners inside an iframe + * sandboxed without allow-scripts. Ask the iframe. Real Safari keeps + * the user-agent result so we do not dispatch a probe event there. + */ + const isSafari = + uaSaysSafari || webkitBlocksIframeHandlers(activeIframeDocument); + /** * We allow users to dismiss messages by clicking outside of the * message not only when isRequiredToDismissMessage is not true - * but also when browser is detected to be Safari, regardless of - * whether isRequiredToDismissMessage is true. Safari blocks + * but also when listeners inside the iframe will not run, regardless of + * whether isRequiredToDismissMessage is true. WebKit blocks * all bound event handlers and so we cannot execute Javascript * to listen for click events. As such, we should not prevent users * from being able to dismiss the message by clicking outside of it. diff --git a/src/inapp/tests/inapp.test.ts b/src/inapp/tests/inapp.test.ts index 28433e36..6a68d0b3 100644 --- a/src/inapp/tests/inapp.test.ts +++ b/src/inapp/tests/inapp.test.ts @@ -8,7 +8,11 @@ import { setTypeOfAuthForTestingOnly } from '../../testing'; import { GETMESSAGES_PATH, SDK_VERSION, WEB_PLATFORM } from '../../constants'; import { baseAxiosRequest } from '../../request'; import { createClientError } from '../../utils/testUtils'; -import { getInAppMessages, clearMessages } from '../inapp'; +import { + getInAppMessages, + clearMessages, + webkitBlocksIframeHandlers +} from '../inapp'; import { DisplayOptions, HandleLinks } from '../types'; jest.mock('../../utils/srSpeak', () => ({ @@ -1313,3 +1317,25 @@ describe('getInAppMessages', () => { }); }); }); + +describe('webkitBlocksIframeHandlers', () => { + it('returns false when a click listener on the iframe document runs', () => { + const doc = document.implementation.createHTMLDocument('probe'); + expect(webkitBlocksIframeHandlers(doc)).toBe(false); + }); + + it('returns true when the listener does not run', () => { + const doc = document.implementation.createHTMLDocument('probe'); + const realCreate = doc.createElement.bind(doc); + doc.createElement = ((tag: string) => { + const el = realCreate(tag); + el.addEventListener = () => undefined; + return el; + }) as typeof doc.createElement; + expect(webkitBlocksIframeHandlers(doc)).toBe(true); + }); + + it('returns true when there is no iframe document', () => { + expect(webkitBlocksIframeHandlers(null)).toBe(true); + }); +}); From b6ffa30ca703bdb1c9edc34f4c849961fdb795ae Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 25 Sep 2026 01:23:37 +0100 Subject: [PATCH 2/3] SDK-762 lock Chrome on iOS dismiss to the parent page in tests. Dropping the WebKit probe wiring would leave the close button inside the iframe and this test would fail. --- src/inapp/tests/inapp.test.ts | 87 ++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/src/inapp/tests/inapp.test.ts b/src/inapp/tests/inapp.test.ts index 6a68d0b3..dd654f2e 100644 --- a/src/inapp/tests/inapp.test.ts +++ b/src/inapp/tests/inapp.test.ts @@ -5,7 +5,13 @@ import MockAdapter from 'axios-mock-adapter'; import { messages } from '../../__data__/inAppMessages'; import { initialize } from '../../authorization'; import { setTypeOfAuthForTestingOnly } from '../../testing'; -import { GETMESSAGES_PATH, SDK_VERSION, WEB_PLATFORM } from '../../constants'; +import { + ABSOLUTE_DISMISS_BUTTON_ID, + CLOSE_X_BUTTON_ID, + GETMESSAGES_PATH, + SDK_VERSION, + WEB_PLATFORM +} from '../../constants'; import { baseAxiosRequest } from '../../request'; import { createClientError } from '../../utils/testUtils'; import { @@ -424,6 +430,85 @@ describe('getInAppMessages', () => { expect(document.getElementById('iterable-iframe')).toBe(null); }); + it('puts the SDK close button on the parent page when Chrome on iOS cannot bind iframe handlers', async () => { + const previousUa = navigator.userAgent; + (navigator as Navigator & { userAgent: string }).userAgent = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.7339.101 Mobile/15E148 Safari/604.1'; + + // jsdom gives the iframe its own Document, so the probe has to be + // blocked on that document. Drop listeners on divs created there. + const originalAppend = HTMLElement.prototype.appendChild; + const restorers: (() => void)[] = []; + HTMLElement.prototype.appendChild = function appendChild( + this: HTMLElement, + node: T + ): T { + const result = originalAppend.call(this, node) as T; + if (node instanceof HTMLIFrameElement && node.contentWindow) { + const innerWindow = node.contentWindow as Window & { + Document: typeof Document; + }; + const innerProto = innerWindow.Document.prototype; + const innerCreate = innerProto.createElement; + innerProto.createElement = function createElement( + this: Document, + tag: string, + options?: ElementCreationOptions + ) { + const el = innerCreate.call(this, tag, options); + if (String(tag).toLowerCase() === 'div') { + el.addEventListener = () => undefined; + } + return el; + } as typeof innerProto.createElement; + restorers.push(() => { + innerProto.createElement = innerCreate; + }); + } + return result; + }; + + try { + const { request } = getInAppMessages( + { + count: 10, + packageName: 'my-lil-website', + closeButton: { isRequiredToDismissMessage: true } + }, + { display: DisplayOptions.Immediate } + ); + await request(); + jest.advanceTimersByTime(100); + + const frame = document.getElementById( + 'iterable-iframe' + ) as HTMLIFrameElement; + const closeButton = document.getElementById(CLOSE_X_BUTTON_ID); + const dismissLayer = document.getElementById( + ABSOLUTE_DISMISS_BUTTON_ID + ); + + expect(closeButton).not.toBeNull(); + expect(closeButton?.parentElement).toBe(document.body); + expect( + frame.contentDocument?.getElementById(CLOSE_X_BUTTON_ID) + ).toBeNull(); + expect(dismissLayer?.parentElement).toBe(document.body); + expect( + frame.contentDocument?.getElementById(ABSOLUTE_DISMISS_BUTTON_ID) + ).toBeNull(); + + document + .querySelector('[data-test-overlay]') + ?.dispatchEvent(new MouseEvent('click')); + expect(document.getElementById('iterable-iframe')).toBeNull(); + } finally { + restorers.forEach((restore) => restore()); + HTMLElement.prototype.appendChild = originalAppend; + (navigator as Navigator & { userAgent: string }).userAgent = previousUa; + } + }); + it('should paint next message to the DOM after 30s after first is dismissed', async () => { const { request } = getInAppMessages( { count: 10, packageName: 'my-lil-website' }, From d1b40fa6dc6ceac56a549ba213c3acf45a5903a8 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Mon, 28 Sep 2026 16:50:56 +0100 Subject: [PATCH 3/3] SDK-762 cover Chrome on iOS dismiss with a WebKit Playwright test. The Jest probe is stubbed because jsdom runs iframe listeners. This loads the built bundle in WebKit with a CriOS user agent and checks the close button is on the parent page. --- .eslintrc | 8 ++ .github/workflows/ci.yml | 26 ++++++ .gitignore | 4 +- e2e/crios-close-button.spec.ts | 146 +++++++++++++++++++++++++++++++++ package.json | 2 + playwright.config.ts | 30 +++++++ src/inapp/tests/inapp.test.ts | 6 +- yarn.lock | 26 ++++++ 8 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 e2e/crios-close-button.spec.ts create mode 100644 playwright.config.ts diff --git a/.eslintrc b/.eslintrc index 904fc5b8..feaafc68 100644 --- a/.eslintrc +++ b/.eslintrc @@ -56,6 +56,14 @@ "single" ] }, + "overrides": [ + { + "files": ["e2e/**/*.ts", "playwright.config.ts"], + "rules": { + "import/no-extraneous-dependencies": "off" + } + } + ], "settings": { "import/resolver": { "node": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef1a63c7..82b9fe1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,32 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - run: yarn check-deps + webkit-dismiss: + name: Chrome on iOS dismiss + runs-on: ubuntu-latest + timeout-minutes: 15 + container: + image: mcr.microsoft.com/playwright:v1.56.1-jammy + options: --user 1001 + + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Setup Node.js + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: "18.12.0" + cache: "yarn" + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Build the browser bundle + run: yarn webpack + - name: Install Playwright browsers + run: yarn playwright install webkit chromium + - name: Run the Chrome on iOS dismiss test + run: yarn test:webkit + env: + CI: true + e2e-tests: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.gitignore b/.gitignore index 33fde1a2..1804a8ea 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,6 @@ browserstack.json .DS_Store stats.json .idea -.cursor \ No newline at end of file +.cursor +/playwright-report/ +/test-results/ diff --git a/e2e/crios-close-button.spec.ts b/e2e/crios-close-button.spec.ts new file mode 100644 index 00000000..a342f905 --- /dev/null +++ b/e2e/crios-close-button.spec.ts @@ -0,0 +1,146 @@ +/** + * WebKit regression for Chrome on iOS. + * + * The Jest coverage stubs the iframe probe, because jsdom runs listeners + * inside a sandboxed iframe. This loads the built bundle in real WebKit + * with a CriOS user agent, which fails the Safari user-agent check, and + * asserts the SDK close button is painted on the parent page. + * + * Requires index.js. Run `yarn webpack` first. + */ +import fs from 'fs'; +import http from 'http'; +import path from 'path'; +import { expect, test, type Page } from '@playwright/test'; + +const PAGE = ` + + + + + + + + +`; + +let server: http.Server; +let baseURL: string; + +test.beforeAll(async () => { + const sdkPath = path.join(__dirname, '..', 'index.js'); + if (!fs.existsSync(sdkPath)) { + throw new Error('Missing index.js. Run yarn webpack before this test.'); + } + const sdk = fs.readFileSync(sdkPath); + server = http.createServer((req, res) => { + if (req.url?.startsWith('/sdk.js')) { + res.setHeader('content-type', 'application/javascript'); + res.end(sdk); + return; + } + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.end(PAGE); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Test server failed to bind'); + } + baseURL = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => { + if (!server) return; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +}); + +const openMessage = async (page: Page) => { + await page.route('**/*', (route) => { + const { hostname } = new URL(route.request().url()); + if (hostname === '127.0.0.1' || hostname === 'localhost') { + route.continue(); + return; + } + route.abort(); + }); + await page.goto(baseURL); + await page.evaluate(() => + (window as unknown as { show: () => Promise }).show() + ); +}; + +test('Chrome on iOS paints the SDK close button on the parent page', async ({ + page +}) => { + await openMessage(page); + + const parentButton = page.locator('#close-x-button'); + const frameButton = page + .frameLocator('#iterable-iframe') + .locator('#close-x-button'); + + await expect(parentButton).toHaveCount(1); + await expect(frameButton).toHaveCount(0); + + await parentButton.click(); + await expect(page.locator('#iterable-iframe')).toHaveCount(0); +}); + +test('Chromium keeps the SDK close button inside the iframe', async ({ + page +}) => { + await openMessage(page); + + const parentButton = page.locator('#close-x-button'); + const frameButton = page + .frameLocator('#iterable-iframe') + .locator('#close-x-button'); + + await expect(parentButton).toHaveCount(0); + await expect(frameButton).toHaveCount(1); + + await frameButton.click(); + await expect(page.locator('#iterable-iframe')).toHaveCount(0); +}); diff --git a/package.json b/package.json index 0f937abb..0ed5e257 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "build": "ttsc && babel src --out-dir ./dist --extensions '.ts,.tsx' && webpack", "build:node": "yarn build --config webpack.node.config.js", "test": "jest --config jest.config.js --coverage", + "test:webkit": "playwright test", "format": "prettier --write \"src/**/*.{ts,tsx}\" \"src/**/*.{js}\" \"example/src/**/*.{ts,tsx}\" \"example/src/**/*.{js}\" \"react-example/src/**/*.{ts,tsx}\" \"react-example/src/**/*.{js}\" --no-error-on-unmatched-pattern", "lint": "eslint src --ignore-pattern '*.test.*' --ignore-pattern '*.spec.*' --ext '.ts,.tsx'", "typecheck": "ttsc --noEmit true --emitDeclarationOnly false", @@ -71,6 +72,7 @@ "@babel/plugin-transform-runtime": "^7.22.15", "@babel/preset-env": "^7.27.2", "@babel/preset-typescript": "^7.6.0", + "@playwright/test": "1.56.1", "@types/jest": "^28.1.1", "@types/node": "^12.12.6", "@types/throttle-debounce": "^2.1.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..a1ef6c52 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +const criosUserAgent = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.7339.101 Mobile/15E148 Safari/604.1'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + timeout: 30000, + reporter: process.env.CI ? 'line' : 'list', + projects: [ + { + name: 'webkit-crios', + grep: /Chrome on iOS/, + use: { + ...devices['iPhone 15'], + userAgent: criosUserAgent + } + }, + { + name: 'chromium', + grep: /Chromium/, + use: { + ...devices['Desktop Chrome'] + } + } + ] +}); diff --git a/src/inapp/tests/inapp.test.ts b/src/inapp/tests/inapp.test.ts index dd654f2e..f7857c9d 100644 --- a/src/inapp/tests/inapp.test.ts +++ b/src/inapp/tests/inapp.test.ts @@ -435,8 +435,10 @@ describe('getInAppMessages', () => { (navigator as Navigator & { userAgent: string }).userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.7339.101 Mobile/15E148 Safari/604.1'; - // jsdom gives the iframe its own Document, so the probe has to be - // blocked on that document. Drop listeners on divs created there. + // jsdom runs listeners inside a sandboxed iframe, so this stub only + // locks the parent-page branch after the detector returns true. + // e2e/crios-close-button.spec.ts is the WebKit check. + // Drop listeners on divs created in the iframe document. const originalAppend = HTMLElement.prototype.appendChild; const restorers: (() => void)[] = []; HTMLElement.prototype.appendChild = function appendChild( diff --git a/yarn.lock b/yarn.lock index 583bac43..9ce736ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1433,6 +1433,13 @@ resolved "https://registry.yarnpkg.com/@pabra/sortby/-/sortby-1.0.2.tgz#9ef674da9e5048bbe6a08217ce0dede5411cdfc9" integrity sha512-gT4DWbGLlkctE5TwRT6/gZzHiVil1Ywg7FF5OmIilZbGCZqdYpbz98L2bqfhglwK3kj4fBZRMSCtzrjQ6J8jAw== +"@playwright/test@1.56.1": + version "1.56.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.56.1.tgz#6e3bf3d0c90c5cf94bf64bdb56fd15a805c8bd3f" + integrity sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg== + dependencies: + playwright "1.56.1" + "@sinclair/typebox@^0.24.1": version "0.24.51" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f" @@ -4083,6 +4090,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@^2.3.2, fsevents@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -6202,6 +6214,20 @@ pkg-up@^3.1.0: dependencies: find-up "^3.0.0" +playwright-core@1.56.1: + version "1.56.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.56.1.tgz#24a66481e5cd33a045632230aa2c4f0cb6b1db3d" + integrity sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ== + +playwright@1.56.1: + version "1.56.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.56.1.tgz#62e3b99ddebed0d475e5936a152c88e68be55fbf" + integrity sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw== + dependencies: + playwright-core "1.56.1" + optionalDependencies: + fsevents "2.3.2" + please-upgrade-node@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942"