From 97302506ba4a4a7af916e1d5b0e159df53156e84 Mon Sep 17 00:00:00 2001 From: Grant Archibald <31553604+Grant-Archibald-MS@users.noreply.github.com> Date: Fri, 16 May 2025 22:45:19 -0700 Subject: [PATCH 1/7] Adding Preview.AssertJavaScript --- samples/javascript-d365-tests/README.md | 92 +++ samples/javascript-d365-tests/RunTests.ps1 | 587 ++++++++++++++++++ samples/javascript-d365-tests/commandBar.js | 84 +++ .../javascript-d365-tests/commandBar.te.yaml | 59 ++ samples/javascript-d365-tests/formScripts.js | 94 +++ .../javascript-d365-tests/formScripts.te.yaml | 50 ++ samples/javascript-d365-tests/mockXrm.js | 181 ++++++ .../javascript-d365-tests/testSettings.yaml | 12 + samples/javascript-d365-tests/validation.js | 94 +++ .../javascript-d365-tests/validation.te.yaml | 62 ++ samples/javascript-d365-tests/visibility.js | 106 ++++ .../javascript-d365-tests/visibility.te.yaml | 64 ++ ...icrosoft.PowerApps.TestEngine.Tests.csproj | 2 +- .../AssertJavaScriptFunctionTests.cs | 581 +++++++++++++++++ .../PowerFXModel/ControlRecordValueTests.cs | 10 +- .../Microsoft.PowerApps.TestEngine.csproj | 1 + .../Functions/AssertJavaScriptFunction.cs | 387 ++++++++++++ .../PowerFx/PowerFxEngine.cs | 13 +- .../testengine.common.user.tests.csproj | 2 +- .../testengine.provider.mda.tests.csproj | 2 +- 20 files changed, 2473 insertions(+), 10 deletions(-) create mode 100644 samples/javascript-d365-tests/README.md create mode 100644 samples/javascript-d365-tests/RunTests.ps1 create mode 100644 samples/javascript-d365-tests/commandBar.js create mode 100644 samples/javascript-d365-tests/commandBar.te.yaml create mode 100644 samples/javascript-d365-tests/formScripts.js create mode 100644 samples/javascript-d365-tests/formScripts.te.yaml create mode 100644 samples/javascript-d365-tests/mockXrm.js create mode 100644 samples/javascript-d365-tests/testSettings.yaml create mode 100644 samples/javascript-d365-tests/validation.js create mode 100644 samples/javascript-d365-tests/validation.te.yaml create mode 100644 samples/javascript-d365-tests/visibility.js create mode 100644 samples/javascript-d365-tests/visibility.te.yaml create mode 100644 src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/AssertJavaScriptFunctionTests.cs create mode 100644 src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs diff --git a/samples/javascript-d365-tests/README.md b/samples/javascript-d365-tests/README.md new file mode 100644 index 000000000..f7877d358 --- /dev/null +++ b/samples/javascript-d365-tests/README.md @@ -0,0 +1,92 @@ +# JavaScript Testing for Dynamics 365 Client-side Code + +This sample demonstrates how to use the PowerApps Test Engine to test JavaScript code commonly used in Dynamics 365 applications, particularly focusing on: + +1. Command bar button customizations +2. Form visibility/display logic +3. Field validation and business rules +4. Form event handlers + +## Sample Structure + +### Core JavaScript Files (D365 Webresource Pattern) + +- **mockXrm.js**: Mock implementation of the Xrm client-side object model +- **commandBar.js**: Command bar button functions +- **formScripts.js**: Form event handlers +- **visibility.js**: Field/section/tab visibility control +- **validation.js**: Data validation functions + +### Test Configuration & Scripts + +- **commandBar.te.yaml**: Command bar-specific test file +- **formScripts.te.yaml**: Form script-specific test file +- **validation.te.yaml**: Validation-specific test file +- **visibility.te.yaml**: Visibility logic-specific test file +- **RunTests.ps1**: PowerShell script to execute all tests and generate detailed reports +- **config.json**: Configuration file for test execution + +## How to Run + +1. Ensure PowerApps Test Engine is installed +2. Configure your environment in `config.json` with your tenant ID, environment ID, and URL +3. Run all tests with detailed reporting: `.\RunTests.ps1` +4. Optional parameters: + - Compile before running: `.\RunTests.ps1 -compile` + - Record test interactions: `.\RunTests.ps1 -record` + - Specify time threshold: `.\RunTests.ps1 -lastRunTime "2025-05-15 10:00"` + +## Understanding the Tests + +These tests use the `AssertJavaScript` PowerFx function to execute client-side D365 code against a mock Xrm object. The component test configuration files follow this pattern: + +```yaml +testCaseName: CommandBar_NewButtonAlwaysEnabled +description: Tests that new button is always enabled +testSteps: | + = Preview.AssertJavaScript({ + Location="commandBar.js", + Setup="mockXrm.js", + Run: "isCommandEnabled('new')", + Expected: "true" + }); +``` + +Each test specifies: +- **Location**: JavaScript file containing the function to test +- **Setup**: JavaScript setup code or path to a file (like mockXrm.js) +- **Run**: The JavaScript code to execute, directly calling the functions +- **Expected**: The expected result of the test + +The mock framework provides fake implementations of common D365 client-side APIs, allowing you to test form scripts without an actual Dynamics 365 instance. + +## D365 JavaScript Structure + +The project follows modern Dynamics 365 web resource patterns: + +1. **Direct Function Declarations**: All JavaScript is organized as directly callable functions + ```javascript + function isCommandEnabled(commandName) { ... } + ``` + +2. **Testable Functions**: Functions are designed to be independently testable + ```javascript + function validatePhoneNumber(phoneNumber) { ... } + ``` + +3. **Separation of Concerns**: Each JavaScript file focuses on a specific area + - Command bar customizations + - Form event handlers + - Field visibility logic + - Data validation + +## Detailed HTML Reports + +The `RunTests.ps1` script generates detailed HTML reports with: + +- Pass/fail statistics for each component +- Health score calculation (passing files / total files × 100) +- Charts and metrics for test performance +- Execution time analysis + +To view the most recent report, check the `TestResults` folder after running the tests. diff --git a/samples/javascript-d365-tests/RunTests.ps1 b/samples/javascript-d365-tests/RunTests.ps1 new file mode 100644 index 000000000..daa229a6a --- /dev/null +++ b/samples/javascript-d365-tests/RunTests.ps1 @@ -0,0 +1,587 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Check for optional command line argument for last run time +param ( + [string]$lastRunTime, + [switch]$compile, + [switch]$record +) + +# Get current directory so we can reset back to it after running the tests +$currentDirectory = Get-Location + +$timeStart = Get-Date + +# Check for config.json file +if (Test-Path -Path .\config.json) { + $jsonContent = Get-Content -Path .\config.json -Raw + $config = $jsonContent | ConvertFrom-Json + $tenantId = $config.tenantId + $environmentId = $config.environmentId + $environmentUrl = $config.environmentUrl + $staticContext = $config.useStaticContext + $debugTests = $config.debugTests +} else { + # Default values if no config file is present + $tenantId = $null + $environmentId = $null + $environmentUrl = $null + $staticContext = $false + $debugTests = $false +} + +# Prompt for required values if they are missing +if ([string]::IsNullOrEmpty($tenantId)) { + $tenantId = Read-Host -Prompt "Enter your tenant ID" +} + +if ([string]::IsNullOrEmpty($environmentId)) { + $environmentId = Read-Host -Prompt "Enter your environment ID" +} + +if ([string]::IsNullOrEmpty($environmentUrl)) { + $environmentUrl = Read-Host -Prompt "Enter your environment URL (e.g., https://yourorg.crm.dynamics.com)" +} + +# Define the folder path for TestEngine output +$folderPath = "$env:USERPROFILE\AppData\Local\Temp\Microsoft\TestEngine\TestOutput" + +# Set values for flags +$debugTestValue = $debugTests ? "TRUE" : "FALSE" +$staticContextValue = $staticContext ? "TRUE" : "FALSE" + +# Initialize the dictionary (hash table) for test results +$dictionary = @{} + +# Define the TestData class +class TestData { + [string]$TestFile + [int]$PassCount + [int]$FailCount + [bool]$IsSuccess + + TestData([string]$testFile, [int]$passCount, [int]$failCount, [bool]$isSuccess) { + $this.TestFile = $testFile + $this.PassCount = $passCount + $this.FailCount = $failCount + $this.IsSuccess = $isSuccess + } + + # Override ToString method for better display + [string]ToString() { + return "TestFile: $($this.TestFile), PassCount: $($this.PassCount), FailCount: $($this.FailCount), IsSuccess: $($this.IsSuccess)" + } +} + +# Function to add or update a key-value pair +function AddOrUpdate { + param ( + [string]$key, + [object]$value + ) + + if ($dictionary.ContainsKey($key)) { + # Update the pass/fail properties if the key exists + $dictionary[$key].PassCount += $value.PassCount + $dictionary[$key].FailCount += $value.FailCount + if (-not $value.IsSuccess) { + $dictionary[$key].IsSuccess = $false + } + Write-Host "Updated key '$key' with value '$($dictionary[$key])'." + } else { + # Add the key-value pair if the key does not exist + $dictionary[$key] = $value + Write-Host "Added key '$key' with value '$value'." + } +} + +# Function to update test data based on .trx files +function Update-TestData { + param ( + [string]$folderPath, + [datetime]$timeThreshold, + [string]$testFile + ) + + AddOrUpdate -key $testFile -value (New-Object TestData($testFile, 0, 0, $true)) + + # Find all folders newer than the specified time + $folders = Get-ChildItem -Path $folderPath -Directory | Where-Object { $_.LastWriteTime -gt $timeThreshold } + + # Initialize array to store .trx files + $trxFiles = @() + + # Iterate through each folder and find .trx files + foreach ($folder in $folders) { + $trxFiles += Get-ChildItem -Path $folder.FullName -Filter "*.trx" + } + + # Parse each .trx file and update pass/fail counts in TestData + foreach ($trxFile in $trxFiles) { + $xmlContent = Get-Content -Path $trxFile.FullName -Raw + $xml = [xml]::new() + $xml.LoadXml($xmlContent) + + # Create a namespace manager + $namespaceManager = New-Object System.Xml.XmlNamespaceManager($xml.NameTable) + $namespaceManager.AddNamespace("ns", "http://microsoft.com/schemas/VisualStudio/TeamTest/2010") + + # Find the Counters element + $counters = $xml.SelectSingleNode("//ns:Counters", $namespaceManager) + + # Extract the counter properties and update TestData + if ($counters) { + $passCount = [int]$counters.passed + $failCount = [int]$counters.failed + $isSuccess = $failCount -eq 0 -and $passCount -gt 0 + + AddOrUpdate -key $testFile -value (New-Object TestData($testFile, $passCount, $failCount, $isSuccess)) + } + } +} + +# Verify Azure CLI context +if ($tenantId) { + $azTenantId = az account show --query tenantId --output tsv 2>$null + + if ($azTenantId -ne $tenantId) { + Write-Warning "Azure CLI tenant ID ($azTenantId) does not match specified tenant ID ($tenantId)." + $useCliAuth = Read-Host -Prompt "Would you like to log in with the correct tenant ID? (Y/N)" + + if ($useCliAuth -eq "Y" -or $useCliAuth -eq "y") { + az login --tenant $tenantId + } + } +} + +# Build the latest debug version of Test Engine from source if requested +if ($compile) { + Set-Location "$currentDirectory\..\..\src" + Write-Host "Compiling the project..." + dotnet build + + # Install Playwright if needed + Start-Process -FilePath "pwsh" -ArgumentList "-Command `"..\bin\Debug\PowerAppsTestEngine\playwright.ps1 install`"" -Wait + + Set-Location ..\bin\Debug\PowerAppsTestEngine +} else { + # Just navigate to the compiled binary + Write-Host "Using pre-compiled binaries..." + Set-Location ..\..\bin\Debug\PowerAppsTestEngine +} + +# Find all the *.te.yaml files in the JavaScript tests directory +$testFiles = Get-ChildItem -Path "$currentDirectory\*.te.yaml" -File + +Write-Host "Found $($testFiles.Count) test files to execute." + +$totalTests = $testFiles.Count +$passedTests = 0 + +# Execute each test file +foreach ($testFile in $testFiles) { + $testFilePath = $testFile.FullName + $testFileName = $testFile.Name + + Write-Host "----------------------------------------" -ForegroundColor Yellow + Write-Host "Executing test: $testFileName" -ForegroundColor Yellow + Write-Host "----------------------------------------" -ForegroundColor Yellow + + $testStart = Get-Date + + dotnet PowerAppsTestEngine.dll -p "powerfx" -i "$testFilePath" -t $tenantId -e $environmentId -d "$environmentUrl" -l "Debug" + + # Update test results data + Update-TestData -folderPath $folderPath -timeThreshold $testStart -testFile $testFileName +} + +# Reset the location back to the original directory +Set-Location $currentDirectory + +# Set the timeThreshold if provided as parameter +if ($lastRunTime) { + try { + $timeThreshold = [datetime]::ParseExact($lastRunTime, "yyyy-MM-dd HH:mm", $null) + } catch { + Write-Error "Invalid date format. Please use 'yyyy-MM-DD HH:mm'." + $timeThreshold = $timeStart + } +} else { + $timeThreshold = $timeStart +} + +# Generate HTML summary report with timestamp +$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +$reportPath = "$folderPath\summary_report_$timestamp.html" + +# Function to generate HTML table representation of the TestData dictionary +function Generate-HTMLTable { + param ( + [hashtable]$dictionary, + [int]$total + ) + + # Initialize HTML table + $htmlTable = @" +

Test Execution Results

+ + + + + + + +"@ + + $passedFiles = 0 + # Iterate through the dictionary and add rows to the table + foreach ($key in $dictionary.Keys | Sort-Object) { + $value = $dictionary[$key] + if (-not [string]::IsNullOrEmpty($value)) { + $status = $value.IsSuccess ? "Pass" : "Fail" + $statusClass = $value.IsSuccess ? "pass" : "fail" + + if ($value.IsSuccess) { + $passedFiles++ + } + + $htmlTable += "" + } + } + + # Close the table + $htmlTable += "
Test FilePass CountFail CountStatus
$($value.TestFile)$($value.PassCount)$($value.FailCount)$status
" + + $healthPercentage = $total -eq 0 ? "0" : ($passedFiles / $total * 100).ToString("0") + + # Add calculation and formula + $mathFormula = @" +

Test Health Calculation:

+
+

Health Percentage = (Number of Passing Test Files / Total Test Files) × 100

+

Health Percentage = ($passedFiles / $total) × 100 = $healthPercentage%

+
+"@ + + $htmlTable += $mathFormula + + return @{ + Table = $htmlTable + HealthPercentage = $healthPercentage + PassedFiles = $passedFiles + } +} + +# Find all folders newer than the specified time +$folders = Get-ChildItem -Path $folderPath -Directory | Where-Object { $_.LastWriteTime -gt $timeThreshold } + +# Initialize arrays to store .trx files and test results +$trxFiles = @() +$testResults = @() + +# Iterate through each folder and find .trx files +foreach ($folder in $folders) { + $trxFiles += Get-ChildItem -Path $folder.FullName -Filter "*.trx" +} + +# Parse each .trx file and count pass and fail tests +foreach ($trxFile in $trxFiles) { + $xmlContent = Get-Content -Path $trxFile.FullName -Raw + $xml = [xml]::new() + $xml.LoadXml($xmlContent) + + # Create a namespace manager + $namespaceManager = New-Object System.Xml.XmlNamespaceManager($xml.NameTable) + $namespaceManager.AddNamespace("ns", "http://microsoft.com/schemas/VisualStudio/TeamTest/2010") + + # Find the Counters element + $counters = $xml.SelectSingleNode("//ns:Counters", $namespaceManager) + + # Extract the counter properties + $total = [int]$counters.total + $executed = [int]$counters.executed + $passed = [int]$counters.passed + $failed = [int]$counters.failed + $error = [int]$counters.error + $timeout = [int]$counters.timeout + $aborted = [int]$counters.aborted + $inconclusive = [int]$counters.inconclusive + $passedButRunAborted = [int]$counters.passedButRunAborted + $notRunnable = [int]$counters.notRunnable + $notExecuted = [int]$counters.notExecuted + $disconnected = [int]$counters.disconnected + $warning = [int]$counters.warning + $completed = [int]$counters.completed + $inProgress = [int]$counters.inProgress + $pending = [int]$counters.pending + + $testResults += [PSCustomObject]@{ + File = $trxFile.FullName + Total = $total + Executed = $executed + Passed = $passed + Failed = $failed + Error = $error + Timeout = $timeout + Aborted = $aborted + Inconclusive = $inconclusive + PassedButRunAborted = $passedButRunAborted + NotRunnable = $notRunnable + NotExecuted = $notExecuted + Disconnected = $disconnected + Warning = $warning + Completed = $completed + InProgress = $inProgress + Pending = $pending + } +} + +# Calculate totals +$passCount = ($testResults | Measure-Object -Property Passed -Sum).Sum +$failCount = ($testResults | Measure-Object -Property Failed -Sum).Sum +$totalTestCases = $passCount + $failCount + +# Generate table and get health stats +$tableResult = Generate-HTMLTable -dictionary $dictionary -total $testFiles.Count +$healthPercentage = $tableResult.HealthPercentage +$healthPercentageValue = [int]$healthPercentage +$passedFiles = $tableResult.PassedFiles + +# Create HTML report +$htmlReport = @" + + + + + + D365 JavaScript Test Results + + + + + + +

D365 JavaScript Test Results

+

Run Date: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")

+ +
+
+
Test Files
+
$($testFiles.Count)
+
+
+
Passing Files
+
$passedFiles / $($testFiles.Count)
+
+
+
Test Cases
+
$totalTestCases
+
+
+
Health Score
+
$healthPercentage%
+
+
+ +
+

Test Results Summary

+ +
+ +
+

Test Files Status

+ +
+
+ $($tableResult.Table) +
+ +
+

Interactive Test Results Table

+
+
+ + + + +"@ + +# Save HTML report +$htmlReport | Out-File -FilePath $reportPath -Encoding UTF8 + +Write-Host "HTML summary report generated at $reportPath" +Write-Host "Opening report in browser..." + +# Open the report in the default browser +Invoke-Item $reportPath diff --git a/samples/javascript-d365-tests/commandBar.js b/samples/javascript-d365-tests/commandBar.js new file mode 100644 index 000000000..f8f739e53 --- /dev/null +++ b/samples/javascript-d365-tests/commandBar.js @@ -0,0 +1,84 @@ +/** + * commandBar.js - Common command bar functions for Dynamics 365 + * These functions would be called directly from command bar buttons. + */ + +/** + * Determines if the specified command should be enabled based on the current entity state + * @param {string} commandName - Name of the command + * @returns {boolean} - True if the command should be enabled, false otherwise + */ +function isCommandEnabled(commandName) { + // Get the current entity state + var accountStatus = Xrm.Page.getAttribute("accountstatus"); + var statusValue = accountStatus ? accountStatus.getValue() : null; + + // Determine which commands should be enabled + switch(commandName) { + case "new": + // New command is always available + return true; + + case "activate": + // Activate command is only available for inactive records + return statusValue === 0; // 0 = Inactive + + case "deactivate": + // Deactivate command is only available for active records + return statusValue === 1; // 1 = Active + + default: + // Unknown command + return false; + } +}; + +/** + * Validates if an account can be deactivated based on business rules + * @returns {boolean} - True if deactivation is allowed, false otherwise + */ +CommandBar.validateBeforeDeactivate = function() { + // Check if there are active cases + var activeCasesCount = Xrm.Page.getAttribute("activecases_count"); + + // Check if there are active cases + if (activeCasesCount && activeCasesCount.getValue() > 0) { + Xrm.Utility.alertDialog("Cannot deactivate account with active cases."); + return false; + } + + return true; +}; + +/** + * Activates the current account record + * @returns {boolean} - True if operation was successful + */ +CommandBar.activateAccount = function() { + // Implementation would call WebAPI to update record + var accountStatus = Xrm.Page.getAttribute("accountstatus"); + if (accountStatus) { + accountStatus.setValue(1); // 1 = Active + return true; + } + return false; +}; + +/** + * Deactivates the current account record + * @returns {boolean} - True if operation was successful + */ +CommandBar.deactivateAccount = function() { + // Check if deactivation is allowed + if (!CommandBar.validateBeforeDeactivate()) { + return false; + } + + // Implementation would call WebAPI to update record + var accountStatus = Xrm.Page.getAttribute("accountstatus"); + if (accountStatus) { + accountStatus.setValue(0); // 0 = Inactive + return true; + } + return false; +}; diff --git a/samples/javascript-d365-tests/commandBar.te.yaml b/samples/javascript-d365-tests/commandBar.te.yaml new file mode 100644 index 000000000..ae57b02f5 --- /dev/null +++ b/samples/javascript-d365-tests/commandBar.te.yaml @@ -0,0 +1,59 @@ +# yaml-embedded-languages: powerfx +testSuite: + testSuiteName: Command Bar Tests + testSuiteDescription: Tests for Dynamics 365 command bar functionality using PowerFx format + persona: User1 + appLogicalName: na + + testCases: + - testCaseName: CommandBar_NewButtonAlwaysEnabled + testCaseDescription: Tests that new button is always enabled + testSteps: | + = Preview.AssertJavaScript({Location:"commandBar.js", Setup:"mockXrm.js", Run: Join([ + "isCommandEnabled('new')" + ]), Expected: "true" }); + - testCaseName: CommandBar_ActivateButtonEnabledForInactiveAccount + testCaseDescription: Tests that activate button is enabled for inactive accounts + testSteps: | + = Preview.AssertJavaScript({Location:"commandBar.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.getAttribute('accountstatus').setValue(0)", + "isCommandEnabled('activate')" + ]), Expected: "true" }); + - testCaseName: CommandBar_ActivateButtonDisabledForActiveAccount + testCaseDescription: Tests that activate button is disabled for active accounts + testSteps: | + = Preview.AssertJavaScript({Location:"commandBar.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.getAttribute('accountstatus').setValue()", + "isCommandEnabled('activate')" + ]), Expected: "false" }); + - testCaseName: CommandBar_DeactivateButtonEnabledForActiveAccount + testCaseDescription: Tests that deactivate button is enabled for active accounts + testSteps: | + = Preview.AssertJavaScript({Location:"commandBar.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.getAttribute('accountstatus').setValue(1)", + "isCommandEnabled('deactivate')" + ]), Expected: "true" }); + - testCaseName: CommandBar_DeactivateButtonDisabledForInactiveAccount + testCaseDescription: Tests that deactivate button is disabled for inactive accounts + testSteps: | + = Preview.AssertJavaScript({Location:"commandBar.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.getAttribute('accountstatus').setValue(0)", + "isCommandEnabled('deactivate')" + ]), Expected: "false" }); + - testCaseName: CommandBar_DeactivatePreventedWithActiveCases + testCaseDescription: Tests that deactivation is prevented when there are active cases + testSteps: | + = Preview.AssertJavaScript({Location:"commandBar.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.getAttribute('accountstatus').setValue(1)", + "Xrm.Page.getAttribute('activecases_count').setValue(3)", + "validateBeforeDeactivate()" + ]), Expected: "false" }); + +testSettings: + filePath: ./testSettings.yaml + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded \ No newline at end of file diff --git a/samples/javascript-d365-tests/formScripts.js b/samples/javascript-d365-tests/formScripts.js new file mode 100644 index 000000000..b5984dddc --- /dev/null +++ b/samples/javascript-d365-tests/formScripts.js @@ -0,0 +1,94 @@ +/** + * formScripts.js - Common form script functions for Dynamics 365 + * These functions would be registered as event handlers for form events. + */ + +/** + * Handles the form onLoad event + * @param {object} executionContext - The execution context + * @returns {boolean} - True if operation was successful + */ +function onLoad(executionContext) { + // Set default values for new forms + var formType = Xrm.Page.ui.getFormType(); + + // Form type 1 = Create + if (formType === 1) { + var countryAttribute = Xrm.Page.getAttribute("address1_country"); + if (countryAttribute && !countryAttribute.getValue()) { + countryAttribute.setValue("United States"); + } + } + + // Set up field event handlers + setupFieldEventHandlers(); + + return true; +} + +/** + * Set up event handlers for various fields + * @returns {boolean} - True if operation was successful + */ +function setupFieldEventHandlers() { + var creditLimitAttribute = Xrm.Page.getAttribute("creditlimit"); + if (creditLimitAttribute) { + creditLimitAttribute.addOnChange(creditLimitOnChange); + } + + var customerTypeAttribute = Xrm.Page.getAttribute("customertype"); + if (customerTypeAttribute) { + customerTypeAttribute.addOnChange(customerTypeOnChange); + } + + return true; +} + +/** + * Handles changes to the credit limit field + * @returns {boolean} - True if operation was successful + */ +function creditLimitOnChange() { + var creditLimit = Xrm.Page.getAttribute("creditlimit").getValue(); + var creditScoreAttribute = Xrm.Page.getAttribute("creditscore"); + + if (creditLimit && creditLimit > 10000) { + // For high credit limits, ensure credit score is recorded + if (creditScoreAttribute) { + creditScoreAttribute.setRequiredLevel("required"); + } + } else { + // For normal credit limits, credit score is recommended + if (creditScoreAttribute) { + creditScoreAttribute.setRequiredLevel("recommended"); + } + } + + return true; +} + +/** + * Handles changes to the customer type field + * @returns {boolean} - True if operation was successful + */ +function customerTypeOnChange() { + var customerType = Xrm.Page.getAttribute("customertype").getValue(); + + // Show/hide tabs based on customer type + var partnerTab = Xrm.Page.ui.tabs.get("partnertab"); + var consumerTab = Xrm.Page.ui.tabs.get("tab_consumer"); + + if (customerType === 2) { // 2 = Partner + if (partnerTab) partnerTab.setVisible(true); + if (consumerTab) consumerTab.setVisible(false); + } else if (customerType === 1) { // 1 = Consumer + if (partnerTab) partnerTab.setVisible(false); + if (consumerTab) consumerTab.setVisible(true); + } else { + // Default case - show both tabs + if (partnerTab) partnerTab.setVisible(true); + if (consumerTab) consumerTab.setVisible(true); + } + + return true; +} diff --git a/samples/javascript-d365-tests/formScripts.te.yaml b/samples/javascript-d365-tests/formScripts.te.yaml new file mode 100644 index 000000000..57e978219 --- /dev/null +++ b/samples/javascript-d365-tests/formScripts.te.yaml @@ -0,0 +1,50 @@ +# yaml-embedded-languages: powerfx +testSuite: + testSuiteName: Form Script Tests + testSuiteDescription: Tests for Dynamics 365 form script functionality using PowerFx format + persona: User1 + appLogicalName: na + + testCases: + - testCaseName: FormScripts_DefaultCountryForNewForms + testCaseDescription: Tests that default country is set for new forms + testSteps: | + = Preview.AssertJavaScript({Location:"formScripts.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.ui.formType = 1", + "onLoad()", + "Xrm.Page.getAttribute('address1_country').getValue() === 'United States'" + ]), Expected: "true" }); + - testCaseName: FormScripts_UnchangedCountryForExistingForms + testCaseDescription: Tests that country is not changed for existing forms + testSteps: | + = Preview.AssertJavaScript({Location:"formScripts.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.ui.formType = 2", + "Xrm.Page.getAttribute('address1_country').setValue('Canada')", + "onLoad()", + "Xrm.Page.getAttribute('address1_country').getValue() === 'Canada'" + ]), Expected: "true" }); + - testCaseName: FormScripts_CreditLimitRequiresScoreForHighLimit + testCaseDescription: Tests that credit score is required for high credit limits + testSteps: | + = Preview.AssertJavaScript({Location:"formScripts.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.getAttribute('creditlimit').setValue(100000)", + "creditLimitOnChange()", + "Xrm.Page.getControl('creditscore').getRequiredLevel() === 'required'" + ]), Expected: "true" }); + - testCaseName: FormScripts_CustomerTypeShowsPartnerTab + testCaseDescription: Tests that partner tab is shown for partner customer type + testSteps: | + = Preview.AssertJavaScript({Location:"formScripts.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.getAttribute('customertype').setValue(2)", + "customerTypeOnChange()", + "Xrm.Page.ui.tabs.get('partnertab').getVisible()" + ]), Expected: "true" }); + +testSettings: + filePath: ./testSettings.yaml + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded diff --git a/samples/javascript-d365-tests/mockXrm.js b/samples/javascript-d365-tests/mockXrm.js new file mode 100644 index 000000000..0014d3b7d --- /dev/null +++ b/samples/javascript-d365-tests/mockXrm.js @@ -0,0 +1,181 @@ +/** + * mockXrm.js - Mock implementation of Dynamics 365 Xrm object for testing + * This file provides a simple mock of the Xrm object to enable testing of client-side scripts + */ + +// Initialize global Xrm object +var Xrm = { + Page: { + ui: { + formType: 2, // Default to Update form type + tabs: { + items: {}, + get: function(tabName) { + if (!this.items[tabName]) { + this.items[tabName] = { + visible: true, + setVisible: function(visible) { this.visible = visible; }, + getVisible: function() { return this.visible; } + }; + } + return this.items[tabName]; + } + }, + sections: { + items: {}, + get: function(sectionName) { + if (!this.items[sectionName]) { + this.items[sectionName] = { + visible: true, + setVisible: function(visible) { this.visible = visible; }, + getVisible: function() { return this.visible; } + }; + } + return this.items[sectionName]; + } + }, + controls: {}, + getFormType: function() { return this.formType; }, + setFormType: function(type) { this.formType = type; } + }, + + data: { + entity: { + attributes: {} + } + }, + + getAttribute: function(attributeName) { + if (!this.attributes) { + this.attributes = {}; + } + + if (!this.attributes[attributeName]) { + this.attributes[attributeName] = { + value: null, + requiredLevel: "none", + handlers: [], + getValue: function() { return this.value; }, + setValue: function(value) { + this.value = value; + // Call registered handlers when value is changed + this.handlers.forEach(function(handler) { + handler(); + }); + }, + setRequiredLevel: function(level) { this.requiredLevel = level; }, + getRequiredLevel: function() { return this.requiredLevel; }, + addOnChange: function(handler) { this.handlers.push(handler); } + }; + } + + return this.attributes[attributeName]; + }, + + getControl: function(controlName) { + if (!this.ui.controls[controlName]) { + this.ui.controls[controlName] = { + visible: true, + notification: null, + setVisible: function(visible) { this.visible = visible; }, + getVisible: function() { return this.visible; }, + setNotification: function(message, id) { this.notification = { message: message, id: id }; }, + clearNotification: function(id) { + if (this.notification && this.notification.id === id) { + this.notification = null; + } + }, + getNotification: function() { return this.notification; } + }; + } + + return this.ui.controls[controlName]; + } + }, + + Utility: { + alertDialog: function(message, callback) { + console.log("Alert Dialog: " + message); + if (callback) callback(); + return true; + }, + confirmDialog: function(message, callback) { + console.log("Confirm Dialog: " + message); + if (callback) callback(true); // Always confirm in test + return true; + } + }, + + WebApi: { + online: true, + execute: function(request) { + console.log("WebApi.execute called with:", request); + // Mock implementation returns a resolved promise + return Promise.resolve({ + ok: true, + json: function() { + return Promise.resolve({ + value: "Mock API response" + }); + } + }); + }, + retrieveMultipleRecords: function(entityName, options, maxPageSize) { + console.log("WebApi.retrieveMultipleRecords called for:", entityName); + // Mock implementation returns a resolved promise with empty result set + return Promise.resolve({ + entities: [] + }); + } + } +}; + +// Add attributes to support testing +Xrm.Page.getAttribute("accountstatus").setValue(1); // Default to active +Xrm.Page.getAttribute("activecases_count").setValue(0); // Default to no active cases +Xrm.Page.getAttribute("accounttype").setValue(0); // Default to no specific type +Xrm.Page.getAttribute("industrycode").setValue(0); // Default to no specific industry +Xrm.Page.getAttribute("creditlimit").setValue(5000); // Default credit limit +Xrm.Page.getAttribute("creditscore").setValue(650); // Default credit score +Xrm.Page.getAttribute("customertype").setValue(0); // Default customer type + +// Create mock implementation for dialog display used in recommendations +window.showDialogResponse = true; +window.showModalDialog = function(url, args, options) { + console.log("Show Modal Dialog called with:", args); + return window.showDialogResponse; +}; + +// Functions for testing +function resetMockXrm() { + Xrm.Page.ui.formType = 2; + Xrm.Page.getAttribute("accountstatus").setValue(1); + Xrm.Page.getAttribute("activecases_count").setValue(0); + Xrm.Page.getAttribute("accounttype").setValue(0); + Xrm.Page.getAttribute("industrycode").setValue(0); + Xrm.Page.getAttribute("creditlimit").setValue(5000); + Xrm.Page.getAttribute("creditscore").setValue(650); + Xrm.Page.getAttribute("customertype").setValue(0); + window.showDialogResponse = true; +} + +// Add fetch xml support +Xrm.WebApi.retrieveRecords = function(entityType, options) { + console.log("WebApi.retrieveRecords called for:", entityType); + // Mock implementation returns a resolved promise with empty result set + return Promise.resolve({ + entities: [] + }); +}; + +// Add support for form context rather than just global form +Xrm.Page.context = { + getClientUrl: function() { + return "https://mock.crm.dynamics.com"; + } +}; + +// Add form event registration capability +Xrm.Page.data.entity.addOnSave = function(handler) { + // Just store the handler - not implemented for tests +}; \ No newline at end of file diff --git a/samples/javascript-d365-tests/testSettings.yaml b/samples/javascript-d365-tests/testSettings.yaml new file mode 100644 index 000000000..68d4ae69c --- /dev/null +++ b/samples/javascript-d365-tests/testSettings.yaml @@ -0,0 +1,12 @@ +locale: "en-US" +recordVideo: true +browserConfigurations: + - browser: Chromium +powerFxTestTypes: + - name: TextCollection + value: | + [{Value: Text}] +testFunctions: + - description: Join lines of text with a semicolon + code: | + Join(lines: TextCollection): Text = Concat(lines, Value, ";") \ No newline at end of file diff --git a/samples/javascript-d365-tests/validation.js b/samples/javascript-d365-tests/validation.js new file mode 100644 index 000000000..9010563f1 --- /dev/null +++ b/samples/javascript-d365-tests/validation.js @@ -0,0 +1,94 @@ +/** + * validation.js - Field validation functions for Dynamics 365 + * These functions would be used to validate form data. + */ + +/** + * Validates a phone number format + * @param {string} phoneNumber - The phone number to validate + * @returns {boolean} - True if valid, false otherwise + */ +function validatePhoneNumber(phoneNumber) { + if (!phoneNumber) return true; // Empty is valid + + // Simple validation - must be at least 10 digits + var digitsOnly = phoneNumber.replace(/\D/g, ''); + return digitsOnly.length >= 10; +} + +/** + * Validates an email address format + * @param {string} email - The email to validate + * @returns {boolean} - True if valid, false otherwise + */ +function validateEmail(email) { + if (!email) return true; // Empty is valid + + // Simple validation - must have @ and . + return email.includes('@') && email.includes('.') && email.indexOf('@') < email.lastIndexOf('.'); +} + +/** + * Validates credit limit based on credit score + * @param {number} creditLimit - The credit limit amount + * @param {number} creditScore - The credit score + * @returns {boolean} - True if valid, false otherwise + */ +function validateCreditLimit(creditLimit, creditScore) { + if (!creditLimit) return true; // Empty is valid + + // Based on credit score, enforce maximum credit limit + if (creditScore < 500) { + return creditLimit <= 1000; + } else if (creditScore < 600) { + return creditLimit <= 5000; + } else if (creditScore < 700) { + return creditLimit <= 25000; + } else { + // Good credit - no specific validation + return true; + } +} + +/** + * Performs validation for all fields on the account form + * @returns {boolean} - True if all validations pass, false otherwise + */ +function validateAccountForm() { + var isValid = true; + + // Validate phone + var phone = Xrm.Page.getAttribute("telephone1").getValue(); + var phoneControl = Xrm.Page.getControl("telephone1"); + + if (!validatePhoneNumber(phone)) { + phoneControl.setNotification("Phone number must have at least 10 digits", "phone_validation"); + isValid = false; + } else { + phoneControl.clearNotification("phone_validation"); + } + + // Validate email + var email = Xrm.Page.getAttribute("emailaddress1").getValue(); + var emailControl = Xrm.Page.getControl("emailaddress1"); + + if (!validateEmail(email)) { + emailControl.setNotification("Please enter a valid email address", "email_validation"); + isValid = false; + } else { + emailControl.clearNotification("email_validation"); + } + + // Validate credit limit + var creditLimit = Xrm.Page.getAttribute("creditlimit").getValue(); + var creditScore = Xrm.Page.getAttribute("creditscore").getValue() || 0; + var creditLimitControl = Xrm.Page.getControl("creditlimit"); + + if (!validateCreditLimit(creditLimit, creditScore)) { + creditLimitControl.setNotification("Credit limit too high for current credit score", "creditlimit_validation"); + isValid = false; } else { + creditLimitControl.clearNotification("creditlimit_validation"); + } + + return isValid; +} diff --git a/samples/javascript-d365-tests/validation.te.yaml b/samples/javascript-d365-tests/validation.te.yaml new file mode 100644 index 000000000..ecf01537f --- /dev/null +++ b/samples/javascript-d365-tests/validation.te.yaml @@ -0,0 +1,62 @@ +# yaml-embedded-languages: powerfx +testSuite: + testSuiteName: Validation Tests + testSuiteDescription: Tests for Dynamics 365 validation functionality using PowerFx format + persona: User1 + appLogicalName: na + + testCases: + - testCaseName: Validation_PhoneNumberAcceptsValidFormats + testCaseDescription: Tests that phone validation passes for valid formats + testSteps: | + = Preview.AssertJavaScript({Location:"validation.js", Setup:"mockXrm.js", Run: Join([ + "validatePhoneNumber('(555) 123-4567')" + ]), Expected: "true" }); + - testCaseName: Validation_PhoneNumberRejectsInvalidFormats + testCaseDescription: Tests that phone validation fails for invalid formats + testSteps: | + = Preview.AssertJavaScript({Location:"validation.js", Setup:"mockXrm.js", Run: Join([ + "validatePhoneNumber('not-a-phone')" + ]), Expected: "false" }); + - testCaseName: Validation_EmailValidationWithValidEmails + testCaseDescription: Tests that email validation passes for valid emails + testSteps: | + = Preview.AssertJavaScript({Location:"validation.js", Setup:"mockXrm.js", Run: Join([ + "validateEmail('test@example.com')" + ]), Expected: "true" }); + - testCaseName: Validation_EmailValidationWithInvalidEmails + testCaseDescription: Tests that email validation fails for invalid emails + testSteps: | + = Preview.AssertJavaScript({Location:"validation.js", Setup:"mockXrm.js", Run: Join([ + "validateEmail('invalid-email')" + ]), Expected: "false" }); + - testCaseName: Validation_CreditLimitValidationBasedOnScore + testCaseDescription: Tests that credit limit validation is based on credit score + testSteps: | + = Preview.AssertJavaScript({Location:"validation.js", Setup:"mockXrm.js", Run: Join([ + "validateCreditLimit(3000, 550)" + ]), Expected: "true" }); + - testCaseName: Validation_CreditLimitValidationFailsWhenTooHigh + testCaseDescription: Tests that credit limit validation fails when limit is too high for score + testSteps: | + = Preview.AssertJavaScript({Location:"validation.js", Setup:"mockXrm.js", Run: Join([ + "validateCreditLimit(10000, 450)" + ]), Expected: "false" }); + - testCaseName: Validation_FormValidationSetsNotifications + testCaseDescription: Tests that form validation sets notifications for invalid fields + testSteps: | + = Preview.AssertJavaScript({Location:"validation.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.getAttribute('emailaddress1').setValue('invalid-email')", + "Xrm.Page.getAttribute('telephone1').setValue('invalid-phone')", + "validateAccountForm()", + "Xrm.Page.getControl('emailaddress1').getNotification() !== null && Xrm.Page.getControl('telephone1').getNotification() !== null" + ]), Expected: "true" }); + +testSettings: + filePath: ./testSettings.yaml + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded diff --git a/samples/javascript-d365-tests/visibility.js b/samples/javascript-d365-tests/visibility.js new file mode 100644 index 000000000..5d7c60794 --- /dev/null +++ b/samples/javascript-d365-tests/visibility.js @@ -0,0 +1,106 @@ +/** + * visibility.js - Form visibility functions for Dynamics 365 + * These functions are used to control field, section, and tab visibility. + */ + +/** + * Updates visibility of fields and sections based on account type + * @returns {boolean} - True if operation was successful + */ +function updateAccountTypeVisibility() { + var accountType = Xrm.Page.getAttribute("accounttype").getValue(); + + // Show/hide fields based on account type + if (accountType === 1) { // 1 = Customer + // Customer-specific fields + showHideControl("customerid", true); + showHideControl("creditlimit", true); + showHideSection("customer_section", true); + + // Hide vendor fields + showHideControl("vendorid", false); + showHideSection("vendor_section", false); + } else if (accountType === 2) { // 2 = Vendor + // Vendor-specific fields + showHideControl("vendorid", true); + showHideControl("vendorrating", true); + showHideSection("vendor_section", true); + + // Hide customer fields + showHideControl("customerid", false); + showHideControl("creditlimit", false); + showHideSection("customer_section", false); + } else { + // Default - hide all special sections + showHideControl("customerid", false); + showHideControl("vendorid", false); + showHideSection("customer_section", false); + showHideSection("vendor_section", false); + } + + return true; +} + +/** + * Updates visibility based on the selected industry + * @returns {boolean} - True if operation was successful + */ +function updateIndustryBasedVisibility() { + var industryCode = Xrm.Page.getAttribute("industrycode").getValue(); + + // Show financial tab for financial industry (code 1) + if (industryCode === 1) { + showHideTab("financial", true); + showHideSection("compliance", true); + } else { + showHideTab("financial", false); + showHideSection("compliance", false); + } + + return true; +} + +/** + * Shows or hides a field control + * @param {string} controlName - Name of the control to show/hide + * @param {boolean} visible - True to show, false to hide + * @returns {boolean} - True if operation was successful + */ +function showHideControl(controlName, visible) { + var control = Xrm.Page.getControl(controlName); + if (control) { + control.setVisible(visible); + return true; + } + return false; +} + +/** + * Shows or hides a section + * @param {string} sectionName - Name of the section to show/hide + * @param {boolean} visible - True to show, false to hide + * @returns {boolean} - True if operation was successful + */ +function showHideSection(sectionName, visible) { + var section = Xrm.Page.ui.sections.get(sectionName); + if (section) { + section.setVisible(visible); + return true; + } + return false; +} + +/** + * Shows or hides a tab + * @param {string} tabName - Name of the tab to show/hide + * @param {boolean} visible - True to show, false to hide + * @returns {boolean} - True if operation was successful + */ +function showHideTab(tabName, visible) { + var tab = Xrm.Page.ui.tabs.get(tabName); + if (tab) { + tab.setVisible(visible); + return true; + } + return false; +} diff --git a/samples/javascript-d365-tests/visibility.te.yaml b/samples/javascript-d365-tests/visibility.te.yaml new file mode 100644 index 000000000..19304cf4b --- /dev/null +++ b/samples/javascript-d365-tests/visibility.te.yaml @@ -0,0 +1,64 @@ +# yaml-embedded-languages: powerfx +testSuite: + testSuiteName: Visibility Tests + testSuiteDescription: Tests for Dynamics 365 visibility functionality using PowerFx format + persona: User1 + appLogicalName: na + + testCases: + - testCaseName: Visibility_CustomerFieldsAndSectionsForCustomerType + testCaseDescription: Tests that customer fields and sections are shown for customer account type + testSteps: | + = Preview.AssertJavaScript({Location:"visibility.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.getAttribute('accounttype').setValue(1)", + "updateAccountTypeVisibility()", + "Xrm.Page.getControl('customerid').getVisible() && Xrm.Page.ui.sections.get('customer_section').getVisible()" + ]), Expected: "true" }); + - testCaseName: Visibility_VendorFieldsAndSectionsForVendorType + testCaseDescription: Tests that vendor fields and sections are shown for vendor account type + testSteps: | + = Preview.AssertJavaScript({Location:"visibility.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.getAttribute('accounttype').setValue(2)", + "updateAccountTypeVisibility()", + "Xrm.Page.getControl('vendorid').getVisible() && Xrm.Page.ui.sections.get('vendor_section').getVisible()" + ]), Expected: "true" }); + - testCaseName: Visibility_HideAllSectionsForDefaultType + testCaseDescription: Tests that all special sections are hidden for default account type + testSteps: | + = Preview.AssertJavaScript({Location:"visibility.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.getAttribute('accounttype').setValue(0)", + "updateAccountTypeVisibility()", + "!Xrm.Page.getControl('customerid').getVisible() && !Xrm.Page.ui.sections.get('customer_section').getVisible() && !Xrm.Page.getControl('vendorid').getVisible() && !Xrm.Page.ui.sections.get('vendor_section').getVisible()" + ]), Expected: "true" }); + - testCaseName: Visibility_FinancialTabForFinancialIndustry + testCaseDescription: Tests that financial tab is shown for financial industry + testSteps: | + = Preview.AssertJavaScript({Location:"visibility.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.getAttribute('industrycode').setValue(1)", + "updateIndustryBasedVisibility()", + "Xrm.Page.ui.tabs.get('financial').getVisible() && Xrm.Page.ui.sections.get('compliance').getVisible()" + ]), Expected: "true" }); + - testCaseName: Visibility_HideFinancialTabForNonFinancialIndustry + testCaseDescription: Tests that financial tab is hidden for non-financial industry + testSteps: | + = Preview.AssertJavaScript({Location:"visibility.js", Setup:"mockXrm.js", Run: Join([ + "Xrm.Page.getAttribute('industrycode').setValue(2)", + "updateIndustryBasedVisibility()", + "!Xrm.Page.ui.tabs.get('financial').getVisible() && !Xrm.Page.ui.sections.get('compliance').getVisible()" + ]), Expected: "true" }); + - testCaseName: Visibility_IndividualControlVisibility + testCaseDescription: Tests that individual control visibility function works + testSteps: | + = Preview.AssertJavaScript({Location:"visibility.js", Setup:"mockXrm.js", Run: Join([ + "showHideControl('customcontrol', true)", + "Xrm.Page.getControl('customcontrol').getVisible()" + ]), Expected: "true" }); + +testSettings: + filePath: ./testSettings.yaml + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded 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 c06667317..782c6cc2e 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/Microsoft.PowerApps.TestEngine.Tests.csproj +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Microsoft.PowerApps.TestEngine.Tests.csproj @@ -18,7 +18,7 @@ - + diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/AssertJavaScriptFunctionTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/AssertJavaScriptFunctionTests.cs new file mode 100644 index 000000000..ebef20bbe --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/AssertJavaScriptFunctionTests.cs @@ -0,0 +1,581 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerApps.TestEngine.Config; +using Microsoft.PowerApps.TestEngine.PowerFx.Functions; +using Microsoft.PowerApps.TestEngine.System; +using Microsoft.PowerApps.TestEngine.Tests.Helpers; +using Microsoft.PowerFx.Types; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using Moq; +using Xunit; + +namespace Microsoft.PowerApps.TestEngine.Tests.PowerFx.Functions +{ + public class AssertJavaScriptFunctionTests : IDisposable + { + private readonly Mock _mockLogger; + private readonly Mock _mockOrgService; + private readonly Mock _mockFileSystem; + private readonly Mock _mockTestState; + private readonly string _testFilePath; + private readonly string _testFileContent; public AssertJavaScriptFunctionTests() + { + _mockLogger = new Mock(MockBehavior.Strict); + _mockOrgService = new Mock(MockBehavior.Strict); + _mockFileSystem = new Mock(MockBehavior.Strict); + _mockTestState = new Mock(MockBehavior.Loose); + + // Create a temporary JavaScript file for testing + _testFilePath = Path.GetTempFileName() + ".js"; + _testFileContent = "// Test JavaScript file\nfunction testFunction() { return 'hello'; }"; + File.WriteAllText(_testFilePath, _testFileContent); + } + [Fact] + public async Task ExecuteAsync_WithValidRunAndExpected_ReturnsSuccess() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + var recordFields = new Dictionary + { + ["Run"] = StringValue.New("'test' + 'success'"), + ["Expected"] = StringValue.New("testsuccess") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + // Act + var result = await function.ExecuteAsync(record); + // Assert + Assert.IsAssignableFrom(result); + Assert.True(GetBooleanFieldValue(result, "Success")); + Assert.Equal("Assertion passed", GetStringFieldValue(result, "Message")); + Assert.Equal(string.Empty, GetStringFieldValue(result, "Details")); + } + [Fact] + public async Task ExecuteAsync_WithMissingRunParameter_ReturnsError() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + var recordFields = new Dictionary + { + ["Expected"] = StringValue.New("testsuccess") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); // Assert + Assert.IsAssignableFrom(result); + Assert.False(GetBooleanFieldValue(result, "Success")); + Assert.Equal("Missing required parameter", GetStringFieldValue(result, "Message")); + Assert.Contains("Run", GetStringFieldValue(result, "Details")); + } + [Fact] + public async Task ExecuteAsync_WithMissingExpectedParameter_ReturnsError() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + + var recordFields = new Dictionary + { + ["Run"] = StringValue.New("'test' + 'success'") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); + + // Assert + Assert.IsAssignableFrom(result); + Assert.False(GetBooleanFieldValue(result, "Success")); + Assert.Equal("Missing required parameter", GetStringFieldValue(result, "Message")); + Assert.Contains("Expected", GetStringFieldValue(result, "Details")); + } + [Fact] + public async Task ExecuteAsync_WithInvalidRunCode_ReturnsError() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + + var recordFields = new Dictionary + { + ["Run"] = StringValue.New("invalidFunction()"), + ["Expected"] = StringValue.New("result") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); + // Assert + Assert.IsAssignableFrom(result); + Assert.False(GetBooleanFieldValue(result, "Success")); + Assert.Equal("Test code execution failed", GetStringFieldValue(result, "Message")); + Assert.Contains("Error", GetStringFieldValue(result, "Details")); + } + [Fact] + public async Task ExecuteAsync_WithSetupCode_ExecutesCorrectly() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + var recordFields = new Dictionary + { + ["Setup"] = StringValue.New("var x = 10; var y = 5;"), + ["Run"] = StringValue.New("x + y"), + ["Expected"] = StringValue.New("15") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); + + // Assert + Assert.IsAssignableFrom(result); + Assert.True(GetBooleanFieldValue(result, "Success")); + } + [Fact] + public async Task ExecuteAsync_WithInvalidSetupCode_ReturnsError() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + + var recordFields = new Dictionary + { + ["Setup"] = StringValue.New("var x = nonExistentFunction();"), + ["Run"] = StringValue.New("10"), + ["Expected"] = StringValue.New("10") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); + // Assert + Assert.IsAssignableFrom(result); + Assert.False(GetBooleanFieldValue(result, "Success")); + Assert.Equal("Setup code execution failed", GetStringFieldValue(result, "Message")); + } + + [Fact] + public async Task ExecuteAsync_WithLocalFile_ExecutesCorrectly() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + + // Setup mock file system + _mockFileSystem.Setup(fs => fs.FileExists(_testFilePath)).Returns(true); _mockFileSystem.Setup(fs => fs.ReadAllText(_testFilePath)).Returns(_testFileContent); + + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + + var recordFields = new Dictionary + { + ["Location"] = StringValue.New(_testFilePath), + ["Run"] = StringValue.New("testFunction()"), + ["Expected"] = StringValue.New("hello") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); + // Assert + Assert.IsAssignableFrom(result); + Assert.True(GetBooleanFieldValue(result, "Success")); + + // Verify file system was called + _mockFileSystem.Verify(fs => fs.FileExists(_testFilePath), Times.Once); + _mockFileSystem.Verify(fs => fs.ReadAllText(_testFilePath), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_WithNonExistentFile_ReturnsError() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + + // Setup mock file system + string nonExistentPath = "C:/nonexistent/file.js"; + _mockFileSystem.Setup(fs => fs.FileExists(nonExistentPath)).Returns(false); + + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + + var recordFields = new Dictionary + { + ["Location"] = StringValue.New(nonExistentPath), + ["Run"] = StringValue.New("10"), + ["Expected"] = StringValue.New("10") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); + // Assert + Assert.IsAssignableFrom(result); + Assert.False(GetBooleanFieldValue(result, "Success")); + Assert.Equal("File error", GetStringFieldValue(result, "Message")); + + // Verify file system was called + _mockFileSystem.Verify(fs => fs.FileExists(nonExistentPath), Times.Once); + _mockFileSystem.Verify(fs => fs.ReadAllText(nonExistentPath), Times.Never); + } + + [Fact] + public async Task ExecuteAsync_WithWebResource_ExecutesCorrectly() + { // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + // Setup mock org service to return a web resource + var webResourceContent = Convert.ToBase64String(global::System.Text.Encoding.UTF8.GetBytes( + "function webResourceFunction() { return 'from webresource'; }")); + + var mockEntity = new Entity("webresource"); + mockEntity["content"] = webResourceContent; + var mockEntityCollection = new EntityCollection(); + mockEntityCollection.Entities.Add(mockEntity); // Create query expression variable for Moq setup + _mockOrgService.Setup(s => s.RetrieveMultiple(It.Is(q => + q.EntityName == "webresource" && + q.ColumnSet.Columns.Contains("content") && q.ColumnSet.Columns.Contains("name")))) + .Returns(mockEntityCollection); + + var function = new AssertJavaScriptFunction(_mockLogger.Object, _mockOrgService.Object, _mockFileSystem.Object, _mockTestState.Object); + + var recordFields = new Dictionary + { + ["WebResource"] = StringValue.New("new_testscript.js"), + ["Run"] = StringValue.New("webResourceFunction()"), + ["Expected"] = StringValue.New("from webresource") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); // Assert + Assert.IsAssignableFrom(result); + Assert.True(GetBooleanFieldValue(result, "Success")); + + // Verify service was called with the proper query expression + _mockOrgService.Verify(s => s.RetrieveMultiple(It.Is(q => + q.EntityName == "webresource" && + q.ColumnSet.Columns.Contains("content") && + q.ColumnSet.Columns.Contains("name"))), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_WithNonExistentWebResource_ReturnsError() + { // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + + // Setup mock org service to return empty collection + _mockOrgService.Setup(s => s.RetrieveMultiple(It.Is(q => + q.EntityName == "webresource" && + q.ColumnSet.Columns.Contains("content") && q.ColumnSet.Columns.Contains("name")))) + .Returns(new EntityCollection()); + + var function = new AssertJavaScriptFunction(_mockLogger.Object, _mockOrgService.Object, _mockFileSystem.Object, _mockTestState.Object); + + var recordFields = new Dictionary + { + ["WebResource"] = StringValue.New("nonexistent.js"), + ["Run"] = StringValue.New("10"), + ["Expected"] = StringValue.New("10") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); + // Assert + Assert.IsAssignableFrom(result); + Assert.False(GetBooleanFieldValue(result, "Success")); + Assert.Equal("Web resource error", GetStringFieldValue(result, "Message")); + } + [Fact] + public async Task ExecuteAsync_WithFailingAssertion_ReturnsFailure() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + + var recordFields = new Dictionary + { + ["Run"] = StringValue.New("'actual'"), + ["Expected"] = StringValue.New("expected") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); + // Assert + Assert.IsAssignableFrom(result); + Assert.False(GetBooleanFieldValue(result, "Success")); + Assert.Equal("Assertion failed", GetStringFieldValue(result, "Message")); + Assert.Contains("Expected 'expected', got 'actual'", GetStringFieldValue(result, "Details")); + } + [Fact] + public async Task ExecuteAsync_WithComplexJavaScript_ExecutesCorrectly() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + var complexJs = @" + var numbers = [1, 2, 3, 4, 5]; + var sum = numbers.reduce(function(a, b) { return a + b; }, 0); + var doubled = numbers.map(function(n) { return n * 2; }); + doubled.join(',') + "; + + var recordFields = new Dictionary + { + ["Run"] = StringValue.New(complexJs), + ["Expected"] = StringValue.New("2,4,6,8,10") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); + + // Assert + Assert.IsAssignableFrom(result); + Assert.True(GetBooleanFieldValue(result, "Success")); + } + + [Fact] + public async Task ExecuteAsync_FileSystemUsage_VerifyMockCalls() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + + string testPath = "C:/test/script.js"; + string testContent = "function testMock() { return 'mocked content'; }"; + + // Setup detailed mock expectations + _mockFileSystem.Setup(fs => fs.FileExists(testPath)).Returns(true); + _mockFileSystem.Setup(fs => fs.ReadAllText(testPath)).Returns(testContent); + _mockFileSystem.Setup(fs => fs.CanAccessFilePath(testPath)).Returns(true); + + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + + var recordFields = new Dictionary + { + ["Location"] = StringValue.New(testPath), + ["Run"] = StringValue.New("testMock()"), + ["Expected"] = StringValue.New("mocked content") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); + // Assert + Assert.IsAssignableFrom(result); + Assert.True(GetBooleanFieldValue(result, "Success")); + + // Verify that file system methods were called with expected parameters + _mockFileSystem.Verify(fs => fs.FileExists(testPath), Times.Once); + _mockFileSystem.Verify(fs => fs.ReadAllText(testPath), Times.Once); + // Verify that other file system methods were NOT called + // We can't use It.IsAny for methods with optional parameters in expression trees + // so we need to specify all parameters explicitly + _mockFileSystem.Verify(fs => fs.WriteTextToFile(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _mockFileSystem.Verify(fs => fs.Delete(It.IsAny()), Times.Never); + } + + [Fact] + public async Task ExecuteAsync_WithFileSystemError_HandlesGracefully() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + + string testPath = "C:/test/error.js"; + + // Setup mock to throw an exception when reading the file + _mockFileSystem.Setup(fs => fs.FileExists(testPath)).Returns(true); + _mockFileSystem.Setup(fs => fs.ReadAllText(testPath)).Throws(new IOException("Simulated file error")); + + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + + var recordFields = new Dictionary + { + ["Location"] = StringValue.New(testPath), + ["Run"] = StringValue.New("var x = 10;"), + ["Expected"] = StringValue.New("10") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); + // Assert + Assert.IsAssignableFrom(result); + Assert.False(GetBooleanFieldValue(result, "Success")); + Assert.Equal("File error", GetStringFieldValue(result, "Message")); + + // Verify that methods were called correctly + _mockFileSystem.Verify(fs => fs.FileExists(testPath), Times.Once); + _mockFileSystem.Verify(fs => fs.ReadAllText(testPath), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_WithRelativePathInSetup_ResolvesCorrectly() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + + // Setup mock test state with test config file + var mockTestConfigFile = new FileInfo(Path.Combine(Path.GetTempPath(), "test_config.yaml")); + _mockTestState.Setup(ts => ts.GetTestConfigFile()).Returns(mockTestConfigFile); + + // Create a test JS file in the same directory as the mock test config + var relativeJsFile = "setup_script.js"; + var fullJsFilePath = Path.Combine(Path.GetTempPath(), relativeJsFile); + var setupContent = "var setupVar = 'from_setup_file';"; + + try + { + File.WriteAllText(fullJsFilePath, setupContent); + + // Setup mock file system + _mockFileSystem.Setup(fs => fs.FileExists(fullJsFilePath)).Returns(true); + _mockFileSystem.Setup(fs => fs.ReadAllText(fullJsFilePath)).Returns(setupContent); + + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + + var recordFields = new Dictionary + { + ["Setup"] = StringValue.New(relativeJsFile), + ["Run"] = StringValue.New("setupVar"), + ["Expected"] = StringValue.New("from_setup_file") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); + + // Assert + Assert.IsAssignableFrom(result); + Assert.True(GetBooleanFieldValue(result, "Success")); + + // Verify file system was called with the correct full path + _mockFileSystem.Verify(fs => fs.FileExists(fullJsFilePath), Times.Once); + _mockFileSystem.Verify(fs => fs.ReadAllText(fullJsFilePath), Times.Once); + } + finally + { + // Cleanup + if (File.Exists(fullJsFilePath)) + { + File.Delete(fullJsFilePath); + } + } + } + + [Fact] + public async Task ExecuteAsync_WithJavaScriptFileInSetup_LoadsAndExecutesCorrectly() + { + // Arrange + LoggingTestHelper.SetupMock(_mockLogger); + + // Create a temporary JavaScript file for setup code + var setupFilePath = Path.GetTempFileName() + ".js"; + var setupFileContent = "// Setup JavaScript file\nvar setupVar = 42;"; + File.WriteAllText(setupFilePath, setupFileContent); + + try + { + // Setup mock file system + _mockFileSystem.Setup(fs => fs.FileExists(setupFilePath)).Returns(true); + _mockFileSystem.Setup(fs => fs.ReadAllText(setupFilePath)).Returns(setupFileContent); + + var function = new AssertJavaScriptFunction(_mockLogger.Object, null, _mockFileSystem.Object, _mockTestState.Object); + + var recordFields = new Dictionary + { + ["Setup"] = StringValue.New(setupFilePath), + ["Run"] = StringValue.New("setupVar"), + ["Expected"] = StringValue.New("42") + }; + var record = RecordValue.NewRecordFromFields(recordFields.Select(kv => + new NamedValue(kv.Key, kv.Value)).ToArray()); + + // Act + var result = await function.ExecuteAsync(record); + + // Assert + Assert.IsAssignableFrom(result); + Assert.True(GetBooleanFieldValue(result, "Success")); + + // Verify file system was called with the correct path + _mockFileSystem.Verify(fs => fs.FileExists(setupFilePath), Times.Once); + _mockFileSystem.Verify(fs => fs.ReadAllText(setupFilePath), Times.Once); + } + finally + { + // Cleanup + if (File.Exists(setupFilePath)) + { + File.Delete(setupFilePath); + } + } + } + + // Helper methods to extract values from the record + private bool GetBooleanFieldValue(RecordValue record, string fieldName) + { + foreach (var field in record.Fields) + { + if (field.Name == fieldName && field.Value is BooleanValue boolVal) + { + return boolVal.Value; + } + } + throw new ArgumentException($"Field {fieldName} not found or not a boolean value"); + } + + private string GetStringFieldValue(RecordValue record, string fieldName) + { + foreach (var field in record.Fields) + { + if (field.Name == fieldName && field.Value is StringValue strVal) + { + return strVal.Value; + } + } + throw new ArgumentException($"Field {fieldName} not found or not a string value"); + } + + // Cleanup temp file after tests + public void Dispose() + { + try + { + if (File.Exists(_testFilePath)) + { + File.Delete(_testFilePath); + } + } + catch (Exception ex) + { + // Log exception but don't throw during cleanup + Console.WriteLine($"Error in cleanup: {ex.Message}"); + } + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/Provider/PowerFXModel/ControlRecordValueTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/Provider/PowerFXModel/ControlRecordValueTests.cs index cf8840b0a..cf682984d 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/Provider/PowerFXModel/ControlRecordValueTests.cs +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Provider/PowerFXModel/ControlRecordValueTests.cs @@ -360,17 +360,17 @@ public static IEnumerable GetTableData() var dateUnixValue = new DateTimeOffset(dateValue).ToUnixTimeMilliseconds(); yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", StringType.String)), "{PropertyValue: 'A'}", "[{'Test': \"A\"}]", "mda" }; - yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", NumberType.Number)), "{PropertyValue: 1}", "[{'Test': 1}]" , "mda"}; - yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", NumberType.Decimal)), "{PropertyValue: 1.1}", "[{'Test': 1.1}]" , "mda" }; + yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", NumberType.Number)), "{PropertyValue: 1}", "[{'Test': 1}]", "mda" }; + yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", NumberType.Decimal)), "{PropertyValue: 1.1}", "[{'Test': 1.1}]", "mda" }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: true}", "[{'Test': true}]", "mda" }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: 'true'}", "[{'Test': true}]", "mda" }; - yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: false}", "[{'Test': false}]" , "mda" }; - yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: 'false'}", "[{'Test': false}]" , "mda" }; + yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: false}", "[{'Test': false}]", "mda" }; + yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: 'false'}", "[{'Test': false}]", "mda" }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", DateTimeType.DateTime)), $"{{PropertyValue: {dateTimeValue}}}", $"[{{'Test': \"{dateTime.ToString("o")}\"}}]", "mda" }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", DateTimeType.Date)), $"{{PropertyValue: {dateUnixValue}}}", $"[{{'Test': \"{dateValue.ToString("o")}\"}}]", "mda" }; - yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", StringType.String)), "{PropertyValue: 'A'}", "[{'Test': \"A\"}]", string.Empty}; + yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", StringType.String)), "{PropertyValue: 'A'}", "[{'Test': \"A\"}]", string.Empty }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", NumberType.Number)), "{PropertyValue: 1}", "[{'Test': 1}]", string.Empty }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", NumberType.Decimal)), "{PropertyValue: 1.1}", "[{'Test': 1.1}]", string.Empty }; yield return new object[] { TableType.Empty().Add(new NamedFormulaType("Test", BooleanType.Boolean)), "{PropertyValue: true}", "[{'Test': true}]", "canvas" }; diff --git a/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj b/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj index 0928e8cf5..f7606d847 100644 --- a/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj +++ b/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj @@ -41,6 +41,7 @@ + diff --git a/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs new file mode 100644 index 000000000..05019767b --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs @@ -0,0 +1,387 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Jint; +using Jint.Native; +using Jint.Runtime; +using Microsoft.Extensions.Logging; +using Microsoft.PowerApps.TestEngine.Config; +using Microsoft.PowerApps.TestEngine.System; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Core.Utils; +using Microsoft.PowerFx.Types; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace Microsoft.PowerApps.TestEngine.PowerFx.Functions +{ + /// + /// Execute JavaScript assertions for testing web resources and custom JavaScript + /// + public class AssertJavaScriptFunction : ReflectionFunction + { + private readonly ILogger _logger; + private readonly IOrganizationService _client; + private readonly IFileSystem _fileSystem; + private readonly ITestState _testState; + + // Define record type for results + private static readonly RecordType _result = RecordType.Empty() + .Add(new NamedFormulaType("Success", BooleanType.Boolean)) + .Add(new NamedFormulaType("Message", StringType.String)) + .Add(new NamedFormulaType("Details", StringType.String)); + + // Define record type for parameters + private static readonly RecordType _parameters = RecordType.Empty() + .Add(new NamedFormulaType("WebResource", StringType.String)) + .Add(new NamedFormulaType("Location", StringType.String)) + .Add(new NamedFormulaType("Setup", StringType.String)) + .Add(new NamedFormulaType("Run", StringType.String)) + .Add(new NamedFormulaType("Expected", StringType.String)); public AssertJavaScriptFunction(ILogger logger, IOrganizationService client, IFileSystem fileSystem = null, ITestState testState = null) : base(DPath.Root.Append(new DName("Preview")), "AssertJavaScript", _result, _parameters) + { + _logger = logger; + _client = client; + _fileSystem = fileSystem ?? new FileSystem(); + _testState = testState; + } + + /// + /// Executes JavaScript code and assertions to validate test conditions + /// + /// A record containing test parameters + /// A record with test results + public RecordValue Execute(RecordValue record) + { + return ExecuteAsync(record).Result; + } + + /// + /// Retrieves the content of a web resource from Dataverse + /// + /// Name of the web resource + /// The content of the web resource as string + private async Task RetrieveWebResourceContentAsync(string webResourceName) + { + try + { + _logger.LogInformation($"Retrieving web resource '{webResourceName}'"); + + if (_client == null) + { + _logger.LogWarning("Organization service is not available, cannot retrieve web resource"); + return string.Empty; + } + + // Query for the web resource + QueryExpression query = new QueryExpression("webresource") + { + ColumnSet = new ColumnSet("content", "name"), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("name", ConditionOperator.Equal, webResourceName) + } + } + }; + + EntityCollection results = await Task.Run(() => _client.RetrieveMultiple(query)); + + if (results.Entities.Count == 0) + { + _logger.LogWarning($"Web resource '{webResourceName}' not found"); + return string.Empty; + } + + Entity webResource = results.Entities[0]; + string content = webResource.Contains("content") ? + webResource["content"].ToString() : string.Empty; + + // Web resource content is stored as base64 + if (!string.IsNullOrEmpty(content)) + { + byte[] bytes = Convert.FromBase64String(content); + return Encoding.UTF8.GetString(bytes); + } + + return string.Empty; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error retrieving web resource '{webResourceName}'"); + return string.Empty; + } + } + + /// + /// Reads JavaScript content from a local file + /// + /// Path to the file + /// The file content as string + private async Task ReadJavaScriptFileAsync(string filePath) + { + try + { + // Resolve the file path relative to the test config file if needed + filePath = ResolveFilePath(filePath); + + _logger.LogInformation($"Reading JavaScript file from '{filePath}'"); + + if (string.IsNullOrEmpty(filePath)) + { + return string.Empty; + } + + if (!_fileSystem.FileExists(filePath)) + { + _logger.LogWarning($"File not found: '{filePath}'"); + return string.Empty; + } + + return await Task.Run(() => _fileSystem.ReadAllText(filePath)); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error reading file: '{filePath}'"); + return string.Empty; + } + } + + /// + /// Resolves a file path relative to the test configuration file if it's not already an absolute path + /// + /// The file path to resolve + /// The resolved absolute file path + private string ResolveFilePath(string filePath) + { + if (string.IsNullOrEmpty(filePath) || Path.IsPathRooted(filePath) || _testState == null) + { + return filePath; + } + + try + { + var testConfigFile = _testState.GetTestConfigFile(); + if (testConfigFile != null) + { + var testResultDirectory = Path.GetDirectoryName(testConfigFile.FullName); + return Path.Combine(testResultDirectory, filePath); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Unable to resolve file path relative to test config: '{filePath}'"); + } + + return filePath; + } + + public async Task ExecuteAsync(RecordValue record) + { + _logger.LogInformation("Starting JavaScript assertion execution"); + + // Extract parameters from the record + string webResource = string.Empty; + string location = string.Empty; + string setupCode = string.Empty; + string runCode = string.Empty; + string expectedResult = string.Empty; + + // Extract values from record fields + foreach (var field in record.Fields) + { + if (field.Name == "WebResource" && field.Value is StringValue webResourceVal) + { + webResource = webResourceVal.Value; + } + else if (field.Name == "Location" && field.Value is StringValue locationVal) + { + location = locationVal.Value; + } + else if (field.Name == "Setup" && field.Value is StringValue setupVal) + { + setupCode = setupVal.Value; + } + else if (field.Name == "Run" && field.Value is StringValue runVal) + { + runCode = runVal.Value; + } + else if (field.Name == "Expected" && field.Value is StringValue expectedVal) + { + expectedResult = expectedVal.Value; + } + } + + // Validate required parameters + if (string.IsNullOrEmpty(runCode)) + { + return CreateErrorResult(false, "Missing required parameter", "The 'Run' parameter is required."); + } + + if (string.IsNullOrEmpty(expectedResult)) + { + return CreateErrorResult(false, "Missing required parameter", "The 'Expected' parameter is required."); + } + + // Retrieve web resource content from Dataverse if specified + string webResourceContent = string.Empty; + if (!string.IsNullOrEmpty(webResource)) + { + webResourceContent = await RetrieveWebResourceContentAsync(webResource); + if (string.IsNullOrEmpty(webResourceContent)) + { + return CreateErrorResult(false, "Web resource error", $"Could not retrieve web resource '{webResource}'"); + } + } + + // Read JavaScript from local file if location is specified + string locationContent = string.Empty; + if (!string.IsNullOrEmpty(location)) + { + locationContent = await ReadJavaScriptFileAsync(location); + if (string.IsNullOrEmpty(locationContent)) + { + return CreateErrorResult(false, "File error", $"Could not read JavaScript file from '{location}'"); + } + } + + // Create a new engine instance for each test to ensure isolation + Jint.Engine jsEngine = new Jint.Engine(options => + { + options.Strict(); // Use strict mode + options.TimeoutInterval(TimeSpan.FromSeconds(10)); // Prevent infinite loops + options.MaxStatements(10000); // Limit complexity + // No CLR integration as per requirements + }); + + try + { + // Execute web resource content if it was retrieved + if (!string.IsNullOrEmpty(webResourceContent)) + { + _logger.LogInformation("Executing web resource script"); + try + { + await Task.Run(() => jsEngine.Execute(webResourceContent)); + } + catch (JavaScriptException jex) + { + _logger.LogError($"Error in web resource script: {jex.Message}"); + return CreateErrorResult(false, "Web resource execution failed", + $"Error: {jex.Error}"); + } + } + + // Execute local file content if it was read + if (!string.IsNullOrEmpty(locationContent)) + { + _logger.LogInformation("Executing JavaScript file"); + try + { + await Task.Run(() => jsEngine.Execute(locationContent)); + } + catch (JavaScriptException jex) + { + _logger.LogError($"Error in JavaScript file: {jex.Message}"); + return CreateErrorResult(false, "JavaScript file execution failed", + $"Error: {jex.Error}"); + } + } + // Execute setup code if provided + if (!string.IsNullOrEmpty(setupCode)) + { + _logger.LogInformation("Executing setup code"); + try + { + // Check if setupCode is a file path + if (setupCode.EndsWith(".js", StringComparison.OrdinalIgnoreCase) && !setupCode.Contains("\n") && !setupCode.Contains(";")) + { + var setupFileContent = await ReadJavaScriptFileAsync(setupCode); + if (!string.IsNullOrEmpty(setupFileContent)) + { + setupCode = setupFileContent; + } + } + + await Task.Run(() => jsEngine.Execute(setupCode)); + } + catch (JavaScriptException jex) + { + _logger.LogError($"Error in setup code: {jex.Message}"); + return CreateErrorResult(false, "Setup code execution failed", + $"Error: {jex.Error}"); + } + } + + // Execute run code + _logger.LogInformation("Executing test code"); + JsValue result; + + try + { + result = await Task.Run(() => jsEngine.Evaluate(runCode)); + } + catch (JavaScriptException jex) + { + _logger.LogError($"Error in test code: {jex.Message}"); + return CreateErrorResult(false, "Test code execution failed", + $"Error: {jex.Error}"); + } + catch (Exception ex) + { + _logger.LogError($"Unexpected error: {ex.Message}"); + return CreateErrorResult(false, "Unexpected error during execution", ex.ToString()); + } + + // Compare the result with expected value + string actualResult = result.ToString(); + bool testPassed = string.Equals(actualResult, expectedResult); + + if (testPassed) + { + _logger.LogInformation("Assertion passed"); + return CreateSuccessResult(); + } + else + { + _logger.LogWarning($"Assertion failed: Expected '{expectedResult}', got '{actualResult}'"); + return CreateErrorResult(false, "Assertion failed", + $"Expected '{expectedResult}', got '{actualResult}'"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception occurred during JavaScript assertion execution"); + return CreateErrorResult(false, "Execution error", ex.ToString()); + } + } + + /// + /// Creates a success result record + /// + private RecordValue CreateSuccessResult() + { + var success = new NamedValue("Success", FormulaValue.New(true)); + var message = new NamedValue("Message", FormulaValue.New("Assertion passed")); + var details = new NamedValue("Details", FormulaValue.New(string.Empty)); + + return RecordValue.NewRecordFromFields(_result, new[] { success, message, details }); + } + + /// + /// Creates an error result record + /// + private RecordValue CreateErrorResult(bool success, string message, string details) + { + var successValue = new NamedValue("Success", FormulaValue.New(success)); + var messageValue = new NamedValue("Message", FormulaValue.New(message)); + var detailsValue = new NamedValue("Details", FormulaValue.New(details)); + + return RecordValue.NewRecordFromFields(_result, new[] { successValue, messageValue, detailsValue }); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/PowerFx/PowerFxEngine.cs b/src/Microsoft.PowerApps.TestEngine/PowerFx/PowerFxEngine.cs index dc6e97270..840c68608 100644 --- a/src/Microsoft.PowerApps.TestEngine/PowerFx/PowerFxEngine.cs +++ b/src/Microsoft.PowerApps.TestEngine/PowerFx/PowerFxEngine.cs @@ -95,13 +95,22 @@ public void Setup(TestSettings settings) powerFxConfig.AddFunction(new SelectOneParamFunction(_testWebProvider, async () => await UpdatePowerFxModelAsync(), Logger)); powerFxConfig.AddFunction(new SelectTwoParamsFunction(_testWebProvider, async () => await UpdatePowerFxModelAsync(), Logger)); powerFxConfig.AddFunction(new SelectThreeParamsFunction(_testWebProvider, async () => await UpdatePowerFxModelAsync(), Logger)); - powerFxConfig.AddFunction(new SelectFileTwoParamsFunction(_testWebProvider, async () => await UpdatePowerFxModelAsync(), Logger)); - powerFxConfig.AddFunction(new ScreenshotFunction(TestInfraFunctions, SingleTestInstanceState, _fileSystem, Logger)); + powerFxConfig.AddFunction(new SelectFileTwoParamsFunction(_testWebProvider, async () => await UpdatePowerFxModelAsync(), Logger)); powerFxConfig.AddFunction(new ScreenshotFunction(TestInfraFunctions, SingleTestInstanceState, _fileSystem, Logger)); powerFxConfig.AddFunction(new AssertWithoutMessageFunction(Logger)); powerFxConfig.AddFunction(new AssertFunction(Logger)); powerFxConfig.AddFunction(new AssertNotErrorFunction(Logger)); powerFxConfig.AddFunction(new SetPropertyFunction(_testWebProvider, Logger)); powerFxConfig.AddFunction(new IsMatchFunction(Logger)); + // If organization service is available, register AssertJavaScript function with access to it + if (_orgService != null) + { + powerFxConfig.AddFunction(new AssertJavaScriptFunction(Logger, _orgService, _fileSystem)); + } + else + { + // Register with null organization service, will handle gracefully + powerFxConfig.AddFunction(new AssertJavaScriptFunction(Logger, null, _fileSystem)); + } if (settings != null && settings.ExtensionModules != null && settings.ExtensionModules.Enable) { diff --git a/src/testengine.common.user.tests/testengine.common.user.tests.csproj b/src/testengine.common.user.tests/testengine.common.user.tests.csproj index 878dc287e..42409e865 100644 --- a/src/testengine.common.user.tests/testengine.common.user.tests.csproj +++ b/src/testengine.common.user.tests/testengine.common.user.tests.csproj @@ -28,7 +28,7 @@ - + diff --git a/src/testengine.provider.mda.tests/testengine.provider.mda.tests.csproj b/src/testengine.provider.mda.tests/testengine.provider.mda.tests.csproj index 85397ea79..1415e62b4 100644 --- a/src/testengine.provider.mda.tests/testengine.provider.mda.tests.csproj +++ b/src/testengine.provider.mda.tests/testengine.provider.mda.tests.csproj @@ -38,7 +38,7 @@ - + From ea80b8aec51abd625af82ca634e5e558488d7dfd Mon Sep 17 00:00:00 2001 From: v-raghulraja <165115074+v-raghulraja@users.noreply.github.com> Date: Tue, 29 Jul 2025 11:01:23 +0530 Subject: [PATCH 2/7] AgentConfiguration Changes --- .../agentconfiguration.te.yaml | 125 ++++ .../copilotconfiguration_main.js | 560 ++++++++++++++++++ samples/javascript-d365-tests/mockXrm.js | 95 ++- .../Functions/AssertJavaScriptFunction.cs | 78 ++- .../System/FileSystem.cs | 2 + 5 files changed, 794 insertions(+), 66 deletions(-) create mode 100644 samples/javascript-d365-tests/agentconfiguration.te.yaml create mode 100644 samples/javascript-d365-tests/copilotconfiguration_main.js diff --git a/samples/javascript-d365-tests/agentconfiguration.te.yaml b/samples/javascript-d365-tests/agentconfiguration.te.yaml new file mode 100644 index 000000000..e4a08dd28 --- /dev/null +++ b/samples/javascript-d365-tests/agentconfiguration.te.yaml @@ -0,0 +1,125 @@ +testSuite: + testSuiteName: Agent Configuration Form Business Logic Tests + testSuiteDescription: Automated tests for Copilot configuration form logic + persona: "User1" + appLogicalName: na + + testCases: + - testCaseName: File Load Test + testCaseDescription: Checks if copilotconfiguration_main.js loads + testSteps: | + = Preview.AssertJavaScript({ + Location: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\copilotconfiguration_main.js", + Setup: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\mockXrm.js", + Run: "true", + Expected: "true" + }) + + - testCaseName: Hide KPI Section When Not Selected + testCaseDescription: KPI section should be hidden when 'Conversation KPIs' is not selected. + testSteps: | + = Preview.AssertJavaScript({ + Location: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\copilotconfiguration_main.js", + Setup: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\mockXrm.js", + Run: Join([ + "Xrm.Page.getAttribute('cat_configurationtypescodes').setValue([1])", + "hideAndShowConversationKPISettings({ getFormContext: () => Xrm.Page })", + "!Xrm.Page.ui.tabs.get('tab_general').sections.get('tab_general_section_kpisettings').getVisible()" + ]), + Expected: "true" + }) + + + - testCaseName: Show Conversation Analyzer Section + testCaseDescription: Conversation Analyzer section is visible when 'Conversation Analyzer' is selected. + testSteps: | + = Preview.AssertJavaScript({ + Location: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\copilotconfiguration_main.js", + Setup: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\mockXrm.js", + Run: Join([ + "Xrm.Page.getAttribute('cat_configurationtypescodes').setValue([4])", + "hideAndShowConversationKPISettings({ getFormContext: () => Xrm.Page })", + "Xrm.Page.ui.tabs.get('tab_general').sections.get('tab_general_section_conversationanalyzer').getVisible()" + ]), + Expected: "true" + }) + + - testCaseName: Required Level for CopilotID and DataverseURL + testCaseDescription: Sets required level for cat_copilotid and cat_dataverseurl when 'Conversation KPIs' is selected. + testSteps: | + = Preview.AssertJavaScript({ + Location: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\copilotconfiguration_main.js", + Setup: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\mockXrm.js", + Run: Join([ + "Xrm.Page.getAttribute('cat_configurationtypescodes').setValue([2])", + "hideAndShowConversationKPISettings({ getFormContext: () => Xrm.Page })", + "Xrm.Page.getAttribute('cat_copilotid').getRequiredLevel() === 'required' && Xrm.Page.getAttribute('cat_dataverseurl').getRequiredLevel() === 'required'" + ]), + Expected: "true" + }) + + - testCaseName: setFieldRequirements sets required and none + testCaseDescription: setFieldRequirements sets required and none as expected. + testSteps: | + = Preview.AssertJavaScript({ + Location: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\copilotconfiguration_main.js", + Setup: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\mockXrm.js", + Run: Join([ + "setFieldRequirements(Xrm.Page, ['cat_copilotid'], 'required')", + "Xrm.Page.getAttribute('cat_copilotid').getRequiredLevel() === 'required'", + "setFieldRequirements(Xrm.Page, ['cat_copilotid'], 'none')", + "Xrm.Page.getAttribute('cat_copilotid').getRequiredLevel() === 'none'" + ]), + Expected: "true" + }) + + - testCaseName: clearAndHideFields hides and clears fields + testCaseDescription: clearAndHideFields hides and clears fields as expected. + testSteps: | + = Preview.AssertJavaScript({ + Location: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\copilotconfiguration_main.js", + Setup: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\mockXrm.js", + Run: Join([ + "Xrm.Page.getAttribute('cat_testfield').setValue('test')", + "clearAndHideFields(Xrm.Page, ['cat_testfield'])", + "Xrm.Page.getAttribute('cat_testfield').getValue() === null && Xrm.Page.getAttribute('cat_testfield').getRequiredLevel() === 'none' && Xrm.Page.getControl('cat_testfield').getVisible() === false" + ]), + Expected: "true" + }) + + - testCaseName: toggleSectionVisibility hides sections + testCaseDescription: toggleSectionVisibility hides multiple sections. + testSteps: | + = Preview.AssertJavaScript({ + Location: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\copilotconfiguration_main.js", + Setup: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\mockXrm.js", + Run: Join([ + "const tab = Xrm.Page.ui.tabs.get('tab_general')", + "toggleSectionVisibility(tab, ['tab_general_section_directlinesettings', 'tab_general_section_userauthentication'], false)", + "!tab.sections.get('tab_general_section_directlinesettings').getVisible() && !tab.sections.get('tab_general_section_userauthentication').getVisible()" + ]), + Expected: "true" + }) + + - testCaseName: toggleSectionVisibility shows sections + testCaseDescription: toggleSectionVisibility shows multiple sections. + testSteps: | + = Preview.AssertJavaScript({ + Location: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\copilotconfiguration_main.js", + Setup: "C:\\RR\\TestEngine_RR\\PowerApps-TestEngine\\samples\\javascript-d365-tests\\mockXrm.js", + Run: Join([ + "const tab = Xrm.Page.ui.tabs.get('tab_general')", + "toggleSectionVisibility(tab, ['tab_general_section_directlinesettings', 'tab_general_section_userauthentication'], true)", + "tab.sections.get('tab_general_section_directlinesettings').getVisible() && tab.sections.get('tab_general_section_userauthentication').getVisible()" + ]), + Expected: "true" + }) + +testSettings: + filePath: ./testSettings.yaml + +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email + passwordKey: NotNeeded diff --git a/samples/javascript-d365-tests/copilotconfiguration_main.js b/samples/javascript-d365-tests/copilotconfiguration_main.js new file mode 100644 index 000000000..df635f623 --- /dev/null +++ b/samples/javascript-d365-tests/copilotconfiguration_main.js @@ -0,0 +1,560 @@ +/** + * @function hideAndShowConversationKPISettings + * @description Shows or hides KPI settings based on the configuration type. + * @param {object} executionContext - The form execution context. + */ +function hideAndShowConversationKPISettings(executionContext) { + "use strict"; + const formContext = executionContext.getFormContext(); + + // Retrieve multiselect optionset values as an array + const configurationTypeValues = + formContext.getAttribute("cat_configurationtypescodes").getValue() || []; + + const tabGeneral = formContext.ui.tabs.get("tab_general"); + const kpiSection = tabGeneral.sections.get("tab_general_section_kpisettings"); + const fileSection = tabGeneral.sections.get("tab_general_section_file"); + const fileConfigDetailsSection = tabGeneral.sections.get( + "tab_general_section_file_config_details" + ); + const conversationTranscriptsSection = tabGeneral.sections.get( + "tab_general_section_conversationtranscriptsenrichment" + ); + const conversationAnalyzerSection = tabGeneral.sections.get( + "tab_general_section_conversationanalyzer" + ); + + const kpiLogsTab = formContext.ui.tabs.get("tab_conversation_kpi_logs"); + + const sectionsToHideOrShow = [ + "tab_general_section_directlinesettings", + "tab_general_section_userauthentication", + "tab_general_section_resultsenrichment", + "tab_general_section_generativeaitesting", + ]; + + // Hide all sections by default + toggleSectionVisibility(tabGeneral, sectionsToHideOrShow, false); + kpiSection.setVisible(false); + fileSection.setVisible(false); + conversationAnalyzerSection.setVisible(false); + fileConfigDetailsSection.setVisible(false); + conversationTranscriptsSection.setVisible(false); + kpiLogsTab.setVisible(false); + + // Check if configuration type includes 'Conversation KPIs' (2) + if (configurationTypeValues.includes(2)) { + kpiSection.setVisible(true); + kpiLogsTab.setVisible(true); + setFieldRequirements( + formContext, + ["cat_copilotid", "cat_dataverseurl"], + "required" + ); + } else { + setFieldRequirements( + formContext, + ["cat_copilotid", "cat_dataverseurl"], + "none" + ); + } + + // Check if configuration type includes 'Test Automation' (1) + if (configurationTypeValues.includes(1)) { + // Show the Conversation Transcripts section + conversationTranscriptsSection.setVisible(true); + toggleSectionVisibility(tabGeneral, sectionsToHideOrShow, true); + } + + // Check if configuration type includes 'File Synchronization' (3) + if (configurationTypeValues.includes(3)) { + // Show the File section and File Config Details section for File Synchronization + fileSection.setVisible(true); + fileConfigDetailsSection.setVisible(true); + setFieldRequirements( + formContext, + ["cat_copilotid", "cat_dataverseurl"], + "required" + ); + } + + // Check if configuration type includes 'Conversation Analyzer' (4) + if (configurationTypeValues.includes(4)) { + // Show the Conversation Analyzer section + conversationAnalyzerSection.setVisible(true); + setFieldRequirements( + formContext, + ["cat_copilotid", "cat_dataverseurl"], + "required" + ); + } +} + +/** + * @function setFieldRequirements + * @description Sets the requirement level for a list of fields. + * @param {object} formContext - The form context. + * @param {string[]} fieldNames - List of field names to set the requirement level. + * @param {string} requiredLevel - The required level ("required" or "none"). + */ +function setFieldRequirements(formContext, fieldNames, requiredLevel) { + "use strict"; + fieldNames.forEach((fieldName) => { + const attribute = formContext.getAttribute(fieldName); + if (attribute) { + attribute.setRequiredLevel(requiredLevel); + } + }); +} + +/** + * @function setFieldVisibilityForEachSections + * @description Implements the Business Rules (BR) by showing/hiding fields and setting required levels based on certain conditions. + * @param {object} executionContext - The form execution context. + */ +function setFieldVisibilityForEachSections(executionContext) { + "use strict"; + const formContext = executionContext.getFormContext(); + const configurationTypeValues = + formContext.getAttribute("cat_configurationtypescodes").getValue() || []; + + // User Authentication Fields Rules + const userAuth = formContext + .getAttribute("cat_userauthenticationcode") + .getValue(); + + // Entra ID v2 and Test Automation + if (userAuth === 2 && configurationTypeValues.includes(1)) { + setFieldVisibility( + formContext, + ["cat_clientid", "cat_tenantid", "cat_scope", "cat_userauthsecretlocationcode"], + true, + "required" + ); + + // No Authentication and Test Automation + } else if (userAuth === 1 && configurationTypeValues.includes(1)) { + clearAndHideFields(formContext, [ + "cat_clientid", + "cat_tenantid", + "cat_scope", + "cat_userauthsecretlocationcode", + "cat_clientsecret", + "cat_userauthenvironmentvariable" + ]); + } + + // User Authentication Secret Location Rule + const uasecretLocation = formContext + .getAttribute("cat_userauthsecretlocationcode") + .getValue(); + if (uasecretLocation === 1 && configurationTypeValues.includes(1)) { + // Show and require client secret + setFieldVisibility( + formContext, + ["cat_clientsecret"], + true, + "required" + ); + clearAndHideFields(formContext, ["cat_userauthenvironmentvariable"]); + } else if (uasecretLocation === 2 && configurationTypeValues.includes(1)) { + // Show and require user auth environment variable + setFieldVisibility( + formContext, + ["cat_userauthenvironmentvariable"], + true, + "required" + ); + clearAndHideFields(formContext, ["cat_clientsecret"]); + } else { + // Hide both fields if no valid selection + clearAndHideFields(formContext, [ + "cat_clientsecret", + "cat_userauthenvironmentvariable", + ]); + } + + // Enrich With Azure Application Insights Secret Location Rule + const secretLocation = formContext + .getAttribute("cat_azureappinsightssecretlocationcode") + .getValue(); + if (secretLocation === 1 && configurationTypeValues.includes(1)) { + setFieldVisibility( + formContext, + ["cat_azureappinsightssecret"], + true, + "required" + ); + clearAndHideFields(formContext, [ + "cat_azureappinsightsenvironmentvariable", + ]); + } else if (secretLocation === 2 && configurationTypeValues.includes(1)) { + setFieldVisibility( + formContext, + ["cat_azureappinsightsenvironmentvariable"], + true, + "required" + ); + clearAndHideFields(formContext, ["cat_azureappinsightssecret"]); + } else { + clearAndHideFields(formContext, [ + "cat_azureappinsightssecret", + "cat_azureappinsightsenvironmentvariable", + ]); + } + + // Enrich With Azure Application Insights Fields Rules + const enrichWithAI = formContext + .getAttribute("cat_isazureapplicationinsightsenabled") + .getValue(); + if (enrichWithAI === true && configurationTypeValues.includes(1)) { + setFieldVisibility( + formContext, + [ + "cat_azureappinsightsapplicationid", + "cat_azureappinsightstenantid", + "cat_azureappinsightsclientid", + "cat_azureappinsightssecretlocationcode", + ], + true, + "required" + ); + } else if (enrichWithAI === false && configurationTypeValues.includes(1)) { + clearAndHideFields(formContext, [ + "cat_azureappinsightsapplicationid", + "cat_azureappinsightstenantid", + "cat_azureappinsightsclientid", + "cat_azureappinsightssecretlocationcode", + "cat_azureappinsightssecret", + "cat_azureappinsightsenvironmentvariable", + ]); + } + + // Direct Line Channel Security Fields Rules + const dlSecurity = formContext + .getAttribute("cat_isdirectlinechannelsecurityenabled") + .getValue(); + if (dlSecurity === true && configurationTypeValues.includes(1)) { + setFieldVisibility( + formContext, + ["cat_directlinechannelsecretlocationcode"], + true, + "required" + ); + clearAndHideFields(formContext, ["cat_tokenendpoint"]); + } else if (dlSecurity === false && configurationTypeValues.includes(1)) { + clearAndHideFields(formContext, [ + "cat_directlinechannelsecretlocationcode", + ]); + setFieldVisibility(formContext, ["cat_tokenendpoint"], true, "required"); + } + + // Direct Line Channel Security Secret Location Fields Rules + const dlSecretLocation = formContext + .getAttribute("cat_directlinechannelsecretlocationcode") + .getValue(); + if (dlSecretLocation === 1 && configurationTypeValues.includes(1)) { + setFieldVisibility( + formContext, + ["cat_directlinechannelsecuritysecret"], + true, + "required" + ); + clearAndHideFields(formContext, [ + "cat_directlinechannelsecurityenvironmentvariable", + ]); + } else if (dlSecretLocation === 2 && configurationTypeValues.includes(1)) { + setFieldVisibility( + formContext, + ["cat_directlinechannelsecurityenvironmentvariable"], + true, + "required" + ); + clearAndHideFields(formContext, ["cat_directlinechannelsecuritysecret"]); + } else { + clearAndHideFields(formContext, [ + "cat_directlinechannelsecuritysecret", + "cat_directlinechannelsecurityenvironmentvariable", + ]); + } + + // Analyze Generated Answers Fields Rules + const analyzeAnswers = formContext + .getAttribute("cat_isgeneratedanswersanalysisenabled") + .getValue(); + if (analyzeAnswers === true && configurationTypeValues.includes(1)) { + setFieldVisibility( + formContext, + ["cat_generativeaiprovidercode"], + true, + "required" + ); + } else { + clearAndHideFields(formContext, ["cat_generativeaiprovidercode"]); + } + + // Enrich with Conversation Transcript Field Rules + const section = formContext.ui.tabs + .get("tab_general") + .sections.get("tab_general_section_conversationtranscriptsenrichment"); + const isEnrichedWithTranscripts = formContext + .getAttribute("cat_isenrichedwithconversationtranscripts") + .getValue(); + + if ( + isEnrichedWithTranscripts === true && + configurationTypeValues.includes(1) + ) { + section.controls.get("cat_dataverseurl2").setVisible(true); + formContext.getAttribute("cat_dataverseurl").setRequiredLevel("required"); + section.controls.get("cat_iscopyfulltranscriptenabled1").setVisible(true); + section.controls.get("cat_copilotid2").setVisible(true); + formContext.getAttribute("cat_copilotid").setRequiredLevel("required"); + } else { + section.controls.get("cat_dataverseurl2").setVisible(false); + section.controls.get("cat_iscopyfulltranscriptenabled1").setVisible(false); + section.controls.get("cat_copilotid2").setVisible(false); + } +} + +/** + * @function setFieldVisibility + * @description Sets visibility and requirement level for fields. + * @param {object} formContext - The form context. + * @param {string[]} fieldNames - The field names. + * @param {boolean} visible - Whether to show the fields. + * @param {string} requiredLevel - The required level ("required" or "none"). + */ +function setFieldVisibility(formContext, fieldNames, visible, requiredLevel) { + "use strict"; + fieldNames.forEach((fieldName) => { + const control = formContext.getControl(fieldName); + if (control) control.setVisible(visible); + const attribute = formContext.getAttribute(fieldName); + if (attribute) attribute.setRequiredLevel(requiredLevel); + }); +} + +/** + * @function clearAndHideFields + * @description Clears and hides fields. + * @param {object} formContext - The form context. + * @param {string[]} fieldNames - The field names. + */ +function clearAndHideFields(formContext, fieldNames) { + "use strict"; + fieldNames.forEach((fieldName) => { + const attribute = formContext.getAttribute(fieldName); + if (attribute) attribute.setValue(null); + const control = formContext.getControl(fieldName); + if (control) control.setVisible(false); + if (attribute) attribute.setRequiredLevel("none"); + }); +} + +/** + * @function toggleSectionVisibility + * @description Shows or hides a list of sections within a tab. + * @param {object} tab - The tab containing the sections. + * @param {string[]} sectionNames - List of section names to show or hide. + * @param {boolean} visible - Whether to show or hide the sections. + */ +function toggleSectionVisibility(tab, sectionNames, visible) { + "use strict"; + sectionNames.forEach((sectionName) => { + const section = tab.sections.get(sectionName); + if (section) { + section.setVisible(visible); + } + }); +} + +/** + * @function generateConversationKPI + * @description Generate Conversation KPI for selected duration + * @param {object} formContext - The form context. + * @param {string} selectedEntityTypeName - The entity name. + */ +function generateConversationKPI(formContext, selectedEntityTypeName) { + "use strict"; + const pageInput = { + pageType: "custom", + name: "cat_conversationkpi_6082b", + entityName: selectedEntityTypeName, + recordId: formContext.data.entity.getId(), + }; + const navigationOptions = { + target: 2, + position: 1, + height: 330, + width: 540, + title: "Generate Conversation KPI", + }; + Xrm.Navigation.navigateTo(pageInput, navigationOptions).catch(function ( + error + ) { + formContext.ui.setFormNotification( + "Error generating Conversation KPI: " + error.message, + "ERROR", + "COOVERSAIONKPIERROR" + ); + setTimeout(function () { + formContext.ui.clearFormNotification("COOVERSAIONKPIERROR"); + }, 8000); + }); +} + +/** + * @function showSyncFilesDialog function to display dialog for file sync process, calls the custom action for sync process. + * @formContext Get the formContext. + */ +function showSyncFilesDialog(formContext) { + var confirmStrings = { + text: "This action processes all the file indexer configurations for this agent, and synchronizes files from SharePoint to Copilot Studio as knowledge sources. Please note that at the end of the synchronization process, the agent in question will be published to take new knowledge sources in use.Are you sure you want to proceed with the file synchronization process?", + title: "Confirm File Synchronization", + }; + let copilotConfigurationId = formContext.data.entity.getId(); + var confirmOptions = { height: 280, width: 450 }; + let actionExecutionRequest = createExecutionRequest( + "cat_RunSyncFiles", + copilotConfigurationId + ); + let successMessage = "Files sync is in progress."; + Xrm.Navigation.openConfirmDialog(confirmStrings, confirmOptions).then( + function (success) { + if (success.confirmed) + //execute action + Xrm.WebApi.online + .execute(actionExecutionRequest) + .then( + function success(result) { + if (result.ok) { + displayNotification( + formContext, + successMessage, + "INFO", + "FILESYNC_SUCCESS_NOTIFICATION" + ); + removeNotification( + formContext, + "FILESYNC_SUCCESS_NOTIFICATION" + ); + } else { + displayNotification( + formContext, + "An error occurred while executing the action. Please try again.", + "ERROR", + "FILESYNC_ERROR_NOTIFICATION" + ); + removeNotification(formContext, "FILESYNC_ERROR_NOTIFICATION"); + } + }, + function (error) { + displayNotification( + formContext, + `An error occurred while submitting record for file sync execution. Please try again. Error Message: ${error.message}`, + "ERROR", + "FILESYNC_ERROR_NOTIFICATION" + ); + removeNotification(formContext, "FILESYNC_ERROR_NOTIFICATION"); + } + ) + .catch(function (error) { + displayNotification( + formContext, + `An error occurred while executing the action. Please try again. Error Message: ${error.message}`, + "ERROR", + "FILESYNC_ERROR_NOTIFICATION" + ); + removeNotification(formContext, "FILESYNC_ERROR_NOTIFICATION"); + }); + } + ); +} + +/** + * @function createExecutionRequest create an execution request with all required parameters. + * @operationName operation name. + * @copilotConfigurationId Copilot Configuration Id + * @returns execution request. + */ +function createExecutionRequest(operationName, copilotConfigurationId) { + "use strict"; + const executionRequest = { + CopilotConfigurationId: copilotConfigurationId, + getMetadata: function () { + return { + boundParameter: null, + operationType: 0, + operationName: operationName, + parameterTypes: { + CopilotConfigurationId: { + typeName: "Edm.String", + structuralProperty: 1, + }, + }, + }; + }, + }; + return executionRequest; +} + +/** + * @function displayNotification display notification on form. + * @formContext form context. + * @message notification message. + * @level notification type. + * @uniqueId unique id for notification. + */ +function displayNotification(formContext, message, type, uniqueId) { + "use strict"; + formContext.ui.setFormNotification(message, type, uniqueId); +} + +/** + * @function removeNotification remove notification from form after fixed seconds. + * @formContext form context. + * @uniqueId unique id for notification. + */ +function removeNotification(formContext, uniqueId) { + "use strict"; + setTimeout(function () { + formContext.ui.clearFormNotification(uniqueId); + }, 7000); +} + +/** + * @function sharepointValidation + * @description This function opens a custom page to validate SharePoint connection and display file and page counts. + * @param {object} formContext - The form context. + * @param {string} selectedEntityTypeName - The entity name. + */ +function sharepointValidation(formContext, selectedEntityTypeName) { + "use strict"; + const pageInput = { + pageType: "custom", + name: "cat_validatesharepointconnection_d362d", + entityName: selectedEntityTypeName, + recordId: formContext.data.entity.getId(), + }; + const navigationOptions = { + target: 2, + position: 1, + height: 280, + width: 400, + title: "Sharepoint Validation", + }; + Xrm.Navigation.navigateTo(pageInput, navigationOptions).catch(function ( + error + ) { + // Display error notification if navigation fails + formContext.ui.setFormNotification( + "Error generating Sharepoint Validation: " + error.message, + "ERROR", + "SHAREPOINT_VALIDATION_ERROR" + ); + setTimeout(function () { + formContext.ui.clearFormNotification("SHAREPOINT_VALIDATION_ERROR"); + }, 8000); + }); +} \ No newline at end of file diff --git a/samples/javascript-d365-tests/mockXrm.js b/samples/javascript-d365-tests/mockXrm.js index 0014d3b7d..1e1c17163 100644 --- a/samples/javascript-d365-tests/mockXrm.js +++ b/samples/javascript-d365-tests/mockXrm.js @@ -3,78 +3,75 @@ * This file provides a simple mock of the Xrm object to enable testing of client-side scripts */ -// Initialize global Xrm object +// Persistent stores for attributes, controls, and sections +const attributeStore = {}; +const controlStore = {}; +const sectionStore = {}; +const tabStore = {}; + var Xrm = { Page: { ui: { formType: 2, // Default to Update form type tabs: { - items: {}, get: function(tabName) { - if (!this.items[tabName]) { - this.items[tabName] = { - visible: true, - setVisible: function(visible) { this.visible = visible; }, - getVisible: function() { return this.visible; } - }; - } - return this.items[tabName]; - } - }, - sections: { - items: {}, - get: function(sectionName) { - if (!this.items[sectionName]) { - this.items[sectionName] = { + if (!tabStore[tabName]) { + tabStore[tabName] = { visible: true, setVisible: function(visible) { this.visible = visible; }, - getVisible: function() { return this.visible; } + getVisible: function() { return this.visible; }, + sections: { + get: function(sectionName) { + if (!sectionStore[sectionName]) { + sectionStore[sectionName] = { + visible: true, + setVisible: function(visible) { this.visible = visible; }, + getVisible: function() { return this.visible; } + }; + } + return sectionStore[sectionName]; + } + } }; } - return this.items[sectionName]; + return tabStore[tabName]; } }, - controls: {}, + controls: {}, // Not used, see getControl below getFormType: function() { return this.formType; }, setFormType: function(type) { this.formType = type; } }, - + data: { entity: { attributes: {} } }, - + getAttribute: function(attributeName) { - if (!this.attributes) { - this.attributes = {}; - } - - if (!this.attributes[attributeName]) { - this.attributes[attributeName] = { + // Use persistent store + if (!attributeStore[attributeName]) { + attributeStore[attributeName] = { value: null, requiredLevel: "none", handlers: [], getValue: function() { return this.value; }, setValue: function(value) { this.value = value; - // Call registered handlers when value is changed - this.handlers.forEach(function(handler) { - handler(); - }); + this.handlers.forEach(function(handler) { handler(); }); }, setRequiredLevel: function(level) { this.requiredLevel = level; }, getRequiredLevel: function() { return this.requiredLevel; }, addOnChange: function(handler) { this.handlers.push(handler); } }; } - - return this.attributes[attributeName]; + return attributeStore[attributeName]; }, - + getControl: function(controlName) { - if (!this.ui.controls[controlName]) { - this.ui.controls[controlName] = { + // Use persistent store + if (!controlStore[controlName]) { + controlStore[controlName] = { visible: true, notification: null, setVisible: function(visible) { this.visible = visible; }, @@ -88,11 +85,10 @@ var Xrm = { getNotification: function() { return this.notification; } }; } - - return this.ui.controls[controlName]; + return controlStore[controlName]; } }, - + Utility: { alertDialog: function(message, callback) { console.log("Alert Dialog: " + message); @@ -105,7 +101,7 @@ var Xrm = { return true; } }, - + WebApi: { online: true, execute: function(request) { @@ -126,6 +122,12 @@ var Xrm = { return Promise.resolve({ entities: [] }); + }, + retrieveRecords: function(entityType, options) { + console.log("WebApi.retrieveRecords called for:", entityType); + return Promise.resolve({ + entities: [] + }); } } }; @@ -159,15 +161,6 @@ function resetMockXrm() { window.showDialogResponse = true; } -// Add fetch xml support -Xrm.WebApi.retrieveRecords = function(entityType, options) { - console.log("WebApi.retrieveRecords called for:", entityType); - // Mock implementation returns a resolved promise with empty result set - return Promise.resolve({ - entities: [] - }); -}; - // Add support for form context rather than just global form Xrm.Page.context = { getClientUrl: function() { @@ -178,4 +171,4 @@ Xrm.Page.context = { // Add form event registration capability Xrm.Page.data.entity.addOnSave = function(handler) { // Just store the handler - not implemented for tests -}; \ No newline at end of file +}; diff --git a/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs index 05019767b..886c6a349 100644 --- a/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs +++ b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs @@ -120,29 +120,33 @@ private async Task RetrieveWebResourceContentAsync(string webResourceNam /// /// Reads JavaScript content from a local file /// - /// Path to the file + /// Path to the .js file /// The file content as string private async Task ReadJavaScriptFileAsync(string filePath) { try { - // Resolve the file path relative to the test config file if needed filePath = ResolveFilePath(filePath); - _logger.LogInformation($"Reading JavaScript file from '{filePath}'"); if (string.IsNullOrEmpty(filePath)) { + _logger.LogWarning("File path is empty after resolution."); return string.Empty; } + _logger.LogDebug($"Resolved file path: '{filePath}'"); + if (!_fileSystem.FileExists(filePath)) { _logger.LogWarning($"File not found: '{filePath}'"); + _logger.LogDebug($"Current directory: '{Directory.GetCurrentDirectory()}'"); return string.Empty; } - return await Task.Run(() => _fileSystem.ReadAllText(filePath)); + var content = await Task.Run(() => _fileSystem.ReadAllText(filePath)); + _logger.LogInformation($"Successfully read JavaScript file: '{filePath}'"); + return content; } catch (Exception ex) { @@ -158,23 +162,47 @@ private async Task ReadJavaScriptFileAsync(string filePath) /// The resolved absolute file path private string ResolveFilePath(string filePath) { - if (string.IsNullOrEmpty(filePath) || Path.IsPathRooted(filePath) || _testState == null) + if (string.IsNullOrEmpty(filePath)) { - return filePath; + _logger.LogWarning("File path is null or empty."); + return string.Empty; } - try + // Check for invalid characters in the file path + if (filePath.IndexOfAny(Path.GetInvalidPathChars()) >= 0) { - var testConfigFile = _testState.GetTestConfigFile(); - if (testConfigFile != null) - { - var testResultDirectory = Path.GetDirectoryName(testConfigFile.FullName); - return Path.Combine(testResultDirectory, filePath); - } + _logger.LogWarning($"File path contains invalid characters: '{filePath}'"); + return string.Empty; } - catch (Exception ex) + + // Prevent malformed paths like ".C:/..." + if (filePath.StartsWith(".") && filePath.Contains(":")) + { + _logger.LogWarning($"File path is malformed: '{filePath}'"); + return string.Empty; + } + + // If the path is already absolute, return it + if (Path.IsPathRooted(filePath)) + { + return Path.GetFullPath(filePath); + } + + // Resolve relative paths based on the test configuration + if (_testState != null) { - _logger.LogWarning(ex, $"Unable to resolve file path relative to test config: '{filePath}'"); + try + { + var baseDirectory = Path.Combine(Directory.GetCurrentDirectory(), "../../samples/javascript-d365-tests"); + var resolvedPath = Path.Combine(baseDirectory, filePath); + + _logger.LogDebug($"Resolved file path: '{resolvedPath}'"); + return Path.GetFullPath(resolvedPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Unable to resolve file path relative to test config: '{filePath}'"); + } } return filePath; @@ -260,6 +288,26 @@ public async Task ExecuteAsync(RecordValue record) try { + // Mock the `window` object + jsEngine.SetValue("window", new + { + showDialogResponse = true, + showModalDialog = new Func((url, args, options) => + { + _logger.LogInformation($"[JS WINDOW] showModalDialog called with URL: {url}, Args: {args}, Options: {options}"); + return true; // Simulate a successful dialog response + }) + }); + + // Mock the `console` object + jsEngine.SetValue("console", new + { + log = new Action(message => _logger.LogInformation($"[JS LOG] {message}")), + error = new Action(message => _logger.LogError($"[JS ERROR] {message}")), + warn = new Action(message => _logger.LogWarning($"[JS WARN] {message}")), + debug = new Action(message => _logger.LogDebug($"[JS DEBUG] {message}")) + }); + // Execute web resource content if it was retrieved if (!string.IsNullOrEmpty(webResourceContent)) { diff --git a/src/Microsoft.PowerApps.TestEngine/System/FileSystem.cs b/src/Microsoft.PowerApps.TestEngine/System/FileSystem.cs index 4d7bb955c..d030b811b 100644 --- a/src/Microsoft.PowerApps.TestEngine/System/FileSystem.cs +++ b/src/Microsoft.PowerApps.TestEngine/System/FileSystem.cs @@ -482,6 +482,8 @@ public bool CanAccessFilePath(string filePath) ext.Equals(".csx", StringComparison.OrdinalIgnoreCase) || ext.Equals(".png", StringComparison.OrdinalIgnoreCase) + || + ext.Equals(".js", StringComparison.OrdinalIgnoreCase) ) ) { From 94218031bdd3877da8574e16386ac35d247c85f6 Mon Sep 17 00:00:00 2001 From: v-raghulraja <165115074+v-raghulraja@users.noreply.github.com> Date: Tue, 29 Jul 2025 15:17:06 +0530 Subject: [PATCH 3/7] ReadJavaScriptFileAsync () reverting it for unit test failures --- .../Functions/AssertJavaScriptFunction.cs | 444 ++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs diff --git a/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs new file mode 100644 index 000000000..aba69960f --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs @@ -0,0 +1,444 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Jint; +using Jint.Native; +using Jint.Runtime; +using Microsoft.Extensions.Logging; +using Microsoft.PowerApps.TestEngine.Config; +using Microsoft.PowerApps.TestEngine.System; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Core.Utils; +using Microsoft.PowerFx.Types; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace Microsoft.PowerApps.TestEngine.PowerFx.Functions +{ + /// + /// Execute JavaScript assertions for testing web resources and custom JavaScript + /// + public class AssertJavaScriptFunction : ReflectionFunction + { + private readonly ILogger _logger; + private readonly IOrganizationService _client; + private readonly IFileSystem _fileSystem; + private readonly ITestState _testState; + + // Define record type for results + private static readonly RecordType _result = RecordType.Empty() + .Add(new NamedFormulaType("Success", BooleanType.Boolean)) + .Add(new NamedFormulaType("Message", StringType.String)) + .Add(new NamedFormulaType("Details", StringType.String)); + + // Define record type for parameters + private static readonly RecordType _parameters = RecordType.Empty() + .Add(new NamedFormulaType("WebResource", StringType.String)) + .Add(new NamedFormulaType("Location", StringType.String)) + .Add(new NamedFormulaType("Setup", StringType.String)) + .Add(new NamedFormulaType("Run", StringType.String)) + .Add(new NamedFormulaType("Expected", StringType.String)); public AssertJavaScriptFunction(ILogger logger, IOrganizationService client, IFileSystem fileSystem = null, ITestState testState = null) : base(DPath.Root.Append(new DName("Preview")), "AssertJavaScript", _result, _parameters) + { + _logger = logger; + _client = client; + _fileSystem = fileSystem ?? new FileSystem(); + _testState = testState; + } + + /// + /// Executes JavaScript code and assertions to validate test conditions + /// + /// A record containing test parameters + /// A record with test results + public RecordValue Execute(RecordValue record) + { + return ExecuteAsync(record).Result; + } + + /// + /// Retrieves the content of a web resource from Dataverse + /// + /// Name of the web resource + /// The content of the web resource as string + private async Task RetrieveWebResourceContentAsync(string webResourceName) + { + try + { + _logger.LogInformation($"Retrieving web resource '{webResourceName}'"); + + if (_client == null) + { + _logger.LogWarning("Organization service is not available, cannot retrieve web resource"); + return string.Empty; + } + + // Query for the web resource + QueryExpression query = new QueryExpression("webresource") + { + ColumnSet = new ColumnSet("content", "name"), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("name", ConditionOperator.Equal, webResourceName) + } + } + }; + + EntityCollection results = await Task.Run(() => _client.RetrieveMultiple(query)); + + if (results.Entities.Count == 0) + { + _logger.LogWarning($"Web resource '{webResourceName}' not found"); + return string.Empty; + } + + Entity webResource = results.Entities[0]; + string content = webResource.Contains("content") ? + webResource["content"].ToString() : string.Empty; + + // Web resource content is stored as base64 + if (!string.IsNullOrEmpty(content)) + { + byte[] bytes = Convert.FromBase64String(content); + return Encoding.UTF8.GetString(bytes); + } + + return string.Empty; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error retrieving web resource '{webResourceName}'"); + return string.Empty; + } + } + + // In ReadJavaScriptFileAsync, add more diagnostics to help debug file existence issues + + private async Task ReadJavaScriptFileAsync(string filePath) + { + try + { + filePath = ResolveFilePath(filePath); + _logger.LogInformation($"Reading JavaScript file from '{filePath}'"); + + if (string.IsNullOrEmpty(filePath)) + { + _logger.LogWarning("File path is empty after resolution."); + return string.Empty; + } + + _logger.LogDebug($"Resolved file path: '{filePath}'"); + + // Additional diagnostics + bool fileExists = _fileSystem.FileExists(filePath); + bool canAccess = _fileSystem.CanAccessFilePath(filePath); + _logger.LogDebug($"FileExists: {fileExists}, CanAccessFilePath: {canAccess}"); + + if (!fileExists) + { + _logger.LogWarning($"File not found: '{filePath}'"); + _logger.LogDebug($"Current directory: '{Directory.GetCurrentDirectory()}'"); + // List files in the directory for debugging + var dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) + { + var files = Directory.GetFiles(dir); + _logger.LogDebug($"Files in directory '{dir}': {string.Join(", ", files)}"); + } + return string.Empty; + } + + var content = await Task.Run(() => _fileSystem.ReadAllText(filePath)); + _logger.LogInformation($"Successfully read JavaScript file: '{filePath}'"); + return content; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error reading file: '{filePath}'"); + return string.Empty; + } + } + + /// + /// Resolves a file path relative to the test configuration file if it's not already an absolute path + /// + /// The file path to resolve + /// The resolved absolute file path + private string ResolveFilePath(string filePath) + { + if (string.IsNullOrEmpty(filePath)) + { + _logger.LogWarning("File path is null or empty."); + return string.Empty; + } + + // Check for invalid characters in the file path + if (filePath.IndexOfAny(Path.GetInvalidPathChars()) >= 0) + { + _logger.LogWarning($"File path contains invalid characters: '{filePath}'"); + return string.Empty; + } + + // Prevent malformed paths like ".C:/..." + if (filePath.StartsWith(".") && filePath.Contains(":")) + { + _logger.LogWarning($"File path is malformed: '{filePath}'"); + return string.Empty; + } + + // If the path is already absolute, return it + if (Path.IsPathRooted(filePath)) + { + return Path.GetFullPath(filePath); + } + + // Resolve relative paths based on the test configuration + if (_testState != null) + { + try + { + var baseDirectory = Path.Combine(Directory.GetCurrentDirectory(), "../../samples/javascript-d365-tests"); + var resolvedPath = Path.Combine(baseDirectory, filePath); + + _logger.LogDebug($"Resolved file path: '{resolvedPath}'"); + return Path.GetFullPath(resolvedPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Unable to resolve file path relative to test config: '{filePath}'"); + } + } + + return filePath; + } + + public async Task ExecuteAsync(RecordValue record) + { + _logger.LogInformation("Starting JavaScript assertion execution"); + + // Extract parameters from the record + string webResource = string.Empty; + string location = string.Empty; + string setupCode = string.Empty; + string runCode = string.Empty; + string expectedResult = string.Empty; + + // Extract values from record fields + foreach (var field in record.Fields) + { + if (field.Name == "WebResource" && field.Value is StringValue webResourceVal) + { + webResource = webResourceVal.Value; + } + else if (field.Name == "Location" && field.Value is StringValue locationVal) + { + location = locationVal.Value; + } + else if (field.Name == "Setup" && field.Value is StringValue setupVal) + { + setupCode = setupVal.Value; + } + else if (field.Name == "Run" && field.Value is StringValue runVal) + { + runCode = runVal.Value; + } + else if (field.Name == "Expected" && field.Value is StringValue expectedVal) + { + expectedResult = expectedVal.Value; + } + } + + // Validate required parameters + if (string.IsNullOrEmpty(runCode)) + { + return CreateErrorResult(false, "Missing required parameter", "The 'Run' parameter is required."); + } + + if (string.IsNullOrEmpty(expectedResult)) + { + return CreateErrorResult(false, "Missing required parameter", "The 'Expected' parameter is required."); + } + + // Retrieve web resource content from Dataverse if specified + string webResourceContent = string.Empty; + if (!string.IsNullOrEmpty(webResource)) + { + webResourceContent = await RetrieveWebResourceContentAsync(webResource); + if (string.IsNullOrEmpty(webResourceContent)) + { + return CreateErrorResult(false, "Web resource error", $"Could not retrieve web resource '{webResource}'"); + } + } + + // Read JavaScript from local file if location is specified + string locationContent = string.Empty; + if (!string.IsNullOrEmpty(location)) + { + locationContent = await ReadJavaScriptFileAsync(location); + if (string.IsNullOrEmpty(locationContent)) + { + return CreateErrorResult(false, "File error", $"Could not read JavaScript file from '{location}'"); + } + } + + // Create a new engine instance for each test to ensure isolation + Jint.Engine jsEngine = new Jint.Engine(options => + { + options.Strict(); // Use strict mode + options.TimeoutInterval(TimeSpan.FromSeconds(10)); // Prevent infinite loops + options.MaxStatements(10000); // Limit complexity + // No CLR integration as per requirements + }); + + try + { + // Mock the `window` object + jsEngine.SetValue("window", new + { + showDialogResponse = true, + showModalDialog = new Func((url, args, options) => + { + _logger.LogInformation($"[JS WINDOW] showModalDialog called with URL: {url}, Args: {args}, Options: {options}"); + return true; // Simulate a successful dialog response + }) + }); + + // Mock the `console` object + jsEngine.SetValue("console", new + { + log = new Action(message => _logger.LogInformation($"[JS LOG] {message}")), + error = new Action(message => _logger.LogError($"[JS ERROR] {message}")), + warn = new Action(message => _logger.LogWarning($"[JS WARN] {message}")), + debug = new Action(message => _logger.LogDebug($"[JS DEBUG] {message}")) + }); + + // Execute web resource content if it was retrieved + if (!string.IsNullOrEmpty(webResourceContent)) + { + _logger.LogInformation("Executing web resource script"); + try + { + await Task.Run(() => jsEngine.Execute(webResourceContent)); + } + catch (JavaScriptException jex) + { + _logger.LogError($"Error in web resource script: {jex.Message}"); + return CreateErrorResult(false, "Web resource execution failed", + $"Error: {jex.Error}"); + } + } + + // Execute local file content if it was read + if (!string.IsNullOrEmpty(locationContent)) + { + _logger.LogInformation("Executing JavaScript file"); + try + { + await Task.Run(() => jsEngine.Execute(locationContent)); + } + catch (JavaScriptException jex) + { + _logger.LogError($"Error in JavaScript file: {jex.Message}"); + return CreateErrorResult(false, "JavaScript file execution failed", + $"Error: {jex.Error}"); + } + } + // Execute setup code if provided + if (!string.IsNullOrEmpty(setupCode)) + { + _logger.LogInformation("Executing setup code"); + try + { + // Check if setupCode is a file path + if (setupCode.EndsWith(".js", StringComparison.OrdinalIgnoreCase) && !setupCode.Contains("\n") && !setupCode.Contains(";")) + { + var setupFileContent = await ReadJavaScriptFileAsync(setupCode); + if (!string.IsNullOrEmpty(setupFileContent)) + { + setupCode = setupFileContent; + } + } + + await Task.Run(() => jsEngine.Execute(setupCode)); + } + catch (JavaScriptException jex) + { + _logger.LogError($"Error in setup code: {jex.Message}"); + return CreateErrorResult(false, "Setup code execution failed", + $"Error: {jex.Error}"); + } + } + + // Execute run code + _logger.LogInformation("Executing test code"); + JsValue result; + + try + { + result = await Task.Run(() => jsEngine.Evaluate(runCode)); + } + catch (JavaScriptException jex) + { + _logger.LogError($"Error in test code: {jex.Message}"); + return CreateErrorResult(false, "Test code execution failed", + $"Error: {jex.Error}"); + } + catch (Exception ex) + { + _logger.LogError($"Unexpected error: {ex.Message}"); + return CreateErrorResult(false, "Unexpected error during execution", ex.ToString()); + } + + // Compare the result with expected value + string actualResult = result.ToString(); + bool testPassed = string.Equals(actualResult, expectedResult); + + if (testPassed) + { + _logger.LogInformation("Assertion passed"); + return CreateSuccessResult(); + } + else + { + _logger.LogWarning($"Assertion failed: Expected '{expectedResult}', got '{actualResult}'"); + return CreateErrorResult(false, "Assertion failed", + $"Expected '{expectedResult}', got '{actualResult}'"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception occurred during JavaScript assertion execution"); + return CreateErrorResult(false, "Execution error", ex.ToString()); + } + } + + /// + /// Creates a success result record + /// + private RecordValue CreateSuccessResult() + { + var success = new NamedValue("Success", FormulaValue.New(true)); + var message = new NamedValue("Message", FormulaValue.New("Assertion passed")); + var details = new NamedValue("Details", FormulaValue.New(string.Empty)); + + return RecordValue.NewRecordFromFields(_result, new[] { success, message, details }); + } + + /// + /// Creates an error result record + /// + private RecordValue CreateErrorResult(bool success, string message, string details) + { + var successValue = new NamedValue("Success", FormulaValue.New(success)); + var messageValue = new NamedValue("Message", FormulaValue.New(message)); + var detailsValue = new NamedValue("Details", FormulaValue.New(details)); + + return RecordValue.NewRecordFromFields(_result, new[] { successValue, messageValue, detailsValue }); + } + } +} From acdaefb2d513dfc7389fc6701bc96f427f5e8945 Mon Sep 17 00:00:00 2001 From: v-raghulraja <165115074+v-raghulraja@users.noreply.github.com> Date: Tue, 29 Jul 2025 17:20:07 +0530 Subject: [PATCH 4/7] Revert --- samples/javascript-d365-tests/mockXrm.js | 181 ++++++++++++++++++ .../Functions/AssertJavaScriptFunction.cs | 84 ++------ 2 files changed, 196 insertions(+), 69 deletions(-) create mode 100644 samples/javascript-d365-tests/mockXrm.js diff --git a/samples/javascript-d365-tests/mockXrm.js b/samples/javascript-d365-tests/mockXrm.js new file mode 100644 index 000000000..bfe97a88c --- /dev/null +++ b/samples/javascript-d365-tests/mockXrm.js @@ -0,0 +1,181 @@ +/** + * mockXrm.js - Mock implementation of Dynamics 365 Xrm object for testing + * This file provides a simple mock of the Xrm object to enable testing of client-side scripts + */ + +// Initialize global Xrm object +var Xrm = { + Page: { + ui: { + formType: 2, // Default to Update form type + tabs: { + items: {}, + get: function (tabName) { + if (!this.items[tabName]) { + this.items[tabName] = { + visible: true, + setVisible: function (visible) { this.visible = visible; }, + getVisible: function () { return this.visible; } + }; + } + return this.items[tabName]; + } + }, + sections: { + items: {}, + get: function (sectionName) { + if (!this.items[sectionName]) { + this.items[sectionName] = { + visible: true, + setVisible: function (visible) { this.visible = visible; }, + getVisible: function () { return this.visible; } + }; + } + return this.items[sectionName]; + } + }, + controls: {}, + getFormType: function () { return this.formType; }, + setFormType: function (type) { this.formType = type; } + }, + + data: { + entity: { + attributes: {} + } + }, + + getAttribute: function (attributeName) { + if (!this.attributes) { + this.attributes = {}; + } + + if (!this.attributes[attributeName]) { + this.attributes[attributeName] = { + value: null, + requiredLevel: "none", + handlers: [], + getValue: function () { return this.value; }, + setValue: function (value) { + this.value = value; + // Call registered handlers when value is changed + this.handlers.forEach(function (handler) { + handler(); + }); + }, + setRequiredLevel: function (level) { this.requiredLevel = level; }, + getRequiredLevel: function () { return this.requiredLevel; }, + addOnChange: function (handler) { this.handlers.push(handler); } + }; + } + + return this.attributes[attributeName]; + }, + + getControl: function (controlName) { + if (!this.ui.controls[controlName]) { + this.ui.controls[controlName] = { + visible: true, + notification: null, + setVisible: function (visible) { this.visible = visible; }, + getVisible: function () { return this.visible; }, + setNotification: function (message, id) { this.notification = { message: message, id: id }; }, + clearNotification: function (id) { + if (this.notification && this.notification.id === id) { + this.notification = null; + } + }, + getNotification: function () { return this.notification; } + }; + } + + return this.ui.controls[controlName]; + } + }, + + Utility: { + alertDialog: function (message, callback) { + console.log("Alert Dialog: " + message); + if (callback) callback(); + return true; + }, + confirmDialog: function (message, callback) { + console.log("Confirm Dialog: " + message); + if (callback) callback(true); // Always confirm in test + return true; + } + }, + + WebApi: { + online: true, + execute: function (request) { + console.log("WebApi.execute called with:", request); + // Mock implementation returns a resolved promise + return Promise.resolve({ + ok: true, + json: function () { + return Promise.resolve({ + value: "Mock API response" + }); + } + }); + }, + retrieveMultipleRecords: function (entityName, options, maxPageSize) { + console.log("WebApi.retrieveMultipleRecords called for:", entityName); + // Mock implementation returns a resolved promise with empty result set + return Promise.resolve({ + entities: [] + }); + } + } +}; + +// Add attributes to support testing +Xrm.Page.getAttribute("accountstatus").setValue(1); // Default to active +Xrm.Page.getAttribute("activecases_count").setValue(0); // Default to no active cases +Xrm.Page.getAttribute("accounttype").setValue(0); // Default to no specific type +Xrm.Page.getAttribute("industrycode").setValue(0); // Default to no specific industry +Xrm.Page.getAttribute("creditlimit").setValue(5000); // Default credit limit +Xrm.Page.getAttribute("creditscore").setValue(650); // Default credit score +Xrm.Page.getAttribute("customertype").setValue(0); // Default customer type + +// Create mock implementation for dialog display used in recommendations +window.showDialogResponse = true; +window.showModalDialog = function (url, args, options) { + console.log("Show Modal Dialog called with:", args); + return window.showDialogResponse; +}; + +// Functions for testing +function resetMockXrm() { + Xrm.Page.ui.formType = 2; + Xrm.Page.getAttribute("accountstatus").setValue(1); + Xrm.Page.getAttribute("activecases_count").setValue(0); + Xrm.Page.getAttribute("accounttype").setValue(0); + Xrm.Page.getAttribute("industrycode").setValue(0); + Xrm.Page.getAttribute("creditlimit").setValue(5000); + Xrm.Page.getAttribute("creditscore").setValue(650); + Xrm.Page.getAttribute("customertype").setValue(0); + window.showDialogResponse = true; +} + +// Add fetch xml support +Xrm.WebApi.retrieveRecords = function (entityType, options) { + console.log("WebApi.retrieveRecords called for:", entityType); + // Mock implementation returns a resolved promise with empty result set + return Promise.resolve({ + entities: [] + }); +}; + +// Add support for form context rather than just global form +Xrm.Page.context = { + getClientUrl: function () { + return "https://mock.crm.dynamics.com"; + } +}; + +// Add form event registration capability +Xrm.Page.data.entity.addOnSave = function (handler) { + // Just store the handler - not implemented for tests +}; \ No newline at end of file diff --git a/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs index 99e5535bb..58db645b1 100644 --- a/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs +++ b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/AssertJavaScriptFunction.cs @@ -120,40 +120,30 @@ private async Task RetrieveWebResourceContentAsync(string webResourceNam /// /// Reads JavaScript content from a local file /// - /// Path to the .js file + /// Path to the file /// The file content as string private async Task ReadJavaScriptFileAsync(string filePath) { try { + // Resolve the file path relative to the test config file if needed filePath = ResolveFilePath(filePath); + _logger.LogInformation($"Reading JavaScript file from '{filePath}'"); if (string.IsNullOrEmpty(filePath)) { - _logger.LogWarning("File path is empty after resolution."); return string.Empty; } - _logger.LogDebug($"Resolved file path: '{filePath}'"); - if (!_fileSystem.FileExists(filePath)) { _logger.LogWarning($"File not found: '{filePath}'"); _logger.LogDebug($"Current directory: '{Directory.GetCurrentDirectory()}'"); - // List files in the directory for debugging - var dir = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) - { - var files = Directory.GetFiles(dir); - _logger.LogDebug($"Files in directory '{dir}': {string.Join(", ", files)}"); - } return string.Empty; } - var content = await Task.Run(() => _fileSystem.ReadAllText(filePath)); - _logger.LogInformation($"Successfully read JavaScript file: '{filePath}'"); - return content; + return await Task.Run(() => _fileSystem.ReadAllText(filePath)); } catch (Exception ex) { @@ -169,48 +159,24 @@ private async Task ReadJavaScriptFileAsync(string filePath) /// The resolved absolute file path private string ResolveFilePath(string filePath) { - if (string.IsNullOrEmpty(filePath)) - { - _logger.LogWarning("File path is null or empty."); - return string.Empty; - } - - // Check for invalid characters in the file path - if (filePath.IndexOfAny(Path.GetInvalidPathChars()) >= 0) - { - _logger.LogWarning($"File path contains invalid characters: '{filePath}'"); - return string.Empty; - } - - // Prevent malformed paths like ".C:/..." - if (filePath.StartsWith(".") && filePath.Contains(":")) - { - _logger.LogWarning($"File path is malformed: '{filePath}'"); - return string.Empty; - } - - // If the path is already absolute, return it - if (Path.IsPathRooted(filePath)) + if (string.IsNullOrEmpty(filePath) || Path.IsPathRooted(filePath) || _testState == null) { - return Path.GetFullPath(filePath); + return filePath; } - // Resolve relative paths based on the test configuration - if (_testState != null) + try { - try - { - var baseDirectory = Path.Combine(Directory.GetCurrentDirectory(), "../../samples/javascript-d365-tests"); - var resolvedPath = Path.Combine(baseDirectory, filePath); - - _logger.LogDebug($"Resolved file path: '{resolvedPath}'"); - return Path.GetFullPath(resolvedPath); - } - catch (Exception ex) + var testConfigFile = _testState.GetTestConfigFile(); + if (testConfigFile != null) { - _logger.LogWarning(ex, $"Unable to resolve file path relative to test config: '{filePath}'"); + var testResultDirectory = Path.GetDirectoryName(testConfigFile.FullName); + return Path.Combine(testResultDirectory, filePath); } } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Unable to resolve file path relative to test config: '{filePath}'"); + } return filePath; } @@ -295,26 +261,6 @@ public async Task ExecuteAsync(RecordValue record) try { - // Mock the `window` object - jsEngine.SetValue("window", new - { - showDialogResponse = true, - showModalDialog = new Func((url, args, options) => - { - _logger.LogInformation($"[JS WINDOW] showModalDialog called with URL: {url}, Args: {args}, Options: {options}"); - return true; // Simulate a successful dialog response - }) - }); - - // Mock the `console` object - jsEngine.SetValue("console", new - { - log = new Action(message => _logger.LogInformation($"[JS LOG] {message}")), - error = new Action(message => _logger.LogError($"[JS ERROR] {message}")), - warn = new Action(message => _logger.LogWarning($"[JS WARN] {message}")), - debug = new Action(message => _logger.LogDebug($"[JS DEBUG] {message}")) - }); - // Execute web resource content if it was retrieved if (!string.IsNullOrEmpty(webResourceContent)) { From 9b4f1dbdcab1b617318a41fc626b9a53001902d1 Mon Sep 17 00:00:00 2001 From: v-raghulraja <165115074+v-raghulraja@users.noreply.github.com> Date: Tue, 29 Jul 2025 18:18:35 +0530 Subject: [PATCH 5/7] fixing jint library issue --- .../Microsoft.PowerApps.TestEngine.Tests.csproj | 2 +- .../Microsoft.PowerApps.TestEngine.csproj | 1 + .../testengine.common.user.tests.csproj | 2 +- .../testengine.provider.mda.tests.csproj | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) 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 c06667317..782c6cc2e 100644 --- a/src/Microsoft.PowerApps.TestEngine.Tests/Microsoft.PowerApps.TestEngine.Tests.csproj +++ b/src/Microsoft.PowerApps.TestEngine.Tests/Microsoft.PowerApps.TestEngine.Tests.csproj @@ -18,7 +18,7 @@ - + diff --git a/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj b/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj index 0928e8cf5..f7606d847 100644 --- a/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj +++ b/src/Microsoft.PowerApps.TestEngine/Microsoft.PowerApps.TestEngine.csproj @@ -41,6 +41,7 @@ + diff --git a/src/testengine.common.user.tests/testengine.common.user.tests.csproj b/src/testengine.common.user.tests/testengine.common.user.tests.csproj index 878dc287e..42409e865 100644 --- a/src/testengine.common.user.tests/testengine.common.user.tests.csproj +++ b/src/testengine.common.user.tests/testengine.common.user.tests.csproj @@ -28,7 +28,7 @@ - + diff --git a/src/testengine.provider.mda.tests/testengine.provider.mda.tests.csproj b/src/testengine.provider.mda.tests/testengine.provider.mda.tests.csproj index 85397ea79..1415e62b4 100644 --- a/src/testengine.provider.mda.tests/testengine.provider.mda.tests.csproj +++ b/src/testengine.provider.mda.tests/testengine.provider.mda.tests.csproj @@ -38,7 +38,7 @@ - + From 05b81b8ca6cd92b7abac82770e8c9ccfaeedac01 Mon Sep 17 00:00:00 2001 From: snamilikonda Date: Fri, 16 May 2025 21:56:36 -0700 Subject: [PATCH 6/7] User/snamilikonda/32383063 (#620) * skim handling * passing path * moving job level * reverting last * sign debug * reset debug * readding explicit debug * using val * set as bool * setting to release * reducing file range * additional pattern * reset file --- .azurepipelines/azure-pipelines-1ES.yml | 20 +++------ .config/guardian/.gdnsuppress | 56 +++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 .config/guardian/.gdnsuppress diff --git a/.azurepipelines/azure-pipelines-1ES.yml b/.azurepipelines/azure-pipelines-1ES.yml index c81331d6b..f5b9e1552 100644 --- a/.azurepipelines/azure-pipelines-1ES.yml +++ b/.azurepipelines/azure-pipelines-1ES.yml @@ -23,6 +23,8 @@ extends: break: true policheck: enabled: true + suppression: + suppressionFile: $(Build.SourcesDirectory)\.config\guardian\.gdnsuppress codeql: tsaEnabled: true ${{ if eq(variables['Build.SourceBranch'], variables['AllowedBranch']) }}: @@ -38,25 +40,15 @@ extends: displayName: 'Build PowerAppsTestEngine Solution' strategy: matrix: - Debug: - BuildConfiguration: 'Debug' Release: BuildConfiguration: 'Release' templateContext: sdl: - ${{ if eq(variables['BuildConfiguration'], 'Release') }}: - codeSignValidation: - additionalTargetsGlobPattern: -|**\.playwright\**;-|**PowerAppsTestEngineWrapper\playwright.ps1;-|**PowerAppsTestEngineWrapper\JS\** - enabled: true - break: true + codeSignValidation: + additionalTargetsGlobPattern: -|**\PowerAppsTestEngineWrapper\.playwright\**\*.js;-|**\PowerAppsTestEngineWrapper\.playwright\**\*.exe;-|**\PowerAppsTestEngineWrapper\.playwright\**\*.ps1;-|**\PowerAppsTestEngineWrapper\playwright.ps1;-|**\PowerAppsTestEngineWrapper\JS\**\*.js + enabled: true + break: true - ${{ if ne(variables['BuildConfiguration'], 'Release') }}: - codeSignValidation: - additionalTargetsGlobPattern: -|**\.playwright\**;-|**PowerAppsTestEngineWrapper\playwright.ps1;-|**PowerAppsTestEngineWrapper\JS\** - enabled: false - break: true - - outputs: - output: pipelineArtifact condition: succeeded() diff --git a/.config/guardian/.gdnsuppress b/.config/guardian/.gdnsuppress new file mode 100644 index 000000000..94b01a98c --- /dev/null +++ b/.config/guardian/.gdnsuppress @@ -0,0 +1,56 @@ +{ + "hydrated": false, + "properties": { + "helpUri": "https://eng.ms/docs/microsoft-security/security/azure-security/cloudai-security-fundamentals-engineering/security-integration/guardian-wiki/microsoft-guardian/general/suppressions" + }, + "version": "1.0.0", + "suppressionSets": { + "default": { + "name": "default", + "createdDate": "2025-05-12 17:45:01Z", + "lastUpdatedDate": "2025-05-12 17:45:01Z" + } + }, + "results": { + "b057a63e25bdc6d630ec098251d060c1c86cf058a73da6562a6107bfb8c4d271": { + "signature": "b057a63e25bdc6d630ec098251d060c1c86cf058a73da6562a6107bfb8c4d271", + "alternativeSignatures": [ + "5f6b205b31a2d99db69157c9296eda17c6d40ec1c64fdda4e85231be10212554" + ], + "memberOf": [ + "default" + ], + "createdDate": "2025-05-12 17:45:01Z" + }, + "c9b43e17b61625127dae42db6261819b13c64c578ff2135c0c7429f1ecf895ae": { + "signature": "c9b43e17b61625127dae42db6261819b13c64c578ff2135c0c7429f1ecf895ae", + "alternativeSignatures": [ + "e219cca440574349b1e610f45d9f3cdcf7c8bd3ddd595a6fdcd18731708e6730" + ], + "memberOf": [ + "default" + ], + "createdDate": "2025-05-12 17:45:01Z" + }, + "69b6d4e783a284ae0764beaf3f8727e6d7ccd7b1e4e81c0236e0b47d2c601954": { + "signature": "69b6d4e783a284ae0764beaf3f8727e6d7ccd7b1e4e81c0236e0b47d2c601954", + "alternativeSignatures": [ + "8e897bb45731713521d871bb8fd89ae277b6cc716430a4c9912a799ae45b1fd3" + ], + "memberOf": [ + "default" + ], + "createdDate": "2025-05-12 17:45:01Z" + }, + "258cbb42f12b414eace596f34eb7ea2de3d17a4f57bd5540fbc99bc7f3069f3c": { + "signature": "258cbb42f12b414eace596f34eb7ea2de3d17a4f57bd5540fbc99bc7f3069f3c", + "alternativeSignatures": [ + "fa766c38acaf67ade224fa2b6e7cba4c320949b38c09dc64566511836a2776c9" + ], + "memberOf": [ + "default" + ], + "createdDate": "2025-05-12 17:45:01Z" + } + } +} \ No newline at end of file From e67c7a640f6363f0e72698ac532d71f9803351da Mon Sep 17 00:00:00 2001 From: v-raghulraja <165115074+v-raghulraja@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:57:28 +0530 Subject: [PATCH 7/7] Revert "User/snamilikonda/32383063 (#620)" This reverts commit 05b81b8ca6cd92b7abac82770e8c9ccfaeedac01. --- .azurepipelines/azure-pipelines-1ES.yml | 20 ++++++--- .config/guardian/.gdnsuppress | 56 ------------------------- 2 files changed, 14 insertions(+), 62 deletions(-) delete mode 100644 .config/guardian/.gdnsuppress diff --git a/.azurepipelines/azure-pipelines-1ES.yml b/.azurepipelines/azure-pipelines-1ES.yml index f5b9e1552..c81331d6b 100644 --- a/.azurepipelines/azure-pipelines-1ES.yml +++ b/.azurepipelines/azure-pipelines-1ES.yml @@ -23,8 +23,6 @@ extends: break: true policheck: enabled: true - suppression: - suppressionFile: $(Build.SourcesDirectory)\.config\guardian\.gdnsuppress codeql: tsaEnabled: true ${{ if eq(variables['Build.SourceBranch'], variables['AllowedBranch']) }}: @@ -40,15 +38,25 @@ extends: displayName: 'Build PowerAppsTestEngine Solution' strategy: matrix: + Debug: + BuildConfiguration: 'Debug' Release: BuildConfiguration: 'Release' templateContext: sdl: - codeSignValidation: - additionalTargetsGlobPattern: -|**\PowerAppsTestEngineWrapper\.playwright\**\*.js;-|**\PowerAppsTestEngineWrapper\.playwright\**\*.exe;-|**\PowerAppsTestEngineWrapper\.playwright\**\*.ps1;-|**\PowerAppsTestEngineWrapper\playwright.ps1;-|**\PowerAppsTestEngineWrapper\JS\**\*.js - enabled: true - break: true + ${{ if eq(variables['BuildConfiguration'], 'Release') }}: + codeSignValidation: + additionalTargetsGlobPattern: -|**\.playwright\**;-|**PowerAppsTestEngineWrapper\playwright.ps1;-|**PowerAppsTestEngineWrapper\JS\** + enabled: true + break: true + ${{ if ne(variables['BuildConfiguration'], 'Release') }}: + codeSignValidation: + additionalTargetsGlobPattern: -|**\.playwright\**;-|**PowerAppsTestEngineWrapper\playwright.ps1;-|**PowerAppsTestEngineWrapper\JS\** + enabled: false + break: true + + outputs: - output: pipelineArtifact condition: succeeded() diff --git a/.config/guardian/.gdnsuppress b/.config/guardian/.gdnsuppress deleted file mode 100644 index 94b01a98c..000000000 --- a/.config/guardian/.gdnsuppress +++ /dev/null @@ -1,56 +0,0 @@ -{ - "hydrated": false, - "properties": { - "helpUri": "https://eng.ms/docs/microsoft-security/security/azure-security/cloudai-security-fundamentals-engineering/security-integration/guardian-wiki/microsoft-guardian/general/suppressions" - }, - "version": "1.0.0", - "suppressionSets": { - "default": { - "name": "default", - "createdDate": "2025-05-12 17:45:01Z", - "lastUpdatedDate": "2025-05-12 17:45:01Z" - } - }, - "results": { - "b057a63e25bdc6d630ec098251d060c1c86cf058a73da6562a6107bfb8c4d271": { - "signature": "b057a63e25bdc6d630ec098251d060c1c86cf058a73da6562a6107bfb8c4d271", - "alternativeSignatures": [ - "5f6b205b31a2d99db69157c9296eda17c6d40ec1c64fdda4e85231be10212554" - ], - "memberOf": [ - "default" - ], - "createdDate": "2025-05-12 17:45:01Z" - }, - "c9b43e17b61625127dae42db6261819b13c64c578ff2135c0c7429f1ecf895ae": { - "signature": "c9b43e17b61625127dae42db6261819b13c64c578ff2135c0c7429f1ecf895ae", - "alternativeSignatures": [ - "e219cca440574349b1e610f45d9f3cdcf7c8bd3ddd595a6fdcd18731708e6730" - ], - "memberOf": [ - "default" - ], - "createdDate": "2025-05-12 17:45:01Z" - }, - "69b6d4e783a284ae0764beaf3f8727e6d7ccd7b1e4e81c0236e0b47d2c601954": { - "signature": "69b6d4e783a284ae0764beaf3f8727e6d7ccd7b1e4e81c0236e0b47d2c601954", - "alternativeSignatures": [ - "8e897bb45731713521d871bb8fd89ae277b6cc716430a4c9912a799ae45b1fd3" - ], - "memberOf": [ - "default" - ], - "createdDate": "2025-05-12 17:45:01Z" - }, - "258cbb42f12b414eace596f34eb7ea2de3d17a4f57bd5540fbc99bc7f3069f3c": { - "signature": "258cbb42f12b414eace596f34eb7ea2de3d17a4f57bd5540fbc99bc7f3069f3c", - "alternativeSignatures": [ - "fa766c38acaf67ade224fa2b6e7cba4c320949b38c09dc64566511836a2776c9" - ], - "memberOf": [ - "default" - ], - "createdDate": "2025-05-12 17:45:01Z" - } - } -} \ No newline at end of file