diff --git a/samples/entitylist/testPlan.fx.yaml b/samples/entitylist/testPlan.fx.yaml new file mode 100644 index 000000000..5faf15dfb --- /dev/null +++ b/samples/entitylist/testPlan.fx.yaml @@ -0,0 +1,118 @@ +testSuite: + testSuiteName: Employee Entity Form Automation + testSuiteDescription: Verifies that the Employee Entity controls work correctly. + persona: User1 + appLogicalName: Entity_controls_app + + testCases: + + # 1. Insert Employee + - testCaseName: Insert Employee + testCaseDescription: Inserts a new employee record and asserts the table contains 4 records after insertion. + testSteps: | + Collect( + cr693_employee5, + { + cr693_employeename: "Ra Ra", + cr693_empoyeeid: "E006", + cr693_dob: DateTime(1991,05,15,00,0,0) + } + ); + Assert(CountRows(cr693_employee5) = 4, "The employee table should contain 4 records after insertion"); + + # 2. Sort Employee IDs + - testCaseName: Sort Employee IDs + testCaseDescription: Sorts the employee list by Employee ID in descending order. + testSteps: | + Sort(cr693_employee5, cr693_empoyeeid, SortOrder.Descending); + + # 3. Delete Employee with ID E006 + - testCaseName: Delete Employee with ID E006 + testCaseDescription: Deletes the employee record with ID 'E006' and asserts it no longer exists. + testSteps: | + Refresh(cr693_employee5); + Remove( + cr693_employee5, + LookUp(cr693_employee5, cr693_empoyeeid = "E007") + ); + Assert( + IsBlank(LookUp(cr693_employee5, cr693_empoyeeid = "E007")), + "The record with employee ID 'E006' should no longer exist in the employee table" + ); + + # 4. Update Employee + - testCaseName: Update Employee + testCaseDescription: Updates the name of the first employee record and asserts the update. + testSteps: | + Patch( + cr693_employee5, + First(cr693_employee5), + { + cr693_employeename: "RR2" + } + ); + Assert(First(cr693_employee5).cr693_employeename = "RR2", "The employee name should be updated to 'RR2'"); + + # 5. Verify Employee Record Count + - testCaseName: Verify Employee Record Count + testCaseDescription: Asserts that the employee table displays exactly 3 employee records. + testSteps: | + Assert(CountRows(cr693_employee5) = 3, "Checking if Table displays correct number of items"); + + # 6. Verify Employee List Filtering by Name + - testCaseName: Verify Employee List Filtering by Name + testCaseDescription: Asserts that filtering the employee list by name returns 1 record starting with 'uu'. + testSteps: | + Assert(CountRows(Filter(cr693_employee5, StartsWith(cr693_employeename, "uu"))) = 1, "The employee list should contain 1 record with a name starting with 'Test'"); + + # 7. Verify Employee Name Field + - testCaseName: Verify Employee Name Field + testCaseDescription: Asserts that the Employee Name for ID 'E001' is 'Alice Smith'. + testSteps: | + Assert(LookUp(cr693_employee5, cr693_empoyeeid = "E001").cr693_employeename = "RR2", "The Employee Name for ID 'E001' should be 'RR2'"); + + # 8. Click New Record button on the Command Bar Button + - testCaseName: Click New Record button on the Command Bar Button + testCaseDescription: Automates clicking the New Record button, entering details, and saving the new employee. + testSteps: | + Assert(NavigateToRecord("cr693_employee5", "entityrecord", 1)); + SetProperty(cr693_empoyeeid.Text, "E007"); + SetProperty(cr693_employeename.Text, "RR Test_Create"); + SetDOBFields("04/09/1976", "11:30 PM"); + SelectDropdownOption("IT"); + CommandBarAction(SaveAndClose()); + Assert(NavigateToRecord("cr693_employee5", "entitylist", 1)); + + # 9. Test SelectGridRowCheckbox + - testCaseName: Test SelectGridRowCheckbox + testCaseDescription: Selects the checkbox of the first grid row. + testSteps: | + SelectGridRowCheckbox(1); + + # 10. Click Edit Record button on the Command Bar Button + - testCaseName: Click Edit Record button on the Command Bar Button + testCaseDescription: Automates editing the first record, updating all fields, and saving. + testSteps: | + Assert(NavigateToRecord("cr693_employee5", "entityrecord", 1)); + SetProperty(cr693_employeename.Text, "John 20 Updated"); + SetProperty(cr693_empoyeeid.Text, "E020 Updated"); + SetDOBFields("04/09/1976", "12:30 PM"); + SelectDropdownOption("Finance"); + CommandBarAction(SaveAndClose()); + Assert(NavigateToRecord("cr693_employee5", "entitylist", 1)); + + # 11. Delete Record + - testCaseName: Delete Record + testCaseDescription: Automates deleting the first record from the list and verifies navigation. + testSteps: | + SelectGridRowCheckbox(1); + Assert(NavigateToRecord("cr693_employee5", "entityrecord", 1)); + DeleteRecord(); + Assert(NavigateToRecord("cr693_employee5", "entitylist", 1)); + +testSettings: + filePath: ./testSettings.yaml +environmentVariables: + users: + - personaName: User1 + emailKey: user1Email diff --git a/samples/entitylist/testSettings.yaml b/samples/entitylist/testSettings.yaml new file mode 100644 index 000000000..ab936e141 --- /dev/null +++ b/samples/entitylist/testSettings.yaml @@ -0,0 +1,34 @@ +locale: "en-US" +headless: false +recordVideo: true +extensionModules: + enable: true + parameters: + enableDataverseFunctions: true + allowPowerFxNamespaces: + - Preview +timeout: 1200000 +browserConfigurations: + - browser: Chromium + channel: msedge + +testFunctions: + - description: Get Identifier of save comamnd bar item + code: | + SaveForm(): Boolean = Preview.SaveForm(); + - description: Get Identifier of New command bar item + code: | + NewRecord(): Text = "New"; + - description: Get Identifier of save and close command bar item + code: | + SaveAndClose(): Text = "Save & Close"; + - description: Save and close the form using Document Object Model (DOM) selector for command bar + code: | + CommandBarAction(name: Text): Void = + Preview.PlaywrightAction(Concatenate("//*[@aria-label='", name, "']"), "click"); + + - description: Delete the current record using Power Fx control from MDA + code: | + DeleteRecord(): Boolean = Preview.DeleteRecord(); + + diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/NavigateToRecordFunctionTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/NavigateToRecordFunctionTests.cs new file mode 100644 index 000000000..2feb50776 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/NavigateToRecordFunctionTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using Microsoft.PowerApps.TestEngine.PowerFx.Functions; +using Microsoft.PowerApps.TestEngine.Providers; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Microsoft.PowerFx.Types; +using Moq; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Microsoft.PowerApps.TestEngine.Tests.PowerFx.Functions +{ + public class NavigateToRecordFunctionTests + { + [Fact] + public async Task ExecuteAsync_NavigatesToExistingRecord_ReturnsTrue() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + var mockPage = new Mock(); + var mockContext = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.GetContext()).Returns(mockContext.Object); + mockContext.Setup(x => x.Pages).Returns(new[] { mockPage.Object }); + + // Simulate entityId found + mockPage.Setup(x => x.EvaluateAsync(It.IsAny(), null)) + .ReturnsAsync("entity123"); + + mockPage.Setup(x => x.EvaluateAsync(It.IsAny(), null)) + .ReturnsAsync(true); + + bool updateModelCalled = false; + Task UpdateModel() { updateModelCalled = true; return Task.CompletedTask; } + + var func = new NavigateToRecordFunction( + mockWebProvider.Object, + UpdateModel, + mockLogger.Object); + + // Act + var result = await func.ExecuteAsync( + FormulaValue.New("account") as StringValue, + FormulaValue.New("entityrecord") as StringValue, + NumberValue.New(1.0)); + + // Assert + Assert.True(((BooleanValue)result).Value); + Assert.True(updateModelCalled); + mockLogger.Verify(l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Navigating to existing record")), + (Exception)null, + It.IsAny>()), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_NavigatesToNewRecord_ReturnsTrue() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + var mockPage = new Mock(); + var mockContext = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.GetContext()).Returns(mockContext.Object); + mockContext.Setup(x => x.Pages).Returns(new[] { mockPage.Object }); + + // Simulate no entityId found + mockPage.Setup(x => x.EvaluateAsync(It.IsAny(), null)) + .ReturnsAsync(string.Empty); + + mockPage.Setup(x => x.EvaluateAsync(It.IsAny(), null)) + .ReturnsAsync(true); + + bool updateModelCalled = false; + Task UpdateModel() { updateModelCalled = true; return Task.CompletedTask; } + + var func = new NavigateToRecordFunction( + mockWebProvider.Object, + UpdateModel, + mockLogger.Object); + + // Act + var result = await func.ExecuteAsync( + FormulaValue.New("contact") as StringValue, + FormulaValue.New("entityrecord") as StringValue, + NumberValue.New(1.0)); + + // Assert + Assert.True(((BooleanValue)result).Value); + Assert.True(updateModelCalled); + mockLogger.Verify(l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("No selected entity found")), + (Exception)null, + It.IsAny>()), Times.Once); + } + + [Fact] + public void Execute_CallsAsyncSynchronously() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + var mockPage = new Mock(); + var mockContext = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.GetContext()).Returns(mockContext.Object); + mockContext.Setup(x => x.Pages).Returns(new[] { mockPage.Object }); + + mockPage.Setup(x => x.EvaluateAsync(It.IsAny(), null)) + .ReturnsAsync("entity456"); + mockPage.Setup(x => x.EvaluateAsync(It.IsAny(), null)) + .ReturnsAsync(true); + + var func = new NavigateToRecordFunction( + mockWebProvider.Object, + () => Task.CompletedTask, + mockLogger.Object); + + // Act + var result = func.Execute( + FormulaValue.New("lead") as StringValue, + FormulaValue.New("entityrecord") as StringValue, + NumberValue.New(1.0)); + + // Assert + Assert.True(((BooleanValue)result).Value); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/SelectDropdownOptionFunctionTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/SelectDropdownOptionFunctionTests.cs new file mode 100644 index 000000000..6003d99f1 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/SelectDropdownOptionFunctionTests.cs @@ -0,0 +1,197 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerApps.TestEngine.PowerFx.Functions; +using Microsoft.PowerApps.TestEngine.Providers; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Microsoft.PowerFx.Types; +using System; +using Moq; +using Xunit; + +namespace Microsoft.PowerApps.TestEngine.Tests.PowerFx.Functions +{ + public class SelectDropdownOptionFunctionTests + { + [Fact] + public async Task ExecuteAsync_ValidDropdown_CallsOptionAsyncAndLogs() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.SelectDropdownOptionAsync(It.IsAny())) + .Returns(Task.FromResult(true)); + + var func = new SelectDropdownOptionFunction(mockWebProvider.Object, mockLogger.Object); + var dropdownOption = StringValue.New("HR"); + + // Act + var result = await func.ExecuteAsync(dropdownOption); + + // Assert + Assert.True(result.Value); + mockTestInfra.Verify(x => x.SelectDropdownOptionAsync("HR"), Times.Once); + mockLogger.Verify( + l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Executing SelectDropdownOptionFunction for dropdown 'HR'.")), + null, + It.IsAny>()), + Times.Once); + mockLogger.Verify( + l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("SelectDropdownOptionFunction execution completed.")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public void Execute_CallsAsyncSynchronously() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.SelectDropdownOptionAsync(It.IsAny())) + .Returns(Task.FromResult(true)); + + var func = new SelectDropdownOptionFunction(mockWebProvider.Object, mockLogger.Object); + var dropdownOption = StringValue.New("Finance"); + + // Act + var result = func.Execute(dropdownOption); + + // Assert + Assert.True(result.Value); + mockTestInfra.Verify(x => x.SelectDropdownOptionAsync("Finance"), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_NullTestInfraFunctions_ThrowsNullReferenceException() + { + // Arrange + var mockWebProvider = new Mock(); + var mockLogger = new Mock(); + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns((ITestInfraFunctions)null); + + var func = new SelectDropdownOptionFunction(mockWebProvider.Object, mockLogger.Object); + var dropdownOption = StringValue.New("IT"); + + // Act & Assert + await Assert.ThrowsAsync(() => func.ExecuteAsync(dropdownOption)); + } + + [Fact] + public async Task ExecuteAsync_EmptyDropdown_CallsSelectDropdownOptionAsyncWithEmptyString() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.SelectDropdownOptionAsync(It.IsAny())) + .Returns(Task.FromResult(true)); + + var func = new SelectDropdownOptionFunction(mockWebProvider.Object, mockLogger.Object); + var dropdownOption = StringValue.New(string.Empty); + + // Act + var result = await func.ExecuteAsync(dropdownOption); + + // Assert + Assert.True(result.Value); + mockTestInfra.Verify(x => x.SelectDropdownOptionAsync(string.Empty), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_WhitespaceDropdown_CallsSelectDropdownOptionAsyncWithWhitespace() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.SelectDropdownOptionAsync(It.IsAny())) + .Returns(Task.FromResult(true)); + + var func = new SelectDropdownOptionFunction(mockWebProvider.Object, mockLogger.Object); + var dropdownOption = StringValue.New(" "); + + // Act + var result = await func.ExecuteAsync(dropdownOption); + + // Assert + Assert.True(result.Value); + mockTestInfra.Verify(x => x.SelectDropdownOptionAsync(" "), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_NullDropdown_ThrowsArgumentNullException() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + + var func = new SelectDropdownOptionFunction(mockWebProvider.Object, mockLogger.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => func.ExecuteAsync(null)); + } + + [Fact] + public async Task ExecuteAsync_SelectDropdownOptionAsyncReturnsFalse_ReturnsTrue() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.SelectDropdownOptionAsync(It.IsAny())) + .Returns(Task.FromResult(false)); + + var func = new SelectDropdownOptionFunction(mockWebProvider.Object, mockLogger.Object); + var dropdownOption = StringValue.New("Legal"); + + // Act + var result = await func.ExecuteAsync(dropdownOption); + + // Assert + // The function always returns true, regardless of the async result + Assert.True(result.Value); + mockTestInfra.Verify(x => x.SelectDropdownOptionAsync("Legal"), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_SelectDropdownOptionAsyncThrows_PropagatesException() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.SelectDropdownOptionAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Test exception")); + + var func = new SelectDropdownOptionFunction(mockWebProvider.Object, mockLogger.Object); + var dropdownOption = StringValue.New("Admin"); + + // Act & Assert + await Assert.ThrowsAsync(() => func.ExecuteAsync(dropdownOption)); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/SelectGridRowCheckboxFunctionTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/SelectGridRowCheckboxFunctionTests.cs new file mode 100644 index 000000000..c44036989 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/SelectGridRowCheckboxFunctionTests.cs @@ -0,0 +1,105 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using Microsoft.PowerApps.TestEngine.PowerFx.Functions; +using Microsoft.PowerApps.TestEngine.Providers; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Microsoft.PowerFx.Types; +using Moq; +using Xunit; + +namespace Microsoft.PowerApps.TestEngine.Tests.PowerFx.Functions +{ + public class SelectGridRowCheckboxFunctionTests + { + [Fact] + public async Task ExecuteAsync_ValidRowIndex_ExecutesJavaScriptAndReturnsTrue() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + var mockPage = new Mock(); + var mockContext = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.GetContext()).Returns(mockContext.Object); + mockContext.Setup(x => x.Pages).Returns(new[] { mockPage.Object }); + + bool jsCalled = false; + mockPage.Setup(x => x.EvaluateAsync(It.IsAny(), null)) + .Callback(() => jsCalled = true) + .Returns(Task.FromResult((JsonElement?)default)); + + var func = new SelectGridRowCheckboxFunction( + mockWebProvider.Object, + mockLogger.Object); + + // Act + var result = await func.ExecuteAsync(FormulaValue.New(1.0) as NumberValue); + + // Assert + Assert.True(result.Value); + Assert.True(jsCalled); + mockLogger.Verify(l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Executing SelectGridRowCheckboxFunction")), + null, + It.IsAny>()), Times.Once); + } + + [Fact] + public void Execute_CallsAsyncSynchronously() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + var mockPage = new Mock(); + var mockContext = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.GetContext()).Returns(mockContext.Object); + mockContext.Setup(x => x.Pages).Returns(new[] { mockPage.Object }); + + mockPage.Setup(x => x.EvaluateAsync(It.IsAny(), null)) + .Returns(Task.FromResult((JsonElement?)default)); + + var func = new SelectGridRowCheckboxFunction( + mockWebProvider.Object, + mockLogger.Object); + + // Act + var result = func.Execute(FormulaValue.New(0.0) as NumberValue); + + // Assert + Assert.True(result.Value); + } + + [Fact] + public async Task ExecuteAsync_EmptyPages_ThrowsInvalidOperationException() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + var mockContext = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.GetContext()).Returns(mockContext.Object); + mockContext.Setup(x => x.Pages).Returns(Array.Empty()); + + var func = new SelectGridRowCheckboxFunction( + mockWebProvider.Object, + mockLogger.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => + func.ExecuteAsync(FormulaValue.New(0.0) as NumberValue)); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/SetDOBFieldsFunctionTests.cs b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/SetDOBFieldsFunctionTests.cs new file mode 100644 index 000000000..49f830865 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine.Tests/PowerFx/Functions/SetDOBFieldsFunctionTests.cs @@ -0,0 +1,105 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using Microsoft.PowerApps.TestEngine.PowerFx.Functions; +using Microsoft.PowerApps.TestEngine.Providers; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Microsoft.PowerFx.Types; +using Moq; +using Xunit; + +namespace Microsoft.PowerApps.TestEngine.Tests.PowerFx.Functions +{ + public class SetDOBFieldsFunctionTests + { + [Fact] + public async Task ExecuteAsync_ValidInputs_ExecutesJavaScriptAndReturnsTrue() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + var mockPage = new Mock(); + var mockContext = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.GetContext()).Returns(mockContext.Object); + mockContext.Setup(x => x.Pages).Returns(new[] { mockPage.Object }); + + bool jsCalled = false; + mockPage.Setup(x => x.EvaluateAsync(It.IsAny(), null)) + .Callback((js, arg) => jsCalled = true) + .Returns(Task.FromResult((JsonElement?)default)); + + var func = new SetDOBFieldsFunction( + mockWebProvider.Object, + mockLogger.Object); + + // Act + var result = await func.ExecuteAsync(FormulaValue.New("2023-01-01") as StringValue, FormulaValue.New("12:00:00") as StringValue); + + // Assert + Assert.True(result.Value); + Assert.True(jsCalled); + mockLogger.Verify(l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Executing SetDOBFieldsFunction")), + null, + It.IsAny>()), Times.Once); + } + + [Fact] + public void Execute_ValidInputs_CallsAsyncSynchronously() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + var mockPage = new Mock(); + var mockContext = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.GetContext()).Returns(mockContext.Object); + mockContext.Setup(x => x.Pages).Returns(new[] { mockPage.Object }); + + mockPage.Setup(x => x.EvaluateAsync(It.IsAny(), null)) + .Returns(Task.FromResult((JsonElement?)default)); + + var func = new SetDOBFieldsFunction( + mockWebProvider.Object, + mockLogger.Object); + + // Act + var result = func.Execute(FormulaValue.New("2023-01-01") as StringValue, FormulaValue.New("12:00:00") as StringValue); + + // Assert + Assert.True(result.Value); + } + + [Fact] + public async Task ExecuteAsync_NoPages_ThrowsInvalidOperationException() + { + // Arrange + var mockWebProvider = new Mock(); + var mockTestInfra = new Mock(); + var mockLogger = new Mock(); + var mockContext = new Mock(); + + mockWebProvider.SetupGet(x => x.TestInfraFunctions).Returns(mockTestInfra.Object); + mockTestInfra.Setup(x => x.GetContext()).Returns(mockContext.Object); + mockContext.Setup(x => x.Pages).Returns(Array.Empty()); + + var func = new SetDOBFieldsFunction( + mockWebProvider.Object, + mockLogger.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => + func.ExecuteAsync(FormulaValue.New("2023-01-01") as StringValue, FormulaValue.New("12:00:00") as StringValue)); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/NavigateToRecordFunction.cs b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/NavigateToRecordFunction.cs new file mode 100644 index 000000000..afb25f377 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/NavigateToRecordFunction.cs @@ -0,0 +1,104 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerApps.TestEngine.Providers; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Core.Utils; +using Microsoft.PowerFx.Types; +using Newtonsoft.Json.Linq; + + + +namespace Microsoft.PowerApps.TestEngine.PowerFx.Functions +{ + /// + /// Navigates to the selected entity record in the grid, or to the new record page if none selected. + /// + public class NavigateToRecordFunction : ReflectionFunction + { + private readonly ITestWebProvider _testWebProvider; + private readonly Func _updateModelFunction; + private readonly ILogger _logger; + + + public NavigateToRecordFunction(ITestWebProvider testWebProvider, Func updateModelFunction, ILogger logger) + : base("NavigateToRecord", FormulaType.Boolean, FormulaType.String, FormulaType.String, FormulaType.Number) + { + _testWebProvider = testWebProvider; + _updateModelFunction = updateModelFunction; + _logger = logger; + } + + public BooleanValue Execute( + StringValue entityName, + StringValue entityPage, + NumberValue target) + { + return ExecuteAsync(entityName, entityPage, target).Result; + } + + public async Task ExecuteAsync( + StringValue entityName, + StringValue entityPage, + NumberValue target) + { + _logger.LogInformation("Executing NavigateToRecordFunction: extracting selected entityId from grid."); + + // Extract the selected entityId from the grid + var jsExtractId = @" + (function() { + const selectedRows = document.querySelectorAll(""div[role='row'][aria-label*='deselect']""); + for (let row of selectedRows) { + const link = row.querySelector(""a[aria-label][href*='etn=" + entityName.Value + @"']""); + if (link) { + const url = new URL(link.href, window.location.origin); + const entityId = url.searchParams.get('id'); + if (entityId) { + return entityId; + } + } + } + return ''; + })(); + "; + + var page = _testWebProvider.TestInfraFunctions.GetContext().Pages.First(); + var entityId = await page.EvaluateAsync(jsExtractId); + + var pageInput = new JObject + { + ["pageType"] = entityPage.Value, + ["entityName"] = entityName.Value + }; + + if (!string.IsNullOrEmpty(entityId)) + { + pageInput["entityId"] = entityId; + _logger.LogInformation($"Navigating to existing record with entityId: {entityId}"); + } + else + { + _logger.LogInformation("No selected entity found. Navigating to new record page."); + } + + var navigationOptions = new JObject + { + ["target"] = (int)target.Value + }; + + var jsNavigate = $@" + Xrm.Navigation.navigateTo({pageInput}, {navigationOptions}) + .then(function() {{ return true; }}, function(error) {{ return false; }}); + "; + + var navResult = await page.EvaluateAsync(jsNavigate); + _logger.LogInformation($"Navigation result: {navResult}"); + + // Ensure Power Fx model is updated after navigation + await _updateModelFunction(); + + return FormulaValue.New(navResult); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/SelectDropdownOptionFunction.cs b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/SelectDropdownOptionFunction.cs new file mode 100644 index 000000000..5df5ecd3c --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/SelectDropdownOptionFunction.cs @@ -0,0 +1,46 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using Microsoft.PowerApps.TestEngine.Providers; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.PowerApps.TestEngine.PowerFx.Functions +{ + /// + /// Power Fx function to select an option in a dropdown. + /// + public class SelectDropdownOptionFunction : ReflectionFunction + { + private readonly ITestWebProvider _testWebProvider; + private readonly ILogger _logger; + + public SelectDropdownOptionFunction(ITestWebProvider testWebProvider, ILogger logger) + : base("SelectDropdownOption", FormulaType.Boolean, FormulaType.String) + { + _testWebProvider = testWebProvider; + _logger = logger; + } + + public BooleanValue Execute(StringValue department) + { + return ExecuteAsync(department).Result; + } + + /// + /// Asynchronously selects a dropdown option in the dropdown. + /// + public async Task ExecuteAsync(StringValue dropdownOption) + { + if (dropdownOption == null) + throw new ArgumentNullException(nameof(dropdownOption)); + + _logger.LogInformation($"Executing SelectDropdownOptionFunction for dropdown '{dropdownOption.Value}'."); + await _testWebProvider.TestInfraFunctions.SelectDropdownOptionAsync(dropdownOption.Value); + _logger.LogInformation("SelectDropdownOptionFunction execution completed."); + return FormulaValue.New(true); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/SelectGridRowCheckboxFunction.cs b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/SelectGridRowCheckboxFunction.cs new file mode 100644 index 000000000..90139229c --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/SelectGridRowCheckboxFunction.cs @@ -0,0 +1,55 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerApps.TestEngine.Providers; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.PowerApps.TestEngine.PowerFx.Functions +{ + /// + /// Selects or deselects a checkbox in a grid row based on the row index. + /// + public class SelectGridRowCheckboxFunction : ReflectionFunction + { + private readonly ITestWebProvider _testWebProvider; + private readonly ILogger _logger; + + public SelectGridRowCheckboxFunction(ITestWebProvider testWebProvider, ILogger logger) + : base("SelectGridRowCheckbox", FormulaType.Boolean, FormulaType.Number) + { + _testWebProvider = testWebProvider; + _logger = logger; + } + + public BooleanValue Execute(NumberValue rowIndex) + { + return ExecuteAsync(rowIndex).Result; + } + + public async Task ExecuteAsync(NumberValue rowIndex) + { + _logger.LogInformation($"Executing SelectGridRowCheckboxFunction for row index {rowIndex.Value}."); + + var js = $@" + (function() {{ + var checkboxes = document.querySelectorAll(""input[type='checkbox'][aria-label='select or deselect the row']""); + var idx = {rowIndex.Value} - 1; + if (idx >= 0 && checkboxes.length > idx) {{ + checkboxes[idx].click(); + console.log('Checkbox in row ' + (idx + 1) + ' clicked.'); + }} else {{ + console.log('Row index ' + idx + ' is out of bounds. Only ' + checkboxes.length + ' checkbox(es) found.'); + }} + }})(); + "; + + var page = _testWebProvider.TestInfraFunctions.GetContext().Pages.First(); + await page.EvaluateAsync(js); + + _logger.LogInformation("SelectGridRowCheckboxFunction execution completed."); + return FormulaValue.New(true); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/SetDOBFieldsFunction.cs b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/SetDOBFieldsFunction.cs new file mode 100644 index 000000000..42b629705 --- /dev/null +++ b/src/Microsoft.PowerApps.TestEngine/PowerFx/Functions/SetDOBFieldsFunction.cs @@ -0,0 +1,170 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerApps.TestEngine.Providers; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Microsoft.Xrm.Sdk; + +namespace Microsoft.PowerApps.TestEngine.PowerFx.Functions +{ + /// + /// Sets the Date of Birth and Time of Birth fields using aria-label selectors. + /// + public class SetDOBFieldsFunction : ReflectionFunction + { + private readonly ITestWebProvider _testWebProvider; + private readonly ILogger _logger; + + public SetDOBFieldsFunction(ITestWebProvider testWebProvider, ILogger logger) + : base("SetDOBFields", FormulaType.Boolean, FormulaType.String, FormulaType.String) + { + _testWebProvider = testWebProvider; + _logger = logger; + } + + public BooleanValue Execute(StringValue dateValue, StringValue timeValue) + { + return ExecuteAsync(dateValue, timeValue).Result; + } + + public async Task ExecuteAsync(StringValue dateValue, StringValue timeValue) + { + _logger.LogInformation($"Executing SetDOBFieldsFunction with provided values: date={dateValue.Value}, time={timeValue.Value}"); + + // Default time to 08:00 AM if not provided + var time = string.IsNullOrWhiteSpace(timeValue.Value) ? "08:00 AM" : timeValue.Value; + + var js = $@" + (async function() {{ + function waitForElement(selector, timeout = 5000) {{ + return new Promise((resolve, reject) => {{ + const interval = 100; + let elapsed = 0; + const check = () => {{ + const el = document.querySelector(selector); + if (el) return resolve(el); + elapsed += interval; + if (elapsed >= timeout) return reject('Timeout: ' + selector); + setTimeout(check, interval); + }}; + check(); + }}); + }} + + const dateStr = '{dateValue.Value}'; + const timeStr = '{time.Replace("'", "\\'")}'; + console.log('SetDOBFieldsFunction JS: dateStr=', dateStr, 'timeStr=', timeStr); + const [month, day, year] = dateStr.split('/').map(part => parseInt(part, 10)); + const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + const monthName = monthNames[month - 1]; + + try {{ + const dateInput = await waitForElement(""[aria-label='Date of DOB']""); + const calendarIcon = dateInput.parentElement.querySelector('svg'); + if (!calendarIcon) {{ + console.log('Calendar icon not found.'); + return; + }} + + // Step 1: Open calendar + calendarIcon.dispatchEvent(new MouseEvent('click', {{ bubbles: true }})); + console.log('Calendar opened.'); + + // Step 2: Switch to year picker + const yearSwitchBtn = await waitForElement(""button[aria-label*='select to switch to year picker']""); + yearSwitchBtn.click(); + console.log('Switched to year picker.'); + + // Step 3: Navigate to correct year + async function selectYear() {{ + let yearBtn = Array.from(document.querySelectorAll('button')) + .find(btn => btn.textContent.trim() === String(year)); + let attempts = 0; + while (!yearBtn && attempts < 15) {{ + const prevYearBtn = document.querySelector(""button[title*='Navigate to previous year']""); + if (prevYearBtn) {{ + prevYearBtn.click(); + await new Promise(r => setTimeout(r, 200)); + }} + yearBtn = Array.from(document.querySelectorAll('button')) + .find(btn => btn.textContent.trim() === String(year)); + attempts++; + }} + if (yearBtn) {{ + yearBtn.click(); + console.log(`Year ${{year}} selected.`); + }} else {{ + console.warn('Year button not found.'); + return false; + }} + return true; + }} + if (!await selectYear()) return; + + // Step 4: Select month + const monthBtn = Array.from(document.querySelectorAll(""button[role='gridcell']"")) + .find(btn => btn.textContent.trim().toLowerCase().startsWith(monthName.slice(0, 3).toLowerCase())); + if (monthBtn) {{ + monthBtn.click(); + console.log(`Month ${{monthName}} selected.`); + }} else {{ + console.warn(`Month ${{monthName}} not found.`); + return; + }} + + // Step 5: Select day + await new Promise(r => setTimeout(r, 400)); + const dayBtn = Array.from(document.querySelectorAll(""td button[aria-label]"")) + .find(btn => btn.getAttribute('aria-label')?.includes(`${{day}}, ${{monthName}}, ${{year}}`)); + if (dayBtn) {{ + dayBtn.click(); + console.log(`Day ${{day}} selected.`); + }} else {{ + console.warn(`Day ${{day}} not found.`); + return; + }} + + // Step 6: Set time + await new Promise(r => setTimeout(r, 600)); + const timeInput = await waitForElement(""[aria-label='Time of DOB']""); + timeInput.focus(); + timeInput.value = timeStr; + timeInput.setAttribute('value', timeStr); + [ + 'focus', 'keydown', 'keypress', 'input', 'keyup', 'change', 'blur', 'focusout', 'click', 'paste', + 'compositionstart', 'compositionupdate', 'compositionend' + ].forEach(eventType => {{ + timeInput.dispatchEvent(new Event(eventType, {{ bubbles: true }})); + }}); + setTimeout(() => {{ + if (timeInput.value !== timeStr) {{ + timeInput.value = timeStr; + timeInput.setAttribute('value', timeStr); + [ + 'focus', 'keydown', 'keypress', 'input', 'keyup', 'change', 'blur', 'focusout', 'click', 'paste', + 'compositionstart', 'compositionupdate', 'compositionend' + ].forEach(eventType => {{ + timeInput.dispatchEvent(new Event(eventType, {{ bubbles: true }})); + }}); + console.log('Retried setting time to:', timeStr); + }} + }}, 300); + console.log(`Time set to ${{timeStr}}.`); + }} catch (e) {{ + console.warn('SetDOBFieldsFunction error:', e); + }} + }})(); + "; + + var page = _testWebProvider.TestInfraFunctions.GetContext().Pages.First(); + await page.EvaluateAsync(js); + + _logger.LogInformation("SetDOBFieldsFunction execution completed."); + return FormulaValue.New(true); + } + } +} diff --git a/src/Microsoft.PowerApps.TestEngine/PowerFx/PowerFxEngine.cs b/src/Microsoft.PowerApps.TestEngine/PowerFx/PowerFxEngine.cs index dc6e97270..b992d3439 100644 --- a/src/Microsoft.PowerApps.TestEngine/PowerFx/PowerFxEngine.cs +++ b/src/Microsoft.PowerApps.TestEngine/PowerFx/PowerFxEngine.cs @@ -102,6 +102,10 @@ public void Setup(TestSettings settings) powerFxConfig.AddFunction(new AssertNotErrorFunction(Logger)); powerFxConfig.AddFunction(new SetPropertyFunction(_testWebProvider, Logger)); powerFxConfig.AddFunction(new IsMatchFunction(Logger)); + powerFxConfig.AddFunction(new NavigateToRecordFunction(_testWebProvider, async () => await UpdatePowerFxModelAsync(), Logger)); + powerFxConfig.AddFunction(new SetDOBFieldsFunction(_testWebProvider, Logger)); + powerFxConfig.AddFunction(new SelectGridRowCheckboxFunction(_testWebProvider, Logger)); + powerFxConfig.AddFunction(new SelectDropdownOptionFunction(_testWebProvider, Logger)); if (settings != null && settings.ExtensionModules != null && settings.ExtensionModules.Enable) { diff --git a/src/Microsoft.PowerApps.TestEngine/Providers/PowerFxModel/MDATypeMapping.cs b/src/Microsoft.PowerApps.TestEngine/Providers/PowerFxModel/MDATypeMapping.cs index 27418f271..e5dbc4b03 100644 --- a/src/Microsoft.PowerApps.TestEngine/Providers/PowerFxModel/MDATypeMapping.cs +++ b/src/Microsoft.PowerApps.TestEngine/Providers/PowerFxModel/MDATypeMapping.cs @@ -33,6 +33,7 @@ public MDATypeMapping() typeMappings.Add("m", FormulaType.Decimal); typeMappings.Add("v", FormulaType.UntypedObject); typeMappings.Add("i", FormulaType.String); + typeMappings.Add("p", FormulaType.Unknown); } /// diff --git a/src/Microsoft.PowerApps.TestEngine/TestInfra/ITestInfraFunctions.cs b/src/Microsoft.PowerApps.TestEngine/TestInfra/ITestInfraFunctions.cs index 8aa246559..ce5273324 100644 --- a/src/Microsoft.PowerApps.TestEngine/TestInfra/ITestInfraFunctions.cs +++ b/src/Microsoft.PowerApps.TestEngine/TestInfra/ITestInfraFunctions.cs @@ -107,5 +107,12 @@ public interface ITestInfraFunctions /// The physical file path for image file /// public Task TriggerControlClickEvent(string controlName, string filePath); + + /// + /// Selects a dropdown option in the user interface. + /// + /// The dropdown value to select. + /// True if the dropdown option was successfully selected; otherwise, false. + public Task SelectDropdownOptionAsync(string value); } } diff --git a/src/Microsoft.PowerApps.TestEngine/TestInfra/PlaywrightTestInfraFunctions.cs b/src/Microsoft.PowerApps.TestEngine/TestInfra/PlaywrightTestInfraFunctions.cs index 5ada989e8..a283aa1be 100644 --- a/src/Microsoft.PowerApps.TestEngine/TestInfra/PlaywrightTestInfraFunctions.cs +++ b/src/Microsoft.PowerApps.TestEngine/TestInfra/PlaywrightTestInfraFunctions.cs @@ -513,5 +513,36 @@ public async Task TriggerControlClickEvent(string controlName, string file } return false; } + + /// + /// Selects an option in the given dropdown by its name. + /// + /// The name of the dropdown option to select. + /// True if the option was successfully selected, otherwise false. + public async Task SelectDropdownOptionAsync(string value) + { + if (string.IsNullOrEmpty(value)) + { + _singleTestInstanceState.GetLogger().LogError("Dropdown option value cannot be null or empty."); + throw new ArgumentException("Dropdown option value must be provided.", nameof(value)); + } + + try + { + ValidatePage(); + + // Open the dropdown + await Page.GetByLabel("Department", new() { Exact = true }).ClickAsync(); + // Select the specified option + await Page.GetByRole(AriaRole.Option, new() { Name = value }).ClickAsync(); + + return true; // Indicate success + } + catch (Exception ex) + { + _singleTestInstanceState.GetLogger().LogError($"Error occurred while selecting dropdown option: {value}. Exception: {ex.Message}"); + return false; // Indicate failure + } + } } } diff --git a/src/testengine.provider.mda/DeleteRecordFunction.cs b/src/testengine.provider.mda/DeleteRecordFunction.cs new file mode 100644 index 000000000..1fd3b1d9b --- /dev/null +++ b/src/testengine.provider.mda/DeleteRecordFunction.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.Logging; +using Microsoft.PowerApps.TestEngine.Config; +using Microsoft.PowerApps.TestEngine.Helpers; +using Microsoft.PowerApps.TestEngine.TestInfra; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Core.Utils; +using Microsoft.PowerFx.Types; +using System.Threading.Tasks; + +namespace testengine.provider.mda +{ + /// + /// This will wait for the current record to be deleted. + /// + public class DeleteRecordFunction : ReflectionFunction + { + private readonly ITestInfraFunctions _testInfraFunctions; + private readonly ITestState? _testState; + private readonly ILogger _logger; + + public DeleteRecordFunction(ITestInfraFunctions testInfraFunctions, ITestState? testState, ILogger logger) + : base(DPath.Root.Append(new DName("Preview")), "DeleteRecord", BooleanType.Boolean) + { + _testInfraFunctions = testInfraFunctions; + _testState = testState; + _logger = logger; + } + + /// + /// Attempt to delete the current record + /// + /// True if record successfully deleted. + public BooleanValue Execute() + { + _logger.LogInformation("Starting Delete Record"); + return ExecuteAsync().Result; + } + + public async Task ExecuteAsync() + { + + await _testInfraFunctions.RunJavascriptAsync( + @"window.deleteCompleted = null; + var entityName = Xrm.Page.data.entity.getEntityName && Xrm.Page.data.entity.getEntityName(); + var entityId = Xrm.Page.data.entity.getId && Xrm.Page.data.entity.getId(); + if (entityName && entityId) { + Xrm.WebApi.deleteRecord(entityName, entityId.replace(/[{}]/g, '')) + .then(function() { window.deleteCompleted = true; }) + .catch(function() { window.deleteCompleted = false; }); + } else { + window.deleteCompleted = false; + }" + ); + + + var getValue = () => _testInfraFunctions.RunJavascriptAsync("window.deleteCompleted").Result; + + var result = PollingHelper.Poll( + null, + x => x == null, + getValue, + _testState != null ? 3000 : _testState.GetTimeout(), + _logger, + "Unable to complete delete" + ); + + if (result is bool value) + { + return BooleanValue.New(value); + } + + return BooleanValue.New(false); + } + } +} diff --git a/src/testengine.provider.mda/ModelDrivenApplicationProvider.cs b/src/testengine.provider.mda/ModelDrivenApplicationProvider.cs index d4dee61aa..b0647f9df 100644 --- a/src/testengine.provider.mda/ModelDrivenApplicationProvider.cs +++ b/src/testengine.provider.mda/ModelDrivenApplicationProvider.cs @@ -469,6 +469,8 @@ public async Task SetPropertyAsync(ItemPath itemPath, FormulaValue value) break; case (DateType): return await SetPropertyDateAsync(itemPath, (DateValue)value); + case (DateTimeType): + return await SetPropertyDateTimeAsync(itemPath, (DateTimeValue)value); case (RecordType): return await SetPropertyRecordAsync(itemPath, (RecordValue)value); case (TableType): @@ -539,6 +541,30 @@ public async Task SetPropertyRecordAsync(ItemPath itemPath, RecordValue va } } + public async Task SetPropertyDateTimeAsync(ItemPath itemPath, DateTimeValue value) + { + try + { + ValidateItemPath(itemPath, false); + + var itemPathString = JsonConvert.SerializeObject(itemPath); + var propertyNameString = JsonConvert.SerializeObject(itemPath.PropertyName); + var recordValue = value.GetConvertedValue(null); + + // TODO - Set the Xrm SDK Value and update state for any JS to run + + // Date.parse() parses the date to unix timestamp + var expression = $"PowerAppsTestEngine.setPropertyValue({itemPathString},Date.parse(\"{recordValue}\"))"; + + return await TestInfraFunctions.RunJavascriptAsync(expression); + } + catch (Exception ex) + { + ExceptionHandlingHelper.CheckIfOutDatedPublishedApp(ex, SingleTestInstanceState.GetLogger()); + throw; + } + } + /// /// Convert Power Fx formula value to the string representation /// @@ -756,6 +782,8 @@ public void Setup(PowerFxConfig powerFxConfig, ITestInfraFunctions testInfraFunc powerFxConfig.AddFunction(new SetOptionsFunction(testInfraFunctions, logger)); powerFxConfig.AddFunction(new SetValueJsonFunction(testInfraFunctions, logger)); powerFxConfig.AddFunction(new SaveFormFunction(testInfraFunctions, _testState, logger)); + powerFxConfig.AddFunction(new DeleteRecordFunction(testInfraFunctions, testState, logger)); + } public void ConfigurePowerFx(PowerFxConfig powerFxConfig) diff --git a/src/testengine.provider.mda/PowerAppsTestEngineMDA.js b/src/testengine.provider.mda/PowerAppsTestEngineMDA.js index 8ba6a5519..f0ef7a76b 100644 --- a/src/testengine.provider.mda/PowerAppsTestEngineMDA.js +++ b/src/testengine.provider.mda/PowerAppsTestEngineMDA.js @@ -70,6 +70,8 @@ class PowerAppsTestEngine { return PowerAppsModelDrivenCanvas.getControlProperties(itemPath); case PowerAppsTestEngine.CONSTANTS.EntityRecord: return PowerAppsModelDrivenEntityRecord.getControlProperties(itemPath); + case PowerAppsTestEngine.CONSTANTS.EntityList: + return PowerAppsModelDrivenEntityList.getControlProperties(itemPath); } return JSON.stringify(data); } @@ -83,6 +85,8 @@ class PowerAppsTestEngine { return PowerAppsModelDrivenCanvas.setPropertyValueForControl(item, data); case PowerAppsTestEngine.CONSTANTS.EntityRecord: return PowerAppsModelDrivenEntityRecord.setPropertyValueForControl(item, data); + case PowerAppsTestEngine.CONSTANTS.EntityList: + return PowerAppsModelDrivenEntityList.setPropertyValueForControl(itemPath); } return false; } @@ -95,7 +99,7 @@ class PowerAppsTestEngine { case PowerAppsTestEngine.CONSTANTS.Custom: return PowerAppsModelDrivenCanvas.fetchArrayItemCount(itemPath); case PowerAppsTestEngine.CONSTANTS.EntityList: - return PowerAppsModelDrivenCanvas.fetchArrayItemCount(itemPath); + return PowerAppsModelDrivenEntityList.fetchArrayItemCount(itemPath); case PowerAppsTestEngine.CONSTANTS.EntityRecord: // TODO - Get count of items for name break; @@ -109,6 +113,8 @@ class PowerAppsTestEngine { switch (PowerAppsTestEngine.pageType()) { case PowerAppsTestEngine.CONSTANTS.Custom: return PowerAppsModelDrivenCanvas.selectControl(itemPath); + case PowerAppsTestEngine.CONSTANTS.EntityList: + return PowerAppsModelDrivenEntityList.selectControl(itemPath); case PowerAppsTestEngine.CONSTANTS.EntityRecord: // TODO - Selectitem break; diff --git a/src/testengine.provider.mda/PowerAppsTestEngineMDAEntityList.js b/src/testengine.provider.mda/PowerAppsTestEngineMDAEntityList.js index b727c0373..8a981cba8 100644 --- a/src/testengine.provider.mda/PowerAppsTestEngineMDAEntityList.js +++ b/src/testengine.provider.mda/PowerAppsTestEngineMDAEntityList.js @@ -16,7 +16,214 @@ class PowerAppsModelDrivenEntityList { //TODO: set property types other than columns and individual column properties also // Warning: control object population for the main grid is only for rows currently var propertyTypeString = "*[" + var attributes = new Set(); + var columnInfo = getCurrentXrmStatus().mainGrid.getColumnInfo(); + attributes.add("entityId:s"); + Object.keys(columnInfo).forEach(attribute => { + var columnName = attribute; // see how to reconcile it to displayname since it can have multiword columnInfo[attribute].DisplayName; + var attrType = columnInfo[attribute].Type; + //TODO: for lookup type handle case where no value is present and if value is present then set type + var columnType = PowerAppsModelDrivenEntityList.getAttributeDataType(attrType); + attributes.add(`${columnName}:${columnType}`); + }); + propertyTypeString += Array.from(attributes).join(", "); propertyTypeString += "]"; return JSON.stringify({ Controls: [{ Name: PowerAppsModelDrivenEntityList.CONSTANTS.MainGrid, Properties: [{ PropertyName: PowerAppsModelDrivenEntityList.CONSTANTS.MainGridRowsRecordName, PropertyType: propertyTypeString }] }] }); } + + static fetchArrayItemCount(itemPath) { + if (itemPath.controlName == PowerAppsModelDrivenEntityList.CONSTANTS.MainGrid && itemPath.propertyName == PowerAppsModelDrivenEntityList.CONSTANTS.MainGridRowsRecordName) { + var rowCount = getCurrentXrmStatus().mainGrid.getGrid().getRows().getLength(); + return rowCount; + } + else { + if (itemPath.parentControl && itemPath.parentControl.index === null) { + // Components do not have an item count + throw "Not a gallery, no item count available. Most likely a component"; + } + // TODO: Identify not main grid controls of gallery type and return item count + return 0; + } + } + + static getControlProperties(itemPath) { + var data = []; + // Handle grid row properties + if ( + itemPath.parentControl && + itemPath.parentControl.index !== null && + itemPath.parentControl.controlName == PowerAppsModelDrivenEntityList.CONSTANTS.MainGrid + ) { + // Handle columns (attributes) + if (itemPath.parentControl.propertyName == PowerAppsModelDrivenEntityList.CONSTANTS.MainGridRowsRecordName) { + if (itemPath.propertyName == "entityId") { + // Special case for entityId + var currentGridRow = getCurrentXrmStatus().mainGrid.getGrid().getRows().get(itemPath.parentControl.index); + var idVal = currentGridRow.getData().getEntity().getId(); + idVal = idVal.replace(/{|}/g, ""); + data.push({ Key: itemPath.propertyName, Value: idVal }); + } else { + // Standard attribute/column + var currentGridRow = getCurrentXrmStatus().mainGrid.getGrid().getRows().get(itemPath.parentControl.index); + var currentRowEntity = currentGridRow.getData().getEntity(); + var currentAttribute = currentRowEntity.attributes.getByName(itemPath.propertyName); + var currentAttributeValue = currentAttribute.getValue(); + data.push({ Key: itemPath.propertyName, Value: currentAttributeValue }); + } + } else { + // Non-column properties (e.g., checkbox) + var currentGridRow = getCurrentXrmStatus().mainGrid.getGrid().getRows().get(itemPath.parentControl.index); + + // Example: handle a selection checkbox (commonly used for row selection) + if (itemPath.propertyName === "selected" || itemPath.propertyName === "isSelected") { + // Try to use the API if available + if (typeof currentGridRow.isSelected === "function") { + var isSelected = currentGridRow.isSelected(); + data.push({ Key: itemPath.propertyName, Value: isSelected }); + } else if (typeof currentGridRow.getData === "function" && typeof currentGridRow.getData().isSelected === "function") { + // Fallback: sometimes selection is on the data object + var isSelected = currentGridRow.getData().isSelected(); + data.push({ Key: itemPath.propertyName, Value: isSelected }); + } else { + // If not available, return null or handle as needed + data.push({ Key: itemPath.propertyName, Value: null }); + } + } else { + // Add more non-column property handlers here as needed + data.push({ Key: itemPath.propertyName, Value: null }); + } + } + } else { + // TODO: handle non-grid controls if needed + data.push({ Key: itemPath.propertyName, Value: null }); + } + return JSON.stringify(data); + } + + static getAttributeDataType(attribute) { + // value to get the notation for records based on type of attrtibute, append unknown types as required + var attributeType; + switch (attribute) { + case "integer": + attributeType = "i"; // Integer type + break; + case "boolean": + attributeType = "b"; // Boolean type + break; + case "datetime": + attributeType = "d"; // DateTime type + break; + case "decimal": + attributeType = "n"; // Decimal type + break; + case "string": + case "memo": + attributeType = "s"; // String or memo type + break; + case "lookup": + attributeType = "![id:s, name:s, entityType:s]"; // Lookup type + break; + case "picklist": + attributeType = "p"; // OptionSet type + break; + case "money": + attributeType = "m"; // Currency type + break; + // Add more cases as needed + default: + attributeType = "s"; // Default to 's' for string if no match + break; + } + return attributeType; + //enum AttributeType { + // Boolean = "boolean", + // Unknown = "unknown", + // Customer = "customer", + // // Date and DateTime are treated the same, Date is not a real attribute type + // Date = "date", + // DateTime = "datetime", + // Decimal = "decimal", + // Double = "double", + // Image = "image", + // Integer = "integer", + // Lookup = "lookup", + // ManagedProperty = "managedproperty", + // Memo = "memo", + // Money = "money", + // Owner = "owner", + // PartyList = "partylist", + // PickList = "picklist", + // State = "state", + // Status = "status", + // String = "string", + // UniqueIdentifier = "uniqueidentifier", + // CalendarRules = "calendarrules", + // Virtual = "virtual", + // BigInt = "bigint", + // EntityName = "entityname", + // EntityImage = "entityimage", + // AliasedValue = "aliasedvalue", + // Regarding = "regarding", + // MultiSelectPickList = "multiselectpicklist", + // File = "file", + // NavigationProperty = "navigationproperty", + // RichText = "RichText", + //} + + + } + + static setPropertyValueForControl(itemPath, value) { + var bindingContext = PowerAppsModelDrivenEntityList.getMainGridControls(itemPath); + + var controlContext = bindingContext.controlContexts[itemPath.controlName]; + + if (controlContext) { + if (controlContext.modelProperties[itemPath.propertyName]) { + propertyValue = controlContext.modelProperties[itemPath.propertyName]?.setValue(value); + return true; + } + } + + return false; + } + + + + static selectControl(itemPath) { + // Validate the itemPath object + if (!itemPath || typeof itemPath.index === "undefined" || itemPath.index === null) { + throw new Error("Invalid itemPath: 'index' is required."); + } + + // Get the main grid rows + const mainGrid = getCurrentXrmStatus().mainGrid; + const gridRows = mainGrid.getGrid().getRows(); + + // Validate the index + if (itemPath.index < 0 || itemPath.index >= gridRows.getLength()) { + throw new Error(`Invalid index: ${itemPath.index}. Must be between 0 and ${gridRows.getLength() - 1}.`); + } + + // Select the row at the specified index + const currentGridRow = gridRows.get(itemPath.index); + + + const recordId = currentGridRow.getData().getEntity().getId(); + + if (typeof grid.setSelectedRecordIds === "function") { + grid.setSelectedRecordIds([recordId]); + console.log("Row selected:", recordId); + } else { + console.error("setSelectedRecordIds is not available in this context."); + } + + + // Return the selected row data + const currentRowData = currentGridRow.getData(); + const data = []; + data.push({ Key: "row", Value: currentRowData }); + return JSON.stringify(data); + } + } \ No newline at end of file