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/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/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/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..f7857c9d 100644
--- a/src/inapp/tests/inapp.test.ts
+++ b/src/inapp/tests/inapp.test.ts
@@ -5,10 +5,20 @@ 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 { getInAppMessages, clearMessages } from '../inapp';
+import {
+ getInAppMessages,
+ clearMessages,
+ webkitBlocksIframeHandlers
+} from '../inapp';
import { DisplayOptions, HandleLinks } from '../types';
jest.mock('../../utils/srSpeak', () => ({
@@ -420,6 +430,87 @@ 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 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(
+ 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' },
@@ -1313,3 +1404,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);
+ });
+});
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"