Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@
"single"
]
},
"overrides": [
{
"files": ["e2e/**/*.ts", "playwright.config.ts"],
"rules": {
"import/no-extraneous-dependencies": "off"
}
}
],
"settings": {
"import/resolver": {
"node": {
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ browserstack.json
.DS_Store
stats.json
.idea
.cursor
.cursor
/playwright-report/
/test-results/
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
146 changes: 146 additions & 0 deletions e2e/crios-close-button.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="/sdk.js"></script>
</head>
<body>
<script>
window.show = () => {
const sdk = window['@iterable/web-sdk'];
const { triggerDisplayMessages } = sdk.getInAppMessages(
{
count: 1,
packageName: 'webkit-regression',
closeButton: {
position: 'top-right',
color: '#000',
size: '24px',
isRequiredToDismissMessage: true
}
},
{ display: 'deferred' }
);
return triggerDisplayMessages([
{
messageId: 'm1',
campaignId: 1,
createdAt: Date.now(),
expiresAt: Date.now() + 1e9,
priorityLevel: 300.5,
read: false,
saveToInbox: false,
trigger: { type: 'immediate' },
content: {
html: '<div style="padding:24px;background:#fff">Hello</div>',
inAppDisplaySettings: {
bgColor: { hex: '#000000', alpha: 0.5 },
shouldAnimate: false
},
webInAppDisplaySettings: { position: 'Center' }
}
}
]);
};
</script>
</body>
</html>`;

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<void>((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<void>((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<void> }).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);
});
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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']
}
}
]
});
32 changes: 29 additions & 3 deletions src/inapp/inapp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading