-
Notifications
You must be signed in to change notification settings - Fork 51
Expand file tree
/
Copy pathPlaywrightTestInfraFunctions.cs
More file actions
529 lines (461 loc) · 22.7 KB
/
PlaywrightTestInfraFunctions.cs
File metadata and controls
529 lines (461 loc) · 22.7 KB
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
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Net;
using System.Runtime;
using System.Security;
using ICSharpCode.Decompiler.CSharp.Syntax;
using ICSharpCode.Decompiler.IL;
using ICSharpCode.Decompiler.TypeSystem;
using Microsoft.Extensions.Logging;
using Microsoft.Playwright;
using Microsoft.PowerApps.TestEngine.Config;
using Microsoft.PowerApps.TestEngine.Providers;
using Microsoft.PowerApps.TestEngine.System;
using Microsoft.PowerApps.TestEngine.Users;
namespace Microsoft.PowerApps.TestEngine.TestInfra
{
/// <summary>
/// Playwright implementation of the test infrastructure function
/// </summary>
public class PlaywrightTestInfraFunctions : ITestInfraFunctions
{
private readonly ITestState _testState;
private readonly ISingleTestInstanceState _singleTestInstanceState;
private readonly IFileSystem _fileSystem;
private readonly ITestWebProvider _testWebProvider;
private readonly IEnvironmentVariable _environmentVariable;
private readonly IUserCertificateProvider _certificateProvider;
public static string BrowserNotSupportedErrorMessage = "Browser not supported by Playwright, for more details check https://playwright.dev/dotnet/docs/browsers";
private IPlaywright PlaywrightObject { get; set; }
private IBrowser Browser { get; set; }
private IBrowserContext BrowserContext { get; set; }
public IPage Page { get; set; }
public PlaywrightTestInfraFunctions(ITestState testState, ISingleTestInstanceState singleTestInstanceState, IFileSystem fileSystem, ITestWebProvider testWebProvider, IEnvironmentVariable environmentVariable, IUserCertificateProvider certificateProvider)
{
_testState = testState;
_singleTestInstanceState = singleTestInstanceState;
_fileSystem = fileSystem;
_testWebProvider = testWebProvider;
_environmentVariable = environmentVariable;
_certificateProvider = certificateProvider;
}
// Constructor to aid with unit testing
public PlaywrightTestInfraFunctions(ITestState testState, ISingleTestInstanceState singleTestInstanceState, IFileSystem fileSystem,
IPlaywright playwrightObject = null, IBrowserContext browserContext = null, IPage page = null, ITestWebProvider testWebProvider = null, IEnvironmentVariable environmentVariable = null, IUserCertificateProvider certificateProvider = null) : this(testState, singleTestInstanceState, fileSystem, testWebProvider, environmentVariable, certificateProvider)
{
PlaywrightObject = playwrightObject;
Page = page;
BrowserContext = browserContext;
}
public IBrowserContext GetContext()
{
return BrowserContext;
}
public async Task SetupAsync(IUserManager userManager)
{
var browserConfig = _singleTestInstanceState.GetBrowserConfig();
var staticContext = new BrowserTypeLaunchPersistentContextOptions();
if (browserConfig == null)
{
_singleTestInstanceState.GetLogger().LogError("Browser config cannot be null");
throw new InvalidOperationException();
}
if (string.IsNullOrEmpty(browserConfig.Browser))
{
_singleTestInstanceState.GetLogger().LogError("Browser cannot be null");
throw new InvalidOperationException();
}
if (PlaywrightObject == null)
{
PlaywrightObject = await Playwright.Playwright.CreateAsync();
}
var testSettings = _testState.GetTestSettings();
if (testSettings == null)
{
_singleTestInstanceState.GetLogger().LogError("Test settings cannot be null.");
throw new InvalidOperationException();
}
var launchOptions = new BrowserTypeLaunchOptions()
{
Headless = testSettings.Headless,
Timeout = testSettings.Timeout
};
if (!string.IsNullOrEmpty(testSettings.ExecutablePath))
{
launchOptions.ExecutablePath = testSettings.ExecutablePath;
staticContext.ExecutablePath = testSettings.ExecutablePath;
}
staticContext.Headless = launchOptions.Headless;
staticContext.Timeout = launchOptions.Timeout;
//this is added for the new headless mode in chromium
if (testSettings.Headless && PlaywrightObject?.Chromium?.Name != null && string.Equals(browserConfig.Browser, PlaywrightObject.Chromium.Name, StringComparison.OrdinalIgnoreCase))
{
var headlessArgs = new[] { "--headless=new" };
launchOptions.Args = (launchOptions.Args ?? Array.Empty<string>()).Concat(headlessArgs).ToArray();
staticContext.Args = (staticContext.Args ?? Array.Empty<string>()).Concat(headlessArgs).ToArray();
}
// Use indexer for valid browser (tests assert this), translate null or ArgumentException to UserInputException
IBrowserType browser = null;
try
{
browser = PlaywrightObject[browserConfig.Browser];
}
catch (ArgumentException)
{
// Ensure we map any Playwright internal invalid browser exceptions to UserInputException
_singleTestInstanceState.GetLogger().LogError(BrowserNotSupportedErrorMessage);
throw new UserInputException(UserInputException.ErrorMapping.UserInputExceptionInvalidTestSettings.ToString());
}
if (browser == null)
{
_singleTestInstanceState.GetLogger().LogError(BrowserNotSupportedErrorMessage);
throw new UserInputException(UserInputException.ErrorMapping.UserInputExceptionInvalidTestSettings.ToString());
}
if (!userManager.UseStaticContext)
{
// Check if a channel has been specified
if (!string.IsNullOrEmpty(browserConfig.Channel))
{
launchOptions.Channel = browserConfig.Channel;
}
Browser = await browser.LaunchAsync(launchOptions);
_singleTestInstanceState.GetLogger().LogInformation("Browser setup finished");
}
var contextOptions = new BrowserNewContextOptions();
// Use local when start browser
contextOptions.Locale = testSettings.Locale;
staticContext.Locale = contextOptions.Locale;
if (!string.IsNullOrEmpty(browserConfig.Device))
{
contextOptions = PlaywrightObject.Devices[browserConfig.Device];
}
if (testSettings.RecordVideo)
{
contextOptions.RecordVideoDir = _singleTestInstanceState.GetTestResultsDirectory();
staticContext.RecordVideoDir = contextOptions.RecordVideoDir;
}
if (browserConfig.ScreenWidth != null && browserConfig.ScreenHeight != null)
{
contextOptions.ViewportSize = new ViewportSize()
{
Width = browserConfig.ScreenWidth.Value,
Height = browserConfig.ScreenHeight.Value
};
staticContext.RecordVideoSize = new RecordVideoSize()
{
Width = browserConfig.ScreenWidth.Value,
Height = browserConfig.ScreenHeight.Value,
};
}
if (testSettings.ExtensionModules != null && testSettings.ExtensionModules.Enable)
{
foreach (var module in _testState.GetTestEngineModules())
{
module.ExtendBrowserContextOptions(contextOptions, testSettings);
}
}
if (userManager is IConfigurableUserManager configurableUserManager)
{
// Add file state as user manager may need access to file system
configurableUserManager.Settings.Add("FileSystem", _fileSystem);
// Add Evironment variable as provider may need additional settings
configurableUserManager.Settings.Add("Environment", _environmentVariable);
// Pass in current test state
configurableUserManager.Settings.Add("TestState", _testState);
configurableUserManager.Settings.Add("SingleTestState", _singleTestInstanceState);
// Pass in certificate provider
configurableUserManager.Settings.Add("UserCertificate", _certificateProvider);
if (configurableUserManager.Settings.ContainsKey("LoadState")
&& configurableUserManager.Settings["LoadState"] is Func<IEnvironmentVariable, ISingleTestInstanceState, ITestState, IFileSystem, string> loadState)
{
var storageState = loadState.DynamicInvoke(_environmentVariable, _singleTestInstanceState, _testState, _fileSystem) as string;
// Optionally check if user manager wants to load a previous session state from storage
if (!string.IsNullOrEmpty(storageState))
{
_singleTestInstanceState.GetLogger().LogInformation("Loading storage stage");
contextOptions.StorageState = storageState;
}
// *** Storage State and Security context ***
//
// ** Why It Is Important: **
//
// ** Session Management: **
// Cookies are used to store session information, such as authentication tokens.
// Without the ability to store and retrieve cookies, the browser context cannot maintain the user's session, leading to authentication failures.
//
// ** Authentication State: **
// When a user logs in, the authentication tokens are often stored in cookies.
// These tokens are required for subsequent requests to authenticate the user.
// If cookies are not enabled, expired or related to sessions that are no longer valid, the browser context will not have access to these tokens or have tokens which are invalid.
// This resulting can result in errors like AADSTS50058.
//
// ** Example: **
// Lets look at an example of the impact of cookies and how it can generate Entra based login errors.
// The user initially logins in successfully using [Temporary Access Pass](https://learn.microsoft.com/entra/identity/authentication/howto-authentication-temporary-access-pass) with a lifetime of one hour.
//
// In this example we will later see AADSTS50058 error occuring when a silent sign-in request is sent, but no user is signed in after the Temporary Access Pass (TAP) with a lifetime has expired or had been revoked.
//
// Explaination:
// Test can receive error "AADSTS50058: A silent sign-in request was sent but no user is signed in."
//
// The error occurs because the silent sign-in request is sent to the login.microsoftonline.com endpoint.
// Entra validates the request and determines the usable authentication methods and determine that the original TAP has expired
// This prompts the interactive sign in process again
//
// For deeper discussion
// 1. Start with [Microsoft Entra authentication documentation](https://learn.microsoft.com/entra/identity/authentication/)
// 1. Single Sign On and how it works review [Microsoft Entra seamless single sign-on: Technical deep dive](https://learn.microsoft.com/entra/identity/hybrid/connect/how-to-connect-sso-how-it-works)
// 2. [What authentication and verification methods are available in Microsoft Entra ID?](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods)
}
}
if (userManager.UseStaticContext)
{
//remove context directory if any present previously
await RemoveContext(userManager);
var location = userManager.ContextLocation;
if (!Path.IsPathRooted(location))
{
location = Path.Combine(_fileSystem.GetDefaultRootTestEngine(), location);
}
_fileSystem.CreateDirectory(location);
// Check if a channel has been specified
if (!string.IsNullOrEmpty(browserConfig.Channel))
{
staticContext.Channel = browserConfig.Channel;
}
BrowserContext = await browser.LaunchPersistentContextAsync(location, staticContext);
}
else
{
BrowserContext = await Browser.NewContextAsync(contextOptions);
}
_singleTestInstanceState.GetLogger().LogInformation("Browser context created");
}
public async Task SetupNetworkRequestMockAsync()
{
var mocks = _singleTestInstanceState.GetTestSuiteDefinition().NetworkRequestMocks;
if (mocks == null || mocks.Count == 0)
{
return;
}
if (Page == null)
{
Page = await BrowserContext.NewPageAsync();
}
foreach (var mock in mocks)
{
if (mock.IsExtension)
{
foreach (var module in _testState.GetTestEngineModules())
{
await module.RegisterNetworkRoute(_testState, _singleTestInstanceState, _fileSystem, Page, mock);
}
}
else
{
if (string.IsNullOrEmpty(mock.RequestURL))
{
_singleTestInstanceState.GetLogger().LogError("RequestURL cannot be null");
throw new UserInputException(UserInputException.ErrorMapping.UserInputExceptionTestConfig.ToString());
}
if (string.IsNullOrEmpty(mock.RequestURL))
{
_singleTestInstanceState.GetLogger().LogError("RequestURL cannot be null");
throw new UserInputException(UserInputException.ErrorMapping.UserInputExceptionTestConfig.ToString());
}
if (!_fileSystem.CanAccessFilePath(mock.ResponseDataFile) || !_fileSystem.FileExists(mock.ResponseDataFile))
{
_singleTestInstanceState.GetLogger().LogError("ResponseDataFile is invalid or missing");
throw new UserInputException(UserInputException.ErrorMapping.UserInputExceptionInvalidFilePath.ToString());
}
await Page.RouteAsync(mock.RequestURL, async route => await RouteNetworkRequest(route, mock));
}
}
}
public async Task RouteNetworkRequest(IRoute route, NetworkRequestMock mock)
{
// For optional properties of NetworkRequestMock, if the property is not specified,
// the routing applies to all. Ex: If Method is null, we mock response whatever the method is.
bool notMatch = false;
if (!string.IsNullOrEmpty(mock.Method))
{
notMatch = !string.Equals(mock.Method, route.Request.Method);
}
if (!string.IsNullOrEmpty(mock.RequestBodyFile))
{
notMatch = notMatch || !string.Equals(route.Request.PostData, _fileSystem.ReadAllText(mock.RequestBodyFile));
}
if (mock.Headers != null && mock.Headers.Count != 0)
{
foreach (var header in mock.Headers)
{
var requestHeaderValue = await route.Request.HeaderValueAsync(header.Key);
notMatch = notMatch || !string.Equals(header.Value, requestHeaderValue);
}
}
if (!notMatch)
{
await route.FulfillAsync(new RouteFulfillOptions { Path = mock.ResponseDataFile });
}
else
{
await route.ContinueAsync();
}
}
public async Task GoToUrlAsync(string url)
{
if (string.IsNullOrEmpty(url))
{
_singleTestInstanceState.GetLogger().LogError("Url cannot be null or empty");
throw new InvalidOperationException();
}
if (!Uri.TryCreate(url, UriKind.Absolute, out Uri uri))
{
_singleTestInstanceState.GetLogger().LogError("Url is invalid");
throw new InvalidOperationException();
}
if ((uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
{
if (url != "about:blank")
{
_singleTestInstanceState.GetLogger().LogError("Url must be http/https");
throw new InvalidOperationException();
}
}
if (Page == null)
{
Page = await BrowserContext.NewPageAsync();
}
var response = await Page.GotoAsync(url);
// The response might be null because "The method either throws an error or returns a main resource response.
// The only exceptions are navigation to about:blank or navigation to the same URL with a different hash, which would succeed and return null."
//(From playwright https://playwright.dev/dotnet/docs/api/class-page#page-goto)
if (response != null && !response.Ok)
{
_singleTestInstanceState.GetLogger().LogTrace($"Page is {url}, response is {response?.Status}");
_singleTestInstanceState.GetLogger().LogError($"Error navigating to page.");
throw new InvalidOperationException();
}
}
public async Task EndTestRunAsync(IUserManager userManager)
{
if (BrowserContext != null)
{
await Task.Delay(200);
await BrowserContext.CloseAsync();
}
await RemoveContext(userManager);
}
public async Task RemoveContext(IUserManager userManager)
{
try
{
if (userManager.UseStaticContext)
{
var location = userManager.ContextLocation;
if (!Path.IsPathRooted(location))
{
location = Path.Combine(_fileSystem.GetDefaultRootTestEngine(), location);
}
_fileSystem.DeleteDirectory(location);
}
}
catch
{
_singleTestInstanceState.GetLogger().LogInformation("Missing context or error deleting context");
}
}
public async Task DisposeAsync()
{
if (BrowserContext != null)
{
await BrowserContext.DisposeAsync();
BrowserContext = null;
}
if (PlaywrightObject != null)
{
PlaywrightObject.Dispose();
PlaywrightObject = null;
}
}
private void ValidatePage()
{
if (Page == null)
{
throw new InvalidOperationException("Page is null, make sure to call GoToUrlAsync first");
}
}
public async Task ScreenshotAsync(string screenshotFilePath)
{
ValidatePage();
if (!_fileSystem.CanAccessFilePath(screenshotFilePath))
{
throw new InvalidOperationException("screenshotFilePath must be provided");
}
await Page.ScreenshotAsync(new PageScreenshotOptions() { Path = $"{screenshotFilePath}" });
}
public async Task FillAsync(string selector, string value)
{
ValidatePage();
await Page.FillAsync(selector, value);
}
public async Task ClickAsync(string selector)
{
ValidatePage();
await Page.ClickAsync(selector);
}
public async Task AddScriptTagAsync(string scriptTag, string frameName)
{
ValidatePage();
if (string.IsNullOrEmpty(frameName))
{
await Page.AddScriptTagAsync(new PageAddScriptTagOptions() { Path = scriptTag });
}
else
{
await Page.Frame(frameName).AddScriptTagAsync(new FrameAddScriptTagOptions() { Path = scriptTag });
}
}
public async Task<T> RunJavascriptAsync<T>(string jsExpression)
{
ValidatePage();
if (!jsExpression.Equals(_testWebProvider.CheckTestEngineObject))
{
_singleTestInstanceState.GetLogger().LogDebug("Run Javascript: " + jsExpression);
}
return await Page.EvaluateAsync<T>(jsExpression);
}
public async Task AddScriptContentAsync(string content)
{
ValidatePage();
await Page.AddScriptTagAsync(new PageAddScriptTagOptions { Content = content });
}
public async Task<bool> TriggerControlClickEvent(string controlName, string filePath)
{
ValidatePage();
if (!string.IsNullOrEmpty(filePath))
{
try
{
//Add Picture Control
var fileChooser = await Page.RunAndWaitForFileChooserAsync(async () =>
{
var match = Page.Locator($"[data-control-name='{controlName}']");
await match.ClickAsync();
});
await fileChooser.SetFilesAsync(filePath);
return true;
}
catch (Exception ex)
{
_singleTestInstanceState.GetLogger().LogError($"Error triggering Add Picture control click event: {ex.Message}");
return false; // Return false if there was an error
}
}
return false;
}
}
}