From 3c08c6e06a4a8d154d78be6d538e21506c6af7a8 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Mon, 25 May 2026 23:49:11 +0200 Subject: [PATCH] feat: add Playwright-backed ui browser sessions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 2 + TALXIS.CLI.sln | 60 +++ docs/MIGRATION-CP1.md | 26 ++ .../Browser/BrowserLaunchOptions.cs | 9 + src/TALXIS.CLI.Core/Browser/BrowserSession.cs | 13 + .../Browser/IBrowserSessionManager.cs | 13 + .../Component/Browse/BrowseUrlConstants.cs | 4 +- .../Component/Browse/CanvasAppUrls.cs | 15 +- .../Component/Browse/CopilotStudioUrls.cs | 2 +- .../Component/Browse/MakerPortalUrls.cs | 2 +- .../Component/Browse/PowerAppsUciUrls.cs | 2 +- .../Component/Browse/PowerAutomateUrls.cs | 2 +- .../Component/Url/UrlBuilder.cs | 188 +++++----- src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj | 2 + .../Component/Url/UrlCommandBase.cs | 1 + .../Browser/UiBrowserCliCommand.cs | 14 + .../Browser/UiBrowserEvalCliCommand.cs | 51 +++ .../Session/UiSessionCliCommand.cs | 19 + .../Session/UiSessionCloseCliCommand.cs | 56 +++ .../Session/UiSessionListCliCommand.cs | 29 ++ .../Session/UiSessionOpenCliCommand.cs | 91 +++++ .../Session/UiSessionStatusCliCommand.cs | 46 +++ .../TALXIS.CLI.Features.Ui.csproj | 18 + src/TALXIS.CLI.Features.Ui/UiCliCommand.cs | 14 + .../TxcServicesBootstrap.cs | 2 + ....CLI.Platform.Dataverse.Application.csproj | 1 + .../BrowserProfilePaths.cs | 40 ++ .../BrowserSessionManager.cs | 349 ++++++++++++++++++ .../PlaywrightServiceCollectionExtensions.cs | 15 + .../ReAuthDialogWatcher.cs | 91 +++++ src/TALXIS.CLI.Platform.Playwright/SETUP.md | 19 + .../SessionRecoveryService.cs | 80 ++++ .../StorageStateManager.cs | 110 ++++++ .../TALXIS.CLI.Platform.Playwright.csproj | 22 ++ src/TALXIS.CLI/TALXIS.CLI.csproj | 1 + src/TALXIS.CLI/TxcCliCommand.cs | 2 +- .../TALXIS.CLI.Features.Ui.Tests/CliRunner.cs | 62 ++++ .../TALXIS.CLI.Features.Ui.Tests.csproj | 30 ++ .../TestAssembly.cs | 3 + .../TestExecutionContext.cs | 47 +++ .../UiCliIntegrationTests.cs | 15 + .../UiCommandTestHost.cs | 104 ++++++ .../UiCommandTests.cs | 64 ++++ .../BrowserSessionManagerTests.cs | 72 ++++ .../ReAuthDialogWatcherTests.cs | 57 +++ .../SessionRecoveryServiceTests.cs | 17 + .../StorageStateManagerTests.cs | 84 +++++ ...ALXIS.CLI.Platform.Playwright.Tests.csproj | 28 ++ .../TestAssembly.cs | 3 + .../Architecture/CommandConventionTests.cs | 1 + .../Architecture/LayeringTests.cs | 1 + 51 files changed, 1892 insertions(+), 107 deletions(-) create mode 100644 docs/MIGRATION-CP1.md create mode 100644 src/TALXIS.CLI.Core/Browser/BrowserLaunchOptions.cs create mode 100644 src/TALXIS.CLI.Core/Browser/BrowserSession.cs create mode 100644 src/TALXIS.CLI.Core/Browser/IBrowserSessionManager.cs rename src/{TALXIS.CLI.Features.Environment => TALXIS.CLI.Core}/Component/Browse/BrowseUrlConstants.cs (88%) rename src/{TALXIS.CLI.Features.Environment => TALXIS.CLI.Core}/Component/Browse/CanvasAppUrls.cs (77%) rename src/{TALXIS.CLI.Features.Environment => TALXIS.CLI.Core}/Component/Browse/CopilotStudioUrls.cs (90%) rename src/{TALXIS.CLI.Features.Environment => TALXIS.CLI.Core}/Component/Browse/MakerPortalUrls.cs (96%) rename src/{TALXIS.CLI.Features.Environment => TALXIS.CLI.Core}/Component/Browse/PowerAppsUciUrls.cs (97%) rename src/{TALXIS.CLI.Features.Environment => TALXIS.CLI.Core}/Component/Browse/PowerAutomateUrls.cs (97%) rename src/{TALXIS.CLI.Features.Environment => TALXIS.CLI.Core}/Component/Url/UrlBuilder.cs (65%) create mode 100644 src/TALXIS.CLI.Features.Ui/Browser/UiBrowserCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Ui/Browser/UiBrowserEvalCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Ui/Session/UiSessionCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Ui/Session/UiSessionCloseCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Ui/Session/UiSessionListCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Ui/Session/UiSessionOpenCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Ui/Session/UiSessionStatusCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Ui/TALXIS.CLI.Features.Ui.csproj create mode 100644 src/TALXIS.CLI.Features.Ui/UiCliCommand.cs create mode 100644 src/TALXIS.CLI.Platform.Playwright/BrowserProfilePaths.cs create mode 100644 src/TALXIS.CLI.Platform.Playwright/BrowserSessionManager.cs create mode 100644 src/TALXIS.CLI.Platform.Playwright/DependencyInjection/PlaywrightServiceCollectionExtensions.cs create mode 100644 src/TALXIS.CLI.Platform.Playwright/ReAuthDialogWatcher.cs create mode 100644 src/TALXIS.CLI.Platform.Playwright/SETUP.md create mode 100644 src/TALXIS.CLI.Platform.Playwright/SessionRecoveryService.cs create mode 100644 src/TALXIS.CLI.Platform.Playwright/StorageStateManager.cs create mode 100644 src/TALXIS.CLI.Platform.Playwright/TALXIS.CLI.Platform.Playwright.csproj create mode 100644 tests/TALXIS.CLI.Features.Ui.Tests/CliRunner.cs create mode 100644 tests/TALXIS.CLI.Features.Ui.Tests/TALXIS.CLI.Features.Ui.Tests.csproj create mode 100644 tests/TALXIS.CLI.Features.Ui.Tests/TestAssembly.cs create mode 100644 tests/TALXIS.CLI.Features.Ui.Tests/TestExecutionContext.cs create mode 100644 tests/TALXIS.CLI.Features.Ui.Tests/UiCliIntegrationTests.cs create mode 100644 tests/TALXIS.CLI.Features.Ui.Tests/UiCommandTestHost.cs create mode 100644 tests/TALXIS.CLI.Features.Ui.Tests/UiCommandTests.cs create mode 100644 tests/TALXIS.CLI.Platform.Playwright.Tests/BrowserSessionManagerTests.cs create mode 100644 tests/TALXIS.CLI.Platform.Playwright.Tests/ReAuthDialogWatcherTests.cs create mode 100644 tests/TALXIS.CLI.Platform.Playwright.Tests/SessionRecoveryServiceTests.cs create mode 100644 tests/TALXIS.CLI.Platform.Playwright.Tests/StorageStateManagerTests.cs create mode 100644 tests/TALXIS.CLI.Platform.Playwright.Tests/TALXIS.CLI.Platform.Playwright.Tests.csproj create mode 100644 tests/TALXIS.CLI.Platform.Playwright.Tests/TestAssembly.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1dd02079..157641ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,8 @@ txc These five groups are deliberately small. Adding a sixth top-level group requires a strong justification — if a new piece of functionality fits under an existing noun, put it there. +`ui` is the current justified exception. Browser-session automation for model-driven apps is not configuration, workspace authoring, environment deployment, data migration, or documentation. It is an interactive runtime surface that spans auth reuse, navigation, inspection, and recovery inside a live browser, so it would be misleading to hide it under one of the existing nouns. + ### `config` sub-nouns The `config` group has four sub-nouns, each owning one aspect of the resolution pipeline: diff --git a/TALXIS.CLI.sln b/TALXIS.CLI.sln index 29653ccc..21185a83 100644 --- a/TALXIS.CLI.sln +++ b/TALXIS.CLI.sln @@ -45,6 +45,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Analyzers", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Abstractions", "src\TALXIS.CLI.Abstractions\TALXIS.CLI.Abstractions.csproj", "{C15B8E89-AD5F-4F61-AD7B-2DAFD43B10DD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Platform.Playwright", "src\TALXIS.CLI.Platform.Playwright\TALXIS.CLI.Platform.Playwright.csproj", "{79FB065D-060D-4580-809D-EA65E9FDA07A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Features.Ui", "src\TALXIS.CLI.Features.Ui\TALXIS.CLI.Features.Ui.csproj", "{AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Platform.Playwright.Tests", "tests\TALXIS.CLI.Platform.Playwright.Tests\TALXIS.CLI.Platform.Playwright.Tests.csproj", "{230EE734-7BDF-4DE0-AEFA-85CBE8635599}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Features.Ui.Tests", "tests\TALXIS.CLI.Features.Ui.Tests\TALXIS.CLI.Features.Ui.Tests.csproj", "{F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -283,6 +291,54 @@ Global {C15B8E89-AD5F-4F61-AD7B-2DAFD43B10DD}.Release|x64.Build.0 = Release|Any CPU {C15B8E89-AD5F-4F61-AD7B-2DAFD43B10DD}.Release|x86.ActiveCfg = Release|Any CPU {C15B8E89-AD5F-4F61-AD7B-2DAFD43B10DD}.Release|x86.Build.0 = Release|Any CPU + {79FB065D-060D-4580-809D-EA65E9FDA07A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79FB065D-060D-4580-809D-EA65E9FDA07A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {79FB065D-060D-4580-809D-EA65E9FDA07A}.Debug|x64.ActiveCfg = Debug|Any CPU + {79FB065D-060D-4580-809D-EA65E9FDA07A}.Debug|x64.Build.0 = Debug|Any CPU + {79FB065D-060D-4580-809D-EA65E9FDA07A}.Debug|x86.ActiveCfg = Debug|Any CPU + {79FB065D-060D-4580-809D-EA65E9FDA07A}.Debug|x86.Build.0 = Debug|Any CPU + {79FB065D-060D-4580-809D-EA65E9FDA07A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79FB065D-060D-4580-809D-EA65E9FDA07A}.Release|Any CPU.Build.0 = Release|Any CPU + {79FB065D-060D-4580-809D-EA65E9FDA07A}.Release|x64.ActiveCfg = Release|Any CPU + {79FB065D-060D-4580-809D-EA65E9FDA07A}.Release|x64.Build.0 = Release|Any CPU + {79FB065D-060D-4580-809D-EA65E9FDA07A}.Release|x86.ActiveCfg = Release|Any CPU + {79FB065D-060D-4580-809D-EA65E9FDA07A}.Release|x86.Build.0 = Release|Any CPU + {AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771}.Debug|x64.ActiveCfg = Debug|Any CPU + {AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771}.Debug|x64.Build.0 = Debug|Any CPU + {AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771}.Debug|x86.ActiveCfg = Debug|Any CPU + {AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771}.Debug|x86.Build.0 = Debug|Any CPU + {AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771}.Release|Any CPU.Build.0 = Release|Any CPU + {AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771}.Release|x64.ActiveCfg = Release|Any CPU + {AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771}.Release|x64.Build.0 = Release|Any CPU + {AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771}.Release|x86.ActiveCfg = Release|Any CPU + {AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771}.Release|x86.Build.0 = Release|Any CPU + {230EE734-7BDF-4DE0-AEFA-85CBE8635599}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {230EE734-7BDF-4DE0-AEFA-85CBE8635599}.Debug|Any CPU.Build.0 = Debug|Any CPU + {230EE734-7BDF-4DE0-AEFA-85CBE8635599}.Debug|x64.ActiveCfg = Debug|Any CPU + {230EE734-7BDF-4DE0-AEFA-85CBE8635599}.Debug|x64.Build.0 = Debug|Any CPU + {230EE734-7BDF-4DE0-AEFA-85CBE8635599}.Debug|x86.ActiveCfg = Debug|Any CPU + {230EE734-7BDF-4DE0-AEFA-85CBE8635599}.Debug|x86.Build.0 = Debug|Any CPU + {230EE734-7BDF-4DE0-AEFA-85CBE8635599}.Release|Any CPU.ActiveCfg = Release|Any CPU + {230EE734-7BDF-4DE0-AEFA-85CBE8635599}.Release|Any CPU.Build.0 = Release|Any CPU + {230EE734-7BDF-4DE0-AEFA-85CBE8635599}.Release|x64.ActiveCfg = Release|Any CPU + {230EE734-7BDF-4DE0-AEFA-85CBE8635599}.Release|x64.Build.0 = Release|Any CPU + {230EE734-7BDF-4DE0-AEFA-85CBE8635599}.Release|x86.ActiveCfg = Release|Any CPU + {230EE734-7BDF-4DE0-AEFA-85CBE8635599}.Release|x86.Build.0 = Release|Any CPU + {F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA}.Debug|x64.ActiveCfg = Debug|Any CPU + {F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA}.Debug|x64.Build.0 = Debug|Any CPU + {F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA}.Debug|x86.ActiveCfg = Debug|Any CPU + {F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA}.Debug|x86.Build.0 = Debug|Any CPU + {F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA}.Release|Any CPU.Build.0 = Release|Any CPU + {F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA}.Release|x64.ActiveCfg = Release|Any CPU + {F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA}.Release|x64.Build.0 = Release|Any CPU + {F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA}.Release|x86.ActiveCfg = Release|Any CPU + {F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -307,6 +363,10 @@ Global {145543D7-2369-460F-AC41-5B48A28616F6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {7DFD9789-0399-4C36-BFC9-091CDA8DE4E2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {C15B8E89-AD5F-4F61-AD7B-2DAFD43B10DD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {79FB065D-060D-4580-809D-EA65E9FDA07A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {AB2ACAC4-1DDD-4677-ADFA-02B0DDD7E771} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {230EE734-7BDF-4DE0-AEFA-85CBE8635599} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {F709E6E7-D9B3-49A7-AA9A-470CBE89D4CA} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {53733BD6-A32A-41B7-9472-E377AF68151F} diff --git a/docs/MIGRATION-CP1.md b/docs/MIGRATION-CP1.md new file mode 100644 index 00000000..da55f5fc --- /dev/null +++ b/docs/MIGRATION-CP1.md @@ -0,0 +1,26 @@ +# CP1 migration notes for BDD agents + +When CP1 is available in the CLI, the CP0 BDD agents should stop launching raw Playwright sessions themselves and delegate browser-session lifecycle to `txc`. + +## What to change in `bdd-agent-v2` + +1. Replace raw Playwright browser launch in `Hooks.cs` with: + +```bash +txc ui session open --type AppModule --param name=WarehouseApp --profile +``` + +2. Have agents call `txc ui session open` from shell steps instead of owning Playwright bootstrap logic directly. +3. Keep using `guide_testing` to discover binding signatures and available steps. +4. Point follow-up browser inspection to: + +```bash +txc ui session status +txc ui browser eval --eval "" +``` + +## Expected impact + +- Agent instructions become shorter because Playwright launch, recovery, and auth reuse move into the CLI. +- Session reuse becomes profile-scoped and consistent with the rest of `txc`. +- Mid-session recovery logic stays centralized in the CLI instead of being duplicated across agent prompts. diff --git a/src/TALXIS.CLI.Core/Browser/BrowserLaunchOptions.cs b/src/TALXIS.CLI.Core/Browser/BrowserLaunchOptions.cs new file mode 100644 index 00000000..551a5b35 --- /dev/null +++ b/src/TALXIS.CLI.Core/Browser/BrowserLaunchOptions.cs @@ -0,0 +1,9 @@ +namespace TALXIS.CLI.Core.Browser; + +public sealed record BrowserLaunchOptions( + string ProfileName, + string? AppUrl = null, + bool Headless = false, + int SlowMo = 0, + string BrowserType = "chromium" +); diff --git a/src/TALXIS.CLI.Core/Browser/BrowserSession.cs b/src/TALXIS.CLI.Core/Browser/BrowserSession.cs new file mode 100644 index 00000000..78054dbf --- /dev/null +++ b/src/TALXIS.CLI.Core/Browser/BrowserSession.cs @@ -0,0 +1,13 @@ +namespace TALXIS.CLI.Core.Browser; + +public sealed record BrowserSession( + string Id, + string ProfileName, + string CdpEndpoint, + string? AppUrl, + DateTime CreatedAt, + int Pid, + bool Headless, + string BrowserType, + string UserDataDir +); diff --git a/src/TALXIS.CLI.Core/Browser/IBrowserSessionManager.cs b/src/TALXIS.CLI.Core/Browser/IBrowserSessionManager.cs new file mode 100644 index 00000000..9e6bb262 --- /dev/null +++ b/src/TALXIS.CLI.Core/Browser/IBrowserSessionManager.cs @@ -0,0 +1,13 @@ +namespace TALXIS.CLI.Core.Browser; + +public interface IBrowserSessionManager +{ + Task LaunchAsync(BrowserLaunchOptions options, CancellationToken ct); + Task AttachAsync(string cdpEndpoint, CancellationToken ct); + Task CloseAsync(string sessionId, CancellationToken ct); + Task GetActiveSessionAsync(CancellationToken ct); + Task GetSessionAsync(string sessionId, CancellationToken ct); + Task> ListSessionsAsync(CancellationToken ct); + Task GetCurrentUrlAsync(string sessionId, CancellationToken ct); + Task EvaluateAsync(string sessionId, string script, CancellationToken ct); +} diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/BrowseUrlConstants.cs b/src/TALXIS.CLI.Core/Component/Browse/BrowseUrlConstants.cs similarity index 88% rename from src/TALXIS.CLI.Features.Environment/Component/Browse/BrowseUrlConstants.cs rename to src/TALXIS.CLI.Core/Component/Browse/BrowseUrlConstants.cs index f326ce21..a212cb67 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/BrowseUrlConstants.cs +++ b/src/TALXIS.CLI.Core/Component/Browse/BrowseUrlConstants.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Features.Environment.Component.Browse; +namespace TALXIS.CLI.Core.Component.Browse; /// /// Shared constants for browse URL construction. @@ -16,7 +16,7 @@ public static string NormalizeOrgUrl(string orgUrl) { if (Uri.TryCreate(orgUrl, UriKind.Absolute, out var uri)) return uri.Host; - // Already a bare hostname + return orgUrl.TrimEnd('/'); } } diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/CanvasAppUrls.cs b/src/TALXIS.CLI.Core/Component/Browse/CanvasAppUrls.cs similarity index 77% rename from src/TALXIS.CLI.Features.Environment/Component/Browse/CanvasAppUrls.cs rename to src/TALXIS.CLI.Core/Component/Browse/CanvasAppUrls.cs index 8c3bfea8..9e872a52 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/CanvasAppUrls.cs +++ b/src/TALXIS.CLI.Core/Component/Browse/CanvasAppUrls.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Features.Environment.Component.Browse; +namespace TALXIS.CLI.Core.Component.Browse; /// /// URL builders for the Power Apps canvas app player (apps.powerapps.com/play). @@ -11,8 +11,13 @@ public static class CanvasAppUrls /// /// Open a canvas app in the Power Apps player. /// - public static Uri Play(Guid environmentId, Guid appId, string? tenantId, - string? screenName = null, IDictionary? customParams = null, bool hideNavbar = false) + public static Uri Play( + Guid environmentId, + Guid appId, + string? tenantId, + string? screenName = null, + IDictionary? customParams = null, + bool hideNavbar = false) { var qs = new List(); if (!string.IsNullOrWhiteSpace(tenantId)) @@ -22,10 +27,12 @@ public static Uri Play(Guid environmentId, Guid appId, string? tenantId, if (hideNavbar) qs.Add("hidenavbar=true"); if (customParams != null) + { foreach (var (key, value) in customParams) qs.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}"); + } - var query = qs.Count > 0 ? "?" + string.Join("&", qs) : ""; + var query = qs.Count > 0 ? "?" + string.Join("&", qs) : string.Empty; return new Uri($"{Base}/e/{environmentId}/a/{appId}{query}"); } } diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/CopilotStudioUrls.cs b/src/TALXIS.CLI.Core/Component/Browse/CopilotStudioUrls.cs similarity index 90% rename from src/TALXIS.CLI.Features.Environment/Component/Browse/CopilotStudioUrls.cs rename to src/TALXIS.CLI.Core/Component/Browse/CopilotStudioUrls.cs index 6ad89029..94ed74b7 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/CopilotStudioUrls.cs +++ b/src/TALXIS.CLI.Core/Component/Browse/CopilotStudioUrls.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Features.Environment.Component.Browse; +namespace TALXIS.CLI.Core.Component.Browse; /// /// URL builders for Copilot Studio (copilotstudio.microsoft.com). diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrls.cs b/src/TALXIS.CLI.Core/Component/Browse/MakerPortalUrls.cs similarity index 96% rename from src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrls.cs rename to src/TALXIS.CLI.Core/Component/Browse/MakerPortalUrls.cs index 8d9a731e..355d802e 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/MakerPortalUrls.cs +++ b/src/TALXIS.CLI.Core/Component/Browse/MakerPortalUrls.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Features.Environment.Component.Browse; +namespace TALXIS.CLI.Core.Component.Browse; /// /// URL builders for the Power Apps maker portal (make.powerapps.com). diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/PowerAppsUciUrls.cs b/src/TALXIS.CLI.Core/Component/Browse/PowerAppsUciUrls.cs similarity index 97% rename from src/TALXIS.CLI.Features.Environment/Component/Browse/PowerAppsUciUrls.cs rename to src/TALXIS.CLI.Core/Component/Browse/PowerAppsUciUrls.cs index 838a0317..ac1c1aff 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/PowerAppsUciUrls.cs +++ b/src/TALXIS.CLI.Core/Component/Browse/PowerAppsUciUrls.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Features.Environment.Component.Browse; +namespace TALXIS.CLI.Core.Component.Browse; /// /// URL builders for the Power Apps UCI runtime ({org}.crm{N}.dynamics.com/main.aspx). diff --git a/src/TALXIS.CLI.Features.Environment/Component/Browse/PowerAutomateUrls.cs b/src/TALXIS.CLI.Core/Component/Browse/PowerAutomateUrls.cs similarity index 97% rename from src/TALXIS.CLI.Features.Environment/Component/Browse/PowerAutomateUrls.cs rename to src/TALXIS.CLI.Core/Component/Browse/PowerAutomateUrls.cs index b5908851..113e43a5 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Browse/PowerAutomateUrls.cs +++ b/src/TALXIS.CLI.Core/Component/Browse/PowerAutomateUrls.cs @@ -1,4 +1,4 @@ -namespace TALXIS.CLI.Features.Environment.Component.Browse; +namespace TALXIS.CLI.Core.Component.Browse; /// /// URL builders for the Power Automate maker portal (make.powerautomate.com). diff --git a/src/TALXIS.CLI.Features.Environment/Component/Url/UrlBuilder.cs b/src/TALXIS.CLI.Core/Component/Url/UrlBuilder.cs similarity index 65% rename from src/TALXIS.CLI.Features.Environment/Component/Url/UrlBuilder.cs rename to src/TALXIS.CLI.Core/Component/Url/UrlBuilder.cs index deefb4c5..ed9b8d89 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Url/UrlBuilder.cs +++ b/src/TALXIS.CLI.Core/Component/Url/UrlBuilder.cs @@ -1,12 +1,11 @@ using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Component.Browse; using TALXIS.CLI.Core.Contracts.Dataverse; using TALXIS.CLI.Core.DependencyInjection; -using TALXIS.CLI.Features.Environment.Component.Browse; using TALXIS.Platform.Metadata; -namespace TALXIS.CLI.Features.Environment.Component.Url; +namespace TALXIS.CLI.Core.Component.Url; /// /// Shared URL construction logic extracted from the old browse command. @@ -15,21 +14,12 @@ namespace TALXIS.CLI.Features.Environment.Component.Url; /// public static class UrlBuilder { - /// - /// Builds a URL for a component editor/viewer based on the given type and parameters. - /// - /// Component type string (canonical name, alias, template name, or integer type code). - /// Key-value parameters parsed from --param options. - /// Profile name for connection resolution. - /// Logger for error reporting. - /// The built URL and resolved type name, or null if building failed. public static async Task BuildUrlAsync( string type, IReadOnlyDictionary parameters, string? profileName, ILogger logger) { - // Resolve component type var def = ComponentDefinitionRegistry.GetByName(type); ComponentType? typeCode = def?.TypeCode; if (typeCode is null && int.TryParse(type, out var rawCode) && rawCode > 0) @@ -40,7 +30,6 @@ public static class UrlBuilder return null; } - // Resolve profile + connection var configResolver = TxcServices.Get(); var ctx = await configResolver.ResolveAsync(profileName, CancellationToken.None).ConfigureAwait(false); var connection = ctx.Connection; @@ -50,11 +39,11 @@ public static class UrlBuilder logger.LogError("Environment ID is not set on the connection. Run 'config connection check' to populate it."); return null; } - var environmentId = connection.EnvironmentId.Value; - // Validate EnvironmentUrl for types that require it (UCI, reports, SCF record forms) + var environmentId = connection.EnvironmentId.Value; var needsOrgUrl = typeCode.Value is ComponentType.AppModule or ComponentType.Report || (typeCode.Value is not ComponentType.CanvasApp and not ComponentType.Workflow); + string? orgUrl = null; if (!string.IsNullOrWhiteSpace(connection.EnvironmentUrl) && Uri.TryCreate(connection.EnvironmentUrl, UriKind.Absolute, out var orgUri)) @@ -67,7 +56,6 @@ public static class UrlBuilder return null; } - // Dispatch by component type Uri? url = typeCode.Value switch { ComponentType.AppModule => BuildAppModuleUrl(orgUrl!, parameters, logger), @@ -77,39 +65,30 @@ public static class UrlBuilder _ => await BuildMakerEditorUrlAsync(typeCode.Value, environmentId, orgUrl, parameters, profileName, logger).ConfigureAwait(false) }; - if (url is null) - return null; - - return new UrlBuilderResult(url, def?.Name ?? typeCode.Value.ToString()); + return url is null ? null : new UrlBuilderResult(url, def?.Name ?? typeCode.Value.ToString()); } - /// - /// Parses a list of "key=value" strings into a dictionary. - /// public static Dictionary ParseParams(IEnumerable paramStrings) { var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var p in paramStrings) + foreach (var param in paramStrings) { - var idx = p.IndexOf('='); - if (idx > 0 && idx < p.Length - 1) - dict[p[..idx]] = p[(idx + 1)..]; + var idx = param.IndexOf('='); + if (idx > 0 && idx < param.Length - 1) + dict[param[..idx]] = param[(idx + 1)..]; } + return dict; } - // ── Helper: read optional param from dictionary ── - - private static string? Get(IReadOnlyDictionary p, string key) - => p.TryGetValue(key, out var v) ? v : null; - - // ── AppModule URL builder ── + private static string? Get(IReadOnlyDictionary parameters, string key) + => parameters.TryGetValue(key, out var value) ? value : null; - private static Uri? BuildAppModuleUrl(string orgUrl, IReadOnlyDictionary p, ILogger logger) + private static Uri? BuildAppModuleUrl(string orgUrl, IReadOnlyDictionary parameters, ILogger logger) { - var pageType = Get(p, "pagetype"); - var name = Get(p, "name"); - var id = Get(p, "id"); + var pageType = Get(parameters, "pagetype"); + var name = Get(parameters, "name"); + var id = Get(parameters, "id"); if (string.IsNullOrWhiteSpace(pageType)) { @@ -117,21 +96,29 @@ public static Dictionary ParseParams(IEnumerable paramSt return PowerAppsUciUrls.AppByName(orgUrl, name); if (!string.IsNullOrWhiteSpace(id) && Guid.TryParse(id, out var appId)) return PowerAppsUciUrls.AppById(orgUrl, appId); + logger.LogError("Provide 'name' or 'id' parameter for the app module."); return null; } var queryParams = new Dictionary(); - void AddIf(string paramKey, string qsKey) { var v = Get(p, paramKey); if (!string.IsNullOrWhiteSpace(v)) queryParams[qsKey] = v; } + + void AddIf(string paramKey, string qsKey) + { + var value = Get(parameters, paramKey); + if (!string.IsNullOrWhiteSpace(value)) + queryParams[qsKey] = value; + } AddIf("entity", "etn"); AddIf("record", "id"); AddIf("formid", "formid"); - if (!string.IsNullOrWhiteSpace(Get(p, "viewid"))) + if (!string.IsNullOrWhiteSpace(Get(parameters, "viewid"))) { - queryParams["viewid"] = Get(p, "viewid")!; + queryParams["viewid"] = Get(parameters, "viewid")!; queryParams["viewtype"] = "1039"; } + AddIf("dashboard", "id"); AddIf("custom-page", "name"); AddIf("control", "controlName"); @@ -151,25 +138,25 @@ public static Dictionary ParseParams(IEnumerable paramSt return PowerAppsUciUrls.DeepLink(orgUrl, name, appId2, pageType, queryParams); } - // ── Flow URL builder ── - private static async Task BuildFlowUrlAsync( - Guid environmentId, IReadOnlyDictionary p, string? profileName, ILogger logger) + Guid environmentId, + IReadOnlyDictionary parameters, + string? profileName, + ILogger logger) { - var id = Get(p, "id"); + var id = Get(parameters, "id"); if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var flowId)) { logger.LogError("'id' parameter (GUID) is required for flows."); return null; } - Guid? solutionId = await ResolveSolutionIdAsync(Get(p, "solution"), profileName).ConfigureAwait(false); - - var run = Get(p, "run"); + var solutionId = await ResolveSolutionIdAsync(Get(parameters, "solution"), profileName).ConfigureAwait(false); + var run = Get(parameters, "run"); if (!string.IsNullOrWhiteSpace(run)) return PowerAutomateUrls.FlowRun(environmentId, flowId, run, solutionId); - var flowView = Get(p, "flow-view"); + var flowView = Get(parameters, "flow-view"); return (flowView?.ToLowerInvariant()) switch { "details" => PowerAutomateUrls.FlowDetails(environmentId, flowId, solutionId), @@ -178,64 +165,70 @@ public static Dictionary ParseParams(IEnumerable paramSt }; } - // ── Canvas app URL builder ── - private static Uri? BuildCanvasAppUrl( - Guid environmentId, string? tenantId, IReadOnlyDictionary p, ILogger logger) + Guid environmentId, + string? tenantId, + IReadOnlyDictionary parameters, + ILogger logger) { - var id = Get(p, "id"); + var id = Get(parameters, "id"); if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var appId)) { logger.LogError("'id' parameter (GUID) is required for canvas apps."); return null; } - var screen = Get(p, "screen"); - var hideNavbar = string.Equals(Get(p, "hidenavbar"), "true", StringComparison.OrdinalIgnoreCase); + var screen = Get(parameters, "screen"); + var hideNavbar = string.Equals(Get(parameters, "hidenavbar"), "true", StringComparison.OrdinalIgnoreCase); - // Collect remaining params as custom canvas app parameters - var reserved = new HashSet(StringComparer.OrdinalIgnoreCase) - { "id", "screen", "hidenavbar" }; + var reserved = new HashSet(StringComparer.OrdinalIgnoreCase) { "id", "screen", "hidenavbar" }; var customParams = new Dictionary(); - foreach (var kvp in p) + foreach (var kvp in parameters) { if (!reserved.Contains(kvp.Key)) customParams[kvp.Key] = kvp.Value; } - return CanvasAppUrls.Play(environmentId, appId, tenantId, screen, - customParams.Count > 0 ? customParams : null, hideNavbar); + return CanvasAppUrls.Play( + environmentId, + appId, + tenantId, + screen, + customParams.Count > 0 ? customParams : null, + hideNavbar); } - // ── Report URL builder ── - - private static Uri? BuildReportUrl(string orgUrl, IReadOnlyDictionary p, ILogger logger) + private static Uri? BuildReportUrl(string orgUrl, IReadOnlyDictionary parameters, ILogger logger) { - var id = Get(p, "id"); + var id = Get(parameters, "id"); if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var reportId)) { logger.LogError("'id' parameter (GUID) is required for reports."); return null; } - var action = Get(p, "report-action") ?? "run"; + + var action = Get(parameters, "report-action") ?? "run"; return PowerAppsUciUrls.Report(orgUrl, reportId, action); } - // ── Maker portal editor URL builder (generic types) ── - private static async Task BuildMakerEditorUrlAsync( - ComponentType typeCode, Guid environmentId, string? orgUrl, - IReadOnlyDictionary p, string? profileName, ILogger logger) + ComponentType typeCode, + Guid environmentId, + string? orgUrl, + IReadOnlyDictionary parameters, + string? profileName, + ILogger logger) { - var id = Get(p, "id"); - var name = Get(p, "name"); - var entity = Get(p, "entity"); + var id = Get(parameters, "id"); + var name = Get(parameters, "name"); + var entity = Get(parameters, "entity"); if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name)) { logger.LogError("Provide 'id' or 'name' parameter."); return null; } + if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(name)) { logger.LogError("'id' and 'name' are mutually exclusive."); @@ -254,12 +247,13 @@ public static Dictionary ParseParams(IEnumerable paramSt else { var resolved = await ResolveNameToGuidAsync(typeCode, name!, profileName, logger).ConfigureAwait(false); - if (resolved is null) return null; + if (resolved is null) + return null; + componentId = resolved.Value; } - Guid? solutionId = await ResolveSolutionIdAsync(Get(p, "solution"), profileName).ConfigureAwait(false); - + var solutionId = await ResolveSolutionIdAsync(Get(parameters, "solution"), profileName).ConfigureAwait(false); if (typeCode is ComponentType.SystemForm or ComponentType.Form or ComponentType.SavedQuery && string.IsNullOrWhiteSpace(entity)) { @@ -270,38 +264,41 @@ public static Dictionary ParseParams(IEnumerable paramSt var url = BuildEditorUrl(typeCode, environmentId, orgUrl, componentId, entity, solutionId); if (url is null) logger.LogError("Cannot build URL for type code {Code}. For SCF types, provide 'entity' parameter.", (int)typeCode); + return url; } - /// Dispatches to the appropriate URL builder based on component type. - private static Uri? BuildEditorUrl(ComponentType typeCode, Guid envId, string? orgUrl, Guid componentId, string? entity, Guid? solutionId) + private static Uri? BuildEditorUrl( + ComponentType typeCode, + Guid environmentId, + string? orgUrl, + Guid componentId, + string? entity, + Guid? solutionId) { return typeCode switch { - ComponentType.Solution => MakerPortalUrls.Solution(envId, componentId), - ComponentType.Entity => MakerPortalUrls.Entity(envId, componentId, solutionId), - ComponentType.SystemForm when entity != null => MakerPortalUrls.FormDesigner(envId, entity, componentId, solutionId), - ComponentType.Form when entity != null => MakerPortalUrls.FormDesigner(envId, entity, componentId, solutionId), - ComponentType.SavedQuery when entity != null => MakerPortalUrls.ViewDesigner(envId, entity, componentId, solutionId), - ComponentType.Bot => CopilotStudioUrls.BotEditor(envId, componentId, solutionId), - ComponentType.Dataflow => MakerPortalUrls.DataflowEditor(envId, componentId), - ComponentType.Role => MakerPortalUrls.SecurityRoleEditor(envId, componentId, solutionId), - // SCF / unknown — fallback to UCI record form + ComponentType.Solution => MakerPortalUrls.Solution(environmentId, componentId), + ComponentType.Entity => MakerPortalUrls.Entity(environmentId, componentId, solutionId), + ComponentType.SystemForm when entity != null => MakerPortalUrls.FormDesigner(environmentId, entity, componentId, solutionId), + ComponentType.Form when entity != null => MakerPortalUrls.FormDesigner(environmentId, entity, componentId, solutionId), + ComponentType.SavedQuery when entity != null => MakerPortalUrls.ViewDesigner(environmentId, entity, componentId, solutionId), + ComponentType.Bot => CopilotStudioUrls.BotEditor(environmentId, componentId, solutionId), + ComponentType.Dataflow => MakerPortalUrls.DataflowEditor(environmentId, componentId), + ComponentType.Role => MakerPortalUrls.SecurityRoleEditor(environmentId, componentId, solutionId), _ when orgUrl != null && entity != null => PowerAppsUciUrls.RecordForm(orgUrl, entity, componentId), _ => null }; } - // ── Name-to-GUID resolution ── - private static async Task ResolveNameToGuidAsync(ComponentType typeCode, string name, string? profileName, ILogger logger) { switch (typeCode) { case ComponentType.Solution: - var slnService = TxcServices.Get(); - var (sln, _) = await slnService.ShowAsync(profileName, name, CancellationToken.None).ConfigureAwait(false); - return sln.Id; + var solutionService = TxcServices.Get(); + var (solution, _) = await solutionService.ShowAsync(profileName, name, CancellationToken.None).ConfigureAwait(false); + return solution.Id; case ComponentType.Entity: var metadataResolver = TxcServices.Get(); @@ -317,16 +314,15 @@ public static Dictionary ParseParams(IEnumerable paramSt } } - /// Resolves a solution unique name to its GUID, if provided. private static async Task ResolveSolutionIdAsync(string? solutionUniqueName, string? profileName) { if (string.IsNullOrWhiteSpace(solutionUniqueName)) return null; - var slnService = TxcServices.Get(); - var (sln, _) = await slnService.ShowAsync(profileName, solutionUniqueName, CancellationToken.None).ConfigureAwait(false); - return sln.Id; + + var solutionService = TxcServices.Get(); + var (solution, _) = await solutionService.ShowAsync(profileName, solutionUniqueName, CancellationToken.None).ConfigureAwait(false); + return solution.Id; } } -/// Result of a successful URL build operation. public sealed record UrlBuilderResult(Uri Url, string TypeName); diff --git a/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj b/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj index 79d0022a..34c37990 100644 --- a/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj +++ b/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj @@ -27,6 +27,8 @@ + + diff --git a/src/TALXIS.CLI.Features.Environment/Component/Url/UrlCommandBase.cs b/src/TALXIS.CLI.Features.Environment/Component/Url/UrlCommandBase.cs index 6384f54c..ca5e7265 100644 --- a/src/TALXIS.CLI.Features.Environment/Component/Url/UrlCommandBase.cs +++ b/src/TALXIS.CLI.Features.Environment/Component/Url/UrlCommandBase.cs @@ -1,6 +1,7 @@ using DotMake.CommandLine; using Microsoft.Extensions.Logging; using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Component.Url; using TALXIS.CLI.Core.Resolution; using TALXIS.Platform.Metadata; using TALXIS.Platform.Metadata.Serialization.Xml; diff --git a/src/TALXIS.CLI.Features.Ui/Browser/UiBrowserCliCommand.cs b/src/TALXIS.CLI.Features.Ui/Browser/UiBrowserCliCommand.cs new file mode 100644 index 00000000..c78c38ec --- /dev/null +++ b/src/TALXIS.CLI.Features.Ui/Browser/UiBrowserCliCommand.cs @@ -0,0 +1,14 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Ui.Browser; + +[CliCommand( + Name = "browser", + Description = "Advanced browser escape hatches for an open session.", + Children = new[] { typeof(UiBrowserEvalCliCommand) }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class UiBrowserCliCommand +{ + public void Run(CliContext context) => context.ShowHelp(); +} diff --git a/src/TALXIS.CLI.Features.Ui/Browser/UiBrowserEvalCliCommand.cs b/src/TALXIS.CLI.Features.Ui/Browser/UiBrowserEvalCliCommand.cs new file mode 100644 index 00000000..b5cc43fb --- /dev/null +++ b/src/TALXIS.CLI.Features.Ui/Browser/UiBrowserEvalCliCommand.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Browser; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Ui.Browser; + +[CliReadOnly] +[CliCommand(Name = "eval", Description = "Evaluate JavaScript in the active browser session.")] +public class UiBrowserEvalCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UiBrowserEvalCliCommand)); + + [CliOption(Name = "--eval", Aliases = ["-e"], Description = "JavaScript expression to evaluate.", Required = true)] + public string Eval { get; set; } = string.Empty; + + [CliOption(Name = "--session", Aliases = ["-s"], Description = "Session ID to use. Defaults to the active session.", Required = false)] + public string? Session { get; set; } + + protected override async Task ExecuteAsync() + { + var sessionManager = TxcServices.Get(); + var session = !string.IsNullOrWhiteSpace(Session) + ? await sessionManager.GetSessionAsync(Session, CancellationToken.None).ConfigureAwait(false) + : await sessionManager.GetActiveSessionAsync(CancellationToken.None).ConfigureAwait(false); + + if (session is null) + { + Logger.LogError("No active session. Run 'txc ui session open' first."); + return ExitValidationError; + } + + var result = await sessionManager.EvaluateAsync(session.Id, Eval, CancellationToken.None).ConfigureAwait(false); + var raw = result.ValueKind == JsonValueKind.String + ? JsonSerializer.Serialize(result.GetString(), TxcOutputJsonOptions.Default) + : result.GetRawText(); + + OutputFormatter.WriteRaw(raw, () => + { + if (result.ValueKind == JsonValueKind.String) + OutputWriter.WriteLine(result.GetString() ?? string.Empty); + else + OutputWriter.WriteLine(result.GetRawText()); + }); + + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Ui/Session/UiSessionCliCommand.cs b/src/TALXIS.CLI.Features.Ui/Session/UiSessionCliCommand.cs new file mode 100644 index 00000000..515f82cc --- /dev/null +++ b/src/TALXIS.CLI.Features.Ui/Session/UiSessionCliCommand.cs @@ -0,0 +1,19 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Ui.Session; + +[CliCommand( + Name = "session", + Description = "Manage browser sessions.", + Children = new[] { + typeof(UiSessionOpenCliCommand), + typeof(UiSessionCloseCliCommand), + typeof(UiSessionStatusCliCommand), + typeof(UiSessionListCliCommand) + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class UiSessionCliCommand +{ + public void Run(CliContext context) => context.ShowHelp(); +} diff --git a/src/TALXIS.CLI.Features.Ui/Session/UiSessionCloseCliCommand.cs b/src/TALXIS.CLI.Features.Ui/Session/UiSessionCloseCliCommand.cs new file mode 100644 index 00000000..7600ceff --- /dev/null +++ b/src/TALXIS.CLI.Features.Ui/Session/UiSessionCloseCliCommand.cs @@ -0,0 +1,56 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Browser; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Ui.Session; + +[CliIdempotent] +[CliCommand(Name = "close", Description = "Close one or more browser sessions.")] +public class UiSessionCloseCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UiSessionCloseCliCommand)); + + [CliOption(Name = "--session", Aliases = ["-s"], Description = "Session ID to close. Defaults to the active session.", Required = false)] + public string? Session { get; set; } + + [CliOption(Name = "--all", Description = "Close all active sessions.", Required = false)] + public bool All { get; set; } + + protected override async Task ExecuteAsync() + { + var sessionManager = TxcServices.Get(); + + if (All) + { + var sessions = await sessionManager.ListSessionsAsync(CancellationToken.None).ConfigureAwait(false); + if (sessions.Count == 0) + { + Logger.LogError("No active session. Run 'txc ui session open' first."); + return ExitValidationError; + } + + foreach (var value in sessions) + await sessionManager.CloseAsync(value.Id, CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteResult("succeeded", $"Closed {sessions.Count} session(s)."); + return ExitSuccess; + } + + var session = !string.IsNullOrWhiteSpace(Session) + ? await sessionManager.GetSessionAsync(Session, CancellationToken.None).ConfigureAwait(false) + : await sessionManager.GetActiveSessionAsync(CancellationToken.None).ConfigureAwait(false); + + if (session is null) + { + Logger.LogError("No active session. Run 'txc ui session open' first."); + return ExitValidationError; + } + + await sessionManager.CloseAsync(session.Id, CancellationToken.None).ConfigureAwait(false); + OutputFormatter.WriteResult("succeeded", $"Session {session.Id} closed.", session.Id); + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Ui/Session/UiSessionListCliCommand.cs b/src/TALXIS.CLI.Features.Ui/Session/UiSessionListCliCommand.cs new file mode 100644 index 00000000..e0f1e827 --- /dev/null +++ b/src/TALXIS.CLI.Features.Ui/Session/UiSessionListCliCommand.cs @@ -0,0 +1,29 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Browser; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Ui.Session; + +[CliReadOnly] +[CliCommand(Name = "list", Description = "List active browser sessions.")] +public class UiSessionListCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UiSessionListCliCommand)); + + protected override async Task ExecuteAsync() + { + var sessionManager = TxcServices.Get(); + var sessions = await sessionManager.ListSessionsAsync(CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteList(sessions, values => + { + foreach (var value in values) + OutputWriter.WriteLine($"{value.Id} {value.ProfileName} {value.AppUrl}"); + }); + + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Ui/Session/UiSessionOpenCliCommand.cs b/src/TALXIS.CLI.Features.Ui/Session/UiSessionOpenCliCommand.cs new file mode 100644 index 00000000..c6b201ef --- /dev/null +++ b/src/TALXIS.CLI.Features.Ui/Session/UiSessionOpenCliCommand.cs @@ -0,0 +1,91 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Browser; +using TALXIS.CLI.Core.Component.Url; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Ui.Session; + +[CliIdempotent] +[CliCommand(Name = "open", Description = "Open a browser session for a Power Apps app or URL.")] +public class UiSessionOpenCliCommand : ProfiledCliCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UiSessionOpenCliCommand)); + + [CliOption(Name = "--type", Description = "Component type to navigate to (for example AppModule).", Required = false)] + public string? Type { get; set; } + + [CliOption(Name = "--param", Description = "URL parameter in key=value format. Can be specified multiple times.", Required = false)] + public List Param { get; set; } = new(); + + [CliOption(Name = "--url", Description = "Direct URL to navigate to.", Required = false)] + public string? Url { get; set; } + + [CliOption(Name = "--headless", Description = "Launch in headless mode when saved auth state exists.", Required = false)] + public bool Headless { get; set; } + + [CliOption(Name = "--headed", Description = "Force headed mode even when --headless was requested.", Required = false)] + public bool Headed { get; set; } + + [CliOption(Name = "--slow-mo", Description = "Slow down browser operations by N milliseconds.", Required = false)] + public int SlowMo { get; set; } + + protected override async Task ExecuteAsync() + { + if (string.IsNullOrWhiteSpace(Url) && string.IsNullOrWhiteSpace(Type)) + { + Logger.LogError("Provide --url or --type to specify the target."); + return ExitValidationError; + } + + if (!string.IsNullOrWhiteSpace(Url) && !string.IsNullOrWhiteSpace(Type)) + { + Logger.LogError("Use either --url or --type/--param, not both."); + return ExitValidationError; + } + + var targetUrl = await ResolveTargetUrlAsync().ConfigureAwait(false); + if (targetUrl is null) + return ExitValidationError; + + var resolver = TxcServices.Get(); + var resolved = await resolver.ResolveAsync(Profile, CancellationToken.None).ConfigureAwait(false); + var sessionManager = TxcServices.Get(); + var session = await sessionManager.LaunchAsync( + new BrowserLaunchOptions( + ProfileName: resolved.Profile.Id, + AppUrl: targetUrl.ToString(), + Headless: Headed ? false : Headless, + SlowMo: SlowMo), + CancellationToken.None).ConfigureAwait(false); + + OutputFormatter.WriteData(session, value => + { + OutputWriter.WriteLine($"Session {value.Id} opened"); + OutputWriter.WriteLine($" Profile: {value.ProfileName}"); + OutputWriter.WriteLine($" URL: {value.AppUrl}"); + OutputWriter.WriteLine($" CDP: {value.CdpEndpoint}"); + }); + + return ExitSuccess; + } + + private async Task ResolveTargetUrlAsync() + { + if (!string.IsNullOrWhiteSpace(Url)) + { + if (Uri.TryCreate(Url, UriKind.Absolute, out var url)) + return url; + + Logger.LogError("The value provided to --url is not a valid absolute URL."); + return null; + } + + var parameters = UrlBuilder.ParseParams(Param); + var built = await UrlBuilder.BuildUrlAsync(Type!, parameters, Profile, Logger).ConfigureAwait(false); + return built?.Url; + } +} diff --git a/src/TALXIS.CLI.Features.Ui/Session/UiSessionStatusCliCommand.cs b/src/TALXIS.CLI.Features.Ui/Session/UiSessionStatusCliCommand.cs new file mode 100644 index 00000000..2a95e3c5 --- /dev/null +++ b/src/TALXIS.CLI.Features.Ui/Session/UiSessionStatusCliCommand.cs @@ -0,0 +1,46 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Browser; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Ui.Session; + +[CliReadOnly] +[CliCommand(Name = "status", Description = "Show the current browser session.")] +public class UiSessionStatusCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(UiSessionStatusCliCommand)); + + [CliOption(Name = "--session", Aliases = ["-s"], Description = "Session ID to inspect. Defaults to the active session.", Required = false)] + public string? Session { get; set; } + + protected override async Task ExecuteAsync() + { + var sessionManager = TxcServices.Get(); + var session = !string.IsNullOrWhiteSpace(Session) + ? await sessionManager.GetSessionAsync(Session, CancellationToken.None).ConfigureAwait(false) + : await sessionManager.GetActiveSessionAsync(CancellationToken.None).ConfigureAwait(false); + + if (session is null) + { + Logger.LogError("No active session. Run 'txc ui session open' first."); + return ExitValidationError; + } + + var currentUrl = await sessionManager.GetCurrentUrlAsync(session.Id, CancellationToken.None).ConfigureAwait(false); + var current = string.IsNullOrWhiteSpace(currentUrl) ? session : session with { AppUrl = currentUrl }; + + OutputFormatter.WriteData(current, value => + { + OutputWriter.WriteLine($"Session {value.Id}"); + OutputWriter.WriteLine($" Profile: {value.ProfileName}"); + OutputWriter.WriteLine($" URL: {value.AppUrl}"); + OutputWriter.WriteLine($" PID: {value.Pid}"); + OutputWriter.WriteLine($" Headless: {value.Headless}"); + }); + + return ExitSuccess; + } +} diff --git a/src/TALXIS.CLI.Features.Ui/TALXIS.CLI.Features.Ui.csproj b/src/TALXIS.CLI.Features.Ui/TALXIS.CLI.Features.Ui.csproj new file mode 100644 index 00000000..6a74bab8 --- /dev/null +++ b/src/TALXIS.CLI.Features.Ui/TALXIS.CLI.Features.Ui.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/src/TALXIS.CLI.Features.Ui/UiCliCommand.cs b/src/TALXIS.CLI.Features.Ui/UiCliCommand.cs new file mode 100644 index 00000000..c9b9fd79 --- /dev/null +++ b/src/TALXIS.CLI.Features.Ui/UiCliCommand.cs @@ -0,0 +1,14 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Ui; + +[CliCommand( + Name = "ui", + Description = "Browser automation for Power Apps model-driven apps.", + Children = new[] { typeof(Session.UiSessionCliCommand), typeof(Browser.UiBrowserCliCommand) }, + ShortFormAutoGenerate = CliNameAutoGenerate.None +)] +public class UiCliCommand +{ + public void Run(CliContext context) => context.ShowHelp(); +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/TxcServicesBootstrap.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/TxcServicesBootstrap.cs index f4a34df9..a62e2098 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/TxcServicesBootstrap.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/DependencyInjection/TxcServicesBootstrap.cs @@ -4,6 +4,7 @@ using TALXIS.CLI.Logging; using TALXIS.CLI.Platform.Dataverse.Data.DependencyInjection; using TALXIS.CLI.Platform.Dataverse.Runtime.DependencyInjection; +using TALXIS.CLI.Platform.Playwright.DependencyInjection; namespace TALXIS.CLI.Platform.Dataverse.Application.DependencyInjection; @@ -31,6 +32,7 @@ public static void EnsureInitialized() services.AddTxcDataverseProvider(); services.AddTxcDataverseApplication(); services.AddTxcDataverseData(); + services.AddTxcPlaywright(); services.AddSingleton(); var provider = services.BuildServiceProvider(); diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/TALXIS.CLI.Platform.Dataverse.Application.csproj b/src/TALXIS.CLI.Platform.Dataverse.Application/TALXIS.CLI.Platform.Dataverse.Application.csproj index cc49bfe0..50b9d441 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/TALXIS.CLI.Platform.Dataverse.Application.csproj +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/TALXIS.CLI.Platform.Dataverse.Application.csproj @@ -25,6 +25,7 @@ + diff --git a/src/TALXIS.CLI.Platform.Playwright/BrowserProfilePaths.cs b/src/TALXIS.CLI.Platform.Playwright/BrowserProfilePaths.cs new file mode 100644 index 00000000..9c7a81da --- /dev/null +++ b/src/TALXIS.CLI.Platform.Playwright/BrowserProfilePaths.cs @@ -0,0 +1,40 @@ +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; + +namespace TALXIS.CLI.Platform.Playwright; + +internal static class BrowserProfilePaths +{ + public static string BrowserRoot(ConfigPaths paths) + => Path.Combine(paths.Root, "browser"); + + public static string ProfileDirectory(ConfigPaths paths, string profileName) + => Path.Combine(BrowserRoot(paths), SanitizeProfileName(profileName)); + + public static string UserDataDirectory(ConfigPaths paths, string profileName) + => Path.Combine(ProfileDirectory(paths, profileName), "user-data"); + + public static string SessionFile(ConfigPaths paths, string profileName) + => Path.Combine(ProfileDirectory(paths, profileName), "session.json"); + + public static string StorageStateFile(ConfigPaths paths, string profileName) + => Path.Combine(ProfileDirectory(paths, profileName), "storage-state.enc"); + + public static SecretRef StorageStateKeyRef(string profileName) + => SecretRef.Create($"browser-{SanitizeProfileName(profileName)}", "storage-state-key"); + + public static void EnsureProfileDirectories(ConfigPaths paths, string profileName) + { + Directory.CreateDirectory(ProfileDirectory(paths, profileName)); + Directory.CreateDirectory(UserDataDirectory(paths, profileName)); + } + + private static string SanitizeProfileName(string profileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(profileName); + + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new string(profileName.Select(ch => invalid.Contains(ch) ? '-' : ch).ToArray()).Trim(); + return string.IsNullOrWhiteSpace(sanitized) ? "default" : sanitized; + } +} diff --git a/src/TALXIS.CLI.Platform.Playwright/BrowserSessionManager.cs b/src/TALXIS.CLI.Platform.Playwright/BrowserSessionManager.cs new file mode 100644 index 00000000..6cb7a84a --- /dev/null +++ b/src/TALXIS.CLI.Platform.Playwright/BrowserSessionManager.cs @@ -0,0 +1,349 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Browser; +using TALXIS.CLI.Core.Storage; + +namespace TALXIS.CLI.Platform.Playwright; + +public sealed class BrowserSessionManager : IBrowserSessionManager +{ + private static readonly JsonSerializerOptions SessionSerializerOptions = TxcJsonOptions.Default; + private static readonly Dictionary Watchers = new(StringComparer.OrdinalIgnoreCase); + + private readonly ConfigPaths _paths; + private readonly StorageStateManager _storageStateManager; + private readonly SessionRecoveryService _sessionRecoveryService; + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + private readonly IHttpClientFactoryWrapper _httpFactory; + + public BrowserSessionManager( + ConfigPaths paths, + StorageStateManager storageStateManager, + SessionRecoveryService sessionRecoveryService, + ILogger logger, + ILoggerFactory loggerFactory, + IHttpClientFactoryWrapper? httpFactory = null) + { + _paths = paths; + _storageStateManager = storageStateManager; + _sessionRecoveryService = sessionRecoveryService; + _logger = logger; + _loggerFactory = loggerFactory; + _httpFactory = httpFactory ?? DefaultHttpClientFactoryWrapper.Instance; + } + + public async Task LaunchAsync(BrowserLaunchOptions options, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(options.ProfileName); + + var existing = await TryGetProfileSessionAsync(options.ProfileName, ct).ConfigureAwait(false); + if (existing is not null) + await CloseAsync(existing.Id, ct).ConfigureAwait(false); + + BrowserProfilePaths.EnsureProfileDirectories(_paths, options.ProfileName); + var hasSavedState = await _storageStateManager.ExistsAsync(options.ProfileName, ct).ConfigureAwait(false); + var effectiveHeadless = options.Headless && hasSavedState; + if (options.Headless && !effectiveHeadless) + { + _logger.LogInformation( + "Saved browser state was not found for profile '{ProfileName}'. Falling back to headed launch for interactive sign-in.", + options.ProfileName); + } + + using var playwright = await Microsoft.Playwright.Playwright.CreateAsync().ConfigureAwait(false); + var browserType = ResolveBrowserType(playwright, options.BrowserType); + var executablePath = browserType.ExecutablePath; + if (string.IsNullOrWhiteSpace(executablePath)) + throw new InvalidOperationException("Playwright Chromium executable path could not be resolved. Install browsers with 'npx playwright install chromium'."); + + var port = ReservePort(); + var userDataDir = BrowserProfilePaths.UserDataDirectory(_paths, options.ProfileName); + using var process = StartBrowserProcess(executablePath, userDataDir, port, options with { Headless = effectiveHeadless }); + + var cdpEndpoint = await WaitForCdpEndpointAsync(port, ct).ConfigureAwait(false); + await using var browser = await browserType.ConnectOverCDPAsync(cdpEndpoint).ConfigureAwait(false); + var context = browser.Contexts.FirstOrDefault() + ?? throw new InvalidOperationException("Connected browser did not expose a default context."); + var page = context.Pages.FirstOrDefault() ?? await context.NewPageAsync().ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(options.AppUrl)) + { + await page.GotoAsync( + options.AppUrl, + new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 60000 }).ConfigureAwait(false); + await _sessionRecoveryService.CheckAndRecoverAsync(page, ct).ConfigureAwait(false); + await _storageStateManager.SaveAsync(context, options.ProfileName, ct).ConfigureAwait(false); + var watcher = new ReAuthDialogWatcher(_loggerFactory.CreateLogger()); + await watcher.StartAsync(page, CancellationToken.None).ConfigureAwait(false); + lock (Watchers) + { + Watchers[options.ProfileName] = watcher; + } + } + + var session = new BrowserSession( + Id: Guid.NewGuid().ToString("N")[..8], + ProfileName: options.ProfileName, + CdpEndpoint: cdpEndpoint, + AppUrl: page.Url, + CreatedAt: DateTime.UtcNow, + Pid: process.Id, + Headless: effectiveHeadless, + BrowserType: options.BrowserType, + UserDataDir: userDataDir); + + await WriteSessionAsync(session, ct).ConfigureAwait(false); + return session; + } + + public async Task AttachAsync(string cdpEndpoint, CancellationToken ct) + { + using var playwright = await Microsoft.Playwright.Playwright.CreateAsync().ConfigureAwait(false); + var browser = await playwright.Chromium.ConnectOverCDPAsync(cdpEndpoint).ConfigureAwait(false); + await browser.DisposeAsync().ConfigureAwait(false); + + var sessions = await ListSessionsAsync(ct).ConfigureAwait(false); + return sessions.FirstOrDefault(session => string.Equals(session.CdpEndpoint, cdpEndpoint, StringComparison.OrdinalIgnoreCase)); + } + + public async Task CloseAsync(string sessionId, CancellationToken ct) + { + var session = await GetSessionAsync(sessionId, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Session '{sessionId}' was not found."); + + try + { + using var playwright = await Microsoft.Playwright.Playwright.CreateAsync().ConfigureAwait(false); + await using var browser = await playwright.Chromium.ConnectOverCDPAsync(session.CdpEndpoint).ConfigureAwait(false); + await browser.CloseAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Graceful browser close via CDP failed for session {SessionId}. Falling back to PID shutdown.", sessionId); + } + + TryKillProcess(session.Pid); + await DeleteSessionFileAsync(session.ProfileName, ct).ConfigureAwait(false); + + ReAuthDialogWatcher? watcher = null; + lock (Watchers) + { + if (Watchers.TryGetValue(session.ProfileName, out watcher)) + Watchers.Remove(session.ProfileName); + } + + if (watcher is not null) + await watcher.DisposeAsync().ConfigureAwait(false); + } + + public async Task GetActiveSessionAsync(CancellationToken ct) + { + var sessions = await ListSessionsAsync(ct).ConfigureAwait(false); + return sessions.OrderByDescending(session => session.CreatedAt).FirstOrDefault(); + } + + public async Task GetSessionAsync(string sessionId, CancellationToken ct) + { + var sessions = await ListSessionsAsync(ct).ConfigureAwait(false); + return sessions.FirstOrDefault(session => string.Equals(session.Id, sessionId, StringComparison.OrdinalIgnoreCase)); + } + + public async Task> ListSessionsAsync(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + var root = BrowserProfilePaths.BrowserRoot(_paths); + if (!Directory.Exists(root)) + return Array.Empty(); + + var sessions = new List(); + foreach (var sessionFile in Directory.EnumerateFiles(root, "session.json", SearchOption.AllDirectories)) + { + var session = await ReadSessionFileAsync(sessionFile, ct).ConfigureAwait(false); + if (session is null) + continue; + + if (!IsProcessRunning(session.Pid)) + { + File.Delete(sessionFile); + continue; + } + + sessions.Add(session); + } + + return sessions.OrderByDescending(session => session.CreatedAt).ToList(); + } + + public async Task GetCurrentUrlAsync(string sessionId, CancellationToken ct) + { + var session = await GetSessionAsync(sessionId, ct).ConfigureAwait(false); + if (session is null) + return null; + + using var playwright = await Microsoft.Playwright.Playwright.CreateAsync().ConfigureAwait(false); + await using var browser = await playwright.Chromium.ConnectOverCDPAsync(session.CdpEndpoint).ConfigureAwait(false); + var page = await GetPrimaryPageAsync(browser).ConfigureAwait(false); + return page.Url; + } + + public async Task EvaluateAsync(string sessionId, string script, CancellationToken ct) + { + var session = await GetSessionAsync(sessionId, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Session '{sessionId}' was not found."); + + using var playwright = await Microsoft.Playwright.Playwright.CreateAsync().ConfigureAwait(false); + await using var browser = await playwright.Chromium.ConnectOverCDPAsync(session.CdpEndpoint).ConfigureAwait(false); + var page = await GetPrimaryPageAsync(browser).ConfigureAwait(false); + return await page.EvaluateAsync(script).ConfigureAwait(false); + } + + private static IBrowserType ResolveBrowserType(IPlaywright playwright, string browserType) + => browserType.Equals("chromium", StringComparison.OrdinalIgnoreCase) + ? playwright.Chromium + : throw new NotSupportedException($"Browser type '{browserType}' is not supported in CP1. Use 'chromium'."); + + private async Task TryGetProfileSessionAsync(string profileName, CancellationToken ct) + { + var path = BrowserProfilePaths.SessionFile(_paths, profileName); + if (!File.Exists(path)) + return null; + + var session = await ReadSessionFileAsync(path, ct).ConfigureAwait(false); + if (session is null || !IsProcessRunning(session.Pid)) + { + if (File.Exists(path)) + File.Delete(path); + return null; + } + + return session; + } + + private Process StartBrowserProcess(string executablePath, string userDataDir, int port, BrowserLaunchOptions options) + { + var startInfo = new ProcessStartInfo(executablePath) + { + UseShellExecute = false, + CreateNoWindow = options.Headless, + }; + + startInfo.ArgumentList.Add($"--remote-debugging-port={port}"); + startInfo.ArgumentList.Add($"--user-data-dir={userDataDir}"); + startInfo.ArgumentList.Add("--no-first-run"); + startInfo.ArgumentList.Add("--no-default-browser-check"); + startInfo.ArgumentList.Add("--disable-popup-blocking"); + if (options.Headless) + { + startInfo.ArgumentList.Add("--headless=new"); + startInfo.ArgumentList.Add("--disable-gpu"); + } + else + { + startInfo.ArgumentList.Add("--start-maximized"); + } + + var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start the Chromium process."); + return process; + } + + private async Task WaitForCdpEndpointAsync(int port, CancellationToken ct) + { + using var client = _httpFactory.Create(); + + for (var attempt = 0; attempt < 60; attempt++) + { + ct.ThrowIfCancellationRequested(); + + try + { + var payload = await client.GetStringAsync($"http://127.0.0.1:{port}/json/version", ct).ConfigureAwait(false); + using var document = JsonDocument.Parse(payload); + if (document.RootElement.TryGetProperty("webSocketDebuggerUrl", out var endpoint)) + { + var value = endpoint.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + } + catch (HttpRequestException) + { + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + } + + await Task.Delay(500, ct).ConfigureAwait(false); + } + + throw new TimeoutException("Timed out waiting for Chromium's CDP endpoint."); + } + + private async Task WriteSessionAsync(BrowserSession session, CancellationToken ct) + { + var path = BrowserProfilePaths.SessionFile(_paths, session.ProfileName); + await JsonFile.WriteAtomicAsync(path, session, ct).ConfigureAwait(false); + } + + private async Task DeleteSessionFileAsync(string profileName, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var path = BrowserProfilePaths.SessionFile(_paths, profileName); + if (File.Exists(path)) + File.Delete(path); + } + + private static async Task ReadSessionFileAsync(string path, CancellationToken ct) + { + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, SessionSerializerOptions, ct).ConfigureAwait(false); + } + + private static async Task GetPrimaryPageAsync(IBrowser browser) + { + var context = browser.Contexts.FirstOrDefault() + ?? throw new InvalidOperationException("Connected browser did not expose a default context."); + + return context.Pages.FirstOrDefault() ?? await context.NewPageAsync().ConfigureAwait(false); + } + + private static bool IsProcessRunning(int pid) + { + try + { + var process = Process.GetProcessById(pid); + return !process.HasExited; + } + catch + { + return false; + } + } + + private static void TryKillProcess(int pid) + { + try + { + using var process = Process.GetProcessById(pid); + if (!process.HasExited) + process.Kill(true); + } + catch + { + } + } + + private static int ReservePort() + { + using var listener = new TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + } +} diff --git a/src/TALXIS.CLI.Platform.Playwright/DependencyInjection/PlaywrightServiceCollectionExtensions.cs b/src/TALXIS.CLI.Platform.Playwright/DependencyInjection/PlaywrightServiceCollectionExtensions.cs new file mode 100644 index 00000000..da56e79a --- /dev/null +++ b/src/TALXIS.CLI.Platform.Playwright/DependencyInjection/PlaywrightServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; +using TALXIS.CLI.Core.Browser; + +namespace TALXIS.CLI.Platform.Playwright.DependencyInjection; + +public static class PlaywrightServiceCollectionExtensions +{ + public static IServiceCollection AddTxcPlaywright(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + return services; + } +} diff --git a/src/TALXIS.CLI.Platform.Playwright/ReAuthDialogWatcher.cs b/src/TALXIS.CLI.Platform.Playwright/ReAuthDialogWatcher.cs new file mode 100644 index 00000000..28bae700 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Playwright/ReAuthDialogWatcher.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace TALXIS.CLI.Platform.Playwright; + +public sealed class ReAuthDialogWatcher : IAsyncDisposable +{ + private readonly ILogger _logger; + private readonly TimeSpan _pollInterval; + private readonly TimeSpan _recoveryDelay; + private CancellationTokenSource? _cts; + private Task? _backgroundTask; + + public ReAuthDialogWatcher( + ILogger logger, + TimeSpan? pollInterval = null, + TimeSpan? recoveryDelay = null) + { + _logger = logger; + _pollInterval = pollInterval ?? TimeSpan.FromSeconds(5); + _recoveryDelay = recoveryDelay ?? TimeSpan.FromSeconds(1); + } + + public Task StartAsync(IPage page, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(page); + + if (_backgroundTask is not null) + return Task.CompletedTask; + + _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _backgroundTask = RunAsync(page, _cts.Token); + return Task.CompletedTask; + } + + public async Task StopAsync() + { + if (_cts is null) + return; + + _cts.Cancel(); + if (_backgroundTask is not null) + { + try + { + await _backgroundTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + _cts.Dispose(); + _cts = null; + _backgroundTask = null; + } + + public async ValueTask DisposeAsync() + { + await StopAsync().ConfigureAwait(false); + } + + private async Task RunAsync(IPage page, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + await Task.Delay(_pollInterval, ct).ConfigureAwait(false); + + try + { + var dialog = page.GetByRole(AriaRole.Dialog, new PageGetByRoleOptions { Name = "Sign in to continue" }); + var visible = await dialog.IsVisibleAsync().ConfigureAwait(false); + if (!visible) + continue; + + await dialog.GetByRole(AriaRole.Button, new LocatorGetByRoleOptions { Name = "Close" }) + .ClickAsync(new LocatorClickOptions { Timeout = 1000 }).ConfigureAwait(false); + await Task.Delay(_recoveryDelay, ct).ConfigureAwait(false); + _logger.LogInformation("Dismissed 'Sign in to continue' dialog"); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Re-auth dialog watcher polling iteration failed."); + } + } + } +} diff --git a/src/TALXIS.CLI.Platform.Playwright/SETUP.md b/src/TALXIS.CLI.Platform.Playwright/SETUP.md new file mode 100644 index 00000000..56a97e0a --- /dev/null +++ b/src/TALXIS.CLI.Platform.Playwright/SETUP.md @@ -0,0 +1,19 @@ +# Playwright setup for `TALXIS.CLI.Platform.Playwright` + +Before running the Playwright-backed tests or `txc ui` commands on a fresh machine, install Chromium for Playwright: + +```bash +npx playwright install chromium +``` + +If your CI agent does not already have the OS dependencies that Playwright needs, install them together with the browser binaries: + +```bash +npx playwright install --with-deps chromium +``` + +## CI notes + +- Cache `~/.cache/ms-playwright/` to avoid downloading browser binaries on every run. +- `PLAYWRIGHT_BROWSERS_PATH` can be used to override the browser cache location. +- If test bootstrap ever needs to install browsers programmatically, use `Microsoft.Playwright.Program.Main(new[] { "install", "chromium" })` in setup code rather than ad-hoc shell logic. diff --git a/src/TALXIS.CLI.Platform.Playwright/SessionRecoveryService.cs b/src/TALXIS.CLI.Platform.Playwright/SessionRecoveryService.cs new file mode 100644 index 00000000..3875c1d9 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Playwright/SessionRecoveryService.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace TALXIS.CLI.Platform.Playwright; + +public sealed class SessionRecoveryService +{ + private readonly ILogger _logger; + + public SessionRecoveryService(ILogger logger) + { + _logger = logger; + } + + public async Task CheckAndRecoverAsync(IPage page, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(page); + + if (string.IsNullOrWhiteSpace(page.Url) || !page.Url.Contains("errorhandler.aspx", StringComparison.OrdinalIgnoreCase)) + return false; + + try + { + var safeUrl = TryBuildRecoveryUrl(page.Url); + if (safeUrl is null) + return false; + + await page.EvaluateAsync("() => { try { sessionStorage.clear(); } catch {} }").ConfigureAwait(false); + await page.GotoAsync( + safeUrl, + new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 60000 }).ConfigureAwait(false); + await page.WaitForURLAsync("**/main.aspx**", new PageWaitForURLOptions { Timeout = 30000 }).ConfigureAwait(false); + await page.Locator("[role='menuitem']").First.WaitForAsync( + new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30000 }).ConfigureAwait(false); + + _logger.LogInformation("Recovered from errorhandler.aspx — navigated to {SafeUrl}", safeUrl); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to recover from errorhandler.aspx."); + return false; + } + } + + internal static string? TryBuildRecoveryUrl(string currentUrl) + { + if (!Uri.TryCreate(currentUrl, UriKind.Absolute, out var currentUri)) + return null; + + var query = ParseQuery(currentUri.Query); + if (!query.TryGetValue("BackUri", out var backUriRaw) || string.IsNullOrWhiteSpace(backUriRaw)) + return null; + + var decodedBackUri = Uri.UnescapeDataString(backUriRaw); + if (!Uri.TryCreate(decodedBackUri, UriKind.Absolute, out var backUri)) + return null; + + var backQuery = ParseQuery(backUri.Query); + if (!backQuery.TryGetValue("appid", out var appId) || string.IsNullOrWhiteSpace(appId)) + return null; + + return $"{currentUri.GetLeftPart(UriPartial.Authority)}/main.aspx?appid={Uri.EscapeDataString(appId)}"; + } + + internal static Dictionary ParseQuery(string query) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var separator = pair.IndexOf('='); + if (separator < 0) + continue; + + result[pair[..separator]] = pair[(separator + 1)..]; + } + + return result; + } +} diff --git a/src/TALXIS.CLI.Platform.Playwright/StorageStateManager.cs b/src/TALXIS.CLI.Platform.Playwright/StorageStateManager.cs new file mode 100644 index 00000000..301dc7eb --- /dev/null +++ b/src/TALXIS.CLI.Platform.Playwright/StorageStateManager.cs @@ -0,0 +1,110 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Playwright; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; + +namespace TALXIS.CLI.Platform.Playwright; + +public sealed class StorageStateManager +{ + private readonly ConfigPaths _paths; + private readonly ICredentialVault _vault; + + public StorageStateManager(ConfigPaths paths, ICredentialVault vault) + { + _paths = paths; + _vault = vault; + } + + public async Task SaveAsync(IBrowserContext context, string profileName, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(context); + + var json = await context.StorageStateAsync().ConfigureAwait(false); + var key = await GetOrCreateKeyAsync(profileName, ct).ConfigureAwait(false); + var nonce = RandomNumberGenerator.GetBytes(12); + var plainBytes = Encoding.UTF8.GetBytes(json); + var cipherBytes = new byte[plainBytes.Length]; + var tag = new byte[16]; + + using (var aes = new AesGcm(key, 16)) + { + aes.Encrypt(nonce, plainBytes, cipherBytes, tag); + } + + var envelope = new StorageStateEnvelope + { + Nonce = Convert.ToBase64String(nonce), + CipherText = Convert.ToBase64String(cipherBytes), + Tag = Convert.ToBase64String(tag), + }; + + var path = BrowserProfilePaths.StorageStateFile(_paths, profileName); + await JsonFile.WriteAtomicAsync(path, envelope, ct).ConfigureAwait(false); + } + + public async Task LoadAsync(string profileName, CancellationToken ct) + { + var path = BrowserProfilePaths.StorageStateFile(_paths, profileName); + if (!File.Exists(path)) + return null; + + var key = await GetOrCreateKeyAsync(profileName, ct).ConfigureAwait(false); + var envelope = await JsonFile.ReadOrDefaultAsync(path, ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(envelope.Nonce) + || string.IsNullOrWhiteSpace(envelope.CipherText) + || string.IsNullOrWhiteSpace(envelope.Tag)) + { + return null; + } + + var nonce = Convert.FromBase64String(envelope.Nonce); + var cipherBytes = Convert.FromBase64String(envelope.CipherText); + var tag = Convert.FromBase64String(envelope.Tag); + var plainBytes = new byte[cipherBytes.Length]; + + using (var aes = new AesGcm(key, 16)) + { + aes.Decrypt(nonce, cipherBytes, tag, plainBytes); + } + + return Encoding.UTF8.GetString(plainBytes); + } + + public Task ExistsAsync(string profileName, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(File.Exists(BrowserProfilePaths.StorageStateFile(_paths, profileName))); + } + + public async Task DeleteAsync(string profileName, CancellationToken ct) + { + var path = BrowserProfilePaths.StorageStateFile(_paths, profileName); + if (File.Exists(path)) + File.Delete(path); + + await _vault.DeleteSecretAsync(BrowserProfilePaths.StorageStateKeyRef(profileName), ct).ConfigureAwait(false); + } + + private async Task GetOrCreateKeyAsync(string profileName, CancellationToken ct) + { + var secretRef = BrowserProfilePaths.StorageStateKeyRef(profileName); + var current = await _vault.GetSecretAsync(secretRef, ct).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(current)) + return Convert.FromBase64String(current); + + var key = RandomNumberGenerator.GetBytes(32); + await _vault.SetSecretAsync(secretRef, Convert.ToBase64String(key), ct).ConfigureAwait(false); + return key; + } + + private sealed class StorageStateEnvelope + { + public string? Nonce { get; set; } + public string? CipherText { get; set; } + public string? Tag { get; set; } + } +} diff --git a/src/TALXIS.CLI.Platform.Playwright/TALXIS.CLI.Platform.Playwright.csproj b/src/TALXIS.CLI.Platform.Playwright/TALXIS.CLI.Platform.Playwright.csproj new file mode 100644 index 00000000..17550555 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Playwright/TALXIS.CLI.Platform.Playwright.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/src/TALXIS.CLI/TALXIS.CLI.csproj b/src/TALXIS.CLI/TALXIS.CLI.csproj index 293c33d2..d102c5ec 100644 --- a/src/TALXIS.CLI/TALXIS.CLI.csproj +++ b/src/TALXIS.CLI/TALXIS.CLI.csproj @@ -8,6 +8,7 @@ + diff --git a/src/TALXIS.CLI/TxcCliCommand.cs b/src/TALXIS.CLI/TxcCliCommand.cs index 1c705bfb..28c55b01 100644 --- a/src/TALXIS.CLI/TxcCliCommand.cs +++ b/src/TALXIS.CLI/TxcCliCommand.cs @@ -4,7 +4,7 @@ namespace TALXIS.CLI; [CliCommand( Description = "Tool for automating development loops in Power Platform", - Children = new[] { typeof(TALXIS.CLI.Features.Data.DataCliCommand), typeof(TALXIS.CLI.Features.Environment.EnvironmentCliCommand), typeof(TALXIS.CLI.Features.Workspace.WorkspaceCliCommand), typeof(TALXIS.CLI.Features.Config.ConfigCliCommand), typeof(TALXIS.CLI.Features.Docs.DocsCliCommand), typeof(TALXIS.CLI.Component.ComponentCliCommand) }, + Children = new[] { typeof(TALXIS.CLI.Features.Data.DataCliCommand), typeof(TALXIS.CLI.Features.Environment.EnvironmentCliCommand), typeof(TALXIS.CLI.Features.Workspace.WorkspaceCliCommand), typeof(TALXIS.CLI.Features.Config.ConfigCliCommand), typeof(TALXIS.CLI.Features.Docs.DocsCliCommand), typeof(TALXIS.CLI.Component.ComponentCliCommand), typeof(TALXIS.CLI.Features.Ui.UiCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class TxcCliCommand diff --git a/tests/TALXIS.CLI.Features.Ui.Tests/CliRunner.cs b/tests/TALXIS.CLI.Features.Ui.Tests/CliRunner.cs new file mode 100644 index 00000000..738e2f50 --- /dev/null +++ b/tests/TALXIS.CLI.Features.Ui.Tests/CliRunner.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; + +namespace TALXIS.CLI.Features.Ui.Tests; + +public record CliResult(int ExitCode, string Output, string Error); + +public static class CliRunner +{ + private static readonly string CliProject = TestExecutionContext.GetProjectPath("src", "TALXIS.CLI", "TALXIS.CLI.csproj"); + + public static Task RunAsync(string[] args) + => RunAsync(args, null); + + public static async Task RunAsync(string[] args, IReadOnlyDictionary? env) + { + var result = await RunRawAsync(args, env); + if (result.ExitCode != 0) + throw new InvalidOperationException($"CLI command failed: {string.Join(' ', args)}\n{result.Error}\n{result.Output}"); + + return result.Output; + } + + public static async Task RunRawAsync(string[] args, IReadOnlyDictionary? env = null) + { + var psi = new ProcessStartInfo("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Directory.GetCurrentDirectory(), + }; + + psi.ArgumentList.Add("run"); + psi.ArgumentList.Add("--project"); + psi.ArgumentList.Add(CliProject); + psi.ArgumentList.Add("--configuration"); + psi.ArgumentList.Add(TestExecutionContext.BuildConfiguration); + psi.ArgumentList.Add("--no-build"); + psi.ArgumentList.Add("--"); + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + if (env is not null) + { + foreach (var kvp in env) + { + if (kvp.Value is null) + psi.Environment.Remove(kvp.Key); + else + psi.Environment[kvp.Key] = kvp.Value; + } + } + + using var process = Process.Start(psi)!; + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return new CliResult(process.ExitCode, await outputTask, await errorTask); + } +} diff --git a/tests/TALXIS.CLI.Features.Ui.Tests/TALXIS.CLI.Features.Ui.Tests.csproj b/tests/TALXIS.CLI.Features.Ui.Tests/TALXIS.CLI.Features.Ui.Tests.csproj new file mode 100644 index 00000000..4b8d817e --- /dev/null +++ b/tests/TALXIS.CLI.Features.Ui.Tests/TALXIS.CLI.Features.Ui.Tests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/TALXIS.CLI.Features.Ui.Tests/TestAssembly.cs b/tests/TALXIS.CLI.Features.Ui.Tests/TestAssembly.cs new file mode 100644 index 00000000..21712008 --- /dev/null +++ b/tests/TALXIS.CLI.Features.Ui.Tests/TestAssembly.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/TALXIS.CLI.Features.Ui.Tests/TestExecutionContext.cs b/tests/TALXIS.CLI.Features.Ui.Tests/TestExecutionContext.cs new file mode 100644 index 00000000..3eec31fd --- /dev/null +++ b/tests/TALXIS.CLI.Features.Ui.Tests/TestExecutionContext.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; + +namespace TALXIS.CLI.Features.Ui.Tests; + +internal static class TestExecutionContext +{ + private static readonly Lazy BuildConfigurationValue = new(ResolveBuildConfiguration); + private static readonly Lazy RepositoryRootValue = new(ResolveRepositoryRoot); + + public static string BuildConfiguration => BuildConfigurationValue.Value; + public static string RepositoryRoot => RepositoryRootValue.Value; + + public static string GetProjectPath(params string[] relativePathSegments) + { + var path = RepositoryRoot; + foreach (var segment in relativePathSegments) + path = Path.Combine(path, segment); + return path; + } + + private static string ResolveBuildConfiguration() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if (string.Equals(directory.Parent?.Name, "bin", StringComparison.OrdinalIgnoreCase)) + return directory.Name; + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not determine build configuration from AppContext.BaseDirectory"); + } + + private static string ResolveRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "TALXIS.CLI.sln"))) + directory = directory.Parent; + + if (directory == null) + throw new InvalidOperationException("Could not find repository root"); + + return directory.FullName; + } +} diff --git a/tests/TALXIS.CLI.Features.Ui.Tests/UiCliIntegrationTests.cs b/tests/TALXIS.CLI.Features.Ui.Tests/UiCliIntegrationTests.cs new file mode 100644 index 00000000..cadc0dd1 --- /dev/null +++ b/tests/TALXIS.CLI.Features.Ui.Tests/UiCliIntegrationTests.cs @@ -0,0 +1,15 @@ +using Xunit; + +namespace TALXIS.CLI.Features.Ui.Tests; + +public class UiCliIntegrationTests +{ + [Fact] + public async Task UiHelp_ShowsSessionAndBrowserSubcommands() + { + var output = await CliRunner.RunAsync(["ui", "--help"]); + + Assert.Contains("session", output); + Assert.Contains("browser", output); + } +} diff --git a/tests/TALXIS.CLI.Features.Ui.Tests/UiCommandTestHost.cs b/tests/TALXIS.CLI.Features.Ui.Tests/UiCommandTestHost.cs new file mode 100644 index 00000000..ab3d5263 --- /dev/null +++ b/tests/TALXIS.CLI.Features.Ui.Tests/UiCommandTestHost.cs @@ -0,0 +1,104 @@ +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; +using TALXIS.CLI.Core.Browser; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; + +namespace TALXIS.CLI.Features.Ui.Tests; + +internal sealed class UiCommandTestHost : IDisposable +{ + public UiCommandTestHost() + { + BrowserManager = new FakeBrowserSessionManager(); + Resolver = new FakeConfigurationResolver(); + + var services = new ServiceCollection(); + services.AddSingleton(Resolver); + services.AddSingleton(BrowserManager); + Provider = services.BuildServiceProvider(); + + TxcServices.Reset(); + TxcServices.Initialize(Provider); + } + + public ServiceProvider Provider { get; } + public FakeBrowserSessionManager BrowserManager { get; } + public FakeConfigurationResolver Resolver { get; } + + public void Dispose() + { + TxcServices.Reset(); + Provider.Dispose(); + TALXIS.CLI.Core.OutputContext.Reset(); + } + + internal sealed class FakeConfigurationResolver : TALXIS.CLI.Core.Abstractions.IConfigurationResolver + { + public Task ResolveAsync(string? profileName, CancellationToken ct) + { + var profileId = profileName ?? "default-profile"; + return Task.FromResult(new ResolvedProfileContext( + new Profile { Id = profileId, ConnectionRef = "conn", CredentialRef = "cred" }, + new Connection + { + Id = "conn", + Provider = ProviderKind.Dataverse, + EnvironmentId = Guid.Parse("11111111-1111-1111-1111-111111111111"), + EnvironmentUrl = "https://contoso.crm4.dynamics.com/", + TenantId = "tenant" + }, + new Credential { Id = "cred", Kind = CredentialKind.InteractiveBrowser }, + ResolutionSource.CommandLine)); + } + } + + internal sealed class FakeBrowserSessionManager : IBrowserSessionManager + { + public BrowserLaunchOptions? LastLaunchOptions { get; private set; } + public BrowserSession Session { get; set; } = new( + "session-1", + "dev", + "ws://127.0.0.1:9222/devtools/browser/test", + "https://contoso.crm4.dynamics.com/main.aspx?appid=test", + DateTime.UtcNow, + 1234, + false, + "chromium", + "/tmp/user-data"); + + public JsonElement EvalResult { get; set; } = JsonDocument.Parse("\"hello\"").RootElement.Clone(); + + public Task LaunchAsync(BrowserLaunchOptions options, CancellationToken ct) + { + LastLaunchOptions = options; + Session = Session with + { + ProfileName = options.ProfileName, + AppUrl = options.AppUrl, + Headless = options.Headless, + }; + return Task.FromResult(Session); + } + + public Task AttachAsync(string cdpEndpoint, CancellationToken ct) + => Task.FromResult(Session); + + public Task CloseAsync(string sessionId, CancellationToken ct) => Task.CompletedTask; + + public Task GetActiveSessionAsync(CancellationToken ct) + => Task.FromResult(Session); + + public Task GetSessionAsync(string sessionId, CancellationToken ct) + => Task.FromResult(Session); + + public Task> ListSessionsAsync(CancellationToken ct) + => Task.FromResult>(new[] { Session }); + + public Task GetCurrentUrlAsync(string sessionId, CancellationToken ct) + => Task.FromResult(Session.AppUrl); + + public Task EvaluateAsync(string sessionId, string script, CancellationToken ct) + => Task.FromResult(EvalResult); + } +} diff --git a/tests/TALXIS.CLI.Features.Ui.Tests/UiCommandTests.cs b/tests/TALXIS.CLI.Features.Ui.Tests/UiCommandTests.cs new file mode 100644 index 00000000..9441a448 --- /dev/null +++ b/tests/TALXIS.CLI.Features.Ui.Tests/UiCommandTests.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using TALXIS.CLI.Core; +using TALXIS.CLI.Features.Ui.Browser; +using TALXIS.CLI.Features.Ui.Session; +using Xunit; + +namespace TALXIS.CLI.Features.Ui.Tests; + +public class UiCommandTests +{ + [Fact] + public async Task SessionOpen_WithTypeAndParams_BuildsUrlAndLaunchesBrowserSession() + { + using var host = new UiCommandTestHost(); + using var writer = new StringWriter(); + using var redirect = OutputWriter.RedirectTo(writer); + + var command = new UiSessionOpenCliCommand + { + Type = "AppModule", + Param = ["name=WarehouseApp"], + Profile = "dev-profile", + Format = "json", + }; + + var exitCode = await command.RunAsync(); + + Assert.Equal(0, exitCode); + Assert.NotNull(host.BrowserManager.LastLaunchOptions); + Assert.Equal("dev-profile", host.BrowserManager.LastLaunchOptions!.ProfileName); + Assert.Contains("appname=WarehouseApp", host.BrowserManager.LastLaunchOptions.AppUrl); + Assert.Contains("\"profileName\": \"dev-profile\"", writer.ToString()); + } + + [Fact] + public async Task SessionStatus_WritesCurrentSessionData() + { + using var host = new UiCommandTestHost(); + using var writer = new StringWriter(); + using var redirect = OutputWriter.RedirectTo(writer); + + var command = new UiSessionStatusCliCommand { Format = "json" }; + var exitCode = await command.RunAsync(); + + Assert.Equal(0, exitCode); + Assert.Contains("\"id\": \"session-1\"", writer.ToString()); + Assert.Contains("\"appUrl\": \"https://contoso.crm4.dynamics.com/main.aspx?appid=test\"", writer.ToString()); + } + + [Fact] + public async Task BrowserEval_WritesJsonAndTextFriendlyOutput() + { + using var host = new UiCommandTestHost(); + host.BrowserManager.EvalResult = JsonDocument.Parse("\"Document Title\"").RootElement.Clone(); + using var writer = new StringWriter(); + using var redirect = OutputWriter.RedirectTo(writer); + + var command = new UiBrowserEvalCliCommand { Eval = "document.title", Format = "json" }; + var exitCode = await command.RunAsync(); + + Assert.Equal(0, exitCode); + Assert.Equal("\"Document Title\"" + System.Environment.NewLine, writer.ToString()); + } +} diff --git a/tests/TALXIS.CLI.Platform.Playwright.Tests/BrowserSessionManagerTests.cs b/tests/TALXIS.CLI.Platform.Playwright.Tests/BrowserSessionManagerTests.cs new file mode 100644 index 00000000..8a30c3f3 --- /dev/null +++ b/tests/TALXIS.CLI.Platform.Playwright.Tests/BrowserSessionManagerTests.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Playwright; +using Moq; +using TALXIS.CLI.Core.Storage; +using Xunit; + +namespace TALXIS.CLI.Platform.Playwright.Tests; + +public class BrowserSessionManagerTests +{ + [Fact] + public void BrowserProfilePaths_UsesProfileScopedDirectories() + { + var paths = new ConfigPaths("/tmp/txc-tests"); + + var profileDirectory = BrowserProfilePaths.ProfileDirectory(paths, "dev/profile"); + var userDataDirectory = BrowserProfilePaths.UserDataDirectory(paths, "dev/profile"); + var sessionFile = BrowserProfilePaths.SessionFile(paths, "dev/profile"); + + Assert.Equal(Path.Combine("/tmp/txc-tests", "browser", "dev-profile"), profileDirectory); + Assert.Equal(Path.Combine("/tmp/txc-tests", "browser", "dev-profile", "user-data"), userDataDirectory); + Assert.Equal(Path.Combine("/tmp/txc-tests", "browser", "dev-profile", "session.json"), sessionFile); + } + + [Fact] + public async Task LaunchAsync_WritesSessionFile_WhenChromiumIsAvailable() + { + using var playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + if (string.IsNullOrWhiteSpace(playwright.Chromium.ExecutablePath) || !File.Exists(playwright.Chromium.ExecutablePath)) + return; + + using var temp = new TempConfigDir(); + var storageStateManager = new StorageStateManager(temp.Paths, new StorageStateManagerTests.FakeVault()); + var context = new Mock(); + context.Setup(value => value.StorageStateAsync()).ReturnsAsync("{\"cookies\":[],\"origins\":[]}"); + await storageStateManager.SaveAsync(context.Object, "integration", CancellationToken.None); + + var manager = new BrowserSessionManager( + temp.Paths, + storageStateManager, + new SessionRecoveryService(NullLogger.Instance), + NullLogger.Instance, + NullLoggerFactory.Instance); + + var session = await manager.LaunchAsync( + new TALXIS.CLI.Core.Browser.BrowserLaunchOptions("integration", "about:blank", Headless: true), + CancellationToken.None); + + Assert.True(File.Exists(BrowserProfilePaths.SessionFile(temp.Paths, "integration"))); + Assert.True(session.Headless); + + await manager.CloseAsync(session.Id, CancellationToken.None); + } + + private sealed class TempConfigDir : IDisposable + { + public TempConfigDir() + { + Root = Path.Combine(Path.GetTempPath(), "txc-browser-session-tests-" + Path.GetRandomFileName()); + Directory.CreateDirectory(Root); + Paths = new ConfigPaths(Root); + } + + public string Root { get; } + public ConfigPaths Paths { get; } + + public void Dispose() + { + try { Directory.Delete(Root, recursive: true); } catch { } + } + } +} diff --git a/tests/TALXIS.CLI.Platform.Playwright.Tests/ReAuthDialogWatcherTests.cs b/tests/TALXIS.CLI.Platform.Playwright.Tests/ReAuthDialogWatcherTests.cs new file mode 100644 index 00000000..0767a245 --- /dev/null +++ b/tests/TALXIS.CLI.Platform.Playwright.Tests/ReAuthDialogWatcherTests.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Playwright; +using Moq; +using Xunit; + +namespace TALXIS.CLI.Platform.Playwright.Tests; + +public class ReAuthDialogWatcherTests +{ + [Fact] + public async Task StartAsync_ClicksCloseWhenDialogIsVisible() + { + var page = new Mock(); + var dialog = new Mock(); + var closeButton = new Mock(); + + page.Setup(value => value.GetByRole(AriaRole.Dialog, It.IsAny())) + .Returns(dialog.Object); + dialog.Setup(value => value.IsVisibleAsync()).ReturnsAsync(true); + dialog.Setup(value => value.GetByRole(AriaRole.Button, It.IsAny())) + .Returns(closeButton.Object); + closeButton.Setup(value => value.ClickAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + await using var watcher = new ReAuthDialogWatcher( + NullLogger.Instance, + pollInterval: TimeSpan.FromMilliseconds(10), + recoveryDelay: TimeSpan.FromMilliseconds(1)); + + await watcher.StartAsync(page.Object, CancellationToken.None); + await Task.Delay(40); + await watcher.StopAsync(); + + closeButton.Verify(value => value.ClickAsync(It.IsAny()), Times.AtLeastOnce); + } + + [Fact] + public async Task StopAsync_CancelsPollingLoopCleanly() + { + var page = new Mock(); + var dialog = new Mock(); + page.Setup(value => value.GetByRole(AriaRole.Dialog, It.IsAny())) + .Returns(dialog.Object); + dialog.Setup(value => value.IsVisibleAsync()).ReturnsAsync(false); + + await using var watcher = new ReAuthDialogWatcher( + NullLogger.Instance, + pollInterval: TimeSpan.FromMilliseconds(10), + recoveryDelay: TimeSpan.FromMilliseconds(1)); + + await watcher.StartAsync(page.Object, CancellationToken.None); + await Task.Delay(20); + await watcher.StopAsync(); + + Assert.True(true); + } +} diff --git a/tests/TALXIS.CLI.Platform.Playwright.Tests/SessionRecoveryServiceTests.cs b/tests/TALXIS.CLI.Platform.Playwright.Tests/SessionRecoveryServiceTests.cs new file mode 100644 index 00000000..bfecc420 --- /dev/null +++ b/tests/TALXIS.CLI.Platform.Playwright.Tests/SessionRecoveryServiceTests.cs @@ -0,0 +1,17 @@ +using Xunit; + +namespace TALXIS.CLI.Platform.Playwright.Tests; + +public class SessionRecoveryServiceTests +{ + [Fact] + public void TryBuildRecoveryUrl_ExtractsSafeMainUrl() + { + var currentUrl = "https://org.crm4.dynamics.com/errorhandler.aspx?BackUri=" + + Uri.EscapeDataString("https://org.crm4.dynamics.com/main.aspx?appid=warehouse-app"); + + var safeUrl = SessionRecoveryService.TryBuildRecoveryUrl(currentUrl); + + Assert.Equal("https://org.crm4.dynamics.com/main.aspx?appid=warehouse-app", safeUrl); + } +} diff --git a/tests/TALXIS.CLI.Platform.Playwright.Tests/StorageStateManagerTests.cs b/tests/TALXIS.CLI.Platform.Playwright.Tests/StorageStateManagerTests.cs new file mode 100644 index 00000000..45a51d15 --- /dev/null +++ b/tests/TALXIS.CLI.Platform.Playwright.Tests/StorageStateManagerTests.cs @@ -0,0 +1,84 @@ +using Microsoft.Playwright; +using Moq; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Core.Storage; +using Xunit; + +namespace TALXIS.CLI.Platform.Playwright.Tests; + +public class StorageStateManagerTests +{ + [Fact] + public async Task SaveAndLoad_RoundTripsEncryptedState() + { + using var temp = new TempConfigDir(); + var vault = new FakeVault(); + var manager = new StorageStateManager(temp.Paths, vault); + var context = new Mock(); + context.Setup(value => value.StorageStateAsync()).ReturnsAsync("{\"cookies\":[],\"origins\":[]}"); + + await manager.SaveAsync(context.Object, "dev-profile", CancellationToken.None); + + var loaded = await manager.LoadAsync("dev-profile", CancellationToken.None); + + Assert.Equal("{\"cookies\":[],\"origins\":[]}", loaded); + Assert.True(await manager.ExistsAsync("dev-profile", CancellationToken.None)); + Assert.NotEmpty(vault.Contents); + } + + [Fact] + public async Task LoadAsync_ReturnsNullWhenStateFileDoesNotExist() + { + using var temp = new TempConfigDir(); + var manager = new StorageStateManager(temp.Paths, new FakeVault()); + + var loaded = await manager.LoadAsync("missing", CancellationToken.None); + + Assert.Null(loaded); + Assert.False(await manager.ExistsAsync("missing", CancellationToken.None)); + } + + private sealed class TempConfigDir : IDisposable + { + public TempConfigDir() + { + Root = Path.Combine(Path.GetTempPath(), "txc-playwright-tests-" + Path.GetRandomFileName()); + Directory.CreateDirectory(Root); + Paths = new ConfigPaths(Root); + } + + public string Root { get; } + public ConfigPaths Paths { get; } + + public void Dispose() + { + try { Directory.Delete(Root, recursive: true); } catch { } + } + } + + internal sealed class FakeVault : ICredentialVault + { + private readonly Dictionary _store = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyDictionary Contents => _store; + + public Task GetSecretAsync(SecretRef reference, CancellationToken ct) + => Task.FromResult(_store.TryGetValue(reference.Uri, out var value) ? value : null); + + public Task SetSecretAsync(SecretRef reference, string value, CancellationToken ct) + { + _store[reference.Uri] = value; + return Task.CompletedTask; + } + + public Task DeleteSecretAsync(SecretRef reference, CancellationToken ct) + => Task.FromResult(_store.Remove(reference.Uri)); + + public Task ClearAsync(CancellationToken ct) + { + _store.Clear(); + return Task.CompletedTask; + } + } +} diff --git a/tests/TALXIS.CLI.Platform.Playwright.Tests/TALXIS.CLI.Platform.Playwright.Tests.csproj b/tests/TALXIS.CLI.Platform.Playwright.Tests/TALXIS.CLI.Platform.Playwright.Tests.csproj new file mode 100644 index 00000000..d68e1631 --- /dev/null +++ b/tests/TALXIS.CLI.Platform.Playwright.Tests/TALXIS.CLI.Platform.Playwright.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/TALXIS.CLI.Platform.Playwright.Tests/TestAssembly.cs b/tests/TALXIS.CLI.Platform.Playwright.Tests/TestAssembly.cs new file mode 100644 index 00000000..21712008 --- /dev/null +++ b/tests/TALXIS.CLI.Platform.Playwright.Tests/TestAssembly.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/TALXIS.CLI.Tests/Architecture/CommandConventionTests.cs b/tests/TALXIS.CLI.Tests/Architecture/CommandConventionTests.cs index 808339e7..9f5f23cc 100644 --- a/tests/TALXIS.CLI.Tests/Architecture/CommandConventionTests.cs +++ b/tests/TALXIS.CLI.Tests/Architecture/CommandConventionTests.cs @@ -26,6 +26,7 @@ public class CommandConventionTests typeof(TALXIS.CLI.Features.Environment.EnvironmentCliCommand).Assembly, typeof(TALXIS.CLI.Features.Workspace.WorkspaceCliCommand).Assembly, typeof(TALXIS.CLI.Features.Data.DataCliCommand).Assembly, + typeof(TALXIS.CLI.Features.Ui.UiCliCommand).Assembly, typeof(TALXIS.CLI.TxcCliCommand).Assembly, }; diff --git a/tests/TALXIS.CLI.Tests/Architecture/LayeringTests.cs b/tests/TALXIS.CLI.Tests/Architecture/LayeringTests.cs index 13ccc371..8d1a28d5 100644 --- a/tests/TALXIS.CLI.Tests/Architecture/LayeringTests.cs +++ b/tests/TALXIS.CLI.Tests/Architecture/LayeringTests.cs @@ -67,6 +67,7 @@ public void DestructiveCommands_WithYesFlag_MustHaveDestructiveAnnotation() typeof(TALXIS.CLI.Features.Environment.EnvironmentCliCommand).Assembly, typeof(TALXIS.CLI.Features.Workspace.WorkspaceCliCommand).Assembly, typeof(TALXIS.CLI.Features.Data.DataCliCommand).Assembly, + typeof(TALXIS.CLI.Features.Ui.UiCliCommand).Assembly, }; var violations = commandAssemblies