diff --git a/build/criteo-mraid.js b/build/criteo-mraid.js index 0f3e04f..55677ab 100644 --- a/build/criteo-mraid.js +++ b/build/criteo-mraid.js @@ -1 +1 @@ -!function(){"use strict";function isFunction(any){return"function"==typeof any}!function(LogLevel){LogLevel.Debug="Debug",LogLevel.Info="Info",LogLevel.Warning="Warning",LogLevel.Error="Error"}(LogLevel=LogLevel||{}),function(MraidEvent){MraidEvent.Ready="ready",MraidEvent.Error="error",MraidEvent.StateChange="stateChange",MraidEvent.ViewableChange="viewableChange"}(MraidEvent=MraidEvent||{});var LogLevel,MraidEvent,MraidState,MraidPlacementType,EventsCoordinator=function(){function EventsCoordinator(){this.eventListeners=new Map(Object.values(MraidEvent).map(function(e){return[e,new Set]}))}return EventsCoordinator.prototype.addEventListener=function(event,listener,logger){var _a;event&&this.isCorrectEvent(event)?listener?isFunction(listener)?null!=(_a=this.eventListeners.get(event))&&_a.add(listener):logger(LogLevel.Error,"addEventListener","Incorrect listener when addEventListener. \n Listener is not a function. Actual type = ".concat(typeof listener)):logger(LogLevel.Error,"addEventListener","Incorrect listener when addEventListener. It is null or undefined"):logger(LogLevel.Error,"addEventListener","Incorrect event when addEventListener.Type = ".concat(typeof event,", value = ").concat(event))},EventsCoordinator.prototype.removeEventListener=function(event,listener,logger){var listeners;event&&this.isCorrectEvent(event)?listener&&!isFunction(listener)?logger(LogLevel.Error,"removeEventListener","Incorrect listener when removeEventListener. \n Listener is not a function. Actual type = ".concat(typeof listener)):(listeners=this.eventListeners.get(event),listener?null!=listeners&&listeners.delete(listener):null!=listeners&&listeners.clear()):logger(LogLevel.Error,"removeEventListener","Incorrect event when removeEventListener.Type = ".concat(typeof event,", value = ").concat(event))},EventsCoordinator.prototype.fireReadyEvent=function(){var _a;null!=(_a=this.eventListeners.get(MraidEvent.Ready))&&_a.forEach(function(value){null!=value&&value()})},EventsCoordinator.prototype.fireErrorEvent=function(message,action){var _a;null!=(_a=this.eventListeners.get(MraidEvent.Error))&&_a.forEach(function(value){null!=value&&value(message,action)})},EventsCoordinator.prototype.fireStateChangeEvent=function(newState){var _a;null!=(_a=this.eventListeners.get(MraidEvent.StateChange))&&_a.forEach(function(value){null!=value&&value(newState)})},EventsCoordinator.prototype.fireViewableChangeEvent=function(isViewable){var _a;null!=(_a=this.eventListeners.get(MraidEvent.ViewableChange))&&_a.forEach(function(value){null!=value&&value(isViewable)})},EventsCoordinator.prototype.isCorrectEvent=function(event){return event&&this.eventListeners.has(event)},EventsCoordinator}(),ExpandProperties=(!function(MraidState){MraidState.Loading="loading",MraidState.Default="default",MraidState.Expanded="expanded",MraidState.Hidden="hidden"}(MraidState=MraidState||{}),!function(MraidPlacementType){MraidPlacementType.Unknown="",MraidPlacementType.Inline="inline",MraidPlacementType.Interstitial="interstitial"}(MraidPlacementType=MraidPlacementType||{}),function(width,height){this.useCustomClose=!1,this.isModal=!0,this.width=width,this.height=height});var SdkFeature,Size=function(){function Size(width,height){this.width=width,this.height=height}return Size.prototype.clone=function(){return new Size(this.width,this.height)},Size}();!function(SdkFeature){SdkFeature.Sms="sms",SdkFeature.Tel="tel",SdkFeature.Calendar="calendar",SdkFeature.StorePicture="storePicture",SdkFeature.InlineVideo="inlineVideo"}(SdkFeature=SdkFeature||{});var defaultSupportedSdkFeatures=new function(sms,tel,inlineVideo){this.calendar=!1,this.storePicture=!1,this.sms=sms,this.tel=tel,this.inlineVideo=inlineVideo}(!1,!1,!1),Position=function(){function Position(x,y,width,height){this.x=x,this.y=y,this.width=width,this.height=height}return Position.prototype.clone=function(){return new Position(this.x,this.y,this.width,this.height)},Position}(),initialPosition=new Position(0,0,0,0),MRAIDImplementation=function(){function MRAIDImplementation(eventsCoordinator,sdkInteractor,logger){this.currentState=MraidState.Loading,this.placementType=MraidPlacementType.Unknown,this.isCurrentlyViewable=!1,this.currentExpandProperties=new ExpandProperties(-1,-1),this.currentMaxSize=new Size(0,0),this.currentScreenSize=new Size(0,0),this.pixelMultiplier=1,this.supportedSdkFeatures=defaultSupportedSdkFeatures,this.defaultPosition=initialPosition.clone(),this.currentPosition=initialPosition.clone(),this.eventsCoordinator=eventsCoordinator,this.sdkInteractor=sdkInteractor,this.logger=logger,this.spreadMraidInstance()}return MRAIDImplementation.prototype.getVersion=function(){return"2.0"},MRAIDImplementation.prototype.addEventListener=function(event,listener){try{this.eventsCoordinator.addEventListener(event,listener,this.logger.log)}catch(e){this.logger.log(LogLevel.Error,"addEventListener","error when addEventListener, event = ".concat(event,", listenerType = ").concat(typeof listener))}},MRAIDImplementation.prototype.removeEventListener=function(event,listener){try{this.eventsCoordinator.removeEventListener(event,listener,this.logger.log)}catch(e){this.logger.log(LogLevel.Error,"removeEventListener","error when removeEventListener, event = ".concat(event,", listenerType = ").concat(typeof listener))}},MRAIDImplementation.prototype.getState=function(){return this.currentState},MRAIDImplementation.prototype.getPlacementType=function(){return this.placementType},MRAIDImplementation.prototype.isViewable=function(){return this.isCurrentlyViewable},MRAIDImplementation.prototype.expand=function(url){this.canPerformActions()?this.placementType===MraidPlacementType.Interstitial?this.logger.log(LogLevel.Error,"expand","can't expand interstitial ad"):null!=url?this.logger.log(LogLevel.Error,"expand","two-part expandable ads are not supported"):this.sdkInteractor.expand(this.currentExpandProperties.width,this.currentExpandProperties.height):this.logger.log(LogLevel.Error,"expand","can't expand in ".concat(this.currentState," state"))},MRAIDImplementation.prototype.getExpandProperties=function(){var width=-1===this.currentExpandProperties.width?this.currentMaxSize.width*this.pixelMultiplier:this.currentExpandProperties.width,height=-1===this.currentExpandProperties.height?this.currentMaxSize.height*this.pixelMultiplier:this.currentExpandProperties.height;return new ExpandProperties(width,height)},MRAIDImplementation.prototype.setExpandProperties=function(properties){var _a;this.isCorrectProperties(properties)&&(this.currentExpandProperties.width=null!=(_a=properties.width)?_a:-1,this.currentExpandProperties.height=null!=(_a=properties.height)?_a:-1)},MRAIDImplementation.prototype.close=function(){this.sdkInteractor.close()},MRAIDImplementation.prototype.useCustomClose=function(useCustomClose){this.logger.log(LogLevel.Error,"useCustomClose","useCustomClose() is not supported")},MRAIDImplementation.prototype.open=function(url){url?"string"==typeof url?this.sdkInteractor.open(url):url instanceof URL?this.sdkInteractor.open(url.toString()):this.logger.log(LogLevel.Error,"open","Error when open(), url is not a string"):this.logger.log(LogLevel.Error,"open","Error when open(), url is null, empty or undefined")},MRAIDImplementation.prototype.createCalendarEvent=function(parameters){this.logger.log(LogLevel.Error,"createCalendarEvent","createCalendarEvent() is not supported")},MRAIDImplementation.prototype.storePicture=function(uri){this.logger.log(LogLevel.Error,"storePicture","storePicture() is not supported")},MRAIDImplementation.prototype.getMaxSize=function(){return this.currentMaxSize.clone()},MRAIDImplementation.prototype.getScreenSize=function(){return this.currentScreenSize.clone()},MRAIDImplementation.prototype.supports=function(feature){var value;return(value=feature)&&Object.values(SdkFeature).includes(value)?this.supportedSdkFeatures[feature]:(this.logger.log(LogLevel.Error,"supports","Feature param is not one of ".concat("[".concat(Object.values(SdkFeature).join(", "),"]"))),!1)},MRAIDImplementation.prototype.getCurrentPosition=function(){return this.currentPosition.clone()},MRAIDImplementation.prototype.getDefaultPosition=function(){return this.defaultPosition.clone()},MRAIDImplementation.prototype.playVideo=function(url){url?"string"==typeof url?this.sdkInteractor.playVideo(url):url instanceof URL?this.sdkInteractor.playVideo(url.toString()):this.logger.log(LogLevel.Error,"playVideo","Error when playVideo(), url is not a string"):this.logger.log(LogLevel.Error,"playVideo","Error when playVideo(), url is null, empty or undefined")},MRAIDImplementation.prototype.notifyReady=function(placementType){this.logger.log(LogLevel.Debug,"notifyReady","placementType=".concat(placementType)),this.placementType=placementType,this.setReady()},MRAIDImplementation.prototype.notifyError=function(message,action){this.eventsCoordinator.fireErrorEvent(message,action)},MRAIDImplementation.prototype.setIsViewable=function(isViewable){this.logger.log(LogLevel.Debug,"setIsViewable","isViewable=".concat(isViewable)),this.isCurrentlyViewable!==isViewable&&(this.isCurrentlyViewable=isViewable,this.eventsCoordinator.fireViewableChangeEvent(isViewable))},MRAIDImplementation.prototype.setMaxSize=function(width,height,pixelMultiplier){this.currentMaxSize.width=width,this.currentMaxSize.height=height,this.pixelMultiplier=pixelMultiplier},MRAIDImplementation.prototype.setScreenSize=function(width,height){this.currentScreenSize.width=width,this.currentScreenSize.height=height},MRAIDImplementation.prototype.notifyClosed=function(){this.canPerformActions()?this.currentState===MraidState.Expanded?this.updateState(MraidState.Default):this.currentState===MraidState.Default&&this.updateState(MraidState.Hidden):this.logger.log(LogLevel.Warning,"notifyClosed","can't close in ".concat(this.currentState," state"))},MRAIDImplementation.prototype.notifyExpanded=function(){switch(this.currentState){case MraidState.Default:this.updateState(MraidState.Expanded);break;case MraidState.Expanded:this.logger.log(LogLevel.Warning,"notifyExpanded","ad is already expanded");break;case MraidState.Loading:case MraidState.Hidden:this.logger.log(LogLevel.Warning,"notifyExpanded","can't expand from ".concat(this.currentState))}},MRAIDImplementation.prototype.setSupports=function(supportedSdkFeatures){var _a;this.supportedSdkFeatures.sms=null!=(_a=supportedSdkFeatures.sms)?_a:this.supportedSdkFeatures.sms,this.supportedSdkFeatures.tel=null!=(_a=supportedSdkFeatures.tel)?_a:this.supportedSdkFeatures.tel,this.supportedSdkFeatures.inlineVideo=this.isInlineVideoSupported()},MRAIDImplementation.prototype.setCurrentPosition=function(x,y,width,height){x=new Position(x,y,width,height);JSON.stringify(this.defaultPosition)===JSON.stringify(initialPosition)&&(this.defaultPosition=x),this.currentPosition=x},MRAIDImplementation.prototype.updateState=function(newState){this.currentState=newState,this.eventsCoordinator.fireStateChangeEvent(newState)},MRAIDImplementation.prototype.setReady=function(){this.currentState===MraidState.Loading&&(this.updateState(MraidState.Default),this.eventsCoordinator.fireReadyEvent())},MRAIDImplementation.prototype.canPerformActions=function(){return this.currentState!==MraidState.Loading&&this.currentState!==MraidState.Hidden},MRAIDImplementation.prototype.isCorrectProperties=function(properties){var width,height,useCustomClose,isModal;return function(properties){var hasAnyProperty;if(null!=properties&&"object"==typeof properties)return hasAnyProperty=!1,Object.keys(new ExpandProperties(0,0)).forEach(function(property){Object.prototype.hasOwnProperty.call(properties,property)&&(hasAnyProperty=!0)}),0===Object.keys(properties).length||hasAnyProperty}(properties)?(width=properties.width,height=properties.height,useCustomClose=properties.useCustomClose,isModal=properties.isModal,!!this.isCorrectDimension(width)&&!!this.isCorrectDimension(height)&&(useCustomClose&&this.logger.log(LogLevel.Warning,"setExpandProperties","useCustomClose is not supported"),null==isModal||isModal||this.logger.log(LogLevel.Warning,"setExpandProperties","isModal property is readonly and always equals to true"),!0)):(this.logger.log(LogLevel.Error,"setExpandProperties","properties is ".concat(properties)),!1)},MRAIDImplementation.prototype.isCorrectDimension=function(dimension){return!(dimension&&("number"!=typeof dimension?(this.logger.log(LogLevel.Error,"setExpandProperties","width is not a number, width is ".concat(typeof dimension)),1):!this.isInAcceptedBounds(dimension)&&(this.logger.log(LogLevel.Error,"setExpandProperties","width is ".concat(dimension)),1)))},MRAIDImplementation.prototype.isInAcceptedBounds=function(number){return Number.isFinite(number)&&0<=number},MRAIDImplementation.prototype.spreadMraidInstance=function(){for(var _a,_b,iframes=document.getElementsByTagName("iframe"),i=0;i = (t1: T1, t2: T2) => void; @@ -14,16 +15,17 @@ export type ErrorEventListener = EventListener; export type StateChangeEventListener = EventListener; export type ViewableChangeEventListener = EventListener; export type ReadyEventListener = EventListener; +export type SizeChangeEventListener = EventListener; // Describes functions with possible parameters defined by MRAID spec export type MraidEventListener = | ErrorEventListener | StateChangeEventListener | ViewableChangeEventListener - | ReadyEventListener; + | ReadyEventListener + | SizeChangeEventListener; export class EventsCoordinator { - // TODO: Object.values is ES2017. Check if it will work for Android and iOS private eventListeners: Map> = new Map( Object.values(MraidEvent).map((e) => [e, new Set()]) ); @@ -120,6 +122,12 @@ export class EventsCoordinator { }); } + fireSizeChangeEvent(width: number, height: number) { + this.eventListeners.get(MraidEvent.SizeChange)?.forEach((value) => { + (value as SizeChangeEventListener)?.(width, height); + }); + } + private isCorrectEvent(event: MraidEvent): boolean { return event && this.eventListeners.has(event); } diff --git a/src/main.ts b/src/main.ts index 30a6f81..9e6796e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import { AndroidMraidBridge } from "./mraidbridge/androidmraidbridge"; import { SdkInteractor } from "./mraidbridge/sdkinteractor"; import { Logger } from "./log/logger"; import {} from "./mraidwindow"; +import { ResizePropertiesValidator } from "./resize"; export {}; @@ -14,7 +15,13 @@ const sdkInteractor = new SdkInteractor([ ]); const eventsCoordinator = new EventsCoordinator(); const logger = new Logger(eventsCoordinator, sdkInteractor); +const resizePropertiesValidator = new ResizePropertiesValidator(); window.mraid = window.mraid ?? - new MRAIDImplementation(eventsCoordinator, sdkInteractor, logger); + new MRAIDImplementation( + eventsCoordinator, + sdkInteractor, + logger, + resizePropertiesValidator + ); diff --git a/src/mraid.ts b/src/mraid.ts index 034dde1..c223843 100644 --- a/src/mraid.ts +++ b/src/mraid.ts @@ -21,6 +21,7 @@ import { SupportedSdkFeatures, } from "./sdkfeature"; import { initialPosition, Position } from "./position"; +import { ResizeProperties, ResizePropertiesValidator } from "./resize"; export class MRAIDImplementation implements MRAIDApi, SDKApi { private eventsCoordinator: EventsCoordinator; @@ -29,6 +30,8 @@ export class MRAIDImplementation implements MRAIDApi, SDKApi { private logger: Logger; + private resizePropertiesValidator: ResizePropertiesValidator; + private currentState = MraidState.Loading; private placementType = MraidPlacementType.Unknown; @@ -52,14 +55,18 @@ export class MRAIDImplementation implements MRAIDApi, SDKApi { private currentPosition = initialPosition.clone(); + private currentResizeProperties?: ResizeProperties = undefined; + constructor( eventsCoordinator: EventsCoordinator, sdkInteractor: SdkInteractor, - logger: Logger + logger: Logger, + resizePropertiesValidator: ResizePropertiesValidator ) { this.eventsCoordinator = eventsCoordinator; this.sdkInteractor = sdkInteractor; this.logger = logger; + this.resizePropertiesValidator = resizePropertiesValidator; this.spreadMraidInstance(); } @@ -271,6 +278,90 @@ export class MRAIDImplementation implements MRAIDApi, SDKApi { } } + getResizeProperties(): ResizeProperties | undefined { + return this.currentResizeProperties?.copy(); + } + + resize(): void { + if ( + this.currentState !== MraidState.Resized && + this.currentState !== MraidState.Default + ) { + this.logger.log( + LogLevel.Error, + "resize", + `Can't resize in ${this.currentState} state` + ); + return; + } + + if (this.placementType !== MraidPlacementType.Inline) { + this.logger.log( + LogLevel.Error, + "resize", + "Resize is only available for inline placement" + ); + return; + } + + if (!this.currentResizeProperties) { + this.logger.log( + LogLevel.Error, + "resize", + "You must set resize properties before calling resize" + ); + } else { + // validate resize properties one more time because position and max size + // might have changed by this time + const errorMessage = this.resizePropertiesValidator.validate( + this.currentResizeProperties, + this.currentMaxSize, + this.currentPosition + ); + if (errorMessage) { + this.logger.log(LogLevel.Error, "resize", errorMessage); + return; + } + + const { + offsetY, + height, + offsetX, + width, + customClosePosition, + allowOffscreen, + } = this.currentResizeProperties; + this.sdkInteractor.resize( + width, + height, + offsetX, + offsetY, + customClosePosition, + allowOffscreen + ); + } + } + + setResizeProperties(resizeProperties: ResizeProperties | Anything): void { + const errorMessage = this.resizePropertiesValidator.validate( + resizeProperties, + this.currentMaxSize, + this.currentPosition + ); + if (errorMessage) { + this.logger.log(LogLevel.Error, "setResizeProperties", errorMessage); + } else { + this.currentResizeProperties = new ResizeProperties( + resizeProperties.width, + resizeProperties.height, + resizeProperties.offsetX, + resizeProperties.offsetY, + resizeProperties.customClosePosition, + resizeProperties.allowOffscreen + ); + } + } + // #endregion // #region SDKApi @@ -321,7 +412,10 @@ export class MRAIDImplementation implements MRAIDApi, SDKApi { ); return; } - if (this.currentState === MraidState.Expanded) { + if ( + this.currentState === MraidState.Expanded || + this.currentState === MraidState.Resized + ) { this.updateState(MraidState.Default); } else if (this.currentState === MraidState.Default) { this.updateState(MraidState.Hidden); @@ -331,6 +425,7 @@ export class MRAIDImplementation implements MRAIDApi, SDKApi { notifyExpanded(): void { switch (this.currentState) { case MraidState.Default: + case MraidState.Resized: this.updateState(MraidState.Expanded); break; case MraidState.Expanded: @@ -371,10 +466,29 @@ export class MRAIDImplementation implements MRAIDApi, SDKApi { JSON.stringify(this.defaultPosition) === JSON.stringify(initialPosition) ) { this.defaultPosition = newPosition; + } else { + this.eventsCoordinator.fireSizeChangeEvent(width, height); } this.currentPosition = newPosition; } + notifyResized(): void { + switch (this.currentState) { + case MraidState.Default: + case MraidState.Resized: { + this.updateState(MraidState.Resized); + break; + } + default: + this.logger.log( + LogLevel.Warning, + "notifyResized", + `Can't resize from ${this.currentState} state` + ); + break; + } + } + // #endregion private updateState(newState: MraidState) { diff --git a/src/mraidapi.ts b/src/mraidapi.ts index 5da0ef6..6c5033f 100644 --- a/src/mraidapi.ts +++ b/src/mraidapi.ts @@ -5,6 +5,7 @@ import { ExpandProperties } from "./expand"; import { Size } from "./size"; import { SdkFeature } from "./sdkfeature"; import { Position } from "./position"; +import { ResizeProperties } from "./resize"; export interface MRAIDApi { /** @@ -183,4 +184,21 @@ export interface MRAIDApi { * @param url - the URI of the video or video stream */ playVideo(url: Url | Anything): void; + + /** + * The resize method will cause the existing web view to change size using the existing HTML + * document + */ + resize(): void; + + /** + * @returns current ResizeProperties object + */ + getResizeProperties(): ResizeProperties | undefined; + + /** + * Sets resize properties object + * @param resizeProperties + */ + setResizeProperties(resizeProperties: ResizeProperties | Anything): void; } diff --git a/src/mraidbridge/androidmraidbridge.ts b/src/mraidbridge/androidmraidbridge.ts index d302d38..b55b72f 100644 --- a/src/mraidbridge/androidmraidbridge.ts +++ b/src/mraidbridge/androidmraidbridge.ts @@ -1,5 +1,6 @@ import { MraidBridge } from "./mraidbridge"; import { LogLevel } from "../log/loglevel"; +import { ClosePosition } from "../resize"; /** * Interaction object is defined in global scope (window) so we are using @@ -28,6 +29,14 @@ export declare interface CriteoInterface { expand(width: number, height: number): void; close(): void; playVideo(url: string): void; + resize( + width: number, + height: number, + offsetX: number, + offsetY: number, + customClosePosition: string, + allowOffscreen: boolean + ): void; } export class AndroidMraidBridge implements MraidBridge { @@ -51,6 +60,24 @@ export class AndroidMraidBridge implements MraidBridge { this.getMraidBridge()?.playVideo(url); } + resize( + width: number, + height: number, + offsetX: number, + offsetY: number, + customClosePosition: ClosePosition, + allowOffscreen: boolean + ): void { + this.getMraidBridge()?.resize( + width, + height, + offsetX, + offsetY, + customClosePosition, + allowOffscreen + ); + } + private getMraidBridge(): CriteoInterface | undefined | null { // criteoMraidBridge object is not injected into iframe on Android // but doc says it should be. It is always available on topmost window diff --git a/src/mraidbridge/iosmraidbridge.ts b/src/mraidbridge/iosmraidbridge.ts index 9a076d4..4ec581e 100644 --- a/src/mraidbridge/iosmraidbridge.ts +++ b/src/mraidbridge/iosmraidbridge.ts @@ -1,5 +1,6 @@ import { MraidBridge } from "./mraidbridge"; import { LogLevel } from "../log/loglevel"; +import { ClosePosition } from "../resize"; // #region MessageHandler @@ -50,6 +51,15 @@ export declare interface PlayVideoIosMessage extends IosMessage { url: string; } +export declare interface ResizeIosMessage extends IosMessage { + width: number; + height: number; + offsetX: number; + offsetY: number; + customClosePosition: string; + allowOffscreen: boolean; +} + // #endregion export class IosMraidBridge implements MraidBridge { @@ -95,6 +105,26 @@ export class IosMraidBridge implements MraidBridge { this.postMessage(playVideoMessage); } + resize( + width: number, + height: number, + offsetX: number, + offsetY: number, + customClosePosition: ClosePosition, + allowOffscreen: boolean + ): void { + const resizeMessage: ResizeIosMessage = { + action: "resize", + width, + height, + offsetX, + offsetY, + customClosePosition, + allowOffscreen, + }; + this.postMessage(resizeMessage); + } + private postMessage(message: IosMessage) { window?.webkit?.messageHandlers?.criteoMraidBridge?.postMessage(message); } diff --git a/src/mraidbridge/mraidbridge.ts b/src/mraidbridge/mraidbridge.ts index ee7691b..b66c306 100644 --- a/src/mraidbridge/mraidbridge.ts +++ b/src/mraidbridge/mraidbridge.ts @@ -1,4 +1,5 @@ import { LogLevel } from "../log/loglevel"; +import { ClosePosition } from "../resize"; /** * Defines API for interaction with native platforms (iOS and Android) @@ -10,4 +11,12 @@ export interface MraidBridge { expand(width: number, height: number): void; close(): void; playVideo(url: string): void; + resize( + width: number, + height: number, + offsetX: number, + offsetY: number, + customClosePosition: ClosePosition, + allowOffscreen: boolean + ): void; } diff --git a/src/mraidbridge/sdkinteractor.ts b/src/mraidbridge/sdkinteractor.ts index 251ac4b..6be5e88 100644 --- a/src/mraidbridge/sdkinteractor.ts +++ b/src/mraidbridge/sdkinteractor.ts @@ -1,5 +1,7 @@ import { MraidBridge } from "./mraidbridge"; import { LogLevel } from "../log/loglevel"; +import { Anything } from "../utils"; +import { ClosePosition } from "../resize"; /** * Composite which delegates calls to native platforms handlers @@ -41,6 +43,26 @@ export class SdkInteractor { }); } + resize( + width: number, + height: number, + offsetX: number, + offsetY: number, + customClosePosition: ClosePosition, + allowOffscreen: boolean + ) { + this.callForAll((bridge) => { + bridge.resize( + width, + height, + offsetX, + offsetY, + customClosePosition, + allowOffscreen + ); + }); + } + private callForAll(lambda: (mraidBridge: MraidBridge) => void) { this.bridges.forEach((bridge) => { lambda(bridge); diff --git a/src/resize.ts b/src/resize.ts new file mode 100644 index 0000000..f8e413e --- /dev/null +++ b/src/resize.ts @@ -0,0 +1,303 @@ +// eslint-disable-next-line max-classes-per-file +import { Anything, isNumber } from "./utils"; +import { Size } from "./size"; +import { Position } from "./position"; + +export enum ClosePosition { + TopLeft = "top-left", + TopRight = "top-right", + Center = "center", + BottomLeft = "bottom-left", + BottomRight = "bottom-right", + TopCenter = "top-center", + BottomCenter = "bottom-center", +} + +export class ResizeProperties { + /** + * Width in density-independent pixels + */ + width: number | Anything; + + /** + * Height in density-independent pixels + */ + height: number | Anything; + + /** + * Horizontal delta from upper left corner of banner in density-independent pixels + */ + offsetX: number | Anything; + + /** + * Vertical delta from upper left corner of banner in density-independent pixels + */ + offsetY: number | Anything; + + /** + * Indicates the origin of container supplied close event region relative to + * resized creative + */ + customClosePosition: ClosePosition | Anything; + + /** + * Tells the container whether it should allow the resized + * creative to be drawn fully/partially offscreen + */ + allowOffscreen: boolean | Anything; + + constructor( + width: number | Anything, + height: number | Anything, + offsetX: number | Anything, + offsetY: number | Anything, + customClosePosition: ClosePosition | Anything = ClosePosition.TopRight, + allowOffscreen: boolean | Anything = true + ) { + this.width = width; + this.height = height; + this.offsetX = offsetX; + this.offsetY = offsetY; + this.customClosePosition = customClosePosition; + this.allowOffscreen = allowOffscreen; + } + + copy(): ResizeProperties { + return new ResizeProperties( + this.width, + this.height, + this.offsetX, + this.offsetY, + this.customClosePosition, + this.allowOffscreen + ); + } +} + +export class ResizePropertiesValidator { + private closeRegionSize = 50; + + private halfCloseRegionSize = this.closeRegionSize / 2; + + /** + * Defines minimum size of ad which equals to size of close region + */ + private adMinSize = this.closeRegionSize; + + /** + * Validates resize properties according to mraid spec + * @returns string with error message if something is invalid or null + * if properties are passed correctly + * + * @param resizeProperties to validate + * @param maxSize - current max size that is available to resize to + * @param currentPosition - current ad position + */ + validate( + resizeProperties: ResizeProperties | Anything, + maxSize: Size, + currentPosition: Position + ): string | null { + if (!resizeProperties) { + return "Resize properties object is not passed"; + } + if (Object.keys(resizeProperties).length === 0) { + return "Resize properties object is empty"; + } + + const widthErrorMessage = this.validateSize( + resizeProperties.width, + "width", + maxSize.width + ); + if (widthErrorMessage) { + return widthErrorMessage; + } + + const heightErrorMessage = this.validateSize( + resizeProperties.height, + "height", + maxSize.height + ); + if (heightErrorMessage) { + return heightErrorMessage; + } + + const offsetXErrorMessage = this.validateOffset( + resizeProperties.offsetX, + "offsetX" + ); + if (offsetXErrorMessage) { + return offsetXErrorMessage; + } + + const offsetYErrorMessage = this.validateOffset( + resizeProperties.offsetY, + "offsetY" + ); + if (offsetYErrorMessage) { + return offsetYErrorMessage; + } + + const customClosePositionErrorMessage = this.validateCustomClosePosition( + resizeProperties.customClosePosition + ); + if (customClosePositionErrorMessage) { + return customClosePositionErrorMessage; + } + + const allowOffscreenErrorMessage = this.validateAllowOffscreen( + resizeProperties.allowOffscreen + ); + if (allowOffscreenErrorMessage) { + return allowOffscreenErrorMessage; + } + + return this.validateCloseButtonPosition( + resizeProperties, + maxSize, + currentPosition + ); + } + + private joinedClosePosition(): string { + return `[${Object.values(ClosePosition).join(", ")}]`; + } + + private validateSize( + value: number | Anything, + side: string, + maxSize: number + ): string | null { + if (value || value === 0) { + if (!isNumber(value)) { + return `${side} should be valid integer`; + } + if (!Number.isFinite(value)) { + return `${side} should be valid integer`; + } + if (value < this.adMinSize) { + return `${side} should be at least ${this.adMinSize}`; + } + if (value > maxSize) { + return `${side} is bigger than getMaxSize().${side}`; + } + } else { + return `${side} property is required`; + } + return null; + } + + private validateOffset( + value: number | Anything, + side: string + ): string | null { + if (value || value === 0) { + if (!isNumber(value)) { + return `${side} should be valid integer`; + } + if (!Number.isFinite(value)) { + return `${side} should be valid integer`; + } + } else { + return `${side} property is required`; + } + return null; + } + + private validateCustomClosePosition( + value: ClosePosition | Anything + ): string | null { + if (value) { + if (typeof value === "string") { + if (!Object.values(ClosePosition).includes(value as ClosePosition)) { + return `customClosePosition should be one of ${this.joinedClosePosition()}`; + } + } else { + return "customClosePosition should be a string"; + } + } + + return null; + } + + private validateAllowOffscreen(value: boolean | Anything) { + if (value) { + if (typeof value === "boolean") { + return null; + } + return "allowOffscreen should be boolean"; + } + return null; + } + + private isCloseButtonOnScreen( + closeX: number, + closeY: number, + maxSize: Size + ): boolean { + return ( + closeX >= 0 && + closeX <= maxSize.width - this.closeRegionSize && + closeY >= 0 && + closeY <= maxSize.height - this.closeRegionSize + ); + } + + private validateCloseButtonPosition( + resizeProperties: ResizeProperties | Anything, + maxSize: Size, + currentPosition: Position + ): string | null { + const newAdX = currentPosition.x + resizeProperties.offsetX; + const newAdY = currentPosition.y + resizeProperties.offsetY; + const closeRegionLocation = + resizeProperties.customClosePosition ?? ClosePosition.TopRight; + + let closeX = 0; + let closeY = 0; + + switch (closeRegionLocation) { + case ClosePosition.TopCenter: + closeX = + newAdX + (resizeProperties.width / 2 - this.halfCloseRegionSize); + closeY = newAdY; + break; + case ClosePosition.TopRight: + closeX = newAdX + resizeProperties.width - this.closeRegionSize; + closeY = newAdY; + break; + case ClosePosition.TopLeft: + closeX = newAdX; + closeY = newAdY; + break; + case ClosePosition.Center: + closeX = + newAdX + (resizeProperties.width / 2 - this.halfCloseRegionSize); + closeY = + newAdY + (resizeProperties.height / 2 - this.halfCloseRegionSize); + break; + case ClosePosition.BottomCenter: + closeX = + newAdX + (resizeProperties.width / 2 - this.halfCloseRegionSize); + closeY = newAdY + resizeProperties.height - this.closeRegionSize; + break; + case ClosePosition.BottomRight: + closeX = newAdX + resizeProperties.width - this.closeRegionSize; + closeY = newAdY + resizeProperties.height - this.closeRegionSize; + break; + case ClosePosition.BottomLeft: + closeX = newAdX; + closeY = newAdY + resizeProperties.height - this.closeRegionSize; + break; + default: + break; + } + + if (this.isCloseButtonOnScreen(closeX, closeY, maxSize)) { + return null; + } + + return "Close button will be offscreen"; + } +} diff --git a/src/sdkapi.ts b/src/sdkapi.ts index 6fd0ca0..782f324 100644 --- a/src/sdkapi.ts +++ b/src/sdkapi.ts @@ -81,4 +81,9 @@ export interface SDKApi { * @param height - current height of container in density-independent pixels */ setCurrentPosition(x: number, y: number, width: number, height: number): void; + + /** + * Report mraid object about successful resize + */ + notifyResized(): void; } diff --git a/src/state.ts b/src/state.ts index 99cc7d5..c5d8c4a 100644 --- a/src/state.ts +++ b/src/state.ts @@ -3,4 +3,5 @@ export enum MraidState { Default = "default", Expanded = "expanded", Hidden = "hidden", + Resized = "resized", } diff --git a/tests/events.test.ts b/tests/events.test.ts index ef3e57b..5ff62be 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -113,6 +113,29 @@ describe("when addEventListener", () => { expect(capturedIsViewable).toBe(isViewable); }); + test("for sizeChange event and fireSizeChangeEvent should trigger listener", () => { + let triggerCount = 0; + + let capturedWidth = 0; + let capturedHeight = 0; + const listener = (width: number, height: number) => { + triggerCount += 1; + capturedWidth = width; + capturedHeight = height; + }; + + eventsCoordinator.addEventListener( + MraidEvent.SizeChange, + listener, + logger.log + ); + eventsCoordinator.fireSizeChangeEvent(100, 150); + + expect(triggerCount).toBe(1); + expect(capturedWidth).toBe(100); + expect(capturedHeight).toBe(150); + }); + test("for ready event with multiple listeners should trigger multiple listeners", () => { let triggerCount1 = 0; let triggerCount2 = 0; diff --git a/tests/mraid.test.ts b/tests/mraid.test.ts index 5fd046c..d5ceb18 100644 --- a/tests/mraid.test.ts +++ b/tests/mraid.test.ts @@ -5,6 +5,7 @@ import { instance, mock, verify, + when, } from "ts-mockito"; import { MRAIDImplementation } from "../src/mraid"; import { EventsCoordinator, MraidEvent } from "../src/events"; @@ -14,20 +15,22 @@ import { SdkInteractor } from "../src/mraidbridge/sdkinteractor"; import { LogLevel } from "../src/log/loglevel"; import { defaultPropertiesValue, ExpandProperties } from "../src/expand"; import { Logger } from "../src/log/logger"; -import {} from "../src/mraidwindow"; import { SdkFeature } from "../src/sdkfeature"; import { initialPosition, Position } from "../src/position"; +import { ClosePosition, ResizePropertiesValidator } from "../src/resize"; let mraid: MRAIDImplementation; let eventsCoordinator: EventsCoordinator; let sdkInteractor: SdkInteractor; let logger: Logger; let contentWindow: Window; +let resizePropertiesValidator: ResizePropertiesValidator; beforeEach(() => { eventsCoordinator = mock(EventsCoordinator); sdkInteractor = mock(SdkInteractor); logger = mock(Logger); + resizePropertiesValidator = mock(ResizePropertiesValidator); const frame = document.createElement("iframe"); document.body.appendChild(frame); @@ -36,7 +39,8 @@ beforeEach(() => { mraid = new MRAIDImplementation( instance(eventsCoordinator), instance(sdkInteractor), - instance(logger) + instance(logger), + instance(resizePropertiesValidator) ); }); @@ -53,7 +57,8 @@ test("when create mraid object in sub window main window should have the same in contentWindow.mraid = new MRAIDImplementation( instance(eventsCoordinator), instance(sdkInteractor), - instance(logger) + instance(logger), + instance(resizePropertiesValidator) ); expect(window.mraid).toBe(contentWindow.mraid); }); @@ -318,6 +323,15 @@ describe("when notifyExpanded", () => { expect(mraid.getState()).toBe(MraidState.Expanded); }); + test("current state is resized then should change state to expanded", () => { + mraid.notifyReady(MraidPlacementType.Inline); + mraid.notifyResized(); + + mraid.notifyExpanded(); + + expect(mraid.getState()).toBe(MraidState.Expanded); + }); + test("current state is expanded then should log warning", () => { mraid.notifyReady(MraidPlacementType.Inline); mraid.notifyExpanded(); @@ -375,6 +389,15 @@ describe("when notifyClosed()", () => { expect(mraid.getState()).toBe(MraidState.Hidden); }); + + test("current state is resized then should change state to default", () => { + mraid.notifyReady(MraidPlacementType.Inline); + mraid.notifyResized(); + + mraid.notifyClosed(); + + expect(mraid.getState()).toBe(MraidState.Default); + }); }); test("when createCalendarEvent then should log error", () => { @@ -510,6 +533,19 @@ describe("when getCurrentPosition", () => { }); }); +test("when setCurrentPosition one time should not trigger size change event", () => { + mraid.setCurrentPosition(0, 0, 100, 100); + + verify(eventsCoordinator.fireSizeChangeEvent(100, 100)).never(); +}); + +test("when setCurrentPositon two times should trigger size chnage event once", () => { + mraid.setCurrentPosition(0, 0, 100, 100); + mraid.setCurrentPosition(0, 0, 200, 200); + + verify(eventsCoordinator.fireSizeChangeEvent(200, 200)).once(); +}); + describe("when getDefaultPosition", () => { test("should return initial position", () => { expect(mraid.getDefaultPosition()).toEqual(initialPosition); @@ -571,3 +607,281 @@ describe("when playVideo", () => { } ); }); + +describe("when setResizeProperties", () => { + test("given validator returns error should log error", () => { + when( + resizePropertiesValidator.validate(anything(), anything(), anything()) + ).thenReturn("Error message"); + + mraid.setResizeProperties({ + width: 100, + height: 100, + offsetY: 2, + offsetX: 3, + }); + + verify( + logger.log(LogLevel.Error, "setResizeProperties", "Error message") + ).once(); + }); + + test("given validator returns no error should set resize properties", () => { + const setResizeProperties = { + width: 100, + height: 100, + offsetY: 2, + offsetX: 3, + customClosePosition: "center", + allowOffscreen: true, + }; + + mraid.setResizeProperties(setResizeProperties); + + const resizeProperties = mraid.getResizeProperties(); + + expect(resizeProperties).toEqual(setResizeProperties); + }); +}); + +test("getResizeProperties given setResizeProperties never called should return undefined", () => { + const resizeProperties = mraid.getResizeProperties(); + + expect(resizeProperties).toBe(undefined); +}); + +describe("when resize", () => { + test("given current state is loading should log error", () => { + mraid.resize(); + verify( + logger.log(LogLevel.Error, "resize", "Can't resize in loading state") + ).once(); + }); + + test("given current state is hidden should log error", () => { + mraid.notifyReady(MraidPlacementType.Inline); + mraid.notifyClosed(); + + mraid.resize(); + + verify( + logger.log(LogLevel.Error, "resize", "Can't resize in hidden state") + ).once(); + verify( + sdkInteractor.resize( + anything(), + anything(), + anything(), + anything(), + anything(), + anything() + ) + ).never(); + }); + + test("given current state is expanded should log error", () => { + mraid.notifyReady(MraidPlacementType.Inline); + mraid.notifyExpanded(); + + mraid.resize(); + + verify( + logger.log(LogLevel.Error, "resize", "Can't resize in expanded state") + ).once(); + verify( + sdkInteractor.resize( + anything(), + anything(), + anything(), + anything(), + anything(), + anything() + ) + ).never(); + }); + + test("given placement type is interstitial should log error", () => { + mraid.notifyReady(MraidPlacementType.Interstitial); + + mraid.resize(); + + verify( + logger.log( + LogLevel.Error, + "resize", + "Resize is only available for inline placement" + ) + ).once(); + verify( + sdkInteractor.resize( + anything(), + anything(), + anything(), + anything(), + anything(), + anything() + ) + ).never(); + }); + + test("given resize properties never set should log error", () => { + mraid.notifyReady(MraidPlacementType.Inline); + + mraid.resize(); + + verify( + logger.log( + LogLevel.Error, + "resize", + "You must set resize properties before calling resize" + ) + ).once(); + verify( + sdkInteractor.resize( + anything(), + anything(), + anything(), + anything(), + anything(), + anything() + ) + ).never(); + }); + + test("given resize properties set, current position changes and resize properties become invalid should trigger error", () => { + mraid.setMaxSize(500, 500, 1); + mraid.setCurrentPosition(0, 0, 300, 300); + mraid.notifyReady(MraidPlacementType.Inline); + + mraid.setResizeProperties({ + width: 350, + height: 350, + offsetX: 0, + offsetY: 0, + }); + + when( + resizePropertiesValidator.validate(anything(), anything(), anything()) + ).thenReturn("Error message"); + + mraid.resize(); + + verify(logger.log(LogLevel.Error, "resize", "Error message")).once(); + verify( + sdkInteractor.resize( + anything(), + anything(), + anything(), + anything(), + anything(), + anything() + ) + ).never(); + }); + + test("given valid properties and default state should call sdk interactor", () => { + mraid.setMaxSize(500, 500, 1); + mraid.setCurrentPosition(0, 0, 300, 300); + mraid.notifyReady(MraidPlacementType.Inline); + + mraid.setResizeProperties({ + width: 350, + height: 350, + offsetX: 0, + offsetY: 0, + customClosePosition: "center", + allowOffscreen: true, + }); + + mraid.resize(); + + verify( + sdkInteractor.resize(350, 350, 0, 0, ClosePosition.Center, true) + ).once(); + }); + + test("given valid properties and default state then resize from resized state should call sdk interactor twice", () => { + mraid.setMaxSize(500, 500, 1); + mraid.setCurrentPosition(0, 0, 300, 300); + mraid.notifyReady(MraidPlacementType.Inline); + + mraid.setResizeProperties({ + width: 350, + height: 350, + offsetX: 0, + offsetY: 0, + customClosePosition: "center", + allowOffscreen: true, + }); + + mraid.resize(); + mraid.resize(); + + verify( + sdkInteractor.resize(350, 350, 0, 0, ClosePosition.Center, true) + ).twice(); + }); +}); + +describe("when notifyResized", () => { + test("given current state is default should update state and fire event", () => { + mraid.notifyReady(MraidPlacementType.Inline); + + mraid.notifyResized(); + + expect(mraid.getState()).toBe(MraidState.Resized); + verify(eventsCoordinator.fireStateChangeEvent(MraidState.Resized)).once(); + }); + + test("given current state is resized should keep state and fire event", () => { + mraid.notifyReady(MraidPlacementType.Inline); + mraid.notifyResized(); + + mraid.notifyResized(); + + expect(mraid.getState()).toBe(MraidState.Resized); + verify(eventsCoordinator.fireStateChangeEvent(MraidState.Resized)).twice(); + }); + + test("given current state is default should log warning", () => { + mraid.notifyResized(); + + verify( + logger.log( + LogLevel.Warning, + "notifyResized", + "Can't resize from loading state" + ) + ).once(); + }); + + test("given current state is hidden should log warning", () => { + mraid.notifyReady(MraidPlacementType.Inline); + mraid.notifyClosed(); + + mraid.notifyResized(); + + verify( + logger.log( + LogLevel.Warning, + "notifyResized", + "Can't resize from hidden state" + ) + ).once(); + }); + + test("given current state is expanded should log warning", () => { + mraid.notifyReady(MraidPlacementType.Inline); + mraid.notifyExpanded(); + + mraid.notifyResized(); + + verify( + logger.log( + LogLevel.Warning, + "notifyResized", + "Can't resize from expanded state" + ) + ).once(); + }); +}); diff --git a/tests/mraidbridge/androidmraidbridge.test.ts b/tests/mraidbridge/androidmraidbridge.test.ts index dc59ae1..60cab54 100644 --- a/tests/mraidbridge/androidmraidbridge.test.ts +++ b/tests/mraidbridge/androidmraidbridge.test.ts @@ -4,6 +4,7 @@ import { AndroidMraidBridge, } from "../../src/mraidbridge/androidmraidbridge"; import { LogLevel } from "../../src/log/loglevel"; +import { ClosePosition } from "../../src/resize"; let androidMraidBridge: AndroidMraidBridge; let androidBridge: CriteoInterface; @@ -52,3 +53,32 @@ test("when call playVideo should delegate to criteoMraidBridge on window", () => verify(androidBridge.playVideo(url)).once(); }); + +test("when call resize should delegate to criteoMraidBridge on window", () => { + const width = 133; + const height = 444; + const offsetX = 13; + const offsetY = 0; + const customClosePosition = ClosePosition.Center; + const allowOffscreen = true; + + androidMraidBridge.resize( + width, + height, + offsetX, + offsetY, + customClosePosition, + allowOffscreen + ); + + verify( + androidBridge.resize( + width, + height, + offsetX, + offsetY, + customClosePosition, + allowOffscreen + ) + ).once(); +}); diff --git a/tests/mraidbridge/iosmraidbridge.test.ts b/tests/mraidbridge/iosmraidbridge.test.ts index 5e89c2b..04cb7de 100644 --- a/tests/mraidbridge/iosmraidbridge.test.ts +++ b/tests/mraidbridge/iosmraidbridge.test.ts @@ -1,20 +1,27 @@ import { capture, instance, mock, when } from "ts-mockito"; import { LogLevel } from "../../src/log/loglevel"; import { - IosMraidBridge, + CloseIosMessage, CriteoMessageHandler, + ExpandIosMessage, + IosMessage, + IosMraidBridge, LogIosMessage, - Webkit, MessageHandlers, OpenIosMessage, - ExpandIosMessage, - CloseIosMessage, PlayVideoIosMessage, + ResizeIosMessage, + Webkit, } from "../../src/mraidbridge/iosmraidbridge"; +import { ClosePosition } from "../../src/resize"; let iosMraidBridge: IosMraidBridge; let iosMessageHandler: CriteoMessageHandler; +function captureLastMessage(): IosMessage { + return capture(iosMessageHandler.postMessage).last()[0]; +} + beforeEach(() => { iosMraidBridge = new IosMraidBridge(); iosMessageHandler = mock(); @@ -37,8 +44,6 @@ test("when call log should delegate to criteoMraidBridge on window", () => { const message = "Some fancy text"; const logId = "id"; - iosMraidBridge.log(logLevel, message, logId); - const expectedMessage: LogIosMessage = { action: "log", logLevel, @@ -46,59 +51,91 @@ test("when call log should delegate to criteoMraidBridge on window", () => { logId, }; - const capturedMessage = capture(iosMessageHandler.postMessage).last()[0]; + iosMraidBridge.log(logLevel, message, logId); + + const capturedMessage = captureLastMessage(); expect(capturedMessage).toStrictEqual(expectedMessage); }); test("when call open should delegate to criteoMraidBridge on window", () => { const url = "https://www.criteo.com/"; - iosMraidBridge.open(url); - const expectedMessage: OpenIosMessage = { action: "open", url, }; - const capturedMessage = capture(iosMessageHandler.postMessage).last()[0]; + iosMraidBridge.open(url); + + const capturedMessage = captureLastMessage(); expect(capturedMessage).toStrictEqual(expectedMessage); }); test("when call expand should delegate to criteoMraidBridge on window", () => { const width = 123; const height = 222; - iosMraidBridge.expand(width, height); - const expectedMessage: ExpandIosMessage = { action: "expand", width, height, }; - const capturedMessage = capture(iosMessageHandler.postMessage).last()[0]; + iosMraidBridge.expand(width, height); + + const capturedMessage = captureLastMessage(); expect(capturedMessage).toStrictEqual(expectedMessage); }); test("when call close should delegate to criteoMraidBridge on window", () => { - iosMraidBridge.close(); - const expectedMessage: CloseIosMessage = { action: "close", }; - const capturedMessage = capture(iosMessageHandler.postMessage).last()[0]; + iosMraidBridge.close(); + + const capturedMessage = captureLastMessage(); expect(capturedMessage).toStrictEqual(expectedMessage); }); test("when call playVideo should delegate to criteoMraidBridge on window", () => { const url = "https://criteo.com/funny_cat_video.mp4"; - iosMraidBridge.playVideo(url); - const expectedMessage: PlayVideoIosMessage = { action: "play_video", url, }; - const capturedMessage = capture(iosMessageHandler.postMessage).last()[0]; + iosMraidBridge.playVideo(url); + + const capturedMessage = captureLastMessage(); + expect(capturedMessage).toStrictEqual(expectedMessage); +}); + +test("when call resize should delegate to criteoMraidBridge on window", () => { + const width = 133; + const height = 444; + const offsetX = 13; + const offsetY = 0; + const customClosePosition = "center"; + const allowOffscreen = true; + const expectedMessage: ResizeIosMessage = { + action: "resize", + width, + height, + offsetX, + offsetY, + customClosePosition, + allowOffscreen, + }; + + iosMraidBridge.resize( + width, + height, + offsetX, + offsetY, + ClosePosition.Center, + allowOffscreen + ); + + const capturedMessage = captureLastMessage(); expect(capturedMessage).toStrictEqual(expectedMessage); }); diff --git a/tests/mraidbridge/sdkinteractor.test.ts b/tests/mraidbridge/sdkinteractor.test.ts index fbf7e3b..716d4ff 100644 --- a/tests/mraidbridge/sdkinteractor.test.ts +++ b/tests/mraidbridge/sdkinteractor.test.ts @@ -2,6 +2,7 @@ import { instance, mock, verify } from "ts-mockito"; import { SdkInteractor } from "../../src/mraidbridge/sdkinteractor"; import { MraidBridge } from "../../src/mraidbridge/mraidbridge"; import { LogLevel } from "../../src/log/loglevel"; +import { ClosePosition } from "../../src/resize"; let sdkInteractor: SdkInteractor; let mraidBridges: MraidBridge[]; @@ -53,3 +54,34 @@ test("when call playVideo should delegate to every MraidBridge object", () => { mraidBridges.forEach((bridge) => verify(bridge.playVideo(url)).once()); }); + +test("when call resize should delegate to every MraidBridge object", () => { + const width = 133; + const height = 444; + const offsetX = 13; + const offsetY = 0; + const customClosePosition = ClosePosition.BottomCenter; + const allowOffscreen = true; + + sdkInteractor.resize( + width, + height, + offsetX, + offsetY, + customClosePosition, + allowOffscreen + ); + + mraidBridges.forEach((bridge) => + verify( + bridge.resize( + width, + height, + offsetX, + offsetY, + customClosePosition, + allowOffscreen + ) + ).once() + ); +}); diff --git a/tests/resize.test.ts b/tests/resize.test.ts new file mode 100644 index 0000000..541fee0 --- /dev/null +++ b/tests/resize.test.ts @@ -0,0 +1,504 @@ +import { + ClosePosition, + ResizeProperties, + ResizePropertiesValidator, +} from "../src/resize"; +import { Size } from "../src/size"; +import { Position } from "../src/position"; + +let resizePropertiesValidator: ResizePropertiesValidator; + +beforeEach(() => { + resizePropertiesValidator = new ResizePropertiesValidator(); +}); + +describe("when create resize properties", () => { + test("given passed all values should have them as properties", () => { + const resizeProperties = new ResizeProperties( + 100, + 100, + 0, + 0, + ClosePosition.Center, + false + ); + + expect(resizeProperties.width).toBe(100); + expect(resizeProperties.height).toBe(100); + expect(resizeProperties.offsetX).toBe(0); + expect(resizeProperties.offsetY).toBe(0); + expect(resizeProperties.customClosePosition).toBe(ClosePosition.Center); + expect(resizeProperties.allowOffscreen).toBe(false); + }); + + test("given closePosition and allowOffscreen not passed should have default values", () => { + const resizeProperties = new ResizeProperties(100, 100, 0, 0); + + expect(resizeProperties.customClosePosition).toBe(ClosePosition.TopRight); + expect(resizeProperties.allowOffscreen).toBe(true); + }); +}); + +test("when copy resize properties should have the same values", () => { + const resizeProperties = new ResizeProperties( + 100, + 100, + 0, + 0, + ClosePosition.Center, + false + ); + + const copiedResizedProperties = resizeProperties.copy(); + + expect(resizeProperties).toEqual(copiedResizedProperties); +}); + +describe("when ResizePropertiesInteractor.validate", () => { + test("given undefined resize properties should return proper error message", () => { + const result = resizePropertiesValidator.validate( + undefined, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("Resize properties object is not passed"); + }); + + test("given null resize properties should return proper error message", () => { + const result = resizePropertiesValidator.validate( + null, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("Resize properties object is not passed"); + }); + + test("given empty object should return proper error message", () => { + const result = resizePropertiesValidator.validate( + {}, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("Resize properties object is empty"); + }); + + it.each([null, undefined, NaN])( + "given %p width should return proper error message", + (param) => { + const result = resizePropertiesValidator.validate( + { width: param, height: 100, offsetX: 0, offsetY: 0 }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("width property is required"); + } + ); + + it.each([Infinity, -Infinity, "123", new Set(), true])( + "given %p width should return proper error message", + (param) => { + const result = resizePropertiesValidator.validate( + { width: param, height: 100, offsetX: 0, offsetY: 0 }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("width should be valid integer"); + } + ); + + it.each([-123, 0, 42])( + "given %p width should return proper error message", + (param) => { + const result = resizePropertiesValidator.validate( + { + width: param, + height: 100, + offsetX: 0, + offsetY: 0, + }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("width should be at least 50"); + } + ); + + test("given width is bigger that max width should return proper error message", () => { + const result = resizePropertiesValidator.validate( + { + width: 666, + height: 100, + offsetX: 0, + offsetY: 0, + }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("width is bigger than getMaxSize().width"); + }); + + it.each([null, undefined, NaN])( + "given %p height should return proper error message", + (param) => { + const result = resizePropertiesValidator.validate( + { + width: 100, + height: param, + offsetX: 0, + offsetY: 0, + }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("height property is required"); + } + ); + + it.each([Infinity, -Infinity, "123", new Set(), true])( + "given %p height should return proper error message", + (param) => { + const result = resizePropertiesValidator.validate( + { width: 100, height: param, offsetX: 0, offsetY: 0 }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("height should be valid integer"); + } + ); + + it.each([-123, 0, 42])( + "given %p height should return proper error message", + (param) => { + const result = resizePropertiesValidator.validate( + { + width: 100, + height: param, + offsetX: 0, + offsetY: 0, + }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("height should be at least 50"); + } + ); + + test("given height is bigger that max width should return proper error message", () => { + const result = resizePropertiesValidator.validate( + { + width: 200, + height: 777, + offsetX: 0, + offsetY: 0, + }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("height is bigger than getMaxSize().height"); + }); + + it.each([null, undefined, NaN])( + "given %p offsetX should return proper error message", + (param) => { + const result = resizePropertiesValidator.validate( + { + width: 100, + height: 100, + offsetX: param, + offsetY: 0, + }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("offsetX property is required"); + } + ); + + it.each([Infinity, -Infinity, "123", new Set(), true])( + "given %p offsetX should return proper error message", + (param) => { + const result = resizePropertiesValidator.validate( + { width: 100, height: 100, offsetX: param, offsetY: 0 }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("offsetX should be valid integer"); + } + ); + + test("given zero offsetX should return null", () => { + const result = resizePropertiesValidator.validate( + { width: 100, height: 100, offsetX: 0, offsetY: 0 }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe(null); + }); + + it.each([null, undefined, NaN])( + "given %p offsetY should return proper error message", + (param) => { + const result = resizePropertiesValidator.validate( + { + width: 100, + height: 100, + offsetX: 0, + offsetY: param, + }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("offsetY property is required"); + } + ); + + it.each([Infinity, -Infinity, "123", new Set(), true])( + "given %p offsetY should return proper error message", + (param) => { + const result = resizePropertiesValidator.validate( + { width: 100, height: 100, offsetX: 0, offsetY: param }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("offsetY should be valid integer"); + } + ); + + test("given zero offsetY should return null", () => { + const result = resizePropertiesValidator.validate( + { width: 100, height: 100, offsetX: 0, offsetY: 0 }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe(null); + }); + + it.each([Infinity, -Infinity, new Set(), 123])( + "given %p customClosePosition should return proper error message", + (param) => { + const result = resizePropertiesValidator.validate( + { + width: 100, + height: 100, + offsetX: 0, + offsetY: 0, + customClosePosition: param, + }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("customClosePosition should be a string"); + } + ); + + it.each([ + "centerOrNot", + "bottomcenter", + "bottom center", + "bottom_center", + "Bottom-center", + ])( + "given %p customClosePosition should return proper error message", + (param) => { + const result = resizePropertiesValidator.validate( + { + width: 100, + height: 100, + offsetX: 0, + offsetY: 0, + customClosePosition: param, + }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe( + "customClosePosition should be one of [top-left, top-right, center, bottom-left, bottom-right, top-center, bottom-center]" + ); + } + ); + + it.each([Infinity, -Infinity, new Set(), 123, "string"])( + "given %p allowOffscreen should return proper error message", + (param) => { + const result = resizePropertiesValidator.validate( + { + width: 100, + height: 100, + offsetX: 0, + offsetY: 0, + allowOffscreen: param, + }, + new Size(300, 500), + new Position(0, 0, 100, 100) + ); + expect(result).toBe("allowOffscreen should be boolean"); + } + ); + + it.each([ + new ResizeProperties(300, 300, 0, -20), + new ResizeProperties(300, 300, 0, 488), + new ResizeProperties(300, 300, 201, 0), + new ResizeProperties(300, 300, -251, 0), + new ResizeProperties(300, 300, -251, 499, undefined, undefined), + ])( + // undefined means top-right by default + "given close position is undefined and close button is offscreen should return proper error message", + (resizeProperties) => { + const result = resizePropertiesValidator.validate( + resizeProperties, + new Size(500, 500), + new Position(0, 0, 200, 200) + ); + expect(result).toBe("Close button will be offscreen"); + } + ); + + it.each([ + new ResizeProperties(300, 300, 0, -20, ClosePosition.TopRight), + new ResizeProperties(300, 300, 0, 488, ClosePosition.TopRight), + new ResizeProperties(300, 300, 201, 0, ClosePosition.TopRight), + new ResizeProperties(300, 300, -251, 0, ClosePosition.TopRight), + new ResizeProperties( + 300, + 300, + -251, + 499, + ClosePosition.TopRight, + undefined + ), + ])( + "given close position is top-right and close button is offscreen should return proper error message", + (resizeProperties) => { + const result = resizePropertiesValidator.validate( + resizeProperties, + new Size(500, 500), + new Position(0, 0, 200, 200) + ); + expect(result).toBe("Close button will be offscreen"); + } + ); + + it.each([ + new ResizeProperties(300, 300, 0, -20, ClosePosition.TopLeft), + new ResizeProperties(300, 300, 0, 488, ClosePosition.TopLeft), + new ResizeProperties(300, 300, 467, 0, ClosePosition.TopLeft), + new ResizeProperties(300, 300, -23, 0, ClosePosition.TopLeft), + new ResizeProperties(300, 300, -42, 499, ClosePosition.TopLeft), + ])( + "given close position is top-left and close button is offscreen should return proper error message", + (resizeProperties) => { + const result = resizePropertiesValidator.validate( + resizeProperties, + new Size(500, 500), + new Position(0, 0, 200, 200) + ); + expect(result).toBe("Close button will be offscreen"); + } + ); + + it.each([ + new ResizeProperties(300, 300, 0, -20, ClosePosition.TopCenter), + new ResizeProperties(300, 300, 0, 488, ClosePosition.TopCenter), + new ResizeProperties(300, 300, 353, 0, ClosePosition.TopCenter), + new ResizeProperties(300, 300, -352, 0, ClosePosition.TopCenter), + new ResizeProperties(300, 300, -351, 499, ClosePosition.TopCenter), + ])( + "given close position is top-center and close button is offscreen should return proper error message", + (resizeProperties) => { + const result = resizePropertiesValidator.validate( + resizeProperties, + new Size(500, 500), + new Position(0, 0, 200, 200) + ); + expect(result).toBe("Close button will be offscreen"); + } + ); + + it.each([ + new ResizeProperties(300, 300, 0, 203, ClosePosition.BottomRight), + new ResizeProperties(300, 300, 0, -488, ClosePosition.BottomRight), + new ResizeProperties(300, 300, 204, 0, ClosePosition.BottomRight), + new ResizeProperties(300, 300, -289, 0, ClosePosition.BottomRight), + new ResizeProperties(300, 300, -288, 499, ClosePosition.BottomRight), + ])( + "given close position is bottom-right and close button is offscreen should return proper error message", + (resizeProperties) => { + const result = resizePropertiesValidator.validate( + resizeProperties, + new Size(500, 500), + new Position(0, 0, 200, 200) + ); + expect(result).toBe("Close button will be offscreen"); + } + ); + + it.each([ + new ResizeProperties(300, 300, 0, 203, ClosePosition.BottomLeft), + new ResizeProperties(300, 300, 0, -487, ClosePosition.BottomLeft), + new ResizeProperties(300, 300, 467, 0, ClosePosition.BottomLeft), + new ResizeProperties(300, 300, -23, 0, ClosePosition.BottomLeft), + new ResizeProperties(300, 300, -42, 204, ClosePosition.BottomLeft), + ])( + "given close position is bottom-left and close button is offscreen should return proper error message", + (resizeProperties) => { + const result = resizePropertiesValidator.validate( + resizeProperties, + new Size(500, 500), + new Position(0, 0, 200, 200) + ); + expect(result).toBe("Close button will be offscreen"); + } + ); + + it.each([ + new ResizeProperties(300, 300, 0, 203, ClosePosition.BottomCenter), + new ResizeProperties(300, 300, 0, -488, ClosePosition.BottomCenter), + new ResizeProperties(300, 300, 354, 0, ClosePosition.BottomCenter), + new ResizeProperties(300, 300, -354, 0, ClosePosition.BottomCenter), + new ResizeProperties(300, 300, -354, 499, ClosePosition.BottomCenter), + ])( + "given close position is bottom-center and close button is offscreen should return proper error message", + (resizeProperties) => { + const result = resizePropertiesValidator.validate( + resizeProperties, + new Size(500, 500), + new Position(0, 0, 200, 200) + ); + expect(result).toBe("Close button will be offscreen"); + } + ); + + it.each([ + new ResizeProperties(300, 300, 0, -154, ClosePosition.Center), + new ResizeProperties(300, 300, 0, 351, ClosePosition.Center), + new ResizeProperties(300, 300, -156, 0, ClosePosition.Center), + new ResizeProperties(300, 300, 352, 0, ClosePosition.Center), + new ResizeProperties(300, 300, -158, 499, ClosePosition.Center), + ])( + "given close position is center and close button is offscreen should return proper error message", + (resizeProperties) => { + const result = resizePropertiesValidator.validate( + resizeProperties, + new Size(500, 500), + new Position(0, 0, 200, 200) + ); + expect(result).toBe("Close button will be offscreen"); + } + ); + + it.each([ + new ResizeProperties(300, 300, 0, 20, ClosePosition.Center, true), + new ResizeProperties(300, 300, 0, 351, ClosePosition.TopRight, false), + new ResizeProperties(300, 300, -50, 0), + new ResizeProperties(300, 300, 100, 100, ClosePosition.Center), + new ResizeProperties(300, 300, 150, 50), + ])("given valid resize properties should return null", (resizeProperties) => { + const result = resizePropertiesValidator.validate( + resizeProperties, + new Size(500, 500), + new Position(0, 0, 200, 200) + ); + expect(result).toBe(null); + }); +});