diff --git a/src/domain/OperatingSystem.ts b/src/domain/OperatingSystem.ts index aad33c68..29bc7332 100644 --- a/src/domain/OperatingSystem.ts +++ b/src/domain/OperatingSystem.ts @@ -4,10 +4,34 @@ export enum OperatingSystem { Linux, KaiOS, ChromeOS, - BlackBerryOS, - BlackBerry, - BlackBerryTabletOS, Android, iOS, + iPadOS, + + /** + * Legacy: Released in 1999, discontinued in 2013, succeeded by BlackBerry10. + */ + BlackBerryOS, + + /** + * Legacy: Released in 2013, discontinued in 2015, succeeded by {@link OperatingSystem.Android}. + */ + BlackBerry10, + + /** + * Legacy: Released in 2010, discontinued in 2017, + * succeeded by {@link OperatingSystem.Windows10Mobile}. + */ WindowsPhone, + + /** + * Legacy: Released in 2015, discontinued in 2017, succeeded by {@link OperatingSystem.Android}. + */ + Windows10Mobile, + + /** + * Also known as "BlackBerry PlayBook OS" + * Legacy: Released in 2011, discontinued in 2014, succeeded by {@link OperatingSystem.Android}. + */ + BlackBerryTabletOS, } diff --git a/src/infrastructure/Log/WindowInjectedLogger.ts b/src/infrastructure/Log/WindowInjectedLogger.ts index e863cadd..cf4e172a 100644 --- a/src/infrastructure/Log/WindowInjectedLogger.ts +++ b/src/infrastructure/Log/WindowInjectedLogger.ts @@ -5,7 +5,7 @@ export class WindowInjectedLogger implements Logger { private readonly logger: Logger; constructor(windowVariables: WindowVariables | undefined | null = window) { - if (!windowVariables) { // do not trust strict null checks for global objects + if (!windowVariables) { // do not trust strictNullChecks for global objects throw new Error('missing window'); } if (!windowVariables.log) { diff --git a/src/infrastructure/RuntimeEnvironment/BrowserOs/BrowserCondition.ts b/src/infrastructure/RuntimeEnvironment/BrowserOs/BrowserCondition.ts new file mode 100644 index 00000000..ba1f6b04 --- /dev/null +++ b/src/infrastructure/RuntimeEnvironment/BrowserOs/BrowserCondition.ts @@ -0,0 +1,16 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; + +export enum TouchSupportExpectation { + MustExist, + MustNotExist, +} + +export interface BrowserCondition { + readonly operatingSystem: OperatingSystem; + + readonly existingPartsInSameUserAgent: readonly string[]; + + readonly notExistingPartsInUserAgent?: readonly string[]; + + readonly touchSupport?: TouchSupportExpectation; +} diff --git a/src/infrastructure/RuntimeEnvironment/BrowserOs/BrowserConditions.ts b/src/infrastructure/RuntimeEnvironment/BrowserOs/BrowserConditions.ts new file mode 100644 index 00000000..26eda7fc --- /dev/null +++ b/src/infrastructure/RuntimeEnvironment/BrowserOs/BrowserConditions.ts @@ -0,0 +1,86 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { BrowserCondition, TouchSupportExpectation } from './BrowserCondition'; + +// They include "Android", "iPhone" in their user agents. +const WindowsMobileIdentifiers: readonly string[] = [ + 'Windows Phone', + 'Windows Mobile', +] as const; + +export const BrowserConditions: readonly BrowserCondition[] = [ + { + operatingSystem: OperatingSystem.KaiOS, + existingPartsInSameUserAgent: ['KAIOS'], + }, + { + operatingSystem: OperatingSystem.ChromeOS, + existingPartsInSameUserAgent: ['CrOS'], + }, + { + operatingSystem: OperatingSystem.BlackBerryOS, + existingPartsInSameUserAgent: ['BlackBerry'], + }, + { + operatingSystem: OperatingSystem.BlackBerryTabletOS, + existingPartsInSameUserAgent: ['RIM Tablet OS'], + }, + { + operatingSystem: OperatingSystem.BlackBerry10, + existingPartsInSameUserAgent: ['BB10'], + }, + { + operatingSystem: OperatingSystem.Android, + existingPartsInSameUserAgent: ['Android'], + notExistingPartsInUserAgent: [...WindowsMobileIdentifiers], + }, + { + operatingSystem: OperatingSystem.Android, + existingPartsInSameUserAgent: ['Adr'], + notExistingPartsInUserAgent: [...WindowsMobileIdentifiers], + }, + { + operatingSystem: OperatingSystem.iOS, + existingPartsInSameUserAgent: ['iPhone'], + notExistingPartsInUserAgent: [...WindowsMobileIdentifiers], + }, + { + operatingSystem: OperatingSystem.iOS, + existingPartsInSameUserAgent: ['iPod'], + }, + { + operatingSystem: OperatingSystem.iPadOS, + existingPartsInSameUserAgent: ['iPad'], + // On Safari, only for older iPads running ≤ iOS 12 reports `iPad` + // Other browsers report `iPad` both for older devices (≤ iOS 12) and newer (≥ iPadOS 13) + // We detect all as `iPadOS` for simplicity. + }, + { + operatingSystem: OperatingSystem.iPadOS, + existingPartsInSameUserAgent: ['Macintosh'], // Reported by Safari on iPads running ≥ iPadOS 13 + touchSupport: TouchSupportExpectation.MustExist, // Safari same user agent as desktop macOS + }, + { + operatingSystem: OperatingSystem.Linux, + existingPartsInSameUserAgent: ['Linux'], + notExistingPartsInUserAgent: ['Android', 'Adr'], + }, + { + operatingSystem: OperatingSystem.Windows, + existingPartsInSameUserAgent: ['Windows'], + notExistingPartsInUserAgent: [...WindowsMobileIdentifiers], + }, + ...['Windows Phone OS', 'Windows Phone 8'].map((userAgentPart) => ({ + operatingSystem: OperatingSystem.WindowsPhone, + existingPartsInSameUserAgent: [userAgentPart], + })), + ...['Windows Mobile', 'Windows Phone 10'].map((userAgentPart) => ({ + operatingSystem: OperatingSystem.Windows10Mobile, + existingPartsInSameUserAgent: [userAgentPart], + })), + { + operatingSystem: OperatingSystem.macOS, + existingPartsInSameUserAgent: ['Macintosh'], + notExistingPartsInUserAgent: ['like Mac OS X'], // Eliminate iOS and iPadOS for Safari + touchSupport: TouchSupportExpectation.MustNotExist, // Distinguish from iPadOS for Safari + }, +] as const; diff --git a/src/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector.ts b/src/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector.ts index 3022c3d3..a721e5b1 100644 --- a/src/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector.ts +++ b/src/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector.ts @@ -1,57 +1,10 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; -import { DetectorBuilder } from './DetectorBuilder'; -import { IBrowserOsDetector } from './IBrowserOsDetector'; -export class BrowserOsDetector implements IBrowserOsDetector { - private readonly detectors = BrowserDetectors; - - public detect(userAgent: string): OperatingSystem | undefined { - if (!userAgent) { - return undefined; - } - for (const detector of this.detectors) { - const os = detector.detect(userAgent); - if (os !== undefined) { - return os; - } - } - return undefined; - } +export interface BrowserEnvironment { + readonly isTouchSupported: boolean; + readonly userAgent: string; } -// Reference: https://github.com/keithws/browser-report/blob/master/index.js#L304 -const BrowserDetectors = [ - define(OperatingSystem.KaiOS, (b) => b - .mustInclude('KAIOS')), - define(OperatingSystem.ChromeOS, (b) => b - .mustInclude('CrOS')), - define(OperatingSystem.BlackBerryOS, (b) => b - .mustInclude('BlackBerry')), - define(OperatingSystem.BlackBerryTabletOS, (b) => b - .mustInclude('RIM Tablet OS')), - define(OperatingSystem.BlackBerry, (b) => b - .mustInclude('BB10')), - define(OperatingSystem.Android, (b) => b - .mustInclude('Android').mustNotInclude('Windows Phone')), - define(OperatingSystem.Android, (b) => b - .mustInclude('Adr').mustNotInclude('Windows Phone')), - define(OperatingSystem.iOS, (b) => b - .mustInclude('like Mac OS X')), - define(OperatingSystem.Linux, (b) => b - .mustInclude('Linux').mustNotInclude('Android').mustNotInclude('Adr')), - define(OperatingSystem.Windows, (b) => b - .mustInclude('Windows').mustNotInclude('Windows Phone')), - define(OperatingSystem.WindowsPhone, (b) => b - .mustInclude('Windows Phone')), - define(OperatingSystem.macOS, (b) => b - .mustInclude('OS X').mustNotInclude('Android').mustNotInclude('like Mac OS X')), -]; - -function define( - os: OperatingSystem, - applyRules: (builder: DetectorBuilder) => DetectorBuilder, -): IBrowserOsDetector { - const builder = new DetectorBuilder(os); - applyRules(builder); - return builder.build(); +export interface BrowserOsDetector { + detect(environment: BrowserEnvironment): OperatingSystem | undefined; } diff --git a/src/infrastructure/RuntimeEnvironment/BrowserOs/ConditionBasedOsDetector.ts b/src/infrastructure/RuntimeEnvironment/BrowserOs/ConditionBasedOsDetector.ts new file mode 100644 index 00000000..f7f28261 --- /dev/null +++ b/src/infrastructure/RuntimeEnvironment/BrowserOs/ConditionBasedOsDetector.ts @@ -0,0 +1,92 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { assertInRange } from '@/application/Common/Enum'; +import { BrowserEnvironment, BrowserOsDetector } from './BrowserOsDetector'; +import { BrowserCondition, TouchSupportExpectation } from './BrowserCondition'; +import { BrowserConditions } from './BrowserConditions'; + +export class ConditionBasedOsDetector implements BrowserOsDetector { + constructor(private readonly conditions: readonly BrowserCondition[] = BrowserConditions) { + validateConditions(conditions); + } + + public detect(environment: BrowserEnvironment): OperatingSystem | undefined { + if (!environment.userAgent) { + return undefined; + } + for (const condition of this.conditions) { + if (satisfiesCondition(condition, environment)) { + return condition.operatingSystem; + } + } + return undefined; + } +} + +function satisfiesCondition( + condition: BrowserCondition, + browserEnvironment: BrowserEnvironment, +): boolean { + const { userAgent } = browserEnvironment; + if (condition.touchSupport !== undefined) { + if (!satisfiesTouchExpectation(condition.touchSupport, browserEnvironment)) { + return false; + } + } + if (condition.existingPartsInSameUserAgent.some((part) => !userAgent.includes(part))) { + return false; + } + if (condition.notExistingPartsInUserAgent?.some((part) => userAgent.includes(part))) { + return false; + } + return true; +} + +function satisfiesTouchExpectation( + expectation: TouchSupportExpectation, + browserEnvironment: BrowserEnvironment, +): boolean { + switch (expectation) { + case TouchSupportExpectation.MustExist: + if (!browserEnvironment.isTouchSupported) { + return false; + } + break; + case TouchSupportExpectation.MustNotExist: + if (browserEnvironment.isTouchSupported) { + return false; + } + break; + default: + throw new Error(`Unsupported touch support expectation: ${TouchSupportExpectation[expectation]}`); + } + return true; +} + +function validateConditions(conditions: readonly BrowserCondition[]) { + if (!conditions.length) { + throw new Error('empty conditions'); + } + for (const condition of conditions) { + validateCondition(condition); + } +} + +function validateCondition(condition: BrowserCondition) { + if (!condition.existingPartsInSameUserAgent.length) { + throw new Error('Each condition must include at least one identifiable part of the user agent string.'); + } + const duplicates = getDuplicates([ + ...condition.existingPartsInSameUserAgent, + ...(condition.notExistingPartsInUserAgent ?? []), + ]); + if (duplicates.length > 0) { + throw new Error(`Found duplicate entries in user agent parts: ${duplicates.join(', ')}. Each part should be unique.`); + } + if (condition.touchSupport !== undefined) { + assertInRange(condition.touchSupport, TouchSupportExpectation); + } +} + +function getDuplicates(texts: readonly string[]): string[] { + return texts.filter((text, index) => texts.indexOf(text) !== index); +} diff --git a/src/infrastructure/RuntimeEnvironment/BrowserOs/DetectorBuilder.ts b/src/infrastructure/RuntimeEnvironment/BrowserOs/DetectorBuilder.ts deleted file mode 100644 index 3d81edb3..00000000 --- a/src/infrastructure/RuntimeEnvironment/BrowserOs/DetectorBuilder.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { OperatingSystem } from '@/domain/OperatingSystem'; -import { IBrowserOsDetector } from './IBrowserOsDetector'; - -export class DetectorBuilder { - private readonly existingPartsInUserAgent = new Array(); - - private readonly notExistingPartsInUserAgent = new Array(); - - constructor(private readonly os: OperatingSystem) { } - - public mustInclude(str: string): DetectorBuilder { - return this.add(str, this.existingPartsInUserAgent); - } - - public mustNotInclude(str: string): DetectorBuilder { - return this.add(str, this.notExistingPartsInUserAgent); - } - - public build(): IBrowserOsDetector { - if (!this.existingPartsInUserAgent.length) { - throw new Error('Must include at least a part'); - } - return { - detect: (agent) => this.detect(agent), - }; - } - - private detect(userAgent: string): OperatingSystem | undefined { - if (!userAgent) { - return undefined; - } - if (this.existingPartsInUserAgent.some((part) => !userAgent.includes(part))) { - return undefined; - } - if (this.notExistingPartsInUserAgent.some((part) => userAgent.includes(part))) { - return undefined; - } - return this.os; - } - - private add(part: string, array: string[]): DetectorBuilder { - if (!part) { - throw new Error('part is empty or undefined'); - } - if (this.existingPartsInUserAgent.includes(part)) { - throw new Error(`part ${part} is already included as existing part`); - } - if (this.notExistingPartsInUserAgent.includes(part)) { - throw new Error(`part ${part} is already included as not existing part`); - } - array.push(part); - return this; - } -} diff --git a/src/infrastructure/RuntimeEnvironment/BrowserOs/IBrowserOsDetector.ts b/src/infrastructure/RuntimeEnvironment/BrowserOs/IBrowserOsDetector.ts deleted file mode 100644 index 368e10f8..00000000 --- a/src/infrastructure/RuntimeEnvironment/BrowserOs/IBrowserOsDetector.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { OperatingSystem } from '@/domain/OperatingSystem'; - -export interface IBrowserOsDetector { - detect(userAgent: string): OperatingSystem | undefined; -} diff --git a/src/infrastructure/RuntimeEnvironment/RuntimeEnvironment.ts b/src/infrastructure/RuntimeEnvironment/RuntimeEnvironment.ts index 87c4d021..6e8cac66 100644 --- a/src/infrastructure/RuntimeEnvironment/RuntimeEnvironment.ts +++ b/src/infrastructure/RuntimeEnvironment/RuntimeEnvironment.ts @@ -2,9 +2,10 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables'; import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory'; -import { BrowserOsDetector } from './BrowserOs/BrowserOsDetector'; -import { IBrowserOsDetector } from './BrowserOs/IBrowserOsDetector'; +import { ConditionBasedOsDetector } from './BrowserOs/ConditionBasedOsDetector'; +import { BrowserEnvironment, BrowserOsDetector } from './BrowserOs/BrowserOsDetector'; import { IRuntimeEnvironment } from './IRuntimeEnvironment'; +import { isTouchEnabledDevice } from './TouchSupportDetection'; export class RuntimeEnvironment implements IRuntimeEnvironment { public static readonly CurrentEnvironment: IRuntimeEnvironment = new RuntimeEnvironment(window); @@ -18,11 +19,10 @@ export class RuntimeEnvironment implements IRuntimeEnvironment { protected constructor( window: Partial, environmentVariables: IEnvironmentVariables = EnvironmentVariablesFactory.Current.instance, - browserOsDetector: IBrowserOsDetector = new BrowserOsDetector(), + browserOsDetector: BrowserOsDetector = new ConditionBasedOsDetector(), + touchDetector = isTouchEnabledDevice, ) { - if (!window) { - throw new Error('missing window'); - } + if (!window) { throw new Error('missing window'); } // do not trust strictNullChecks for global objects this.isNonProduction = environmentVariables.isNonProduction; this.isDesktop = isDesktop(window); if (this.isDesktop) { @@ -31,7 +31,11 @@ export class RuntimeEnvironment implements IRuntimeEnvironment { this.os = undefined; const userAgent = getUserAgent(window); if (userAgent) { - this.os = browserOsDetector.detect(userAgent); + const browserEnvironment: BrowserEnvironment = { + userAgent, + isTouchSupported: touchDetector(), + }; + this.os = browserOsDetector.detect(browserEnvironment); } } } diff --git a/src/infrastructure/RuntimeEnvironment/TouchSupportDetection.ts b/src/infrastructure/RuntimeEnvironment/TouchSupportDetection.ts new file mode 100644 index 00000000..a60f3549 --- /dev/null +++ b/src/infrastructure/RuntimeEnvironment/TouchSupportDetection.ts @@ -0,0 +1,52 @@ +export function isTouchEnabledDevice( + browserTouchAccessor: BrowserTouchSupportAccessor = GlobalTouchSupportAccessor, +): boolean { + return TouchSupportChecks.some( + (check) => check(browserTouchAccessor), + ); +} + +export interface BrowserTouchSupportAccessor { + navigatorMaxTouchPoints: () => number | undefined; + windowMatchMediaMatches: (query: string) => boolean; + documentOntouchend: () => undefined | unknown; + windowTouchEvent: () => undefined | unknown; +} + +const TouchSupportChecks: ReadonlyArray<(accessor: BrowserTouchSupportAccessor) => boolean> = [ + /* + ✅ Mobile: Chrome, Safari, Firefox on iOS and Android + ❌ Touch-enabled Windows laptop: Chrome + (Chromium has removed ontouch* events on desktop since Chrome 70+.) + ❌ Touch-enabled Windows laptop: Firefox + */ + (accessor) => accessor.documentOntouchend() !== undefined, + /* + ✅ Mobile: Chrome, Safari, Firefox on iOS and Android + ✅ Touch-enabled Windows laptop: Chrome + ❌ Touch-enabled Windows laptop: Firefox + */ + (accessor) => { + const maxTouchPoints = accessor.navigatorMaxTouchPoints(); + return maxTouchPoints !== undefined && maxTouchPoints > 0; + }, + /* + ✅ Mobile: Chrome, Safari, Firefox on iOS and Android + ✅ Touch-enabled Windows laptop: Chrome + ❌ Touch-enabled Windows laptop: Firefox + */ + (accessor) => accessor.windowMatchMediaMatches('(any-pointer: coarse)'), + /* + ✅ Mobile: Chrome, Safari, Firefox on iOS and Android + ✅ Touch-enabled Windows laptop: Chrome + ❌ Touch-enabled Windows laptop: Firefox + */ + (accessor) => accessor.windowTouchEvent() !== undefined, +]; + +const GlobalTouchSupportAccessor: BrowserTouchSupportAccessor = { + navigatorMaxTouchPoints: () => navigator.maxTouchPoints, + windowMatchMediaMatches: (query: string) => window.matchMedia(query)?.matches, + documentOntouchend: () => document.ontouchend, + windowTouchEvent: () => window.TouchEvent, +} as const; diff --git a/src/presentation/bootstrapping/ApplicationBootstrapper.ts b/src/presentation/bootstrapping/ApplicationBootstrapper.ts index 6c34a1bb..e08c2ffa 100644 --- a/src/presentation/bootstrapping/ApplicationBootstrapper.ts +++ b/src/presentation/bootstrapping/ApplicationBootstrapper.ts @@ -2,6 +2,7 @@ import { Bootstrapper } from './Bootstrapper'; import { RuntimeSanityValidator } from './Modules/RuntimeSanityValidator'; import { AppInitializationLogger } from './Modules/AppInitializationLogger'; import { DependencyBootstrapper } from './Modules/DependencyBootstrapper'; +import { MobileSafariActivePseudoClassEnabler } from './Modules/MobileSafariActivePseudoClassEnabler'; import type { App } from 'vue'; export class ApplicationBootstrapper implements Bootstrapper { @@ -19,6 +20,7 @@ export class ApplicationBootstrapper implements Bootstrapper { new RuntimeSanityValidator(), new DependencyBootstrapper(), new AppInitializationLogger(), + new MobileSafariActivePseudoClassEnabler(), ]; } } diff --git a/src/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.ts b/src/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.ts new file mode 100644 index 00000000..63a737d2 --- /dev/null +++ b/src/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.ts @@ -0,0 +1,104 @@ +import { IRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/IRuntimeEnvironment'; +import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { Bootstrapper } from '../Bootstrapper'; + +export class MobileSafariActivePseudoClassEnabler implements Bootstrapper { + constructor( + private readonly currentEnvironment = RuntimeEnvironment.CurrentEnvironment, + private readonly browser: BrowserAccessor = GlobalBrowserAccessor, + ) { + + } + + public async bootstrap(): Promise { + if (!isMobileSafari(this.currentEnvironment, this.browser.getNavigatorUserAgent())) { + return; + } + /* + Workaround to fix issue with `:active` pseudo-class not working on mobile Safari. + This is required so `hover-or-touch` mixin works properly. + Last tested: iPhone with iOS 17.1.1 + See: + - Source: https://stackoverflow.com/a/33681490 + - Snapshot 1: https://web.archive.org/web/20231112151701/https://stackoverflow.com/questions/3885018/active-pseudo-class-doesnt-work-in-mobile-safari/33681490#33681490 + - Snapshot 2: tps://archive.ph/r1zpJ + */ + this.browser.addWindowEventListener('touchstart', () => {}, { + /* + - Setting to `true` removes the need for scrolling to block on touch and wheel + event listeners. + - If set to `true`, it indicates that the function specified by listener will + never call `preventDefault`. + - Defaults to `true` on Safari for `touchstart`. + */ + passive: true, + }); + } +} + +export interface BrowserAccessor { + getNavigatorUserAgent(): string; + addWindowEventListener(...args: Parameters): void; +} + +function isMobileSafari(environment: IRuntimeEnvironment, userAgent: string): boolean { + if (!isMobileAppleOperatingSystem(environment)) { + return false; + } + return isSafari(userAgent); +} + +function isMobileAppleOperatingSystem(environment: IRuntimeEnvironment): boolean { + if (environment.os === undefined) { + return false; + } + if (![OperatingSystem.iOS, OperatingSystem.iPadOS].includes(environment.os)) { + return false; + } + return true; +} + +function isSafari(userAgent: string): boolean { + if (!userAgent) { + return false; + } + return SafariUserAgentIdentifiers.every((i) => userAgent.includes(i)) + && NonSafariBrowserIdentifiers.every((i) => !userAgent.includes(i)); +} + +const GlobalBrowserAccessor: BrowserAccessor = { + getNavigatorUserAgent: () => navigator.userAgent, + addWindowEventListener: (...args) => window.addEventListener(...args), +} as const; + +const SafariUserAgentIdentifiers = [ + 'Safari', +] as const; + +const NonSafariBrowserIdentifiers = [ + // Chrome: + 'Chrome', + 'CriOS', + // Firefox: + 'FxiOS', + // Opera: + 'OPiOS', + 'OPR', // Opera Desktop and Android + 'Opera', // Opera Mini + 'OPT', + // Edge: + 'EdgiOS', // Edge on iOS/iPadOS + 'Edg', // Edge on macOS + 'EdgA', // Edge on Android + 'Edge', // Microsoft Edge Legacy + // UC Browser: + 'UCBrowser', + // Baidu: + 'BaiduHD', + 'BaiduBrowser', + 'baiduboxapp', + 'baidubrowser', + // QQ Browser: + 'MQQBrowser', +] as const; diff --git a/tests/checks/external-urls/StatusChecker/ExponentialBackOffRetryHandler.ts b/tests/checks/external-urls/StatusChecker/ExponentialBackOffRetryHandler.ts index 3cbaa8fb..43d05ab2 100644 --- a/tests/checks/external-urls/StatusChecker/ExponentialBackOffRetryHandler.ts +++ b/tests/checks/external-urls/StatusChecker/ExponentialBackOffRetryHandler.ts @@ -13,7 +13,6 @@ export async function retryWithExponentialBackOff( if (shouldRetry(status)) { if (currentRetry <= maxTries) { const exponentialBackOffInMs = getRetryTimeoutInMs(currentRetry, baseRetryIntervalInMs); - // tslint:disable-next-line: no-console console.log(`Retrying (${currentRetry}) in ${exponentialBackOffInMs / 1000} seconds`, status); await sleep(exponentialBackOffInMs); return retryWithExponentialBackOff(action, baseRetryIntervalInMs, currentRetry + 1); diff --git a/tests/e2e/card-list-layout-stability-on-load.cy.ts b/tests/e2e/card-list-layout-stability-on-load.cy.ts index 77dfd020..07020966 100644 --- a/tests/e2e/card-list-layout-stability-on-load.cy.ts +++ b/tests/e2e/card-list-layout-stability-on-load.cy.ts @@ -1,4 +1,5 @@ // eslint-disable-next-line max-classes-per-file +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; import { getHeaderBrandTitle } from './support/interactions/header'; import { ViewportTestScenarios } from './support/scenarios/viewport-test-scenarios'; @@ -49,11 +50,12 @@ describe('card list layout stability', () => { // assert const widthToleranceInPx = 0; const widthsInPx = dimensions.getUniqueWidths(); - expect(isWithinTolerance(widthsInPx, widthToleranceInPx)).to.equal(true, [ - `Unique width values over time: ${[...widthsInPx].join(', ')}`, - `Height changes are more than ${widthToleranceInPx}px tolerance`, - `Captured metrics: ${dimensions.toString()}`, - ].join('\n\n')); + expect(isWithinTolerance(widthsInPx, widthToleranceInPx)) + .to.equal(true, formatAssertionMessage([ + `Unique width values over time: ${[...widthsInPx].join(', ')}`, + `Height changes are more than ${widthToleranceInPx}px tolerance`, + `Captured metrics: ${dimensions.toString()}`, + ])); const heightToleranceInPx = 100; // Set in relation to card sizes. // Tolerance allows for minor layout shifts without (e.g. for icon or font loading) @@ -61,11 +63,12 @@ describe('card list layout stability', () => { // cards per row changes, avoiding failures for shifts less than the smallest card // size (~175px). const heightsInPx = dimensions.getUniqueHeights(); - expect(isWithinTolerance(heightsInPx, heightToleranceInPx)).to.equal(true, [ - `Unique height values over time: ${[...heightsInPx].join(', ')}`, - `Height changes are more than ${heightToleranceInPx}px tolerance`, - `Captured metrics: ${dimensions.toString()}`, - ].join('\n\n')); + expect(isWithinTolerance(heightsInPx, heightToleranceInPx)) + .to.equal(true, formatAssertionMessage([ + `Unique height values over time: ${[...heightsInPx].join(', ')}`, + `Height changes are more than ${heightToleranceInPx}px tolerance`, + `Captured metrics: ${dimensions.toString()}`, + ])); }); }); }); diff --git a/tests/e2e/modal-layout-shifts.cy.ts b/tests/e2e/modal-layout-shifts.cy.ts index 36ebe42f..365eba76 100644 --- a/tests/e2e/modal-layout-shifts.cy.ts +++ b/tests/e2e/modal-layout-shifts.cy.ts @@ -1,3 +1,4 @@ +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; import { ViewportTestScenarios } from './support/scenarios/viewport-test-scenarios'; describe('Modal interaction and layout stability', () => { @@ -24,10 +25,10 @@ describe('Modal interaction and layout stability', () => { captureViewportMetrics((metrics) => { const metricsAfterModal = metrics; - expect(metricsBeforeModal).to.deep.equal(metricsAfterModal, [ + expect(metricsBeforeModal).to.deep.equal(metricsAfterModal, formatAssertionMessage([ `Expected (initial metrics before modal): ${JSON.stringify(metricsBeforeModal)}`, `Actual (metrics after modal is opened): ${JSON.stringify(metricsAfterModal)}`, - ].join('\n')); + ])); }); }); }); diff --git a/tests/e2e/no-unintended-overflow.cy.ts b/tests/e2e/no-unintended-overflow.cy.ts index ce06cbae..e6e3c429 100644 --- a/tests/e2e/no-unintended-overflow.cy.ts +++ b/tests/e2e/no-unintended-overflow.cy.ts @@ -1,3 +1,5 @@ +import { formatAssertionMessage } from '../shared/FormatAssertionMessage'; + describe('has no unintended overflow', () => { it('fits the content without horizontal scroll', () => { // arrange @@ -6,7 +8,7 @@ describe('has no unintended overflow', () => { cy.visit('/'); // assert cy.window().then((win) => { - expect(win.document.documentElement.scrollWidth, [ + expect(win.document.documentElement.scrollWidth, formatAssertionMessage([ `Window inner dimensions: ${win.innerWidth}x${win.innerHeight}`, `Window outer dimensions: ${win.outerWidth}x${win.outerHeight}`, `Body scrollWidth: ${win.document.body.scrollWidth}`, @@ -17,7 +19,7 @@ describe('has no unintended overflow', () => { `Meta viewport content: ${win.document.querySelector('meta[name="viewport"]')?.getAttribute('content')}`, `Device Pixel Ratio: ${win.devicePixelRatio}`, `Cypress Viewport: ${Cypress.config('viewportWidth')}x${Cypress.config('viewportHeight')}`, - ].join('\n')).to.be.lte(win.innerWidth); + ])).to.be.lte(win.innerWidth); }); }); }); diff --git a/tests/integration/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsTestCases.ts b/tests/integration/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsTestCases.ts new file mode 100644 index 00000000..c4c670e0 --- /dev/null +++ b/tests/integration/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsTestCases.ts @@ -0,0 +1,253 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { determineTouchSupportOptions } from '@tests/integration/shared/TestCases/TouchSupportOptions'; + +interface BrowserOsTestCase { + readonly userAgent: string; + readonly platformTouchSupport: boolean; + readonly expectedOs: OperatingSystem; +} + +export const BrowserOsTestCases: ReadonlyArray = [ + ...createTests({ + operatingSystem: OperatingSystem.Windows, + userAgents: [ + // Internet Explorer: + 'Mozilla/5.0 (Windows NT 6.3; Win64, x64; Trident/7.0; rv:11.0) like Gecko', + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; Win64; x64; Trident/6.0)', + 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)', + 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0)', + // Edge (Legacy): + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/18.17763', + 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/17.17134', + 'Mozilla/5.0 (Windows NT 10.0; WebView/3.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299', + // Edge (Chromium): + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0', + // Firefox: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0', + 'Mozilla/5.0 (Windows NT 6.4; WOW64; rv:32.0) Gecko/20100101 Firefox/32.0', + // Chrome/Brave/QQ Browser: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', + // Opera: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 OPR/105.0.0.0', + 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.52 Safari/537.36 OPR/15.0.1147.100', + 'Opera/9.80 (Windows NT 6.0) Presto/2.12.388 Version/12.14', + 'Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.15 Version/10.10', + 'Opera/9.27 (Windows NT 5.1; U; en)', + 'Opera/9.80 (Windows NT 6.1; Opera Tablet/15165; U; en) Presto/2.8.149 Version/11.1', + // UC Browser: + 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 UBrowser/6.0.1308.1016 Safari/537.36', + ], + }), + ...createTests({ + operatingSystem: OperatingSystem.macOS, + userAgents: [ + // Firefox: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/119.0', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:66.0) Gecko/20100101 Firefox/66.0', + // Chrome/Brave: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Safari/537.36', + // Safari: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0.3 Safari/605.1.15', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15', + // Opera: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 OPR/105.0.0.0', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.82 Safari/537.36 OPR/29.0.1795.41 (Edition beta)', + // Edge: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0', + ], + }), + ...createTests({ + operatingSystem: OperatingSystem.Linux, + userAgents: [ + // Firefox: + 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/116.0', + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:66.0) Gecko/20100101 Firefox/66.0', + // Chrome: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', + // Edge: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.188', + ], + }), + ...createTests({ + operatingSystem: OperatingSystem.iOS, + userAgents: [ + ...[ // iPhone + // Safari: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', + // Chrome: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.109 Mobile/15E148 Safari/604.1', + // Firefox: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) FxiOS/1.0 Mobile/12F69 Safari/600.1.4', + // Firefox Focus: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/7.0.4 Mobile/16B91 Safari/605.1.15', + // Opera Mini: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPiOS/16.0.15.124050 Mobile/15E148 Safari/9537.53', + // Opera Touch (discontinued): + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.7 Mobile/15E148 Safari/604.1 OPT/4.3.2', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPT/3.3.3 Mobile/15E148', + ], + ...[ // iPod + // Safari: + 'Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_2 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8H7 Safari/6533.18.5', + 'Mozilla/5.0 (iPod; CPU iPhone OS 9_3 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13E233 Safari/601.1', + // Chrome: + 'Mozilla/5.0 (iPod; CPU iPhone OS 14_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/87.0.4280.77 Mobile/15E148 Safari/604.1', + ], + ], + }), + ...createTests({ + operatingSystem: OperatingSystem.iPadOS, + userAgents: [ + /* + `iPad` might be included in user agents on older iPad that's running iOS (not iPadOS), + to avoid additional complexity, we just detect them as iPadOS. + */ + // Safari on iPad (running iOS): + 'Mozilla/5.0 (iPad; U; CPU OS 3_2 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Version/4.0.4 Mobile/7B367 Safari/531.21.10', + // Edge on iOS: + 'Mozilla/5.0 (iPad; CPU OS 17_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 EdgiOS/46.2.0 Mobile/15E148 Safari/605.1.15', + // Safari on iPad Mini (running iPadOS): + 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + ], + }), + ...createTests({ + operatingSystem: OperatingSystem.iPadOS, + // Safari on (≥ iPadOS 13) and iPhone on desktop mode reports user agents identical to macOS + userAgents: [ + // Safari: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10156) AppleWebKit/605.1.15 (KHTML, like Gecko)', + ], + }), + ...createTests({ + operatingSystem: OperatingSystem.ChromeOS, + userAgents: [ + // Chrome: + 'Mozilla/5.0 (X11; CrOS x86_64 11316.165.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.122 Safari/537.36', + 'Mozilla/5.0 (X11; CrOS armv7l 4537.56.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.38 Safari/537.36', + ], + }), + ...createTests({ + operatingSystem: OperatingSystem.Android, + userAgents: [ + // Opera Mini: + 'Opera/9.80 (Android; Opera Mini/32.0/88.150; U; sr) Presto/2.12 Version/12.16', + 'Opera/9.80 (Android; Opera Mini/8.0.1807/36.1609; U; en) Presto/2.12.423 Version/12.16', + 'Opera/9.80 (Android 2.2; Opera Mobi/-2118645896; U; pl) Presto/2.7.60 Version/10.5', + // Chrome: + 'Mozilla/5.0 (Linux; Android 4.4.4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.52 Safari/537.36 Mobile OPR/15.0.1147.100', + 'Mozilla/5.0 (Linux; Android 9; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 6.0; CAM-L03) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.99 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 4.2.2; GT-I9505 Build/JDQ39) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 4.3; Nexus 10 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Safari/537.36', + 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/46.0.2490.76 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 9; ONEPLUS A6003) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Mobile Safari/537.36', + // Firefox: + 'Mozilla/5.0 (Android 4.4; Tablet; rv:41.0) Gecko/41.0 Firefox/41.0', + 'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0', + 'Mozilla/5.0 (Android; Mobile; rv:40.0) Gecko/40.0 Firefox/40.0', + 'Mozilla/5.0 (Android; Tablet; rv:40.0) Gecko/40.0 Firefox/40.0', + // Firefox Focus: + 'Mozilla/5.0 (Linux; Android 7.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Focus/1.0 Chrome/59.0.3029.83 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 7.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Focus/1.0 Chrome/59.0.3029.83 Safari/537.36', + 'Mozilla/5.0 (Android 7.0; Mobile; rv:62.0) Gecko/62.0 Firefox/62.0', + // Firefox Klar (german edition): + 'Mozilla/5.0 (Linux; Android 7.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Klar/1.0 Chrome/58.0.3029.83 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 7.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Focus/4.1 Chrome/62.0.3029.83 Mobile Safari/537.36', + 'Mozilla/5.0 (Android 7.0; Mobile; rv:62.0) Gecko/62.0 Firefox/62.0', + // UC Browser: + 'Mozilla/5.0 (Linux; U; Android 6.0; en-US; CPH1609 Build/MRA58K) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.108 UCBrowser/12.10.2.1164 Mobile Safari/537.36', + 'UCWEB/2.0 (Linux; U; Adr 5.1; en-US; Lenovo Z90a40 Build/LMY47O) U2/1.0.0 UCBrowser/11.1.5.890 U2/1.0.0 Mobile', + 'Mozilla/5.0 (Linux; U; Android 5.1; en-US; Lenovo Z90a40 Build/LMY47O) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 UCBrowser/11.1.5.890 U3/0.8.0 Mobile Safari/534.30', + 'Mozilla/5.0 (Linux; U; Android 2.3; zh-CN; MI-ONEPlus) AppleWebKit/534.13 (KHTML, like Gecko) UCBrowser/8.6.0.199 U3/0.8.0 Mobile Safari/534.13', + 'UCWEB/2.0 (Linux; U; Adr 2.3; en-US; MI-ONEPlus) U2/1.0.0 UCBrowser/8.6.0.199 U2/1.0.0 Mobile', + // Opera: + 'Mozilla/5.0 (Linux; Android 2.3.4; MT11i Build/4.0.2.A.0.62) AppleWebKit/537.22 (KHTML, like Gecko) Chrome/25.0.1364.123 Mobile Safari/537.22 OPR/14.0.1025.52315', + // Opera Touch (discontinued): + 'Mozilla/5.0 (Linux; Android 8.1.0; BBF100-6 Build/OPM1.171019.026) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/68.0.3440.91 Mobile Safari/537.36 OPT/6B8575B', + // Samsung Browser: + 'Mozilla/5.0 (Linux; Android 9; SAMSUNG SM-G965F Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/9.0 Chrome/67.0.3396.87 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 8.0.0; SAMSUNG SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/8.2 Chrome/63.0.3239.111 Mobile Safari/537.36', + // QQ Browser: + 'Mozilla/5.0 (Linux; U; Android 8.1.0; zh-cn; vivo X21A Build/OPM1.171019.011) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.132 MQQBrowser/9.1 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; U; Android 4.4.2; zh-cn; GT-I9500 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko)Version/4.0 MQQBrowser/5.0 QQ-URL-Manager Mobile Safari/537.36', + // Vivo Browser: + 'Mozilla/5.0 (Linux; Android 10; V1990A; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/87.0.4280.141 Mobile Safari/537.36 VivoBrowser/10.3.10.0', + // Android Generic Webkit based: + 'Mozilla/5.0 (Linux; U; Android 4.4.4; pt-br; SM-G530BT Build/KTU84P) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + 'Mozilla/5.0 (Linux; U; Android 4.4.2; zh-cn; Q40; Android/4.4.2; Release/12.15.2015) AppleWebKit/534.30 (KHTML, like Gecko) Mobile Safari/534.30', + 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + ], + }), + ...createTests({ + operatingSystem: OperatingSystem.BlackBerry10, + userAgents: [ + // BlackBerry Browser: + 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.1.0.1429 Mobile Safari/537.10+', + ], + }), + ...createTests({ + operatingSystem: OperatingSystem.BlackBerryTabletOS, + userAgents: [ + // BlackBerry Browser: + 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.0.0; en-US) AppleWebKit/535.8+ (KHTML, like Gecko) Version/7.2.0.0 Safari/535.8+', + ], + }), + ...createTests({ + operatingSystem: OperatingSystem.BlackBerryOS, + userAgents: [ + // BlackBerry Browser: + 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9800; en-US) AppleWebKit/534.8+ (KHTML, like Gecko) Version/6.0.0.466 Mobile Safari/534.8+', + ], + }), + ...createTests({ + operatingSystem: OperatingSystem.WindowsPhone, + userAgents: [ + // Internet Explorer Mobile: + 'Mozilla/5.0 (Mobile; Windows Phone 8.1; Android 4.0; ARM; Trident/7.0; Touch; rv:11.0; IEMobile/11.0; NOKIA; Lumia 625) like iPhone OS 7_0_3 Mac OS X AppleWebKit/537 (KHTML, like Gecko) Mobile Safari/537', + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 920)', + 'Mozilla/5.0 (Windows Phone 8.1; ARM; Trident/7.0; Touch; rv:11.0; IEMobile/11.0; NOKIA; id313-3) like Gecko', + 'Mozilla/5.0 (compatible; MSIE 9.0; Windows Phone OS 7.5; Trident/5.0; IEMobile/9.0; NOKIA; Lumia 900)', + ], + }), + ...createTests({ + operatingSystem: OperatingSystem.Windows10Mobile, + userAgents: [ + // Chrome: + 'Mozilla/5.0 (Windows Mobile 13; Android 10.0; Microsoft; Lumia 950XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36 Safari/537.36', + // Opera Mini: + 'Opera/9.80 (Windows Mobile; Opera Mini/103.1.21595/25.657; U; en) Presto/2.5.25 Version/10.54', + // Edge 100: + 'Mozilla/5.0 (compatible; Android 10.0;SM-G973F; Windows Mobile 10.0; Chrome/106.0.5249.126 ) AppleWebKit/535.1 (KHTML, like Gecko) EdgA/100/0.1185.50 Mobile Safari/535.1 3gpp-gba', + // Edge legacy: + 'Mozilla/5.0 (Windows Phone 10.0; Android 5.1.1; NOKIA; Lumia 1520) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/13.10586', + // Firefox: + 'Mozilla/5.0 (Windows Phone 10.0; Mobile; rv:107.0) Gecko/107.0 Firefox/107.0', + ], + }), + ...createTests({ + operatingSystem: OperatingSystem.KaiOS, + userAgents: [ + // Firefox: + 'Mozilla/5.0 (Mobile; LYF/F90M/LYF_F90M_000-03-12-110119; Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', + ], + }), +]; + +function createTests(testScenario: { + readonly operatingSystem: OperatingSystem, + readonly userAgents: readonly string[], +}): BrowserOsTestCase[] { + return determineTouchSupportOptions(testScenario.operatingSystem) + .flatMap((hasTouch): readonly BrowserOsTestCase[] => testScenario + .userAgents.map((userAgent): BrowserOsTestCase => ({ + userAgent, + platformTouchSupport: hasTouch, + expectedOs: testScenario.operatingSystem, + }))); +} diff --git a/tests/integration/infrastructure/RuntimeEnvironment/BrowserOs/ConditionBasedOsDetector.spec.ts b/tests/integration/infrastructure/RuntimeEnvironment/BrowserOs/ConditionBasedOsDetector.spec.ts new file mode 100644 index 00000000..a8e9d4d9 --- /dev/null +++ b/tests/integration/infrastructure/RuntimeEnvironment/BrowserOs/ConditionBasedOsDetector.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { ConditionBasedOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/ConditionBasedOsDetector'; +import { BrowserEnvironment } from '@/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; +import { BrowserOsTestCases } from './BrowserOsTestCases'; + +describe('ConditionBasedOsDetector', () => { + describe('detect', () => { + it('detects as expected', () => { + BrowserOsTestCases.forEach((testCase) => { + // arrange + const sut = new ConditionBasedOsDetector(); + const environment: BrowserEnvironment = { + userAgent: testCase.userAgent, + isTouchSupported: testCase.platformTouchSupport, + }; + // act + const actual = sut.detect(environment); + // assert + expect(actual).to.equal(testCase.expectedOs, formatAssertionMessage([ + `Expected: "${OperatingSystem[testCase.expectedOs]}"`, + `Actual: "${actual === undefined ? 'undefined' : OperatingSystem[actual]}"`, + `User agent: "${testCase.userAgent}"`, + `Touch support: "${testCase.platformTouchSupport ? 'Yes, supported' : 'No, unsupported.'}"`, + ])); + }); + }); + }); +}); diff --git a/tests/integration/presentation/bootstrapping/ApplicationBootstrapper.spec.ts b/tests/integration/presentation/bootstrapping/ApplicationBootstrapper.spec.ts index f7d81ec3..b74010b0 100644 --- a/tests/integration/presentation/bootstrapping/ApplicationBootstrapper.spec.ts +++ b/tests/integration/presentation/bootstrapping/ApplicationBootstrapper.spec.ts @@ -1,4 +1,4 @@ -import { describe } from 'vitest'; +import { describe, it } from 'vitest'; import { createApp } from 'vue'; import { ApplicationBootstrapper } from '@/presentation/bootstrapping/ApplicationBootstrapper'; import { expectDoesNotThrowAsync } from '@tests/shared/Assertions/ExpectThrowsAsync'; diff --git a/tests/integration/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.spec.ts b/tests/integration/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.spec.ts new file mode 100644 index 00000000..22d272e9 --- /dev/null +++ b/tests/integration/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, afterEach } from 'vitest'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { MobileSafariActivePseudoClassEnabler } from '@/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler'; +import { EventName, createWindowEventSpies } from '@tests/shared/Spies/WindowEventSpies'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; +import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; +import { isTouchEnabledDevice } from '@/infrastructure/RuntimeEnvironment/TouchSupportDetection'; +import { MobileSafariDetectionTestCases } from './MobileSafariDetectionTestCases'; + +describe('MobileSafariActivePseudoClassEnabler', () => { + describe('bootstrap', () => { + MobileSafariDetectionTestCases.forEach(({ + description, userAgent, supportsTouch, expectedResult, + }) => { + it(description, () => { + // arrange + const expectedEvent: EventName = 'touchstart'; + patchUserAgent(userAgent, afterEach); + const { isAddEventCalled, currentListeners } = createWindowEventSpies(afterEach); + const patchedEnvironment = new ConstructibleRuntimeEnvironment(supportsTouch); + const sut = new MobileSafariActivePseudoClassEnabler(patchedEnvironment); + // act + sut.bootstrap(); + // assert + const isSet = isAddEventCalled(expectedEvent); + expect(isSet).to.equal(expectedResult, formatAssertionMessage([ + `Expected result\t\t: ${expectedResult ? 'true (mobile Safari)' : 'false (not mobile Safari)'}`, + `Actual result\t\t: ${isSet ? 'true (mobile Safari)' : 'false (not mobile Safari)'}`, + `User agent\t\t: ${navigator.userAgent}`, + `Touch supported\t\t: ${supportsTouch}`, + `Current OS\t\t: ${patchedEnvironment.os === undefined ? 'unknown' : OperatingSystem[patchedEnvironment.os]}`, + `Is desktop?\t\t: ${patchedEnvironment.isDesktop ? 'Yes (Desktop app)' : 'No (Browser)'}`, + `Listeners (${currentListeners.length})\t\t: ${JSON.stringify(currentListeners)}`, + ])); + }); + }); + }); +}); + +function patchUserAgent( + userAgent: string, + restoreCallback: (restoreFunc: () => void) => void, +) { + const originalNavigator = window.navigator; + const userAgentGetter = { get: () => userAgent }; + window.navigator = Object.create(navigator, { + userAgent: userAgentGetter, + }); + restoreCallback(() => { + Object.assign(window, { + navigator: originalNavigator, + }); + }); +} + +function getTouchDetectorMock( + isTouchEnabled: boolean, +): typeof isTouchEnabledDevice { + return () => isTouchEnabled; +} + +class ConstructibleRuntimeEnvironment extends RuntimeEnvironment { + public constructor(isTouchEnabled: boolean) { + super(window, undefined, undefined, getTouchDetectorMock(isTouchEnabled)); + } +} diff --git a/tests/integration/presentation/bootstrapping/Modules/MobileSafariDetectionTestCases.ts b/tests/integration/presentation/bootstrapping/Modules/MobileSafariDetectionTestCases.ts new file mode 100644 index 00000000..8c1acbc8 --- /dev/null +++ b/tests/integration/presentation/bootstrapping/Modules/MobileSafariDetectionTestCases.ts @@ -0,0 +1,216 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { determineTouchSupportOptions } from '@tests/integration/shared/TestCases/TouchSupportOptions'; + +interface PlatformTestCase { + readonly description: string; + readonly userAgent: string; + readonly supportsTouch: boolean; + readonly expectedResult: boolean; +} + +export const MobileSafariDetectionTestCases: ReadonlyArray = [ + ...createBrowserTestCases({ + browserName: 'Safari', + expectedResult: true, + userAgents: [ + { + deviceInfo: 'Safari on iPad (≥ 13)', + operatingSystem: OperatingSystem.iPadOS, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15', // same as macOS (desktop) + supportsTouch: true, + }, + { + deviceInfo: 'Safari on iPad (< 13)', + operatingSystem: OperatingSystem.iPadOS, + userAgent: 'Mozilla/5.0 (iPad; CPU OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B14 3 Safari/601.1', + }, + { + deviceInfo: 'Safari on iPhone', + operatingSystem: OperatingSystem.iOS, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1', + }, + { + deviceInfo: 'Safari on iPod touch', + operatingSystem: OperatingSystem.iOS, + userAgent: 'Mozila/5.0 (iPod; U; CPU like Mac OS X; en) AppleWebKit/420.1 (KHTML, like Geckto) Version/3.0 Mobile/3A101a Safari/419.3', + // https://web.archive.org/web/20231112165804/https://www.cnet.com/tech/mobile/safari-for-ipod-touch-has-different-user-agent-string-may-not-go-directly-to-iphone-optimized-sites/null/ + }, + ], + }), + ...createBrowserTestCases({ + browserName: 'Safari', + expectedResult: false, + userAgents: [ + { + deviceInfo: 'Safari on macOS', + operatingSystem: OperatingSystem.macOS, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15', + supportsTouch: false, + }, + ], + }), + ...createBrowserTestCases({ + browserName: 'Chrome', + expectedResult: false, + userAgents: [ + { + deviceInfo: 'macOS', + operatingSystem: OperatingSystem.macOS, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', + }, + { + deviceInfo: 'iPad (iPadOS 17)', + operatingSystem: OperatingSystem.iPadOS, + userAgent: 'Mozilla/5.0 (iPad; CPU OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.109 Mobile/15E148 Safari/604.1', + }, + { + deviceInfo: 'iPhone (iOS 17)', + operatingSystem: OperatingSystem.iOS, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.109 Mobile/15E148 Safari/604.1', + }, + { + deviceInfo: 'iPhone (iOS 12)', + operatingSystem: OperatingSystem.iOS, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/70.0.3538.75 Mobile/15E148 Safari/605.1', + }, + { + deviceInfo: 'iPod Touch (iOS 12)', + operatingSystem: OperatingSystem.iOS, + userAgent: 'Mozilla/5.0 (iPod; CPU iPhone OS 12_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/86.0.4240.93 Mobile/15E148 Safari/604.1', + }, + ], + }), + ...createBrowserTestCases({ + browserName: 'Firefox', + expectedResult: false, + userAgents: [ + { + deviceInfo: 'macOS', + operatingSystem: OperatingSystem.macOS, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.1; rv:119.0) Gecko/20100101 Firefox/119.0', + }, + { + deviceInfo: 'iPad (iPadOS 13)', + operatingSystem: OperatingSystem.iPadOS, + userAgent: 'Mozilla/5.0 (iPad; CPU OS 13_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/19.1b16203 Mobile/15E148 Safari/605.1.15', + }, + { + deviceInfo: 'iPhone (iOS 17)', + operatingSystem: OperatingSystem.iOS, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/117.2 Mobile/15E148 Safari/605.1.15', + }, + ], + }), + ...createBrowserTestCases({ + browserName: 'Edge', + expectedResult: false, + userAgents: [ + { + deviceInfo: 'macOS', + operatingSystem: OperatingSystem.macOS, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 Edg/117.0.2045.55', + }, + { + deviceInfo: 'iPad (iPadOS 15)', + operatingSystem: OperatingSystem.iPadOS, + userAgent: 'Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/96.0.1054.61 Version/15.0 Mobile/15E148 Safari/604.1', + }, + { + deviceInfo: 'iPhone (iOS 17)', + operatingSystem: OperatingSystem.iOS, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/118.0.2088.81 Version/17.0 Mobile/15E148 Safari/604.1', + }, + ], + }), + ...createBrowserTestCases({ + browserName: 'Opera', + expectedResult: false, + userAgents: [ + { + deviceInfo: 'Opera Mini on iPhone', + operatingSystem: OperatingSystem.iOS, + userAgent: 'Opera/9.80 (iPhone; Opera Mini/5.0.0176/764; U; en) Presto/2.4.15', + // https://web.archive.org/web/20140221034354/http://my.opera.com/haavard/blog/2010/04/16/iphone-user-agent + }, + { + deviceInfo: 'Opera Mini (Opera Turbo) on iPhone', + operatingSystem: OperatingSystem.iOS, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_1 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) OPiOS/8.0.0.78129 Mobile/11D201 Safari/9537.53', + // https://web.archive.org/web/20231112164709/https://dev.opera.com/blog/opera-mini-8-for-ios/ + }, + { + deviceInfo: 'Opera on macOS', + operatingSystem: OperatingSystem.macOS, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 OPR/94.0.0.0', + // https://web.archive.org/web/20231112164741/https://forums.opera.com/topic/59600/have-the-user-agent-browser-identification-match-with-the-mac-os-version-the-browser-is-running-on + }, + { + deviceInfo: 'Opera on macOS (legacy)', + operatingSystem: OperatingSystem.macOS, + userAgent: 'Opera/9.80 (Macintosh; Intel Mac OS X 10.8; U; ru) Presto/2.10 Version/12.00', + // https://web.archive.org/web/20231112164741/https://forums.opera.com/topic/59600/have-the-user-agent-browser-identification-match-with-the-mac-os-version-the-browser-is-running-on + }, + { + deviceInfo: 'Opera Touch on iPhone', + operatingSystem: OperatingSystem.iOS, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPT/3.3.3 Mobile/15E148', + }, + { + deviceInfo: 'Opera Mini on iPhone', + operatingSystem: OperatingSystem.iOS, + userAgent: 'Opera/9.80 (iPhone; Opera Mini/14.0.0/37.8603; U; en) Presto/2.12.423 Version/12.16', + }, + { + deviceInfo: 'Opera Mini on iPad', + operatingSystem: OperatingSystem.iPadOS, + userAgent: 'Opera/9.80 (iPad; Opera Mini/7.0.5/191.320; U; id) Presto/2.12.423 Version/12.16', + }, + ], + }), + ...createBrowserTestCases({ + browserName: 'Vivo Browser', // Runs only Vivo (Android) devices + expectedResult: false, + userAgents: [ + { + deviceInfo: 'VivoBrowser on Android', + operatingSystem: OperatingSystem.Android, + userAgent: 'Mozilla/5.0 (Linux; Android 10; V1990A; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/87.0.4280.141 Mobile Safari/537.36 VivoBrowser/10.3.10.0', + }, + ], + }), +]; + +interface UserAgentTestScenario { + readonly userAgent: string; + readonly operatingSystem: OperatingSystem; + readonly deviceInfo: string; + readonly supportsTouch?: boolean; +} + +interface BrowserTestScenario { + readonly browserName: string; + readonly expectedResult: boolean; + readonly userAgents: readonly UserAgentTestScenario[]; +} + +function createBrowserTestCases( + scenario: BrowserTestScenario, +): PlatformTestCase[] { + return scenario.userAgents.flatMap((agentInfo): readonly PlatformTestCase[] => { + const touchCases = agentInfo.supportsTouch === undefined + ? determineTouchSupportOptions(agentInfo.operatingSystem) + : [agentInfo.supportsTouch]; + return touchCases.map((hasTouch): PlatformTestCase => ({ + description: [ + scenario.expectedResult ? '[POSITIVE]' : '[NEGATIVE]', + scenario.browserName, + OperatingSystem[agentInfo.operatingSystem], + agentInfo.deviceInfo, + hasTouch === true ? '[TOUCH]' : '[NO TOUCH]', + ].join(' | '), + userAgent: agentInfo.userAgent, + supportsTouch: hasTouch, + expectedResult: scenario.expectedResult, + })); + }); +} diff --git a/tests/integration/shared/TestCases/TouchSupportOptions.ts b/tests/integration/shared/TestCases/TouchSupportOptions.ts new file mode 100644 index 00000000..ef92a2f1 --- /dev/null +++ b/tests/integration/shared/TestCases/TouchSupportOptions.ts @@ -0,0 +1,37 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; + +enum TouchSupportState { + AlwaysSupported, + MayBeSupported, + NeverSupported, +} + +const TouchSupportPerOperatingSystem: Record = { + [OperatingSystem.Android]: TouchSupportState.AlwaysSupported, + [OperatingSystem.iOS]: TouchSupportState.AlwaysSupported, + [OperatingSystem.iPadOS]: TouchSupportState.AlwaysSupported, + [OperatingSystem.ChromeOS]: TouchSupportState.AlwaysSupported, + [OperatingSystem.KaiOS]: TouchSupportState.MayBeSupported, + [OperatingSystem.BlackBerry10]: TouchSupportState.AlwaysSupported, + [OperatingSystem.BlackBerryOS]: TouchSupportState.AlwaysSupported, + [OperatingSystem.BlackBerryTabletOS]: TouchSupportState.AlwaysSupported, + [OperatingSystem.WindowsPhone]: TouchSupportState.AlwaysSupported, + [OperatingSystem.Windows10Mobile]: TouchSupportState.AlwaysSupported, + [OperatingSystem.Windows]: TouchSupportState.MayBeSupported, + [OperatingSystem.Linux]: TouchSupportState.MayBeSupported, + [OperatingSystem.macOS]: TouchSupportState.NeverSupported, // Consider Touch Bar as a special case +}; + +export function determineTouchSupportOptions(os: OperatingSystem): boolean[] { + const state = TouchSupportPerOperatingSystem[os]; + switch (state) { + case TouchSupportState.AlwaysSupported: + return [true]; + case TouchSupportState.MayBeSupported: + return [true, false]; + case TouchSupportState.NeverSupported: + return [false]; + default: + throw new Error(`Unknown state: ${TouchSupportState[state]}`); + } +} diff --git a/tests/shared/FormatAssertionMessage.ts b/tests/shared/FormatAssertionMessage.ts new file mode 100644 index 00000000..f50ed4fd --- /dev/null +++ b/tests/shared/FormatAssertionMessage.ts @@ -0,0 +1,7 @@ +export function formatAssertionMessage(lines: readonly string[]) { + return [ // Using many newlines so `vitest` output looks good + '\n---', + ...lines, + '---\n\n', + ].join('\n'); +} diff --git a/tests/shared/Spies/WindowEventSpies.ts b/tests/shared/Spies/WindowEventSpies.ts new file mode 100644 index 00000000..55a2b0cf --- /dev/null +++ b/tests/shared/Spies/WindowEventSpies.ts @@ -0,0 +1,67 @@ +export type EventName = keyof WindowEventMap; + +export function createWindowEventSpies(restoreCallback: (restoreFunc: () => void) => void) { + const originalAddEventListener = window.addEventListener; + const originalRemoveEventListener = window.removeEventListener; + + const currentListeners = new Array>(); + + const addEventListenerCalls = new Array>(); + const removeEventListenerCalls = new Array>(); + + window.addEventListener = ( + ...args: Parameters + ): ReturnType => { + addEventListenerCalls.push(args); + currentListeners.push(args); + return originalAddEventListener.call(window, ...args); + }; + + window.removeEventListener = ( + ...args: Parameters + ): ReturnType => { + removeEventListenerCalls.push(args); + const [type, listener] = args; + const registeredListener = findCurrentListener(type as EventName, listener); + if (registeredListener) { + const index = currentListeners.indexOf(registeredListener); + if (index > -1) { + currentListeners.splice(index, 1); + } + } + return originalRemoveEventListener.call(window, ...args); + }; + + function findCurrentListener( + type: EventName, + listener: EventListenerOrEventListenerObject, + ): Parameters | undefined { + return currentListeners.find((args) => { + const [eventType, eventListener] = args; + return eventType === type && listener === eventListener; + }); + } + + restoreCallback(() => { + window.addEventListener = originalAddEventListener; + window.removeEventListener = originalRemoveEventListener; + }); + + return { + isAddEventCalled(eventType: EventName): boolean { + const call = addEventListenerCalls.find((args) => { + const [type] = args; + return type === eventType; + }); + return call !== undefined; + }, + isRemoveEventCalled(eventType: EventName) { + const call = removeEventListenerCalls.find((args) => { + const [type] = args; + return type === eventType; + }); + return call !== undefined; + }, + currentListeners, + }; +} diff --git a/tests/unit/application/Context/State/Selection/Script/ExpectEqualSelectedScripts.ts b/tests/unit/application/Context/State/Selection/Script/ExpectEqualSelectedScripts.ts index 98730349..28f1eabc 100644 --- a/tests/unit/application/Context/State/Selection/Script/ExpectEqualSelectedScripts.ts +++ b/tests/unit/application/Context/State/Selection/Script/ExpectEqualSelectedScripts.ts @@ -1,4 +1,5 @@ import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; export function expectEqualSelectedScripts( actual: readonly SelectedScript[], @@ -14,11 +15,11 @@ function expectSameScriptIds( ) { const existingScriptIds = expected.map((script) => script.id).sort(); const expectedScriptIds = actual.map((script) => script.id).sort(); - expect(existingScriptIds).to.deep.equal(expectedScriptIds, [ + expect(existingScriptIds).to.deep.equal(expectedScriptIds, formatAssertionMessage([ 'Unexpected script IDs.', `Expected: ${expectedScriptIds.join(', ')}`, `Actual: ${existingScriptIds.join(', ')}`, - ].join('\n')); + ])); } function expectSameRevertStates( @@ -33,7 +34,7 @@ function expectSameRevertStates( } return script.revert !== other.revert; }); - expect(scriptsWithDifferentRevertStates).to.have.lengthOf(0, [ + expect(scriptsWithDifferentRevertStates).to.have.lengthOf(0, formatAssertionMessage([ 'Scripts with different revert states:', scriptsWithDifferentRevertStates .map((s) => [ @@ -42,5 +43,5 @@ function expectSameRevertStates( `Expected revert state: "${expected.find((existing) => existing.id === s.id)?.revert ?? 'unknown'}"`, ].map((line) => `\t${line}`).join('\n')) .join('\n---\n'), - ].join('\n')); + ])); } diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts index 9d195391..ee2ddf79 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts @@ -12,6 +12,7 @@ import { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Scrip import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { IExpressionEvaluationContext } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext'; import { expectExists } from '@tests/shared/Assertions/ExpectExists'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; describe('Expression', () => { describe('ctor', () => { @@ -116,11 +117,10 @@ describe('Expression', () => { // arrange const actual = sut.evaluate(context); // assert - expect(expected).to.equal(actual, printMessage()); - function printMessage(): string { - return `\nGiven arguments: ${JSON.stringify(givenArguments)}\n` - + `\nExpected parameter names: ${JSON.stringify(expectedParameterNames)}\n`; - } + expect(expected).to.equal(actual, formatAssertionMessage([ + `Given arguments: ${JSON.stringify(givenArguments)}`, + `Expected parameter names: ${JSON.stringify(expectedParameterNames)}`, + ])); }); it('sends pipeline compiler as it is', () => { // arrange diff --git a/tests/unit/application/Parser/Script/Compiler/Function/ExpectFunctionBodyType.ts b/tests/unit/application/Parser/Script/Compiler/Function/ExpectFunctionBodyType.ts index 4147fe27..f934be1e 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/ExpectFunctionBodyType.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/ExpectFunctionBodyType.ts @@ -1,6 +1,7 @@ import { CallFunctionBody, CodeFunctionBody, FunctionBodyType, SharedFunctionBody, } from '@/application/Parser/Script/Compiler/Function/ISharedFunction'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; export function expectCodeFunctionBody( body: SharedFunctionBody, @@ -16,14 +17,9 @@ export function expectCallsFunctionBody( function expectBodyType(body: SharedFunctionBody, expectedType: FunctionBodyType) { const actualType = body.type; - expect(actualType).to.equal( - expectedType, - [ - '\n---', - `Actual: ${FunctionBodyType[actualType]}`, - `Expected: ${FunctionBodyType[expectedType]}`, - `Body: ${JSON.stringify(body)}`, - '---\n\n', - ].join('\n'), - ); + expect(actualType).to.equal(expectedType, formatAssertionMessage([ + `Actual: ${FunctionBodyType[actualType]}`, + `Expected: ${FunctionBodyType[expectedType]}`, + `Body: ${JSON.stringify(body)}`, + ])); } diff --git a/tests/unit/application/collections/NoUnintentedInlining.spec.ts b/tests/unit/application/collections/NoUnintentedInlining.spec.ts index 2ea47c14..ba402c7e 100644 --- a/tests/unit/application/collections/NoUnintentedInlining.spec.ts +++ b/tests/unit/application/collections/NoUnintentedInlining.spec.ts @@ -1,6 +1,7 @@ import { readdirSync, readFileSync } from 'fs'; import { resolve, join, basename } from 'path'; import { describe, it, expect } from 'vitest'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; /* A common mistake when working with yaml files to forget mentioning that a value should @@ -24,11 +25,10 @@ describe('collection files to have no unintended inlining', () => { // act const lines = await findBadLineNumbers(testCase.content); // assert - expect(lines).to.be.have.lengthOf(0, printMessage()); - function printMessage(): string { - return 'Did you intend to have multi-lined string in lines: ' // eslint-disable-line prefer-template - + lines.map(((line) => line.toString())).join(', '); - } + expect(lines).to.be.have.lengthOf(0, formatAssertionMessage([ + 'Did you intend to have multi-lined string in lines: ', + lines.map(((line) => line.toString())).join(', '), + ])); }); } }); diff --git a/tests/unit/domain/Application.spec.ts b/tests/unit/domain/Application.spec.ts index 80e5a528..fa14330c 100644 --- a/tests/unit/domain/Application.spec.ts +++ b/tests/unit/domain/Application.spec.ts @@ -69,7 +69,7 @@ describe('Application', () => { value: [ new CategoryCollectionStub().withOs(OperatingSystem.Windows), new CategoryCollectionStub().withOs(OperatingSystem.Windows), - new CategoryCollectionStub().withOs(OperatingSystem.BlackBerry), + new CategoryCollectionStub().withOs(OperatingSystem.BlackBerry10), ], }, ]; diff --git a/tests/unit/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector.spec.ts b/tests/unit/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector.spec.ts deleted file mode 100644 index 95c557df..00000000 --- a/tests/unit/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { OperatingSystem } from '@/domain/OperatingSystem'; -import { BrowserOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector'; -import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; -import { BrowserOsTestCases } from './BrowserOsTestCases'; - -describe('BrowserOsDetector', () => { - describe('returns undefined when user agent is absent', () => { - itEachAbsentStringValue((absentValue) => { - // arrange - const expected = undefined; - const userAgent = absentValue; - const sut = new BrowserOsDetector(); - // act - const actual = sut.detect(userAgent); - // assert - expect(actual).to.equal(expected); - }, { excludeNull: true, excludeUndefined: true }); - }); - it('detects as expected', () => { - BrowserOsTestCases.forEach((testCase) => { - // arrange - const sut = new BrowserOsDetector(); - // act - const actual = sut.detect(testCase.userAgent); - // assert - expect(actual).to.equal(testCase.expectedOs, printMessage()); - function printMessage(): string { - return `Expected: "${OperatingSystem[testCase.expectedOs]}"\n` - + `Actual: "${actual === undefined ? 'undefined' : OperatingSystem[actual]}"\n` - + `UserAgent: "${testCase.userAgent}"`; - } - }); - }); -}); diff --git a/tests/unit/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsTestCases.ts b/tests/unit/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsTestCases.ts deleted file mode 100644 index 383ea2a3..00000000 --- a/tests/unit/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsTestCases.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { OperatingSystem } from '@/domain/OperatingSystem'; - -interface IBrowserOsTestCase { - userAgent: string; - expectedOs: OperatingSystem; -} - -export const BrowserOsTestCases: ReadonlyArray = [ - { - userAgent: 'Mozilla/5.0 (Windows NT 6.3; Win64, x64; Trident/7.0; rv:11.0) like Gecko', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; Win64; x64; Trident/6.0)', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.0; Trident/5.0)', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0)', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/18.17763', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/17.17134', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; WebView/3.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36 Edge/15.15063', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.82 Safari/537.36 Edge/14.14316', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Safari/537.36 Edge/13.10586', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (Windows Phone 10.0; Android 5.1.1; NOKIA; Lumia 1520) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/13.10586', - expectedOs: OperatingSystem.WindowsPhone, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.10136', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:66.0) Gecko/20100101 Firefox/66.0', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:66.0) Gecko/20100101 Firefox/66.0', - expectedOs: OperatingSystem.macOS, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Safari/537.36', - expectedOs: OperatingSystem.macOS, - }, - { - userAgent: 'Mozilla/5.0 (X11; CrOS x86_64 11316.165.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.122 Safari/537.36', - expectedOs: OperatingSystem.ChromeOS, - }, - { - userAgent: 'Mozilla/5.0 (X11; CrOS x86_64 8872.76.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.105 Safari/537.36', - expectedOs: OperatingSystem.ChromeOS, - }, - { - userAgent: 'Mozilla/5.0 (X11; CrOS armv7l 4537.56.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.38 Safari/537.36', - expectedOs: OperatingSystem.ChromeOS, - }, - { - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0.3 Safari/605.1.15', - expectedOs: OperatingSystem.macOS, - }, - { - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15', - expectedOs: OperatingSystem.macOS, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36 OPR/58.0.3135.114', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.170 Safari/537.36 OPR/53.0.2907.68', - expectedOs: OperatingSystem.macOS, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2393.94 Safari/537.36 OPR/42.0.2393.94', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.82 Safari/537.36 OPR/29.0.1795.41 (Edition beta)', - expectedOs: OperatingSystem.macOS, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.52 Safari/537.36 OPR/15.0.1147.100', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Opera/9.80 (Windows NT 6.0) Presto/2.12.388 Version/12.14', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.15 Version/10.10', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Opera/9.27 (Windows NT 5.1; U; en)', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', - expectedOs: OperatingSystem.iOS, - }, - { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', - expectedOs: OperatingSystem.iOS, - }, - { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_0 like Mac OS X) AppleWebKit/602.1.38 (KHTML, like Gecko) Version/10.0 Mobile/14A300 Safari/602.1', - expectedOs: OperatingSystem.iOS, - }, - { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', - expectedOs: OperatingSystem.iOS, - }, - { - userAgent: 'Opera/9.80 (Android; Opera Mini/32.0/88.150; U; sr) Presto/2.12 Version/12.16', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Opera/9.80 (Android; Opera Mini/8.0.1807/36.1609; U; en) Presto/2.12.423 Version/12.16', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; U; Android 4.4.4; pt-br; SM-G530BT Build/KTU84P) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; U; Android 4.4.2; zh-cn; Q40; Android/4.4.2; Release/12.15.2015) AppleWebKit/534.30 (KHTML, like Gecko) Mobile Safari/534.30', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.1.0.1429 Mobile Safari/537.10+', - expectedOs: OperatingSystem.BlackBerry, - }, - { - userAgent: 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.0.0; en-US) AppleWebKit/535.8+ (KHTML, like Gecko) Version/7.2.0.0 Safari/535.8+', - expectedOs: OperatingSystem.BlackBerryTabletOS, - }, - { - userAgent: 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9800; en-US) AppleWebKit/534.8+ (KHTML, like Gecko) Version/6.0.0.466 Mobile Safari/534.8+', - expectedOs: OperatingSystem.BlackBerryOS, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 4.4.4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.52 Safari/537.36 Mobile OPR/15.0.1147.100', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 2.3.4; MT11i Build/4.0.2.A.0.62) AppleWebKit/537.22 (KHTML, like Gecko) Chrome/25.0.1364.123 Mobile Safari/537.22 OPR/14.0.1025.52315', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Opera/9.80 (Windows NT 6.1; Opera Tablet/15165; U; en) Presto/2.8.149 Version/11.1', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Opera/9.80 (Android 2.2; Opera Mobi/-2118645896; U; pl) Presto/2.7.60 Version/10.5', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 9; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 6.0; CAM-L03) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.99 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 4.2.2; GT-I9505 Build/JDQ39) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 4.3; Nexus 10 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/46.0.2490.76 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Android 9; Mobile; rv:64.0) Gecko/64.0 Firefox/64.0', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) FxiOS/1.0 Mobile/12F69 Safari/600.1.4', - expectedOs: OperatingSystem.iOS, - }, - { - userAgent: 'Mozilla/5.0 (Mobile; Windows Phone 8.1; Android 4.0; ARM; Trident/7.0; Touch; rv:11.0; IEMobile/11.0; NOKIA; Lumia 625) like iPhone OS 7_0_3 Mac OS X AppleWebKit/537 (KHTML, like Gecko) Mobile Safari/537', - expectedOs: OperatingSystem.WindowsPhone, - }, - { - userAgent: 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)', - expectedOs: OperatingSystem.WindowsPhone, - }, - { - userAgent: 'Mozilla/5.0 (Linux; U; Android 6.0; en-US; CPH1609 Build/MRA58K) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.108 UCBrowser/12.10.2.1164 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'UCWEB/2.0 (Linux; U; Adr 5.1; en-US; Lenovo Z90a40 Build/LMY47O) U2/1.0.0 UCBrowser/11.1.5.890 U2/1.0.0 Mobile', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; U; Android 5.1; en-US; Lenovo Z90a40 Build/LMY47O) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 UCBrowser/11.1.5.890 U3/0.8.0 Mobile Safari/534.30', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'UCWEB/2.0 (Linux; U; Adr 2.3; en-US; MI-ONEPlus) U2/1.0.0 UCBrowser/8.6.0.199 U2/1.0.0 Mobile', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; U; Android 2.3; zh-CN; MI-ONEPlus) AppleWebKit/534.13 (KHTML, like Gecko) UCBrowser/8.6.0.199 U3/0.8.0 Mobile Safari/534.13', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 9; SAMSUNG SM-G965F Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/9.0 Chrome/67.0.3396.87 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 8.0.0; SAMSUNG SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/8.2 Chrome/63.0.3239.111 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 7.0; SAMSUNG SM-J330FN Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/7.2 Chrome/59.0.3071.125 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 5.0.2; SAMSUNG SM-G925F Build/LRX22G) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/4.0 Chrome/44.0.2403.133 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 5.0.2; SAMSUNG SM-G925F Build/LRX22G) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/4.0 Chrome/44.0.2403.133 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; U; Android 8.1.0; zh-cn; vivo X21A Build/OPM1.171019.011) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.132 MQQBrowser/9.1 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; U; Android 4.4.2; zh-cn; GT-I9500 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko)Version/4.0 MQQBrowser/5.0 QQ-URL-Manager Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Linux; Android 9; ONEPLUS A6003) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Mobile Safari/537.36', - expectedOs: OperatingSystem.Android, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (Windows NT 6.4; WOW64; rv:32.0) Gecko/20100101 Firefox/32.0', - expectedOs: OperatingSystem.Windows, - }, - { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', - expectedOs: OperatingSystem.iOS, - }, - { - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36', - expectedOs: OperatingSystem.macOS, - }, - { - userAgent: 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:66.0) Gecko/20100101 Firefox/66.0', - expectedOs: OperatingSystem.Linux, - }, - { - userAgent: 'Mozilla/5.0 (X11; CrOS x86_64 11316.165.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.122 Safari/537.36', - expectedOs: OperatingSystem.ChromeOS, - }, - { - userAgent: 'Mozilla/5.0 (Mobile; LYF/F90M/LYF_F90M_000-03-12-110119; Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', - expectedOs: OperatingSystem.KaiOS, - }, -]; diff --git a/tests/unit/infrastructure/RuntimeEnvironment/BrowserOs/ConditionBasedOsDetector.spec.ts b/tests/unit/infrastructure/RuntimeEnvironment/BrowserOs/ConditionBasedOsDetector.spec.ts new file mode 100644 index 00000000..d16fa912 --- /dev/null +++ b/tests/unit/infrastructure/RuntimeEnvironment/BrowserOs/ConditionBasedOsDetector.spec.ts @@ -0,0 +1,260 @@ +import { describe, it, expect } from 'vitest'; +import { BrowserCondition, TouchSupportExpectation } from '@/infrastructure/RuntimeEnvironment/BrowserOs/BrowserCondition'; +import { ConditionBasedOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/ConditionBasedOsDetector'; +import { getAbsentStringTestCases, itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { BrowserEnvironmentStub } from '@tests/unit/shared/Stubs/BrowserEnvironmentStub'; +import { BrowserConditionStub } from '@tests/unit/shared/Stubs/BrowserConditionStub'; +import { EnumRangeTestRunner } from '@tests/unit/application/Common/EnumRangeTestRunner'; + +describe('ConditionBasedOsDetector', () => { + describe('constructor', () => { + describe('throws when given no conditions', () => { + itEachAbsentCollectionValue((absentCollection) => { + // arrange + const expectedError = 'empty conditions'; + const conditions = absentCollection; + // act + const act = () => new ConditionBasedOsDetectorBuilder() + .withConditions(conditions) + .build(); + // assert + expect(act).to.throw(expectedError); + }, { excludeUndefined: true, excludeNull: true }); + }); + it('throws if user agent part is missing', () => { + // arrange + const expectedError = 'Each condition must include at least one identifiable part of the user agent string.'; + const invalidCondition = new BrowserConditionStub().withExistingPartsInSameUserAgent([]); + // act + const act = () => new ConditionBasedOsDetectorBuilder() + .withConditions([invalidCondition]) + .build(); + // assert + expect(act).to.throw(expectedError); + }); + describe('validates touch support expectation range', () => { + // arrange + const validValue = TouchSupportExpectation.MustExist; + // act + const act = (touchSupport: TouchSupportExpectation) => new ConditionBasedOsDetectorBuilder() + .withConditions([new BrowserConditionStub().withTouchSupport(touchSupport)]) + .build(); + // assert + new EnumRangeTestRunner(act) + .testOutOfRangeThrows() + .testValidValueDoesNotThrow(validValue); + }); + it('throws if duplicate parts exist in user agent', () => { + // arrange + const expectedError = 'Found duplicate entries in user agent parts: Windows. Each part should be unique.'; + const invalidCondition = { + operatingSystem: OperatingSystem.Windows, + existingPartsInSameUserAgent: ['Windows', 'Windows'], + }; + // act + const act = () => new ConditionBasedOsDetectorBuilder() + .withConditions([invalidCondition]) + .build(); + // assert + expect(act).toThrowError(expectedError); + }); + it('throws if duplicate non-existing parts exist in user agent', () => { + // arrange + const expectedError = 'Found duplicate entries in user agent parts: Linux. Each part should be unique.'; + const invalidCondition = { + operatingSystem: OperatingSystem.Linux, + existingPartsInSameUserAgent: ['Linux'], + notExistingPartsInUserAgent: ['Linux'], + }; + // act + const act = () => new ConditionBasedOsDetectorBuilder() + .withConditions([invalidCondition]) + .build(); + // assert + expect(act).toThrowError(expectedError); + }); + it('throws if duplicates found in any user agent parts', () => { + // arrange + const expectedError = 'Found duplicate entries in user agent parts: Android. Each part should be unique.'; + const invalidCondition = { + operatingSystem: OperatingSystem.Android, + existingPartsInSameUserAgent: ['Android'], + notExistingPartsInUserAgent: ['iOS', 'Android'], + }; + // act + const act = () => new ConditionBasedOsDetectorBuilder() + .withConditions([invalidCondition]) + .build(); + // assert + expect(act).toThrowError(expectedError); + }); + }); + describe('detect', () => { + it('detects the correct OS when multiple conditions match', () => { + // arrange + const expectedOperatingSystem = OperatingSystem.Linux; + const testUserAgent = 'test-user-agent'; + const expectedCondition = new BrowserConditionStub() + .withOperatingSystem(expectedOperatingSystem) + .withExistingPartsInSameUserAgent([testUserAgent]); + const conditions = [ + expectedCondition, + new BrowserConditionStub() + .withExistingPartsInSameUserAgent(['unrelated user agent']) + .withOperatingSystem(OperatingSystem.Android), + new BrowserConditionStub() + .withNotExistingPartsInUserAgent([testUserAgent]) + .withOperatingSystem(OperatingSystem.macOS), + ]; + const environment = new BrowserEnvironmentStub() + .withUserAgent(testUserAgent); + const detector = new ConditionBasedOsDetectorBuilder() + .withConditions(conditions) + .build(); + // act + const actualOperatingSystem = detector.detect(environment); + // assert + expect(actualOperatingSystem).to.equal(expectedOperatingSystem); + }); + + describe('user agent checks', () => { + const testScenarios: ReadonlyArray<{ + readonly description: string; + readonly buildEnvironment: (environment: BrowserEnvironmentStub) => BrowserEnvironmentStub; + readonly buildCondition: (condition: BrowserConditionStub) => BrowserConditionStub; + readonly detects: boolean; + }> = [ + ...getAbsentStringTestCases({ excludeUndefined: true, excludeNull: true }) + .map((testCase) => ({ + description: `does not detect when user agent is empty (${testCase.valueName})`, + buildEnvironment: (environment) => environment.withUserAgent(testCase.absentValue), + buildCondition: (condition) => condition, + detects: false, + })), + { + description: 'detects when user agent matches completely', + buildEnvironment: (environment) => environment.withUserAgent('test-user-agent'), + buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['test-user-agent']), + detects: true, + }, + { + description: 'detects when substring of user agent exists', + buildEnvironment: (environment) => environment.withUserAgent('test-user-agent'), + buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['test']), + detects: true, + }, + { + description: 'does not detect when no part of user agent exists', + buildEnvironment: (environment) => environment.withUserAgent('unrelated-user-agent'), + buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['lorem-ipsum']), + detects: false, + }, + { + description: 'detects when non-existing parts do not match', + buildEnvironment: (environment) => environment.withUserAgent('1-3'), + buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['1']).withNotExistingPartsInUserAgent(['2']), + detects: true, + }, + { + description: 'does not detect when non-existing and existing parts match', + buildEnvironment: (environment) => environment.withUserAgent('1-2'), + buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['1']).withNotExistingPartsInUserAgent(['2']), + detects: false, + }, + ]; + testScenarios.forEach(({ + description, buildEnvironment, buildCondition, detects, + }) => { + it(description, () => { + // arrange + const environment = buildEnvironment(new BrowserEnvironmentStub()); + const condition = buildCondition( + new BrowserConditionStub().withOperatingSystem(OperatingSystem.Linux), + ); + const detector = new ConditionBasedOsDetectorBuilder() + .withConditions([condition]) + .build(); + // act + const actualOperatingSystem = detector.detect(environment); + // assert + expect(actualOperatingSystem !== undefined).to.equal(detects); + }); + }); + }); + + describe('touch support checks', () => { + const testScenarios: ReadonlyArray<{ + readonly description: string; + readonly expectation: TouchSupportExpectation; + readonly isTouchSupportInEnvironment: boolean; + readonly detects: boolean; + }> = [ + { + description: 'detects when touch support exists and is expected', + expectation: TouchSupportExpectation.MustExist, + isTouchSupportInEnvironment: true, + detects: true, + }, + { + description: 'does not detect when touch support does not exists but is expected', + expectation: TouchSupportExpectation.MustExist, + isTouchSupportInEnvironment: false, + detects: false, + }, + { + description: 'detects when touch support does not exist and is not expected', + expectation: TouchSupportExpectation.MustNotExist, + isTouchSupportInEnvironment: false, + detects: true, + }, + { + description: 'does not detect when touch support exists but is not expected', + expectation: TouchSupportExpectation.MustNotExist, + isTouchSupportInEnvironment: true, + detects: false, + }, + ]; + testScenarios.forEach(({ + description, expectation, isTouchSupportInEnvironment, detects, + }) => { + it(description, () => { + // arrange + const userAgent = 'iPhone'; + const environment = new BrowserEnvironmentStub() + .withUserAgent(userAgent) + .withIsTouchSupported(isTouchSupportInEnvironment); + const conditionWithTouchSupport = new BrowserConditionStub() + .withExistingPartsInSameUserAgent([userAgent]) + .withTouchSupport(expectation); + const detector = new ConditionBasedOsDetectorBuilder() + .withConditions([conditionWithTouchSupport]) + .build(); + // act + const actualOperatingSystem = detector.detect(environment); + // assert + expect(actualOperatingSystem !== undefined) + .to.equal(detects); + }); + }); + }); + }); +}); + +class ConditionBasedOsDetectorBuilder { + private conditions: readonly BrowserCondition[] = [{ + operatingSystem: OperatingSystem.iOS, + existingPartsInSameUserAgent: ['iPhone'], + }]; + + public withConditions(conditions: readonly BrowserCondition[]): this { + this.conditions = conditions; + return this; + } + + public build(): ConditionBasedOsDetector { + return new ConditionBasedOsDetector( + this.conditions, + ); + } +} diff --git a/tests/unit/infrastructure/RuntimeEnvironment/RuntimeEnvironment.spec.ts b/tests/unit/infrastructure/RuntimeEnvironment/RuntimeEnvironment.spec.ts index cdaca116..ef801aca 100644 --- a/tests/unit/infrastructure/RuntimeEnvironment/RuntimeEnvironment.spec.ts +++ b/tests/unit/infrastructure/RuntimeEnvironment/RuntimeEnvironment.spec.ts @@ -1,11 +1,13 @@ +// eslint-disable-next-line max-classes-per-file import { describe, it, expect } from 'vitest'; -import { IBrowserOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/IBrowserOsDetector'; +import { BrowserOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { BrowserOsDetectorStub } from '@tests/unit/shared/Stubs/BrowserOsDetectorStub'; import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables'; import { EnvironmentVariablesStub } from '@tests/unit/shared/Stubs/EnvironmentVariablesStub'; +import { expectExists } from '@tests/shared/Assertions/ExpectExists'; describe('RuntimeEnvironment', () => { describe('ctor', () => { @@ -22,6 +24,22 @@ describe('RuntimeEnvironment', () => { expect(act).to.throw(expectedError); }); }); + it('uses browser OS detector with current touch support', () => { + // arrange + const expectedTouchSupport = true; + const osDetector = new BrowserOsDetectorStub(); + // act + createEnvironment({ + window: { os: undefined, navigator: { userAgent: 'Forcing touch detection' } } as Partial, + isTouchSupported: expectedTouchSupport, + browserOsDetector: osDetector, + }); + // assert + const actualCall = osDetector.callHistory.find((c) => c.methodName === 'detect'); + expectExists(actualCall); + const [{ isTouchSupported: actualTouchSupport }] = actualCall.args; + expect(actualTouchSupport).to.equal(expectedTouchSupport); + }); }); describe('isDesktop', () => { it('returns true when window property isDesktop is true', () => { @@ -54,7 +72,7 @@ describe('RuntimeEnvironment', () => { it('returns undefined if user agent is missing', () => { // arrange const expected = undefined; - const browserDetectorMock: IBrowserOsDetector = { + const browserDetectorMock: BrowserOsDetector = { detect: () => { throw new Error('should not reach here'); }, @@ -76,9 +94,9 @@ describe('RuntimeEnvironment', () => { userAgent: givenUserAgent, }, }; - const browserDetectorMock: IBrowserOsDetector = { - detect: (agent) => { - if (agent !== givenUserAgent) { + const browserDetectorMock: BrowserOsDetector = { + detect: (environment) => { + if (environment.userAgent !== givenUserAgent) { throw new Error('Unexpected user agent'); } return expected; @@ -155,23 +173,31 @@ describe('RuntimeEnvironment', () => { }); interface EnvironmentOptions { - window: Partial; - browserOsDetector?: IBrowserOsDetector; - environmentVariables?: IEnvironmentVariables; + readonly window?: Partial; + readonly browserOsDetector?: BrowserOsDetector; + readonly environmentVariables?: IEnvironmentVariables; + readonly isTouchSupported?: boolean; } function createEnvironment(options: Partial = {}): TestableRuntimeEnvironment { - const defaultOptions: EnvironmentOptions = { + const defaultOptions: Required = { window: {}, browserOsDetector: new BrowserOsDetectorStub(), environmentVariables: new EnvironmentVariablesStub(), + isTouchSupported: false, }; return new TestableRuntimeEnvironment({ ...defaultOptions, ...options }); } class TestableRuntimeEnvironment extends RuntimeEnvironment { - public constructor(options: EnvironmentOptions) { - super(options.window, options.environmentVariables, options.browserOsDetector); + /* Using a separate object instead of `ConstructorParameter<..>` */ + public constructor(options: Required) { + super( + options.window, + options.environmentVariables, + options.browserOsDetector, + () => options.isTouchSupported, + ); } } diff --git a/tests/unit/infrastructure/RuntimeEnvironment/TouchSupportDetection.spec.ts b/tests/unit/infrastructure/RuntimeEnvironment/TouchSupportDetection.spec.ts new file mode 100644 index 00000000..c2e8c379 --- /dev/null +++ b/tests/unit/infrastructure/RuntimeEnvironment/TouchSupportDetection.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { BrowserTouchSupportAccessor, isTouchEnabledDevice } from '@/infrastructure/RuntimeEnvironment/TouchSupportDetection'; + +describe('TouchSupportDetection', () => { + describe('isTouchEnabledDevice', () => { + const testScenarios: ReadonlyArray<{ + readonly description: string; + readonly accessor: BrowserTouchSupportAccessor; + readonly expectedTouch: boolean; + }> = [ + { + description: 'detects no touch capabilities', + accessor: createMockAccessor(), + expectedTouch: false, + }, + { + description: 'detects touch capability with defined document.ontouchend', + accessor: createMockAccessor({ documentOntouchend: () => 'not-undefined' }), + expectedTouch: true, + }, + { + description: 'detects touch capability with navigator.maxTouchPoints > 0', + accessor: createMockAccessor({ navigatorMaxTouchPoints: () => 1 }), + expectedTouch: true, + }, + { + description: 'detects touch capability when matchMedia for pointer coarse is true', + accessor: createMockAccessor({ + windowMatchMediaMatches: (query: string) => { + return query === '(any-pointer: coarse)'; + }, + }), + expectedTouch: true, + }, + { + description: 'detects touch capability with defined window.TouchEvent', + expectedTouch: true, + accessor: createMockAccessor({ windowTouchEvent: () => class {} }), + }, + ]; + testScenarios.forEach(({ description, accessor, expectedTouch }) => { + it(`${description} - returns ${expectedTouch}`, () => { + // act + const isTouchDetected = isTouchEnabledDevice(accessor); + // assert + expect(isTouchDetected).to.equal(expectedTouch); + }); + }); + }); +}); + +function createMockAccessor( + touchSupportFeatures: Partial = {}, +): BrowserTouchSupportAccessor { + const defaultTouchSupport: BrowserTouchSupportAccessor = { + navigatorMaxTouchPoints: () => undefined, + windowMatchMediaMatches: () => false, + documentOntouchend: () => undefined, + windowTouchEvent: () => undefined, + }; + return { + ...defaultTouchSupport, + ...touchSupportFeatures, + }; +} diff --git a/tests/unit/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.spec.ts b/tests/unit/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.spec.ts new file mode 100644 index 00000000..ab604891 --- /dev/null +++ b/tests/unit/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.spec.ts @@ -0,0 +1,137 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { BrowserAccessor, MobileSafariActivePseudoClassEnabler } from '@/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler'; +import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub'; + +describe('MobileSafariActivePseudoClassEnabler', () => { + describe('bootstrap', () => { + it('when environment is not iOS or iPadOS', () => { + // arrange + const operatingSystem = OperatingSystem.Android; + // act + const { isBootstrapped } = testBootstrap({ + operatingSystem, + }); + // assert + expect(isBootstrapped).to.equal(false); + }); + describe('for Apple mobile operating systems', () => { + // arrange + const appleMobileOperatingSystems = [OperatingSystem.iOS, OperatingSystem.iPadOS]; + appleMobileOperatingSystems.forEach((operatingSystem) => { + describe(`when operating system is ${OperatingSystem[operatingSystem]}`, () => { + describe('when browser is not Safari', () => { + UserAgents.nonSafariUserAgents.forEach((nonSafariUserAgent) => { + it(`ignores non-Safari user agent: "${nonSafariUserAgent}"`, () => { + // act + const { isBootstrapped } = testBootstrap({ + operatingSystem, + userAgent: nonSafariUserAgent, + }); + // assert + expect(isBootstrapped).to.equal(false); + }); + }); + }); + describe('when browser is Safari', () => { + UserAgents.safariUserAgents.forEach((safariUserAgent) => { + it(`activates for Safari user agent: "${safariUserAgent}"`, () => { + // act + const { isBootstrapped } = testBootstrap({ + operatingSystem, + userAgent: safariUserAgent, + }); + // assert + expect(isBootstrapped).to.equal(true); + }); + }); + }); + }); + }); + }); + }); +}); + +function testBootstrap(options?: { + operatingSystem?: OperatingSystem, + userAgent?: string, +}) { + // arrange + let isBootstrapped = false; + const browser: BrowserAccessor = { + getNavigatorUserAgent: () => options?.userAgent ?? UserAgents.nonSafariUserAgents[0], + addWindowEventListener: (type) => { + isBootstrapped = type === 'touchstart'; + }, + }; + const environment = new RuntimeEnvironmentStub().withOs( + options?.operatingSystem ?? OperatingSystem.macOS, + ); + // act + const sut = new MobileSafariActivePseudoClassEnabler(environment, browser); + sut.bootstrap(); + // assert + return { isBootstrapped }; +} + +const UserAgents: { + readonly safariUserAgents: readonly string[]; + readonly nonSafariUserAgents: readonly string[]; +} = { + safariUserAgents: [ + // macOS / iPad + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15', + // iPhone + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + // iPad mini + 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + ], + nonSafariUserAgents: [ + ...[ // Apple devices + // Chrome on macOS + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', + // Opera on macOS + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 OPR/105.0.0.0', + // Edge on macOS + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0', + // Firefox on macOS + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/119.0', + // Firefox Focus on iPhone + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/7.0.4 Mobile/16B91 Safari/605.1.15', + // Baidu Box App on iPhone + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 SP-engine/2.30.0 main%2F1.0 baiduboxapp/12.13.0.10 (Baidu; P2 16.6.1) NABar/1.0 themeUA=Theme/default', + // Baidu Browser on iPad + 'Mozilla/5.0 (iPad; CPU OS 13_3_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) BaiduHD/5.4.0.0 Mobile/10A406 Safari/8536.25', + // Baidu Browser on iPhone + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_2 like Mac OS X) AppleWebKit/603.2.4 (KHTML, like Gecko) Version/4.0 Chrome/37.0.0.0 Mobile Safari/537.36 baidubrowser/6.3.15.0', + // Edge on iPad + 'Mozilla/5.0 (iPad; CPU OS 17_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 EdgiOS/46.2.0 Mobile/15E148 Safari/605.1.15', + // Chrome on iPod: + 'Mozilla/5.0 (iPod; CPU iPhone OS 14_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/87.0.4280.77 Mobile/15E148 Safari/604.1', + // Opera mini on iPhone: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPiOS/16.0.15.124050 Mobile/15E148 Safari/9537.53', + // Opera Touch (discontinued): + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.7 Mobile/15E148 Safari/604.1 OPT/4.3.2', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPT/3.3.3 Mobile/15E148', + // Opera Mini on iPad: + 'Opera/9.80 (iPad; Opera Mini/7.0.5/191.283; U; es) Presto/2.12.423 Version/12.16', + // QQ Browser on iPhone: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 MQQBrowser/12.9.7 Mobile/15E148 Safari/604.1 QBWebViewUA/2 QBWebViewType/1 WKType/1', + ], + ...[ // Non Apple devices + // Chrome/Brave/QQ Browser on Windows + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', + // Firefox on Windows + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0', + // Edge on Windows + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0', + // Opera on Windows + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 OPR/105.0.0.0', + // UC Browser on Windows + 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 UBrowser/6.0.1308.1016 Safari/537.36', + // Opera mini on Android + 'Opera/9.80 (Android; Opera Mini/8.0.1807/36.1609; U; en) Presto/2.12.423 Version/12.16', + // Vivo Browser on Android, + 'Mozilla/5.0 (Linux; Android 10; V1990A; wv) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36 VivoBrowser/8.4.14.0', + ], + ], +} as const; diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewFilterEvent.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewFilterEvent.spec.ts index 1f231fd8..033d5c76 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewFilterEvent.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewFilterEvent.spec.ts @@ -21,6 +21,7 @@ import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCo import { NodeMetadataStub } from '@tests/unit/shared/Stubs/NodeMetadataStub'; import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; describe('UseTreeViewFilterEvent', () => { describe('initially', () => { @@ -259,16 +260,11 @@ function itExpectedFilterTriggeredEvent( } expect(event.value.predicate).toBeDefined(); const actualPredicateResult = event.value.predicate(givenNode); - expect(actualPredicateResult).to.equal( - expectedPredicateResult, - [ - '\n---', - `Script matches (${scriptMatches.length}): [${scriptMatches.map((s) => s.id).join(', ')}]`, - `Category matches (${categoryMatches.length}): [${categoryMatches.map((s) => s.id).join(', ')}]`, - `Expected node: "${givenNode.id}"`, - '---\n\n', - ].join('\n'), - ); + expect(actualPredicateResult).to.equal(expectedPredicateResult, formatAssertionMessage([ + `Script matches (${scriptMatches.length}): [${scriptMatches.map((s) => s.id).join(', ')}]`, + `Category matches (${categoryMatches.length}): [${categoryMatches.map((s) => s.id).join(', ')}]`, + `Expected node: "${givenNode.id}"`, + ])); }); }); } diff --git a/tests/unit/presentation/components/Shared/Icon/AppIcon.spec.ts b/tests/unit/presentation/components/Shared/Icon/AppIcon.spec.ts index 74798c57..a6f8371a 100644 --- a/tests/unit/presentation/components/Shared/Icon/AppIcon.spec.ts +++ b/tests/unit/presentation/components/Shared/Icon/AppIcon.spec.ts @@ -6,6 +6,7 @@ import { nextTick } from 'vue'; import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue'; import { IconName } from '@/presentation/components/Shared/Icon/IconName'; import { UseSvgLoaderStub } from '@tests/unit/shared/Stubs/UseSvgLoaderStub'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; describe('AppIcon.vue', () => { it('renders the correct SVG content based on the icon prop', async () => { @@ -23,12 +24,8 @@ describe('AppIcon.vue', () => { await nextTick(); // assert - const actualSvg = extractAndNormalizeSvg(wrapper.html()); - const expectedSvg = extractAndNormalizeSvg(expectedIconContent); - expect(actualSvg).to.equal( - expectedSvg, - `Expected:\n\n${expectedSvg}\n\nActual:\n\n${actualSvg}`, - ); + const actualSvg = wrapper.html(); + expectSvg(actualSvg, expectedIconContent); }); it('updates the SVG content when the icon prop changes', async () => { // arrange @@ -48,12 +45,8 @@ describe('AppIcon.vue', () => { await nextTick(); // assert - const actualSvg = extractAndNormalizeSvg(wrapper.html()); - const expectedSvg = extractAndNormalizeSvg(updatedIconContent); - expect(actualSvg).to.equal( - expectedSvg, - `Expected:\n\n${expectedSvg}\n\nActual:\n\n${actualSvg}`, - ); + const actualSvg = wrapper.html(); + expectSvg(actualSvg, updatedIconContent); }); it('emits `click` event when clicked', async () => { // arrange @@ -86,6 +79,15 @@ function mountComponent(options?: { }); } +function expectSvg(actualSvg: string, expectedSvg: string): ReturnType { + const normalizedExpectedSvg = extractAndNormalizeSvg(expectedSvg); + const normalizedActualSvg = extractAndNormalizeSvg(actualSvg); + return expect(normalizedActualSvg).to.equal(normalizedExpectedSvg, formatAssertionMessage([ + 'Expected:\n', normalizedExpectedSvg, + 'Actual:\n', normalizedActualSvg, + ])); +} + function extractAndNormalizeSvg(svgString: string): string { const svg = extractSvg(svgString); return normalizeSvg(svg); diff --git a/tests/unit/presentation/components/Shared/Modal/Hooks/UseEscapeKeyListener.spec.ts b/tests/unit/presentation/components/Shared/Modal/Hooks/UseEscapeKeyListener.spec.ts index 6d06104f..43bc2ae8 100644 --- a/tests/unit/presentation/components/Shared/Modal/Hooks/UseEscapeKeyListener.spec.ts +++ b/tests/unit/presentation/components/Shared/Modal/Hooks/UseEscapeKeyListener.spec.ts @@ -1,7 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { + describe, it, expect, afterEach, +} from 'vitest'; import { shallowMount } from '@vue/test-utils'; import { nextTick, defineComponent } from 'vue'; import { useEscapeKeyListener } from '@/presentation/components/Shared/Modal/Hooks/UseEscapeKeyListener'; +import { EventName, createWindowEventSpies } from '@tests/shared/Spies/WindowEventSpies'; describe('useEscapeKeyListener', () => { it('executes the callback when the Escape key is pressed', async () => { @@ -40,19 +43,20 @@ describe('useEscapeKeyListener', () => { it('adds an event listener on component mount', () => { // arrange - const { restore, isAddEventCalled } = createWindowEventSpies(); + const expectedEventType: EventName = 'keyup'; + const { isAddEventCalled } = createWindowEventSpies(afterEach); // act createComponent(); // assert - expect(isAddEventCalled()).to.equal(true); - restore(); + expect(isAddEventCalled(expectedEventType)).to.equal(true); }); it('removes the event listener on component unmount', async () => { // arrange - const { restore, isRemoveEventCalled } = createWindowEventSpies(); + const expectedEventType: EventName = 'keyup'; + const { isRemoveEventCalled } = createWindowEventSpies(afterEach); // act const wrapper = createComponent(); @@ -60,8 +64,7 @@ describe('useEscapeKeyListener', () => { await nextTick(); // assert - expect(isRemoveEventCalled()).to.equal(true); - restore(); + expect(isRemoveEventCalled(expectedEventType)).to.equal(true); }); }); @@ -73,46 +76,3 @@ function createComponent(callback = () => {}) { template: '
', })); } - -function createWindowEventSpies() { - let addEventCalled = false; - let removeEventCalled = false; - - const originalAddEventListener = window.addEventListener; - const originalRemoveEventListener = window.removeEventListener; - - window.addEventListener = ( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions, - ): void => { - if (type === 'keyup' && typeof listener === 'function') { - addEventCalled = true; - } - originalAddEventListener(type, listener, options); - }; - - window.removeEventListener = ( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | EventListenerOptions, - ): void => { - if (type === 'keyup' && typeof listener === 'function') { - removeEventCalled = true; - } - originalRemoveEventListener(type, listener, options); - }; - - return { - restore: () => { - window.addEventListener = originalAddEventListener; - window.removeEventListener = originalRemoveEventListener; - }, - isAddEventCalled() { - return addEventCalled; - }, - isRemoveEventCalled() { - return removeEventCalled; - }, - }; -} diff --git a/tests/unit/presentation/components/Shared/Modal/ModalContainer.spec.ts b/tests/unit/presentation/components/Shared/Modal/ModalContainer.spec.ts index 2e5e0829..67c24ae9 100644 --- a/tests/unit/presentation/components/Shared/Modal/ModalContainer.spec.ts +++ b/tests/unit/presentation/components/Shared/Modal/ModalContainer.spec.ts @@ -1,7 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { + describe, it, expect, afterEach, +} from 'vitest'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import ModalContainer from '@/presentation/components/Shared/Modal/ModalContainer.vue'; +import { createWindowEventSpies } from '@tests/shared/Spies/WindowEventSpies'; const DOM_MODAL_CONTAINER_SELECTOR = '.modal-container'; const COMPONENT_MODAL_OVERLAY_NAME = 'ModalOverlay'; @@ -70,17 +73,16 @@ describe('ModalContainer.vue', () => { it('closes on pressing ESC key', async () => { // arrange - const { triggerKeyUp, restore } = createWindowEventSpies(); + createWindowEventSpies(afterEach); const wrapper = mountComponent({ modelValue: true }); // act const escapeEvent = new KeyboardEvent('keyup', { key: 'Escape' }); - triggerKeyUp(escapeEvent); + window.dispatchEvent(escapeEvent); await wrapper.vm.$nextTick(); // assert expect(wrapper.emitted('update:modelValue')).to.deep.equal([[false]]); - restore(); }); it('emit false value after overlay and content transitions out and model prop is true', async () => { @@ -173,44 +175,3 @@ function mountComponent(options: { }, }); } - -function createWindowEventSpies() { - const originalAddEventListener = window.addEventListener; - const originalRemoveEventListener = window.removeEventListener; - - let savedListener: EventListenerOrEventListenerObject | null = null; - - window.addEventListener = ( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions, - ): void => { - if (type === 'keyup' && typeof listener === 'function') { - savedListener = listener; - } - originalAddEventListener.call(window, type, listener, options); - }; - - window.removeEventListener = ( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | EventListenerOptions, - ): void => { - if (type === 'keyup' && typeof listener === 'function') { - savedListener = null; - } - originalRemoveEventListener.call(window, type, listener, options); - }; - - return { - triggerKeyUp: (event: KeyboardEvent) => { - if (savedListener) { - (savedListener as EventListener)(event); - } - }, - restore: () => { - window.addEventListener = originalAddEventListener; - window.removeEventListener = originalRemoveEventListener; - }, - }; -} diff --git a/tests/unit/presentation/electron/preload/NodeOsMapper.spec.ts b/tests/unit/presentation/electron/preload/NodeOsMapper.spec.ts index 2fa82887..d1686443 100644 --- a/tests/unit/presentation/electron/preload/NodeOsMapper.spec.ts +++ b/tests/unit/presentation/electron/preload/NodeOsMapper.spec.ts @@ -1,6 +1,7 @@ import { describe } from 'vitest'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { convertPlatformToOs } from '@/presentation/electron/preload/NodeOsMapper'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; describe('NodeOsMapper', () => { describe('convertPlatformToOs', () => { @@ -45,15 +46,14 @@ describe('NodeOsMapper', () => { // act const actualOs = convertPlatformToOs(nodePlatform); // assert - expect(actualOs).to.equal(expectedOs, printMessage()); + expect(actualOs).to.equal(expectedOs, formatAssertionMessage([ + `Expected: "${printResult(expectedOs)}"\n`, + `Actual: "${printResult(actualOs)}"\n`, + `Platform: "${nodePlatform}"`, + ])); function printResult(os: ReturnType): string { return os === undefined ? 'undefined' : OperatingSystem[os]; } - function printMessage(): string { - return `Expected: "${printResult(expectedOs)}"\n` - + `Actual: "${printResult(actualOs)}"\n` - + `Platform: "${nodePlatform}"`; - } }); }); }); diff --git a/tests/unit/shared/Stubs/BrowserConditionStub.ts b/tests/unit/shared/Stubs/BrowserConditionStub.ts new file mode 100644 index 00000000..a057786e --- /dev/null +++ b/tests/unit/shared/Stubs/BrowserConditionStub.ts @@ -0,0 +1,36 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { BrowserCondition, TouchSupportExpectation } from '@/infrastructure/RuntimeEnvironment/BrowserOs/BrowserCondition'; + +export class BrowserConditionStub implements BrowserCondition { + public operatingSystem: OperatingSystem = OperatingSystem.Android; + + public existingPartsInSameUserAgent: readonly string[] = [ + `[${BrowserConditionStub.name}] existing part`, + ]; + + public notExistingPartsInUserAgent?: readonly string[] = [ + `[${BrowserConditionStub.name}] non-existing part`, + ]; + + public touchSupport?: TouchSupportExpectation = undefined; + + public withOperatingSystem(operatingSystem: OperatingSystem): this { + this.operatingSystem = operatingSystem; + return this; + } + + public withExistingPartsInSameUserAgent(existingPartsInSameUserAgent: readonly string[]): this { + this.existingPartsInSameUserAgent = existingPartsInSameUserAgent; + return this; + } + + public withNotExistingPartsInUserAgent(notExistingPartsInUserAgent?: readonly string[]): this { + this.notExistingPartsInUserAgent = notExistingPartsInUserAgent; + return this; + } + + public withTouchSupport(touchSupport?: TouchSupportExpectation): this { + this.touchSupport = touchSupport; + return this; + } +} diff --git a/tests/unit/shared/Stubs/BrowserEnvironmentStub.ts b/tests/unit/shared/Stubs/BrowserEnvironmentStub.ts new file mode 100644 index 00000000..1abe3c26 --- /dev/null +++ b/tests/unit/shared/Stubs/BrowserEnvironmentStub.ts @@ -0,0 +1,17 @@ +import { BrowserEnvironment } from '@/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector'; + +export class BrowserEnvironmentStub implements BrowserEnvironment { + public isTouchSupported = false; + + public userAgent = `[${BrowserEnvironmentStub.name}] User-Agent`; + + public withIsTouchSupported(isTouchSupported: boolean): this { + this.isTouchSupported = isTouchSupported; + return this; + } + + public withUserAgent(userAgent: string): this { + this.userAgent = userAgent; + return this; + } +} diff --git a/tests/unit/shared/Stubs/BrowserOsDetectorStub.ts b/tests/unit/shared/Stubs/BrowserOsDetectorStub.ts index a0c3b7f7..3812e14c 100644 --- a/tests/unit/shared/Stubs/BrowserOsDetectorStub.ts +++ b/tests/unit/shared/Stubs/BrowserOsDetectorStub.ts @@ -1,8 +1,15 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; -import { IBrowserOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/IBrowserOsDetector'; +import { BrowserEnvironment, BrowserOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector'; +import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; -export class BrowserOsDetectorStub implements IBrowserOsDetector { - public detect(): OperatingSystem { +export class BrowserOsDetectorStub + extends StubWithObservableMethodCalls + implements BrowserOsDetector { + public detect(environment: BrowserEnvironment): OperatingSystem { + this.registerMethodCall({ + methodName: 'detect', + args: [environment], + }); return OperatingSystem.BlackBerryTabletOS; } }