diff --git a/package.json b/package.json index ef5576c..f4703e3 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "changelog": "bash bin/fill-changelog-file-and-notify-github.sh", "compile": "yarn compile:clean && yarn compile:ts && yarn compile:copy-resources", "compile:copy-package-json": "shx cp package.json .publish/package.json", - "compile:copy-resources": "yarn copyfiles -e \"**/*.{[jt]s*(x),snap}\" -e \"**/*.json\" -e \"src/mock/**/*\" -u 1 \"src/**/*\" .publish", + "compile:copy-resources": "yarn copyfiles -e \"**/*.{[jt]s*(x),snap}\" -e \"**/*.json\" -u 1 \"src/**/*\" .publish", "compile:clean": "shx rm -rf .publish", "compile:ts": "tsc --project tsconfig.build.json", "lint:scripts": "eslint \"**/*.{js,jsx,ts,tsx}\" --ext .js,.jsx,.ts,.tsx", @@ -67,13 +67,15 @@ ], "coveragePathIgnorePatterns": [ "/node_modules/", - "/test/" + "/test/", + "/src/index.ts" ], "transformIgnorePatterns": [ "node_modules/(?!(uuid)/)" ], "testPathIgnorePatterns": [ - "/node_modules/" + "/node_modules/", + "/test/mock" ], "reporters": [ "jest-junit", diff --git a/src/bridge-to-native.ts b/src/bridge-to-native.ts new file mode 100644 index 0000000..37b4ad4 --- /dev/null +++ b/src/bridge-to-native.ts @@ -0,0 +1,228 @@ +/* eslint-disable no-underscore-dangle */ + +import { + CLOSE_WEBVIEW_SEARCH_KEY, + CLOSE_WEBVIEW_SEARCH_VALUE, + nativeFeaturesFromVersion, + PREVIOUS_B2N_STATE_STORAGE_KEY, + versionToIosAppId, +} from './constants'; +import { NativeFallbacks } from './native-fallbacks'; +import { NativeNavigationAndTitle } from './native-navigation-and-title'; +import type { + Environment, + HandleRedirect, + NativeFeatureKey, + NativeParams, + Theme, + WebViewWindow, +} from './types'; +import { PreviousBridgeToNativeState } from './types'; +import { isValidVersionFormat } from './utils'; + +/** + * Этот класс — абстракция для связи веб приложения с нативом и предназначен ТОЛЬКО + * для использования в вебвью окружении. + */ +export class BridgeToNative { + // Webview, запущенное в Android окружении имеет объект `Android` в window. + public readonly AndroidBridge = (window as WebViewWindow).Android; + + public readonly environment: Environment = this.AndroidBridge ? 'android' : 'ios'; + + public readonly nativeFallbacks: NativeFallbacks; + + private nextPageId: number | null; + + private _nativeNavigationAndTitle: NativeNavigationAndTitle; + + private _originalWebviewParams: string; + + // В формате `x.x.x`. + private _appVersion: string; + + // Необходимо для формирования диплинка. + private _iosAppId?: string; + + private _theme: Theme; + + private readonly _blankPagePath: string; + + private readonly _handleRedirect: HandleRedirect; + + constructor( + handleRedirect: HandleRedirect, + blankPagePath: string, + nativeParams?: NativeParams, + ) { + const previousState = !!sessionStorage.getItem(PREVIOUS_B2N_STATE_STORAGE_KEY); + + if (previousState) { + this.restorePreviousState(); + this.nativeFallbacks = new NativeFallbacks(this); + this._blankPagePath = blankPagePath; + + return; + } + + this._appVersion = + nativeParams && isValidVersionFormat(nativeParams?.appVersion) + ? nativeParams.appVersion + : '0.0.0'; + this._iosAppId = this.getIosAppId(nativeParams?.iosAppId); + this._theme = nativeParams?.theme === 'dark' ? 'dark' : 'light'; + this._originalWebviewParams = nativeParams?.originalWebviewParams || ''; + this._nativeNavigationAndTitle = new NativeNavigationAndTitle( + this, + nativeParams ? nativeParams.nextPageId : null, + nativeParams?.title, + handleRedirect, + ); + this._handleRedirect = handleRedirect; + + this.nextPageId = nativeParams ? nativeParams.nextPageId : null; + this.nativeFallbacks = new NativeFallbacks(this); + this._blankPagePath = blankPagePath; + } + + get theme() { + return this._theme; + } + + get appVersion() { + return this._appVersion; + } + + get iosAppId() { + return this._iosAppId; + } + + get nativeNavigationAndTitle() { + return this._nativeNavigationAndTitle; + } + + get originalWebviewParams() { + return this._originalWebviewParams; + } + + /** + * Метод, проверяющий, можно ли использовать нативную функциональность в текущей версии приложения. + * + * @param feature Название функциональности, которую нужно проверить. + */ + public canUseNativeFeature(feature: NativeFeatureKey) { + const { fromVersion } = nativeFeaturesFromVersion[this.environment][feature]; + + return this.isCurrentVersionHigherOrEqual(fromVersion); + } + + /** + * Метод, отправляющий сигнал нативу, что нужно закрыть текущее вебвью. + */ + // eslint-disable-next-line class-methods-use-this + public closeWebview() { + const originalPageUrl = new URL(window.location.href); + + originalPageUrl.searchParams.set(CLOSE_WEBVIEW_SEARCH_KEY, CLOSE_WEBVIEW_SEARCH_VALUE); + window.location.href = originalPageUrl.toString(); + } + + /** + * Сравнивает текущую версию приложения с переданной. + * + * @param versionToCompare Версия, с которой нужно сравнить текущую. + * @returns `true` – текущая версия больше или равняется переданной, + * `false` – текущая версия ниже. + */ + public isCurrentVersionHigherOrEqual(versionToCompare: string) { + if (!isValidVersionFormat(versionToCompare)) { + return false; + } + + const matchPattern = /(\d+)\.(\d+)\.(\d+)/; + + type ExpectedTupple = [string, string, string, string]; + + const [, ...appVersionComponents] = this._appVersion.match(matchPattern) as ExpectedTupple; // Формат версии проверен в конструкторе, можно смело убирать `null` из типа. + + const [, ...versionToCompareComponents] = versionToCompare.match( + matchPattern, + ) as ExpectedTupple; + + for (let i = 0; i < appVersionComponents.length; i++) { + if (appVersionComponents[i] !== versionToCompareComponents[i]) { + return appVersionComponents[i] >= versionToCompareComponents[i]; + } + } + + return true; + } + + /** + * Сохраняет текущее состояние BridgeToNative в sessionStorage. + * Так же сохраняет текущее состояние nativeNavigationAndTitle. + */ + private saveCurrentState() { + // В nativeNavigationAndTitle этот метод отмечен модификатором доступа private дабы не торчал наружу, но тут его нужно вызвать + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this._nativeNavigationAndTitle.saveCurrentState(); + + const currentState: PreviousBridgeToNativeState = { + appVersion: this._appVersion, + theme: this._theme, + nextPageId: this.nextPageId, + originalWebviewParams: this._originalWebviewParams || '', + iosAppId: this._iosAppId, + }; + + sessionStorage.setItem(PREVIOUS_B2N_STATE_STORAGE_KEY, JSON.stringify(currentState)); + } + + /** + * Возвращает схему приложения в iOS окружении, на основе версии. + * + * @param knownIosAppId Тип iOS приложения, если он известен. + * @returns Тип приложения, `undefined` для Android окружения. + */ + private getIosAppId(knownIosAppId?: string) { + if (this.environment !== 'ios') { + return undefined; + } + + if (knownIosAppId) { + return knownIosAppId; + } + + const keys = Object.keys(versionToIosAppId); + + const rightKey = + [...keys].reverse().find((version) => this.isCurrentVersionHigherOrEqual(version)) || + keys[0]; + + return atob(versionToIosAppId[rightKey as keyof typeof versionToIosAppId]); + } + + /** + * Восстанавливает свое предыдущее состояние из sessionStorage + */ + private restorePreviousState() { + const previousState: PreviousBridgeToNativeState = JSON.parse( + sessionStorage.getItem(PREVIOUS_B2N_STATE_STORAGE_KEY) || '', + ); + + this._appVersion = previousState.appVersion; + this._iosAppId = previousState.iosAppId; + this._theme = previousState.theme; + this._originalWebviewParams = previousState.originalWebviewParams; + this.nextPageId = previousState.nextPageId; + this._nativeNavigationAndTitle = new NativeNavigationAndTitle( + this, + previousState.nextPageId, + '', + this._handleRedirect, + ); + + sessionStorage.removeItem(PREVIOUS_B2N_STATE_STORAGE_KEY); + } +} diff --git a/src/constants.ts b/src/constants.ts index fcac742..67348b3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,7 +6,6 @@ export const PREVIOUS_B2N_STATE_STORAGE_KEY = 'previousBridgeToNativeState'; export const PREVIOUS_NATIVE_NAVIGATION_AND_TITLE_STATE_STORAGE_KEY = 'previousNativeNavigationAndTitleState'; - export const versionToIosAppId = { '0.0.0': 'YWxmYWJhbms=', '12.22.0': 'YWNvbmNpZXJnZQ==', @@ -17,16 +16,17 @@ export const versionToIosAppId = { export const nativeFeaturesFromVersion: NativeFeaturesFromVersion = { android: { linksInBrowser: { - nativeFeatureFtKey: 'linksInBrowserAndroid', fromVersion: '11.71.0', }, geolocation: { fromVersion: '11.71.0' }, }, ios: { linksInBrowser: { - nativeFeatureFtKey: 'linksInBrowserIos', fromVersion: '13.3.0', }, geolocation: { fromVersion: '0.0.0' }, }, } as const; + +export const DEEP_LINK_PATTERN = + /^(\/|\x61\x6c\x66\x61\x62\x61\x6e\x6b:\/{3}dashboard\/|\x61\x6c\x66\x61\x62\x61\x6e\x6b:\/{3}|\x61\x6c\x66\x61\x62\x61\x6e\x6b:\/{2}|https:\/{2}\x6f\x6e\x6c\x69\x6e\x65\x2e\x61\x6c\x66\x61\x62\x61\x6e\x6b\x2e\x72\x75\/)/; diff --git a/src/index.ts b/src/index.ts index 3201545..683d027 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,230 +1,2 @@ -/* eslint-disable no-underscore-dangle */ - -import { - CLOSE_WEBVIEW_SEARCH_KEY, - CLOSE_WEBVIEW_SEARCH_VALUE, - nativeFeaturesFromVersion, - PREVIOUS_B2N_STATE_STORAGE_KEY, - versionToIosAppId, -} from './constants'; -import { NativeFallbacks } from './native-fallbacks'; -import { HandleRedirect, NativeNavigationAndTitle } from './native-navigation-and-title'; -import type { - Environment, - NativeFeatureKey, - NativeFeaturesFts, - NativeParams, - WebViewWindow, -} from './types'; -import { PreviousBridgeToNativeState } from './types'; -import { isValidVersionFormat } from './utils'; - -type Theme = 'light' | 'dark'; - -/** - * Этот класс - абстракция для связи веб приложения с нативом и предназначен ТОЛЬКО - * для использования в вебвью окружении. - */ -export class BridgeToNative { - // Webview, запущенное в Android окружении имеет объект `Android` в window. - public readonly AndroidBridge = (window as WebViewWindow).Android; - - public readonly environment: Environment = this.AndroidBridge ? 'android' : 'ios'; - - public readonly nativeFallbacks: NativeFallbacks; - - private nextPageId: number | null; - - private _nativeNavigationAndTitle: NativeNavigationAndTitle; - - private _originalWebviewParams: string; - - // В формате `x.x.x`. - private _appVersion: string; - - // Необходимо для формирования диплинка. - private _iosAppId?: string; - - private _theme: Theme; - - private _handleRedirect: HandleRedirect; - - constructor( - public nativeFeaturesFts: NativeFeaturesFts = {'linksInBrowserAndroid': true, 'linksInBrowserIos': true}, - handleRedirect: HandleRedirect, - nativeParams?: NativeParams, - ) { - const previousState = !!sessionStorage.getItem(PREVIOUS_B2N_STATE_STORAGE_KEY); - - if (previousState) { - this.restorePreviousState(); - this.nativeFallbacks = new NativeFallbacks(this); - - return; - } - - this._appVersion = - nativeParams && isValidVersionFormat(nativeParams?.appVersion) - ? nativeParams.appVersion - : '0.0.0'; - this._iosAppId = this.getIosAppId(nativeParams?.iosAppId); - this._theme = nativeParams?.theme === 'dark' ? 'dark' : 'light'; - this._originalWebviewParams = nativeParams?.originalWebviewParams || ''; - this._nativeNavigationAndTitle = new NativeNavigationAndTitle( - this, - nativeParams ? nativeParams.nextPageId : null, - nativeParams?.title, - handleRedirect, - ); - this._handleRedirect = handleRedirect; - - this.nextPageId = nativeParams ? nativeParams.nextPageId : null; - this.nativeFallbacks = new NativeFallbacks(this); - } - - get theme() { - return this._theme; - } - - get appVersion() { - return this._appVersion; - } - - get iosAppId() { - return this._iosAppId; - } - - get nativeNavigationAndTitle() { - return this._nativeNavigationAndTitle; - } - - get originalWebviewParams() { - return this._originalWebviewParams; - } - - /** - * Метод, проверяющий, можно ли использовать нативную функциональность в текущей версии приложения. - * - * @param feature Название функциональности, которую нужно проверить. - */ - public canUseNativeFeature(feature: NativeFeatureKey) { - const { nativeFeatureFtKey, fromVersion } = - nativeFeaturesFromVersion[this.environment][feature]; - - if (nativeFeatureFtKey && !this.nativeFeaturesFts[nativeFeatureFtKey]) { - return false; - } - - return this.isCurrentVersionHigherOrEqual(fromVersion); - } - - /** - * Метод, отправляющий сигнал нативу, что нужно закрыть текущее вебвью. - */ - // eslint-disable-next-line class-methods-use-this - public closeWebview() { - const originalPageUrl = new URL(window.location.href); - - originalPageUrl.searchParams.set(CLOSE_WEBVIEW_SEARCH_KEY, CLOSE_WEBVIEW_SEARCH_VALUE); - window.location.href = originalPageUrl.toString(); - } - - /** - * Сравнивает текущую версию приложения с переданной. - * - * @param versionToCompare Версия, с которой нужно сравнить текущую. - * @returns `true` – текущая версия больше или равняется переданной, - * `false` – текущая версия ниже. - */ - public isCurrentVersionHigherOrEqual(versionToCompare: string) { - if (!isValidVersionFormat(versionToCompare)) { - return false; - } - - const matchPattern = /(\d+)\.(\d+)\.(\d+)/; - - type ExpectedTupple = [string, string, string, string]; - - const [, ...appVersionComponents] = this._appVersion.match(matchPattern) as ExpectedTupple; // Формат версии проверен в конструкторе, можно смело убирать `null` из типа. - - const [, ...versionToCompareComponents] = versionToCompare.match( - matchPattern, - ) as ExpectedTupple; - - for (let i = 0; i < appVersionComponents.length; i++) { - if (appVersionComponents[i] !== versionToCompareComponents[i]) { - return appVersionComponents[i] >= versionToCompareComponents[i]; - } - } - - return true; - } - - /** - * Сохраняет текущее состояние BridgeToNative в sessionStorage. - * Так же сохраняет текущее состояние nativeNavigationAndTitle. - */ - private saveCurrentState() { - // В nativeNavigationAndTitle этот метод отмечен модификатором доступа private дабы не торчал наружу, но тут его нужно вызвать - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - this._nativeNavigationAndTitle.saveCurrentState(); - - const currentState: PreviousBridgeToNativeState = { - appVersion: this._appVersion, - theme: this._theme, - nextPageId: this.nextPageId, - originalWebviewParams: this._originalWebviewParams || '', - iosAppId: this._iosAppId, - }; - - sessionStorage.setItem(PREVIOUS_B2N_STATE_STORAGE_KEY, JSON.stringify(currentState)); - } - - /** - * Возвращает схему приложения в iOS окружении, на основе версии. - * - * @param knownIosAppId Тип iOS приложения, если он известен. - * @returns Тип приложения, `undefined` для Android окружения. - */ - private getIosAppId(knownIosAppId?: string) { - if (this.environment !== 'ios') { - return undefined; - } - - if (knownIosAppId) { - return knownIosAppId; - } - - const keys = Object.keys(versionToIosAppId); - - const rightKey = - [...keys].reverse().find((version) => this.isCurrentVersionHigherOrEqual(version)) || - keys[0]; - - return atob(versionToIosAppId[rightKey as keyof typeof versionToIosAppId]); - } - - /** - * Восстанавливает свое предыдущее состояние из sessionStorage - */ - private restorePreviousState() { - const previousState: PreviousBridgeToNativeState = JSON.parse( - sessionStorage.getItem(PREVIOUS_B2N_STATE_STORAGE_KEY) || '', - ); - - this._appVersion = previousState.appVersion; - this._iosAppId = previousState.iosAppId; - this._theme = previousState.theme; - this._originalWebviewParams = previousState.originalWebviewParams; - this.nextPageId = previousState.nextPageId; - this._nativeNavigationAndTitle = new NativeNavigationAndTitle( - this, - previousState.nextPageId, - '', - this._handleRedirect, - ); - - sessionStorage.removeItem(PREVIOUS_B2N_STATE_STORAGE_KEY); - } -} +export { BridgeToNative } from './bridge-to-native'; +export { NativeParams, Theme, Environment, NativeFeatureKey, PdfType } from './types'; diff --git a/src/native-fallbacks.ts b/src/native-fallbacks.ts index 004d902..48e2461 100644 --- a/src/native-fallbacks.ts +++ b/src/native-fallbacks.ts @@ -1,6 +1,6 @@ -import type { BridgeToNative } from '.'; import { PdfType } from './types'; import { getUrlInstance } from './utils'; +import type { BridgeToNative } from './bridge-to-native'; /** * Класс содержит реализацию обходных путей для веб-фич, которые не работают в нативном-вебвью. diff --git a/src/native-navigation-and-title.ts b/src/native-navigation-and-title.ts index 1f96511..5bdd8b7 100644 --- a/src/native-navigation-and-title.ts +++ b/src/native-navigation-and-title.ts @@ -1,19 +1,13 @@ -import { PREVIOUS_NATIVE_NAVIGATION_AND_TITLE_STATE_STORAGE_KEY } from './constants'; -import { PreviousNativeNavigationAndTitleState } from './types'; +import { + DEEP_LINK_PATTERN, + PREVIOUS_NATIVE_NAVIGATION_AND_TITLE_STATE_STORAGE_KEY, +} from './constants'; +import { HandleRedirect, PreviousNativeNavigationAndTitleState, SyncPurpose } from './types'; import { extractAppNameRouteAndQuery } from './utils'; -import { BridgeToNative } from '.'; - -type SyncPurpose = 'initialization' | 'navigation' | 'title-replacing'; - -export type HandleRedirect = ( - appName: string, - path?: string, - params?: Record, -) => void; +import { BridgeToNative } from './bridge-to-native'; /** - * Класс, отвечающий за взаимодействие с нативными элементами в приложении – заголовком - * и нативной кнопкой назад. + * Класс, отвечающий за взаимодействие с нативными элементами в приложении – заголовком и нативной кнопкой назад. */ export class NativeNavigationAndTitle { private nativeHistoryStack: string[] = ['']; @@ -24,7 +18,7 @@ export class NativeNavigationAndTitle { // Просто, чтобы не слать одинаковые сигналы в приложение. private lastSetPageSettingsParams = ''; - private handleWindowRedirect: HandleRedirect; + private readonly _handleWindowRedirect: HandleRedirect; constructor( private b2n: BridgeToNative, @@ -33,7 +27,7 @@ export class NativeNavigationAndTitle { handleWindowRedirect: HandleRedirect, ) { this.handleBack = this.handleBack.bind(this); - this.handleWindowRedirect = handleWindowRedirect; + this._handleWindowRedirect = handleWindowRedirect; const previousState = !!sessionStorage.getItem( PREVIOUS_NATIVE_NAVIGATION_AND_TITLE_STATE_STORAGE_KEY, ); @@ -57,15 +51,15 @@ export class NativeNavigationAndTitle { /** * Метод, вызывающий history.go(-колл. шагов назад) и модифицирует внутреннее - * состояние, чтобы в дальнейшем зарегистририровать этот переход в приложении. + * состояние, чтобы в дальнейшем зарегистрировать этот переход в приложении. * * @param stepsNumber Количество шагов назад. * Возможно передача как положительного, так и отрицательного числа. * 0 будет проигнорирован. - * @param autocloseWebview Флаг – закрывать ли вебвью автоматически, - * если переданное кол-во шагов будет больше, чем записей в истории. + * @param autoCloseWebview Флаг – закрывать ли вебвью автоматически, + * если переданное кол-во шагов будет больше чем записей в истории. */ - public goBackAFewSteps(stepsNumber: number, autocloseWebview = false) { + public goBackAFewSteps(stepsNumber: number, autoCloseWebview = false) { if (!stepsNumber) { return; } @@ -74,7 +68,7 @@ export class NativeNavigationAndTitle { const maxStepsToBack = this.nativeHistoryStack.length - 1; if (stepsToBack > maxStepsToBack) { - if (autocloseWebview) { + if (autoCloseWebview) { this.b2n.closeWebview(); return; @@ -118,7 +112,7 @@ export class NativeNavigationAndTitle { params?: Record, ) { if (appName) { - this.handleWindowRedirect(appName, path, params); + this._handleWindowRedirect(appName, path, params); } else { const { appName: extractedAppName, @@ -126,7 +120,7 @@ export class NativeNavigationAndTitle { query: extractedQuery, } = extractAppNameRouteAndQuery(pageTitleOrPath); - this.handleWindowRedirect(extractedAppName, extractedPath, extractedQuery); + this._handleWindowRedirect(extractedAppName, extractedPath, extractedQuery); } const title = appName ? pageTitleOrPath : ''; @@ -137,7 +131,7 @@ export class NativeNavigationAndTitle { /** * Информирует натив, что веб находится на первом экране (сбрасывает историю переходов, не влияя на браузерную - * историю), а значит следующее нажатие на кнопку "Назад" в нативне закроет вебвью. + * историю), а значит следующее нажатие на кнопку "Назад" в нативе закроет вебвью. * * @param pageTitle Заголовок, который нужно отрисовать в нативе. */ @@ -159,17 +153,19 @@ export class NativeNavigationAndTitle { } /** - * Метод для открытия второго web приложения в рамках - * одной вебвью сессии - * сохраняет все текущее состояние текущего экземпляра bridgeToAm и AmNavigationAndTitle в sessionStorage, а + * Метод для открытия второго web приложения в рамках одной вебвью сессии. + * Сохраняет все текущее состояние текущего экземпляра bridgeToAm и AmNavigationAndTitle в sessionStorage, а * так же наполняет url необходимыми query параметрами. Работает только в Android окружении. * В IOS окружении будет открыто новое webview поверх текущего. - * @param url адрес второго web приложения к которому перед переходом на него будут добавлены + * + * @param url адрес второго web приложения, к которому перед переходом на него будут добавлены * все initial query параметры от натива и параметр nextPageId (Android) */ public navigateInsideASharedSession(url: string) { if (this.b2n.environment === 'ios') { - this.b2n.nativeFallbacks.visitExternalResource(url); + const nativeDeeplink = `/webFeature?type=recommendation&url=${encodeURIComponent(url)}`; + + this.handleNativeDeeplink(nativeDeeplink); return; } @@ -183,20 +179,46 @@ export class NativeNavigationAndTitle { } /** - * ПОКА НЕ ИСПОЛЬЗОВАТЬ В ПРОДЕ (МЕТОД НЕ РАБОТАЕТ КОРРЕКТНО НА ANDROID) - * НА ANDROID ПРИ ПЕРЕЗАГРУЗКЕ СТРАНИЦЫ В ИСТРИЮ ЛОЖИТСЯ + ЕЩЕ ОДИН ЛИШНИЙ ЭЛЕМЕНТ - * Данный метод будет дорабатываться отдельной задачей * Безопасный способ для перезагрузки страницы. - * Производит предварительное сохранение текущего состояния - * BridgeToNative и NativeNavigationAndTitle перед вызовом location.reload() */ - public reloadPage() { + public pseudoReloadPage() { // В b2n этот метод отмечен модификатором доступа private, но тут его нужно вызвать // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - this.b2n.saveCurrentState(); + this.handleRedirect(this.b2n._blankPagePath); + + this.goBack(); + } + + /** + * Вызывает обработчик deeplinks в нативе (АМ) и передает туда переданный deeplink. + * На Android текущее webview будет закрыто из-за технических особенностей. + * На IOS нативная фича открывается в следующем по стеку экране и при выходе из нее пользователь вернется обратно в webview. + * На IOS есть возможность закрыть webview перед открытием нативной фичи, передав второй параметр closeIOSWebviewBeforeCallNativeDeeplinkHandler = true + * @param deeplink диплинк на нативную АМ фичу в AM + * @param [closeIOSWebviewBeforeCallNativeDeeplinkHandler = false] закрыть текущее webview после открытия нативной фичи (применимо только для IOS на Android по техническим причинам webview всегда будет закрываться) + */ + public handleNativeDeeplink( + deeplink: string, + closeIOSWebviewBeforeCallNativeDeeplinkHandler = false, + ) { + const clearedDeeplinkPath = deeplink.replace(DEEP_LINK_PATTERN, ''); + + if (this.b2n.environment === 'ios') { + if (closeIOSWebviewBeforeCallNativeDeeplinkHandler) { + this.b2n.closeWebview(); - window.location.reload(); + setTimeout( + () => window.location.replace(`${this.b2n.iosAppId}://${clearedDeeplinkPath}`), + 0, + ); + + return; + } + window.location.replace(`${this.b2n.iosAppId}://${clearedDeeplinkPath}`); + } else { + window.location.replace(`alfabank://${clearedDeeplinkPath}`); + } } /** @@ -269,7 +291,7 @@ export class NativeNavigationAndTitle { const stackSize = this.nativeHistoryStack.length; // Нажимая на кнопку назад, можно дойти до "первой" страницы, - // в iOS для "первой" страницы не нужно слать `pageId` + // в iOS для "первой" страницы не нужно слать `pageId`. return this.b2n.environment === 'ios' && stackSize <= 1 ? null : stackSize; } @@ -401,7 +423,7 @@ export class NativeNavigationAndTitle { * рамках одной вебвью сессии * @param url - url иного веб приложения * @return подготовленная согласно контракту ссылка на иное веб приложение с initial query - * параметрами от нативе, а так же nextPageId + * параметрами от натива, а так же nextPageId. */ private prepareExternalLinkBeforeOpen(url: string) { const currentPageId = this.nativeHistoryStack.length; diff --git a/src/types.ts b/src/types.ts index 61e8b04..717a39e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,18 +9,13 @@ export type NativeParams = { originalWebviewParams: string; }; -type NativeFeatureFtKey = 'linksInBrowserAndroid' | 'linksInBrowserIos'; -export type NativeFeaturesFts = Record; - export type NativeFeatureKey = // Возможность работы с геолокацией. | 'geolocation' // Возможность открыть ссылку в браузере. | 'linksInBrowser'; -type NativeFeaturesParams = Readonly< - Record ->; +type NativeFeaturesParams = Readonly>; export type NativeFeaturesFromVersion = Readonly<{ android: NativeFeaturesParams; ios: NativeFeaturesParams; @@ -49,3 +44,13 @@ export type PreviousNativeNavigationAndTitleState = { nativeHistoryStack: string[]; title: string; }; + +export type SyncPurpose = 'initialization' | 'navigation' | 'title-replacing'; + +export type HandleRedirect = ( + appName: string, + path?: string, + params?: Record, +) => void; + +export type Theme = 'light' | 'dark'; diff --git a/test/index.test.ts b/test/index.test.ts index 4cab552..0541d3f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,8 +1,12 @@ /* eslint-disable @typescript-eslint/dot-notation -- отключено, чтобы можно было обращаться к приватным полям для их тестирования */ -import { BridgeToNative } from '../src'; -import { PREVIOUS_B2N_STATE_STORAGE_KEY } from '../src/constants'; -import { mockSessionStorage } from '../src/mock/mock-session-storage'; +import { BridgeToNative } from '../src/bridge-to-native'; +import { + CLOSE_WEBVIEW_SEARCH_KEY, + CLOSE_WEBVIEW_SEARCH_VALUE, + PREVIOUS_B2N_STATE_STORAGE_KEY, +} from '../src/constants'; +import { mockSessionStorage } from './mock/mock-session-storage'; import { WebViewWindow } from '../src/types'; const mockedNativeFallbacksInstance = {}; @@ -28,11 +32,6 @@ jest.mock('../src/native-navigation-and-title', () => ({ })); describe('BridgeToNative', () => { - const defaultAmFeaturesFts = { - linksInBrowserAndroid: true, - linksInBrowserIos: true, - }; - const defaultAmParams = { appVersion: '12.0.0', theme: 'light', @@ -50,10 +49,7 @@ describe('BridgeToNative', () => { nextPageId: null, }; - // const { getItem, setItem, removeItem } = mockSessionStorage({ - // [PREVIOUS_B2N_STATE_STORAGE_KEY]: savedBridgeToAmState, - // }); - const { getItem, setItem } = mockSessionStorage( + const { getItem, setItem, removeItem } = mockSessionStorage( PREVIOUS_B2N_STATE_STORAGE_KEY, savedBridgeToAmState, ); @@ -69,7 +65,7 @@ describe('BridgeToNative', () => { BridgeToNative.prototype['restorePreviousState'] = jest.fn(); - const inst = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const inst = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, title: 'Initial Title', }); @@ -84,11 +80,7 @@ describe('BridgeToNative', () => { describe('method `saveCurrentState`', () => { it('should save current state into sessionStorage and call `AmNavigationAndTitle.saveCurrentState`', () => { - const inst = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const inst = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); const currentB2amState = { appVersion: inst['appVersion'], @@ -109,27 +101,22 @@ describe('BridgeToNative', () => { describe('method `restorePreviousState`', () => { it('should get previous state from sessionStorage and restore it and cleared storage', () => { - // JSON.parse = jest.fn().mockImplementationOnce(() => savedBridgeToAmState); + JSON.parse = jest.fn(() => savedBridgeToAmState); - const inst = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const inst = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); inst['restorePreviousState'](); expect(getItem).toBeCalledWith(PREVIOUS_B2N_STATE_STORAGE_KEY); - // expect(inst['appVersion']).toBe(savedBridgeToAmState.appVersion); - // expect(inst['iosAppId']).toBe(savedBridgeToAmState.iosAppId); - // expect(inst['theme']).toBe(savedBridgeToAmState.theme); - // expect(inst['originalWebviewParams']).toBe( - // savedBridgeToAmState.originalWebviewParams, - // ); - // expect(inst['nextPageId']).toBe(savedBridgeToAmState.nextPageId); - // - // expect(removeItem).toBeCalledWith(PREVIOUS_B2N_STATE_STORAGE_KEY); + expect(inst['appVersion']).toBe(savedBridgeToAmState.appVersion); + expect(inst['iosAppId']).toBe(savedBridgeToAmState.iosAppId); + expect(inst['theme']).toBe(savedBridgeToAmState.theme); + expect(inst['originalWebviewParams']).toBe( + savedBridgeToAmState.originalWebviewParams, + ); + expect(inst['nextPageId']).toBe(savedBridgeToAmState.nextPageId); + expect(removeItem).toBeCalledWith(PREVIOUS_B2N_STATE_STORAGE_KEY); }); }); }); @@ -153,7 +140,7 @@ describe('BridgeToNative', () => { describe('constructor', () => { it('should pass `initialAmTitle` to `AmNavigationAndTitle` constructor', () => { - const inst = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const inst = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, title: 'Initial Title', }); @@ -169,12 +156,8 @@ describe('BridgeToNative', () => { describe('public props', () => { it('should save theme used by AM in `theme` property', () => { - const inst1 = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); - const inst2 = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const inst1 = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); + const inst2 = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, theme: 'dark', }); @@ -186,7 +169,7 @@ describe('BridgeToNative', () => { it('should save original AM query params in `originalWebviewParams` property', () => { const originalWebviewParamsExample = 'device_uuid=8441576F-A09F-41E9-89A7-EE1FA486C20A&device_id=2E32AFD5-F50B-4B2F-B758-CAE59DF2BF6C&applicationId=1842D0AA-0008-4941-93E0-4FD80E087841&device_os_version=com.aconcierge.app&device_app_version=iOS 16.1&scope=12.26.0&device_boot_time=openid mobile-bank'; - const inst = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const inst = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, originalWebviewParams: originalWebviewParamsExample, }); @@ -195,13 +178,12 @@ describe('BridgeToNative', () => { }); it('should save nextPageId in `nextPageId` property and send it into `AmNavigationAndTitle` constructor', () => { - const inst = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const inst = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, title: 'Test', nextPageId: 7, }); - // removeItem expect(inst['nextPageId']).toBe(7); expect(MockedNativeNavigationAndTitleConstructor).toBeCalledWith( inst, @@ -212,21 +194,13 @@ describe('BridgeToNative', () => { }); it('should provide `NativeFallbacks` instance', () => { - const inst = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const inst = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); expect(inst.nativeFallbacks).toEqual(mockedNativeFallbacksInstance); }); it('should provide `AmNavigationAndTitle` instance', () => { - const inst = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const inst = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); expect(inst.nativeNavigationAndTitle).toEqual( mockedNativeNavigationAndTitleInstance, @@ -239,31 +213,19 @@ describe('BridgeToNative', () => { }); it('should provide `AndroidBridge` property', () => { - const inst = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const inst = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); expect(inst.AndroidBridge).toEqual((window as WebViewWindow).Android); }); it('should set `environment` property correctly', () => { - const inst = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const inst = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); expect(inst.environment).toBe('android'); }); it('should not provide application type using `iosAppId` property', () => { - const ins = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const ins = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); expect(ins.iosAppId).not.toBeDefined(); }); @@ -271,21 +233,13 @@ describe('BridgeToNative', () => { describe('iOS environment', () => { it('should not provide `AndroidBridge` property', () => { - const ins = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const ins = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); expect(ins.AndroidBridge).not.toBeDefined(); }); it('should set `environment` property correctly', () => { - const ins = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const ins = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); expect(ins.environment).toBe('ios'); }); @@ -303,7 +257,7 @@ describe('BridgeToNative', () => { ])( 'should detect app scheme for version %s correctly and save it in `iosAppId` property', (appVersion, expected) => { - const ins = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const ins = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, appVersion, }); @@ -313,12 +267,12 @@ describe('BridgeToNative', () => { ); it('should use `iosAppId` parameter as value for `iosApplicationId` while parameter exists', () => { - const inst1 = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const inst1 = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, appVersion: '0.0.0', iosAppId: 'kittycash', }); - const inst2 = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const inst2 = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, appVersion: '12.22.0', iosAppId: 'kittycash', @@ -345,7 +299,7 @@ describe('BridgeToNative', () => { androidEnvFlag = true; } - const inst = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const inst = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, appVersion, }); @@ -353,27 +307,32 @@ describe('BridgeToNative', () => { expect(inst.canUseNativeFeature('linksInBrowser')).toBe(expected); }, ); + }); - it('should return `false` for feature while FT is off', () => { - const inst = new BridgeToNative( - { ...defaultAmFeaturesFts, linksInBrowserIos: false }, - mockedHandleRedirect, - { ...defaultAmParams, appVersion: '14.0.0' }, - ); + describe('method `closeWebview`', () => { + const testUrl = 'http://test.com'; - expect(inst.canUseNativeFeature('linksInBrowser')).toBeFalsy(); + window = Object.create(window); + Object.defineProperty(window, 'location', { + value: { + href: testUrl, + }, + writable: true, }); - }); - describe('method `closeWebview`', () => { - // В тестовом окружении дескриптор свойства `window.localtion.href` не configurable, - // поэтоу не проверить его изменение (метод просто присваивает новое значение этому свойству). + const inst = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); + + inst.closeWebview(); + expect(window.location.href).toBe( + `${testUrl}/?${CLOSE_WEBVIEW_SEARCH_KEY}=${CLOSE_WEBVIEW_SEARCH_VALUE}`, + ); }); describe('method `isCurrentVersionHigherOrEqual`', () => { it.each([ ['5.0.0', '0.0.0', true], ['0.0.0', '5.0.0', false], + ['0.0.1', 'unknown', false], ['5.0.0', '5.0.0', true], ['1.3.4', '1.2.4', true], ['1.3.4', '1.4.4', false], @@ -389,7 +348,7 @@ describe('BridgeToNative', () => { ])( 'should compare current version `%s` with `%s` and return `%s`', (currentVersion, versionToCompare, result) => { - const inst = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const inst = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, appVersion: currentVersion, @@ -404,11 +363,7 @@ describe('BridgeToNative', () => { it('should return `undefined` in Android environment', () => { androidEnvFlag = true; - const inst = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const inst = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); expect(inst['getIosAppId']()).toBeUndefined(); expect(inst['getIosAppId']('aconcierge')).toBeUndefined(); @@ -430,7 +385,7 @@ describe('BridgeToNative', () => { ])( 'should detect app scheme for version `%s` as `%s` while parameter is not passed', (version, appId) => { - const inst = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const inst = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, appVersion: version, }); @@ -440,7 +395,7 @@ describe('BridgeToNative', () => { ); it('should use app scheme from parameter', () => { - const inst = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const inst = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, appVersion: '1.0.0', }); diff --git a/test/integration.test.ts b/test/integration.test.ts index 6a1db7f..7546823 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1,13 +1,8 @@ /* eslint-disable @typescript-eslint/dot-notation -- отключено, чтобы можно было обращаться к приватным полям для их тестирования */ -import { BridgeToNative } from '../src'; +import { BridgeToNative } from '../src/bridge-to-native'; describe('BridgeToNative integration testing', () => { - const defaultAmFeaturesFts = { - linksInBrowserAndroid: true, - linksInBrowserIos: true, - }; - const defaultAmParams = { appVersion: '12.0.0', theme: 'light', @@ -57,7 +52,7 @@ describe('BridgeToNative integration testing', () => { }); it('should use AM interface correctly when moving forward and then backward', async () => { - const inst = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const inst = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, title: 'Initial Title', }); @@ -115,11 +110,7 @@ describe('BridgeToNative integration testing', () => { }); it('should act and use AM interface correctly when using `goBackAFewSteps`', async () => { - const inst = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const inst = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); const mockedCloseWebview = jest.fn(); inst.closeWebview = mockedCloseWebview; @@ -149,11 +140,7 @@ describe('BridgeToNative integration testing', () => { }); it('should act and use AM interface correctly when using `setInitialView`', async () => { - const inst = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const inst = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); const mockedCloseWebview = jest.fn(); inst.closeWebview = mockedCloseWebview; @@ -179,7 +166,7 @@ describe('BridgeToNative integration testing', () => { describe('iOS environment', () => { it('should use AM interface correctly when moving forward and then backward', async () => { - const inst = new BridgeToNative(defaultAmFeaturesFts, mockedHandleRedirect, { + const inst = new BridgeToNative(mockedHandleRedirect, '/', { ...defaultAmParams, title: 'Initial Title', }); @@ -245,11 +232,7 @@ describe('BridgeToNative integration testing', () => { }); it('should act and use AM interface correctly when using `goBackAFewSteps`', async () => { - const inst = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const inst = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); const mockedCloseWebview = jest.fn(); inst.closeWebview = mockedCloseWebview; @@ -281,11 +264,7 @@ describe('BridgeToNative integration testing', () => { }); it('should act and use AM interface correctly when using `setInitialView`', async () => { - const inst = new BridgeToNative( - defaultAmFeaturesFts, - mockedHandleRedirect, - defaultAmParams, - ); + const inst = new BridgeToNative(mockedHandleRedirect, '/', defaultAmParams); const mockedCloseWebview = jest.fn(); inst.closeWebview = mockedCloseWebview; diff --git a/src/mock/mock-session-storage.ts b/test/mock/mock-session-storage.ts similarity index 100% rename from src/mock/mock-session-storage.ts rename to test/mock/mock-session-storage.ts diff --git a/test/native-fallbacks.test.ts b/test/native-fallbacks.test.ts index 7134980..88b3b0e 100644 --- a/test/native-fallbacks.test.ts +++ b/test/native-fallbacks.test.ts @@ -1,17 +1,17 @@ -import type { BridgeToNative } from '../src'; +import type { BridgeToNative } from '../src/bridge-to-native'; import { nativeFeaturesFromVersion } from '../src/constants'; import { NativeFallbacks } from '../src/native-fallbacks'; import { PdfType } from '../src/types'; let androidEnvFlag = false; let iosAppId: string | undefined; -let linksInBrowserFeatureFlag = false; +let canOpenLinksInBrowser = false; let mockedHandleRedirect: any; let mockedSetInitialView: any; const mockedBridgeToAmInstance = { canUseNativeFeature() { - return linksInBrowserFeatureFlag; + return canOpenLinksInBrowser; }, get environment() { return androidEnvFlag ? 'android' : 'ios'; @@ -24,7 +24,7 @@ const mockedBridgeToAmInstance = { }, } as unknown as BridgeToNative; -jest.mock('../src', () => ({ +jest.mock('../src/bridge-to-native', () => ({ __esModule: true, BridgeToNative: function MockedBridgeToAmConstructor() { return mockedBridgeToAmInstance; @@ -54,7 +54,7 @@ describe('AmFallbacks', () => { afterEach(() => { androidEnvFlag = false; iosAppId = undefined; - linksInBrowserFeatureFlag = false; + canOpenLinksInBrowser = false; windowSpy.mockRestore(); jest.resetAllMocks(); @@ -68,7 +68,7 @@ describe('AmFallbacks', () => { "should return href with link with 'openInBrowser' query for '%s' app v. >= '%s'", (platform) => { androidEnvFlag = platform === 'android'; - linksInBrowserFeatureFlag = true; + canOpenLinksInBrowser = true; const inst = new NativeFallbacks(mockedBridgeToAmInstance); @@ -197,7 +197,7 @@ describe('AmFallbacks', () => { ['android', nativeFeaturesFromVersion.android.linksInBrowser.fromVersion], ])("should visit link with 'openInBrowser' query for '%s' app v. >= '%s'", (platform) => { androidEnvFlag = platform === 'android'; - linksInBrowserFeatureFlag = true; + canOpenLinksInBrowser = true; const inst = new NativeFallbacks(mockedBridgeToAmInstance); diff --git a/test/native-navigation-and-title.test.ts b/test/native-navigation-and-title.test.ts index 8c2c9dd..9a549d2 100644 --- a/test/native-navigation-and-title.test.ts +++ b/test/native-navigation-and-title.test.ts @@ -1,24 +1,28 @@ /* eslint-disable @typescript-eslint/dot-notation -- отключено, чтобы можно было обращаться к приватным полям для их тестирования */ -import type { BridgeToNative } from '../src'; +import type { BridgeToNative } from '../src/bridge-to-native'; import { PREVIOUS_NATIVE_NAVIGATION_AND_TITLE_STATE_STORAGE_KEY } from '../src/constants'; -import { mockSessionStorage } from '../src/mock/mock-session-storage'; +import { mockSessionStorage } from './mock/mock-session-storage'; import { NativeNavigationAndTitle } from '../src/native-navigation-and-title'; let androidEnvFlag = false; let mockedSetPageSettings: unknown; const mockedHandleRedirect = jest.fn(); -const mockedBridgeToAmInstance = { +const mockedBridgeToNativeInstance = { get AndroidBridge() { return androidEnvFlag ? { setPageSettings: mockedSetPageSettings } : undefined; }, get environment() { return androidEnvFlag ? 'android' : 'ios'; }, + get _blankPagePath() { + return '/blank?reload=true'; + }, get originalWebviewParams() { return 'title=superTitle'; }, + iosAppId: 'assistmekz', closeWebview: jest.fn(), saveCurrentState: jest.fn(), restorePreviousState: jest.fn(), @@ -32,21 +36,15 @@ Object.defineProperty(global, 'handleRedirect', { configurable: true, }); -jest.mock('../src', () => ({ +jest.mock('../src/bridge-to-native', () => ({ __esModule: true, BridgeToNative: function MockedBridgeToAmConstructor() { - return mockedBridgeToAmInstance; + return mockedBridgeToNativeInstance; }, })); describe('AmNavigationAndTitle', () => { describe('sessionStorage interaction', () => { - // const mockedSessionStorage = mockSessionStorage({ - // [PREVIOUS_NATIVE_NAVIGATION_AND_TITLE_STATE_STORAGE_KEY]: { - // nativeHistoryStack: ['page1', 'page2', 'lastPage'], - // title: 'lastPage', - // }, - // }); const mockedSessionStorage = mockSessionStorage( PREVIOUS_NATIVE_NAVIGATION_AND_TITLE_STATE_STORAGE_KEY, { @@ -62,7 +60,7 @@ describe('AmNavigationAndTitle', () => { NativeNavigationAndTitle.prototype['restorePreviousState'] = jest.fn(); const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -82,7 +80,7 @@ describe('AmNavigationAndTitle', () => { describe('method `handleBack`', () => { it('should restore previous state and unblock itself if property isPopstateListenerBlocked=true', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, 1, '', mockedHandleRedirect, @@ -93,7 +91,7 @@ describe('AmNavigationAndTitle', () => { PREVIOUS_NATIVE_NAVIGATION_AND_TITLE_STATE_STORAGE_KEY, ); - expect(mockedBridgeToAmInstance['restorePreviousState']).toBeCalled(); + expect(mockedBridgeToNativeInstance['restorePreviousState']).toBeCalled(); }); }); @@ -109,7 +107,7 @@ describe('AmNavigationAndTitle', () => { title: titleExample, })); const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, 1, '', mockedHandleRedirect, @@ -142,7 +140,7 @@ describe('AmNavigationAndTitle', () => { }; const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, 1, '', mockedHandleRedirect, @@ -222,7 +220,7 @@ describe('AmNavigationAndTitle', () => { NativeNavigationAndTitle.prototype['supportSharedSession'] = jest.fn(); const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, 2, 'example', mockedHandleRedirect, @@ -236,7 +234,7 @@ describe('AmNavigationAndTitle', () => { it('should call `setInitialView` method', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -247,7 +245,7 @@ describe('AmNavigationAndTitle', () => { it('should set initial AM title', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, 'My Title', mockedHandleRedirect, @@ -267,7 +265,7 @@ describe('AmNavigationAndTitle', () => { [-5, -4], ])('should work correctly with `%p` as argument', (stepsNumber, expected) => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -287,7 +285,7 @@ describe('AmNavigationAndTitle', () => { it('should work correctly with `0` as argument', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -309,7 +307,7 @@ describe('AmNavigationAndTitle', () => { describe('method `handleRedirect`', () => { it('should pass 2th, 3th and 4th params to `handleRedirect`', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -325,7 +323,7 @@ describe('AmNavigationAndTitle', () => { it('should work with 1 parameter', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -356,7 +354,7 @@ describe('AmNavigationAndTitle', () => { it('should modify inner history stack correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -373,7 +371,7 @@ describe('AmNavigationAndTitle', () => { it('should call `syncHistoryWithNative` method correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -391,7 +389,7 @@ describe('AmNavigationAndTitle', () => { describe('method `setInitialView`', () => { it('should reset inner history stack', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -408,7 +406,7 @@ describe('AmNavigationAndTitle', () => { it('should call `syncHistoryWithNative` method correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -424,7 +422,7 @@ describe('AmNavigationAndTitle', () => { it('should refresh popstate event listener when method is called', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -444,7 +442,7 @@ describe('AmNavigationAndTitle', () => { describe('method `setTitle`', () => { it('should modify inner history stack correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -460,7 +458,7 @@ describe('AmNavigationAndTitle', () => { it('should call `syncHistoryWithNative` method correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -482,7 +480,7 @@ describe('AmNavigationAndTitle', () => { it('should calculate pageId for `initialization` purpose correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -494,7 +492,7 @@ describe('AmNavigationAndTitle', () => { it('should calculate pageId for `navigation` purpose correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -514,7 +512,7 @@ describe('AmNavigationAndTitle', () => { it('should calculate pageId for `title-replacing` purpose correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -532,7 +530,7 @@ describe('AmNavigationAndTitle', () => { describe('iOS environment', () => { it('should calculate pageId for `initialization` purpose correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -544,7 +542,7 @@ describe('AmNavigationAndTitle', () => { it('should calculate pageId for `navigation` purpose correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -564,7 +562,7 @@ describe('AmNavigationAndTitle', () => { it('should calculate pageId for `title-replacing` purpose correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -587,23 +585,23 @@ describe('AmNavigationAndTitle', () => { describe('method `handleBack`', () => { it('should close webview when its inner history stack is empty', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, ); inst['handleBack'](); - expect(mockedBridgeToAmInstance.closeWebview).toBeCalledTimes(1); + expect(mockedBridgeToNativeInstance.closeWebview).toBeCalledTimes(1); inst['nativeHistoryStack'] = ['Title 1', 'Title 2']; inst['handleBack'](); - expect(mockedBridgeToAmInstance.closeWebview).toBeCalledTimes(1); + expect(mockedBridgeToNativeInstance.closeWebview).toBeCalledTimes(1); }); it('should modify inner history stack correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -613,16 +611,16 @@ describe('AmNavigationAndTitle', () => { inst['handleBack'](); expect(inst['nativeHistoryStack']).toEqual(['Title 1', 'Title 2']); - expect(mockedBridgeToAmInstance.closeWebview).not.toBeCalled(); + expect(mockedBridgeToNativeInstance.closeWebview).not.toBeCalled(); inst['handleBack'](); expect(inst['nativeHistoryStack']).toEqual(['Title 1']); - expect(mockedBridgeToAmInstance.closeWebview).not.toBeCalled(); + expect(mockedBridgeToNativeInstance.closeWebview).not.toBeCalled(); }); it('should modify inner history stack after multiple steps back', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -636,7 +634,7 @@ describe('AmNavigationAndTitle', () => { it('should call `syncHistoryWithNative` method correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -660,7 +658,7 @@ describe('AmNavigationAndTitle', () => { it('should reset `numOfBackSteps`', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -676,7 +674,7 @@ describe('AmNavigationAndTitle', () => { describe('method `syncHistoryWithNative`', () => { it('should pass `purpose` argument to `getNativePageId` method', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -702,7 +700,7 @@ describe('AmNavigationAndTitle', () => { it('should use AM interface with `pageId` correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -723,7 +721,7 @@ describe('AmNavigationAndTitle', () => { it('should use AM interface without `pageId` correctly', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -736,7 +734,7 @@ describe('AmNavigationAndTitle', () => { it('should not send two identical signals in a row to AM', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -752,7 +750,7 @@ describe('AmNavigationAndTitle', () => { describe('iOS environment', () => { it('should use correctly AM interface with `pageId`', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -773,7 +771,7 @@ describe('AmNavigationAndTitle', () => { it('should use correctly AM interface without `pageId`', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -788,7 +786,7 @@ describe('AmNavigationAndTitle', () => { it('should not send two identical signals in a row to AM', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -808,7 +806,7 @@ describe('AmNavigationAndTitle', () => { const mockedReassignPopstateListener = jest.fn(); const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -828,7 +826,7 @@ describe('AmNavigationAndTitle', () => { describe('method `reassignPopstateListener`', () => { it('should remove and add listener in correct priority', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, null, '', mockedHandleRedirect, @@ -847,7 +845,7 @@ describe('AmNavigationAndTitle', () => { describe('method `prepareExternalLinkBeforeOpen`', () => { const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, 44, '', mockedHandleRedirect, @@ -883,7 +881,7 @@ describe('AmNavigationAndTitle', () => { ); const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, 1, '', mockedHandleRedirect, @@ -906,35 +904,117 @@ describe('AmNavigationAndTitle', () => { it('should call b2n.nativeFallbacks.visitExternalResource if current environment IOS', () => { const externalUrlExample = 'https://ya.ru/'; + const mockedHandleNativeDeeplink = jest.fn(); + const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, 1, '', mockedHandleRedirect, ); + inst['handleNativeDeeplink'] = mockedHandleNativeDeeplink; + inst.navigateInsideASharedSession(externalUrlExample); - expect(inst['b2n']['nativeFallbacks']['visitExternalResource']).toBeCalledWith( - externalUrlExample, + expect(mockedHandleNativeDeeplink).toBeCalledWith( + `/webFeature?type=recommendation&url=${encodeURIComponent( + externalUrlExample, + )}`, ); }); }); }); - describe('method `reloadPage`', () => { - it('should call `b2n.saveCurrentState` and location.reload', () => { + describe('method `pseudoReloadPage`', () => { + it('should call `handleRedirect` and `goBack`', () => { + const mockedGoBack = jest.fn(); + const inst = new NativeNavigationAndTitle( - mockedBridgeToAmInstance, + mockedBridgeToNativeInstance, 44, '', mockedHandleRedirect, ); - inst.reloadPage(); + inst['goBack'] = mockedGoBack; + + inst.pseudoReloadPage(); + + expect(mockedHandleRedirect).toBeCalledWith('blank', '', { reload: 'true' }); + expect(mockedGoBack).toBeCalled(); + }); + }); + + describe('method `handleNativeDeeplink`', () => { + const root = atob('YWxmYWJhbms6'); + + describe('Android environment', () => { + beforeEach(() => { + androidEnvFlag = true; + }); + + it.each([ + [ + 'webFeature?type=recommendation&url=https%3A%2F%2Ftemplate.app', + `${root}//webFeature?type=recommendation&url=https%3A%2F%2Ftemplate.app`, + ], + [`${root}///dashboard/deeplink_template`, `${root}//deeplink_template`], + [`${root}///deeplink_template`, `${root}//deeplink_template`], + [`${root}//deeplink_template`, `${root}//deeplink_template`], + ['/deeplink_template', `${root}//deeplink_template`], + ])( + 'should modify input deeplink `%s` and call locationReplace with `%s`', + (deeplink, expectedValue) => { + const inst = new NativeNavigationAndTitle( + mockedBridgeToNativeInstance, + null, + '', + mockedHandleRedirect, + ); + + inst['handleNativeDeeplink'](deeplink); + + expect(mockedLocationReplace).toBeCalledWith(expectedValue); + }, + ); + }); + + describe('IOS environment', () => { + beforeEach(() => { + androidEnvFlag = false; + jest.useFakeTimers(); + }); - expect(inst['b2n']['saveCurrentState']).toBeCalled(); - expect(mockedLocationReload).toBeCalled(); + it.each([ + [ + 'webFeature?type=recommendation&url=https%3A%2F%2Ftemplate.app', + 'assistmekz://webFeature?type=recommendation&url=https%3A%2F%2Ftemplate.app', + ], + [`${root}///dashboard/deeplink_template`, 'assistmekz://deeplink_template'], + [`${root}///deeplink_template`, 'assistmekz://deeplink_template'], + [`${root}//deeplink_template`, 'assistmekz://deeplink_template'], + ['/deeplink_template', 'assistmekz://deeplink_template'], + ])( + 'should modify input deeplink `%s` and call locationReplace with `%s`', + (deeplink, expectedValue) => { + const inst = new NativeNavigationAndTitle( + mockedBridgeToNativeInstance, + null, + '', + mockedHandleRedirect, + ); + + inst['handleNativeDeeplink'](deeplink); + + expect(mockedLocationReplace).toBeCalledWith(expectedValue); + + inst['handleNativeDeeplink'](deeplink, true); + + expect(mockedLocationReplace).toBeCalledWith(expectedValue); + expect(inst['b2n']['closeWebview']).toBeCalled(); + }, + ); }); }); }); diff --git a/test/utils.test.ts b/test/utils.test.ts index 74e8b2f..6d1086e 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,8 +1,4 @@ -import { - extractAppNameRouteAndQuery, - getUrlInstance, - isValidVersionFormat, -} from '../src/utils'; +import { extractAppNameRouteAndQuery, getUrlInstance, isValidVersionFormat } from '../src/utils'; describe('extractAppNameRouteAndQuery', () => { it('should extract app-name without path and query', () => { diff --git a/tsconfig.build.json b/tsconfig.build.json index 533eb6c..be81d7e 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -7,5 +7,5 @@ "composite": false, "removeComments": false }, - "exclude": ["test/**/*.*", "src/mock/*.*"] + "exclude": ["test/**/*.*"] }