From ba16fbe4b2a6d81e229051d89df98bd4cb3e5b4a Mon Sep 17 00:00:00 2001 From: Grant Archibald <31553604+Grant-Archibald-MS@users.noreply.github.com> Date: Fri, 22 Nov 2024 10:26:51 -0800 Subject: [PATCH] Multi Feature Merge - Test Engine Authentication, Providers and Power Fx extensions (#380) * Adding namespace checks and support for Power FX #330 * Adding TestEngine.PlaywrightScript() #335 * Adding TestEngine.PlaywrightAction() #337 * Adding TestEngine.PlaywrightAction docs * Adding TestEngine.PlaywrightScript docs * Update TestEngine.PlaywrightScript.md * Initial portal provider * Partial PowerApps Portal provider implementation * WIP Power Apps Portal provider implementation * Adding TestEngine.GetConnections() * Test update * Adding CreateConnection * Adding TestEngine.CheckConnectionExists * TestEngine.CreateConnection update * Review edits * Add TestEngine.UpdateConnectionReferences() * Solution update * Connection list * Connection and format * Export connections * Package update * NuGet version update * NuGet Updates * Playright updates * Provider update * Review edits. * Update TestEngine namespace * Power Apps Portal update * Format updates * Add default certificate provider * Adding Variables ans Collection support * Remoding date tets * Adding TestEngine.SelectSection() * Adding tests * Format update * Adding MDA module and CoE custom page Sample * Review edit * Adding browser locale change * Adding Experimental.SelectControl() for MDA custom pages * Adding WIP Experimental.SimulateDataverse() * WIP SimuateDataverse GET list * Update for $batch and Query * Basic query use cases * Update .Net 8.0 * Update to .Net 8 * Additional Context error handling * Remove legacy player * Adding SimulateConnector Power Fx function * Updates for Debug/Trace logging * Networking monitoring update * Minior edits * Simulate update * WIP record implementation for SimulateDataverse and SimulateConnector * Sign only Release build record SimulateDataverse and SimulateConnector * Adding Mouse recording * Recorder update and Experimental functions * Review edits * Reviw edits for .Net 8 * Review edits * Adding parameter for custom page * Adding audio recording * Add audio event tracking * Asing storage auth provider * Suppotr for negative test case * Adding error checks for storage state * Adding NotificationTitle error detection * Storage state and permissions example * Update variable state * Refactor to common PowerPlatformLogin * Adding missing common files * Review changes * Adding storage state for #389 * Update samples to storagestate * Adding changes * Log updates and Test Cases * Format update and .Net 8.0 build * Build update * Pull request review edits * Review edit * Review changes * Update dotnet-format.yml * Adding docs --- .github/workflows/build-test.yml | 2 +- .github/workflows/dotnet-format.yml | 2 +- README.md | 4 +- azure-pipelines.yml | 4 +- .../scripts/yaml-integration-tests.sh | 4 +- docs/Extensions/PowerAppsPortal.md | 3 + docs/PowerFX/Pause.md | 10 +- docs/PowerFX/README.md | 20 +- docs/PowerFX/SimulateConnector.md | 41 + docs/PowerFX/SimulateDataverse.md | 46 + docs/PowerFX/TestEngine.PlaywrightAction.md | 36 + docs/PowerFX/TestEngine.PlaywrightScript.md | 44 + samples/.gitignore | 1 + samples/basicgallery/README.md | 24 + samples/basicgallery/RunTests.ps1 | 31 + samples/basicgallery/testPlan.fx.yaml | 3 +- ...anForRegionUseSemicolonAsSeparator.fx.yaml | 3 +- samples/buttonclicker/README.md | 22 +- samples/buttonclicker/RunTests.ps1 | 30 + samples/buttonclicker/testPlan.cba.fx.yaml | 1 + samples/buttonclicker/testPlan.fx.yaml | 1 + samples/calculator/README.md | 27 +- samples/calculator/RunTests.ps1 | 31 + samples/calculator/testPlan.fx.yaml | 1 + .../testPlanWithCommaForDecimal.fx.yaml | 1 + samples/coe-kit-setup-wizard/.gitignore | 2 + samples/coe-kit-setup-wizard/GetAppId.powerfx | 1 + samples/coe-kit-setup-wizard/README.md | 107 +- samples/coe-kit-setup-wizard/Record.ps1 | 73 ++ samples/coe-kit-setup-wizard/RecordCanvas.ps1 | 25 + samples/coe-kit-setup-wizard/RunTests.ps1 | 72 ++ samples/coe-kit-setup-wizard/record.fx.yaml | 27 + .../coe-kit-setup-wizard/recordCanvas.fx.yaml | 28 + samples/coe-kit-setup-wizard/testPlan.fx.yaml | 23 +- samples/connector/README.md | 24 + samples/connector/RunTests.ps1 | 34 + samples/connector/testPlan-simulated.fx.yaml | 28 + samples/connector/testPlan.fx.yaml | 3 +- samples/containers/README.md | 24 + samples/containers/RunTests.ps1 | 30 + samples/containers/testPlan.fx.yaml | 1 + samples/differentvariabletypes/README.md | 27 +- samples/differentvariabletypes/RunTests.ps1 | 30 + .../differentvariabletypes/testPlan.fx.yaml | 1 + .../testPlanAppIdPreprod.fx.yaml | 1 + .../testPlanAppIdPreview.fx.yaml | 1 + .../testPlanAppIdTest.fx.yaml | 1 + .../testPlanForScriptInjection.fx.yaml | 1 + samples/extensions/README.md | 24 + samples/extensions/RunTests.ps1 | 33 + .../extensions/testPlan-denyCommand.fx.yaml | 3 +- .../extensions/testPlan-denyModule.fx.yaml | 3 +- .../testPlan-enableOnlyWriteLine.fx.yaml | 3 +- samples/extensions/testPlan.fx.yaml | 3 +- samples/manyscreens/README.md | 24 + samples/manyscreens/RunTests.ps1 | 30 + samples/manyscreens/testPlan.fx.yaml | 1 + samples/mda/RunTests.ps1 | 58 + samples/mda/testPlan.fx.yaml | 3 +- samples/modules/README.md | 24 + samples/modules/RunTests.ps1 | 30 + samples/modules/testPlan.fx.yaml | 18 +- samples/nestedgallery/README.md | 24 + samples/nestedgallery/RunTests.ps1 | 30 + samples/nestedgallery/testPlan.fx.yaml | 1 + samples/pause/README.md | 24 + samples/pause/RunTests.ps1 | 30 + samples/pause/testPlan.fx.yaml | 7 +- samples/pcfcomponent/README.md | 30 +- samples/pcfcomponent/RunTests.ps1 | 30 + samples/pcfcomponent/testPlan.fx.yaml | 1 + samples/permissions/.gitignore | 1 + samples/permissions/Permissions_1_0_0_1.zip | Bin 0 -> 59411 bytes samples/permissions/README.md | 92 ++ samples/permissions/RunTests.ps1 | 78 ++ .../canvas-no-powerapps-licence.te.yaml | 27 + samples/permissions/canvas-not-shared.te.yaml | 27 + .../custom-page-no-permissions.te.yaml | 27 + .../entity-list-no-permissions.te.yaml | 27 + .../user1-power-apps-portal.te.yaml | 27 + .../user2-power-apps-portal.te.yaml | 27 + samples/playwrightaction/README.md | 24 + samples/playwrightaction/RunTests.ps1 | 30 + samples/playwrightaction/testPlan.fx.yaml | 28 + samples/playwrightscript/README.md | 28 + samples/playwrightscript/RunTests.ps1 | 30 + samples/playwrightscript/sample.csx | 28 + samples/playwrightscript/testPlan.fx.yaml | 30 + samples/portal/README.md | 24 + samples/portal/RunTests.ps1 | 30 + .../testPlan.connectionreference.fx.yaml | 27 + samples/portal/testPlan.fx.yaml | 50 + samples/simulation/README.md | 24 + samples/simulation/RunTests.ps1 | 31 + samples/simulation/testPlan.fx.yaml | 31 + samples/template/TestPlanTemplate.fx.yaml | 1 + .../Config/TestStateTests.cs | 9 - ...icrosoft.PowerApps.TestEngine.Tests.csproj | 16 +- .../TestEngineExtensionCheckerTests.cs | 36 +- .../PowerFx/Functions/IsMatchFunctionTests.cs | 93 ++ .../PowerFx/PowerFxEngineTests.cs | 39 + .../Reporting/TestLogTests.cs | 25 + .../SingleTestRunnerTests.cs | 12 +- .../TestEngineTests.cs | 24 +- .../MicrosoftEntraNetworkMonitorTests.cs | 198 +++ .../PlaywrightTestInfraFunctionTests.cs | 3 +- .../TestInfra/TestRecorderTests.cs | 537 +++++++++ .../Config/DefaultUserCertificateProvider.cs | 17 + .../Config/ITestState.cs | 34 + .../Config/TestSettingExtensions.cs | 13 +- .../Config/TestState.cs | 37 +- .../Config/TestStepEventArgs.cs | 31 + .../Config/TestSuiteDefinition.cs | 5 + .../Microsoft.PowerApps.TestEngine.csproj | 31 +- .../Modules/TestEngineExtensionChecker.cs | 214 +++- .../Modules/TestEngineModuleMEFLoader.cs | 5 +- .../PowerFx/Functions/IsMatchFunction.cs | 61 + .../PowerFx/IPowerFxEngine.cs | 6 + .../PowerFx/PowerFxEngine.cs | 34 +- .../Reporting/TestLog.cs | 22 + .../Reporting/TestLogger.cs | 9 +- .../SingleTestRunner.cs | 94 +- .../System/FileSystem.cs | 7 +- .../System/IFileSystem.cs | 8 + .../System/UriRedactionFormatter.cs | 26 + .../TestEngine.cs | 14 +- .../TestInfra/MicrosoftEntraNetworkMonitor.cs | 160 +++ .../TestInfra/PlaywrightTestInfraFunctions.cs | 71 +- .../TestInfra/TestRecorder.cs | 1062 +++++++++++++++++ .../Users/IConfigurableUserManager.cs | 11 + src/PowerAppsTestEngine.sln | 193 +-- src/PowerAppsTestEngine/InputOptions.cs | 2 + .../PowerAppsTestEngine.csproj | 13 +- src/PowerAppsTestEngine/Program.cs | 51 +- ...tengine.auth.certificatestore.tests.csproj | 2 +- .../testengine.auth.certificatestore.csproj | 2 +- ...tengine.auth.localcertificate.tests.csproj | 2 +- .../testengine.auth.localcertificate.csproj | 2 +- .../PowerPlatformLoginTests.cs | 128 ++ .../testengine.common.user.tests.csproj | 45 + src/testengine.common.user/LoginState.cs | 29 + .../PowerPlatformLogin.cs | 144 +++ .../testengine.common.user.csproj | 39 + .../SelectControlTests.cs | 54 + .../testengine.module.mda.tests.csproj | 13 +- .../ConsentDialogFunction.cs | 2 +- .../ModelDrivenApplicationModule.cs | 3 + src/testengine.module.mda/SelectControl.cs | 72 ++ .../testengine.module.mda.csproj | 21 +- .../testengine.module.pause.tests.csproj | 13 +- src/testengine.module.pause/PauseFunction.cs | 4 +- .../testengine.module.pause.csproj | 19 +- .../PlaywrightActionFunctionTests.cs | 155 +++ .../PlaywrightActionFunctionValueTests.cs | 133 +++ .../PlaywrightActionModuleTests.cs | 87 ++ .../Usings.cs | 1 + ...ngine.module.playwrightaction.tests.csproj | 41 + .../PlaywrightActionFunction.cs | 87 ++ .../PlaywrightActionModule.cs | 39 + .../PlaywrightActionValueFunction.cs | 120 ++ .../testengine.module.playwrightaction.csproj | 34 + .../PlaywrightScriptsFunctionTests.cs | 89 ++ .../PlaywrightScriptsModuleTests.cs | 89 ++ .../Usings.cs | 1 + ...ngine.module.playwrightscript.tests.csproj | 41 + .../PlaywrightScriptFunction.cs | 158 +++ .../PlaywrightScriptModule.cs | 36 + .../testengine.module.playwrightscript.csproj | 42 + .../CheckConnectionExistsFunctionTests.cs | 66 + .../ConnectionHelperTests.cs | 226 ++++ .../CreateConnectionFunctionTests.cs | 269 +++++ .../ExportConnectionsFunctionTest.cs | 85 ++ .../GetConnectionsFunctionTests.cs | 74 ++ .../PowerAppsPortalModuleTests.cs | 73 ++ .../SelectSectionFunctionTests.cs | 56 + .../Usings.cs | 1 + ...ngine.module.powerapps.portal.tests.csproj | 46 + .../CheckConnectionExistsFunction.cs | 54 + .../Connection.cs | 14 + .../ConnectionHelper.cs | 179 +++ .../CreateConnectionFunction.cs | 294 +++++ .../ExportConnectionsFunction.cs | 56 + .../GetConnectionsFunction.cs | 76 ++ .../PowerAppsPortalConnections.js | 89 ++ .../PowerAppsPortalModule.cs | 46 + .../SelectSection.cs | 63 + .../UpdateConnectionReferencesFunction.cs | 64 + .../testengine.module.powerapps.portal.csproj | 63 + .../SampleFunction.cs | 3 +- .../testengine.module.sample.csproj | 17 +- .../SimulateConnectorFunction.cs | 437 +++++++ .../SimulateDataverseFunction.cs | 395 ++++++ .../SimulationModule.cs | 54 + .../testengine.module.simulation.csproj | 37 + .../MockLoggerHelper.cs | 17 + .../testengine.module.tests.common.csproj | 24 + .../SimulateConnectorFunctionTests.cs | 284 +++++ .../SimulateDataverseFunctionTests.cs | 299 +++++ ...testengine.modules.simulation.tests.csproj | 45 + .../testengine.provider.canvas.tests.csproj | 11 +- .../PowerAppFunctions.cs | 40 +- .../testengine.provider.canvas.csproj | 15 +- .../ModelDrivenApplicationCanvasStateTests.cs | 314 +++++ ...DrivenApplicationProviderCustomPageTest.cs | 3 + ...rivenApplicationProviderEntityListTest.cs} | 0 .../ModelDrivenApplicationProviderTest.cs | 42 +- .../data-collection.json | 6 + .../data-collection2.json | 6 + .../data-empty.json | 6 + .../data-sample.json | 55 + .../data-variable-int.json | 9 + .../data-variable-int2.json | 9 + .../testengine.provider.mda.tests.csproj | 26 +- .../ModelDrivenApplicationCanvasState.cs | 634 ++++++++++ .../ModelDrivenApplicationProvider.cs | 66 +- .../VariableStateValue.cs | 14 + .../testengine.provider.mda.csproj | 15 +- .../PowerAppPortalProviderTest.cs | 70 ++ .../Usings.cs | 1 + ...ine.provider.powerapps,portal.tests.csproj | 40 + .../PowerAppPortalProvider.cs | 227 ++++ .../PowerAppsPortal.js | 43 + ...estengine.provider.powerapps.portal.csproj | 42 + .../BrowserUserManagerModuleTests.cs | 9 + .../testengine.user.browser.tests.csproj | 13 +- .../BrowserUserManagerModule.cs | 57 +- .../testengine.user.browser.csproj | 12 +- .../testengine.user.certificate.tests.csproj | 2 +- .../testengine.user.certificate.csproj | 2 +- .../testengine.user.environment.tests.csproj | 13 +- .../EnvironmentUserManagerModule.cs | 4 +- .../testengine.user.environment.csproj | 11 +- .../StorageStateUserManagerModuleTests.cs | 199 +++ .../testengine.user.storagestate.tests.csproj | 44 + .../StorageStateUserManagerModule.cs | 258 ++++ .../testengine.user.storagestate.csproj | 38 + targets/targets.csproj | 2 +- 237 files changed, 12657 insertions(+), 316 deletions(-) create mode 100644 docs/Extensions/PowerAppsPortal.md create mode 100644 docs/PowerFX/SimulateConnector.md create mode 100644 docs/PowerFX/SimulateDataverse.md create mode 100644 docs/PowerFX/TestEngine.PlaywrightAction.md create mode 100644 docs/PowerFX/TestEngine.PlaywrightScript.md create mode 100644 samples/.gitignore create mode 100644 samples/basicgallery/README.md create mode 100644 samples/basicgallery/RunTests.ps1 create mode 100644 samples/buttonclicker/RunTests.ps1 create mode 100644 samples/calculator/RunTests.ps1 create mode 100644 samples/coe-kit-setup-wizard/.gitignore create mode 100644 samples/coe-kit-setup-wizard/GetAppId.powerfx create mode 100644 samples/coe-kit-setup-wizard/Record.ps1 create mode 100644 samples/coe-kit-setup-wizard/RecordCanvas.ps1 create mode 100644 samples/coe-kit-setup-wizard/RunTests.ps1 create mode 100644 samples/coe-kit-setup-wizard/record.fx.yaml create mode 100644 samples/coe-kit-setup-wizard/recordCanvas.fx.yaml create mode 100644 samples/connector/README.md create mode 100644 samples/connector/RunTests.ps1 create mode 100644 samples/connector/testPlan-simulated.fx.yaml create mode 100644 samples/containers/README.md create mode 100644 samples/containers/RunTests.ps1 create mode 100644 samples/differentvariabletypes/RunTests.ps1 create mode 100644 samples/extensions/README.md create mode 100644 samples/extensions/RunTests.ps1 create mode 100644 samples/manyscreens/README.md create mode 100644 samples/manyscreens/RunTests.ps1 create mode 100644 samples/mda/RunTests.ps1 create mode 100644 samples/modules/README.md create mode 100644 samples/modules/RunTests.ps1 create mode 100644 samples/nestedgallery/README.md create mode 100644 samples/nestedgallery/RunTests.ps1 create mode 100644 samples/pause/README.md create mode 100644 samples/pause/RunTests.ps1 create mode 100644 samples/pcfcomponent/RunTests.ps1 create mode 100644 samples/permissions/.gitignore create mode 100644 samples/permissions/Permissions_1_0_0_1.zip create mode 100644 samples/permissions/README.md create mode 100644 samples/permissions/RunTests.ps1 create mode 100644 samples/permissions/canvas-no-powerapps-licence.te.yaml create mode 100644 samples/permissions/canvas-not-shared.te.yaml create mode 100644 samples/permissions/custom-page-no-permissions.te.yaml create mode 100644 samples/permissions/entity-list-no-permissions.te.yaml create mode 100644 samples/permissions/user1-power-apps-portal.te.yaml create mode 100644 samples/permissions/user2-power-apps-portal.te.yaml create mode 100644 samples/playwrightaction/README.md create mode 100644 samples/playwrightaction/RunTests.ps1 create mode 100644 samples/playwrightaction/testPlan.fx.yaml create mode 100644 samples/playwrightscript/README.md create mode 100644 samples/playwrightscript/RunTests.ps1 create mode 100644 samples/playwrightscript/sample.csx create mode 100644 samples/playwrightscript/testPlan.fx.yaml create mode 100644 samples/portal/README.md create mode 100644 samples/portal/RunTests.ps1 create mode 100644 samples/portal/testPlan.connectionreference.fx.yaml create mode 100644 samples/portal/testPlan.fx.yaml create mode 100644 samples/simulation/README.md create mode 100644 samples/simulation/RunTests.ps1 create mode 100644 samples/simulation/testPlan.fx.yaml create mode 100644 src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/IsMatchFunctionTests.cs create mode 100644 src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestLogTests.cs create mode 100644 src/Microsoft.PowerApps.TestEngine.Tests/TestInfra/MicrosoftEntraNetworkMonitorTests.cs create mode 100644 src/Microsoft.PowerApps.TestEngine.Tests/TestInfra/TestRecorderTests.cs create mode 100644 src/Microsoft.PowerApps.TestEngine/Config/DefaultUserCertificateProvider.cs create mode 100644 src/Microsoft.PowerApps.TestEngine/Config/TestStepEventArgs.cs create mode 100644 src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/IsMatchFunction.cs create mode 100644 src/Microsoft.PowerApps.TestEngine/System/UriRedactionFormatter.cs create mode 100644 src/Microsoft.PowerApps.TestEngine/TestInfra/MicrosoftEntraNetworkMonitor.cs create mode 100644 src/Microsoft.PowerApps.TestEngine/TestInfra/TestRecorder.cs create mode 100644 src/Microsoft.PowerApps.TestEngine/Users/IConfigurableUserManager.cs create mode 100644 src/testengine.common.user.tests/PowerPlatformLoginTests.cs create mode 100644 src/testengine.common.user.tests/testengine.common.user.tests.csproj create mode 100644 src/testengine.common.user/LoginState.cs create mode 100644 src/testengine.common.user/PowerPlatformLogin.cs create mode 100644 src/testengine.common.user/testengine.common.user.csproj create mode 100644 src/testengine.module.mda.tests/SelectControlTests.cs create mode 100644 src/testengine.module.mda/SelectControl.cs create mode 100644 src/testengine.module.playwrightaction.tests/PlaywrightActionFunctionTests.cs create mode 100644 src/testengine.module.playwrightaction.tests/PlaywrightActionFunctionValueTests.cs create mode 100644 src/testengine.module.playwrightaction.tests/PlaywrightActionModuleTests.cs create mode 100644 src/testengine.module.playwrightaction.tests/Usings.cs create mode 100644 src/testengine.module.playwrightaction.tests/testengine.module.playwrightaction.tests.csproj create mode 100644 src/testengine.module.playwrightaction/PlaywrightActionFunction.cs create mode 100644 src/testengine.module.playwrightaction/PlaywrightActionModule.cs create mode 100644 src/testengine.module.playwrightaction/PlaywrightActionValueFunction.cs create mode 100644 src/testengine.module.playwrightaction/testengine.module.playwrightaction.csproj create mode 100644 src/testengine.module.playwrightscript.tests/PlaywrightScriptsFunctionTests.cs create mode 100644 src/testengine.module.playwrightscript.tests/PlaywrightScriptsModuleTests.cs create mode 100644 src/testengine.module.playwrightscript.tests/Usings.cs create mode 100644 src/testengine.module.playwrightscript.tests/testengine.module.playwrightscript.tests.csproj create mode 100644 src/testengine.module.playwrightscript/PlaywrightScriptFunction.cs create mode 100644 src/testengine.module.playwrightscript/PlaywrightScriptModule.cs create mode 100644 src/testengine.module.playwrightscript/testengine.module.playwrightscript.csproj create mode 100644 src/testengine.module.powerapps.portal.tests/CheckConnectionExistsFunctionTests.cs create mode 100644 src/testengine.module.powerapps.portal.tests/ConnectionHelperTests.cs create mode 100644 src/testengine.module.powerapps.portal.tests/CreateConnectionFunctionTests.cs create mode 100644 src/testengine.module.powerapps.portal.tests/ExportConnectionsFunctionTest.cs create mode 100644 src/testengine.module.powerapps.portal.tests/GetConnectionsFunctionTests.cs create mode 100644 src/testengine.module.powerapps.portal.tests/PowerAppsPortalModuleTests.cs create mode 100644 src/testengine.module.powerapps.portal.tests/SelectSectionFunctionTests.cs create mode 100644 src/testengine.module.powerapps.portal.tests/Usings.cs create mode 100644 src/testengine.module.powerapps.portal.tests/testengine.module.powerapps.portal.tests.csproj create mode 100644 src/testengine.module.powerapps.portal/CheckConnectionExistsFunction.cs create mode 100644 src/testengine.module.powerapps.portal/Connection.cs create mode 100644 src/testengine.module.powerapps.portal/ConnectionHelper.cs create mode 100644 src/testengine.module.powerapps.portal/CreateConnectionFunction.cs create mode 100644 src/testengine.module.powerapps.portal/ExportConnectionsFunction.cs create mode 100644 src/testengine.module.powerapps.portal/GetConnectionsFunction.cs create mode 100644 src/testengine.module.powerapps.portal/PowerAppsPortalConnections.js create mode 100644 src/testengine.module.powerapps.portal/PowerAppsPortalModule.cs create mode 100644 src/testengine.module.powerapps.portal/SelectSection.cs create mode 100644 src/testengine.module.powerapps.portal/UpdateConnectionReferencesFunction.cs create mode 100644 src/testengine.module.powerapps.portal/testengine.module.powerapps.portal.csproj create mode 100644 src/testengine.module.simulation/SimulateConnectorFunction.cs create mode 100644 src/testengine.module.simulation/SimulateDataverseFunction.cs create mode 100644 src/testengine.module.simulation/SimulationModule.cs create mode 100644 src/testengine.module.simulation/testengine.module.simulation.csproj create mode 100644 src/testengine.module.tests.common/MockLoggerHelper.cs create mode 100644 src/testengine.module.tests.common/testengine.module.tests.common.csproj create mode 100644 src/testengine.modules.simulation.tests/SimulateConnectorFunctionTests.cs create mode 100644 src/testengine.modules.simulation.tests/SimulateDataverseFunctionTests.cs create mode 100644 src/testengine.modules.simulation.tests/testengine.modules.simulation.tests.csproj create mode 100644 src/testengine.provider.mda.tests/ModelDrivenApplicationCanvasStateTests.cs rename src/testengine.provider.mda.tests/{ModelDrivenApplicationProvideEntityListTest.cs => ModelDrivenApplicationProviderEntityListTest.cs} (100%) create mode 100644 src/testengine.provider.mda.tests/data-collection.json create mode 100644 src/testengine.provider.mda.tests/data-collection2.json create mode 100644 src/testengine.provider.mda.tests/data-empty.json create mode 100644 src/testengine.provider.mda.tests/data-sample.json create mode 100644 src/testengine.provider.mda.tests/data-variable-int.json create mode 100644 src/testengine.provider.mda.tests/data-variable-int2.json create mode 100644 src/testengine.provider.mda/ModelDrivenApplicationCanvasState.cs create mode 100644 src/testengine.provider.mda/VariableStateValue.cs create mode 100644 src/testengine.provider.powerapps.portal.tests/PowerAppPortalProviderTest.cs create mode 100644 src/testengine.provider.powerapps.portal.tests/Usings.cs create mode 100644 src/testengine.provider.powerapps.portal.tests/testengine.provider.powerapps,portal.tests.csproj create mode 100644 src/testengine.provider.powerapps.portal/PowerAppPortalProvider.cs create mode 100644 src/testengine.provider.powerapps.portal/PowerAppsPortal.js create mode 100644 src/testengine.provider.powerapps.portal/testengine.provider.powerapps.portal.csproj create mode 100644 src/testengine.user.storagestate.tests/StorageStateUserManagerModuleTests.cs create mode 100644 src/testengine.user.storagestate.tests/testengine.user.storagestate.tests.csproj create mode 100644 src/testengine.user.storagestate/StorageStateUserManagerModule.cs create mode 100644 src/testengine.user.storagestate/testengine.user.storagestate.csproj diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 8e09e65b3..bfbaa14fe 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -21,7 +21,7 @@ jobs: runs-on: windows-latest strategy: matrix: - dotnet-version: ['6.0.x'] + dotnet-version: ['8.0.x'] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml index 4aa6a6a99..b6b817769 100644 --- a/.github/workflows/dotnet-format.yml +++ b/.github/workflows/dotnet-format.yml @@ -17,7 +17,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: '6.0.x' + dotnet-version: '8.0.x' - name: Install dotnet-format tool run: dotnet tool install -g dotnet-format diff --git a/README.md b/README.md index b1419722b..21750e9d4 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ To get started, you will need to clone the Test Engine code from GitHub, locally ### Prerequisites for building Test Engine -1. Install [.NET Core 6.0.x SDK](https://dotnet.microsoft.com/en-us/download/dotnet/6.0) -1. Ensure that your `MSBuildSDKsPath` environment variable is pointing to [.NET Core 6.0.x SDK](https://dotnet.microsoft.com/en-us/download/dotnet/6.0). +1. Install [.NET Core 8.0.x SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) +1. Ensure that your `MSBuildSDKsPath` environment variable is pointing to [.NET Core 8.0.x SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0). 1. Make sure [PowerShell](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell?view=powershell-7.2) is installed. ### Build locally diff --git a/azure-pipelines.yml b/azure-pipelines.yml index e72753835..c7c8a4d22 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -27,9 +27,9 @@ jobs: steps: - task: UseDotNet@2 - displayName: 'Use dotnet sdk 6.0' + displayName: 'Use dotnet sdk 8.0' inputs: - version: 6.0.x + version: 8.0.x installationPath: '$(Agent.ToolsDirectory)/dotnet' - task: CodeQL3000Init@0 diff --git a/build-pipelines/scripts/yaml-integration-tests.sh b/build-pipelines/scripts/yaml-integration-tests.sh index 828ff9823..66f8cf694 100644 --- a/build-pipelines/scripts/yaml-integration-tests.sh +++ b/build-pipelines/scripts/yaml-integration-tests.sh @@ -44,7 +44,7 @@ do fi done if [[ -n "${envId}" && -n "${tenantId}" && -n "${domain}" && -n "${testPlanFile}" && -n "${outputDir}" ]]; then # null checks on args - dotnet run -f net6.0 -- -e ${envId} -t ${tenantId} -d ${domain} -i ${testPlanFile} -o ${outputDir} -q "&PAOverrideFGRollout.OnePlayerStandaloneWebPlayer=false"; - dotnet run -f net6.0 -- -e ${envId} -t ${tenantId} -d ${domain} -i ${testPlanFile} -o ${outputDir} -q "&PAOverrideFGRollout.OnePlayerStandaloneWebPlayer=true"; + dotnet run -f net8.0 -- -e ${envId} -t ${tenantId} -d ${domain} -i ${testPlanFile} -o ${outputDir} -q "&PAOverrideFGRollout.OnePlayerStandaloneWebPlayer=false"; + dotnet run -f net8.0 -- -e ${envId} -t ${tenantId} -d ${domain} -i ${testPlanFile} -o ${outputDir} -q "&PAOverrideFGRollout.OnePlayerStandaloneWebPlayer=true"; fi done \ No newline at end of file diff --git a/docs/Extensions/PowerAppsPortal.md b/docs/Extensions/PowerAppsPortal.md new file mode 100644 index 000000000..f60cad9c0 --- /dev/null +++ b/docs/Extensions/PowerAppsPortal.md @@ -0,0 +1,3 @@ +# Power Apps Portal Provider + +The -p "powerapps.portal" provider allow automation of the Power Apps portal using Test Engine. \ No newline at end of file diff --git a/docs/PowerFX/Pause.md b/docs/PowerFX/Pause.md index 599aff545..9d9e97970 100644 --- a/docs/PowerFX/Pause.md +++ b/docs/PowerFX/Pause.md @@ -1,9 +1,13 @@ # Pause -`Pause()` +`Experimental.Pause()` -This will open the interactive Playwright Inspector and wait for the user to resume execution. +This will open the interactive [Playwright Inspector](https://playwright.dev/dotnet/docs/debug#playwright-inspector) and wait for the user to resume execution. + +## Using with Simulation commands + +Using the playwright inspector you can access the [Browser Developer Tools](https://playwright.dev/dotnet/docs/debug#browser-developer-tools) to monitor network traffic between the page and the services ## Example -`Pause()` +`Experimental.Pause()` diff --git a/docs/PowerFX/README.md b/docs/PowerFX/README.md index 76d6edc21..700e37f08 100644 --- a/docs/PowerFX/README.md +++ b/docs/PowerFX/README.md @@ -4,11 +4,21 @@ There are several specifically defined functions for the test framework. - [Assert](./Assert.md) - [Screenshot](./Screenshot.md) -- [Pause](./Pause.md) - [Select](./Select.md) - [SetProperty](./SetProperty.md) - [Wait](./Wait.md) +- [Experimental.Pause](./Pause.md) +- [Experimental.PlaywrightAction](./PlaywrightAction.md) +- [Experimental.PlaywrightScript](./PlaywrightAction.md) + +## Experimental Functions + +The following functions will be enabled in Debug build and when Experimental is enabled as a Namespace + +- [Experimental.SimulateConnector](./SimulateConnector.md) +- [Experimental.SimulateDataverse](./SimulateDataverse.md) + ## Naming When creating additional functions using [modules](../modules.md) for Power Fx in the Test Engine, it's important to follow naming standards to ensure consistency and readability. @@ -23,6 +33,14 @@ Here are some guidelines for naming your functions in Power Fx: By following these naming standards, your Power Fx code will be easier to read and maintain, and other developers will be able to understand your code more easily. +### Use Namespaces + +Namespaces should be used for Power Fx functions in the Power Apps Test Engine for several reasons. First, using namespaces ensures that there is no clash with built-in functions, which can cause confusion and errors. By using namespaces, Power Fx functions can be organized and grouped together in a clear and concise manner. + +Additionally, namespaces make it clear that these Power Fx functions belong to the Test Engine, and are not part of the larger Power Apps ecosystem. This helps to avoid confusion and ensures that the functions are used appropriately within the context of the Test Engine. + +Overall, using namespaces for Power Fx functions in the Power Apps Test Engine is a best practice that helps to ensure clarity, organization, and consistency in the testing process. + ### Using Descriptive Names Using descriptive names is important because it makes it easier for others (and yourself) to understand what the function or service does. A good name should be concise but also convey the function's or service's purpose. For example, instead of naming a function "Calculate," you could name it "CalculateTotalCost" to make it clear what the function is doing. diff --git a/docs/PowerFX/SimulateConnector.md b/docs/PowerFX/SimulateConnector.md new file mode 100644 index 000000000..872fb6eb0 --- /dev/null +++ b/docs/PowerFX/SimulateConnector.md @@ -0,0 +1,41 @@ +# Simulate Connection + +The Experimental.SimluateConnection function allows you to simulate requests to Power Platform connector and provide responses without actually making live requests. This is particularly useful for testing and development purposes, as it enables you to create predictable and controlled responses for various scenarios. + +```powerfx +Experimental.SimulateConnection({Name: "connectorname", Action: "actionname", Parameters: {}, Filter: "optionalfilter", Then: {Value: Table()}}) +``` + +## Parameters + +| Name | Description | +|------|-------------| +| Name | The name of the connector from thr url of the [connector list](https://learn.microsoft.com/connectors/connector-reference/connector-reference-powerapps-connectors). For example the name of the [Office 365 Users](https://learn.microsoft.com/en-us/connectors/office365users/) is **office365users** +| Action | The part of the url request that will match against the action +| Parameters | A Power Fx Record that will be mapped to Query parameters required to me matched +| Filter | A Power Fx expression that needs to be matched | + +## Recording Sample Values + +To obtain values for the `Experimental.SimulateConnection()` function you can use the network trace of the Browser Developer Tools when using [Experimental.Pause()](./Pause.md) where you can filter traffic by searching for **/invoke** + +## Examples + +1. Query user using Power 365 Users connector + +```powerfx +Experimental.SimulateConnection({Name: "office365users", Action: "/v1.0/me", Then: { + displayName: "Sample User", + "id": "c12345678-1111-2222-3333-44445555666", + "jobTitle": null, + "mail": "sample@contoso.onmicrosoft.com", + "userPrincipalName": "sample@contoso.onmicrosoft.com", + "userType": "Member" +}}) +``` + +2. Query groups using Power 365 groups connector + +```powerfx +Experimental.SimulateConnection({Name: "office365groups", Filter: "name = 'allcompany@contoso.onmicrosoft.com'", Then: Table()}) +``` diff --git a/docs/PowerFX/SimulateDataverse.md b/docs/PowerFX/SimulateDataverse.md new file mode 100644 index 000000000..02a2f80a8 --- /dev/null +++ b/docs/PowerFX/SimulateDataverse.md @@ -0,0 +1,46 @@ +# Simulate Dataverse + +The Experimental.SimulateDataverse function allows you to simulate responses from the Dataverse without actually querying the live data. This is particularly useful for testing and development purposes, as it enables you to create predictable and controlled responses for various scenarios. + +```powerfx +Experimental.SimulateDatarse({ Action: "query", Entity: "TableName", When: { Field: "value" }, Then: Table({Name: "Test"}) }) +``` + +| Name | Description | +|------|-------------| +| Action | The dataverse action to simulate from Query, Create, Update, Delete +| Entity | The name pluralized entity name from [metadata](https://learn.microsoft.com/power-apps/developer/data-platform/webapi/web-api-service-documents) +| When | The optional query string to apply +| Filter | A Power Fx expression that needs to be matched. This will automatically be mapped to odata $filter command | +| When | The Power Fx table to return in the odata value response that will be returned to the Power App + +## Recording Sample Values + +To obtain values for the `Experimental.SimulateDataverse()` function you can use the network trace of the Browser Developer Tools when using [Experimental.Pause()](./Pause.md) where you can filter traffic by searching for **/api/data/v** + +## Example + +1. Simulate a Query Response with Sample Data + +When the Power App queries all accounts, respond with sample data: + +```powerfx +Experimental.SimulateDataverse({Action:"query",Entity: "accounts", Then: Table({accountid: "a1234567-1111-2222-3333-44445555666", name: "Test"}) }); +``` + +2. Simulate a Query with Specific Conditions + +When make request with account with query name of Other return no results + +```powerfx +Experimental.SimulateDataverse({Action:"query",Entity: "accounts", When: {Name: "Other"}, Then: Table()}); +``` + +## Why This Function is Useful +The `Experimental.SimulateDataverse()` function is useful because it allows developers and makers to: + +1. **Test and Debug**: Simulate different scenarios and responses without affecting live data, making it easier to test and debug applications. +1. **Predictable Results**: Create controlled and predictable responses, which is essential for automated testing and ensuring consistent behavior. +1. **Development Efficiency**: Speed up the development process by allowing developers to work with simulated data instead of waiting for actual data to be available. + +By using this function, you can ensure that your Power Apps behave as expected in various scenarios, leading to more robust and reliable applications. \ No newline at end of file diff --git a/docs/PowerFX/TestEngine.PlaywrightAction.md b/docs/PowerFX/TestEngine.PlaywrightAction.md new file mode 100644 index 000000000..5a8c9a7fd --- /dev/null +++ b/docs/PowerFX/TestEngine.PlaywrightAction.md @@ -0,0 +1,36 @@ +# Experimental.PlaywrightAction + +` Experimental.PlaywrightAction(Locator, Action)` + +` Experimental.PlaywrightAction(Url, Action)` + +This use the locators or Url to apply an action to the current web page. + +## Locators + +When selecting actions that require a locator you can make use of [CSS Selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors) or XPath queries. + +Locators for web pages are based on Playwright locators. More information on locators is available from [Playwright documentation](https://playwright.dev/docs/other-locators). + +Playwright also supports experimental React and vue base selectors that can be useful for selecting elements on code first extensions like PCF controls within a Power App. + +## Actions + +The following actions are supported + +| Action | Description | +|----------|----------------------------------------| +| click | Select matching locator items | +| exists | Returns True or False is locator exist | +| navigate | Navigate to the url | +| wait | Wait for locator items to exist | + +## Examples + +` Experimental.PlaywrightAction("//button", "click")` + +` Assert(Experimental.PlaywrightAction("//button", "exists") = true)` + +` Experimental.PlaywrightAction("https://www.microsoft.com", "navigate")` + +` Experimental.PlaywrightAction("//button", "wait")` diff --git a/docs/PowerFX/TestEngine.PlaywrightScript.md b/docs/PowerFX/TestEngine.PlaywrightScript.md new file mode 100644 index 000000000..6ce26c191 --- /dev/null +++ b/docs/PowerFX/TestEngine.PlaywrightScript.md @@ -0,0 +1,44 @@ +# TestEngine.PlaywrightScript + +`TestEngine.PlaywrightScript(csxFileName)` + +The PlaywrightScript function provides a "no cliffs" extensibility for Test Engine providing the ability to execute CSharp Scripts (*.csx) files inside a Test Engine web provider based test that uses Playwright as web page test framework. + +You can use the playwright inspector to record C# commands to build the C# script + +## C# Script + +This action takes advantage of [dotnet-script](https://github.com/dotnet-script/dotnet-script) and the underlying [Rosyln](https://github.com/dotnet/roslyn) compiler to allow projectless scripting of Playwright code. The Action assumes the following: + +1. Any required .Net Assemblies are globally available or in the current folder and can be loaded using #r compiler directive +2. A public class named **PlaywrightScript** MUST exist +3. A method with **public static void Run(IBrowserContext context, ILogger logger)** MUST exist + +## Sample Test + +A sample [testPlan.fx.yaml](../../samples/playwrightscript/testPlan.fx.yaml) and [sample.csx](../../samples/playwrightscript/sample.csx) provide a demonstration of how this action can be integrated into a Test Engine test. + +## Example + +` TestEngine.PlaywrightScript("sample.csx") + +Where sample could use template to include Playwright + +```csharp +#r "Microsoft.Playwright.dll" +#r "Microsoft.Extensions.Logging.dll" +using Microsoft.Playwright; +using Microsoft.Extensions.Logging; +using System.Linq; +using System.Threading.Tasks; + +public class PlaywrightScript { + public static void Run(IBrowserContext context, ILogger logger) { + Execute(context, logger).Wait(); + } + + public static async Task Execute(IBrowserContext context, ILogger logger) { + // Insert your code here + } +} +``` diff --git a/samples/.gitignore b/samples/.gitignore new file mode 100644 index 000000000..0630a88c6 --- /dev/null +++ b/samples/.gitignore @@ -0,0 +1 @@ +**\config.json \ No newline at end of file diff --git a/samples/basicgallery/README.md b/samples/basicgallery/README.md new file mode 100644 index 000000000..f0438874b --- /dev/null +++ b/samples/basicgallery/README.md @@ -0,0 +1,24 @@ +# Overview + +This Power Apps Test Engine sample demonstrates how to basic gallery of a canvas application + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` diff --git a/samples/basicgallery/RunTests.ps1 b/samples/basicgallery/RunTests.ps1 new file mode 100644 index 000000000..85c2b7203 --- /dev/null +++ b/samples/basicgallery/RunTests.ps1 @@ -0,0 +1,31 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlanForRegionUseSemicolonAsSeparator.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/basicgallery/testPlan.fx.yaml b/samples/basicgallery/testPlan.fx.yaml index 3d57e03eb..ef42d3bd8 100644 --- a/samples/basicgallery/testPlan.fx.yaml +++ b/samples/basicgallery/testPlan.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Basic Gallery testSuiteDescription: Verifies that you can interact with controls within a basic gallery @@ -35,4 +36,4 @@ environmentVariables: users: - personaName: User1 emailKey: user1Email - passwordKey: user1Password + passwordKey: NotNeeded diff --git a/samples/basicgallery/testPlanForRegionUseSemicolonAsSeparator.fx.yaml b/samples/basicgallery/testPlanForRegionUseSemicolonAsSeparator.fx.yaml index 6c303ec50..175ba9591 100644 --- a/samples/basicgallery/testPlanForRegionUseSemicolonAsSeparator.fx.yaml +++ b/samples/basicgallery/testPlanForRegionUseSemicolonAsSeparator.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Basic Gallery testSuiteDescription: Verifies that you can interact with controls within a basic gallery @@ -32,4 +33,4 @@ environmentVariables: users: - personaName: User1 emailKey: user1Email - passwordKey: user1Password + passwordKey: NotNeeded diff --git a/samples/buttonclicker/README.md b/samples/buttonclicker/README.md index 0520958c9..d737c3e4e 100644 --- a/samples/buttonclicker/README.md +++ b/samples/buttonclicker/README.md @@ -4,17 +4,21 @@ This Power Apps Test Engine sample demonstrates how to clicking button of a canv ## Usage -1. Build the Test Engine solution +2. Get the Environment Id and Tenant of the environment that the solution has been imported into -2. Get the Environment Id and Tenant of the environment that the samplication solution has been imported into +3. Create config.json file using tenant, environment and user1Email -3. Execute the test for custom page changing the example below to the url of your organization using browser persistant cookies +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test ```pwsh -cd bin\Debug\PowerAppsEngine -dotnet PowerAppsTestEngine.dll -i ..\..\..\samples\buttonclicker\testPlan.fx.yaml -e 00000000-0000-0000-0000-11112223333 -t 11112222-3333-4444-5555-666677778888 -u browser -p canvas +.\RunTests.ps1 ``` - -NOTES: -- If the BrowserCache folder does not exist with valid Persistent Session cookies an interactive login will be required -- After an interactive login has been completed a headless test session can be run \ No newline at end of file diff --git a/samples/buttonclicker/RunTests.ps1 b/samples/buttonclicker/RunTests.ps1 new file mode 100644 index 000000000..323615553 --- /dev/null +++ b/samples/buttonclicker/RunTests.ps1 @@ -0,0 +1,30 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/buttonclicker/testPlan.cba.fx.yaml b/samples/buttonclicker/testPlan.cba.fx.yaml index 11e0d8791..b6cccbcdd 100644 --- a/samples/buttonclicker/testPlan.cba.fx.yaml +++ b/samples/buttonclicker/testPlan.cba.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Button Clicker testSuiteDescription: Verifies that counter increments when the button is clicked diff --git a/samples/buttonclicker/testPlan.fx.yaml b/samples/buttonclicker/testPlan.fx.yaml index 7ea1460a2..84b0409ae 100644 --- a/samples/buttonclicker/testPlan.fx.yaml +++ b/samples/buttonclicker/testPlan.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Button Clicker testSuiteDescription: Verifies that counter increments when the button is clicked diff --git a/samples/calculator/README.md b/samples/calculator/README.md index 176c43e82..c9595207a 100644 --- a/samples/calculator/README.md +++ b/samples/calculator/README.md @@ -1,4 +1,29 @@ -# Different Variants of the Calculator Sample +# Overview + +This Power Apps Test Engine sample demonstrates how to basic gallery of a canvas application + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` + +## Different Variants of the Calculator Sample The Calculator sample has two variants - one for `en-US` and another for locales that use commas `","` and periods `"."` differently. See below for usage - diff --git a/samples/calculator/RunTests.ps1 b/samples/calculator/RunTests.ps1 new file mode 100644 index 000000000..6368076b9 --- /dev/null +++ b/samples/calculator/RunTests.ps1 @@ -0,0 +1,31 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlanWithCommaForDecimal.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/calculator/testPlan.fx.yaml b/samples/calculator/testPlan.fx.yaml index 2e566bb84..ad103daca 100644 --- a/samples/calculator/testPlan.fx.yaml +++ b/samples/calculator/testPlan.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Calculator testSuiteDescription: Verifies that the calculator app works. The calculator is a component. diff --git a/samples/calculator/testPlanWithCommaForDecimal.fx.yaml b/samples/calculator/testPlanWithCommaForDecimal.fx.yaml index b5558377a..0fd7d4c10 100644 --- a/samples/calculator/testPlanWithCommaForDecimal.fx.yaml +++ b/samples/calculator/testPlanWithCommaForDecimal.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Calculator testSuiteDescription: Verifies that the calculator app works. The calculator is a component. diff --git a/samples/coe-kit-setup-wizard/.gitignore b/samples/coe-kit-setup-wizard/.gitignore new file mode 100644 index 000000000..d0535d6fe --- /dev/null +++ b/samples/coe-kit-setup-wizard/.gitignore @@ -0,0 +1,2 @@ +config.json +config.**.json \ No newline at end of file diff --git a/samples/coe-kit-setup-wizard/GetAppId.powerfx b/samples/coe-kit-setup-wizard/GetAppId.powerfx new file mode 100644 index 000000000..6328fdb32 --- /dev/null +++ b/samples/coe-kit-setup-wizard/GetAppId.powerfx @@ -0,0 +1 @@ +Filter('Model-driven Apps', 'Unique Name' = "admin_CoESetupWizardPreview") \ No newline at end of file diff --git a/samples/coe-kit-setup-wizard/README.md b/samples/coe-kit-setup-wizard/README.md index e265fe094..16b018d60 100644 --- a/samples/coe-kit-setup-wizard/README.md +++ b/samples/coe-kit-setup-wizard/README.md @@ -1,18 +1,109 @@ # Overview -The Power Platform Center of Excellence (CoE) starter kit is made up of a number of Power Platform low code solution elements. Amoung these is a model driven application that can be used to setup and upgrade the CoE Starter Kit. +The Power Platform Center of Excellence (CoE) starter kit is made up of a number of Power Platform low code solution elements. Among these is a model driven application that can be used to setup and upgrade the CoE Starter Kit. -This sample includes Power Apps Test Engine tests that can be used to automate and test ket elemenets of the expected behaviour of the Setup and Upgrade Wizard +This sample includes Power Apps Test Engine tests that can be used to automate and test ket elements of the expected behavior of the Setup and Upgrade Wizard + +## What You Need + +Before you start, you'll need a few tools and permissions: +- **Power Platform Command Line Interface (CLI)**: This is a tool that lets you interact with Power Platform from your command line. +- **PowerShell**: A task automation tool from Microsoft. +- **.Net 8.0 SDK**: A software development kit needed to build and run the tests. +- **Power Platform Environment**: A space where your Power Apps live. +- **Admin or Customizer Rights**: Permissions to make changes in your Power Platform environment. + +## Prerequisites + +1. Install of .Net SDK 8.0 from [Downloads](https://dotnet.microsoft.com/download/dotnet/8.0) +2. An install of PowerShell following the [Install Overview](https://learn.microsoft.com/powershell/scripting/install/installing-powershell) for your operating system +3. The Power Platform Command Line interface installed using the [Learn install guidance](https://learn.microsoft.com/power-platform/developer/cli/introduction?tabs=windows#install-microsoft-power-platform-cli) +4. A created Power Platform environment using the [Power Platform Admin Center](https://learn.microsoft.com/power-platform/admin/create-environment) or [Power Platform Command Line](https://learn.microsoft.com/power-platform/developer/cli/reference/admin#pac-admin-create) +5. Granted System Administrator or System Customizer roles as documented in [Microsoft Learn](https://learn.microsoft.compower-apps/maker/model-driven-apps/privileges-required-customization#system-administrator-and-system-customizer-security-roles) +6. Git Client has been installed. For example using [GitHub Desktop](https://desktop.github.com/download/) or the [Git application](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +7. The CoE Starter Kit core module has been installed into the environment ## Getting Started -To get started ensure that you have followed the [Build locally](../../README.md) to have a working version of the Power Apps Test Engine available +1. Clone the repository using the git application and PowerShell command line + +```pwsh +git clone https://github.com/microsoft/PowerApps-TestEngine.git +``` + +2. Change to cloned folder + +```pwsh +cd PowerApps-TestEngine +``` + +3. Checkout the integration branch + +```pwsh +git checkout grant-archibald-ms/data-record-386 +``` -## Usage +3. Ensure logged out out of pac cli. This ensures you're logged out of any previous sessions. + +```pwsh +pac auth clear +``` -You can execute this sample using the following commands +4. Login to Power Platform CLI using [pac auth](https://learn.microsoft.com/power-platform/developer/cli/reference/auth#pac-auth-create) -```bash -cd bin/Debug/PowerAppsTestEngine -dotnet PowerAppsTestEngine.dll -i ../../../samples/coe-kit-setup-wizard/testPlan.fx.yaml -u browser -p mda -d https://contoso.crm.dynamics.com/main.aspx?appid=06f88e88-163e-ef11-840a-0022481fcc8d&pagetype=custom&name=admin_initialsetuppage_d45cf +```pwsh +pac auth create --environment ``` + +5. Add the config.json in the same folder as RunTests.ps1 replacing the value with your tenant and environment id + +```json +{ + "tenantId": "a222222-1111-2222-3333-444455556666", + "environmentId": "12345678-1111-2222-3333-444455556666", + "customPage": "admin_initialsetuppage_d45cf", + "user1Email": "test@contoso.onmicrosoft.com", + "runInstall": true, + "installPlaywright": true +} +``` + +## Run Test + +To Run the sample tests from PowerShell assuming the Getting started steps have been completed + +```pwsh +.\RunTests.ps1 +``` + +## Record and Replay + +To record interaction with Dataverse and generate a sample Test Engine script perform the following steps assuming the Getting started steps have been completed + +1. Start record process + +```pwsh +.\Record.ps1 +``` + +2. If required login to the Power App + +3. Wait for the Playwright Inspector to be displayed + +4. Interact with the Setup and Upgrade Wizard + +5. When ready to complete the record session press play in the Playwright Inspector + +6. Open the generated **recorded.te.yaml** that includes data from recorded Dataverse and Connector calls. + +## What to Expect + +- **Login Prompt**: You'll be asked to log in to the Power Apps Portal. +- **Test Execution**: The Test Engine will run the steps to test your Power Apps Portal. +- **Cached Credentials**: If you choose "Stay Signed In," future tests will use your saved credentials. +- **Interactive Testing**: Commands like `Experimental.Pause()` will let you pause and inspect the test steps. +- **Recorded Sessions**: Test Engine provides the ability to generate recorded video of the test session in the TestOutput folder. + +## Context + +This sample is an example of a "build from source" using the open source licensed version of Test Engine. Features in the the source code version can include feature not yet release as part of the ```pac test run`` command in the Power Platform Command line interface action. diff --git a/samples/coe-kit-setup-wizard/Record.ps1 b/samples/coe-kit-setup-wizard/Record.ps1 new file mode 100644 index 000000000..84638e6fc --- /dev/null +++ b/samples/coe-kit-setup-wizard/Record.ps1 @@ -0,0 +1,73 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$jsonContent = Get-Content -Path .\config.json -Raw +$config = $jsonContent | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +$foundEnvironment = $false +$textResult = (pac env select --environment $environmentId) +$textResult = (pac env list) + +$environmentUrl = "" + +Write-Host "Searching for $environmentId" + +foreach ($line in $textResult) { + if ($line -match $environmentId) { + if ($line -match "(https://\S+/)") { + $environmentUrl = $matches[0].Substring(0,$matches[0].Length - 1) + $foundEnvironment = $true + break + } + } +} + +if ($foundEnvironment) { + Write-Output "Found matching Environment URL: $environmentUrl" +} else { + Write-Output "Environment ID not found." + return +} + +$appId = "" +try{ + $runResult = pac pfx run --file .\GetAppId.powerfx --echo + $appId = $runResult[8].Split('"')[1] -replace '[^a-zA-Z0-9-]', '' +} catch { + +} + +if ([string]::IsNullOrEmpty($appId)) { + Write-Error "App id not found. Check that the CoE Starter kit has been installed" + return +} + +$customPage = $config.customPage + +$mdaUrl = "$environmentUrl/main.aspx?appid=$appId&pagetype=custom&name=$customPage" + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +# Run the tests for each user in the configuration file. +$env:user1Email = $user1Email +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "mda" -a "none" -r True -i "$currentDirectory\record.fx.yaml" -t $tenantId -e $environmentId -d "$mdaUrl" -w True + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/coe-kit-setup-wizard/RecordCanvas.ps1 b/samples/coe-kit-setup-wizard/RecordCanvas.ps1 new file mode 100644 index 000000000..625ee5bf6 --- /dev/null +++ b/samples/coe-kit-setup-wizard/RecordCanvas.ps1 @@ -0,0 +1,25 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +# Run the tests for each user in the configuration file. +$env:$user1Email = $user1Email +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -r True -i "$currentDirectory\recordCanvas.fx.yaml" -t $tenantId -e $environmentId -l Trace -w True + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/coe-kit-setup-wizard/RunTests.ps1 b/samples/coe-kit-setup-wizard/RunTests.ps1 new file mode 100644 index 000000000..085455541 --- /dev/null +++ b/samples/coe-kit-setup-wizard/RunTests.ps1 @@ -0,0 +1,72 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$jsonContent = Get-Content -Path .\config.json -Raw +$config = $jsonContent | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +$foundEnvironment = $false +$textResult = (pac env select --environment $environmentId) +$textResult = (pac env list) + +$environmentUrl = "" + +Write-Host "Searching for $environmentId" + +foreach ($line in $textResult) { + if ($line -match $environmentId) { + if ($line -match "(https://\S+/)") { + $environmentUrl = $matches[0].Substring(0,$matches[0].Length - 1) + $foundEnvironment = $true + break + } + } +} + +if ($foundEnvironment) { + Write-Output "Found matching Environment URL: $environmentUrl" +} else { + Write-Output "Environment ID not found." + return +} + +$appId = "" +try{ + $runResult = pac pfx run --file .\GetAppId.powerfx --echo + $appId = $runResult[8].Split('"')[1] -replace '[^a-zA-Z0-9-]', '' +} catch { + +} + +if ([string]::IsNullOrEmpty($appId)) { + Write-Error "App id not found. Check that the CoE Starter kit has been installed" + return +} + +$customPage = $config.customPage +$mdaUrl = "$environmentUrl/main.aspx?appid=$appId&pagetype=custom&name=$customPage" + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "mda" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId -d "$mdaUrl" -l Debug + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/coe-kit-setup-wizard/record.fx.yaml b/samples/coe-kit-setup-wizard/record.fx.yaml new file mode 100644 index 000000000..cb1bb63f3 --- /dev/null +++ b/samples/coe-kit-setup-wizard/record.fx.yaml @@ -0,0 +1,27 @@ +testSuite: + testSuiteName: CoE Starter Kit Setup Wizard Record + testSuiteDescription: Provide the ability to record actions + persona: User1 + appLogicalName: NotNeeded + + testCases: + - testCaseName: Before Connector + testCaseDescription: Acions to add before the + testSteps: | + = Assert(1=1) + +testSettings: + headless: false + locale: "en-US" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + timeout: 480000 + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded diff --git a/samples/coe-kit-setup-wizard/recordCanvas.fx.yaml b/samples/coe-kit-setup-wizard/recordCanvas.fx.yaml new file mode 100644 index 000000000..acd83f710 --- /dev/null +++ b/samples/coe-kit-setup-wizard/recordCanvas.fx.yaml @@ -0,0 +1,28 @@ +# yaml-embedded-languages: powerfx +testSuite: + testSuiteName: CoE Starter Canvas App + testSuiteDescription: Provide the ability to record actions + persona: User1 + appLogicalName: cr998_app_f2001 + + testCases: + - testCaseName: Failure case + testCaseDescription: User not licenced + testSteps: | + = Assert(ErrorDialogTitle="Start a Power Apps trial?") + +testSettings: + headless: false + locale: "en-US" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + timeout: 10000 + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded diff --git a/samples/coe-kit-setup-wizard/testPlan.fx.yaml b/samples/coe-kit-setup-wizard/testPlan.fx.yaml index 18802b258..c20e428e0 100644 --- a/samples/coe-kit-setup-wizard/testPlan.fx.yaml +++ b/samples/coe-kit-setup-wizard/testPlan.fx.yaml @@ -5,11 +5,23 @@ testSuite: appLogicalName: NotNeeded testCases: - - testCaseName: Verify - testCaseDescription: Verify setup and upgrade Wizard of the CoE Starter Kit + - testCaseName: Step 1 - Confirm Pre-requisites + testCaseDescription: Verify pre-requistes in place testSteps: | - = TestEngine.ConsentDialog(Table({Text: "Center of Excellence Setup Wizard"})); - + = + Experimental.ConsentDialog(Table({Text: "Center of Excellence Setup Wizard"})); + Experimental.Pause(); + Set(configStep, 1); + Assert(configStep=1); + Select(btnNext); + - testCaseName: Step 2 - Configure communication methods + testCaseDescription: Verify communication methods setup + testSteps: | + = + Assert(configStep=2); + Assert(CountRows(colCommunicate)=3); + Experimental.SelectControl(Button3,1); + Experimental.Pause(); testSettings: headless: false locale: "en-US" @@ -18,9 +30,10 @@ testSettings: enable: true browserConfigurations: - browser: Chromium + timeout: 480000 environmentVariables: users: - personaName: User1 - emailKey: NotNeeded + emailKey: user1Email passwordKey: NotNeeded diff --git a/samples/connector/README.md b/samples/connector/README.md new file mode 100644 index 000000000..d43d59fd9 --- /dev/null +++ b/samples/connector/README.md @@ -0,0 +1,24 @@ +# Overview + +This Power Apps Test Engine sample demonstrates how mock a custom connector of a canvas application + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` diff --git a/samples/connector/RunTests.ps1 b/samples/connector/RunTests.ps1 new file mode 100644 index 000000000..66594a106 --- /dev/null +++ b/samples/connector/RunTests.ps1 @@ -0,0 +1,34 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine + +Copy-Item -Path "$currentDirectory\response.json" -Destination "." -Force + +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan-simulated.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/connector/testPlan-simulated.fx.yaml b/samples/connector/testPlan-simulated.fx.yaml new file mode 100644 index 000000000..a722da204 --- /dev/null +++ b/samples/connector/testPlan-simulated.fx.yaml @@ -0,0 +1,28 @@ +# yaml-embedded-languages: powerfx +testSuite: + testSuiteName: Connector App + testSuiteDescription: Verifies that you can mock network requests + persona: User1 + appLogicalName: new_connectorapp_da583 + onTestCaseStart: | + = Experimental.SimulateConnector({name: "msnweather", then: {responses: { daily: { day: { summary: "You are seeing the mock response" }}}}}) + testCases: + - testCaseName: Fill in a city name and do the search + testSteps: | + = Screenshot("connectorapp_loaded.png"); + SetProperty(TextInput1.Text, "Atlanta"); + Select(Button1); + Assert(Label4.Text = "You are seeing the mock response", "Validate the output is from the mock"); + Screenshot("connectorapp_end.png"); + +testSettings: + locale: "en-US" + recordVideo: true + browserConfigurations: + - browser: Chromium + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: user1Password diff --git a/samples/connector/testPlan.fx.yaml b/samples/connector/testPlan.fx.yaml index d74ac825f..6c10a8d7f 100644 --- a/samples/connector/testPlan.fx.yaml +++ b/samples/connector/testPlan.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Connector App testSuiteDescription: Verifies that you can mock network requests @@ -8,7 +9,7 @@ testSuite: method: POST headers: x-ms-request-method: GET - responseDataFile: ../../samples/connector/response.json + responseDataFile: response.json testCases: - testCaseName: Fill in a city name and do the search diff --git a/samples/containers/README.md b/samples/containers/README.md new file mode 100644 index 000000000..566daca0e --- /dev/null +++ b/samples/containers/README.md @@ -0,0 +1,24 @@ +# Overview + +This Power Apps Test Engine sample demonstrates how interact with containers of canvas application + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` diff --git a/samples/containers/RunTests.ps1 b/samples/containers/RunTests.ps1 new file mode 100644 index 000000000..323615553 --- /dev/null +++ b/samples/containers/RunTests.ps1 @@ -0,0 +1,30 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/containers/testPlan.fx.yaml b/samples/containers/testPlan.fx.yaml index 499db2d77..d9b305ac9 100644 --- a/samples/containers/testPlan.fx.yaml +++ b/samples/containers/testPlan.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Container testSuiteDescription: Verifies that you can interact with control in the container diff --git a/samples/differentvariabletypes/README.md b/samples/differentvariabletypes/README.md index 4c7e978bb..aee764a7d 100644 --- a/samples/differentvariabletypes/README.md +++ b/samples/differentvariabletypes/README.md @@ -1,4 +1,29 @@ -# Date_Case and DateTime_Case +# Overview + +This Power Apps Test Engine sample demonstrates how interact with containers of canvas application + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` + +## Date_Case and DateTime_Case Please note that the DatePicker control shows date according to your system timezone. diff --git a/samples/differentvariabletypes/RunTests.ps1 b/samples/differentvariabletypes/RunTests.ps1 new file mode 100644 index 000000000..323615553 --- /dev/null +++ b/samples/differentvariabletypes/RunTests.ps1 @@ -0,0 +1,30 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/differentvariabletypes/testPlan.fx.yaml b/samples/differentvariabletypes/testPlan.fx.yaml index ef15de982..4e5740e49 100644 --- a/samples/differentvariabletypes/testPlan.fx.yaml +++ b/samples/differentvariabletypes/testPlan.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: DifferentVariableTypes testSuiteDescription: Showcases usage of Assert/Wait/SetProperty with multiple types diff --git a/samples/differentvariabletypes/testPlanAppIdPreprod.fx.yaml b/samples/differentvariabletypes/testPlanAppIdPreprod.fx.yaml index b7a6b7e05..6f6ddedaf 100644 --- a/samples/differentvariabletypes/testPlanAppIdPreprod.fx.yaml +++ b/samples/differentvariabletypes/testPlanAppIdPreprod.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: DifferentVariableTypes-AppId testSuiteDescription: Showcases usage of Assert/Wait/SetProperty with multiple types, using AppId instead of AppLogicalName diff --git a/samples/differentvariabletypes/testPlanAppIdPreview.fx.yaml b/samples/differentvariabletypes/testPlanAppIdPreview.fx.yaml index 2e32c5576..0d2980151 100644 --- a/samples/differentvariabletypes/testPlanAppIdPreview.fx.yaml +++ b/samples/differentvariabletypes/testPlanAppIdPreview.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: DifferentVariableTypes-AppId testSuiteDescription: Showcases usage of Assert/Wait/SetProperty with multiple types, using AppId instead of AppLogicalName diff --git a/samples/differentvariabletypes/testPlanAppIdTest.fx.yaml b/samples/differentvariabletypes/testPlanAppIdTest.fx.yaml index 7b21a3d48..51a4b3fa4 100644 --- a/samples/differentvariabletypes/testPlanAppIdTest.fx.yaml +++ b/samples/differentvariabletypes/testPlanAppIdTest.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: DifferentVariableTypes-AppId testSuiteDescription: Showcases usage of Assert/Wait/SetProperty with multiple types, using AppId instead of AppLogicalName diff --git a/samples/differentvariabletypes/testPlanForScriptInjection.fx.yaml b/samples/differentvariabletypes/testPlanForScriptInjection.fx.yaml index 2ab5feef3..b83b09b45 100644 --- a/samples/differentvariabletypes/testPlanForScriptInjection.fx.yaml +++ b/samples/differentvariabletypes/testPlanForScriptInjection.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: ScriptInjectionTestingOnDifferentVariableTypes testSuiteDescription: Testing script injection for SetProperty with multiple types diff --git a/samples/extensions/README.md b/samples/extensions/README.md new file mode 100644 index 000000000..7c05b121f --- /dev/null +++ b/samples/extensions/README.md @@ -0,0 +1,24 @@ +# Overview + +This Power Apps Test Engine sample demonstrates Power Fx extensions + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` diff --git a/samples/extensions/RunTests.ps1 b/samples/extensions/RunTests.ps1 new file mode 100644 index 000000000..65efea5e4 --- /dev/null +++ b/samples/extensions/RunTests.ps1 @@ -0,0 +1,33 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan-denyCommand.fx.yaml" -t $tenantId -e $environmentId +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan-denyModule.fx.yaml" -t $tenantId -e $environmentId +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan-enableOnlyWriteLine.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/extensions/testPlan-denyCommand.fx.yaml b/samples/extensions/testPlan-denyCommand.fx.yaml index 2e3df4a12..79019d9ed 100644 --- a/samples/extensions/testPlan-denyCommand.fx.yaml +++ b/samples/extensions/testPlan-denyCommand.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Extension example testSuiteDescription: Demonstrate the use of PowerFx extension @@ -8,7 +9,7 @@ testSuite: - testCaseName: Run Sample testCaseDescription: Test case will fail as the Sample command uses System.Console testSteps: | - = Sample(); + = Experimental.Sample(); testSettings: locale: "en-US" diff --git a/samples/extensions/testPlan-denyModule.fx.yaml b/samples/extensions/testPlan-denyModule.fx.yaml index ba767becf..09fd22625 100644 --- a/samples/extensions/testPlan-denyModule.fx.yaml +++ b/samples/extensions/testPlan-denyModule.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Extension example testSuiteDescription: Demonstrate the use of PowerFx extension @@ -8,7 +9,7 @@ testSuite: - testCaseName: Run Sample testCaseDescription: Test case will fail as the Sample Power Fx function is not loaded testSteps: | - = Sample(); + = Experimental.Sample(); testSettings: locale: "en-US" diff --git a/samples/extensions/testPlan-enableOnlyWriteLine.fx.yaml b/samples/extensions/testPlan-enableOnlyWriteLine.fx.yaml index 514137bfb..20ce3c80a 100644 --- a/samples/extensions/testPlan-enableOnlyWriteLine.fx.yaml +++ b/samples/extensions/testPlan-enableOnlyWriteLine.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Extension example testSuiteDescription: Demonstrate the use of PowerFx extension @@ -8,7 +9,7 @@ testSuite: - testCaseName: Run Sample testCaseDescription: Test case will pass as WriteLine method has been allowed testSteps: | - = Sample(); + = Experimental.Sample(); testSettings: locale: "en-US" diff --git a/samples/extensions/testPlan.fx.yaml b/samples/extensions/testPlan.fx.yaml index 66389fb5c..4dd38e6ea 100644 --- a/samples/extensions/testPlan.fx.yaml +++ b/samples/extensions/testPlan.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Extension example testSuiteDescription: Demonstrate the use of PowerFx extension @@ -8,7 +9,7 @@ testSuite: - testCaseName: Run Sample testCaseDescription: Run the Sample Command testSteps: | - = Sample(); + = Experimental.Sample(); testSettings: locale: "en-US" diff --git a/samples/manyscreens/README.md b/samples/manyscreens/README.md new file mode 100644 index 000000000..9dd64dcaa --- /dev/null +++ b/samples/manyscreens/README.md @@ -0,0 +1,24 @@ +# Overview + +Verifies that you can interact with controls on other screens + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` diff --git a/samples/manyscreens/RunTests.ps1 b/samples/manyscreens/RunTests.ps1 new file mode 100644 index 000000000..323615553 --- /dev/null +++ b/samples/manyscreens/RunTests.ps1 @@ -0,0 +1,30 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/manyscreens/testPlan.fx.yaml b/samples/manyscreens/testPlan.fx.yaml index 916db5f1e..1dbe44dfd 100644 --- a/samples/manyscreens/testPlan.fx.yaml +++ b/samples/manyscreens/testPlan.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: ManyScreens testSuiteDescription: Verifies that you can interact with controls on other screens diff --git a/samples/mda/RunTests.ps1 b/samples/mda/RunTests.ps1 new file mode 100644 index 000000000..52e3d66ec --- /dev/null +++ b/samples/mda/RunTests.ps1 @@ -0,0 +1,58 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = (Get-Content -Path .\config.json -Raw) | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + + +$foundEnvironment = $false +$textResult = (pac env select --environment $environmentId) +$textResult = (pac env list) + +$environmentUrl = "" + +Write-Host "Searching for $environmentId" + +foreach ($line in $textResult) { + if ($line -match $environmentId) { + if ($line -match "(https://\S+/)") { + $environmentUrl = $matches[0].Substring(0,$matches[0].Length - 1) + $foundEnvironment = $true + break + } + } +} + +if ($foundEnvironment) { + Write-Output "Found matching Environment URL: $environmentUrl" +} else { + Write-Output "Environment ID not found." + return +} + +$mdaUrl = "$environmentUrl/main.aspx?appname=sample_AccountAdmin&pagetype=custom&name=sample_custom_cf8e6" + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "mda" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId -d "$mdaUrl" + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/mda/testPlan.fx.yaml b/samples/mda/testPlan.fx.yaml index 897ff0f76..91398ccd5 100644 --- a/samples/mda/testPlan.fx.yaml +++ b/samples/mda/testPlan.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: MDA Custom Page tests testSuiteDescription: Verify model driven application @@ -24,5 +25,5 @@ testSettings: environmentVariables: users: - personaName: User1 - emailKey: NotNeeded + emailKey: user1Email passwordKey: NotNeeded diff --git a/samples/modules/README.md b/samples/modules/README.md new file mode 100644 index 000000000..7c05b121f --- /dev/null +++ b/samples/modules/README.md @@ -0,0 +1,24 @@ +# Overview + +This Power Apps Test Engine sample demonstrates Power Fx extensions + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` diff --git a/samples/modules/RunTests.ps1 b/samples/modules/RunTests.ps1 new file mode 100644 index 000000000..323615553 --- /dev/null +++ b/samples/modules/RunTests.ps1 @@ -0,0 +1,30 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/modules/testPlan.fx.yaml b/samples/modules/testPlan.fx.yaml index 529ea81d8..ec7c2fc03 100644 --- a/samples/modules/testPlan.fx.yaml +++ b/samples/modules/testPlan.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Button Clicker testSuiteDescription: Verifies that counter increments when the button is clicked @@ -11,15 +12,22 @@ testSuite: - testCaseName: Case1 testCaseDescription: Run sample action testSteps: | - = Sample(); + = Experimental.Sample(); testSettings: - locale: "en-US" headless: false + locale: "en-US" recordVideo: true - enableExtensionModules: true + extensionModules: + enable: true browserConfigurations: - - browser: Firefox + - browser: Chromium + channel: msedge + timeout: 600000 environmentVariables: - filePath: ../../samples/environmentVariables.yaml + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded + diff --git a/samples/nestedgallery/README.md b/samples/nestedgallery/README.md new file mode 100644 index 000000000..e4cec12d7 --- /dev/null +++ b/samples/nestedgallery/README.md @@ -0,0 +1,24 @@ +# Overview + +Verifies that you can interact with controls within a nested gallery + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` diff --git a/samples/nestedgallery/RunTests.ps1 b/samples/nestedgallery/RunTests.ps1 new file mode 100644 index 000000000..323615553 --- /dev/null +++ b/samples/nestedgallery/RunTests.ps1 @@ -0,0 +1,30 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/nestedgallery/testPlan.fx.yaml b/samples/nestedgallery/testPlan.fx.yaml index 85944bc45..9f48be904 100644 --- a/samples/nestedgallery/testPlan.fx.yaml +++ b/samples/nestedgallery/testPlan.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Nested Gallery testSuiteDescription: Verifies that you can interact with controls within a nested gallery diff --git a/samples/pause/README.md b/samples/pause/README.md new file mode 100644 index 000000000..5f0a4c80c --- /dev/null +++ b/samples/pause/README.md @@ -0,0 +1,24 @@ +# Overview + +Pause the browser and open the Playwright Inspector inside the Power App + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` diff --git a/samples/pause/RunTests.ps1 b/samples/pause/RunTests.ps1 new file mode 100644 index 000000000..323615553 --- /dev/null +++ b/samples/pause/RunTests.ps1 @@ -0,0 +1,30 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/pause/testPlan.fx.yaml b/samples/pause/testPlan.fx.yaml index 2fe3d5a53..55948a47b 100644 --- a/samples/pause/testPlan.fx.yaml +++ b/samples/pause/testPlan.fx.yaml @@ -1,6 +1,7 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: Pause tests - testSuiteDescription: Pause the browser and open the Playwright Inspector inside the Power App using browser authentication method + testSuiteDescription: Pause the browser and open the Playwright Inspector inside the Power App persona: User1 appLogicalName: new_buttonclicker_0a877 onTestSuiteComplete: Screenshot("pause_onTestSuiteComplete.png"); @@ -9,7 +10,7 @@ testSuite: - testCaseName: Pause testCaseDescription: Pause example testSteps: | - = Pause(); + = Experimental.Pause(); testSettings: headless: false @@ -23,5 +24,5 @@ testSettings: environmentVariables: users: - personaName: User1 - emailKey: NotNeeded + emailKey: user1Email passwordKey: NotNeeded diff --git a/samples/pcfcomponent/README.md b/samples/pcfcomponent/README.md index 224dc80f4..fe9a4a13d 100644 --- a/samples/pcfcomponent/README.md +++ b/samples/pcfcomponent/README.md @@ -1,5 +1,31 @@ -# Import PCF Component -## Steps for Import PCF Component +# Overview + +Pause the browser and open the Playwright Inspector inside the Power App + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` + +## Import PCF Component + +### Steps for Import PCF Component 1.Set up the config file [more detail](https://github.com/microsoft/PowerApps-TestEngine#import-a-sample-solution). diff --git a/samples/pcfcomponent/RunTests.ps1 b/samples/pcfcomponent/RunTests.ps1 new file mode 100644 index 000000000..323615553 --- /dev/null +++ b/samples/pcfcomponent/RunTests.ps1 @@ -0,0 +1,30 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/pcfcomponent/testPlan.fx.yaml b/samples/pcfcomponent/testPlan.fx.yaml index 61ffd605c..1299a437e 100644 --- a/samples/pcfcomponent/testPlan.fx.yaml +++ b/samples/pcfcomponent/testPlan.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: PCF Component testSuiteDescription: Verifies that you can interact with increment control of the PCF Component diff --git a/samples/permissions/.gitignore b/samples/permissions/.gitignore new file mode 100644 index 000000000..0cffcb348 --- /dev/null +++ b/samples/permissions/.gitignore @@ -0,0 +1 @@ +config.json \ No newline at end of file diff --git a/samples/permissions/Permissions_1_0_0_1.zip b/samples/permissions/Permissions_1_0_0_1.zip new file mode 100644 index 0000000000000000000000000000000000000000..358d177553a9d16ec37eb923ed57879056646932 GIT binary patch literal 59411 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

DI%2Q5mQEOJ1}9ptU@r{nxmE$StdcJ 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 Kt`Gll^uGXx#@hJ+ literal 0 HcmV?d00001 diff --git a/samples/permissions/README.md b/samples/permissions/README.md new file mode 100644 index 000000000..dcc7f6a12 --- /dev/null +++ b/samples/permissions/README.md @@ -0,0 +1,92 @@ +# Overview + +This sample is deigned to test the behavior of Canvas app and Model Driven App (MDA) entity list and custom page. The following table provides some assumptions and example Authentication methods that you can use to validate permissions. + +| Persona | Description | Authentication Method | +|---------|-------------|-----------------------| +| user1 | Assume the permissions Power App canvas app has been shared with user but no Power App license assigned | [Microsoft Authenticator](https://learn.microsoft.com/entra/identity/authentication/concept-authentication-authenticator-app) +| user2 | Assume that user account has not been shared user persona and no Power App license assigned is assigned | [Temporary Access Pass](https://learn.microsoft.com/entra/identity/authentication/howto-authentication-temporary-access-pass) + +## What You Need + +Before you start, you'll need a few tools and permissions: +- **Power Platform Command Line Interface (CLI)**: This is a tool that lets you interact with Power Platform from your command line. +- **PowerShell**: A task automation tool from Microsoft. +- **.Net 8.0 SDK**: A software development kit needed to build and run the tests. +- **Power Platform Environment**: A space where your Power Apps live. +- **Admin or Customizer Rights**: Permissions to make changes in your Power Platform environment. + +## Prerequisites + +1. Install of .Net SDK 8.0 from [Downloads](https://dotnet.microsoft.com/download/dotnet/8.0) +2. An install of PowerShell following the [Install Overview](https://learn.microsoft.com/powershell/scripting/install/installing-powershel) for your operating system +3. The Power Platform Command Line interface installed using the [Learn install guidance](https://learn.microsoft.com/power-platform/developer/cli/introduction?tabs=windows#install-microsoft-power-platform-cli) +4. A created Power Platform environment using the [Power Platform Admin Center](https://learn.microsoft.com/power-platform/admin/create-environment) or [Power Platform Command Line](https://learn.microsoft.com/power-platform/developer/cli/reference/admin#pac-admin-create) +5. Granted System Administrator or System Customizer roles as documented in [Microsoft Learn](https://learn.microsoft.compower-apps/maker/model-driven-apps/privileges-required-customization#system-administrator-and-system-customizer-security-roles) +6. Git Client has been installed. For example using [GitHub Desktop](https://desktop.github.com/download/) or the [Git application](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +7. The CoE Starter Kit core module has been installed into the environment + +## Getting Started + +1. Clone the repository using the git application and PowerShell command line + +```pwsh +git clone https://github.com/microsoft/PowerApps-TestEngine.git +``` + +2. Change to cloned folder + +```pwsh +cd PowerApps-TestEngine +``` + +3. Checkout the branch that user authentication providers are enabled. For example from feature branch + +```pwsh +git checkout grant-archibald-ms/storage-state-389 +``` + +3. Import the solution Permissions*.zip into the environment you want to test with + +4. Ensure logged out out of pac cli. This ensures you're logged out of any previous sessions. + +```pwsh +pac auth clear +``` + +5. Login to Power Platform CLI using [pac auth](https://learn.microsoft.com/power-platform/developer/cli/reference/auth#pac-auth-create) + +```pwsh +pac auth create --environment +``` + +5. Add the config.json in the same folder as RunTests.ps1 replacing the value with your tenant and environment id + +```json +{ + "tenantId": "a1234567-1111-2222-3333-4444555566666", + "environmentId": "c0000001-2222-3333-5555-12345678", + "canvasAppName": "contoso_canvas_4033c", + "customPageName": "contoso_custom_b2441", + "mdaName": "contoso_MDA", + "runInstall": true, + "installPlaywright": true, + "userEmail1": "alans@contoso.onmicrosoft.com", + "userEmail2": "aliciat@contoso.onmicrosoft.com" +} +``` + +## Run Test + +To Run the sample tests from PowerShell assuming the Getting started steps have been completed + +```pwsh +.\RunTests.ps1 +``` + +## What to Expect + +- **Login Prompt**: You'll be asked to log in to the Power Apps Portal for the first time +- **Test Execution**: The Test Engine will run the steps to test your Power Apps Portal, MDA and Canvas apps. +- **Cached Credentials**: If you choose "Stay Signed In," future tests will use your saved credentials. +- **Expired Credentials**: If your temporary access password has expired the test will fail. For example you could use the Entra portal to delete a Temporary Access Pass and observe that the test case should fail for persona `userEmail2`. diff --git a/samples/permissions/RunTests.ps1 b/samples/permissions/RunTests.ps1 new file mode 100644 index 000000000..c8a75522d --- /dev/null +++ b/samples/permissions/RunTests.ps1 @@ -0,0 +1,78 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$jsonContent = Get-Content -Path .\config.json -Raw +$config = $jsonContent | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$mdaName = $config.mdaName + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +$textResult = [string] (pac env list) + +$foundEnvironment = $false +$textResult = [string] (pac env select --environment $environmentId) + +try{ + $textResult -match "'(https://[^\s']+)'" + $environmentMatch = $matches + $foundEnvironment = $true +} catch { + +} + +# Extract the URL using a general regular expression +if ($foundEnvironment -and $environmentMatch.Count -ge 1) { + $environmentUrl = $environmentMatch[1].TrimEnd("/") +} else { + Write-Output "URL not found. Please create authentication and re-run script" + pac auth create --environment $environmentId + return +} + +$customPage = $config.customPage + +$mdaUrlList = "$environmentUrl/main.aspx?appname$mdaName&pagetype=entitylist&etn=account" +$mdaUrlCustom = "$environmentUrl/main.aspx?appname$mdaName&pagetype=custom&name=$customPage" + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +# Run the tests for each user in the configuration file. + +Write-Host "======================================================" +Write-Host "User 1 Persona Tests" +Write-Host "======================================================" + +$env:user1Email=$config.userEmail1 + +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "powerapps.portal" -a "none" -i "$currentDirectory\user1-power-apps-portal.te.yaml" -t $tenantId -e $environmentId -l Debug + +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\canvas-no-powerapps-licence.te.yaml" -t $tenantId -e $environmentId -l Debug + +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "mda" -a "none" -i "$currentDirectory\entity-list-no-permissions.te.yaml" -t $tenantId -e $environmentId -d "$mdaUrlList" -l Debug +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "mda" -a "none" -i "$currentDirectory\custom-page-no-permissions.te.yaml" -t $tenantId -e $environmentId -d "$mdaUrlCustom" -l Debug + +Write-Host "======================================================" +Write-Host "User 2 Persona Tests" +Write-Host "======================================================" + +$env:user2Email=$config.userEmail2 +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "powerapps.portal" -a "none" -i "$currentDirectory\user2-power-apps-portal.te.yaml" -t $tenantId -e $environmentId -l Debug + +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\canvas-not-shared.te.yaml" -t $tenantId -e $environmentId -l Debug + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/permissions/canvas-no-powerapps-licence.te.yaml b/samples/permissions/canvas-no-powerapps-licence.te.yaml new file mode 100644 index 000000000..07124a45d --- /dev/null +++ b/samples/permissions/canvas-no-powerapps-licence.te.yaml @@ -0,0 +1,27 @@ +testSuite: + testSuiteName: Permissions + testSuiteDescription: Power Platform tests + persona: User1 + appLogicalName: contoso_canvas_4033c + + testCases: + - testCaseName: No Power Apps License assligned + testCaseDescription: Behaviour when no Power Apps license assigned. Assumes app is shared + testSteps: | + = Assert(ErrorDialogTitle="Start a Power Apps trial?") + +testSettings: + headless: false + locale: "en-US" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + timeout: 10000 + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded diff --git a/samples/permissions/canvas-not-shared.te.yaml b/samples/permissions/canvas-not-shared.te.yaml new file mode 100644 index 000000000..14b10a427 --- /dev/null +++ b/samples/permissions/canvas-not-shared.te.yaml @@ -0,0 +1,27 @@ +testSuite: + testSuiteName: Permissions + testSuiteDescription: Power Platform tests + persona: User2 + appLogicalName: contoso_canvas_4033c + + testCases: + - testCaseName: App not shared + testCaseDescription: Power App not shared with user + testSteps: | + = Assert(ErrorDialogTitle="Request access") + +testSettings: + headless: false + locale: "en-US" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + timeout: 10000 + +environmentVariables: + users: + - personaName: User2 + emailKey: user2Email + passwordKey: NotNeeded diff --git a/samples/permissions/custom-page-no-permissions.te.yaml b/samples/permissions/custom-page-no-permissions.te.yaml new file mode 100644 index 000000000..9a3c45177 --- /dev/null +++ b/samples/permissions/custom-page-no-permissions.te.yaml @@ -0,0 +1,27 @@ +testSuite: + testSuiteName: Permissions + testSuiteDescription: Power Platform tests + persona: User1 + appLogicalName: contoso_canvas_4033c + + testCases: + - testCaseName: Custom Page no permissions assign + testCaseDescription: Error when no permissions assigned and try access custom page + testSteps: | + = Assert(IsMatch(ErrorDialogTitle , "An error has occured")) + +testSettings: + headless: false + locale: "en-US" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + timeout: 10000 + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded diff --git a/samples/permissions/entity-list-no-permissions.te.yaml b/samples/permissions/entity-list-no-permissions.te.yaml new file mode 100644 index 000000000..6dc936e4b --- /dev/null +++ b/samples/permissions/entity-list-no-permissions.te.yaml @@ -0,0 +1,27 @@ +testSuite: + testSuiteName: Permissions + testSuiteDescription: Power Platform tests + persona: User1 + appLogicalName: NoNeeded + + testCases: + - testCaseName: Entity list no permissions + testCaseDescription: Error when no permissions assigned and try access entity list + testSteps: | + = Assert(IsMatch(ErrorDialogTitle , "An error has occured")) + +testSettings: + headless: false + locale: "en-US" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + timeout: 10000 + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded diff --git a/samples/permissions/user1-power-apps-portal.te.yaml b/samples/permissions/user1-power-apps-portal.te.yaml new file mode 100644 index 000000000..b6f8130df --- /dev/null +++ b/samples/permissions/user1-power-apps-portal.te.yaml @@ -0,0 +1,27 @@ +testSuite: + testSuiteName: Permissions + testSuiteDescription: Power Platform tests + persona: User1 + appLogicalName: NoNeeded + + testCases: + - testCaseName: Power Apps Portal + testCaseDescription: Can start port apps portal with valid MFA credentials + testSteps: | + = Experimental.SelectSection("home") + +testSettings: + headless: false + locale: "en-US" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + timeout: 10000 + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded diff --git a/samples/permissions/user2-power-apps-portal.te.yaml b/samples/permissions/user2-power-apps-portal.te.yaml new file mode 100644 index 000000000..09e4993f6 --- /dev/null +++ b/samples/permissions/user2-power-apps-portal.te.yaml @@ -0,0 +1,27 @@ +testSuite: + testSuiteName: Permissions + testSuiteDescription: Power Platform tests + persona: User2 + appLogicalName: NoNeeded + + testCases: + - testCaseName: Power Apps Portal + testCaseDescription: Can start port apps portal with user who exists in the environment and with valid MFA credentials + testSteps: | + = Experimental.SelectSection("home") + +testSettings: + headless: false + locale: "en-US" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + timeout: 10000 + +environmentVariables: + users: + - personaName: User2 + emailKey: user2Email + passwordKey: NotNeeded diff --git a/samples/playwrightaction/README.md b/samples/playwrightaction/README.md new file mode 100644 index 000000000..7105295a4 --- /dev/null +++ b/samples/playwrightaction/README.md @@ -0,0 +1,24 @@ +# Overview + +Tests ability to interact with page using Playwright locators by waiting for button to be visible on page + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` diff --git a/samples/playwrightaction/RunTests.ps1 b/samples/playwrightaction/RunTests.ps1 new file mode 100644 index 000000000..323615553 --- /dev/null +++ b/samples/playwrightaction/RunTests.ps1 @@ -0,0 +1,30 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/playwrightaction/testPlan.fx.yaml b/samples/playwrightaction/testPlan.fx.yaml new file mode 100644 index 000000000..d2837da3c --- /dev/null +++ b/samples/playwrightaction/testPlan.fx.yaml @@ -0,0 +1,28 @@ +# yaml-embedded-languages: powerfx +testSuite: + testSuiteName: testPlan Template + testSuiteDescription: Playwright action example + persona: User1 + appLogicalName: new_buttonclicker_0a877 + onTestSuiteComplete: Screenshot("playwrightaction_onTestSuiteComplete.png"); + + testCases: + - testCaseName: Run Script + testCaseDescription: Action examples + testSteps: | + = Experimental.PlaywrightAction("//button", "wait"); + +testSettings: + headless: false + locale: "en-US" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: user1Password diff --git a/samples/playwrightscript/README.md b/samples/playwrightscript/README.md new file mode 100644 index 000000000..0ee93100e --- /dev/null +++ b/samples/playwrightscript/README.md @@ -0,0 +1,28 @@ +# Overview + +Tests ability to interact with page using Playwright IPage and "no cliffs" extensibility model of a C# script file + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` + +## Notes + +This sample assumes that you have the buttonclicker sample application in the target environment diff --git a/samples/playwrightscript/RunTests.ps1 b/samples/playwrightscript/RunTests.ps1 new file mode 100644 index 000000000..323615553 --- /dev/null +++ b/samples/playwrightscript/RunTests.ps1 @@ -0,0 +1,30 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "canvas" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/playwrightscript/sample.csx b/samples/playwrightscript/sample.csx new file mode 100644 index 000000000..073d5c80b --- /dev/null +++ b/samples/playwrightscript/sample.csx @@ -0,0 +1,28 @@ +#r "Microsoft.Playwright.dll" +#r "Microsoft.Extensions.Logging.dll" +using Microsoft.Playwright; +using Microsoft.Extensions.Logging; +using System.Linq; +using System.Threading.Tasks; + +public class PlaywrightScript { + public static void Run(IBrowserContext context, ILogger logger) { + Execute(context, logger).Wait(); + } + + public static async Task Execute(IBrowserContext context, ILogger logger) { + var page = context.Pages.First(); + + if ( page.Url == "about:blank" ) { + var nextPage = context.Pages.Skip(1).First(); + await page.CloseAsync(); + page = nextPage; + } + + foreach ( var frame in page.Frames ) { + if ( await frame.Locator("button:has-text('Button')").CountAsync() > 0 ) { + await frame.ClickAsync("button:has-text('Button')"); + } + } + } +} \ No newline at end of file diff --git a/samples/playwrightscript/testPlan.fx.yaml b/samples/playwrightscript/testPlan.fx.yaml new file mode 100644 index 000000000..30934d52f --- /dev/null +++ b/samples/playwrightscript/testPlan.fx.yaml @@ -0,0 +1,30 @@ +# yaml-embedded-languages: powerfx +testSuite: + testSuiteName: testPlan Template + testSuiteDescription: Playwright csx example + persona: User1 + appLogicalName: new_buttonclicker_0a877 + onTestSuiteComplete: Screenshot("playwrightaction_onTestSuiteComplete.png"); + + testCases: + - testCaseName: Run Script + testCaseDescription: CSX example + testSteps: | + = Experimental.Pause(); + Experimental.PlaywrightScript("sample.csx"); + Experimental.Pause(); + +testSettings: + headless: false + locale: "en-US" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: user1Password diff --git a/samples/portal/README.md b/samples/portal/README.md new file mode 100644 index 000000000..8f00259d2 --- /dev/null +++ b/samples/portal/README.md @@ -0,0 +1,24 @@ +# Overview + +Interact with the Power Apps Portal + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` \ No newline at end of file diff --git a/samples/portal/RunTests.ps1 b/samples/portal/RunTests.ps1 new file mode 100644 index 000000000..d4eb828b2 --- /dev/null +++ b/samples/portal/RunTests.ps1 @@ -0,0 +1,30 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "powerapps.portal" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/portal/testPlan.connectionreference.fx.yaml b/samples/portal/testPlan.connectionreference.fx.yaml new file mode 100644 index 000000000..53325c1d5 --- /dev/null +++ b/samples/portal/testPlan.connectionreference.fx.yaml @@ -0,0 +1,27 @@ +# yaml-embedded-languages: powerfx +testSuite: + testSuiteName: Power Apps Portal Test + testSuiteDescription: Interact with the Power Apps Portal + persona: User1 + appLogicalName: NA + + testCases: + - testCaseName: Update Connection References + testCaseDescription: Connect created connections with connection references + testSteps: | + = Experimental.UpdateConnectionReferences(); + +testSettings: + headless: false + locale: "en-US" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + +environmentVariables: + users: + - personaName: User1 + emailKey: NotNeeded + passwordKey: NotNeeded diff --git a/samples/portal/testPlan.fx.yaml b/samples/portal/testPlan.fx.yaml new file mode 100644 index 000000000..94b8d7a56 --- /dev/null +++ b/samples/portal/testPlan.fx.yaml @@ -0,0 +1,50 @@ +# yaml-embedded-languages: powerfx +testSuite: + testSuiteName: Power Apps Portal Test + testSuiteDescription: Interact with the Power Apps Portal + persona: User1 + appLogicalName: NA + + testCases: + - testCaseName: Create connections + testCaseDescription: Create connections in environment required for CoE Starter Kit + testSteps: | + = Experimental.CreateConnection( + Table( + {Name: "shared_approvals"}, + {Name: "shared_arm", Interactive: true}, + {Name: "shared_commondataserviceforapps", Interactive: true}, + {Name: "shared_dataflows", Interactive: true}, + {Name: "shared_flowmanagement", Interactive: true}, + {Name: "shared_microsoftflowforadmins", Interactive: true}, + {Name: "shared_office365", Interactive: true}, + {Name: "shared_office365groups", Interactive: true}, + {Name: "shared_office365users", Interactive: true}, + {Name: "shared_powerappsforadmins", Interactive: true}, + {Name: "shared_powerappsforappmakers", Interactive: true}, + {Name: "shared_powerplatformforadmins", Interactive: true}, + {Name: "shared_powerplatformadminv2", Interactive: true}, + {Name: "shared_rss"}, + {Name: "shared_teams", Interactive: true}, + {Name: "shared_webcontents", Interactive: true, Parameters: "{'Base Resource URL': 'https://graph.microsoft.com', 'Microsoft Entra ID Resource URI (Application ID URI)':'https://graph.microsoft.com'}"} + ) + ); + - testCaseName: Export connections + testCaseDescription: Export the connections to json file + testSteps: | + = Experimental.ExportConnections("connections.json") + +testSettings: + headless: false + locale: "en-US" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded diff --git a/samples/simulation/README.md b/samples/simulation/README.md new file mode 100644 index 000000000..eab9b42d2 --- /dev/null +++ b/samples/simulation/README.md @@ -0,0 +1,24 @@ +# Overview + +Simulate Connector + +## Usage + +2. Get the Environment Id and Tenant of the environment that the solution has been imported into + +3. Create config.json file using tenant, environment and user1Email + +```json +{ + "environmentId": "a0000000-1111-2222-3333-444455556666", + "tenantId": "ccccdddd-1111-2222-3333-444455556666", + "installPlaywright": false, + "user1Email": "test@contoso.onmicosoft.com" +} +``` + +4. Execute the test + +```pwsh +.\RunTests.ps1 +``` \ No newline at end of file diff --git a/samples/simulation/RunTests.ps1 b/samples/simulation/RunTests.ps1 new file mode 100644 index 000000000..76b4667e3 --- /dev/null +++ b/samples/simulation/RunTests.ps1 @@ -0,0 +1,31 @@ +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$config = Get-Content -Path .\config.json -Raw | ConvertFrom-Json +$tenantId = $config.tenantId +$environmentId = $config.environmentId +$user1Email = $config.user1Email + +if ([string]::IsNullOrEmpty($environmentId)) { + Write-Error "Environment not configured. Please update config.json" + return +} + +# Build the latest debug version of Test Engine from source +Set-Location ..\..\src +dotnet build + +if ($config.installPlaywright) { + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait +} else { + Write-Host "Skipped playwright install" +} + +Set-Location ..\bin\Debug\PowerAppsTestEngine +$env:user1Email = $user1Email +# Run the tests for each user in the configuration file. +$env:user1Email = $user1Email +dotnet PowerAppsTestEngine.dll -u "storagestate" -p "powerapps.portal" -a "none" -i "$currentDirectory\testPlan.fx.yaml" -t $tenantId -e $environmentId -w True + +# Reset the location back to the original directory. +Set-Location $currentDirectory \ No newline at end of file diff --git a/samples/simulation/testPlan.fx.yaml b/samples/simulation/testPlan.fx.yaml new file mode 100644 index 000000000..899cb421d --- /dev/null +++ b/samples/simulation/testPlan.fx.yaml @@ -0,0 +1,31 @@ +# yaml-embedded-languages: powerfx +testSuite: + testSuiteName: Power Apps Canvas Datavesre Simulation + testSuiteDescription: Validate SimulationDataverse() + persona: User1 + appLogicalName: contoso_canvasdata_23901 + onTestCaseStart: | + = Experimental.SimulateDataverse({Action:"query",Entity: "accounts", Then: Table({accountid: "a1234567-1111-2222-3333-44445555666", name: "Test"}) }); + + testCases: + - testCaseName: Simulate account + testCaseDescription: Test 1 + testSteps: | + = Experimental.Pause() + +testSettings: + headless: false + locale: "en-US" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + channel: msedge + timeout: 240000 + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded diff --git a/samples/template/TestPlanTemplate.fx.yaml b/samples/template/TestPlanTemplate.fx.yaml index 3d16c9d7e..b1189c831 100644 --- a/samples/template/TestPlanTemplate.fx.yaml +++ b/samples/template/TestPlanTemplate.fx.yaml @@ -1,3 +1,4 @@ +# yaml-embedded-languages: powerfx testSuite: testSuiteName: testPlan Template testSuiteDescription: testPlan template for written own test steps diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/Config/TestStateTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/Config/TestStateTests.cs index 1b22723b1..521e3a2cb 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/Config/TestStateTests.cs +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Config/TestStateTests.cs @@ -580,15 +580,6 @@ public void SetEnvironmentThrowsOnNullInput(string? environment) Assert.Throws(() => state.SetEnvironment(environment)); } - [Theory] - [InlineData("")] - [InlineData(null)] - public void SetDomainThrowsOnNullInput(string? domain) - { - var state = new TestState(MockTestConfigParser.Object); - Assert.Throws(() => state.SetDomain(domain)); - } - [Theory] [InlineData("")] [InlineData(null)] diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/Microsoft.PowerApps.TestEngine.Tests.csproj b/src/Microsoft.PowerApps.TestEngine.Tests/Microsoft.PowerApps.TestEngine.Tests.csproj index b3b7b87ec..2096effd9 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/Microsoft.PowerApps.TestEngine.Tests.csproj +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Microsoft.PowerApps.TestEngine.Tests.csproj @@ -1,23 +1,31 @@  - net6.0 + net8.0 enable false True + + + true - ../../35MSSharedLib1024.snk true + ../../35MSSharedLib1024.snk + + + + false + - + - + diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/Modules/TestEngineExtensionCheckerTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/Modules/TestEngineExtensionCheckerTests.cs index 2aa458c60..ce3ad50f4 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/Modules/TestEngineExtensionCheckerTests.cs +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Modules/TestEngineExtensionCheckerTests.cs @@ -106,6 +106,40 @@ public void IsValid(string usingStatements, string script, bool useTemplate, str Assert.Equal(expected, result); } + + private string _functionTemplate = @" +#r ""Microsoft.PowerFx.Interpreter.dll"" +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Microsoft.PowerFx.Core.Utils; + +%CODE%"; + + [Theory] + [InlineData("Test", "", "public class FooFunction : ReflectionFunction { public FooFunction() : base(\"Foo\", FormulaType.Blank) {} } public BlankValue Execute() { return FormulaValue.NewBlank(); }", false)] // No namespace + [InlineData("Test", "", "public class FooFunction : ReflectionFunction { public FooFunction() : base(DPath.Root, \"Foo\", FormulaType.Blank) {} } public BlankValue Execute() { return FormulaValue.NewBlank(); }", false)] // Root namespace + [InlineData("Test", "", "public class FooFunction : ReflectionFunction { public FooFunction() : base(DPath.Root.Append(new DName(\"Test\")), \"Foo\", FormulaType.Blank) {} } public BlankValue Execute() { return FormulaValue.NewBlank(); }", true)] // Non Root namespace + [InlineData("", "", "public class FooFunction : ReflectionFunction { public FooFunction() : base(DPath.Root.Append(new DName(\"TestEngine\")), \"Foo\", FormulaType.Blank) {} } public BlankValue Execute() { return FormulaValue.NewBlank(); }", true)] // Allow TestEngine namespace + [InlineData("", "", "public class FooFunction : ReflectionFunction { private FooFunction() : base(DPath.Root.Append(new DName(\"TestEngine\")), \"Foo\", FormulaType.Blank) {} } public BlankValue Execute() { return FormulaValue.NewBlank(); }", false)] // Private constructor - Not allow + [InlineData("", "*", "public class FooFunction : ReflectionFunction { public FooFunction() : base(DPath.Root.Append(new DName(\"Other\")), \"Foo\", FormulaType.Blank) {} } public BlankValue Execute() { return FormulaValue.NewBlank(); }", false)] // Deny all and not in allow list + [InlineData("Other", "*", "public class FooFunction : ReflectionFunction { public FooFunction() : base(DPath.Root.Append(new DName(\"Other\")), \"Foo\", FormulaType.Blank) {} } public BlankValue Execute() { return FormulaValue.NewBlank(); }", true)] // Deny all and in allow list + [InlineData("", "", "public class OtherFunction : ReflectionFunction { public OtherFunction(int someNumber) : base(DPath.Root.Append(new DName(\"TestEngine\")), \"Other\", FormulaType.Blank) {} } public BlankValue Execute() { return FormulaValue.NewBlank(); }", true)] // Allow TestEngine namespace with a parameter + public void ValidPowerFxFunction(string allow, string deny, string code, bool valid) + { + // Arrange + var checker = new TestEngineExtensionChecker(MockLogger.Object); + + var assembly = CompileScript(_functionTemplate.Replace("%CODE%", code)); + + var settings = new TestSettingExtensions(); + settings.AllowPowerFxNamespaces.AddRange(allow.Split(',')); + settings.DenyPowerFxNamespaces.AddRange(deny.Split(',')); + + var isValid = checker.VerifyContainsValidNamespacePowerFxFunctions(settings, assembly); + + Assert.Equal(valid, isValid); + } + [Theory] [InlineData(false, true, false, "CN=Test", "CN=Test", 0, 1, true)] [InlineData(false, false, false, "", "", 0, 1, true)] @@ -198,7 +232,7 @@ static X509Certificate2 GenerateSelfSignedCertificate(string subjectName, DateTi } } - private byte[] CompileScript(string script) + public static byte[] CompileScript(string script) { SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(script); ScriptOptions options = ScriptOptions.Default; diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/IsMatchFunctionTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/IsMatchFunctionTests.cs new file mode 100644 index 000000000..4325f1aa4 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/IsMatchFunctionTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Microsoft.PowerApps.TestEngine.PowerFx.Functions; +using Microsoft.PowerApps.TestEngine.Tests.Helpers; +using Microsoft.PowerFx.Types; +using Moq; +using Xunit; + +namespace Microsoft.PowerApps.TestEngine.Tests.PowerFx.Functions +{ + public class IsMatchFunctionTests + { + private Mock MockLogger; + + public IsMatchFunctionTests() + { + MockLogger = new Mock(MockBehavior.Strict); + } + + public static IEnumerable TestData() + { + yield return new object[] { "Hello world", "Hello", true }; // Happy path + yield return new object[] { "Hello world", "hello", false }; // Case sensitivity + yield return new object[] { "Hello world", "world$", true }; // Pattern at the end + yield return new object[] { "Hello world", "^Hello", true }; // Pattern at the beginning + yield return new object[] { "Hello world", "o w", true }; // Pattern in the middle + yield return new object[] { "Hello world", " ", true }; // Space character + yield return new object[] { "Hello world", "Hello world", true }; // Exact match + yield return new object[] { "Hello world", "Goodbye", false }; // No match + yield return new object[] { "", "Hello", false }; // Empty text + yield return new object[] { "Hello world", "", false }; // Empty pattern + yield return new object[] { "", "", false }; // Both empty + yield return new object[] { "12345", "\\d+", true }; // Numeric pattern + yield return new object[] { "abc123", "\\d+", true }; // Alphanumeric pattern + yield return new object[] { "abc", "\\d+", false }; // No numeric match + yield return new object[] { null, "Hello", false }; // Null text + yield return new object[] { "Hello world", ".*", true }; // Match any character + yield return new object[] { "Hello world", "^$", false }; // Match empty string + yield return new object[] { 12345, "\\d+", true }; // Integer pattern + yield return new object[] { (decimal)123.45, "\\d+\\.\\d+", true }; // Decimal pattern + yield return new object[] { (double)123.451, "\\d+\\.\\d+", true }; // Double pattern + yield return new object[] { "2024-11-09", "\\d{4}-\\d{2}-\\d{2}", true }; // Date pattern + yield return new object[] { new DateTime(2024, 11, 09), "\\d{4}-\\d{2}-\\d{2}", true }; // Date pattern + yield return new object[] { new DateTime(2024, 11, 09, 1, 0, 0), "\\d{4}-\\d{2}-\\d{2}T01", true }; // Date / Time pattern + yield return new object[] { new DateTime(2024, 11, 09, 0, 1, 0), "\\d{4}-\\d{2}-\\d{2}T00:01", true }; // Date / Time pattern + yield return new object[] { new DateTime(2024, 11, 09, 0, 0, 1), "\\d{4}-\\d{2}-\\d{2}T00:00:01", true }; // Date / Time pattern + yield return new object[] { new DateTime(2024, 11, 09, 0, 0, 0, 1), "\\d{4}-\\d{2}-\\d{2}T00:00:00.001", true }; // Date / Time pattern + yield return new object[] { new DateTime(2024, 11, 09, 0, 0, 0, 1), "2024-11-09T00:00:00\\.0010000Z", true }; // ISO 8601 format + } + + [Theory] + [MemberData(nameof(TestData))] + public void IsMatchFunctionPatternWithExpectedResult(object? text, string pattern, bool expectedResult) + { + LoggingTestHelper.SetupMock(MockLogger); + var assertFunction = new IsMatchFunction(MockLogger.Object); + + FormulaValue textValue = FormulaValue.NewBlank(); + if (text is string textStringValue) + { + textValue = StringValue.New(textStringValue); + } + else if (text is int textIntValue) + { + textValue = NumberValue.New(textIntValue); + } + else if (text is decimal textDecimalValue) + { + textValue = NumberValue.New((double)textDecimalValue); + } + else if (text is double textDoubleValue) + { + textValue = NumberValue.New(textDoubleValue); + } + else if (text is DateTime textDateValue) + { + textValue = DateValue.New(textDateValue); + } + + var result = assertFunction.Execute( + textValue, + StringValue.New(pattern) + ); + Assert.IsType(result); + + Assert.Equal(expectedResult, (result as BooleanValue).Value); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/PowerFxEngineTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/PowerFxEngineTests.cs index 9329a28dd..885e19005 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/PowerFxEngineTests.cs +++ b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/PowerFxEngineTests.cs @@ -151,6 +151,9 @@ public void ExecuteOneFunctionTest() { MockTestState.Setup(x => x.GetTestSettings()).Returns(new TestSettings()); MockTestState.Setup(x => x.GetTestEngineModules()).Returns(new List()); + MockTestState.SetupGet(x => x.ExecuteStepByStep).Returns(false); + MockTestState.Setup(x => x.OnBeforeTestStepExecuted(It.IsAny())); + MockTestState.Setup(x => x.OnAfterTestStepExecuted(It.IsAny())); var powerFxEngine = new PowerFxEngine(MockTestInfraFunctions.Object, MockTestWebProvider.Object, MockSingleTestInstanceState.Object, MockTestState.Object, MockFileSystem.Object); MockTestState.Setup(x => x.GetTestSettings()).Returns(null); @@ -169,6 +172,12 @@ public void ExecuteMultipleFunctionsTest() { MockTestState.Setup(x => x.GetTestSettings()).Returns(new TestSettings()); MockTestState.Setup(x => x.GetTestEngineModules()).Returns(new List()); + MockTestState.SetupGet(x => x.ExecuteStepByStep).Returns(false); + MockTestState.Setup(x => x.OnBeforeTestStepExecuted(It.IsAny())); + MockTestState.Setup(x => x.OnAfterTestStepExecuted(It.IsAny())); + MockTestState.SetupGet(x => x.ExecuteStepByStep).Returns(false); + MockTestState.Setup(x => x.OnBeforeTestStepExecuted(It.IsAny())); + MockTestState.Setup(x => x.OnAfterTestStepExecuted(It.IsAny())); var powerFxExpression = "1+1; //some comment \n 2+2;\n Concatenate(\"hello\", \"world\");"; var powerFxEngine = new PowerFxEngine(MockTestInfraFunctions.Object, MockTestWebProvider.Object, MockSingleTestInstanceState.Object, MockTestState.Object, MockFileSystem.Object); @@ -188,6 +197,9 @@ public void ExecuteMultipleFunctionsWithDifferentLocaleTest() { MockTestState.Setup(x => x.GetTestSettings()).Returns(new TestSettings()); MockTestState.Setup(x => x.GetTestEngineModules()).Returns(new List()); + MockTestState.SetupGet(x => x.ExecuteStepByStep).Returns(false); + MockTestState.Setup(x => x.OnBeforeTestStepExecuted(It.IsAny())); + MockTestState.Setup(x => x.OnAfterTestStepExecuted(It.IsAny())); // en-US locale var culture = new CultureInfo("en-US"); @@ -249,6 +261,9 @@ public async Task ExecuteWithVariablesTest() var testSettings = new TestSettings() { Timeout = 3000 }; MockTestState.Setup(x => x.GetTestSettings()).Returns(testSettings); MockTestState.Setup(x => x.GetTestEngineModules()).Returns(new List()); + MockTestState.SetupGet(x => x.ExecuteStepByStep).Returns(false); + MockTestState.Setup(x => x.OnBeforeTestStepExecuted(It.IsAny())); + MockTestState.Setup(x => x.OnAfterTestStepExecuted(It.IsAny())); var powerFxEngine = new PowerFxEngine(MockTestInfraFunctions.Object, MockTestWebProvider.Object, MockSingleTestInstanceState.Object, MockTestState.Object, MockFileSystem.Object); powerFxEngine.Setup(); @@ -274,6 +289,9 @@ public async Task ExecuteFailsWhenPowerFXThrowsTest() { MockTestState.Setup(x => x.GetTestSettings()).Returns(new TestSettings()); MockTestState.Setup(x => x.GetTestEngineModules()).Returns(new List()); + MockTestState.SetupGet(x => x.ExecuteStepByStep).Returns(false); + MockTestState.Setup(x => x.OnBeforeTestStepExecuted(It.IsAny())); + MockTestState.Setup(x => x.OnAfterTestStepExecuted(It.IsAny())); var powerFxExpression = "someNonExistentPowerFxFunction(1, 2, 3)"; MockTestWebProvider.Setup(x => x.LoadObjectModelAsync()).Returns(Task.FromResult(new Dictionary())); @@ -287,6 +305,9 @@ public async Task ExecuteFailsWhenUsingNonExistentVariableTest() { MockTestState.Setup(x => x.GetTestSettings()).Returns(new TestSettings()); MockTestState.Setup(x => x.GetTestEngineModules()).Returns(new List()); + MockTestState.SetupGet(x => x.ExecuteStepByStep).Returns(false); + MockTestState.Setup(x => x.OnBeforeTestStepExecuted(It.IsAny())); + MockTestState.Setup(x => x.OnAfterTestStepExecuted(It.IsAny())); var powerFxExpression = "Concatenate(Label1.Text, Label2.Text)"; var powerFxEngine = new PowerFxEngine(MockTestInfraFunctions.Object, MockTestWebProvider.Object, MockSingleTestInstanceState.Object, MockTestState.Object, MockFileSystem.Object); @@ -299,6 +320,9 @@ public void ExecuteAssertFunctionTest() { MockTestState.Setup(x => x.GetTestSettings()).Returns(new TestSettings()); MockTestState.Setup(x => x.GetTestEngineModules()).Returns(new List()); + MockTestState.SetupGet(x => x.ExecuteStepByStep).Returns(false); + MockTestState.Setup(x => x.OnBeforeTestStepExecuted(It.IsAny())); + MockTestState.Setup(x => x.OnAfterTestStepExecuted(It.IsAny())); var powerFxExpression = "Assert(1+1=2, \"Adding 1 + 1\")"; var powerFxEngine = new PowerFxEngine(MockTestInfraFunctions.Object, MockTestWebProvider.Object, MockSingleTestInstanceState.Object, MockTestState.Object, MockFileSystem.Object); @@ -315,6 +339,9 @@ public async Task ExecuteScreenshotFunctionTest() { MockTestState.Setup(x => x.GetTestSettings()).Returns(new TestSettings()); MockTestState.Setup(x => x.GetTestEngineModules()).Returns(new List()); + MockTestState.SetupGet(x => x.ExecuteStepByStep).Returns(false); + MockTestState.Setup(x => x.OnBeforeTestStepExecuted(It.IsAny())); + MockTestState.Setup(x => x.OnAfterTestStepExecuted(It.IsAny())); MockSingleTestInstanceState.Setup(x => x.GetTestResultsDirectory()).Returns("C:\\testResults"); MockFileSystem.Setup(x => x.IsValidFilePath(It.IsAny())).Returns(true); @@ -342,6 +369,9 @@ public async Task ExecuteSelectFunctionTest() var testSettings = new TestSettings() { Timeout = 3000 }; MockTestState.Setup(x => x.GetTestSettings()).Returns(testSettings); MockTestState.Setup(x => x.GetTestEngineModules()).Returns(new List()); + MockTestState.SetupGet(x => x.ExecuteStepByStep).Returns(false); + MockTestState.Setup(x => x.OnBeforeTestStepExecuted(It.IsAny())); + MockTestState.Setup(x => x.OnAfterTestStepExecuted(It.IsAny())); var powerFxExpression = "Select(Button1)"; var powerFxEngine = new PowerFxEngine(MockTestInfraFunctions.Object, MockTestWebProvider.Object, MockSingleTestInstanceState.Object, MockTestState.Object, MockFileSystem.Object); @@ -412,6 +442,9 @@ public async Task ExecuteSetPropertyFunctionTest() var testSettings = new TestSettings() { Timeout = 3000 }; MockTestState.Setup(x => x.GetTestSettings()).Returns(testSettings); MockTestState.Setup(x => x.GetTestEngineModules()).Returns(new List()); + MockTestState.SetupGet(x => x.ExecuteStepByStep).Returns(false); + MockTestState.Setup(x => x.OnBeforeTestStepExecuted(It.IsAny())); + MockTestState.Setup(x => x.OnAfterTestStepExecuted(It.IsAny())); var powerFxExpression = "SetProperty(Button1.Text, \"10\")"; var powerFxEngine = new PowerFxEngine(MockTestInfraFunctions.Object, MockTestWebProvider.Object, MockSingleTestInstanceState.Object, MockTestState.Object, MockFileSystem.Object); @@ -472,6 +505,9 @@ public async Task ExecuteWaitFunctionTest() var testSettings = new TestSettings() { Timeout = 3000 }; MockTestState.Setup(x => x.GetTestSettings()).Returns(testSettings); MockTestState.Setup(x => x.GetTestEngineModules()).Returns(new List()); + MockTestState.SetupGet(x => x.ExecuteStepByStep).Returns(false); + MockTestState.Setup(x => x.OnBeforeTestStepExecuted(It.IsAny())); + MockTestState.Setup(x => x.OnAfterTestStepExecuted(It.IsAny())); var powerFxExpression = "Wait(Label1, \"Text\", \"1\")"; var powerFxEngine = new PowerFxEngine(MockTestInfraFunctions.Object, MockTestWebProvider.Object, MockSingleTestInstanceState.Object, MockTestState.Object, MockFileSystem.Object); @@ -576,6 +612,9 @@ private PowerFxEngine GetPowerFxEngine() public async Task ExecuteFooFromModuleFunction() { var testSettings = new TestSettings() { ExtensionModules = new TestSettingExtensions { Enable = true } }; + MockTestState.SetupGet(x => x.ExecuteStepByStep).Returns(false); + MockTestState.Setup(x => x.OnBeforeTestStepExecuted(It.IsAny())); + MockTestState.Setup(x => x.OnAfterTestStepExecuted(It.IsAny())); var mockModule = new Mock(); var modules = new List() { mockModule.Object }; diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestLogTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestLogTests.cs new file mode 100644 index 000000000..1a1957420 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Reporting/TestLogTests.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using Microsoft.PowerApps.TestEngine.Reporting; +using Xunit; + +namespace Microsoft.PowerApps.TestEngine.Tests.Reporting +{ + public class TestLogTests + { + [Fact] + public void DateTest() + { + // Arrange + var test = new DateTime(2022, 11, 16); + var log = new TestLog() { TimeStamper = () => test }; + + // Act & Assert + Assert.Equal(test, log.When); + + // Assert + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/SingleTestRunnerTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/SingleTestRunnerTests.cs index 53e038d82..1975cf28c 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/SingleTestRunnerTests.cs +++ b/src/Microsoft.PowerApps.TestEngine.Tests/SingleTestRunnerTests.cs @@ -221,7 +221,8 @@ public async Task SingleTestRunnerSuccessWithTestDataOneTest(string[]? additiona var testData = new TestDataOne(); SetupMocks(testData.testRunId, testData.testSuiteId, testData.testId, testData.appUrl, testData.testSuiteDefinition, true, additionalFiles, testData.testSuiteLocale); - + MockLogger.Setup(m => m.IsEnabled(LogLevel.Debug)).Returns(false); + MockLogger.Setup(m => m.IsEnabled(LogLevel.Trace)).Returns(false); var locale = string.IsNullOrEmpty(testData.testSuiteLocale) ? CultureInfo.CurrentCulture : new CultureInfo(testData.testSuiteLocale); await singleTestRunner.RunTestAsync(testData.testRunId, testData.testRunDirectory, testData.testSuiteDefinition, testData.browserConfig, "", "", locale); @@ -253,6 +254,8 @@ public async Task SingleTestRunnerSuccessWithTestDataTwoTest(string[]? additiona var testData = new TestDataTwo(); SetupMocks(testData.testRunId, testData.testSuiteId, testData.testId, testData.appUrl, testData.testSuiteDefinition, true, additionalFiles, testData.testSuiteLocale); + MockLogger.Setup(m => m.IsEnabled(LogLevel.Debug)).Returns(false); + MockLogger.Setup(m => m.IsEnabled(LogLevel.Trace)).Returns(false); var locale = string.IsNullOrEmpty(testData.testSuiteLocale) ? CultureInfo.CurrentCulture : new CultureInfo(testData.testSuiteLocale); @@ -309,6 +312,8 @@ public async Task SingleTestRunnerPowerFxTestFail() var testData = new TestDataOne(); SetupMocks(testData.testRunId, testData.testSuiteId, testData.testId, testData.appUrl, testData.testSuiteDefinition, false, testData.additionalFiles, testData.testSuiteLocale); + MockLogger.Setup(m => m.IsEnabled(LogLevel.Debug)).Returns(false); + MockLogger.Setup(m => m.IsEnabled(LogLevel.Trace)).Returns(false); var locale = string.IsNullOrEmpty(testData.testSuiteLocale) ? CultureInfo.CurrentCulture : new CultureInfo(testData.testSuiteLocale); @@ -458,6 +463,8 @@ public async Task PowerFxExecuteThrowsTest() var testData = new TestDataOne(); SetupMocks(testData.testRunId, testData.testSuiteId, testData.testId, testData.appUrl, testData.testSuiteDefinition, true, testData.additionalFiles, testData.testSuiteLocale); + MockLogger.Setup(m => m.IsEnabled(LogLevel.Debug)).Returns(false); + MockLogger.Setup(m => m.IsEnabled(LogLevel.Trace)).Returns(false); var exceptionToThrow = new InvalidOperationException("Test exception"); @@ -491,6 +498,9 @@ public async Task UserInputExceptionHandlingTest() var testData = new TestDataOne(); SetupMocks(testData.testRunId, testData.testSuiteId, testData.testId, testData.appUrl, testData.testSuiteDefinition, true, testData.additionalFiles, testData.testSuiteLocale); + MockLogger.Setup(m => m.IsEnabled(LogLevel.Debug)).Returns(false); + MockLogger.Setup(m => m.IsEnabled(LogLevel.Trace)).Returns(false); + var locale = string.IsNullOrEmpty(testData.testSuiteLocale) ? CultureInfo.CurrentCulture : new CultureInfo(testData.testSuiteLocale); // Specific setup for this test diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/TestEngineTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/TestEngineTests.cs index e81eb52ad..989086839 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/TestEngineTests.cs +++ b/src/Microsoft.PowerApps.TestEngine.Tests/TestEngineTests.cs @@ -82,7 +82,7 @@ public async Task TestEngineWithDefaultParamsTest() var outputDirectory = new DirectoryInfo("TestOutput"); var testRunId = Guid.NewGuid().ToString(); var expectedOutputDirectory = outputDirectory.FullName; - var testRunDirectory = Path.Combine(expectedOutputDirectory, testRunId.Substring(0, 6)); + var testRunDirectory = Path.Combine(expectedOutputDirectory, "2024-11-20T00-00-00-0000000-" + testRunId.Substring(0, 6)); var domain = "apps.powerapps.com"; var expectedTestReportPath = "C:\\test.trx"; @@ -90,6 +90,8 @@ public async Task TestEngineWithDefaultParamsTest() SetupMocks(expectedOutputDirectory, testSettings, testSuiteDefinition, testRunId, expectedTestReportPath); var testEngine = new TestEngine(MockState.Object, ServiceProvider, MockTestReporter.Object, MockFileSystem.Object, MockLoggerFactory.Object, MockTestEngineEventHandler.Object); + testEngine.Timestamper = () => new DateTime(2024, 11, 20); + var testReportPath = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, ""); Assert.Equal(expectedTestReportPath, testReportPath); @@ -156,7 +158,7 @@ public async Task TestEngineWithUnspecifiedLocaleShowsWarning() var outputDirectory = new DirectoryInfo("TestOutput"); var testRunId = Guid.NewGuid().ToString(); var expectedOutputDirectory = outputDirectory.FullName; - var testRunDirectory = Path.Combine(expectedOutputDirectory, testRunId.Substring(0, 6)); + var testRunDirectory = Path.Combine(expectedOutputDirectory, "2024-11-20T00-00-00-0000000-" + testRunId.Substring(0, 6)); var domain = "apps.powerapps.com"; var expectedTestReportPath = "C:\\test.trx"; @@ -164,6 +166,8 @@ public async Task TestEngineWithUnspecifiedLocaleShowsWarning() SetupMocks(expectedOutputDirectory, testSettings, testSuiteDefinition, testRunId, expectedTestReportPath); var testEngine = new TestEngine(MockState.Object, ServiceProvider, MockTestReporter.Object, MockFileSystem.Object, MockLoggerFactory.Object, MockTestEngineEventHandler.Object); + testEngine.Timestamper = () => new DateTime(2024, 11, 20); + var testReportPath = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, ""); Assert.Equal(expectedTestReportPath, testReportPath); @@ -197,7 +201,7 @@ public async Task TestEngineWithMultipleBrowserConfigTest() var outputDirectory = new DirectoryInfo("TestOutput"); var testRunId = Guid.NewGuid().ToString(); var expectedOutputDirectory = outputDirectory.FullName; - var testRunDirectory = Path.Combine(expectedOutputDirectory, testRunId.Substring(0, 6)); + var testRunDirectory = Path.Combine(expectedOutputDirectory, "2024-11-20T00-00-00-0000000-" + testRunId.Substring(0, 6)); var domain = "apps.powerapps.com"; var expectedTestReportPath = "C:\\test.trx"; @@ -205,6 +209,8 @@ public async Task TestEngineWithMultipleBrowserConfigTest() SetupMocks(expectedOutputDirectory, testSettings, testSuiteDefinition, testRunId, expectedTestReportPath); var testEngine = new TestEngine(MockState.Object, ServiceProvider, MockTestReporter.Object, MockFileSystem.Object, MockLoggerFactory.Object, MockTestEngineEventHandler.Object); + testEngine.Timestamper = () => new DateTime(2024, 11, 20); + var testReportPath = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, ""); Assert.Equal(expectedTestReportPath, testReportPath); @@ -246,7 +252,7 @@ public async Task TestEngineTest(DirectoryInfo outputDirectory, string domain, T { expectedOutputDirectory = new DirectoryInfo("TestOutput"); } - var testRunDirectory = Path.Combine(expectedOutputDirectory.FullName, testRunId.Substring(0, 6)); + var testRunDirectory = Path.Combine(expectedOutputDirectory.FullName, "2024-11-20T00-00-00-0000000-" + testRunId.Substring(0, 6)); if (string.IsNullOrEmpty(domain)) { @@ -258,6 +264,8 @@ public async Task TestEngineTest(DirectoryInfo outputDirectory, string domain, T SetupMocks(expectedOutputDirectory.FullName, testSettings, testSuiteDefinition, testRunId, expectedTestReportPath); var testEngine = new TestEngine(MockState.Object, ServiceProvider, MockTestReporter.Object, MockFileSystem.Object, MockLoggerFactory.Object, MockTestEngineEventHandler.Object); + testEngine.Timestamper = () => new DateTime(2024, 11, 20); + var testReportPath = await testEngine.RunTestAsync(testConfigFile, environmentId, tenantId, outputDirectory, domain, ""); Assert.Equal(expectedTestReportPath, testReportPath); @@ -320,7 +328,6 @@ private void Verify(string testConfigFile, string environmentId, string tenantId [Theory] [InlineData(null, "Default-EnvironmentId", "a01af035-a529-4aaf-aded-011ad676f976", "apps.powerapps.com")] [InlineData("C:\\testPlan.fx.yaml", "", "a01af035-a529-4aaf-aded-011ad676f976", "apps.powerapps.com")] - [InlineData("C:\\testPlan.fx.yaml", "Default-EnvironmentId", "a01af035-a529-4aaf-aded-011ad676f976", "")] public async Task TestEngineThrowsOnNullArguments(string? testConfigFilePath, string environmentId, Guid tenantId, string domain) { MockTestReporter.Setup(x => x.CreateTestRun(It.IsAny(), It.IsAny())).Returns(Guid.NewGuid().ToString()); @@ -332,6 +339,13 @@ public async Task TestEngineThrowsOnNullArguments(string? testConfigFilePath, st MockFileSystem.Setup(x => x.CreateDirectory(It.IsAny())); var testEngine = new TestEngine(MockState.Object, ServiceProvider, MockTestReporter.Object, MockFileSystem.Object, MockLoggerFactory.Object, MockTestEngineEventHandler.Object); + MockLogger.Setup(x => x.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny())); + FileInfo testConfigFile; if (string.IsNullOrEmpty(testConfigFilePath)) { diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/TestInfra/MicrosoftEntraNetworkMonitorTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/TestInfra/MicrosoftEntraNetworkMonitorTests.cs new file mode 100644 index 000000000..1872c4492 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/TestInfra/MicrosoftEntraNetworkMonitorTests.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using Microsoft.PowerApps.TestEngine.Config; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Moq; +using Xunit; + + +namespace Microsoft.PowerApps.TestEngine.Tests.TestInfra +{ + public class MicrosoftEntraNetworkMonitorTests + { + Mock MockLogger; + Mock MockBrowserContext; + Mock MockRoute; + Mock MockRequest; + Mock MockResponse; + Mock MockTestState; + + public MicrosoftEntraNetworkMonitorTests() + { + MockBrowserContext = new Mock(MockBehavior.Strict); + MockLogger = new Mock(); + MockRoute = new Mock(MockBehavior.Strict); + MockRequest = new Mock(MockBehavior.Strict); + MockResponse = new Mock(MockBehavior.Strict); + MockTestState = new Mock(MockBehavior.Strict); + } + + static List urls = new List { + "login.microsoftonline.com", + "login.microsoftonline.us", + "login.chinacloudapi.cn", + "login.microsoftonline.de" + }; + + public static IEnumerable LoginUrls() + { + return urls.Select(val => new object[] { val }); + } + + [Theory] + [MemberData(nameof(LoginUrls))] + public async Task WillTrackRequest(string url) + { + // Arrange + var monitor = new MicrosoftEntraNetworkMonitor(MockLogger.Object, MockBrowserContext.Object, MockTestState.Object); + Func callback = null; + List routeUrl = new List(); + + MockBrowserContext.Setup(m => m.RouteAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Callback((string callbackUrl, Func a, BrowserContextRouteOptions options) => + { + routeUrl.Add(callbackUrl); + callback = a; + }) + .Returns(Task.CompletedTask); + + MockRoute.Setup(m => m.ContinueAsync(null)).Returns(Task.CompletedTask); + MockRoute.Setup(m => m.Request).Returns(MockRequest.Object); + + MockRequest.Setup(m => m.Method).Returns("GET"); + MockRequest.Setup(m => m.Url).Returns($"https://{url}?query=value"); + + // Act + await monitor.MonitorEntraLoginAsync($"https://app.powerapps.com"); + await callback(MockRoute.Object); + + // Assert + Assert.Equal(urls.Count() + 1, routeUrl.Count()); + } + + public static IEnumerable InvalidUrls() + { + yield return new object[] { "https://example.com" }; + } + + [Theory] + [MemberData(nameof(InvalidUrls))] + public async Task WillNotTrackRequest(string url) + { + // Arrange + var monitor = new MicrosoftEntraNetworkMonitor(MockLogger.Object, MockBrowserContext.Object, MockTestState.Object); + Func callback = null; + List routeUrl = new List(); + + MockBrowserContext.Setup(m => m.RouteAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Callback((string callbackUrl, Func a, BrowserContextRouteOptions options) => + { + routeUrl.Add(callbackUrl); + callback = a; + }) + .Returns(Task.CompletedTask); + + MockRoute.Setup(m => m.ContinueAsync(null)).Returns(Task.CompletedTask); + MockRoute.Setup(m => m.Request).Returns(MockRequest.Object); + + MockLogger = new Mock(MockBehavior.Strict); + + MockRequest.Setup(m => m.Method).Returns("GET"); + MockRequest.Setup(m => m.Url).Returns(url); + + // Act + await monitor.MonitorEntraLoginAsync($"https://app.powerapps.com"); + await callback(MockRoute.Object); + + // Assert + Assert.Equal(urls.Count() + 1, routeUrl.Count()); + } + + [Theory] + [MemberData(nameof(LoginUrls))] + public async Task WillTrackResponse(string url) + { + // Arrange + var monitor = new MicrosoftEntraNetworkMonitor(MockLogger.Object, MockBrowserContext.Object, MockTestState.Object); + + MockBrowserContext.Setup(m => m.RouteAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(Task.CompletedTask); + + MockRoute.Setup(m => m.ContinueAsync(null)).Returns(Task.CompletedTask); + MockRoute.Setup(m => m.Request).Returns(MockRequest.Object); + + MockLogger = new Mock(MockBehavior.Strict); + + MockRequest.Setup(m => m.Method).Returns("GET"); + MockRequest.Setup(m => m.Url).Returns($"https://{url}/query=data"); + + MockRequest.Setup(m => m.RedirectedFrom).Returns((IRequest)null); + MockRequest.Setup(m => m.RedirectedTo).Returns((IRequest)null); + MockRequest.Setup(m => m.ResponseAsync()).ReturnsAsync(MockResponse.Object); + + MockResponse.Setup(m => m.Status).Returns(200); + + // Act + await monitor.MonitorEntraLoginAsync($"https://app.powerapps.com"); + MockBrowserContext.Raise(context => context.RequestFinished += null, args: new object[] { null, MockRequest.Object }); + + + // Assert + } + + [Theory] + [MemberData(nameof(InvalidUrls))] + public async Task WillNotTrackResponse(string url) + { + // Arrange + var monitor = new MicrosoftEntraNetworkMonitor(MockLogger.Object, MockBrowserContext.Object, MockTestState.Object); + + MockBrowserContext.Setup(m => m.RouteAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(Task.CompletedTask); + + MockRoute.Setup(m => m.ContinueAsync(null)).Returns(Task.CompletedTask); + MockRoute.Setup(m => m.Request).Returns(MockRequest.Object); + + MockLogger = new Mock(MockBehavior.Strict); + + MockRequest.Setup(m => m.Method).Returns("GET"); + MockRequest.Setup(m => m.Url).Returns($"https://{url}/query=data"); + + // Act + await monitor.MonitorEntraLoginAsync($"https://app.powerapps.com"); + MockBrowserContext.Raise(context => context.RequestFinished += null, args: new object[] { null, MockRequest.Object }); + + + // Assert + } + + [Theory] + [InlineData("https://example.com")] + [InlineData("")] + [InlineData(null)] + [InlineData("/page")] + public async Task NoCookies(string? url) + { + // Arrange + var monitor = new MicrosoftEntraNetworkMonitor(MockLogger.Object, MockBrowserContext.Object, MockTestState.Object); + + MockTestState.Setup(m => m.GetDomain()).Returns(url); + MockBrowserContext.Setup(m => m.CookiesAsync(It.IsAny>())).Returns(Task.FromResult((IReadOnlyList)null)); + + // Act & Assert + await monitor.LogCookies(""); + } + + internal class RequestEventArgs : EventArgs + { + public IRequest? Request { get; set; } + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/TestInfra/PlaywrightTestInfraFunctionTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/TestInfra/PlaywrightTestInfraFunctionTests.cs index 5debd54f6..f12f613e7 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/TestInfra/PlaywrightTestInfraFunctionTests.cs +++ b/src/Microsoft.PowerApps.TestEngine.Tests/TestInfra/PlaywrightTestInfraFunctionTests.cs @@ -77,7 +77,8 @@ public async Task SetupAsyncTest(string browser, string? device, int? screenWidt { BrowserConfigurations = new List() { browserConfig }, RecordVideo = true, - Timeout = 15 + Timeout = 15, + ExtensionModules = new TestSettingExtensions() { Enable = false } }; var testResultsDirectory = "C:\\TestResults"; diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/TestInfra/TestRecorderTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/TestInfra/TestRecorderTests.cs new file mode 100644 index 000000000..030c2bc05 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/TestInfra/TestRecorderTests.cs @@ -0,0 +1,537 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using Microsoft.PowerApps.TestEngine.Config; +using Microsoft.PowerApps.TestEngine.PowerFx; +using Microsoft.PowerApps.TestEngine.System; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Moq; +using Xunit; +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Microsoft.PowerApps.TestEngine.Tests.TestInfra +{ + public class TestRecorderTests + { + private Mock _mockLogger; + private Mock _mockBrowserContext; + private Mock _mockTestState; + private Mock _mockTestInfraFunctions; + private Mock _mockFileSystem; + private Mock _mockPage; + private Mock _mockRequest; + private Mock _mockResponse; + private Mock _mockEngine; + private Mock _mockRoute; + + public TestRecorderTests() + { + _mockLogger = new Mock(); + _mockBrowserContext = new Mock(MockBehavior.Strict); + _mockTestState = new Mock(MockBehavior.Strict); + _mockTestInfraFunctions = new Mock(MockBehavior.Strict); + _mockFileSystem = new Mock(MockBehavior.Strict); + _mockPage = new Mock(MockBehavior.Strict); + _mockRequest = new Mock(MockBehavior.Strict); + _mockResponse = new Mock(MockBehavior.Strict); + _mockEngine = new Mock(MockBehavior.Strict); + _mockEngine = new Mock(MockBehavior.Strict); + _mockRoute = new Mock(MockBehavior.Strict); + + _mockRoute.Setup(m => m.FulfillAsync(It.IsAny())).Returns(Task.CompletedTask); + } + + [Fact] + public void CanCreate() + { + new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + } + + [Fact] + public void Setup_SubscribesToEvents() + { + // Arrange + var recorder = new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + + // Act + recorder.SetupHttpMonitoring(); + + // Assert + _mockBrowserContext.VerifyAdd(m => m.Response += It.IsAny>(), Times.Once); + } + + [Theory] + [InlineData(null)] + [InlineData("// Test")] + public void Generate_CreatesDirectoryAndWritesToFile(string? steps) + { + // Arrange + var path = "testPath"; + var fileContents = string.Empty; + var recorder = new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + _mockFileSystem.Setup(fs => fs.Exists(path)).Returns(false); + _mockFileSystem.Setup(fs => fs.CreateDirectory(path)); + _mockFileSystem.Setup(fs => fs.WriteTextToFile($"{path}/recorded.te.yaml", It.IsAny())) + .Callback((string name, string contents) => + { + fileContents = contents; + }); + if (!string.IsNullOrEmpty(steps)) + { + recorder.TestSteps.Add(steps); + } + + // Act + recorder.Generate(path); + + // Assert + Assert.True(ValidateYamlFile(fileContents)); + } + + private bool ValidateYamlFile(string content) + { + try + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + + var yamlObject = deserializer.Deserialize(content); + + return true; + } + catch (YamlException ex) + { + Console.WriteLine($"YAML validation error: {ex.Message}"); + return false; + } + } + + // TODO: Add test case for datetime + + [Theory] + [InlineData("https://www.example.com", 0, "", "")] + // Empty table + [InlineData("https://www.example.com/api/data/v9.2/accounts", 1, "{\"value\":[]}", "Experimental.SimulateDataverse({Action: \"Query\", Entity: \"accounts\", Then: Table()});")] + // Single record from array + [InlineData("https://www.example.com/api/data/v9.2/accounts", 1, "{\"value\":[{\"Name\":\"Test\"}]}", "Experimental.SimulateDataverse({Action: \"Query\", Entity: \"accounts\", Then: Table({Name: \"Test\"})});")] + // Two records from array + [InlineData("https://www.example.com/api/data/v9.2/accounts", 1, "{\"value\":[{\"Name\":\"Test\"},{\"Name\":\"Other\"}]}", "Experimental.SimulateDataverse({Action: \"Query\", Entity: \"accounts\", Then: Table({Name: \"Test\"}, {Name: \"Other\"})});")] + // Record value + [InlineData("https://www.example.com/api/data/v9.2/accounts", 1, "{\"value\":{\"Name\":\"Test\"}}", "Experimental.SimulateDataverse({Action: \"Query\", Entity: \"accounts\", Then: {Name: \"Test\"}});")] + public async Task OnResponse_HandlesDataverseResponse(string url, int count, string json, string action) + { + // Arrange + var recorder = new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + var tasks = new List(); + + var args = new object[] { tasks, _mockResponse.Object }; + + _mockResponse.Setup(m => m.Request).Returns(_mockRequest.Object); + _mockRequest.SetupGet(m => m.Url).Returns(url); + _mockRequest.SetupGet(m => m.Method).Returns("GET"); + + if (count > 0) + { + _mockResponse.Setup(m => m.JsonAsync()).Returns(Task.FromResult(ParseJson(json))); + } + + // Act + recorder.SetupHttpMonitoring(); + _mockBrowserContext.Raise(m => m.Response += null, args); + if (tasks.Count > 0) + { + await tasks[0]; + } + + // Assert + Assert.Equal(count, recorder.TestSteps.Count()); + + if (count > 0) + { + Assert.Equal(action, recorder.TestSteps.First()); + } + } + + [Theory] + [InlineData("", "", "")] + // Empty table + [InlineData("/apim/test", "{}", "Experimental.SimulateConnector({Name: \"test\", Then: Blank()});")] + // Record match + [InlineData("/apim/test", "{\"Name\": \"Test\"}", "Experimental.SimulateConnector({Name: \"test\", Then: {Name: \"Test\"}});")] + // Complex object + [InlineData("/apim/test", "{\"Name\": {\"Child\":\"Test\"}}", "Experimental.SimulateConnector({Name: \"test\", Then: {Name: {Child: \"Test\"}}});")] + [InlineData("/apim/test", "{\"List\": [{\"Child\":\"Test\"}]}", "Experimental.SimulateConnector({Name: \"test\", Then: {List: Table({Child: \"Test\"})}});")] + [InlineData("/apim/test", @"[{""Name"": {""Child"":""Test""}}]", "Experimental.SimulateConnector({Name: \"test\", Then: Table({Name: {Child: \"Test\"}})});")] + // Test for action after the connector id + [InlineData("/apim/test/a1234567-1111-2222-3333-44445555666/v1.0/action", "{\"Name\": \"Test\"}", "Experimental.SimulateConnector({Name: \"test\", When: {Action: \"v1.0/action\"}, Then: {Name: \"Test\"}});")] + // OData filter scenarios + [InlineData("/apim/test?$filter=a eq 1", "{\"Name\": \"Test\"}", "Experimental.SimulateConnector({Name: \"test\", When: {Filter: \"a = 1\"}, Then: {Name: \"Test\"}});")] + [InlineData("/apim/test?$filter=a ne 1", "{\"Name\": \"Test\"}", "Experimental.SimulateConnector({Name: \"test\", When: {Filter: \"a != 1\"}, Then: {Name: \"Test\"}});")] + [InlineData("/apim/test?$filter=a ge 1", "{\"Name\": \"Test\"}", "Experimental.SimulateConnector({Name: \"test\", When: {Filter: \"a >= 1\"}, Then: {Name: \"Test\"}});")] + [InlineData("/apim/test?$filter=a gt 1", "{\"Name\": \"Test\"}", "Experimental.SimulateConnector({Name: \"test\", When: {Filter: \"a > 1\"}, Then: {Name: \"Test\"}});")] + [InlineData("/apim/test?$filter=a le 1", "{\"Name\": \"Test\"}", "Experimental.SimulateConnector({Name: \"test\", When: {Filter: \"a <= 1\"}, Then: {Name: \"Test\"}});")] + [InlineData("/apim/test?$filter=a lt 1", "{\"Name\": \"Test\"}", "Experimental.SimulateConnector({Name: \"test\", When: {Filter: \"a < 1\"}, Then: {Name: \"Test\"}});")] + // OData filter to function + [InlineData("/apim/test?$filter=(a eq 1) and (b eq 2)", "{\"Name\": \"Test\"}", "Experimental.SimulateConnector({Name: \"test\", When: {Filter: \"AND(a = 1,b = 2)\"}, Then: {Name: \"Test\"}});")] + [InlineData("/apim/test?$filter=(a eq 1) or (b eq 2)", "{\"Name\": \"Test\"}", "Experimental.SimulateConnector({Name: \"test\", When: {Filter: \"OR(a = 1,b = 2)\"}, Then: {Name: \"Test\"}});")] + [InlineData("/apim/test?$filter=(a eq 'value')", "{\"Name\": \"Test\"}", "Experimental.SimulateConnector({Name: \"test\", When: {Filter: \"a = \"\"value\"\"\"}, Then: {Name: \"Test\"}});")] + public async Task OnResponse_HandlesConnectorResponse(string url, string body, string action) + { + // Arrange + var recorder = new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + var tasks = new List(); + + var args = new object[] { tasks, _mockResponse.Object }; + + _mockResponse.Setup(m => m.Request).Returns(_mockRequest.Object); + if (!string.IsNullOrEmpty(url)) + { + _mockResponse.Setup(m => m.JsonAsync()).ReturnsAsync(ParseJson(body)); + } + _mockRequest.SetupGet(m => m.Url).Returns("https://example.com/invoke"); + _mockRequest.SetupGet(m => m.Method).Returns("POST"); + + var headers = new Dictionary { }; + + if (!string.IsNullOrEmpty(url)) + { + headers.Add("x-ms-request-url", url); + } + + _mockRequest.SetupGet(m => m.Headers).Returns(headers); + + // Act + recorder.SetupHttpMonitoring(); + _mockBrowserContext.Raise(m => m.Response += null, args); + + if (tasks.Count > 0) + { + await tasks[0]; + } + + // Assert + if (string.IsNullOrEmpty(action)) + { + Assert.Empty(recorder.TestSteps); + } + else + { + Assert.Single(recorder.TestSteps); + Assert.Equal(action, recorder.TestSteps.First()); + } + } + + private JsonElement? ParseJson(string jsonString) + { + if (string.IsNullOrEmpty(jsonString)) + { + return null; + } + JsonDocument jsonDocument = JsonDocument.Parse(jsonString); + return jsonDocument.RootElement; + } + + [Fact] + public void ApiRegistration() + { + // Arrange + var recorder = new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + _mockTestState.Setup(m => m.GetDomain()).Returns("https://example.com"); + _mockBrowserContext.Setup(m => m.RouteAsync("https://example.com/testengine/**", It.IsAny>(), null)).Returns(Task.CompletedTask); + + // Act + recorder.RegisterTestEngineApi(); + + // Assert + } + + [Theory] + [InlineData("{}", "Select(test);")] + [InlineData("{alt: true}", "Experimental.PlaywrightAction(\"[data-test-id='test']:has-text('')\", \"wait\");")] + [InlineData("{alt: true, text: 'Foo'}", "Experimental.PlaywrightAction(\"[data-test-id='test']:has-text('Foo')\", \"wait\");")] + [InlineData("{control: true}", "Experimental.WaitUntil(test.Text=\"\");")] + [InlineData("{control: true, text: 'Foo'}", "Experimental.WaitUntil(test.Text=\"Foo\");")] + public async Task ClickCallback(string json, string expectedPowerFx) + { + // Arrange + Func callbackInstance = null; + var recorder = new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + _mockTestState.Setup(m => m.GetDomain()).Returns("https://example.com"); + _mockBrowserContext.Setup(m => m.RouteAsync("https://example.com/testengine/**", It.IsAny>(), null)) + .Callback((string url, Func callback, BrowserContextRouteOptions options) => + { + callbackInstance = callback; + }) + .Returns(Task.CompletedTask); + + _mockRoute.SetupGet(m => m.Request).Returns(_mockRequest.Object); + _mockRequest.SetupGet(m => m.Url).Returns("https://www.example.com/testengine/click/test"); + _mockRequest.SetupGet(m => m.PostData).Returns(json); + + // Act + recorder.RegisterTestEngineApi(); + await callbackInstance(_mockRoute.Object); + + // Assert + Assert.Single(recorder.TestSteps); + Assert.Equal(expectedPowerFx, recorder.TestSteps.First()); + } + + [Theory] + [InlineData("// Audio started - 2024-11-07T09:35:43Z - 123e4567-e89b-12d3-a456-426614174000")] + public async Task AudioStart(string message) + { + // Arrange + Func callbackInstance = null; + var recorder = new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + _mockTestState.Setup(m => m.GetDomain()).Returns("https://example.com"); + _mockBrowserContext.Setup(m => m.RouteAsync("https://example.com/testengine/**", It.IsAny>(), null)) + .Callback((string url, Func callback, BrowserContextRouteOptions options) => + { + callbackInstance = callback; + }) + .Returns(Task.CompletedTask); + + _mockRoute.SetupGet(m => m.Request).Returns(_mockRequest.Object); + _mockRequest.SetupGet(m => m.Url).Returns("https://www.example.com/testengine/audio/start"); + _mockRequest.SetupGet(m => m.Method).Returns("POST"); + _mockRequest.SetupGet(m => m.PostData).Returns(@"{ + ""startDateTime"": ""2024-11-07T09:35:43Z"", + ""audioSessionId"": ""123e4567-e89b-12d3-a456-426614174000"" + }"); + + // Act + recorder.RegisterTestEngineApi(); + await callbackInstance(_mockRoute.Object); + + // Assert + Assert.Single(recorder.TestSteps); + Assert.Equal(message, recorder.TestSteps.First()); + } + + + [Theory] + [InlineData("// Audio end - 2024-11-07T09:40:50Z - 123e4567-e89b-12d3-a456-426614174000")] + public async Task FileUpload(string message) + { + // Arrange + Func callbackInstance = null; + var recorder = new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + _mockTestState.Setup(m => m.GetDomain()).Returns("https://example.com"); + _mockBrowserContext.Setup(m => m.RouteAsync("https://example.com/testengine/**", It.IsAny>(), null)) + .Callback((string url, Func callback, BrowserContextRouteOptions options) => + { + callbackInstance = callback; + }) + .Returns(Task.CompletedTask); + + _mockRoute.SetupGet(m => m.Request).Returns(_mockRequest.Object); + _mockRequest.SetupGet(m => m.Url).Returns("https://www.example.com/testengine/audio/upload"); + _mockRequest.SetupGet(m => m.Headers).Returns(new Dictionary { { "enddatetime", "2024-11-07T09:40:50Z" }, { "audiosessionid", "123e4567-e89b-12d3-a456-426614174000" } }); + _mockRequest.SetupGet(m => m.Method).Returns("POST"); + + var testData = new byte[] { }; + _mockRequest.SetupGet(m => m.PostDataBuffer).Returns(testData); + _mockFileSystem.Setup(m => m.Exists("")).Returns(true); + _mockFileSystem.Setup(m => m.WriteFile(It.IsAny(), testData)); + + // Act + recorder.RegisterTestEngineApi(); + await callbackInstance(_mockRoute.Object); + + // Assert + Assert.Single(recorder.TestSteps); + Assert.Equal(message, recorder.TestSteps.First()); + } + + [Fact] + public void MouseEvent_Registration() + { + // Arrange + var recorder = new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + _mockTestState.Setup(m => m.GetDomain()).Returns("https://example.com"); + _mockPage.Setup(m => m.EvaluateAsync(It.IsAny(), null)).Returns(Task.FromResult((JsonElement?)null)); + _mockTestInfraFunctions.SetupGet(m => m.Page).Returns(_mockPage.Object); + + // Act + recorder.SetupMouseMonitoring(); + + // Assert + } + + [Fact] + public void MouseEvent_ValidJavaScript() + { + // Arrange + var recorder = new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + _mockTestState.Setup(m => m.GetDomain()).Returns("https://example.com"); + _mockBrowserContext.Setup(m => m.RouteAsync("https://example.com/testengine/**", It.IsAny>(), null)).Returns(Task.CompletedTask); + + var javaScript = String.Empty; + _mockPage.Setup(m => m.EvaluateAsync(It.IsAny(), null)) + .Callback((string js, object arg) => javaScript = js) + .Returns(Task.FromResult((JsonElement?)null)); + _mockTestInfraFunctions.SetupGet(m => m.Page).Returns(_mockPage.Object); + + var jint = new Jint.Engine(); + jint.Evaluate(@"document = { + addEventListener: (eventName, callback) => { if (eventName != 'click') throw 'Invalid event' } +}"); + + // Act + recorder.SetupMouseMonitoring(); + + // Assert + + jint.Evaluate(javaScript); + Assert.Contains("https://example.com/testengine/click/", javaScript); + } + + [Fact] + public async Task SetupAudioWithValidScript() + { + // Arrange + var recorder = new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + _mockTestState.Setup(m => m.GetDomain()).Returns("https://example.com"); + var setupScript = String.Empty; + _mockTestInfraFunctions.SetupGet(m => m.Page).Returns(_mockPage.Object); + + _mockPage.Setup(m => m.EvaluateAsync(It.IsAny(), null)) + .Callback((string script, object arg) => setupScript = script) + .Returns(Task.FromResult((JsonElement?)null)); + + var engine = new Jint.Engine(); + engine.Evaluate(@"var document = { + addEventListener: (eventName, callback) => { if (eventName != 'keydown') throw 'Invalid event' } + }"); + + // Act + await recorder.SetupAudioRecording(Path.GetTempPath()); + + // Assert + engine.Evaluate(setupScript); + } + + [Fact] + public async Task HtmlDialogRegistration() + { + // Arrange + var recorder = new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + _mockTestState.Setup(m => m.GetDomain()).Returns("https://example.com"); + var setupScript = String.Empty; + + _mockTestInfraFunctions.SetupGet(m => m.Page).Returns(_mockPage.Object); + + _mockPage.Setup(m => m.EvaluateAsync(It.IsAny(), null)) + .Callback((string script, object arg) => setupScript = script) + .Returns(Task.FromResult((JsonElement?)null)); + + var engine = new Jint.Engine(); + engine.Evaluate(MOCK_DOCUMENT); + + // Act + await recorder.SetupAudioRecording(Path.GetTempPath()); + engine.Evaluate(setupScript); + + // Assert + engine.Evaluate("document.callback({ctrlKey: true, key: 'r', preventDefault: () => {}})"); + } + + [Fact] + public async Task HtmlDialogClose() + { + // Arrange + var recorder = new TestRecorder(_mockLogger.Object, _mockBrowserContext.Object, _mockTestState.Object, _mockTestInfraFunctions.Object, _mockEngine.Object, _mockFileSystem.Object); + _mockTestState.Setup(m => m.GetDomain()).Returns("https://example.com"); + var setupScript = String.Empty; + + _mockTestInfraFunctions.SetupGet(m => m.Page).Returns(_mockPage.Object); + + _mockPage.Setup(m => m.EvaluateAsync(It.IsAny(), null)) + .Callback((string script, object arg) => setupScript = script) + .Returns(Task.FromResult((JsonElement?)null)); + + var engine = new Jint.Engine(); + engine.Evaluate(MOCK_DOCUMENT); + + // Act + await recorder.SetupAudioRecording(Path.GetTempPath()); + engine.Evaluate(setupScript); + + // Assert + engine.Evaluate("document.callback({ctrlKey: true, key: 'r', preventDefault: () => {}})"); + engine.Evaluate("document.closeDialog()"); // Should call removeChild + } + + const string MOCK_DOCUMENT = @"var document = { + createElement: (type) => { + return { + innerHTML: '', + setAttribute: (name, value) => { this[name] = value; }, + getAttribute: (name) => { return this[name]; }, + addEventListener: (eventName, callback) => { + if (eventName !== 'keydown') throw 'Invalid event'; + this[eventName] = callback; + }, + removeEventListener: (eventName) => { + if (eventName !== 'keydown') throw 'Invalid event'; + delete this[eventName]; + } + }; + }, + addEventListener: (eventName, callback) => { + if (eventName !== 'keydown') throw 'Invalid event'; + document.callback = callback; + }, + removeEventListener: (eventName) => { + if (eventName !== 'keydown') throw 'Invalid event'; + delete this[eventName]; + }, + body : { + appendChild: (node) => {}, + removeChild: (node) => {} + }, + getElementById: (name) => { + switch (name) { + case 'startRecording': + return { + addEventListener :(eventName, callback) => { + if (eventName !== 'click') throw 'Invalid event'; + document.startRecording = callback; + } + } + break; + case 'stopRecording': + return { + addEventListener :(eventName, callback) => { + if (eventName !== 'click') throw 'Invalid event'; + document.stopRecording = callback; + } + } + break; + case 'closeDialog': + return { + addEventListener :(eventName, callback) => { + if (eventName !== 'click') throw 'Invalid event'; + document.closeDialog = callback; + } + } + break; + } + } + +}"; + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/Config/DefaultUserCertificateProvider.cs b/src/Microsoft.PowerApps.TestEngine/Config/DefaultUserCertificateProvider.cs new file mode 100644 index 000000000..77d912162 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/Config/DefaultUserCertificateProvider.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace Microsoft.PowerApps.TestEngine.Config +{ + public class DefaultUserCertificateProvider : IUserCertificateProvider + { + public string Name => "default"; + + public X509Certificate2 RetrieveCertificateForUser(string userIdentifier) + { + return null; + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/Config/ITestState.cs b/src/Microsoft.PowerApps.TestEngine/Config/ITestState.cs index b2c703616..f48a10078 100644 --- a/src/Microsoft.PowerApps.TestEngine/Config/ITestState.cs +++ b/src/Microsoft.PowerApps.TestEngine/Config/ITestState.cs @@ -146,5 +146,39 @@ public interface ITestState public List GetTestEngineWebProviders(); public List GetTestEngineAuthProviders(); + + /// + /// Determine if the steps of the test steps should be executed step by step or as one action + /// + public bool ExecuteStepByStep { get; set; } + + /// + /// Event triggered before a test step is executed + /// + event EventHandler BeforeTestStepExecuted; + + /// + /// Event triggered after a test step is executed + /// + event EventHandler AfterTestStepExecuted; + + /// + /// This method is called before a test step is executed. + /// It allows for any necessary setup or logging before the test step runs. + /// + /// The event arguments containing details about the test step. + public void OnBeforeTestStepExecuted(TestStepEventArgs e); + + /// + /// This method is called after a test step is executed. + /// It allows for any necessary cleanup or logging after the test step runs. + /// + /// The event arguments containing details about the test step. + public void OnAfterTestStepExecuted(TestStepEventArgs e); + + /// + /// Indicate that this test run should be a record mode not execution + /// + void SetRecordMode(); } } diff --git a/src/Microsoft.PowerApps.TestEngine/Config/TestSettingExtensions.cs b/src/Microsoft.PowerApps.TestEngine/Config/TestSettingExtensions.cs index a7ce4572b..0405931ed 100644 --- a/src/Microsoft.PowerApps.TestEngine/Config/TestSettingExtensions.cs +++ b/src/Microsoft.PowerApps.TestEngine/Config/TestSettingExtensions.cs @@ -11,7 +11,7 @@ public class TestSettingExtensions /// /// Determine if extension modules should be enabled /// - public bool Enable { get; set; } = false; + public bool Enable { get; set; } = true; public TestSettingExtensionSource Source { get; set; } = new TestSettingExtensionSource() { }; @@ -40,6 +40,17 @@ public class TestSettingExtensions /// public List DenyNamespaces { get; set; } = new List(); + /// + /// List of allowed PowerFx Namespaces that can be referenced in a Test Engine Module + /// + public List AllowPowerFxNamespaces { get; set; } = new List(); + + /// + /// List of allowed PowerFx Namespaces that deny load unless explict allow is defined + /// + public List DenyPowerFxNamespaces { get; set; } = new List(); + + /// /// Additional optional parameters for extension modules /// diff --git a/src/Microsoft.PowerApps.TestEngine/Config/TestState.cs b/src/Microsoft.PowerApps.TestEngine/Config/TestState.cs index a1470604e..b9a3a8a1e 100644 --- a/src/Microsoft.PowerApps.TestEngine/Config/TestState.cs +++ b/src/Microsoft.PowerApps.TestEngine/Config/TestState.cs @@ -18,6 +18,10 @@ namespace Microsoft.PowerApps.TestEngine.Config public class TestState : ITestState { private readonly ITestConfigParser _testConfigParser; + private bool _recordMode = false; + + public event EventHandler BeforeTestStepExecuted; + public event EventHandler AfterTestStepExecuted; private TestPlanDefinition TestPlanDefinition { get; set; } private List TestCases { get; set; } = new List(); @@ -42,6 +46,9 @@ public class TestState : ITestState private bool IsValid { get; set; } = false; + // Determine if Power FX expressions delimited by ; should be executed step by step + public bool ExecuteStepByStep { get; set; } = false; + public TestState(ITestConfigParser testConfigParser) { _testConfigParser = testConfigParser; @@ -49,6 +56,17 @@ public TestState(ITestConfigParser testConfigParser) public TestSuiteDefinition GetTestSuiteDefinition() { + if (_recordMode) + { + return new TestSuiteDefinition + { + RecordMode = true, + AppId = TestPlanDefinition?.TestSuite.AppId, + AppLogicalName = TestPlanDefinition?.TestSuite.AppLogicalName, + Persona = TestPlanDefinition?.TestSuite.Persona, + }; + } + return TestPlanDefinition?.TestSuite; } @@ -215,10 +233,6 @@ public string GetEnvironment() public void SetDomain(string domain) { - if (string.IsNullOrEmpty(domain)) - { - throw new ArgumentNullException(nameof(domain)); - } Domain = domain; } @@ -371,5 +385,20 @@ public List GetTestEngineAuthProviders() { return CertificateProviders; } + + public void OnBeforeTestStepExecuted(TestStepEventArgs e) + { + BeforeTestStepExecuted?.Invoke(this, e); + } + + public void OnAfterTestStepExecuted(TestStepEventArgs e) + { + AfterTestStepExecuted?.Invoke(this, e); + } + + public void SetRecordMode() + { + _recordMode = true; + } } } diff --git a/src/Microsoft.PowerApps.TestEngine/Config/TestStepEventArgs.cs b/src/Microsoft.PowerApps.TestEngine/Config/TestStepEventArgs.cs new file mode 100644 index 000000000..459354029 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/Config/TestStepEventArgs.cs @@ -0,0 +1,31 @@ +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.PowerApps.TestEngine.Config +{ + /// + /// Represents the event arguments for a test step event. + /// + public class TestStepEventArgs : EventArgs + { + /// + /// Gets or sets the name of the test step. + /// + public string TestStep { get; set; } + + /// + /// Gets or sets the result of the test step. + /// + public FormulaValue Result { get; set; } + + /// + /// Gets or sets the step number of the test step. + /// + public int? StepNumber { get; set; } + + /// + /// Gets or sets the recalculation engine used for the test step. + /// + public RecalcEngine Engine { get; set; } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/Config/TestSuiteDefinition.cs b/src/Microsoft.PowerApps.TestEngine/Config/TestSuiteDefinition.cs index 61e043b2c..9f8a37304 100644 --- a/src/Microsoft.PowerApps.TestEngine/Config/TestSuiteDefinition.cs +++ b/src/Microsoft.PowerApps.TestEngine/Config/TestSuiteDefinition.cs @@ -69,5 +69,10 @@ public class TestSuiteDefinition /// Gets or sets the test cases to be executed. /// public List TestCases { get; set; } = new List(); + + /// + /// Indicate if record mode rather than execute test suite / test case + /// + public bool RecordMode { get; set; } } } diff --git a/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj b/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj index dce06d359..50150f5ac 100644 --- a/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj +++ b/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj @@ -1,7 +1,7 @@ - netstandard2.0 + netstandard2.1 enable disable True @@ -18,23 +18,34 @@ Intial Alpha release of Microsoft.PowerAppsTestEngine - true - ../../35MSSharedLib1024.snk - true © Microsoft Corporation. All rights reserved. true 1.0 - + + + true + true + ../../35MSSharedLib1024.snk + + + + false + + - - - + + + + + + - + - + + diff --git a/src/Microsoft.PowerApps.TestEngine/Modules/TestEngineExtensionChecker.cs b/src/Microsoft.PowerApps.TestEngine/Modules/TestEngineExtensionChecker.cs index 81d7c90ab..55836d035 100644 --- a/src/Microsoft.PowerApps.TestEngine/Modules/TestEngineExtensionChecker.cs +++ b/src/Microsoft.PowerApps.TestEngine/Modules/TestEngineExtensionChecker.cs @@ -1,13 +1,24 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +using System.IO; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; using System.Security.Cryptography.X509Certificates; using System.Text; +using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.CSharp; +using ICSharpCode.Decompiler.Metadata; using Microsoft.Extensions.Logging; using Microsoft.PowerApps.TestEngine.Config; using Mono.Cecil; using Mono.Cecil.Cil; using Mono.Cecil.Rocks; +using MethodBody = Mono.Cecil.Cil.MethodBody; +using ModuleDefinition = Mono.Cecil.ModuleDefinition; +using TypeDefinition = Mono.Cecil.TypeDefinition; +using TypeReference = Mono.Cecil.TypeReference; namespace Microsoft.PowerApps.TestEngine.Modules { @@ -231,6 +242,12 @@ public virtual bool Validate(TestSettingExtensions settings, string file) var valid = true; + if (!VerifyContainsValidNamespacePowerFxFunctions(settings, contents)) + { + Logger.LogInformation("Invalid Power FX Namespace"); + valid = false; + } + foreach (var item in found) { // Allow if what was found is shorter and starts with allow value or what was found is a subset of a more specific allow rule @@ -248,14 +265,207 @@ public virtual bool Validate(TestSettingExtensions settings, string file) } } + return valid; } /// - /// Load all the types from the assembly using Intermediate Langiage (IL) mode only + /// Validate that the function only contains PowerFx functions that belong to valid Power Fx namespaces + /// + /// + /// + /// + public bool VerifyContainsValidNamespacePowerFxFunctions(TestSettingExtensions settings, byte[] assembly) + { + var isValid = true; + using (var stream = new MemoryStream(assembly)) + { + stream.Position = 0; + ModuleDefinition module = ModuleDefinition.ReadModule(stream); + + // Get the source code of the assembly as will be used to check Power FX Namespaces + var code = DecompileModuleToCSharp(assembly); + + foreach (TypeDefinition type in module.GetAllTypes()) + { + if (type.BaseType != null && type.BaseType.Name == "ReflectionFunction") + { + var constructors = type.GetConstructors(); + + if (constructors.Count() == 0) + { + Logger.LogInformation($"No constructor defined for {type.Name}. Found {constructors.Count()} expected 1 or more"); + return false; + } + + var constructor = constructors.Where(c => c.HasBody).FirstOrDefault(); + + if (constructor == null) + { + Logger.LogInformation($"No constructor with a body"); + } + + if (!constructor.HasBody) + { + Logger.LogInformation($"No body defined for {type.Name}"); + // Needs body for call to base constructor + return false; + } + + var baseCall = constructor.Body.Instructions.FirstOrDefault(i => i.OpCode == OpCodes.Call && i.Operand is MethodReference && ((MethodReference)i.Operand).Name == ".ctor"); + + if (baseCall == null) + { + Logger.LogInformation($"No base constructor defined for {type.Name}"); + // Unable to find base constructor call + return false; + } + + MethodReference baseConstructor = (MethodReference)baseCall.Operand; + + if (baseConstructor.Parameters.Count() < 2) + { + // Not enough parameters + Logger.LogInformation($"No not enough parameters for {type.Name}"); + return false; + } + + if (baseConstructor.Parameters[0].ParameterType.FullName != "Microsoft.PowerFx.Core.Utils.DPath") + { + // First argument should be Namespace + Logger.LogInformation($"No Power FX Namespace for {type.Name}"); + return false; + } + + // Use the decompiled code to get the values of the base constructor, specifically look for the namespace + var name = GetPowerFxNamespace(type.Name, code); + + if (string.IsNullOrEmpty(name)) + { + // No Power FX Namespace found + Logger.LogInformation($"No Power FX Namespace found for {type.Name}"); + return false; + } + + if (settings.DenyPowerFxNamespaces.Contains(name)) + { + // Deny list match + Logger.LogInformation($"Deny Power FX Namespace {name} for {type.Name}"); + return false; + } + +#if DEBUG + // Add Experimenal namespaes in Debug compile it it has not been added in allow list + if (!settings.AllowPowerFxNamespaces.Contains("Experimental")) + { + settings.AllowPowerFxNamespaces.Add("Experimental"); + } +#endif + + if ((settings.DenyPowerFxNamespaces.Contains("*") && ( + !settings.AllowPowerFxNamespaces.Contains(name) || + (!settings.AllowPowerFxNamespaces.Contains(name) && name != "TestEngine") + ) + )) + { + // Deny wildcard exists only. Could not find match in allow list and name was not reserved name TestEngine + Logger.LogInformation($"Deny Power FX Namespace {name} for {type.Name}"); + return false; + } + + if (!settings.AllowPowerFxNamespaces.Contains(name) && name != "TestEngine") + { + Logger.LogInformation($"Not allow Power FX Namespace {name} for {type.Name}"); + // Not in allow list or the Reserved TestEngine namespace + return false; + } + } + } + } + return isValid; + } + + /// + /// Get the declared Power FX Namespace assigned to a Power FX Reflection function + /// + /// The name of the ReflectionFunction to find + /// The decompiled source code to search + /// The DPath Name that has been declared from the code + private string GetPowerFxNamespace(string name, string code) + { + /* + It is assumed that the code will be formatted like the following examples + + public FooFunction() + : base(DPath.Root.Append(new DName("Foo")), "Foo", FormulaType.Blank) { + } + + or + + public OtherFunction(int start) + : base(DPath.Root.Append(new DName("Other")), "Foo", FormulaType.Blank) { + } + + */ + + var lines = code.Split('\n').ToList(); + + var match = lines.Where(l => l.Contains($"public {name}(")).FirstOrDefault(); + + if (match == null) + { + return String.Empty; + } + + var index = lines.IndexOf(match); + + // Search for a DName that is Appended to the Root path as functions should be in a Power FX Namespace not the Root + var baseDeclaration = "base(DPath.Root.Append(new DName(\""; + + // Search for the DName + var declaration = lines[index + 1].IndexOf(baseDeclaration); + + if (declaration >= 0) + { + // Found a match + var start = declaration + baseDeclaration.Length; + var end = lines[index + 1].IndexOf("\"", start); + // Extract the Power FX Namespace argument from the declaration + return lines[index + 1].Substring(declaration + baseDeclaration.Length, end - start); + } + + return String.Empty; + } + + private string DecompileModuleToCSharp(byte[] assembly) + { + var fileName = "module.dll"; + using (var module = new MemoryStream(assembly)) + using (var peFile = new PEFile(fileName, module)) + using (var writer = new StringWriter()) + { + var decompilerSettings = new DecompilerSettings() + { + ThrowOnAssemblyResolveErrors = false, + DecompileMemberBodies = true, + UsingDeclarations = true + }; + decompilerSettings.CSharpFormattingOptions.ConstructorBraceStyle = ICSharpCode.Decompiler.CSharp.OutputVisitor.BraceStyle.EndOfLine; + + var resolver = new UniversalAssemblyResolver(this.GetType().Assembly.Location, decompilerSettings.ThrowOnAssemblyResolveErrors, + peFile.DetectTargetFrameworkId(), peFile.DetectRuntimePack(), + decompilerSettings.LoadInMemory ? PEStreamOptions.PrefetchMetadata : PEStreamOptions.Default, + decompilerSettings.ApplyWindowsRuntimeProjections ? MetadataReaderOptions.ApplyWindowsRuntimeProjections : MetadataReaderOptions.None); + var decompiler = new CSharpDecompiler(peFile, resolver, decompilerSettings); + return decompiler.DecompileWholeModuleAsString(); + } + } + + /// + /// Load all the types from the assembly using Intermediate Language (IL) mode only /// /// The byte representation of the assembly - /// The Dependancies, Types and Method calls found in the assembly + /// The Dependencies, Types and Method calls found in the assembly private List LoadTypes(byte[] assembly) { List found = new List(); diff --git a/src/Microsoft.PowerApps.TestEngine/Modules/TestEngineModuleMEFLoader.cs b/src/Microsoft.PowerApps.TestEngine/Modules/TestEngineModuleMEFLoader.cs index a88a65745..66df489f7 100644 --- a/src/Microsoft.PowerApps.TestEngine/Modules/TestEngineModuleMEFLoader.cs +++ b/src/Microsoft.PowerApps.TestEngine/Modules/TestEngineModuleMEFLoader.cs @@ -48,9 +48,9 @@ public AggregateCatalog LoadModules(TestSettingExtensions settings) // Load MEF exports from this assembly match.Add(new AssemblyCatalog(typeof(TestEngine).Assembly)); - foreach (var sourcelocation in settings.Source.InstallSource) + foreach (var sourceLocation in settings.Source.InstallSource) { - string location = sourcelocation; + string location = sourceLocation; if (settings.Source.EnableNuGet) { var nuGetSettings = Settings.LoadDefaultSettings(null); @@ -78,6 +78,7 @@ public AggregateCatalog LoadModules(TestSettingExtensions settings) { if (!Checker.Validate(settings, file)) { + _logger.LogInformation($"Skipping {file}"); continue; } } diff --git a/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/IsMatchFunction.cs b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/IsMatchFunction.cs new file mode 100644 index 000000000..3e79c2265 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/IsMatchFunction.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Text.RegularExpressions; +using ICSharpCode.Decompiler.CSharp.Syntax.PatternMatching; +using Microsoft.Extensions.Logging; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.PowerApps.TestEngine.PowerFx.Functions +{ + /// + /// The IsMatchFunction class tests whether a text string matches a pattern. + /// The pattern can comprise ordinary characters, predefined patterns, or a regular expression. + /// + public class IsMatchFunction : ReflectionFunction + { + private readonly ILogger _logger; + + public IsMatchFunction(ILogger logger) : base("IsMatch", FormulaType.Number, FormulaType.String, FormulaType.String) + { + _logger = logger; + } + + public BooleanValue Execute(FormulaValue text, StringValue pattern) + { + _logger.LogDebug("------------------------------\n\n" + + "Executing IsMatch function."); + + var textValue = String.Empty; + + if (text is StringValue stringValue) + { + textValue = stringValue.Value; + } + + if (text is BlankValue) + { + return BooleanValue.New(false); + } + + if (text is DateTimeValue dateTimeValue) + { + var utcValue = dateTimeValue.GetConvertedValue(TimeZoneInfo.Utc); + textValue = (utcValue > new DateTime(utcValue.Year, utcValue.Month, utcValue.Day, 0, 0, 0, 0)) ? utcValue.ToString("o") : utcValue.ToString("yyyy-MM-dd"); + } + else if (text.TryGetPrimitiveValue(out var value)) + { + textValue = value.ToString(); + } + + if (string.IsNullOrEmpty(pattern.Value)) + { + return BooleanValue.New(false); + } + + bool isMatch = Regex.IsMatch(textValue, pattern.Value); + return FormulaValue.New(isMatch); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/PowerFx/IPowerFxEngine.cs b/src/Microsoft.PowerApps.TestEngine/PowerFx/IPowerFxEngine.cs index ce8e46ab7..9486b327d 100644 --- a/src/Microsoft.PowerApps.TestEngine/PowerFx/IPowerFxEngine.cs +++ b/src/Microsoft.PowerApps.TestEngine/PowerFx/IPowerFxEngine.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Text.RegularExpressions; using Microsoft.PowerApps.TestEngine.Providers; +using Microsoft.PowerFx; using Microsoft.PowerFx.Types; namespace Microsoft.PowerApps.TestEngine.PowerFx @@ -55,5 +56,10 @@ public interface IPowerFxEngine /// Disables checking Power Apps state checks /// public bool PowerAppIntegrationEnabled { get; set; } + + /// + /// The setup engine instance + /// + public RecalcEngine Engine { get; } } } diff --git a/src/Microsoft.PowerApps.TestEngine/PowerFx/PowerFxEngine.cs b/src/Microsoft.PowerApps.TestEngine/PowerFx/PowerFxEngine.cs index f739eb8f2..6188adfef 100644 --- a/src/Microsoft.PowerApps.TestEngine/PowerFx/PowerFxEngine.cs +++ b/src/Microsoft.PowerApps.TestEngine/PowerFx/PowerFxEngine.cs @@ -26,7 +26,7 @@ public class PowerFxEngine : IPowerFxEngine private readonly ITestState TestState; private int _retryLimit = 2; - private RecalcEngine Engine { get; set; } + public RecalcEngine Engine { get; private set; } private ILogger Logger { get { return SingleTestInstanceState.GetLogger(); } } public PowerFxEngine(ITestInfraFunctions testInfraFunctions, @@ -51,9 +51,8 @@ public void Setup() symbols.EnableMutationFunctions(); powerFxConfig.SymbolTable = symbols; - //TODO: Remove + // Enabled to allow ability to set variable and collection state that can be used with providers and as test variables powerFxConfig.EnableSetFunction(); - //TODO: End powerFxConfig.AddFunction(new SelectOneParamFunction(_testWebProvider, async () => await UpdatePowerFxModelAsync(), Logger)); powerFxConfig.AddFunction(new SelectTwoParamsFunction(_testWebProvider, async () => await UpdatePowerFxModelAsync(), Logger)); @@ -62,6 +61,7 @@ public void Setup() powerFxConfig.AddFunction(new AssertWithoutMessageFunction(Logger)); powerFxConfig.AddFunction(new AssertFunction(Logger)); powerFxConfig.AddFunction(new SetPropertyFunction(_testWebProvider, Logger)); + powerFxConfig.AddFunction(new IsMatchFunction(Logger)); var settings = TestState.GetTestSettings(); if (settings != null && settings.ExtensionModules != null && settings.ExtensionModules.Enable) @@ -72,6 +72,7 @@ public void Setup() } foreach (var module in TestState.GetTestEngineModules()) { + module.RegisterPowerFxFunction(powerFxConfig, TestInfraFunctions, _testWebProvider, SingleTestInstanceState, TestState, _fileSystem); } } @@ -86,6 +87,17 @@ public void Setup() WaitRegisterExtensions.RegisterAll(powerFxConfig, TestState.GetTimeout(), Logger); Engine = new RecalcEngine(powerFxConfig); + + var symbolValues = new SymbolValues(powerFxConfig.SymbolTable); + foreach (var val in powerFxConfig.SymbolTable.SymbolNames.ToList()) + { + // TODO + if (powerFxConfig.SymbolTable.TryLookupSlot(val.Name, out ISymbolSlot slot)) + { + Engine.UpdateVariable(val.Name, symbolValues.Get(slot)); + powerFxConfig.SymbolTable.RemoveVariable(val.Name); + } + } } public async Task ExecuteWithRetryAsync(string testSteps, CultureInfo culture) @@ -131,7 +143,7 @@ public FormulaValue Execute(string testSteps, CultureInfo culture) testSteps = testSteps.Remove(0, 1); } - var goStepByStep = false; + var goStepByStep = TestState.ExecuteStepByStep; // Check if the syntax is correct var checkResult = Engine.Check(testSteps, null, GetPowerFxParserOptions(culture)); @@ -147,10 +159,17 @@ public FormulaValue Execute(string testSteps, CultureInfo culture) var splitSteps = PowerFxHelper.ExtractFormulasSeparatedByChainingOperator(Engine, checkResult, culture); FormulaValue result = FormulaValue.NewBlank(); + int stepNumber = 0; + foreach (var step in splitSteps) { + TestState.OnBeforeTestStepExecuted(new TestStepEventArgs { TestStep = step, StepNumber = stepNumber, Engine = Engine }); + Logger.LogTrace($"Attempting:{step.Replace("\n", "").Replace("\r", "")}"); result = Engine.Eval(step, null, new ParserOptions() { AllowsSideEffects = true, Culture = culture, NumberIsFloat = true }); + + TestState.OnAfterTestStepExecuted(new TestStepEventArgs { TestStep = step, Result = result, StepNumber = stepNumber, Engine = Engine }); + stepNumber++; } return result; } @@ -158,7 +177,10 @@ public FormulaValue Execute(string testSteps, CultureInfo culture) { var values = new SymbolValues(); Logger.LogTrace($"Attempting:\n\n{{\n{testSteps}}}"); - return Engine.Eval(testSteps, null, new ParserOptions() { AllowsSideEffects = true, Culture = culture, NumberIsFloat = true }); + TestState.OnBeforeTestStepExecuted(new TestStepEventArgs { TestStep = testSteps, StepNumber = null, Engine = Engine }); + var result = Engine.Eval(testSteps, null, new ParserOptions() { AllowsSideEffects = true, Culture = culture, NumberIsFloat = true }); + TestState.OnAfterTestStepExecuted(new TestStepEventArgs { TestStep = testSteps, Result = result, StepNumber = 1 }); + return result; } } @@ -188,6 +210,8 @@ private static ParserOptions GetPowerFxParserOptions(CultureInfo culture) { // Currently support for decimal is in progress for PowerApps // Power Fx by default treats number as decimal. Hence setting NumberIsFloat config to true in our case + + // TODO: Evuate culture evaluate across languages return new ParserOptions() { AllowsSideEffects = true, Culture = culture, NumberIsFloat = true }; } diff --git a/src/Microsoft.PowerApps.TestEngine/Reporting/TestLog.cs b/src/Microsoft.PowerApps.TestEngine/Reporting/TestLog.cs index 391ee2bdc..2fd282ff2 100644 --- a/src/Microsoft.PowerApps.TestEngine/Reporting/TestLog.cs +++ b/src/Microsoft.PowerApps.TestEngine/Reporting/TestLog.cs @@ -8,6 +8,28 @@ namespace Microsoft.PowerApps.TestEngine.Reporting { public class TestLog { + private Func _timeStamper = () => DateTime.Now; + public Func TimeStamper + { + get + { + return _timeStamper; + } + + set + { + _timeStamper = value; + When = _timeStamper(); + } + } + + public TestLog() + { + When = TimeStamper(); + } + + public DateTime When { get; private set; } + public string ScopeFilter { get; set; } public string LogMessage { get; set; } } diff --git a/src/Microsoft.PowerApps.TestEngine/Reporting/TestLogger.cs b/src/Microsoft.PowerApps.TestEngine/Reporting/TestLogger.cs index e4b0f5153..e222337c3 100644 --- a/src/Microsoft.PowerApps.TestEngine/Reporting/TestLogger.cs +++ b/src/Microsoft.PowerApps.TestEngine/Reporting/TestLogger.cs @@ -17,9 +17,12 @@ public class TestLogger : ITestLogger public List DebugLogs { get; set; } = new List(); private TestLoggerScope currentScope = null; + public Func TimeStamper { get; set; } + public TestLogger(IFileSystem fileSystem) { _fileSystem = fileSystem; + TimeStamper = new TestLog().TimeStamper; } public IDisposable BeginScope(TState state) @@ -95,15 +98,15 @@ public void Log(LogLevel messageLevel, EventId eventId, TState state, Ex } } - logString += $"{formatter(state, exception)}{Environment.NewLine}"; + logString += $"{TimeStamper().ToString("o")} - {formatter(state, exception)}{Environment.NewLine}"; var scopeFilter = currentScope != null ? currentScope.GetScopeString() : ""; if (messageLevel > LogLevel.Debug) { - Logs.Add(new TestLog() { LogMessage = logString, ScopeFilter = scopeFilter }); + Logs.Add(new TestLog() { TimeStamper = TimeStamper, LogMessage = logString, ScopeFilter = scopeFilter }); } - DebugLogs.Add(new TestLog() { LogMessage = logString, ScopeFilter = scopeFilter }); + DebugLogs.Add(new TestLog() { TimeStamper = TimeStamper, LogMessage = logString, ScopeFilter = scopeFilter }); } } } diff --git a/src/Microsoft.PowerApps.TestEngine/SingleTestRunner.cs b/src/Microsoft.PowerApps.TestEngine/SingleTestRunner.cs index 1107e9d99..0fd0f23b1 100644 --- a/src/Microsoft.PowerApps.TestEngine/SingleTestRunner.cs +++ b/src/Microsoft.PowerApps.TestEngine/SingleTestRunner.cs @@ -11,6 +11,7 @@ using Microsoft.PowerApps.TestEngine.System; using Microsoft.PowerApps.TestEngine.TestInfra; using Microsoft.PowerApps.TestEngine.Users; +using Microsoft.PowerFx.Types; using Newtonsoft.Json; namespace Microsoft.PowerApps.TestEngine @@ -98,12 +99,15 @@ public async Task RunTestAsync(string testRunId, string testRunDirectory, TestSu var testResultDirectory = Path.Combine(testRunDirectory, $"{_fileSystem.RemoveInvalidFileNameChars(testSuiteName)}_{browserConfigName}_{testSuiteId.Substring(0, 6)}"); TestState.SetTestResultsDirectory(testResultDirectory); - casesTotal = TestState.GetTestSuiteDefinition().TestCases.Count(); + var testSuite = TestState.GetTestSuiteDefinition(); + + casesTotal = testSuite.TestCases.Count(); // Number of total cases are recorded and also initialize the passed cases to 0 for this test run _eventHandler.SetAndInitializeCounters(casesTotal); string suiteException = null; + TestRecorder record = null; try { @@ -128,19 +132,83 @@ public async Task RunTestAsync(string testRunId, string testRunDirectory, TestSu _eventHandler.SuiteBegin(testSuiteName, testRunDirectory, browserConfigName, desiredUrl); + MicrosoftEntraNetworkMonitor monitor = null; + if (Logger.IsEnabled(LogLevel.Debug) || Logger.IsEnabled(LogLevel.Trace)) + { + // Enable logging + monitor = new MicrosoftEntraNetworkMonitor(Logger, TestInfraFunctions.GetContext(), _state); + await monitor.MonitorEntraLoginAsync(desiredUrl); + await monitor.LogCookies(desiredUrl); + } + // Navigate to test url await TestInfraFunctions.GoToUrlAsync(desiredUrl); - Logger.LogInformation("Successfully navigated to target URL"); + Logger.LogInformation("After navigate to target URL"); _testReporter.TestRunAppURL = desiredUrl; - // Log in user await _userManager.LoginAsUserAsync(desiredUrl, TestInfraFunctions.GetContext(), _state, TestState, _environmentVariable, _userManagerLoginType); + if (Logger.IsEnabled(LogLevel.Debug) || Logger.IsEnabled(LogLevel.Trace)) + { + Logger.LogDebug("After desired login found"); + await monitor.LogCookies(desiredUrl); + } + // Set up Power Fx _powerFxEngine.Setup(); - await _powerFxEngine.RunRequirementsCheckAsync(); - await _powerFxEngine.UpdatePowerFxModelAsync(); + + var foundErrorState = false; + if (_userManager is IConfigurableUserManager configurableUserManager) + { + foreach (var error in configurableUserManager.Settings.Keys.Where(k => k.StartsWith("Error"))) + { + foundErrorState = true; + _powerFxEngine.Engine.UpdateVariable(error, FormulaValue.New(configurableUserManager.Settings[error].ToString())); + } + } + + if (foundErrorState) + { + try + { + // Attempt the setup, it could fail as we detected some kind of error state from the login provider + await _powerFxEngine.RunRequirementsCheckAsync(); + await _powerFxEngine.UpdatePowerFxModelAsync(); + } + catch (Exception ex) + { + // That failed warn that faild but allow to continue so that can perform negative tests + // For example the test could fail because the user is Unlicensed or the app is not shared with test user persona + + // In the example of a canvas application Power Fx test steps like the following could be added for error dialog + // Assert(ErrorDialogTitle="Start a Power Apps trial?") + Logger.LogError(ex, "Found error setting up initial provider state"); + Logger.LogInformation("Error found during login, proceeding wuth test"); + } + } + else + { + // Run the setup assume that should be in working state, if not fail the test + await _powerFxEngine.RunRequirementsCheckAsync(); + await _powerFxEngine.UpdatePowerFxModelAsync(); + } + + if (testSuite.RecordMode) + { + // Start the recorder before the provider started + record = new TestRecorder(Logger, TestInfraFunctions.GetContext(), _state, TestInfraFunctions, _powerFxEngine, _fileSystem); + + // TODO: Consider settings to determine type of recording to include + record.RegisterTestEngineApi(); + record.SetupHttpMonitoring(); + record.SetupMouseMonitoring(); + await record.SetupAudioRecording(testResultDirectory); + + Logger.LogInformation("Record your test case and press play in the inspector to finish"); + await TestInfraFunctions.Page.PauseAsync(); + } + // Set up network request mocking if any await TestInfraFunctions.SetupNetworkRequestMockAsync(); @@ -201,6 +269,8 @@ public async Task RunTestAsync(string testRunId, string testRunDirectory, TestSu } finally { + + if (TestLoggerProvider.TestLoggers.ContainsKey(testSuiteId)) { var testLogger = TestLoggerProvider.TestLoggers[testSuiteId]; @@ -263,13 +333,25 @@ public async Task RunTestAsync(string testRunId, string testRunDirectory, TestSu if (allTestsSkipped) { // Run test case one by one, mark it as failed - foreach (var testCase in TestState.GetTestSuiteDefinition().TestCases) + foreach (var testCase in testSuite.TestCases) { var testId = _testReporter.CreateTest(testRunId, testSuiteId, $"{testCase.TestCaseName}"); _testReporter.FailTest(testRunId, testId); } } + try + { + if (testSuite.RecordMode && record != null) + { + record.Generate(testResultDirectory); + } + } + catch (Exception ex) + { + Logger.LogError(ex.ToString(), "Unable to generate test results"); + } + string summaryString = $"\nTest suite summary\nTotal cases: {casesTotal}" + $"\nCases passed: {casesPass}" + $"\nCases failed: {(casesTotal - casesPass)}"; diff --git a/src/Microsoft.PowerApps.TestEngine/System/FileSystem.cs b/src/Microsoft.PowerApps.TestEngine/System/FileSystem.cs index 609c3d7e5..eb43626ec 100644 --- a/src/Microsoft.PowerApps.TestEngine/System/FileSystem.cs +++ b/src/Microsoft.PowerApps.TestEngine/System/FileSystem.cs @@ -81,6 +81,11 @@ public string ReadAllText(string filePath) public string RemoveInvalidFileNameChars(string fileName) { return Path.GetInvalidFileNameChars().Aggregate(fileName, (current, c) => current.Replace(c.ToString(), string.Empty)); - } + } + + public void WriteFile(string filePath, byte[] data) + { + File.WriteAllBytes(filePath, data); + } } } diff --git a/src/Microsoft.PowerApps.TestEngine/System/IFileSystem.cs b/src/Microsoft.PowerApps.TestEngine/System/IFileSystem.cs index 9691a7355..33aa3064f 100644 --- a/src/Microsoft.PowerApps.TestEngine/System/IFileSystem.cs +++ b/src/Microsoft.PowerApps.TestEngine/System/IFileSystem.cs @@ -67,5 +67,13 @@ public interface IFileSystem /// File name /// File name with all valid characters public string RemoveInvalidFileNameChars(string fileName); + + /// + /// Writes a binary file to the file system imlementation + /// + /// The name of the file to create + /// The data to write + /// + public void WriteFile(string filePath, byte[] data); } } diff --git a/src/Microsoft.PowerApps.TestEngine/System/UriRedactionFormatter.cs b/src/Microsoft.PowerApps.TestEngine/System/UriRedactionFormatter.cs new file mode 100644 index 000000000..ab7c96881 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/System/UriRedactionFormatter.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Microsoft.PowerApps.TestEngine.System +{ + public class UriRedactionFormatter + { + ILogger _logger; + + public UriRedactionFormatter(ILogger logger) + { + _logger = logger; + } + public string ToString(Uri uri) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + return uri.ToString(); + } + + return "[URI REDACTED]"; + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/TestEngine.cs b/src/Microsoft.PowerApps.TestEngine/TestEngine.cs index 313be2aa7..01ee3430a 100644 --- a/src/Microsoft.PowerApps.TestEngine/TestEngine.cs +++ b/src/Microsoft.PowerApps.TestEngine/TestEngine.cs @@ -29,6 +29,8 @@ public class TestEngine public ILogger Logger { get; set; } + public Func Timestamper { get; set; } + public TestEngine(ITestState state, IServiceProvider serviceProvider, ITestReporter testReporter, @@ -42,6 +44,7 @@ public TestEngine(ITestState state, _fileSystem = fileSystem; _loggerFactory = loggerFactory; _eventHandler = eventHandler; + Timestamper = () => DateTime.UtcNow; } /// @@ -90,11 +93,6 @@ public async Task RunTestAsync(FileInfo testConfigFile, string environme throw new ArgumentNullException(nameof(outputDirectory)); } - if (string.IsNullOrEmpty(domain)) - { - throw new ArgumentNullException(nameof(domain)); - } - if (string.IsNullOrEmpty(queryParams)) { Logger.LogDebug($"Using no additional query parameters."); @@ -118,7 +116,11 @@ public async Task RunTestAsync(FileInfo testConfigFile, string environme _state.SetTestConfigFile(testConfigFile); - testRunDirectory = Path.Combine(_state.GetOutputDirectory(), testRunId.Substring(0, 6)); + var now = Timestamper().ToString("o") + .Replace(":", "-") + .Replace(".", "-"); + + testRunDirectory = Path.Combine(_state.GetOutputDirectory(), now + "-" + testRunId.Substring(0, 6)); _fileSystem.CreateDirectory(testRunDirectory); Logger.LogInformation($"Test results will be stored in: {testRunDirectory}"); diff --git a/src/Microsoft.PowerApps.TestEngine/TestInfra/MicrosoftEntraNetworkMonitor.cs b/src/Microsoft.PowerApps.TestEngine/TestInfra/MicrosoftEntraNetworkMonitor.cs new file mode 100644 index 000000000..7d795feb9 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/TestInfra/MicrosoftEntraNetworkMonitor.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using Microsoft.PowerApps.TestEngine.Config; +using Microsoft.PowerApps.TestEngine.System; + +namespace Microsoft.PowerApps.TestEngine.TestInfra +{ + /// + /// Infrastructure monitoring class that can be applied to help diagnose login issues by monitoring request response from Microsoft Entra + /// + public class MicrosoftEntraNetworkMonitor + { + private readonly ILogger _logger; + private readonly IBrowserContext _browserContext; + private readonly ITestState _testState; + private readonly UriRedactionFormatter _uriRedactionFormatter; + + private readonly string[] _loginServices = new[] + { + "login.microsoftonline.com", + "login.microsoftonline.us", + "login.chinacloudapi.cn", + "login.microsoftonline.de" + }; + + public MicrosoftEntraNetworkMonitor(ILogger logger, IBrowserContext browserContext, ITestState testState) + { + _logger = logger; + _browserContext = browserContext; + _uriRedactionFormatter = new UriRedactionFormatter(logger); + _testState = testState; + } + + public async Task MonitorEntraLoginAsync(string desiredUrl) + { + var hostName = new Uri(desiredUrl).Host; + await _browserContext.RouteAsync($"https://{hostName}/**", async route => + { + var request = route.Request; + var routeUri = new Uri(request.Url); + _logger.LogDebug("Start request: {Method} {Url}", route.Request.Method, _uriRedactionFormatter.ToString(routeUri)); + + await route.ContinueAsync(); + }); + + foreach (var service in _loginServices) + { + // Listen for all requests made + await _browserContext.RouteAsync($"https://{service}/**", async route => + { + var request = route.Request; + var routeUri = new Uri(request.Url); + if (!_loginServices.Contains(routeUri.Host)) + { + await route.ContinueAsync(); + } + + _logger.LogDebug("Start request: {Method} {Url}", request.Method, _uriRedactionFormatter.ToString(routeUri)); + + await route.ContinueAsync(); + }); + } + + // Listen for requests to be finished + _browserContext.RequestFinished += async (s, e) => await _browserContext_RequestFinished(s, e, desiredUrl); + } + + public async Task LogCookies(string desiredUrl) + { + + var hostName = ""; + if (!string.IsNullOrEmpty(desiredUrl)) + { + hostName = new Uri(desiredUrl).Host; + } + else + { + var domain = _testState.GetDomain(); + if (!string.IsNullOrEmpty(domain) && Uri.TryCreate(domain, UriKind.Absolute, out Uri match)) + { + hostName = match.Host; + } + } + + var cookies = await _browserContext.CookiesAsync(); + if (cookies != null) + { + // Get any cookies for Entra related sites or the desired url + foreach (var cookie in cookies + .Where(c => _loginServices.Any(service => c.Name.Contains(service)) || c.Name.Contains(hostName)) + .OrderBy(c => c.Domain) + .ThenBy(c => c.Name)) + { + var expires = DateTimeOffset.FromUnixTimeSeconds((long)cookie.Expires); + _logger.LogDebug($"Domain {cookie.Domain}, Cookie: {cookie.Name}, Secure {cookie.Secure}, Expires {expires}"); + } + } + } + + private async Task _browserContext_RequestFinished(object sender, IRequest e, string requestUrl) + { + var requestHost = new Uri(e.Url).Host; + var requestHash = CreateMD5(e.Url); + // Only listen for login services + if (_loginServices.Any(service => requestHost.Contains(service)) || new Uri(requestUrl).Host == requestHost) + { + var response = await e.ResponseAsync(); + _logger.LogDebug($"Login request [{requestHash}]: {e.Method} {_uriRedactionFormatter.ToString(new Uri(e.Url))}"); + _logger.LogDebug($"Login response status [{requestHash}]: {response.Status} ({response.StatusText})"); + + switch (response.Status) + { + case 302: // Redirect + foreach (var header in response.Headers) + { + _logger.LogTrace($"Cookie [{requestHash}] {header.Key} = {header.Value}"); + } + break; + } + + if (e.RedirectedFrom != null) + { + _logger.LogDebug($"Login redirect from [{requestHash}]: {e.RedirectedFrom.Method} {_uriRedactionFormatter.ToString(new Uri(e.RedirectedFrom.Url))}"); + } + + if (e.RedirectedTo != null) + { + _logger.LogDebug($"Login redirect to [{requestHash}]: {e.RedirectedTo.Method} {_uriRedactionFormatter.ToString(new Uri(e.RedirectedTo.Url))}"); + } + + if (_logger.IsEnabled(LogLevel.Trace)) + { + await LogCookies(String.Empty); + } + } + } + + public static string CreateMD5(string input) + { + using (MD5 md5 = MD5.Create()) + { + byte[] inputBytes = Encoding.ASCII.GetBytes(input); + byte[] hashBytes = md5.ComputeHash(inputBytes); + + // Convert the byte array to a hexadecimal string + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < hashBytes.Length; i++) + { + sb.Append(hashBytes[i].ToString("X2")); + } + return sb.ToString(); + } + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/TestInfra/PlaywrightTestInfraFunctions.cs b/src/Microsoft.PowerApps.TestEngine/TestInfra/PlaywrightTestInfraFunctions.cs index 7e6afea27..717a5cce5 100644 --- a/src/Microsoft.PowerApps.TestEngine/TestInfra/PlaywrightTestInfraFunctions.cs +++ b/src/Microsoft.PowerApps.TestEngine/TestInfra/PlaywrightTestInfraFunctions.cs @@ -3,13 +3,20 @@ 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; +using NuGet.Common; +using YamlDotNet.Core.Tokens; namespace Microsoft.PowerApps.TestEngine.TestInfra { @@ -22,23 +29,25 @@ public class PlaywrightTestInfraFunctions : ITestInfraFunctions private readonly ISingleTestInstanceState _singleTestInstanceState; private readonly IFileSystem _fileSystem; private readonly ITestWebProvider _testWebProvider; + private readonly IEnvironmentVariable _environmentVariable; 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) + public PlaywrightTestInfraFunctions(ITestState testState, ISingleTestInstanceState singleTestInstanceState, IFileSystem fileSystem, ITestWebProvider testWebProvider, IEnvironmentVariable environmentVariable) { _testState = testState; _singleTestInstanceState = singleTestInstanceState; _fileSystem = fileSystem; _testWebProvider = testWebProvider; + _environmentVariable = environmentVariable; } // 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) : this(testState, singleTestInstanceState, fileSystem, testWebProvider) + IPlaywright playwrightObject = null, IBrowserContext browserContext = null, IPage page = null, ITestWebProvider testWebProvider = null, IEnvironmentVariable environmentVariable = null) : this(testState, singleTestInstanceState, fileSystem, testWebProvider, environmentVariable) { PlaywrightObject = playwrightObject; Page = page; @@ -112,6 +121,11 @@ public async Task SetupAsync(IUserManager userManager) 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]; @@ -145,6 +159,56 @@ public async Task SetupAsync(IUserManager userManager) } } + if (userManager is IConfigurableUserManager configurableUserManager) + { + // Add file state as user manager may need access to file system + configurableUserManager.Settings.Add("FileSystem", _fileSystem); + + if (configurableUserManager.Settings.ContainsKey("LoadState") + && configurableUserManager.Settings["LoadState"] is Func 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) { _fileSystem.CreateDirectory(userManager.Location); @@ -153,7 +217,6 @@ public async Task SetupAsync(IUserManager userManager) { location = Path.Combine(Directory.GetCurrentDirectory(), location); } - _singleTestInstanceState.GetLogger().LogInformation($"Using static context in '{location}' using {userManager.Name}"); // Check if a channel has been specified if (!string.IsNullOrEmpty(browserConfig.Channel)) @@ -169,10 +232,8 @@ public async Task SetupAsync(IUserManager userManager) _singleTestInstanceState.GetLogger().LogInformation("Browser context created"); } - public async Task SetupNetworkRequestMockAsync() { - var mocks = _singleTestInstanceState.GetTestSuiteDefinition().NetworkRequestMocks; if (mocks == null || mocks.Count == 0) diff --git a/src/Microsoft.PowerApps.TestEngine/TestInfra/TestRecorder.cs b/src/Microsoft.PowerApps.TestEngine/TestInfra/TestRecorder.cs new file mode 100644 index 000000000..ba1f95122 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/TestInfra/TestRecorder.cs @@ -0,0 +1,1062 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; +using System.Web; +using Microsoft.Data.Edm.Library; +using Microsoft.Data.OData.Query; +using Microsoft.Data.OData.Query.SemanticAst; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using Microsoft.PowerApps.TestEngine.Config; +using Microsoft.PowerApps.TestEngine.PowerFx; +using Microsoft.PowerApps.TestEngine.System; +using Microsoft.PowerFx.Types; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.PowerApps.TestEngine.TestInfra +{ + /// + /// The TestRecorder class is designed to generate and record test steps for the current test session. + /// This includes network interaction for Dataverse and Connectors, as well as user interaction via Mouse. + /// + ///Future support for Keyboard recording could be considered + public class TestRecorder + { + private readonly ILogger _logger; + private readonly IBrowserContext _browserContext; + private readonly ITestState _testState; + private readonly ITestInfraFunctions _infra; + private readonly IPowerFxEngine _engine; + private readonly IFileSystem _fileSystem; + private readonly StringBuilder _textBuilder; + private string _audioPath = string.Empty; + + public ConcurrentBag TestSteps = new ConcurrentBag(); + + /// + /// Initializes a new instance of the TestRecorder class. + /// + ///The logger instance for logging information. + ///The browser context for Playwright interactions. + ///The current test state. + ///The infrastructure functions providing access to the current page. + ///The Power Fx engine representing the current test state of controls, properties, variables, and collections. + ///The file system interface for interacting with the file system. + public TestRecorder(ILogger logger, IBrowserContext browserContext, ITestState testState, ITestInfraFunctions infra, IPowerFxEngine powerFxEngine, IFileSystem fileSystem) + { + _logger = logger; + _browserContext = browserContext; + _testState = testState; + _infra = infra; + _engine = powerFxEngine; + _fileSystem = fileSystem; + _textBuilder = new StringBuilder(); + } + + /// + /// Sets up the TestRecorder by subscribing to browser HTTP Requests + /// + public void SetupHttpMonitoring() + { + _browserContext.Response += OnResponse; + } + + public async Task SetupAudioRecording(string audioPath) + { + var feedbackHost = new Uri(_testState.GetDomain()).Host; + + _audioPath = audioPath; + + var recordingJavaScript = @" +document.addEventListener('keydown', (event) => {{ +if (event.ctrlKey && event.key === 'r') {{ + event.preventDefault(); + // Create a dialog box + const dialog = document.createElement('div'); + dialog.innerHTML = ` + +
+ + + +

+ +
+ `; + document.body.appendChild(dialog); + + // Get buttons, audio element, and feedback element + const startButton = document.getElementById('startRecording'); + const stopButton = document.getElementById('stopRecording'); + const audioPlayback = document.getElementById('audioPlayback'); + const feedback = document.getElementById('feedback'); + const closeButton = document.getElementById('closeDialog'); + + let mediaRecorder; + let audioChunks = []; + + // Function to start recording + startButton.addEventListener('click', async () => {{ + // Request access to the microphone + const stream = await navigator.mediaDevices.getUserMedia({{ audio: true }}); + mediaRecorder = new MediaRecorder(stream); + + // Start recording + mediaRecorder.start(); + startButton.disabled = true; + stopButton.disabled = false; + feedback.textContent = 'Recording...'; + + // Collect audio data + mediaRecorder.addEventListener('dataavailable', event => {{ + audioChunks.push(event.data); + }}); + + document.TestEngineAudioSessionId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {{ + const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }}); + + fetch('https://{0}/testengine/audio/start', {{ + method: 'POST', + body: JSON.stringify({{ 'startDateTime': new Date().toISOString(), 'audioSessionId': document.TestEngineAudioSessionId }}), + headers: {{ + 'Content-Type': 'application/json' + }} + }}); + + // When recording stops, create an audio file + mediaRecorder.addEventListener('stop', () => {{ + const audioBlob = new Blob(audioChunks, {{ type: 'audio/wav' }}); + const audioUrl = URL.createObjectURL(audioBlob); + audioPlayback.src = audioUrl; + + // Post the recorded audio to an API + fetch('https://{0}/testengine/audio/upload', {{ + method: 'POST', + body: audioBlob, + headers: {{ + 'Content-Type': 'audio/wav', + 'endDateTime': new Date().toISOString(), + 'audioSessionId': document.TestEngineAudioSessionId + }} + }}).then(response => {{ + if (response.ok) {{ + feedback.textContent = 'Audio uploaded successfully!'; + }} else {{ + feedback.textContent = 'Failed to upload audio.'; + }} + }}).catch(error => {{ + feedback.textContent = 'Error uploading audio: ' + error; + }}); + }}); + }}); + + // Function to stop recording + stopButton.addEventListener('click', () => {{ + mediaRecorder.stop(); + startButton.disabled = false; + stopButton.disabled = true; + feedback.textContent = 'Recording stopped. Uploading audio...'; + }}); + + // Function to close the dialog + closeButton.addEventListener('click', () => {{ + document.body.removeChild(dialog); + }}); + }} +}}); +"; + + await _infra.Page.EvaluateAsync(string.Format(recordingJavaScript, feedbackHost)); + + // Add recording if page is reloaded + _infra.Page.Load += async (object sender, IPage e) => + { + await _infra.Page.EvaluateAsync(string.Format(recordingJavaScript, feedbackHost)); + }; + } + + /// + /// Sets up the TestRecorder by subscribing to page mouse events. + /// + public void SetupMouseMonitoring() + { + var page = _infra.Page; + + var feedbackUrl = new Uri(new Uri($"https://{new Uri(_testState.GetDomain()).Host}"), new Uri("testengine", UriKind.Relative)); + + AddClickListener(_infra.Page, feedbackUrl).Wait(); + + // Add handler to listen if page reloaded to add the mouse monitoring + _infra.Page.Load += (object sender, IPage e) => + { + AddClickListener(_infra.Page, feedbackUrl).Wait(); + }; + + //TODO: Subscribe to keyboard events from the page. This will need to consider focus changes and how get value for SetProperty() based on control type + } + + /// + /// Sets up the TestRecorder API + /// + public void RegisterTestEngineApi() + { + var feedbackUrl = new Uri(new Uri($"https://{new Uri(_testState.GetDomain()).Host}"), new Uri("testengine", UriKind.Relative)); + + // Intercept ALL calls for testengine feedback for recording + _browserContext.RouteAsync($"{feedbackUrl.ToString()}/**", (IRoute route) => HandleTestEngineData(route)); + } + + /// + /// Handle callback from HTTP request sent from browser to test enging + /// + /// The request that has been intercepted + /// New response to the browser + private async Task HandleTestEngineData(IRoute route) + { + if (route.Request.Url.Contains("/audio/start") && route.Request.Method == "POST") + { + // Read the posted file + JsonSerializerSettings settings = new JsonSerializerSettings + { + DateFormatString = "yyyy-MM-ddTHH:mm:ssZ", + DateTimeZoneHandling = DateTimeZoneHandling.Utc + }; + var audioContext = JsonConvert.DeserializeObject>(route.Request.PostData, settings); + + var started = String.Empty; + var audioId = String.Empty; + + if (audioContext.ContainsKey("startDateTime") && audioContext["startDateTime"] is DateTime startDateTime) + { + started = startDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ"); + } + + if (audioContext.ContainsKey("audioSessionId")) + { + audioId = audioContext["audioSessionId"].ToString(); + } + + TestSteps.Add($"// Audio started - {started} - {audioId}"); + } + + if (route.Request.Url.Contains("/audio/upload")) + { + var headers = route.Request.Headers; + var ended = String.Empty; + var audioId = String.Empty; + if (headers.ContainsKey("endDateTime".ToLower())) + { + ended = headers["endDateTime".ToLower()]; + } + + if (headers.ContainsKey("audioSessionId".ToLower())) + { + audioId = headers["audioSessionId".ToLower()]; + } + + TestSteps.Add($"// Audio end - {ended} - {audioId}"); + + // Read the posted file + + var audioFile = route.Request.PostDataBuffer; + + + if (!_fileSystem.Exists(_audioPath)) + { + _fileSystem.CreateDirectory(_audioPath); + } + + _fileSystem.WriteFile(Path.Combine(_audioPath, $"recording_{DateTime.Now.ToString("yyyyHHmmss")}.wav"), audioFile); + } + + if (route.Request.Url.Contains("/click")) + { + // TODO: handle click for known controls + // TODO: handle click for known like combobox (Or use Keyboard shortcuts to handle differences? + // TODO: handle click for known controls inside gallery or components + // TODO: handle click for controls inside PCF using css selector using Experimental.PlaywrightAction()? + var segments = new Uri(route.Request.Url).AbsolutePath.Split('/'); + if (segments.Length >= 4 + && segments[1].Equals("testengine", StringComparison.OrdinalIgnoreCase) + && segments[2].Equals("click", StringComparison.OrdinalIgnoreCase)) + { + var controlName = segments[3]; + _logger.LogDebug($"Click {controlName}"); + + var data = JsonConvert.DeserializeObject>(route.Request.PostData); + + var text = data.ContainsKey("text") && !String.IsNullOrEmpty(data["text"].ToString()) ? data["text"].ToString() : ""; + var alt = false; + var control = false; + + if (data.ContainsKey("alt") && bool.TryParse(data["alt"].ToString(), out bool altValue)) + { + alt = altValue; + } + + if (data.ContainsKey("control") && bool.TryParse(data["control"].ToString(), out bool controlValue)) + { + control = controlValue; + } + + // TODO: Refactor read Power Fx Template provided for recording session and evaluate templates from the Recording Test Suite + // This will need to consider alt, control values + + // TODO: Consider control names and if need to apply Power Fx [] delimiter + if (alt) + { + // TODO: Handle single quote in the text + TestSteps.Add($"Experimental.PlaywrightAction(\"[data-test-id='{controlName}']:has-text('{text}')\", \"wait\");"); + } + else if (control) + { + TestSteps.Add($"Experimental.WaitUntil({controlName}.Text=\"{text}\");"); + } + else + { + // Assume that the select item is compatible with Select() Power Fx function + TestSteps.Add($"Select({controlName});"); + } + } + } + + // Always send back Status 200 and do not send information to target URL as the request is for recording only + await route.FulfillAsync(new RouteFulfillOptions { Status = 200 }); + } + + /// + /// Listen for clicks on the active page document + /// + /// The page to listen for click events + /// The url to send click summarized event data so testenginge can generate test steps + /// Completed task + private async Task AddClickListener(IPage page, Uri feedbackUrl) + { + // TODO: Handle controls that do not have data-control-name + string listenerJavaScript = String.Format(@"(function() {{ + document.addEventListener('click', function(event) {{ + const element = event.target.closest('[data-control-name]'); + if (element) {{ + const controlName = element.getAttribute('data-control-name'); + const clickData = {{ + controlName: controlName, + x: event.clientX, + y: event.clientY, + text: element.textContent.trim(), + alt: (event.altKey), + control: (event.ctrlKey) + }}; + fetch('{0}/click/' + controlName, {{ + method: 'POST', + headers: {{ + 'Content-Type': 'application/json' + }}, + body: JSON.stringify(clickData) + }}); + }} + }}); + }})();", feedbackUrl); + await page.EvaluateAsync(listenerJavaScript); + } + + /// + /// Handle response to HTTP page that is sent starting work in a new thread not to block execution + /// + /// + /// + private void OnResponse(object sender, IResponse e) + { + TaskCompletionSource tcs = new TaskCompletionSource(); + + Task.Factory.StartNew(async () => + { + try + { + await HandleResponse(e); + tcs.SetResult(true); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + + if (sender is List) + { + var tasks = sender as List; + tasks.Add(tcs.Task); + } + } + + /// + /// Check for responses that need to have handling for Recording test step generation + /// + /// The response to check for matching request/reponse to generate a TestStep + /// Completed task + private async Task HandleResponse(IResponse response) + { + // Check of the request related to a Dataverse connection + if (response.Request.Url.Contains("/api/data/v")) + { + switch (response.Request.Method) + { + case "GET": + var entity = GetODataEntity(response.Request.Url); + var data = await ConvertODataToFormulaValue(response); + // TODO: Check for $filter and convert OData $filter to Filter record and Power Fx expression + TestSteps.Add(GenerateDataverseQuery(entity, data)); + break; + case "POST": + // TODO Handle create + break; + } + } + + // Check for Power Platform connector invocation + if (response.Request.Url.Contains("/invoke") && response.Request.Headers.ContainsKey("x-ms-request-url")) + { + switch (response.Request.Method) + { + case "POST": + var action = GetActionName(response.Request.Headers["x-ms-request-url"]); + var when = GetWhenConnectorValue(response.Request.Headers["x-ms-request-url"]); + var then = await ConvertJsonResultToFormulaValue(response); + TestSteps.Add(GenerateConnector(action, when, then)); + break; + } + } + } + + /// + /// Convert data extracted from HTTP request into Expertimental.SimulateConnection() call + /// + /// The connector that the simulation relates to + /// Paremeters determining when the simulation should apply + /// The table or record to return when a match is found + /// Generated Power Fx function + private string GenerateConnector(string name, FormulaValue when, FormulaValue then) + { + StringBuilder connectorBuilder = new StringBuilder(); + + connectorBuilder.Append($"Experimental.SimulateConnector({{Name: \"{name}\""); + + if (when is RecordValue whenRecord) + { + connectorBuilder.Append(", When: "); + connectorBuilder.Append("{"); + foreach (var field in whenRecord.Fields) + { + connectorBuilder.Append($"{field.Name}: {FormatValue(field.Value)}, "); + } + connectorBuilder.Length -= 2; // Remove the trailing comma and space + connectorBuilder.Append("}, "); + } + else + { + connectorBuilder.Append(", "); + } + + if (then is BlankValue blankThenTable) + { + connectorBuilder.Append("Then: Blank()"); + } + + if (then is TableValue thenTable) + { + connectorBuilder.Append("Then: "); + connectorBuilder.Append("Table("); + + var rowAdded = false; + + foreach (var record in thenTable.Rows) + { + var recordValue = record.Value as RecordValue; + + if (recordValue != null) + { + rowAdded = true; + connectorBuilder.Append("{"); + foreach (var field in recordValue.Fields) + { + connectorBuilder.Append($"{field.Name}: {FormatValue(field.Value)}, "); + } + connectorBuilder.Length -= 2; // Remove the trailing comma and space + connectorBuilder.Append("}, "); + } + } + + if (rowAdded) + { + connectorBuilder.Length -= 2; // Remove the trailing comma and space + } + + connectorBuilder.Append(")"); // Close the table + } + + if (then is RecordValue thenRecord) + { + connectorBuilder.Append("Then: "); + if (thenRecord.Fields.Count() == 0) + { + connectorBuilder.Append("Blank()"); + } + else + { + connectorBuilder.Append("{"); + foreach (var field in thenRecord.Fields) + { + var formattedFieldValue = FormatValue(field.Value); + connectorBuilder.Append($"{field.Name}: {formattedFieldValue}, "); + } + connectorBuilder.Length -= 2; // Remove the trailing comma and space + connectorBuilder.Append("}"); + } + } + + connectorBuilder.Append("});"); // Close record argument and the SimulateConnector function + + return connectorBuilder.ToString(); + } + + /// + /// Extract the Connector action from the url + /// + /// The relative connector url reference + /// The action name + /// + private string GetActionName(string url) + { + var requestUrl = new Uri(new Uri("https://example.com"), new Uri(url, UriKind.Relative)); + + var segments = requestUrl.AbsolutePath.Split('/'); + + // Assuming the entity name is the last segment in the URL and using format /api/data/v9.X/entityname + // The first segment will be empty as has leading / + if (segments.Length >= 3 && segments[1].Equals("apim", StringComparison.OrdinalIgnoreCase)) + { + // TODO: Handle case where requesting connector name vs list using /apim/name/** + return segments[2]; + } + + throw new ArgumentException("Invalid request url"); + } + + + /// + /// Convert the requested action url into Power Fx When record + /// + /// Teh url to be converted + /// The When record that represents the request + private FormulaValue GetWhenConnectorValue(string url) + { + var requestUrl = new Uri(new Uri("https://example.com"), new Uri(url, UriKind.Relative)); + + var segments = requestUrl.AbsolutePath.Split('/'); + + List fields = new List(); + + + var action = String.Empty; + + // Assuming the entity name is the last segment in the URL and using format /api/data/v9.X/entityname + // The first segment will be empty as has leading / + if (segments.Length > 4) + { + // TODO: Handle case where requesting connector name vs list using /apim/name/** + var parts = new List(segments); + parts.RemoveAt(0); // Remove empty item + parts.RemoveAt(0); // Remove apim + parts.RemoveAt(0); // Remove connector name + parts.RemoveAt(0); // Remove connector id + + // Assume the reminaing item is the action + fields.Add(new NamedValue("Action", FormulaValue.New(string.Join("/", parts)))); + } + + if (!string.IsNullOrEmpty(requestUrl.Query)) + { + var items = HttpUtility.ParseQueryString(HttpUtility.UrlDecode(requestUrl.Query)); + foreach (var key in items.AllKeys) + { + switch (key.ToLower()) + { + case "$filter": + string powerFxExpression = ConvertODataToPowerFx(items[key]); + fields.Add(new NamedValue("Filter", FormulaValue.New(powerFxExpression))); + break; + } + } + } + + if (fields.Count() == 0) + { + return RecordValue.NewBlank(); + } + + return RecordValue.NewRecordFromFields(fields); + } + + /// + /// Convert a OData $filter to a Power Fx string expression + /// + /// The filter to be converted + /// Power Fx expression that represents the $filter + string ConvertODataToPowerFx(string odataFilter) + { + // Parse the OData filter without a known EDM model + var filterClause = ParseFilter(odataFilter); + + // Convert the filter clause to Power Fx expression + return ConvertFilterClauseToPowerFx(filterClause.Expression); + } + + /// + /// Parse the odata filter clause and return the Abstract Syntax Tree (AST) representation of the expression + /// + /// The text $filter clause + /// The AST representation of the filter clause + private FilterClause ParseFilter(string odataFilter) + { + EdmModel edmModel = new EdmModel(); + // Define the entity type and set up the EDM model + EdmEntityType entityType = new EdmEntityType("Namespace", "EntityName", null, false, true); + edmModel.AddElement(entityType); + + // Parse the filter + return ODataUriParser.ParseFilter(odataFilter, edmModel, entityType); + } + + /// + /// Convert the AST representation of a OData filter clause to the equivent Power Fx expression + /// + /// An element of the OData AST tree convert + /// Power Fx representation of the AST fragement + /// + string ConvertFilterClauseToPowerFx(SingleValueNode expression) + { + if (expression is BinaryOperatorNode binaryOperatorNode) + { + string left = ConvertFilterClauseToPowerFx(binaryOperatorNode.Left); + string right = ConvertFilterClauseToPowerFx(binaryOperatorNode.Right); + string operatorString = binaryOperatorNode.OperatorKind switch + { + BinaryOperatorKind.Equal => "=", + BinaryOperatorKind.GreaterThan => ">", + BinaryOperatorKind.GreaterThanOrEqual => ">=", + BinaryOperatorKind.LessThan => "<", + BinaryOperatorKind.LessThanOrEqual => "<=", + BinaryOperatorKind.NotEqual => "!=", + BinaryOperatorKind.Multiply => "*", + BinaryOperatorKind.Divide => "/", + BinaryOperatorKind.Modulo => "MOD(", + BinaryOperatorKind.And => "AND(", + BinaryOperatorKind.Or => "OR(", + _ => throw new NotSupportedException($"Operator {binaryOperatorNode.OperatorKind} is not supported") + }; + if (operatorString.Contains("(")) + { + // It is a function + return $"{operatorString}{left},{right})"; + } + else + { + return $"{left} {operatorString} {right}"; + } + + } + else if (expression is UnaryOperatorNode unaryOperatorNode) + { + string operand = ConvertFilterClauseToPowerFx(unaryOperatorNode.Operand); + return $"NOT({operand})"; + } + else if (expression is SingleValuePropertyAccessNode propertyAccessNode) + { + return propertyAccessNode.Property.Name; + } + else if (expression is SingleValueOpenPropertyAccessNode openPropertyAccessNode) + { + return openPropertyAccessNode.Name; + } + else if (expression is ConstantNode constantNode) + { + if (constantNode.Value is string stringValue) + { + // Need to add two quotes as it will be included in a string + return $"\"\"{stringValue}\"\""; + } + return constantNode.Value.ToString(); + } + if (expression is ConvertNode convertNode) + { + return ConvertFilterClauseToPowerFx(convertNode.Source); + } + + throw new NotSupportedException($"Expression type {expression.GetType().Name} is not supported"); + } + + /// + /// Generate a Power Fx Experimental.SimulateDataverse() from extracted HTTP request data + /// + /// The entity that the request relates to + /// The optional data to convert + /// + private string GenerateDataverseQuery(string entity, FormulaValue data) + { + StringBuilder queryBuilder = new StringBuilder(); + + queryBuilder.Append($"Experimental.SimulateDataverse({{Action: \"Query\", Entity: \"{entity}\", Then: "); + + if (data is TableValue tableValue) + { + queryBuilder.Append($"Table("); + + var rowAdded = false; + + foreach (var record in tableValue.Rows) + { + var recordValue = record.Value as RecordValue; + + if (recordValue != null) + { + rowAdded = true; + queryBuilder.Append("{"); + foreach (var field in recordValue.Fields) + { + queryBuilder.Append($"{field.Name}: {FormatValue(field.Value)}, "); + } + queryBuilder.Length -= 2; // Remove the trailing comma and space + queryBuilder.Append("}, "); + } + } + + if (rowAdded) + { + queryBuilder.Length -= 2; // Remove the trailing comma and space + } + + queryBuilder.Append(")"); // Close the table + } + else if (data is RecordValue recordValue) + { + queryBuilder.Append("{"); + foreach (var field in recordValue.Fields) + { + queryBuilder.Append($"{field.Name}: {FormatValue(field.Value)}, "); + } + queryBuilder.Length -= 2; // Remove the trailing comma and space + queryBuilder.Append("}"); + } + else + { + queryBuilder.Append(FormatValue(data)); + } + + queryBuilder.Append("});"); // Close record argument and the SimulateDataverse dataverse function + + return queryBuilder.ToString(); + } + + /// + /// Convert Power Fx formula value to the string representation + /// + /// The vaue to convert + /// + /// + private string FormatValue(FormulaValue value) + { + //TODO: Handle special case of DateTime As unix time to DateTime + return value switch + { + BlankValue blankValue => "Blank()", + StringValue stringValue => $"\"{stringValue.Value}\"", + NumberValue numberValue => numberValue.Value.ToString(), + BooleanValue booleanValue => booleanValue.Value.ToString().ToLower(), + // Assume all dates should be in UTC + DateValue dateValue => dateValue.GetConvertedValue(TimeZoneInfo.Utc).ToString("o"), // ISO 8601 format + DateTimeValue dateTimeValue => dateTimeValue.GetConvertedValue(TimeZoneInfo.Utc).ToString("o"), // ISO 8601 format + RecordValue recordValue => FormatRecordValue(recordValue), + TableValue tableValue => FormatTableValue(tableValue), + _ => throw new ArgumentException("Unsupported FormulaValue type") + }; + } + + /// + /// Convert a Power Fx object to String Representation of the Record + /// + /// The record to be converted + /// Power Fx representation + private string FormatRecordValue(RecordValue recordValue) + { + var fields = recordValue.Fields.Select(field => $"{field.Name}: {FormatValue(field.Value)}"); + return $"{{{string.Join(", ", fields)}}}"; + } + + /// + /// Convert the Power Fx table into string representation + /// + /// The table to be converted + /// The string representation of all rows of the table + private string FormatTableValue(TableValue tableValue) + { + var rows = tableValue.Rows.Select(row => FormatValue(row.Value)); + return $"Table({string.Join(", ", rows)})"; + } + + /// + /// Convert OData response to Power Fx Value + /// + /// The HTTP reponse to read Json response from + /// + private async Task ConvertODataToFormulaValue(IResponse response) + { + // Read the JSON content from the response + var jsonString = await response.JsonAsync(); + var json = jsonString.ToString(); + var jsonObject = JObject.Parse(json); + + if (jsonObject.ContainsKey("value")) + { + return await ConvertJsonToFormulaValue(jsonObject["value"]); + } + return await ConvertJsonToFormulaValue(jsonObject); + } + + /// + /// Convert the Json body of the reponse to Power Fx formula + /// + /// The HTTP reponse to read Json response from + /// The mapped formula value + private async Task ConvertJsonResultToFormulaValue(IResponse response) + { + // Read the JSON content from the response + var jsonString = await response.JsonAsync(); + JToken jsonObject = IsJsonElementArray(jsonString) ? JArray.Parse(jsonString.ToString()) : JObject.Parse(jsonString.ToString()); + + return await ConvertJsonToFormulaValue(jsonObject.Root); + } + + public bool IsJsonElementArray(JsonElement? element) + { + return element?.ValueKind == JsonValueKind.Array; + } + + /// + /// Convert Json object to Power Fx formula value + /// + /// JObject, JArray or JValue token to convert + /// The mapped Power Fx formula + private async Task ConvertJsonToFormulaValue(JToken jsonObject) + { + // Check if the value parameter is an array + if (jsonObject is JArray jsonArray) + { + // Create a list of RecordValue to hold the attributes of each object + var records = new List(); + + // Use empty type as each record might have different values + RecordType recordType = RecordType.Empty(); + + foreach (var item in jsonArray) + { + var fields = new List(); + + foreach (var property in item.Children()) + { + var fieldValue = await ConvertJsonToFormulaValue(property.Value); + fields.Add(new NamedValue(property.Name, fieldValue)); + recordType = recordType.Add(new NamedFormulaType(property.Name, fieldValue.Type)); + } + + records.Add(RecordValue.NewRecordFromFields(fields)); + } + + // Convert the list of RecordValue to a TableValue with the generated recordType + return TableValue.NewTable(recordType, records); + } + // Check if the value parameter is an object + else if (jsonObject is JObject jsonObjectValue) + { + var fields = new List(); + RecordType recordType = RecordType.Empty(); + + foreach (var property in jsonObjectValue.Children()) + { + var name = property.Name; + FormulaValue value = null; + + if (property.Value is JObject || property.Value is JArray) + { + value = await ConvertJsonToFormulaValue(property.Value); + + } + else if (property.Value is JValue) + { + var propertyValue = ((JValue)property.Value).Value; + if (propertyValue is string stringValue) + { + value = FormulaValue.New(stringValue); + } + else if (propertyValue is int intValue) + { + value = FormulaValue.New(intValue); + } + else if (propertyValue is double doubleValue) + { + value = FormulaValue.New(doubleValue); + } + else if (propertyValue is bool boolValue) + { + value = FormulaValue.New(boolValue); + } + else if (propertyValue is DateTime dateTimeValue) + { + value = FormulaValue.New(dateTimeValue); + } + else if (propertyValue == null) + { + value = FormulaValue.NewBlank(); + } + } + else + { + _logger.LogDebug("The property parameter is not not supported"); + } + + if (value == null && property.Value != null) + { + // TODO: Improve unknown value mapping + value = FormulaValue.New(property.Value.ToString()); + } + + if (value == null) + { + // Lets just map to blank + value = BlankValue.NewBlank(); + } + + fields.Add(new NamedValue(name, value)); + + recordType = recordType.Add(new NamedFormulaType(property.Name, value.Type)); + } + + // Convert the object to a RecordValue with the generated recordType + return RecordValue.NewRecordFromFields(recordType, fields); + } + // Check if the value parameter is a scalar value + else if (jsonObject != null) + { + // Convert the scalar value to a FormulaValue + return FormulaValue.New(jsonObject.ToString()); + } + + + _logger.LogDebug("The value parameter is not a valid JSON type"); + return FormulaValue.NewBlank(); + } + + /// + /// Extract the oadata entity from the url + /// + /// + /// + /// + private string GetODataEntity(string url) + { + var requestUrl = new Uri(url); + var segments = requestUrl.AbsolutePath.Split('/'); + + // Assuming the entity name is the last segment in the URL and using format /api/data/v9.X/entityname + // The first segment will be empty as has leading / + if (segments.Length >= 5 && segments[1].Equals("api", StringComparison.OrdinalIgnoreCase) && + segments[2].Equals("data", StringComparison.OrdinalIgnoreCase) && segments[3].StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + // TODO: Handle case where requesting entity vs list using /api/data/v9.X/entityname(id) syntax + return segments[4]; + } + + throw new ArgumentException("Invalid OData URL format"); + } + + /// + /// Generates test steps and data, and saves them to the specified path. + /// + ///The path where the test steps and data will be saved. + public async void Generate(string path) + { + if (!_fileSystem.Exists(path)) + { + _fileSystem.CreateDirectory(path); + } + + string filePath = $"{path}/recorded.te.yaml"; + + var line = 0; + + // Transfer elements to a ConcurrentQueue + ConcurrentQueue queue = new ConcurrentQueue(); + while (!TestSteps.IsEmpty) + { + if (TestSteps.TryTake(out string item)) + { + queue.Enqueue(item); + } + } + + // Enumberate in First In First Out (FIFO) + foreach (var step in queue) + { + line++; + var spaces = String.Empty; + if (line > 1) + { + spaces = new string(' ', 8); + } + _textBuilder.Append($"{spaces}{step}\r\n"); + } + + var template = @"# yaml-embedded-languages: powerfx +testSuite: + testSuiteName: Recorded test suite + testSuiteDescription: Summary of what the test suite + persona: User1 + appLogicalName: NotNeeded + + testCases: + - testCaseName: Recorded test cases + testCaseDescription: Set of test steps recorded from browser + testSteps: | + = + {0} + +testSettings: + headless: false + locale: ""en-US"" + recordVideo: true + extensionModules: + enable: true + browserConfigurations: + - browser: Chromium + +environmentVariables: + users: + - personaName: User1 + emailKey: NotNeeded + passwordKey: NotNeeded +"; + + var results = string.Format(template, _textBuilder.ToString()); + + //TODO: Write the recorded test steps to the file + _fileSystem.WriteTextToFile(filePath, results); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/Users/IConfigurableUserManager.cs b/src/Microsoft.PowerApps.TestEngine/Users/IConfigurableUserManager.cs new file mode 100644 index 000000000..dc0a1ba3b --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/Users/IConfigurableUserManager.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.PowerApps.TestEngine.Users +{ + public interface IConfigurableUserManager : IUserManager + { + public Dictionary Settings { get; } + } +} diff --git a/src/PowerAppsTestEngine.sln b/src/PowerAppsTestEngine.sln index 28b1ba483..d6d97f447 100644 --- a/src/PowerAppsTestEngine.sln +++ b/src/PowerAppsTestEngine.sln @@ -46,42 +46,43 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.pause", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.pause.tests", "testengine.module.pause.tests\testengine.module.pause.tests.csproj", "{3D9F90F2-0937-486D-AA0B-BFE425354F4A}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.user.certificate", "testengine.user.certificate\testengine.user.certificate.csproj", "{998935DC-E292-4818-A47E-3CCA365B7C5E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.playwrightscript.tests", "testengine.module.playwrightscript.tests\testengine.module.playwrightscript.tests.csproj", "{946D460B-23B3-4666-A6EE-6FF8D343FFA8}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Auth", "Auth", "{F5DD02A2-1BA8-481C-A7ED-E36577C2CB15}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.playwrightscript", "testengine.module.playwrightscript\testengine.module.playwrightscript.csproj", "{E6601B53-3DF7-468E-AFE7-D4EAFB0920ED}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.auth.localcertificate", "testengine.auth.localcertificate\testengine.auth.localcertificate.csproj", "{340111C2-8434-46CF-BE93-12E7C484C955}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.playwrightaction", "testengine.module.playwrightaction\testengine.module.playwrightaction.csproj", "{36DD1053-9C66-470E-9939-485E47C5ACFA}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.user.certificate.tests", "testengine.user.certificate.tests\testengine.user.certificate.tests.csproj", "{11605494-3C4E-41E8-B68E-FA347A3CA8E4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.playwrightaction.tests", "testengine.module.playwrightaction.tests\testengine.module.playwrightaction.tests.csproj", "{E5C9F4A8-AA9A-4D06-AEC4-34A3A5D14501}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.auth.localcertificate.tests", "testengine.auth.localcertificate.tests\testengine.auth.localcertificate.tests.csproj", "{7183776B-21BB-4318-958B-62B325CC1A7D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.tests.common", "testengine.module.tests.common\testengine.module.tests.common.csproj", "{2492D24B-62C7-434D-9D30-4289949F9029}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.auth.certificatestore", "testengine.auth.certificatestore\testengine.auth.certificatestore.csproj", "{EF3A270A-53A4-4C08-B45B-7C6993593446}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.provider.mda", "testengine.provider.mda\testengine.provider.mda.csproj", "{617F9A09-22A0-4FB7-A7CC-0344E84F23A6}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.auth.certificatestore.tests", "testengine.auth.certificatestore.tests\testengine.auth.certificatestore.tests.csproj", "{36F79923-74AD-424E-8A74-6902628FBF58}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.provider.mda.tests", "testengine.provider.mda.tests\testengine.provider.mda.tests.csproj", "{38351C2A-18F7-44AC-84F8-07FC3B49FE85}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items (2)", "Solution Items (2)", "{910250A5-D84E-4A3F-A5C6-C0A7AE27C8BD}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - EndProjectSection +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.provider.powerapps.portal", "testengine.provider.powerapps.portal\testengine.provider.powerapps.portal.csproj", "{DD06597F-F023-48A9-B971-A43006A39AFA}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Users", "Users", "{4E409805-A766-4F65-B058-C5264BA1F1CE}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.provider.powerapps,portal.tests", "testengine.provider.powerapps.portal.tests\testengine.provider.powerapps,portal.tests.csproj", "{23F04078-3BAB-477B-8E59-50BB590128E1}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Actions", "Actions", "{65FAC4EC-553A-4293-8484-2427A76BB4A0}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.powerapps.portal", "testengine.module.powerapps.portal\testengine.module.powerapps.portal.csproj", "{82661F2D-122C-4D23-A017-2CE4E82CAFAE}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.provider.mda", "testengine.provider.mda\testengine.provider.mda.csproj", "{25CEB2CE-B6D5-4BE1-ACD7-3CF00441EF94}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.powerapps.portal.tests", "testengine.module.powerapps.portal.tests\testengine.module.powerapps.portal.tests.csproj", "{D2CAF259-9D4A-4076-95F0-12E69152767F}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.provider.mda.tests", "testengine.provider.mda.tests\testengine.provider.mda.tests.csproj", "{B7C8ED63-A77B-4FA0-B441-A07BE40E3264}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.mda", "testengine.module.mda\testengine.module.mda.csproj", "{AAAA3EE0-8A3E-443F-ADD6-32477A2297F8}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ModelDrivenApplicationProvider", "ModelDrivenApplicationProvider", "{A465E028-ED7F-4113-B37B-4B830A5512D5}" - ProjectSection(SolutionItems) = preProject - ..\docs\Extensions\ModelDrivenApplicationProvider\controls.md = ..\docs\Extensions\ModelDrivenApplicationProvider\controls.md - ..\docs\Extensions\ModelDrivenApplicationProvider\README.md = ..\docs\Extensions\ModelDrivenApplicationProvider\README.md - EndProjectSection +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.mda.tests", "testengine.module.mda.tests\testengine.module.mda.tests.csproj", "{197320A0-0A9C-4C8F-B291-4EF82A417062}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.simulation", "testengine.module.simulation\testengine.module.simulation.csproj", "{C0519C50-DF7A-4708-A89B-8FB520275522}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.modules.simulation.tests", "testengine.modules.simulation.tests\testengine.modules.simulation.tests.csproj", "{1BC9BBA8-19E2-4F32-8904-C9DBC89C2516}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.user.storagestate", "testengine.user.storagestate\testengine.user.storagestate.csproj", "{B2C0C7D8-6B51-45F0-8B09-A8935745E3B9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.user.storagestate.tests", "testengine.user.storagestate.tests\testengine.user.storagestate.tests.csproj", "{BC91A675-FE44-44D7-B951-FBE8220D3399}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.mda", "testengine.module.mda\testengine.module.mda.csproj", "{16C931C8-47F8-4ABA-A657-3349DC8BEBF3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "testengine.common.user", "testengine.common.user\testengine.common.user.csproj", "{6CA1D5BF-FF9F-4392-8CCA-03C9B97BB4CE}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "testengine.module.mda.tests", "testengine.module.mda.tests\testengine.module.mda.tests.csproj", "{1BEF602B-9010-4455-811A-7597B700AC4C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "testengine.common.user.tests", "testengine.common.user.tests\testengine.common.user.tests.csproj", "{52D935F1-3567-48B7-904F-1183F824A9FB}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -137,46 +138,82 @@ Global {3D9F90F2-0937-486D-AA0B-BFE425354F4A}.Debug|Any CPU.Build.0 = Debug|Any CPU {3D9F90F2-0937-486D-AA0B-BFE425354F4A}.Release|Any CPU.ActiveCfg = Release|Any CPU {3D9F90F2-0937-486D-AA0B-BFE425354F4A}.Release|Any CPU.Build.0 = Release|Any CPU - {998935DC-E292-4818-A47E-3CCA365B7C5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {998935DC-E292-4818-A47E-3CCA365B7C5E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {998935DC-E292-4818-A47E-3CCA365B7C5E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {998935DC-E292-4818-A47E-3CCA365B7C5E}.Release|Any CPU.Build.0 = Release|Any CPU - {340111C2-8434-46CF-BE93-12E7C484C955}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {340111C2-8434-46CF-BE93-12E7C484C955}.Debug|Any CPU.Build.0 = Debug|Any CPU - {340111C2-8434-46CF-BE93-12E7C484C955}.Release|Any CPU.ActiveCfg = Release|Any CPU - {340111C2-8434-46CF-BE93-12E7C484C955}.Release|Any CPU.Build.0 = Release|Any CPU - {11605494-3C4E-41E8-B68E-FA347A3CA8E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {11605494-3C4E-41E8-B68E-FA347A3CA8E4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {11605494-3C4E-41E8-B68E-FA347A3CA8E4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {11605494-3C4E-41E8-B68E-FA347A3CA8E4}.Release|Any CPU.Build.0 = Release|Any CPU - {7183776B-21BB-4318-958B-62B325CC1A7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7183776B-21BB-4318-958B-62B325CC1A7D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7183776B-21BB-4318-958B-62B325CC1A7D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7183776B-21BB-4318-958B-62B325CC1A7D}.Release|Any CPU.Build.0 = Release|Any CPU - {EF3A270A-53A4-4C08-B45B-7C6993593446}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EF3A270A-53A4-4C08-B45B-7C6993593446}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EF3A270A-53A4-4C08-B45B-7C6993593446}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EF3A270A-53A4-4C08-B45B-7C6993593446}.Release|Any CPU.Build.0 = Release|Any CPU - {36F79923-74AD-424E-8A74-6902628FBF58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {36F79923-74AD-424E-8A74-6902628FBF58}.Debug|Any CPU.Build.0 = Debug|Any CPU - {36F79923-74AD-424E-8A74-6902628FBF58}.Release|Any CPU.ActiveCfg = Release|Any CPU - {36F79923-74AD-424E-8A74-6902628FBF58}.Release|Any CPU.Build.0 = Release|Any CPU - {25CEB2CE-B6D5-4BE1-ACD7-3CF00441EF94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {25CEB2CE-B6D5-4BE1-ACD7-3CF00441EF94}.Debug|Any CPU.Build.0 = Debug|Any CPU - {25CEB2CE-B6D5-4BE1-ACD7-3CF00441EF94}.Release|Any CPU.ActiveCfg = Release|Any CPU - {25CEB2CE-B6D5-4BE1-ACD7-3CF00441EF94}.Release|Any CPU.Build.0 = Release|Any CPU - {B7C8ED63-A77B-4FA0-B441-A07BE40E3264}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B7C8ED63-A77B-4FA0-B441-A07BE40E3264}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B7C8ED63-A77B-4FA0-B441-A07BE40E3264}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B7C8ED63-A77B-4FA0-B441-A07BE40E3264}.Release|Any CPU.Build.0 = Release|Any CPU - {16C931C8-47F8-4ABA-A657-3349DC8BEBF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {16C931C8-47F8-4ABA-A657-3349DC8BEBF3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {16C931C8-47F8-4ABA-A657-3349DC8BEBF3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {16C931C8-47F8-4ABA-A657-3349DC8BEBF3}.Release|Any CPU.Build.0 = Release|Any CPU - {1BEF602B-9010-4455-811A-7597B700AC4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1BEF602B-9010-4455-811A-7597B700AC4C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1BEF602B-9010-4455-811A-7597B700AC4C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1BEF602B-9010-4455-811A-7597B700AC4C}.Release|Any CPU.Build.0 = Release|Any CPU + {946D460B-23B3-4666-A6EE-6FF8D343FFA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {946D460B-23B3-4666-A6EE-6FF8D343FFA8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {946D460B-23B3-4666-A6EE-6FF8D343FFA8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {946D460B-23B3-4666-A6EE-6FF8D343FFA8}.Release|Any CPU.Build.0 = Release|Any CPU + {E6601B53-3DF7-468E-AFE7-D4EAFB0920ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6601B53-3DF7-468E-AFE7-D4EAFB0920ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6601B53-3DF7-468E-AFE7-D4EAFB0920ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6601B53-3DF7-468E-AFE7-D4EAFB0920ED}.Release|Any CPU.Build.0 = Release|Any CPU + {36DD1053-9C66-470E-9939-485E47C5ACFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {36DD1053-9C66-470E-9939-485E47C5ACFA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {36DD1053-9C66-470E-9939-485E47C5ACFA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {36DD1053-9C66-470E-9939-485E47C5ACFA}.Release|Any CPU.Build.0 = Release|Any CPU + {E5C9F4A8-AA9A-4D06-AEC4-34A3A5D14501}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5C9F4A8-AA9A-4D06-AEC4-34A3A5D14501}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5C9F4A8-AA9A-4D06-AEC4-34A3A5D14501}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5C9F4A8-AA9A-4D06-AEC4-34A3A5D14501}.Release|Any CPU.Build.0 = Release|Any CPU + {2492D24B-62C7-434D-9D30-4289949F9029}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2492D24B-62C7-434D-9D30-4289949F9029}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2492D24B-62C7-434D-9D30-4289949F9029}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2492D24B-62C7-434D-9D30-4289949F9029}.Release|Any CPU.Build.0 = Release|Any CPU + {617F9A09-22A0-4FB7-A7CC-0344E84F23A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {617F9A09-22A0-4FB7-A7CC-0344E84F23A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {617F9A09-22A0-4FB7-A7CC-0344E84F23A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {617F9A09-22A0-4FB7-A7CC-0344E84F23A6}.Release|Any CPU.Build.0 = Release|Any CPU + {38351C2A-18F7-44AC-84F8-07FC3B49FE85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38351C2A-18F7-44AC-84F8-07FC3B49FE85}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38351C2A-18F7-44AC-84F8-07FC3B49FE85}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38351C2A-18F7-44AC-84F8-07FC3B49FE85}.Release|Any CPU.Build.0 = Release|Any CPU + {DD06597F-F023-48A9-B971-A43006A39AFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DD06597F-F023-48A9-B971-A43006A39AFA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD06597F-F023-48A9-B971-A43006A39AFA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DD06597F-F023-48A9-B971-A43006A39AFA}.Release|Any CPU.Build.0 = Release|Any CPU + {23F04078-3BAB-477B-8E59-50BB590128E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23F04078-3BAB-477B-8E59-50BB590128E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23F04078-3BAB-477B-8E59-50BB590128E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23F04078-3BAB-477B-8E59-50BB590128E1}.Release|Any CPU.Build.0 = Release|Any CPU + {82661F2D-122C-4D23-A017-2CE4E82CAFAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {82661F2D-122C-4D23-A017-2CE4E82CAFAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {82661F2D-122C-4D23-A017-2CE4E82CAFAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {82661F2D-122C-4D23-A017-2CE4E82CAFAE}.Release|Any CPU.Build.0 = Release|Any CPU + {D2CAF259-9D4A-4076-95F0-12E69152767F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2CAF259-9D4A-4076-95F0-12E69152767F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2CAF259-9D4A-4076-95F0-12E69152767F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2CAF259-9D4A-4076-95F0-12E69152767F}.Release|Any CPU.Build.0 = Release|Any CPU + {AAAA3EE0-8A3E-443F-ADD6-32477A2297F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AAAA3EE0-8A3E-443F-ADD6-32477A2297F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AAAA3EE0-8A3E-443F-ADD6-32477A2297F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AAAA3EE0-8A3E-443F-ADD6-32477A2297F8}.Release|Any CPU.Build.0 = Release|Any CPU + {197320A0-0A9C-4C8F-B291-4EF82A417062}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {197320A0-0A9C-4C8F-B291-4EF82A417062}.Debug|Any CPU.Build.0 = Debug|Any CPU + {197320A0-0A9C-4C8F-B291-4EF82A417062}.Release|Any CPU.ActiveCfg = Release|Any CPU + {197320A0-0A9C-4C8F-B291-4EF82A417062}.Release|Any CPU.Build.0 = Release|Any CPU + {C0519C50-DF7A-4708-A89B-8FB520275522}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0519C50-DF7A-4708-A89B-8FB520275522}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0519C50-DF7A-4708-A89B-8FB520275522}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0519C50-DF7A-4708-A89B-8FB520275522}.Release|Any CPU.Build.0 = Release|Any CPU + {1BC9BBA8-19E2-4F32-8904-C9DBC89C2516}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1BC9BBA8-19E2-4F32-8904-C9DBC89C2516}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1BC9BBA8-19E2-4F32-8904-C9DBC89C2516}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1BC9BBA8-19E2-4F32-8904-C9DBC89C2516}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C0C7D8-6B51-45F0-8B09-A8935745E3B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C0C7D8-6B51-45F0-8B09-A8935745E3B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C0C7D8-6B51-45F0-8B09-A8935745E3B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C0C7D8-6B51-45F0-8B09-A8935745E3B9}.Release|Any CPU.Build.0 = Release|Any CPU + {BC91A675-FE44-44D7-B951-FBE8220D3399}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC91A675-FE44-44D7-B951-FBE8220D3399}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC91A675-FE44-44D7-B951-FBE8220D3399}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC91A675-FE44-44D7-B951-FBE8220D3399}.Release|Any CPU.Build.0 = Release|Any CPU + {6CA1D5BF-FF9F-4392-8CCA-03C9B97BB4CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6CA1D5BF-FF9F-4392-8CCA-03C9B97BB4CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6CA1D5BF-FF9F-4392-8CCA-03C9B97BB4CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6CA1D5BF-FF9F-4392-8CCA-03C9B97BB4CE}.Release|Any CPU.Build.0 = Release|Any CPU + {52D935F1-3567-48B7-904F-1183F824A9FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {52D935F1-3567-48B7-904F-1183F824A9FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {52D935F1-3567-48B7-904F-1183F824A9FB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {52D935F1-3567-48B7-904F-1183F824A9FB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -192,20 +229,28 @@ Global {B91EFA35-C28B-497E-BFDC-8497933393A0} = {0B61ADD8-5EED-4A2C-99AA-B597EC3EE223} {2778A59F-773D-414E-A1FF-9C5B5F90E28F} = {0B61ADD8-5EED-4A2C-99AA-B597EC3EE223} {8AEAF6BD-38E3-4649-9221-6A67AD1E96EC} = {0B61ADD8-5EED-4A2C-99AA-B597EC3EE223} + {D34E437A-6149-46EC-B7DA-FF449E55CEEA} = {63A04DC1-C37E-43E6-8FEA-A480483E11F8} {B3A02421-223D-4E80-A8CE-977B425A6EB2} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} {3D9F90F2-0937-486D-AA0B-BFE425354F4A} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} - {998935DC-E292-4818-A47E-3CCA365B7C5E} = {0B61ADD8-5EED-4A2C-99AA-B597EC3EE223} - {F5DD02A2-1BA8-481C-A7ED-E36577C2CB15} = {63A04DC1-C37E-43E6-8FEA-A480483E11F8} - {340111C2-8434-46CF-BE93-12E7C484C955} = {F5DD02A2-1BA8-481C-A7ED-E36577C2CB15} - {11605494-3C4E-41E8-B68E-FA347A3CA8E4} = {0B61ADD8-5EED-4A2C-99AA-B597EC3EE223} - {7183776B-21BB-4318-958B-62B325CC1A7D} = {F5DD02A2-1BA8-481C-A7ED-E36577C2CB15} - {EF3A270A-53A4-4C08-B45B-7C6993593446} = {F5DD02A2-1BA8-481C-A7ED-E36577C2CB15} - {36F79923-74AD-424E-8A74-6902628FBF58} = {F5DD02A2-1BA8-481C-A7ED-E36577C2CB15} - {25CEB2CE-B6D5-4BE1-ACD7-3CF00441EF94} = {D53FFBF2-F4D0-4139-9FD3-47C8216E4448} - {B7C8ED63-A77B-4FA0-B441-A07BE40E3264} = {D53FFBF2-F4D0-4139-9FD3-47C8216E4448} - {A465E028-ED7F-4113-B37B-4B830A5512D5} = {D34E437A-6149-46EC-B7DA-FF449E55CEEA} - {16C931C8-47F8-4ABA-A657-3349DC8BEBF3} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} - {1BEF602B-9010-4455-811A-7597B700AC4C} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} + {946D460B-23B3-4666-A6EE-6FF8D343FFA8} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} + {E6601B53-3DF7-468E-AFE7-D4EAFB0920ED} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} + {36DD1053-9C66-470E-9939-485E47C5ACFA} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} + {E5C9F4A8-AA9A-4D06-AEC4-34A3A5D14501} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} + {2492D24B-62C7-434D-9D30-4289949F9029} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} + {617F9A09-22A0-4FB7-A7CC-0344E84F23A6} = {D53FFBF2-F4D0-4139-9FD3-47C8216E4448} + {38351C2A-18F7-44AC-84F8-07FC3B49FE85} = {D53FFBF2-F4D0-4139-9FD3-47C8216E4448} + {DD06597F-F023-48A9-B971-A43006A39AFA} = {D53FFBF2-F4D0-4139-9FD3-47C8216E4448} + {23F04078-3BAB-477B-8E59-50BB590128E1} = {D53FFBF2-F4D0-4139-9FD3-47C8216E4448} + {82661F2D-122C-4D23-A017-2CE4E82CAFAE} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} + {D2CAF259-9D4A-4076-95F0-12E69152767F} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} + {AAAA3EE0-8A3E-443F-ADD6-32477A2297F8} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} + {197320A0-0A9C-4C8F-B291-4EF82A417062} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} + {C0519C50-DF7A-4708-A89B-8FB520275522} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} + {1BC9BBA8-19E2-4F32-8904-C9DBC89C2516} = {ACAB614B-304F-48C0-B8B1-8D95F3A9FBC4} + {B2C0C7D8-6B51-45F0-8B09-A8935745E3B9} = {0B61ADD8-5EED-4A2C-99AA-B597EC3EE223} + {BC91A675-FE44-44D7-B951-FBE8220D3399} = {0B61ADD8-5EED-4A2C-99AA-B597EC3EE223} + {6CA1D5BF-FF9F-4392-8CCA-03C9B97BB4CE} = {0B61ADD8-5EED-4A2C-99AA-B597EC3EE223} + {52D935F1-3567-48B7-904F-1183F824A9FB} = {0B61ADD8-5EED-4A2C-99AA-B597EC3EE223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7E7B2C01-DDE2-4C5A-96C3-AF474B074331} diff --git a/src/PowerAppsTestEngine/InputOptions.cs b/src/PowerAppsTestEngine/InputOptions.cs index aee18ab2f..b23f5d07b 100644 --- a/src/PowerAppsTestEngine/InputOptions.cs +++ b/src/PowerAppsTestEngine/InputOptions.cs @@ -16,5 +16,7 @@ public class InputOptions public string? UserAuth { get; set; } public string? Provider { get; set; } public string? UserAuthType { get; set; } + public string? Wait { get; set; } + public string? Record { get; set; } } } diff --git a/src/PowerAppsTestEngine/PowerAppsTestEngine.csproj b/src/PowerAppsTestEngine/PowerAppsTestEngine.csproj index 43014353a..649b013b2 100644 --- a/src/PowerAppsTestEngine/PowerAppsTestEngine.csproj +++ b/src/PowerAppsTestEngine/PowerAppsTestEngine.csproj @@ -2,18 +2,25 @@ Exe - net6.0 + net8.0 enable enable True + + + true - ../../35MSSharedLib1024.snk true + ../../35MSSharedLib1024.snk + + + + false - + diff --git a/src/PowerAppsTestEngine/Program.cs b/src/PowerAppsTestEngine/Program.cs index 7425df636..18f35f31d 100644 --- a/src/PowerAppsTestEngine/Program.cs +++ b/src/PowerAppsTestEngine/Program.cs @@ -3,6 +3,7 @@ using System.ComponentModel.Composition; using System.ComponentModel.Composition.Hosting; +using System.Text.RegularExpressions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -29,7 +30,9 @@ { "-m", "Modules" }, { "-u", "UserAuth" }, { "-p", "Provider" }, - { "-a", "UserAuthType"} + { "-a", "UserAuthType"}, + { "-w", "Wait" }, + { "-r", "Record" } }; var inputOptions = new ConfigurationBuilder() @@ -115,6 +118,14 @@ } } + + if (!string.IsNullOrEmpty(inputOptions.Wait) && inputOptions.Wait.ToLower() == "true") + { + Console.WriteLine("Waiting, press enter to continue"); + Console.ReadLine(); + } + + var logLevel = LogLevel.Information; // Default log level if (string.IsNullOrEmpty(inputOptions.LogLevel) || !Enum.TryParse(inputOptions.LogLevel, true, out logLevel)) { @@ -163,7 +174,15 @@ testState.LoadExtensionModules(logger); userManagers = testState.GetTestEngineUserManager(); } - return userManagers.Where(x => x.Name.Equals(userAuth)).First(); + + var match = userManagers.Where(x => x.Name.Equals(userAuth)).FirstOrDefault(); + + if (match == null) + { + throw new InvalidDataException($"Unable to find user auth {userAuth}"); + } + + return match; }) .AddTransient(sp => { @@ -174,7 +193,16 @@ testState.LoadExtensionModules(logger); testWebProviders = testState.GetTestEngineWebProviders(); } - return testWebProviders.Where(x => x.Name.Equals(provider)).First(); + + var match = testWebProviders.Where(x => x.Name.Equals(provider)).FirstOrDefault(); + + if (match == null) + { + throw new InvalidDataException($"Unable to find provider {provider}"); + } + + + return match; }) .AddSingleton(sp => { @@ -185,7 +213,15 @@ testState.LoadExtensionModules(logger); testAuthProviders = testState.GetTestEngineAuthProviders(); } - return testAuthProviders.Where(x => x.Name.Equals(auth)).First(); + + var match = testAuthProviders.Where(x => x.Name.Equals(auth)).FirstOrDefault(); + + if (match == null) + { + match = new DefaultUserCertificateProvider(); + } + + return match; }) .AddSingleton() .AddSingleton() @@ -208,7 +244,7 @@ var testPlanFile = new FileInfo(inputOptions.TestPlanFile); var tenantId = Guid.Parse(inputOptions.TenantId); var environmentId = inputOptions.EnvironmentId; - var domain = "apps.powerapps.com"; + var domain = string.Empty; var queryParams = ""; DirectoryInfo outputDirectory; @@ -242,6 +278,11 @@ ITestState state = serviceProvider.GetService(); state.SetModulePath(modulePath); + if (!string.IsNullOrEmpty(inputOptions.Record)) + { + state.SetRecordMode(); + } + //setting defaults for optional parameters outside RunTestAsync var testResult = await testEngine.RunTestAsync(testPlanFile, environmentId, tenantId, outputDirectory, domain, queryParams); if (testResult != "InvalidOutputDirectory") diff --git a/src/testengine.auth.certificatestore.tests/testengine.auth.certificatestore.tests.csproj b/src/testengine.auth.certificatestore.tests/testengine.auth.certificatestore.tests.csproj index 0ec1d75ce..648afa490 100644 --- a/src/testengine.auth.certificatestore.tests/testengine.auth.certificatestore.tests.csproj +++ b/src/testengine.auth.certificatestore.tests/testengine.auth.certificatestore.tests.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 enable enable true diff --git a/src/testengine.auth.certificatestore/testengine.auth.certificatestore.csproj b/src/testengine.auth.certificatestore/testengine.auth.certificatestore.csproj index e93dff471..bb4b24a22 100644 --- a/src/testengine.auth.certificatestore/testengine.auth.certificatestore.csproj +++ b/src/testengine.auth.certificatestore/testengine.auth.certificatestore.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 enable enable true diff --git a/src/testengine.auth.localcertificate.tests/testengine.auth.localcertificate.tests.csproj b/src/testengine.auth.localcertificate.tests/testengine.auth.localcertificate.tests.csproj index 30b576d5a..416f3ce0c 100644 --- a/src/testengine.auth.localcertificate.tests/testengine.auth.localcertificate.tests.csproj +++ b/src/testengine.auth.localcertificate.tests/testengine.auth.localcertificate.tests.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 enable enable true diff --git a/src/testengine.auth.localcertificate/testengine.auth.localcertificate.csproj b/src/testengine.auth.localcertificate/testengine.auth.localcertificate.csproj index 60ed9b3ea..46b193617 100644 --- a/src/testengine.auth.localcertificate/testengine.auth.localcertificate.csproj +++ b/src/testengine.auth.localcertificate/testengine.auth.localcertificate.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 enable enable true diff --git a/src/testengine.common.user.tests/PowerPlatformLoginTests.cs b/src/testengine.common.user.tests/PowerPlatformLoginTests.cs new file mode 100644 index 000000000..eaa66de56 --- /dev/null +++ b/src/testengine.common.user.tests/PowerPlatformLoginTests.cs @@ -0,0 +1,128 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using Microsoft.PowerApps.TestEngine.Config; +using Microsoft.PowerApps.TestEngine.System; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Microsoft.PowerApps.TestEngine.Tests.Helpers; +using Microsoft.PowerApps.TestEngine.Users; +using Moq; +using testengine.common.user; +using Xunit; + +namespace testengine.user.storagestate.tests +{ + public class PowerPlatformLoginTests + { + private Mock MockUserManager; + private Dictionary MockSettings; + private Mock MockPage; + private Mock MockLocator; + + public PowerPlatformLoginTests() + { + MockUserManager = new Mock(MockBehavior.Strict); + MockSettings = new Dictionary(); + MockPage = new Mock(MockBehavior.Strict); + MockLocator = new Mock(MockBehavior.Strict); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("new", null)] + [InlineData("new", "old")] + [InlineData(null, "old")] + public async Task DialogErrors(string? title, string? existing) + { + // Arrange + var find = !String.IsNullOrEmpty(title); + + var login = new PowerPlatformLogin(); + var state = new LoginState() + { + Module = MockUserManager.Object, + DesiredUrl = "http://example.com", + Page = MockPage.Object + }; + + MockLocator.Setup(m => m.IsEditableAsync(null)).ReturnsAsync(false); + + MockPage.SetupGet(m => m.Url).Returns("https://someother.com"); + MockPage.Setup(m => m.Locator(PowerPlatformLogin.EmailSelector, null)) + .Returns(MockLocator.Object); + + MockUserManager.SetupGet(m => m.Settings).Returns(MockSettings); + + if (!string.IsNullOrEmpty(existing)) + { + MockSettings.Add(PowerPlatformLogin.ERROR_DIALOG_KEY, existing); + } + + if (find) + { + var engine = new Jint.Engine(); + // Simulate querySelector returning value + engine.Evaluate($"var document = {{ querySelector: name => {{ return {{ textContent: '{title}' }}}} }}"); + MockPage + .Setup(m => m.EvaluateAsync(PowerPlatformLogin.DIAGLOG_CHECK_JAVASCRIPT, null)) + .Returns((string expression, object? args) => + { + var value = engine.Evaluate(expression).ToString(); + return Task.FromResult(value); + }); + } + else + { + MockPage.Setup(m => m.EvaluateAsync(PowerPlatformLogin.DIAGLOG_CHECK_JAVASCRIPT, null)).Returns(Task.FromResult(String.Empty)); + } + + // Act + await login.HandleCommonLoginState(state); + + // Assert + Assert.Equal(find, state.IsError); + + if (find) + { + Assert.Equal(title, MockSettings[PowerPlatformLogin.ERROR_DIALOG_KEY]); + } + + if (!find && !string.IsNullOrEmpty(existing)) + { + Assert.Equal(existing, MockSettings[PowerPlatformLogin.ERROR_DIALOG_KEY]); + } + + if (!find && string.IsNullOrEmpty(existing)) + { + Assert.Empty(MockSettings); + } + } + + [Theory] + [InlineData("http://example.com", "http://example.com", "example.com")] + [InlineData("http://example.com.mcas.ms", "http://example.com", "example.com.mcas.ms")] + [InlineData("http://example.com/Home", "http://example.com", "example.com")] + public async Task FindMatch(string url, string desiredUrl, string host) + { + // Arrange + var login = new PowerPlatformLogin(); + var state = new LoginState() + { + Module = MockUserManager.Object, + DesiredUrl = desiredUrl, + Page = MockPage.Object + }; + + MockPage.SetupGet(m => m.Url).Returns(url); + MockPage.Setup(m => m.EvaluateAsync(PowerPlatformLogin.DEFAULT_OFFICE_365_CHECK, null)) + .Returns(Task.FromResult("Idle")); + + // Act + await login.HandleCommonLoginState(state); + + // Assert + Assert.True(state.FoundMatch); + Assert.Equal(host, state.MatchHost); + } + } +} diff --git a/src/testengine.common.user.tests/testengine.common.user.tests.csproj b/src/testengine.common.user.tests/testengine.common.user.tests.csproj new file mode 100644 index 000000000..878dc287e --- /dev/null +++ b/src/testengine.common.user.tests/testengine.common.user.tests.csproj @@ -0,0 +1,45 @@ + + + + net8.0 + enable + enable + + + + true + true + ../../35MSSharedLib1024.snk + + + + false + + + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/src/testengine.common.user/LoginState.cs b/src/testengine.common.user/LoginState.cs new file mode 100644 index 000000000..04d5717eb --- /dev/null +++ b/src/testengine.common.user/LoginState.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Playwright; +using Microsoft.PowerApps.TestEngine.Users; + +namespace testengine.common.user +{ + public class LoginState + { + public IConfigurableUserManager? Module { get; set; } + + public IPage? Page { get; set; } + public string? UserEmail { get; set; } + public string? DesiredUrl { get; set; } + + + public bool IsError { get; set; } + public bool FoundMatch { get; set; } + public bool EmailHandled { get; set; } + public string? MatchHost { get; set; } + + + public Func? CallbackDesiredUrlFound { get; set; } + public Func? CallbackErrorFound { get; set; } + } +} diff --git a/src/testengine.common.user/PowerPlatformLogin.cs b/src/testengine.common.user/PowerPlatformLogin.cs new file mode 100644 index 000000000..4040ac944 --- /dev/null +++ b/src/testengine.common.user/PowerPlatformLogin.cs @@ -0,0 +1,144 @@ +using Microsoft.Playwright; + +namespace testengine.common.user +{ + public class PowerPlatformLogin + { + public static string EmailSelector = "input[type=\"email\"]"; + + public static string ERROR_DIALOG_KEY = "ErrorDialogTitle"; + + public static string DEFAULT_OFFICE_365_CHECK = "var element = document.getElementById('O365_MainLink_NavMenu'); if (typeof(element) != 'undefined' && element != null) { 'Idle' } else { 'Loading' }"; + public static string DIAGLOG_CHECK_JAVASCRIPT = "var element = document.querySelector('.ms-Dialog-title, #ErrorTitle, .NotificationTitle'); if (typeof(element) != 'undefined' && element != null) { element.textContent.trim() } else { '' }"; + + public Func> LoginIsComplete { get; set; } + + public PowerPlatformLogin() + { + // Use the default check that the login process is idle, caller could override that behaviour with any additional checks + LoginIsComplete = CheckIsIdleAsync; + } + + public virtual async Task HandleCommonLoginState(LoginState state) + { + + // Error Checks - Power Apps Scenarios + //TODO: Verify App not shared + //TODO: Handle unlicenced + //TODO: DLP Violation + //TODO: No dataverse access rights (MDA) + var title = await DialogTitle(state.Page); + if (!string.IsNullOrEmpty(title)) + { + if (!state.Module.Settings.ContainsKey(ERROR_DIALOG_KEY)) + { + state.Module.Settings.TryAdd(ERROR_DIALOG_KEY, title); + } + else + { + state.Module.Settings[ERROR_DIALOG_KEY] = title; + } + + state.IsError = true; + + if (state.CallbackErrorFound != null) + { + await state.CallbackErrorFound(); + } + } + + var url = state.Page.Url; + + // Remove any redirect added by Microsoft Cloud for Web Apps so we get the desired url + url = url?.Replace(".mcas.ms", ""); + + // Remove home location, required for Portal Providers + url = url?.Replace("/home", ""); + + // Need to check if page is idle to avoid case where we can get race condition before redirect to login + if (url.IndexOf(state.DesiredUrl) >= 0 && await LoginIsComplete(state.Page) && !state.IsError) + { + if (state.CallbackDesiredUrlFound != null) + { + await state.CallbackDesiredUrlFound(state.DesiredUrl); + } + + state.FoundMatch = true; + state.MatchHost = new Uri(state.Page.Url).Host; + } + + if (!(state.Page.Url.IndexOf(state.DesiredUrl) >= 0) && !state.IsError) + { + if (state.Page.Url != "about:blank") + { + // Default the user into the dialog if it is visible + await HandleUserEmailScreen(EmailSelector, state); + + // Next user could be presented with password + // Could also be presented with others configured MFA options + } + } + } + + /// + /// Attempts to complete the user email as part of the login process if it is known + /// + /// The selector to fid the email + /// The current login session state + /// Completed task + private async Task HandleUserEmailScreen(string selector, LoginState state) + { + if (state.EmailHandled) + { + return; + } + try + { + var page = state.Page; + if (!await LoginIsComplete(state.Page) && await page.Locator(selector).IsEditableAsync() && !state.EmailHandled) + { + state.EmailHandled = true; + await page.Locator(selector).PressSequentiallyAsync(state.UserEmail, new LocatorPressSequentiallyOptions { Delay = 50 }); + await page.Keyboard.PressAsync("Tab", new KeyboardPressOptions { Delay = 20 }); + } + } + catch + { + } + } + + /// + /// Check if standard post login Document Object Model elements can be found + /// + /// + /// + private async Task CheckIsIdleAsync(IPage page) + { + try + { + return (await page.EvaluateAsync(DEFAULT_OFFICE_365_CHECK)) == "Idle"; + } + catch + { + return false; + } + } + + /// + /// Attempts to determin if a Power Platform dialog is visible to the user. If so return the title + /// + /// The page to check + /// The located title if it exists + private async Task DialogTitle(IPage page) + { + try + { + return await page.EvaluateAsync(DIAGLOG_CHECK_JAVASCRIPT); + } + catch + { + return ""; + } + } + } +} diff --git a/src/testengine.common.user/testengine.common.user.csproj b/src/testengine.common.user/testengine.common.user.csproj new file mode 100644 index 000000000..21e6cc410 --- /dev/null +++ b/src/testengine.common.user/testengine.common.user.csproj @@ -0,0 +1,39 @@ + + + + net8.0 + enable + enable + + + + true + true + ../../35MSSharedLib1024.snk + + + + false + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/testengine.module.mda.tests/SelectControlTests.cs b/src/testengine.module.mda.tests/SelectControlTests.cs new file mode 100644 index 000000000..4ba6e4ac4 --- /dev/null +++ b/src/testengine.module.mda.tests/SelectControlTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using Microsoft.PowerApps.TestEngine.Config; +using Microsoft.PowerApps.TestEngine.Providers; +using Microsoft.PowerApps.TestEngine.Providers.PowerFxModel; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Microsoft.PowerFx.Types; +using Moq; + +namespace testengine.module +{ + public class SelectControlFunctionTests + { + private Mock MockTestInfraFunctions; + private Mock MockTestState; + private Mock MockPage; + private Mock MockTestWebProvider; + private Mock MockLogger; + private Mock MockLocator; + + public SelectControlFunctionTests() + { + MockTestInfraFunctions = new Mock(MockBehavior.Strict); + MockTestState = new Mock(MockBehavior.Strict); + MockPage = new Mock(MockBehavior.Strict); + MockTestWebProvider = new Mock(MockBehavior.Strict); + MockLogger = new Mock(); + MockLocator = new Mock(); + } + + [Fact] + public async Task HappyPathMatchIsFound() + { + // Arrange + var function = new SelectControlFunction(MockTestInfraFunctions.Object, MockTestState.Object, MockLogger.Object); + + MockTestInfraFunctions.SetupGet(x => x.Page).Returns(MockPage.Object); + MockPage.Setup(x => x.Locator("[data-control-name='Button1']", null)).Returns(MockLocator.Object); + + MockLocator.Setup(x => x.Nth(0)).Returns(MockLocator.Object); + + MockLocator.Setup(x => x.ClickAsync(null)).Returns(Task.CompletedTask); + + var recordType = RecordType.Empty().Add("Text", FormulaType.String); + var recordValue = new ControlRecordValue(recordType, MockTestWebProvider.Object, "Button1"); + + // Act & Assert + function.Execute(recordValue, NumberValue.New((float)1.0)); + } + } +} diff --git a/src/testengine.module.mda.tests/testengine.module.mda.tests.csproj b/src/testengine.module.mda.tests/testengine.module.mda.tests.csproj index 101d6ab15..c3d52a01a 100644 --- a/src/testengine.module.mda.tests/testengine.module.mda.tests.csproj +++ b/src/testengine.module.mda.tests/testengine.module.mda.tests.csproj @@ -1,13 +1,20 @@  - net6.0 + net8.0 enable enable + false + + + true - ../../35MSSharedLib1024.snk true - false + ../../35MSSharedLib1024.snk + + + + false diff --git a/src/testengine.module.mda/ConsentDialogFunction.cs b/src/testengine.module.mda/ConsentDialogFunction.cs index 917f9f7e5..cf200fa98 100644 --- a/src/testengine.module.mda/ConsentDialogFunction.cs +++ b/src/testengine.module.mda/ConsentDialogFunction.cs @@ -24,7 +24,7 @@ public class ConsentDialogFunction : ReflectionFunction .Add(new NamedFormulaType("Text", FormulaType.String, displayName: "Text")); public ConsentDialogFunction(ITestInfraFunctions testInfraFunctions, ITestState testState, ILogger logger) - : base(DPath.Root.Append(new DName("TestEngine")), "ConsentDialog", FormulaType.Blank, SearchType) + : base(DPath.Root.Append(new DName("Experimental")), "ConsentDialog", FormulaType.Blank, SearchType) { _testInfraFunctions = testInfraFunctions; _testState = testState; diff --git a/src/testengine.module.mda/ModelDrivenApplicationModule.cs b/src/testengine.module.mda/ModelDrivenApplicationModule.cs index df0d554e0..9e6df3587 100644 --- a/src/testengine.module.mda/ModelDrivenApplicationModule.cs +++ b/src/testengine.module.mda/ModelDrivenApplicationModule.cs @@ -26,6 +26,9 @@ public void RegisterPowerFxFunction(PowerFxConfig config, ITestInfraFunctions te ILogger logger = singleTestInstanceState.GetLogger(); config.AddFunction(new ConsentDialogFunction(testInfraFunctions, testState, logger)); logger.LogInformation("Registered ConsentDialog()"); + + config.AddFunction(new SelectControlFunction(testInfraFunctions, testState, logger)); + logger.LogInformation("Registered Experimental.SelectControlFunction()"); } public async Task RegisterNetworkRoute(ITestState state, ISingleTestInstanceState singleTestInstanceState, IFileSystem fileSystem, IPage Page, NetworkRequestMock mock) diff --git a/src/testengine.module.mda/SelectControl.cs b/src/testengine.module.mda/SelectControl.cs new file mode 100644 index 000000000..c9f293222 --- /dev/null +++ b/src/testengine.module.mda/SelectControl.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using Microsoft.PowerApps.TestEngine.Config; +using Microsoft.PowerApps.TestEngine.Providers; +using Microsoft.PowerApps.TestEngine.Providers.PowerFxModel; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Core.Utils; +using Microsoft.PowerFx.Types; + +namespace testengine.module +{ + /// + /// This will check the custom pages of a model driven app looking for a consent dialog + /// + public class SelectControlFunction : ReflectionFunction + { + private readonly ITestInfraFunctions _testInfraFunctions; + private readonly ITestState _testState; + private readonly ILogger _logger; + + private static TableType SearchType = TableType.Empty() + .Add(new NamedFormulaType("Text", FormulaType.String, displayName: "Text")); + + public SelectControlFunction(ITestInfraFunctions testInfraFunctions, ITestState testState, ILogger logger) + : base(DPath.Root.Append(new DName("Experimental")), "SelectControl", FormulaType.Blank, RecordType.Empty(), FormulaType.Number) + { + _testInfraFunctions = testInfraFunctions; + _testState = testState; + _logger = logger; + } + + public BlankValue Execute(RecordValue control, NumberValue index) + { + _logger.LogInformation("------------------------------\n\n" + + "Executing Experimental.SelectControl() function."); + + ExecuteAsync(control, index).Wait(); + + return FormulaValue.NewBlank(); + } + + private async Task ExecuteAsync(RecordValue obj, NumberValue index) + { + _logger.LogInformation("------------------------------\n\n" + + "Executing Select function."); + + if (obj == null) + { + _logger.LogError($"Object cannot be null."); + throw new ArgumentException(); + } + + var powerAppControlModel = (ControlRecordValue)obj; + + var itemPath = powerAppControlModel.GetItemPath(); + itemPath.Index = (int)index.Value; + + // Experimental support allow selection control using data-control-name DOM element + var match = _testInfraFunctions.Page.Locator($"[data-control-name='{powerAppControlModel.Name}']").Nth((int)index.Value - 1); + + await match.ClickAsync(); + + _logger.LogInformation("Successfully finished executing SelectControl function."); + } + } +} + diff --git a/src/testengine.module.mda/testengine.module.mda.csproj b/src/testengine.module.mda/testengine.module.mda.csproj index 7ddcf0db6..8603afa8d 100644 --- a/src/testengine.module.mda/testengine.module.mda.csproj +++ b/src/testengine.module.mda/testengine.module.mda.csproj @@ -1,25 +1,32 @@  - net6.0 + net8.0 enable enable Microsoft crmsdk,Microsoft - Alpha Release: Pause browser PowerFx action MEF extension + Alpha Release: Model Driven App (MDA) browser PowerFx action MEF extension Notice: This package is an ALPHA release. - Use at your own risk. Intial Alpha release of Microsoft.PowerAppsTestEngine - true - ../../35MSSharedLib1024.snk - true © Microsoft Corporation. All rights reserved. true 1.0 + + + true + true + ../../35MSSharedLib1024.snk + + + + false + @@ -28,8 +35,8 @@ - - + + diff --git a/src/testengine.module.pause.tests/testengine.module.pause.tests.csproj b/src/testengine.module.pause.tests/testengine.module.pause.tests.csproj index e66c368fa..ff092b827 100644 --- a/src/testengine.module.pause.tests/testengine.module.pause.tests.csproj +++ b/src/testengine.module.pause.tests/testengine.module.pause.tests.csproj @@ -1,13 +1,20 @@  - net6.0 + net8.0 enable enable + false + + + true - ../../35MSSharedLib1024.snk true - false + ../../35MSSharedLib1024.snk + + + + false diff --git a/src/testengine.module.pause/PauseFunction.cs b/src/testengine.module.pause/PauseFunction.cs index ce19f7a49..890ac353a 100644 --- a/src/testengine.module.pause/PauseFunction.cs +++ b/src/testengine.module.pause/PauseFunction.cs @@ -2,10 +2,10 @@ // Licensed under the MIT license. using Microsoft.Extensions.Logging; -using Microsoft.Playwright; using Microsoft.PowerApps.TestEngine.Config; using Microsoft.PowerApps.TestEngine.TestInfra; using Microsoft.PowerFx; +using Microsoft.PowerFx.Core.Utils; using Microsoft.PowerFx.Types; namespace testengine.module @@ -20,7 +20,7 @@ public class PauseFunction : ReflectionFunction private readonly ILogger _logger; public PauseFunction(ITestInfraFunctions testInfraFunctions, ITestState testState, ILogger logger) - : base("Pause", FormulaType.Blank) + : base(DPath.Root.Append(new DName("Experimental")), "Pause", FormulaType.Blank) { _testInfraFunctions = testInfraFunctions; _testState = testState; diff --git a/src/testengine.module.pause/testengine.module.pause.csproj b/src/testengine.module.pause/testengine.module.pause.csproj index 434c29cfd..ea3e03993 100644 --- a/src/testengine.module.pause/testengine.module.pause.csproj +++ b/src/testengine.module.pause/testengine.module.pause.csproj @@ -1,7 +1,7 @@  - net6.0 + net8.0 enable enable Microsoft @@ -13,13 +13,20 @@ Intial Alpha release of Microsoft.PowerAppsTestEngine - true - ../../35MSSharedLib1024.snk - true © Microsoft Corporation. All rights reserved. true 1.0 + + + true + true + ../../35MSSharedLib1024.snk + + + + false + @@ -28,8 +35,8 @@ - - + + diff --git a/src/testengine.module.playwrightaction.tests/PlaywrightActionFunctionTests.cs b/src/testengine.module.playwrightaction.tests/PlaywrightActionFunctionTests.cs new file mode 100644 index 000000000..08f485d12 --- /dev/null +++ b/src/testengine.module.playwrightaction.tests/PlaywrightActionFunctionTests.cs @@ -0,0 +1,155 @@ +using System.Text.RegularExpressions; +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.TestInfra; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Moq; +using testengine.module.tests.common; + +namespace testengine.module.browserlocale.tests +{ + public class PlaywrightActionFunctionTests + { + private Mock MockTestInfraFunctions; + private Mock MockTestState; + private Mock MockTestWebProvider; + private Mock MockSingleTestInstanceState; + private Mock MockFileSystem; + private Mock MockPage; + private PowerFxConfig TestConfig; + private NetworkRequestMock TestNetworkRequestMock; + private Mock MockLogger; + + public PlaywrightActionFunctionTests() + { + MockTestInfraFunctions = new Mock(MockBehavior.Strict); + MockTestState = new Mock(MockBehavior.Strict); + MockTestWebProvider = new Mock(); + MockSingleTestInstanceState = new Mock(MockBehavior.Strict); + MockFileSystem = new Mock(MockBehavior.Strict); + MockPage = new Mock(MockBehavior.Strict); + TestConfig = new PowerFxConfig(); + TestNetworkRequestMock = new NetworkRequestMock(); + MockLogger = new Mock(MockBehavior.Strict); + } + + private void RunTestScenario(string id) + { + switch (id) + { + case "click": + MockTestInfraFunctions.Setup(x => x.ClickAsync("//foo")).Returns(Task.CompletedTask); + break; + case "navigate": + MockPage.Setup(x => x.GotoAsync("https://make.powerapps.com", null)).Returns(Task.FromResult(new Mock().Object)); + break; + case "wait": + MockPage.Setup(x => x.WaitForSelectorAsync("//foo", null)).Returns(Task.FromResult(null)); + break; + case "exists": + case "exists-true": + var mockLocator = new Mock(); + MockPage.Setup(x => x.Locator("//foo", null)).Returns(mockLocator.Object); + mockLocator.Setup(x => x.CountAsync()).Returns(Task.FromResult(id == "exists" ? 0 : 1)); + break; + } + } + + [Theory] + [InlineData("//foo", "click", "click", new string[] { "Click item" }, true)] + [InlineData("https://make.powerapps.com", "navigate", "navigate", new string[] { "Navigate to page" }, true)] + [InlineData("//foo", "wait", "wait", new string[] { "Wait for locator" }, true)] + [InlineData("//foo", "exists", "exists", new string[] { "Check if locator exists", "Exists False" }, false)] + [InlineData("//foo", "exists", "exists-true", new string[] { "Check if locator exists", "Exists True" }, false)] + public void PlaywrightExecute(string locator, string value, string scenario, string[] messages, bool standardEnd) + { + // Arrange + + var function = new PlaywrightActionFunction(MockTestInfraFunctions.Object, MockTestState.Object, MockLogger.Object); + var mockBrowserContext = new Mock(); + + MockLogger.Setup(x => x.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny())); + + MockPage.Setup(x => x.Url).Returns("http://localhost"); + + MockTestInfraFunctions.Setup(x => x.GetContext()).Returns(mockBrowserContext.Object); + mockBrowserContext.Setup(x => x.Pages).Returns(new List() { MockPage.Object }); + + RunTestScenario(scenario); + + // Act + function.Execute(StringValue.New(locator), StringValue.New(value)); + + // Assert + MockLogger.VerifyMessage(LogLevel.Information, "------------------------------\n\n" + + "Executing PlaywrightAction function."); + + foreach (var message in messages) + { + MockLogger.VerifyMessage(LogLevel.Information, message); + } + + if (standardEnd) + { + MockLogger.VerifyMessage(LogLevel.Information, "Successfully finished executing PlaywrightAction function."); + } + } + + [Theory] + [InlineData("about:blank", 0, "//foo")] + [InlineData("about:blank,https://localhost", 1, "//foo")] + [InlineData("about:blank,https://localhost,https://microsoft.com", 1, "//foo")] + public void WaitPage(string pages, int waitOnPage, string locator) + { + // Arrange + + var function = new PlaywrightActionFunction(MockTestInfraFunctions.Object, MockTestState.Object, MockLogger.Object); + var mockBrowserContext = new Mock(); + + MockLogger.Setup(x => x.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny())); + + MockTestInfraFunctions.Setup(x => x.GetContext()).Returns(mockBrowserContext.Object); + + var mockPages = new List(); + int index = 0; + foreach (var page in pages.Split(',')) + { + var mockPage = new Mock(); + + if (index <= waitOnPage) + { + mockPage.Setup(m => m.Url).Returns(page); + } + + if (index == waitOnPage) + { + mockPage.Setup(x => x.WaitForSelectorAsync(locator, null)).Returns(Task.FromResult(null)); + } + + mockPages.Add(mockPage.Object); + } + + mockBrowserContext.Setup(x => x.Pages).Returns(mockPages); + + // Act + function.Execute(StringValue.New(locator), StringValue.New("wait")); + + // Assert + Mock.VerifyAll(); + } + } +} diff --git a/src/testengine.module.playwrightaction.tests/PlaywrightActionFunctionValueTests.cs b/src/testengine.module.playwrightaction.tests/PlaywrightActionFunctionValueTests.cs new file mode 100644 index 000000000..ca167fde6 --- /dev/null +++ b/src/testengine.module.playwrightaction.tests/PlaywrightActionFunctionValueTests.cs @@ -0,0 +1,133 @@ +using System.Text.RegularExpressions; +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.TestInfra; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Moq; +using testengine.module.tests.common; + +namespace testengine.module.browserlocale.tests +{ + public class PlaywrightActionValueFunctionTests + { + private Mock MockTestInfraFunctions; + private Mock MockTestState; + private Mock MockTestWebProvider; + private Mock MockSingleTestInstanceState; + private Mock MockFileSystem; + private Mock MockPage; + private PowerFxConfig TestConfig; + private NetworkRequestMock TestNetworkRequestMock; + private Mock MockLogger; + private Mock MockLocator; + + public PlaywrightActionValueFunctionTests() + { + MockTestInfraFunctions = new Mock(MockBehavior.Strict); + MockTestState = new Mock(MockBehavior.Strict); + MockTestWebProvider = new Mock(); + MockSingleTestInstanceState = new Mock(MockBehavior.Strict); + MockFileSystem = new Mock(MockBehavior.Strict); + MockPage = new Mock(MockBehavior.Strict); + TestConfig = new PowerFxConfig(); + TestNetworkRequestMock = new NetworkRequestMock(); + MockLogger = new Mock(MockBehavior.Strict); + MockLocator = new Mock(); + } + + private void RunTestScenario(string id) + { + switch (id) + { + case "click-in-iframe": + var mockFrame = new Mock