-
Notifications
You must be signed in to change notification settings - Fork 12
/
XrmUITest.ts
492 lines (413 loc) · 15.5 KB
/
XrmUITest.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
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;
/**
* Construct a new instance of XrmUiTest
* @param settings Define certain behaviour settings optionally. If not passed, defaults will be used
*/
constructor(settings?: TestSettings) {
this.settings = settings;
}
/**
* Settings for D365-UI-Test behavior
*/
private _settings: TestSettings = {
/**
* Default navigation timeout to use on operations
*
* @default 60000 (60 seconds)
*/
timeout: 60 * 1000,
/**
* Default settle time to use for waiting until an idle page becomes settled
* @default 2000 (2 seconds)
*/
settleTime: 2 * 1000
};
/**
* Default settings for various actions such as navigation
*/
get settings() {
return this._settings;
}
/**
* Update settings
*/
set settings(value: TestSettings) {
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;
}
/**
* Function for launching a playwright instance
*
* @param {string} [browser] [chromium] Decide which browser to launch, options are chromium, firefox or webkit
* @param {playwright.launchOptions} [launchOptions] Launch options for launching playwright. Will be used for calling playwright.launch.
* @returns {Array<playwright.Browser, playwright.BrowserContext, playwright.Page>} Started browser instance, browser context and page
* @remarks viewport in launchOptions is preset to null for using your clients default resolution. Overwrite viewport to change.
*/
launch = async (browser: "chromium" | "firefox" | "webkit" = "chromium",
launchOptions?: playwright.LaunchOptions,
contextOptions?: playwright.BrowserContextOptions,
): Promise<[playwright.Browser, playwright.BrowserContext, playwright.Page]> => {
this._browser = await playwright[browser].launch(launchOptions);
// tslint:disable-next-line:no-null-keyword
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 () => {
// Wait for the page to become idle
const firstIdlenessTime = await this.waitForIdlenessInternal();
let secondIdlenessTime = firstIdlenessTime;
this.logIfDebug(`Page became idle at: ${firstIdlenessTime}`);
do {
// Sleep some time
await new Promise((resolve, reject) => setTimeout(resolve, 200));
// Check the current idle state and remember the timestamp of this check
const [isIdle, idleTime] = await this.checkIdlenessInternal();
// If the page is still idle, we just set the latest time
if (isIdle) {
this.logIfDebug(`Page still idle at: ${secondIdlenessTime}`);
secondIdlenessTime = idleTime;
}
// Otherwise the page was busy and we want to wait for the settle time again
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: string, appId?: string) => {
return `${url}/main.aspx?forceUCI=1${(appId ? `&appid=${appId}` : "")}${(this.settings.performanceMode ? `&perf=true` : "")}`;
};
/**
* Opens your D365 organization and logs you in
*
* @param { String } url Url of your D365 organization
* @param { Object } extendedProperties Options for logging in. User name and password are required. If you have a custom authentication page, you should pass userNameFieldSelector if user name has to be reentered and passwordFieldSelector for password entry. These are css selectors for the inputs.
* @returns {void} Resolves as soon as D365 is logged in and open
*/
open = async (url: string, extendedProperties: OpenProperties) => {
const openInternal = async (retryCount = 0): Promise<any> => {
try {
this._crmUrl = url;
this._appId = extendedProperties.appId;
const navigationUrl = this.buildUrl(url);
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([
// Either wait for clicking the "dont remember login" button and the UCI becoming idle...
this.page.waitForSelector(D365Selectors.Login.dontRememberLogin, { timeout: this.settings.timeout }),
// ...or for getting signed in directly without having to click "dont remember login"
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: string) {
this.settings.debugMode && console.log(message);
}
private async checkIdlenessInternal(): Promise<[boolean, number]> {
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: OpenProperties): Promise<void> {
const result = await Promise.race([
this.page.waitForSelector(D365Selectors.Login.password, { timeout: this.settings.timeout }),
this.page.waitForNavigation({ waitUntil: "load", timeout: this.settings.timeout })
]);
// For non online authentification, wait for custom login page to settle
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: OpenProperties) {
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);
}
}