import * as playwright from "playwright" ; import { Navigation } from "./Navigation" ; import { Entity } from "./Entity" ; import { Dialog } from "./Dialog" ; import { Control } from "./Control" ; import { Attribute } from "./Attribute" ; import { Grid } from "./Grid" ; import { SubGrid } from "./SubGrid" ; import { Form } from "./Form" ; import { Button } from "./Button" ; import { Tab } from "./Tab" ; import { TestSettings } from "../domain/TestSettings" ; import * as speakeasy from "speakeasy" ; import { D365Selectors } from "../domain/D365Selectors" ; import { Section } from "./Section" ; import { isPageElement } from "../domain/SharedLogic" ; import { WebApi } from "./WebApi" ; /**
 * Parameters for opening Dynamics
 */ export interface OpenProperties { /**
     * ID of the Dynamics app to open after login
     */ appId ? : string ; /**
     * Username / email to use for logging in. If left out, it is assumed that CRM does not need explicit authentication (i.e. SSO)
     */ userName ? : string ; /**
     * Password to use for logging in. If left out, SSO is assumed.
     */ password ? : string ; /**
     * CSS selector for typing user name / email on login page. Needed for custom authentication pages of ADFS only.
     */ userNameFieldSelector ? : string ; /**
     * CSS selector for typing password on login page. Needed for custom authentication pages of ADFS only.
     */ passwordFieldSelector ? : string ; /**
     * Secret which you generated for using MFA. Only needed if you actually use MFA
     */ mfaSecret ? : string ; /**
     * Enter css selector for mfaInput, if you use a non MS default MFA provider. If none provided, we will try finding the MS token input
     */ mfaFieldSelector ? : string ; /**
     * If you use a non MS MFA provider and need to switch a toggle first before being able to insert token, specify the toggle field selector that has to be clicked before entering the MFA token here
     */ mfaToggleFieldSelector ? : string ; } /**
 * Main class for testing in D365
 */ export class XrmUiTest { private _browser : playwright . Browser ; private _context : playwright . BrowserContext ; private _page : playwright . Page ; private _crmUrl : string ; private _appId : string ; private _navigation : Navigation ; private _entity : Entity ; private _dialog : Dialog ; private _control : Control ; private _attribute : Attribute ; private _form : Form ; private _grid : Grid ; private _subGrid : SubGrid ; private _button : Button ; private _tab : Tab ; private _section : Section ; private _webApi : WebApi ; param settings constructor ( settings ? ) { this . settings = settings ; } /**
     * Settings for D365-UI-Test behavior
     */ private _settings : TestSettings = { default timeout : 60 * 1000 , default settleTime : 2 * 1000 } ; /**
     * Default settings for various actions such as navigation
     */ get settings ( ) { return this . _settings ; } /**
     * Update settings
     */ set settings ( value ) { if ( ! value ) { return ; } this . _settings = { ... this . _settings , ... value } ; } /**
     * Gets the browser object that was generated when launching playwright
     */ get browser ( ) { return this . _browser ; } /**
     * Gets the browser context object
     */ get context ( ) { return this . _context ; } /**
     * Gets the page object that was generated when connecting to D365
     */ get page ( ) { return this . _page ; } /**
     * Gets the D365 base URL
     */ get crmUrl ( ) { return this . _crmUrl ; } /**
     * Gets the subfunctions for navigating in D365
     */ get Navigation ( ) { if ( ! this . _navigation ) { this . _navigation = new Navigation ( this ) ; } return this . _navigation ; } /**
     * Gets the subfunctions for using ribbon buttons in D365
     */ get Button ( ) { if ( ! this . _button ) { this . _button = new Button ( this ) ; } return this . _button ; } /**
     * Gets the subfunctions for interacting with the record in D365
     */ get Entity ( ) { if ( ! this . _entity ) { this . _entity = new Entity ( this ) ; } return this . _entity ; } /**
     * Gets the subfunctions for interacting with attributes in D365, for example getting or setting values
     */ get Attribute ( ) { if ( ! this . _attribute ) { this . _attribute = new Attribute ( this ) ; } return this . _attribute ; } /**
     * Gets the subfunctions for interacting with controls in D365, for example getting visibility or disable states
     */ get Control ( ) { if ( ! this . _control ) { this . _control = new Control ( this ) ; } return this . _control ; } /**
     * Gets the subfunctions for interacting with dialogs in D365, for example duplicate detection dialogs
     */ get Dialog ( ) { if ( ! this . _dialog ) { this . _dialog = new Dialog ( this ) ; } return this . _dialog ; } /**
     * Gets the subfunctions for interacting with forms in D365, for example opening different forms
     */ get Form ( ) { if ( ! this . _form ) { this . _form = new Form ( this ) ; } return this . _form ; } /**
     * Gets the subfunctions for interacting with grids / entity lists in D365, for example opening specific records
     */ get Grid ( ) { if ( ! this . _grid ) { this . _grid = new Grid ( this ) ; } return this . _grid ; } /**
     * Gets the subfunctions for interacting with subgrids in D365, for example refreshing or opening specific records
     */ get SubGrid ( ) { if ( ! this . _subGrid ) { this . _subGrid = new SubGrid ( this ) ; } return this . _subGrid ; } /**
     * Gets the subfunctions for interacting with tabs in D365, for example to open one
     */ get Tab ( ) { if ( ! this . _tab ) { this . _tab = new Tab ( this ) ; } return this . _tab ; } /**
     * Gets the subfunctions for interacting with sections in D365, for example getting its visibility state
     */ get Section ( ) { if ( ! this . _section ) { this . _section = new Section ( this ) ; } return this . _section ; } /**
     * Gets the currently opened AppId
     */ get AppId ( ) { return this . _appId ; } /**
     * Sets the currently opened AppId. Is set automatically by calling Navigation.openAppById
     */ set AppId ( value ) { this . _appId = value ; } /**
     * Gets the subfunctions for interacting with the D365 WebApi using Xrm.WebApi
     */ get WebApi ( ) { if ( ! this . _webApi ) { this . _webApi = new WebApi ( this ) ; } return this . _webApi ; } param { string } browser param { playwright . launchOptions } launchOptions returns { Array < playwright . Browser , playwright . BrowserContext , playwright . Page > } remarks launch = async ( browser = "chromium" , launchOptions ? , contextOptions ? ) : Promise < [ playwright . Browser , playwright . BrowserContext , playwright . Page ] > => { this . _browser = await playwright [ browser ] . launch ( launchOptions ) ; this . _context = await this . _browser . newContext ( { viewport : null , ... contextOptions } ) ; this . _page = await this . _context . newPage ( ) ; return [ this . _browser , this . _context , this . _page ] ; } ; /**
     * Waits for all pending UCI operations to settle
     */ waitForIdleness = async ( ) => { const firstIdlenessTime = await this . waitForIdlenessInternal ( ) ; let secondIdlenessTime = firstIdlenessTime ; this . logIfDebug ( `Page became idle at: ${ firstIdlenessTime }` ) ; do { await new Promise ( ( resolve , reject ) => setTimeout ( resolve , 200 ) ) ; const [ isIdle , idleTime ] = await this . checkIdlenessInternal ( ) ; if ( isIdle ) { this . logIfDebug ( `Page still idle at: ${ secondIdlenessTime }` ) ; secondIdlenessTime = idleTime ; } else { this . logIfDebug ( "Page became busy again, resetting first settle time" ) ; await this . waitForIdleness ( ) ; } } while ( ( secondIdlenessTime - firstIdlenessTime ) < this . settings . settleTime ) ; this . logIfDebug ( `Page has been idle for ${ secondIdlenessTime - firstIdlenessTime } ms, resolving` ) ; } ; buildUrl = ( url , appId ? ) => { return `${ url }/main.aspx?forceUCI=1${ ( appId ? `&appid=${ appId }` : "" ) }${ ( this . settings . performanceMode ? `&perf=true` : "" ) }` ; } ; param { String } url param { Object } extendedProperties returns { void } open = async ( url , extendedProperties ) => { const openInternal = async ( retryCount = 0 ) : Promise < any > => { try { this . _crmUrl = url ; this . _appId = extendedProperties . appId ; const navigationUrl = this . buildUrl ( url , this . _appId ) ; await Promise . all ( [ this . page . goto ( navigationUrl , { waitUntil : "load" , timeout : this . settings . timeout } ) ] ) ; if ( extendedProperties . userName ) { await this . enterUserName ( extendedProperties ) ; } if ( extendedProperties . password ) { await this . enterPassword ( extendedProperties ) ; } if ( extendedProperties . mfaSecret ) { if ( extendedProperties . mfaToggleFieldSelector ) { await this . page . click ( extendedProperties . mfaToggleFieldSelector , { timeout : this . settings . timeout } ) ; await this . page . waitForTimeout ( 500 ) ; } const token = speakeasy . totp ( { secret : extendedProperties . mfaSecret , encoding : "base32" } ) ; await this . page . type ( extendedProperties . mfaFieldSelector ?? D365Selectors . Login . otp , token ) ; await this . page . waitForTimeout ( 500 ) ; await this . page . keyboard . press ( "Enter" ) ; } const result = await Promise . race ( [ this . page . waitForSelector ( D365Selectors . Login . dontRememberLogin , { timeout : this . settings . timeout } ) , this . waitForIdleness ( ) ] ) ; if ( isPageElement ( result ) ) { await result . click ( ) ; await this . waitForIdleness ( ) ; } } catch ( e ) { if ( retryCount < 3 ) { const waitTime = 5000 * ( retryCount + 1 ) ; console . log ( `Failed to log in, waiting ${ waitTime }ms and retrying. Retry number ${ ( retryCount + 1 ) }` ) ; await this . page . waitForTimeout ( waitTime ) ; return openInternal ( retryCount + 1 ) ; } throw e ; } } ; await openInternal ( ) ; } ; /**
     * Closes the playwright browser session
     */ close = async ( ) => { await this . browser . close ( ) ; } ; private logIfDebug ( message ) { this . settings . debugMode && console . log ( message ) ; } private async checkIdlenessInternal ( ) { const isIdle = await this . _page . evaluate ( ( ) => ( window as any ) . UCWorkBlockTracker && ( window as any ) . UCWorkBlockTracker . isAppIdle ( ) , [ ] ) ; return [ isIdle , Date . now ( ) ] ; } private async waitForIdlenessInternal ( ) { await this . _page . waitForFunction ( ( ) => ( window as any ) . UCWorkBlockTracker && ( window as any ) . UCWorkBlockTracker . isAppIdle ( ) , [ ] , { timeout : this . settings . timeout , polling : 200 } ) ; return Date . now ( ) ; } private async enterPassword ( extendedProperties ) { const result = await Promise . race ( [ this . page . waitForSelector ( D365Selectors . Login . password , { timeout : this . settings . timeout } ) , this . page . waitForNavigation ( { waitUntil : "load" , timeout : this . settings . timeout } ) ] ) ; if ( isPageElement ( result ) ) { await result . fill ( extendedProperties . password , { timeout : this . settings . timeout } ) , await this . page . keyboard . press ( "Enter" ) ; } else { console . log ( `No online auth, handling custom auth. If nothing happens, please specify passwordFieldSelector and optionally userNameFieldSelector.` ) ; if ( extendedProperties . userNameFieldSelector ) { console . log ( "Waiting for user name field: " + extendedProperties . userNameFieldSelector ) ; await this . page . fill ( extendedProperties . userNameFieldSelector , extendedProperties . userName ) ; } if ( extendedProperties . passwordFieldSelector ) { console . log ( "Waiting for password field: " + extendedProperties . passwordFieldSelector ) ; await this . page . fill ( extendedProperties . passwordFieldSelector , extendedProperties . password ) ; await this . page . press ( extendedProperties . passwordFieldSelector , "Enter" ) ; } } } private async enterUserName ( extendedProperties ) { await this . page . fill ( D365Selectors . Login . userName , extendedProperties . userName ) ; await this . page . waitForTimeout ( 1000 ) ; await this . page . press ( D365Selectors . Login . userName , "Enter" ) ; return this . page . waitForTimeout ( 1000 ) ; } }