diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..f593d56dfc --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ + + +## Description + + + +## Screenshots + + + +## Testing instructions + + diff --git a/scripts/semantic-release/assets.sh b/scripts/semantic-release/assets.sh index 7ec0789b65..402dd5e552 100755 --- a/scripts/semantic-release/assets.sh +++ b/scripts/semantic-release/assets.sh @@ -37,7 +37,7 @@ if [ ! -z "$tag" ]; then version=$version-$(echo $tag | sed "s/_/-/g" | sed -E "s/-([0-9]+)$/.\1/") # Check if the CDN tag has already been used before spending time on the webpack build tagStatus=$(web status $tag 2>&1) - if [[ $tagStatus =~ "✔ Complete" ]]; then + if [[ $tagStatus =~ "staged" ]]; then printf "Stage tag already exists and must be unique ($tag)\n\n" exit 1 fi diff --git a/src/components/modal/v2/lib/utils.js b/src/components/modal/v2/lib/utils.js index 18dccab115..402a4dfebd 100644 --- a/src/components/modal/v2/lib/utils.js +++ b/src/components/modal/v2/lib/utils.js @@ -1,5 +1,6 @@ import objectEntries from 'core-js-pure/stable/object/entries'; import arrayFrom from 'core-js-pure/stable/array/from'; +import { isIosWebview, isAndroidWebview } from '@krakenjs/belter/src'; import { request, memoize, ppDebug } from '../../../../utils'; export const getContent = memoize( @@ -64,7 +65,8 @@ export const getContent = memoize( * @returns boolean */ export const isLander = __MESSAGES__.__TARGET__ === 'LANDER'; -export const isIframe = window.top !== window; +const { userAgent } = window.navigator; +export const isIframe = window.top !== window || isIosWebview(userAgent) || isAndroidWebview(userAgent); export function setupTabTrap() { const focusableElementsString = diff --git a/src/components/modal/v2/lib/zoid-polyfill.js b/src/components/modal/v2/lib/zoid-polyfill.js index b0f56f31ba..4598235e72 100644 --- a/src/components/modal/v2/lib/zoid-polyfill.js +++ b/src/components/modal/v2/lib/zoid-polyfill.js @@ -1,21 +1,11 @@ +/* global Android */ +import { isAndroidWebview, isIosWebview, getPerformance } from '@krakenjs/belter/src'; import { logger } from '../../../../utils'; -export default function polyfillZoid() { - const props = window.location.search - .slice(1) - .split('&') - .reduce((acc, query) => { - const [key, value] = query.split('='); - - if (value) { - const propName = key.replace(/_([a-z])/g, (_, p1) => p1.toUpperCase()); - - acc[propName] = value; - } - - return acc; - }, {}); +const IOS_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; +const ANDROID_INTERFACE_NAME = 'paypalMessageModalCallbackHandler'; +const setupBrowser = props => { window.xprops = { // We will never recieve new props via this integration style onProps: () => {}, @@ -105,4 +95,125 @@ export default function polyfillZoid() { // Specified props via query params ...props }; +}; + +const setupWebview = props => { + const postMessage = (() => { + if (window.webkit?.messageHandlers?.[IOS_INTERFACE_NAME]) { + return window.webkit.messageHandlers[IOS_INTERFACE_NAME].postMessage.bind( + window.webkit.messageHandlers[IOS_INTERFACE_NAME] + ); + } + + // `Android` is not on the `window` object but rather an adjacent top level object + if (typeof Android !== 'undefined') { + return Android[ANDROID_INTERFACE_NAME].bind(Android); + } + + // This scenario should only ever occur when developing locally + return payload => console.warn('postMessage:', JSON.parse(payload)); + })(); + + const propListeners = new Set(); + const sendCallbackMessage = (name, ...args) => postMessage(JSON.stringify({ name, args })); + // Functions called from the native app + window.actions = { + updateProps: newProps => { + if (newProps && typeof newProps === 'object') { + Array.from(propListeners.values()).forEach(listener => { + listener({ ...window.xprops, ...newProps }); + }); + + Object.assign(window.xprops, newProps); + } + } + }; + window.xprops = { + onProps: listener => propListeners.add(listener), + + onReady: ({ meta }) => { + const { trackingDetails } = meta; + const timing = getPerformance()?.getEntriesByType('navigation')[0]; + + sendCallbackMessage('onReady', { + __shared__: { + // Analytic Details + fdata: trackingDetails.fdata, + experimentation_experience_ids: trackingDetails.experimentation_experience_ids, + experimentation_treatment_ids: trackingDetails.experimentation_treatment_ids, + credit_product_identifiers: trackingDetails.credit_product_identifiers, + offer_country_code: trackingDetails.offer_country_code, + merchant_country_code: trackingDetails.merchant_country_code, + views: trackingDetails.views, + qualified_products: trackingDetails.qualified_products, + debug_id: trackingDetails.debug_id + }, + event_type: 'modal_render', + request_duration: timing && timing.responseEnd - timing.requestStart, + render_duration: timing && timing.loadEventEnd - timing.responseEnd + }); + }, + + onClick: ({ linkName, src = linkName }) => { + sendCallbackMessage('onClick', { + event_type: 'modal_click', + link_name: linkName, + link_src: src + }); + }, + + onCalculate: ({ value }) => { + sendCallbackMessage('onCalculate', { + event_type: 'modal_click', + link_name: 'Calculator', + link_src: 'Calculator', + data: value + }); + }, + + onShow: () => { + sendCallbackMessage('onShow', { + event_type: 'modal_open', + link_name: 'Show', + link_src: 'Show' + }); + }, + + onClose: ({ linkName, src = linkName }) => { + sendCallbackMessage('onClose', { + event_type: 'modal_close', + link_name: linkName, + link_src: src + }); + }, + // Overridable defaults + integrationType: __MESSAGES__.__TARGET__, + // Specified props via query params + ...props + }; +}; + +export default function polyfillZoid() { + const props = window.location.search + .slice(1) + .split('&') + .reduce((acc, query) => { + const [key, value] = query.split('='); + + if (value) { + const propName = key.replace(/_([a-z])/g, (_, p1) => p1.toUpperCase()); + + acc[propName] = value; + } + + return acc; + }, {}); + + const { userAgent } = window.navigator; + + if (isIosWebview(userAgent) || isAndroidWebview(userAgent)) { + setupWebview(props); + } else { + setupBrowser(props); + } } diff --git a/tests/unit/spec/src/components/modal/v2/lib/zoid-polyfill.test.js b/tests/unit/spec/src/components/modal/v2/lib/zoid-polyfill.test.js new file mode 100644 index 0000000000..8fc3392e96 --- /dev/null +++ b/tests/unit/spec/src/components/modal/v2/lib/zoid-polyfill.test.js @@ -0,0 +1,383 @@ +import zoidPolyfill from 'src/components/modal/v2/lib/zoid-polyfill'; +import { logger } from 'src/utils'; + +// Mock all of utils because the `stats` util that would be included has a side-effect call to logger.track +jest.mock('src/utils', () => ({ + logger: { + track: jest.fn(), + addMetaBuilder: jest.fn() + } +})); + +jest.mock('@krakenjs/belter/src', () => { + const originalModule = jest.requireActual('@krakenjs/belter/src'); + + return { + ...originalModule, + getPerformance: () => ({ + getEntriesByType: () => [ + { + requestStart: 100, + responseEnd: 200, + loadEventEnd: 250 + } + ] + }) + }; +}); + +const mockLoadUrl = (url, { platform = 'web' } = {}) => { + delete window.location; + delete window.xprops; + delete window.actions; + delete window.navigator; + delete window.webkit; + delete global.Android; + + window.location = new URL(url); + window.navigator = { + userAgent: (() => { + switch (platform) { + case 'web': + return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0'; + + case 'ios': + window.webkit = { + messageHandlers: { + paypalMessageModalCallbackHandler: { + postMessage: jest.fn() + } + } + }; + + return 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'; + + case 'android': + global.Android = { + paypalMessageModalCallbackHandler: jest.fn() + }; + + return 'Mozilla/5.0 (Linux; Android 11; sdk_gphone_arm64 Build/RSR1.210722.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/96.0.4664.104 Mobile Safari/537.36'; + + default: + throw new Error(`Invalid platform: ${platform}`); + } + })() + }; +}; + +describe('zoidPollyfill', () => { + test('sets up xprops for browser', () => { + mockLoadUrl( + 'https://localhost.paypal.com:8080/credit-presentment/native/message?client_id=client_1&logo_type=inline&amount=500&devTouchpoint=true' + ); + + zoidPolyfill(); + + expect(window.actions).toBeUndefined(); + expect(window.xprops).toEqual( + expect.objectContaining({ + onProps: expect.any(Function), + onReady: expect.any(Function), + onClick: expect.any(Function), + onCalculate: expect.any(Function), + onShow: expect.any(Function), + onClose: expect.any(Function), + integrationType: 'STANDALONE', + clientId: 'client_1', + logoType: 'inline', + amount: '500', + devTouchpoint: 'true' + }) + ); + + window.xprops.onReady({ + products: ['PRODUCT_1', 'PRODUCT_2'], + deviceID: 'abc123', + meta: { trackingDetails: 'trackingDetails' } + }); + + expect(logger.track).toHaveBeenCalledTimes(1); + expect(logger.track).toHaveBeenCalledWith( + expect.objectContaining({ + event_type: 'modal-render', + modal: 'product_1_product_2:PRODUCT_1' + }) + ); + logger.track.mockClear(); + + window.xprops.onClick({ linkName: 'test link', src: 'test src' }); + + expect(logger.track).toHaveBeenCalledTimes(1); + expect(logger.track).toHaveBeenCalledWith( + expect.objectContaining({ + event_type: 'click', + link: 'test link', + src: 'test src' + }) + ); + logger.track.mockClear(); + + window.xprops.onCalculate({ value: 500 }); + + expect(logger.track).toHaveBeenCalledTimes(1); + expect(logger.track).toHaveBeenCalledWith( + expect.objectContaining({ + event_type: 'click', + link: 'Calculator', + src: 'Calculator', + amount: 500 + }) + ); + logger.track.mockClear(); + + window.xprops.onShow(); + + expect(logger.track).toHaveBeenCalledTimes(1); + expect(logger.track).toHaveBeenCalledWith( + expect.objectContaining({ + event_type: 'modal-open', + src: 'Show' + }) + ); + logger.track.mockClear(); + + window.xprops.onClose({ linkName: 'Close Button' }); + + expect(logger.track).toHaveBeenCalledTimes(1); + expect(logger.track).toHaveBeenCalledWith( + expect.objectContaining({ + event_type: 'modal-close', + link: 'Close Button' + }) + ); + logger.track.mockClear(); + }); + + test('sets up xprops for webview', () => { + mockLoadUrl( + 'https://localhost.paypal.com:8080/credit-presentment/native/message?client_id=client_1&logo_type=inline&amount=500&dev_touchpoint=true', + { + platform: 'ios' + } + ); + const { postMessage } = window.webkit.messageHandlers.paypalMessageModalCallbackHandler; + + zoidPolyfill(); + + expect(window.actions).toEqual( + expect.objectContaining({ + updateProps: expect.any(Function) + }) + ); + expect(window.xprops).toEqual( + expect.objectContaining({ + onProps: expect.any(Function), + onReady: expect.any(Function), + onClick: expect.any(Function), + onCalculate: expect.any(Function), + onShow: expect.any(Function), + onClose: expect.any(Function), + integrationType: 'STANDALONE', + clientId: 'client_1', + logoType: 'inline', + amount: '500', + devTouchpoint: 'true' + }) + ); + + window.xprops.onReady({ + products: ['PRODUCT_1', 'PRODUCT_2'], + deviceID: 'abc123', + meta: { + trackingDetails: { + fdata: '123abc', + credit_product_identifiers: ['PAY_LATER_LONG_TERM_US'], + offer_country_code: 'US', + extra_field: 'should not be present' + } + } + }); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage.mock.calls[0][0]).toEqual(expect.any(String)); + expect(JSON.parse(postMessage.mock.calls[0][0])).toMatchInlineSnapshot(` + Object { + "args": Array [ + Object { + "__shared__": Object { + "credit_product_identifiers": Array [ + "PAY_LATER_LONG_TERM_US", + ], + "fdata": "123abc", + "offer_country_code": "US", + }, + "event_type": "modal_render", + "render_duration": 50, + "request_duration": 100, + }, + ], + "name": "onReady", + } + `); + postMessage.mockClear(); + + window.xprops.onClick({ linkName: 'test link', src: 'test src' }); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage.mock.calls[0][0]).toEqual(expect.any(String)); + expect(JSON.parse(postMessage.mock.calls[0][0])).toMatchInlineSnapshot(` + Object { + "args": Array [ + Object { + "event_type": "modal_click", + "link_name": "test link", + "link_src": "test src", + }, + ], + "name": "onClick", + } + `); + postMessage.mockClear(); + + window.xprops.onCalculate({ value: 500 }); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage.mock.calls[0][0]).toEqual(expect.any(String)); + expect(JSON.parse(postMessage.mock.calls[0][0])).toMatchInlineSnapshot(` + Object { + "args": Array [ + Object { + "data": 500, + "event_type": "modal_click", + "link_name": "Calculator", + "link_src": "Calculator", + }, + ], + "name": "onCalculate", + } + `); + postMessage.mockClear(); + + window.xprops.onShow(); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage.mock.calls[0][0]).toEqual(expect.any(String)); + expect(JSON.parse(postMessage.mock.calls[0][0])).toMatchInlineSnapshot(` + Object { + "args": Array [ + Object { + "event_type": "modal_open", + "link_name": "Show", + "link_src": "Show", + }, + ], + "name": "onShow", + } + `); + postMessage.mockClear(); + + window.xprops.onClose({ linkName: 'Close Button' }); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage.mock.calls[0][0]).toEqual(expect.any(String)); + expect(JSON.parse(postMessage.mock.calls[0][0])).toMatchInlineSnapshot(` + Object { + "args": Array [ + Object { + "event_type": "modal_close", + "link_name": "Close Button", + "link_src": "Close Button", + }, + ], + "name": "onClose", + } + `); + postMessage.mockClear(); + }); + + test('notifies when props update', () => { + mockLoadUrl( + 'https://localhost.paypal.com:8080/credit-presentment/native/message?client_id=client_1&logo_type=inline&amount=500&devTouchpoint=true', + { + platform: 'android' + } + ); + const postMessage = global.Android.paypalMessageModalCallbackHandler; + + zoidPolyfill(); + + expect(window.actions).toEqual( + expect.objectContaining({ + updateProps: expect.any(Function) + }) + ); + expect(window.xprops).toEqual( + expect.objectContaining({ + onProps: expect.any(Function) + }) + ); + + const onPropsCallback = jest.fn(); + + window.xprops.onProps(onPropsCallback); + window.actions.updateProps({ amount: 1000 }); + + expect(onPropsCallback).toHaveBeenCalledTimes(1); + expect(onPropsCallback).toHaveBeenCalledWith( + expect.objectContaining({ + clientId: 'client_1', + logoType: 'inline', + amount: 1000 + }) + ); + + window.actions.updateProps({ offer: 'TEST' }); + + expect(onPropsCallback).toHaveBeenCalledTimes(2); + expect(onPropsCallback).toHaveBeenCalledWith( + expect.objectContaining({ + clientId: 'client_1', + logoType: 'inline', + amount: 1000, + offer: 'TEST' + }) + ); + + window.xprops.onReady({ + products: ['PRODUCT_1', 'PRODUCT_2'], + deviceID: 'abc123', + meta: { + trackingDetails: { + fdata: '123abc', + credit_product_identifiers: ['PAY_LATER_LONG_TERM_US'], + offer_country_code: 'US', + extra_field: 'should not be present' + } + } + }); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage.mock.calls[0][0]).toEqual(expect.any(String)); + expect(JSON.parse(postMessage.mock.calls[0][0])).toMatchInlineSnapshot(` + Object { + "args": Array [ + Object { + "__shared__": Object { + "credit_product_identifiers": Array [ + "PAY_LATER_LONG_TERM_US", + ], + "fdata": "123abc", + "offer_country_code": "US", + }, + "event_type": "modal_render", + "render_duration": 50, + "request_duration": 100, + }, + ], + "name": "onReady", + } + `); + postMessage.mockClear(); + }); +});