diff --git a/.cspell.yaml b/.cspell.yaml index 2e3a0dc..9f6f9c4 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -13,6 +13,7 @@ language: en words: - APFS - Argb + - assertable - axaml - BGRA - Blockquotes diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 5abbed5..1210d7d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -434,7 +434,6 @@ jobs: env: SYSML2WORKBENCH_APP_PATH: ${{ github.workspace }}\publish\win-x64\DemaConsulting.SysML2Workbench.Desktop.exe SYSML2WORKBENCH_ARTIFACTS_DIR: ${{ github.workspace }}\artifacts - SYSML2WORKBENCH_STARTUP_FILE: ${{ github.workspace }}\test\DemaConsulting.SysML2Workbench.IntegrationTests\TestData\InspectionSample.sysml run: > ./run-under-appium.ps1 -- dotnet test diff --git a/build.ps1 b/build.ps1 index 36ff858..10b2f46 100644 --- a/build.ps1 +++ b/build.ps1 @@ -107,12 +107,10 @@ if ($IntegrationTest) { Write-Host "Running Appium IntegrationTests..." $env:SYSML2WORKBENCH_APP_PATH = Join-Path (Get-Location) "publish/$rid/$exeName" $env:SYSML2WORKBENCH_ARTIFACTS_DIR = Join-Path (Get-Location) "artifacts" - $env:SYSML2WORKBENCH_STARTUP_FILE = Join-Path (Get-Location) "test/DemaConsulting.SysML2Workbench.IntegrationTests/TestData/InspectionSample.sysml" ./run-under-appium.ps1 -- dotnet test test/DemaConsulting.SysML2Workbench.IntegrationTests/DemaConsulting.SysML2Workbench.IntegrationTests.csproj --configuration Release --logger "trx;LogFilePrefix=appium-integration" --results-directory artifacts/tests if ($LASTEXITCODE -ne 0) { $buildError = $true } Remove-Item Env:\SYSML2WORKBENCH_APP_PATH -ErrorAction SilentlyContinue Remove-Item Env:\SYSML2WORKBENCH_ARTIFACTS_DIR -ErrorAction SilentlyContinue - Remove-Item Env:\SYSML2WORKBENCH_STARTUP_FILE -ErrorAction SilentlyContinue } } } diff --git a/docs/reqstream/ots/appium.yaml b/docs/reqstream/ots/appium.yaml index 1b5a999..721c7e4 100644 --- a/docs/reqstream/ots/appium.yaml +++ b/docs/reqstream/ots/appium.yaml @@ -19,13 +19,8 @@ sections: justification: | Appium's accessibility-tree-based lookups need a stable identifier that does not depend on a control''s displayed Name/x:Name, so tests remain reliable as UI text changes. tests: - - 'DesktopApp_FileMenu_AddFileSourceMenuItem_IsDiscoverableAndEnabled' - - 'DesktopApp_FileMenu_AddFolderSourceMenuItem_IsDiscoverableAndEnabled' - - 'DesktopApp_ViewMenu_WorkspacePanelMenuItem_IsDiscoverableAndEnabled' - - 'DesktopApp_ViewMenu_PredefinedViewsMenuItem_IsDiscoverableAndEnabled' - - 'DesktopApp_ViewMenu_DiagnosticsMenuItem_IsDiscoverableAndEnabled' - - 'DesktopApp_ViewMenu_ViewBuilderDialogMenuItem_IsDiscoverableAndEnabled' + - 'DesktopApp_FileMenu_Items_AreDiscoverableAndEnabled' + - 'DesktopApp_ViewMenu_Items_AreDiscoverableAndEnabled' - 'DesktopApp_QueryMenu_QueryDialogMenuItem_IsDiscoverableAndEnabled' - 'DesktopApp_QueryMenu_QueryDialogMenuItem_OpensAndClosesQueryDialog' - 'DesktopApp_HelpMenu_AboutMenuItem_OpensAndClosesAboutDialog' - - 'DesktopApp_FileMenu_CloseAllMenuItem_IsDiscoverableAndEnabled' diff --git a/docs/verification/ots/appium.md b/docs/verification/ots/appium.md index da0b4e5..c03e00f 100644 --- a/docs/verification/ots/appium.md +++ b/docs/verification/ots/appium.md @@ -2,12 +2,12 @@ ### Verification Approach -Integration tests in `test/DemaConsulting.SysML2Workbench.IntegrationTests/MainWindowShellIntegrationTests.cs` -qualify the Windows/NovaWindows Appium session by launching the real, -published `DemaConsulting.SysML2Workbench.Desktop` executable through -`AppFixture` and driving it through its actual accessibility tree, rather -than a mocked or headless UI harness, because the end-to-end platform -integration itself is what this tier is qualifying. These tests only run for +Integration tests in `test/DemaConsulting.SysML2Workbench.IntegrationTests/` (split by feature area into +`MainWindowLaunchIntegrationTests.cs`, `FileMenuIntegrationTests.cs`, `ViewMenuIntegrationTests.cs`, +`QueryDialogIntegrationTests.cs`, and `HelpMenuIntegrationTests.cs`) qualify the Windows/NovaWindows Appium +session by launching the real, published `DemaConsulting.SysML2Workbench.Desktop` executable through +`AppFixture` and driving it through its actual accessibility tree, rather than a mocked or headless UI harness, +because the end-to-end platform integration itself is what this tier is qualifying. These tests only run for real in `.github/workflows/build.yaml`'s `appium-windows-integration-tests` job (and locally via `build.ps1 -IntegrationTest`/`run-under-appium.ps1`, which is cross-platform but only validated on Windows), where an Appium @@ -16,15 +16,19 @@ cross-platform `build` job's `Test` step and `build.ps1 -Test`) they are excluded via `--filter "Category!=Integration"`, since no Appium/AT-SPI server is started in those runs. -Several of these tests mutate shared workspace state (adding a real source, or relying on the -`SYSML2WORKBENCH_STARTUP_FILE`-preloaded fixture) against the one `AppFixture`-launched application process shared -across every `[Fact]` in this class (`[Collection("AppFixture")]`). Rather than resetting the workspace before -every test - which would deterministically wipe the `STARTUP_FILE` preload before the one test that depends on it -ever ran, since that preload only happens once at process launch and cannot be redone per test without driving the -very native "Open File" dialog this design avoids - cleanup is owned only by the one state-mutating test itself, -via `AppFixture.CloseAllSources()` (clicks File > Close All through the real accessibility tree) in a `finally` -block. Every other test in this class only asserts menu-item discoverability/enablement, which holds regardless of -whether a source happens to be loaded. +Every test class derives from `AppiumTestBase`, which gives each individual `[Fact]` its own dedicated +`AppFixture` launch and WebDriver session - xUnit constructs a fresh test-class instance per fact, so a plain +`AppFixture` field owned by the base class already gives full per-test isolation with no shared-collection +fixture, and `Dispose()` quits that one test's application process again once it finishes, whether it passed +or failed. `AppiumTestBase.StartApp(string startupArguments = "")` additionally lets a test relaunch mid-test +with different startup arguments (quitting/disposing any previous session first) - `AppFixture` forwards these +arguments to the launched process via the `appium:appArguments`/`appium:arguments` capabilities (recognized by +`App.axaml.cs`'s `ApplyStartupSourceArgumentsForTesting` as repeatable `--startup-source ` tokens), which +is how `DesktopApp_QueryDialog_PopulatedWithSourceAndChip_CapturesInspectionScreenshot` preloads a real +workspace without driving the unautomatable native "Open File" dialog. Because every test gets its own +application instance, no test needs bespoke cleanup (a shared-session `CloseAllSources` reset, restoring a +panel to its originally-open state, and so on) purely to protect other tests it has no other relationship +with. ### Test Scenarios @@ -33,49 +37,63 @@ session's `Title` reports "SysML2Workbench" immediately after launch, proving the session is genuinely driving the compiled application's real main window rather than a stub. -**DesktopApp_FileMenu_AddFileSourceMenuItem_IsDiscoverableAndEnabled**: -Opens the File menu (`MobileBy.Name("File")`) and locates the "Open File..." -item by its `AddFileSourceMenuItem` automation id -(`MobileBy.AccessibilityId`), proving Avalonia's UIA automation peer exposes -the `AutomationProperties.AutomationId` values added to `MainWindowView.axaml` +**DesktopApp_FileMenu_Items_AreDiscoverableAndEnabled**: Opens the File menu +(`MobileBy.Name("File")`) once and locates "Open File..." (`AddFileSourceMenuItem`), +"Open Folder..." (`AddFolderSourceMenuItem`), and "Close All" +(`CloseAllMenuItem`) by automation id (`MobileBy.AccessibilityId`), asserting +each is displayed and enabled, then closes the menu once via Escape without +clicking any item - proving Avalonia's UIA automation peer exposes the +`AutomationProperties.AutomationId` values added to `MainWindowView.axaml` through the real accessibility tree that Appium's NovaWindows driver reads. -The item is not actually clicked, since doing so would open the OS-native -"Open File" dialog, which lives outside Avalonia's own accessibility tree. +All three checks share one menu-open/close and one dedicated application +launch since they are pure, side-effect-free discoverability checks against +the same menu. None of the items are actually clicked: "Open File..."/"Open +Folder..." would open an OS-native dialog that lives outside Avalonia's own +accessibility tree, and "Close All" has no assertable close/reopen +counterpart the way a panel-toggle item does. -**DesktopApp_FileMenu_AddFolderSourceMenuItem_IsDiscoverableAndEnabled**, -**DesktopApp_ViewMenu_WorkspacePanelMenuItem_IsDiscoverableAndEnabled**, -**DesktopApp_ViewMenu_PredefinedViewsMenuItem_IsDiscoverableAndEnabled**, -**DesktopApp_ViewMenu_DiagnosticsMenuItem_IsDiscoverableAndEnabled**, -**DesktopApp_ViewMenu_ViewBuilderDialogMenuItem_IsDiscoverableAndEnabled**, -**DesktopApp_QueryMenu_QueryDialogMenuItem_IsDiscoverableAndEnabled**, and -**DesktopApp_FileMenu_CloseAllMenuItem_IsDiscoverableAndEnabled**: -Each opens its respective top-level menu (`File`/`View`/`Query`) and locates -one child menu item by automation id, asserting it is both displayed and -enabled, then closes the menu via Escape without clicking the item. These -extend automation-id coverage breadth-first across every subsystem's -top-level entry point (workspace panel, predefined views, diagnostics, -custom view builder, query dialog, close-all). The View menu's toggle items -and the Query dialog now also have dedicated click-through round-trip -tests (see below); the others are not yet clicked because they open a -dialog whose controls don't carry automation ids yet -(`ViewBuilderDialogMenuItem`) or a native OS surface outside Avalonia's own -accessibility tree (`AddFileSourceMenuItem`/`AddFolderSourceMenuItem`), or -because a menu item click here would leave the shared `AppFixture` session -in a different state for whichever test runs next (`CloseAllMenuItem`). +**DesktopApp_ViewMenu_Items_AreDiscoverableAndEnabled**: The same +discoverability-only shape as the File menu scenario above, applied to the +View menu's "Workspace" (`WorkspacePanelMenuItem`), "Predefined Views" +(`PredefinedViewsMenuItem`), "Diagnostics" (`DiagnosticsMenuItem`), and +"Custom View Builder..." (`ViewBuilderDialogMenuItem`) items, all checked +against one menu open/close in one test/application launch. The panel-toggle +items also have their own dedicated click-through round-trip tests (see +below); `ViewBuilderDialogMenuItem` is not yet clicked because its opened +dialog's controls don't carry automation ids that would let this fixture +close it deterministically. + +**DesktopApp_QueryMenu_QueryDialogMenuItem_IsDiscoverableAndEnabled**: Opens +the Query menu and locates "Run Query..." (`QueryDialogMenuItem`) by +automation id, asserting it is displayed and enabled, then closes the menu +via Escape without clicking it - the same discoverability-only shape as the +File/View menu scenarios above, kept as its own single-item test since the +Query menu has only this one item to check. **DesktopApp_HelpMenu_AboutMenuItem_OpensAndClosesAboutDialog**: Opens the Help menu, clicks "About" (`AboutMenuItem`), confirms the modal About -dialog's `AboutDialogOkButton` becomes visible, then dismisses it - proving -a full menu-click-to-modal-dialog round trip works end-to-end through the -real windowed application. +dialog's `AboutDialogOkButton` becomes visible, then dismisses it and +confirms `AboutDialogOkButton` disappears from the accessibility tree - +proving a full menu-click-to-modal-dialog-and-back round trip works +end-to-end through the real windowed application. **DesktopApp_QueryMenu_QueryDialogMenuItem_OpensAndClosesQueryDialog**: The same open/click/assert/close round-trip shape as the About dialog test above, applied to the Query dialog - opens the Query menu, clicks "Run Query..." (`QueryDialogMenuItem`), confirms the dialog's own `AddTypeFilterButton` (part of the shared `ElementFilterView`) becomes -visible, then dismisses it via `QueryDialogCloseButton`, reusing the same -close button already exercised by the inspection-screenshot tests below. +visible, captures a cropped PNG of that button to +`artifacts/inspection/query-dialog-add-type-filter-button.png` via +`InspectionScreenshot.CaptureElement` (folded into this same round trip +rather than a separate open/close cycle, since the screenshot is just an +incidental capture of a control this test already has open and asserted +on), then dismisses the dialog via `QueryDialogCloseButton` and confirms +`AddTypeFilterButton` disappears from the accessibility tree. The +screenshot capture is not itself a pass/fail correctness assertion (only +that the button is displayed and the capture mechanism completes without +error) - the actual visual review of the saved image is a human/agent task, +since automatically detecting styling defects is not a suitable CI gate. +Honors `SYSML2WORKBENCH_THEME` for which theme the capture reflects. **DesktopApp_ViewMenu_WorkspacePanelMenuItem_TogglesWorkspacePanel**, **DesktopApp_ViewMenu_PredefinedViewsMenuItem_TogglesPredefinedViewsPanel**, and @@ -92,9 +110,9 @@ not merely that the menu item is clickable - then clicks it a second time and polls for the same control to reappear and be displayed, proving reopening also brings the panel into view. All three panels start open per `WorkbenchDockFactory.CreateLayout`, so the first click always closes and -the second always reopens, and each test restores the panel to its -original open state by the end, leaving no residual effect for later -tests. Checked state is verified purely through panel content presence +the second always reopens; no residual-state cleanup is needed since each +test's dedicated application instance is quit at the end of the test +regardless of outcome. Checked state is verified purely through panel content presence rather than by reading the menu item's own checked state through UI Automation, because Avalonia's Win32 automation bridge does not currently expose the UIA Toggle pattern for `MenuItem` to native automation clients @@ -102,20 +120,13 @@ expose the UIA Toggle pattern for `MenuItem` to native automation clients this codebase) - a real Avalonia platform limitation, not a defect in this test suite. -**DesktopApp_QueryDialog_AddTypeFilterButton_CapturesInspectionScreenshot**: Opens the Query dialog and captures a -cropped PNG of the shared `ElementFilterView`'s "+" add-type-filter button (`AddTypeFilterButton`) to -`artifacts/inspection/query-dialog-add-type-filter-button.png` via `InspectionScreenshot.CaptureElement`, then -closes the dialog. Not a pass/fail correctness assertion (only that the button is displayed and the capture -mechanism completes without error) - the actual visual review of the saved image is a human/agent task, since -automatically detecting styling defects is not a suitable CI gate. Honors `SYSML2WORKBENCH_THEME` for which theme -the single capture reflects. - -**DesktopApp_QueryDialog_PopulatedWithSourceAndChip_CapturesInspectionScreenshot**: Opens the Query dialog against -a workspace already populated by the `SYSML2WORKBENCH_STARTUP_FILE`-preloaded `TestData/InspectionSample.sysml` -fixture (avoiding the unautomatable native "Open File" dialog), clicks `AddTypeFilterButton`, selects the -"attribute" entry from its flyout `ListBox` to add a real filter chip, then captures the whole filter-row region -(`ElementFilterRoot`) to `artifacts/inspection/query-dialog-populated-with-chip.png` - proving the already-applied +**DesktopApp_QueryDialog_PopulatedWithSourceAndChip_CapturesInspectionScreenshot**: Relaunches its own dedicated +application instance with `StartApp("--startup-source ")` pointing at +`TestData/InspectionSample.sysml` (avoiding the unautomatable native "Open File" dialog), opens the Query +dialog, clicks `AddTypeFilterButton`, selects the "attribute" entry from its flyout `ListBox` to add a real +filter chip, then captures the whole filter-row region (`ElementFilterRoot`) to +`artifacts/inspection/query-dialog-populated-with-chip.png` - proving the already-applied `ElementFilterView.axaml` chip-foreground contrast fix (`Foreground="#212121"` on the chip text and its "X" remove -button) is legible with a real populated chip, not just an empty "+" button. Resets the shared session's workspace -back to empty via `AppFixture.CloseAllSources()` in a `finally` block (see the Verification Approach section -above for why cleanup is owned only by this one test). +button) is legible with a real populated chip, not just an empty "+" button. No cleanup is needed afterward: this +test's own dedicated application instance is quit once the test finishes, whether it passed or failed, so the +preloaded workspace can never leak into another test. diff --git a/src/DemaConsulting.SysML2Workbench/App.axaml.cs b/src/DemaConsulting.SysML2Workbench/App.axaml.cs index d964eac..e231974 100644 --- a/src/DemaConsulting.SysML2Workbench/App.axaml.cs +++ b/src/DemaConsulting.SysML2Workbench/App.axaml.cs @@ -56,37 +56,57 @@ public override void OnFrameworkInitializationCompleted() desktop.MainWindow = new MainWindowView(shell); desktop.ShutdownRequested += (_, _) => shell.Dispose(); - ApplyStartupFileForTesting(shell); + ApplyStartupSourceArgumentsForTesting(shell, desktop.Args ?? []); } base.OnFrameworkInitializationCompleted(); } /// - /// Preloads a single workspace file source from the SYSML2WORKBENCH_STARTUP_FILE environment - /// variable, when set, so an Appium/AT-SPI integration-test session can start with a real, populated - /// workspace without driving an unautomatable native OS "Open File" dialog. + /// Preloads zero or more workspace file/folder sources from repeated --startup-source <path> + /// command-line arguments, so an Appium/AT-SPI integration-test session launched with per-session + /// appArguments/arguments capabilities (see AppiumTestBase.StartApp) can start with + /// a real, populated workspace tailored to that one test, without driving an unautomatable native OS + /// "Open File"/"Open Folder" dialog. /// /// - /// This is a test-only hook mirroring 's precedent: it has no - /// effect on normal end-user usage of the application, which never has this variable set. It is invoked - /// right after and its are constructed, before the - /// Avalonia message loop starts pumping. Blocking synchronously here with - /// GetAwaiter().GetResult() is safe: 's awaited - /// call chain (source-set add, file-watcher registration, workspace load, snapshot apply, + /// Unlike a fixed environment variable (which would only be inherited once, for the whole lifetime of + /// the Appium/AT-SPI server process that launches this application - see + /// 's remarks for why SYSML2WORKBENCH_THEME uses that + /// approach instead), command-line arguments are supplied fresh with every individual WebDriver + /// session-creation call, so they can vary per test. Each occurrence of + /// --startup-source is followed by exactly one path; a path is added as a folder source if + /// returns true, otherwise as a file source. This is a test-only + /// hook with no effect on normal end-user usage, which never launches the application with these + /// arguments. Blocking synchronously here with GetAwaiter().GetResult() is safe: it is invoked + /// right after and its are constructed, before + /// the Avalonia message loop starts pumping, and 's/ + /// 's awaited call chains (source-set add, + /// file-watcher registration, workspace load, snapshot apply, /// raised via a fire-and-forget dispatcher post) never - /// awaits a continuation that itself requires the not-yet-started message loop to be pumping. + /// await a continuation that itself requires the not-yet-started message loop to be pumping. /// - /// The freshly composed shell to preload a source into. - private static void ApplyStartupFileForTesting(MainWindowShell shell) + /// The freshly composed shell to preload sources into. + /// The raw command-line arguments the process was launched with. + private static void ApplyStartupSourceArgumentsForTesting(MainWindowShell shell, IReadOnlyList args) { - var startupFile = Environment.GetEnvironmentVariable("SYSML2WORKBENCH_STARTUP_FILE"); - if (string.IsNullOrWhiteSpace(startupFile)) + for (var i = 0; i < args.Count - 1; i++) { - return; - } + if (args[i] != "--startup-source") + { + continue; + } - shell.AddFileSourceAsync(startupFile).GetAwaiter().GetResult(); + var path = args[i + 1]; + if (Directory.Exists(path)) + { + shell.AddFolderSourceAsync(path).GetAwaiter().GetResult(); + } + else if (File.Exists(path)) + { + shell.AddFileSourceAsync(path).GetAwaiter().GetResult(); + } + } } /// diff --git a/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppFixture.cs b/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppFixture.cs index 4baba21..8d646df 100644 --- a/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppFixture.cs +++ b/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppFixture.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Runtime.InteropServices; using OpenQA.Selenium.Appium; using OpenQA.Selenium.Appium.Mac; @@ -56,6 +57,14 @@ public sealed class AppFixture : IDisposable /// started before this test process launched, and using the driver appropriate for the current /// operating system. /// + /// + /// Optional command-line arguments passed to the launched application process, applied via the + /// per-session appArguments/arguments capability (see ). + /// Unlike an environment variable (fixed for the whole lifetime of the already-running Appium/AT-SPI + /// server process), this is supplied fresh with every session-creation call, so it can vary from test to + /// test. See App.axaml.cs's ApplyStartupSourceArgumentsForTesting for the + /// --startup-source <path> format this application recognizes. + /// /// /// This fixture deliberately does not attempt to reposition/resize the launched window (for example /// to guarantee it is fully on-screen for ). Both @@ -72,14 +81,14 @@ public sealed class AppFixture : IDisposable /// comfortably fits on-screen (including under a typical taskbar) without needing any runtime /// repositioning. /// - public AppFixture() + public AppFixture(string startupArguments = "") { Driver = OperatingSystem.IsWindows() - ? CreateWindowsDriver() + ? CreateWindowsDriver(startupArguments) : OperatingSystem.IsMacOS() - ? CreateMacDriver() + ? CreateMacDriver(startupArguments) : OperatingSystem.IsLinux() - ? CreateLinuxDriver() + ? CreateLinuxDriver(startupArguments) : throw new PlatformNotSupportedException( "SysML2Workbench's Appium AppFixture only recognizes Windows, macOS, and Linux."); @@ -103,19 +112,6 @@ public void Dispose() Driver.Dispose(); } - /// - /// Clicks File > Close All (CloseAllMenuItem) to reset the shared session's workspace back to - /// its empty starting state. Reusable cleanup seam for any test that adds a real file/folder source - /// (directly, or via the SYSML2WORKBENCH_STARTUP_FILE preload) and must not leave that state - /// behind for whichever test in this [Collection("AppFixture")]-shared session runs next - see - /// MainWindowShellIntegrationTests's own remarks for which tests call this and why. - /// - public void CloseAllSources() - { - Driver.FindElement(MobileBy.Name("File")).Click(); - Driver.FindElement(MobileBy.AccessibilityId("CloseAllMenuItem")).Click(); - } - /// /// Resolves the path to the published Desktop executable/binary for the current operating system, /// preferring the SYSML2WORKBENCH_APP_PATH environment variable (set by build.ps1/CI @@ -149,7 +145,7 @@ private static string ResolveAppPath() /// Creates the fully implemented, CI-validated Windows session: Appium's NovaWindows driver (the /// maintained successor to the deprecated WinAppDriver) pointed at the published Desktop executable. /// - private static WindowsDriver CreateWindowsDriver() + private static WindowsDriver CreateWindowsDriver(string startupArguments) { var options = new AppiumOptions { @@ -157,6 +153,7 @@ private static WindowsDriver CreateWindowsDriver() PlatformName = "Windows", App = Path.GetFullPath(ResolveAppPath()), }; + ApplyStartupArguments(options, startupArguments); return new WindowsDriver(ServerUri, options); } @@ -170,7 +167,7 @@ private static WindowsDriver CreateWindowsDriver() /// macOS hardware. Not exercised by CI and not validated against a real macOS machine - see /// 's remarks. /// - private static MacDriver CreateMacDriver() + private static MacDriver CreateMacDriver(string startupArguments) { var options = new AppiumOptions { @@ -178,6 +175,7 @@ private static MacDriver CreateMacDriver() PlatformName = "Mac", App = Path.GetFullPath(ResolveAppPath()), }; + ApplyStartupArguments(options, startupArguments); return new MacDriver(ServerUri, options); } @@ -193,17 +191,52 @@ private static MacDriver CreateMacDriver() /// binary's path. Not exercised by CI and not validated against a real Linux machine - see /// 's remarks. /// - private static AppiumDriver CreateLinuxDriver() + private static AppiumDriver CreateLinuxDriver(string startupArguments) { var options = new AppiumOptions { PlatformName = "Linux", App = Path.GetFullPath(ResolveAppPath()), }; + ApplyStartupArguments(options, startupArguments); return new LinuxDriver(ServerUri, options); } + /// + /// Applies to via whichever per-session + /// capability the eventual driver recognizes, written generically (one shared code path for all three + /// platforms) even though only the Windows/NovaWindows branch is validated by CI today. + /// + /// + /// NovaWindows (mirroring WinAppDriver) and the Linux AT-SPI wrapper both document a single + /// shell-style command-line string via appium:appArguments; Mac2 instead documents an array of + /// already-split argument strings via appium:arguments. Setting both additional capabilities is + /// harmless - a driver that does not recognize a given capability name simply ignores it - so this one + /// helper covers every platform without branching by itself. The naive + /// whitespace split used for the array form does not understand quoting; a single layer of matching + /// surrounding double quotes is stripped from each split token (so a defensively-quoted, space-free + /// path like "C:\path\file.sysml" still resolves correctly), but an argument whose value itself + /// contains a space (quoted or not) is still split into multiple array entries and not correctly + /// supported on Mac2 today; this is an acceptable limitation given Mac2 is already unvalidated + /// best-effort support. + /// + private static void ApplyStartupArguments(AppiumOptions options, string startupArguments) + { + if (string.IsNullOrWhiteSpace(startupArguments)) + { + return; + } + + options.AddAdditionalAppiumOption("appium:appArguments", startupArguments); + options.AddAdditionalAppiumOption( + "appium:arguments", + startupArguments + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(token => token.Length >= 2 && token[0] == '"' && token[^1] == '"' ? token[1..^1] : token) + .ToArray()); + } + /// /// is abstract with no generic/platform-agnostic concrete form in this /// package version (Appium.WebDriver 8.x only ships concrete subclasses for its officially-known diff --git a/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppFixtureCollection.cs b/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppFixtureCollection.cs deleted file mode 100644 index 42cdc84..0000000 --- a/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppFixtureCollection.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace DemaConsulting.SysML2Workbench.IntegrationTests; - -/// -/// Pairs the "AppFixture" collection name with so xUnit shares one launched -/// application/Appium session across every test class in this assembly decorated with -/// [Collection("AppFixture")], rather than relaunching the Desktop application per test. Required -/// whenever [Collection] is used - xUnit v3 silently disables parallelism without providing any -/// shared fixture if this definition is omitted (see csharp-testing.md's xUnit v3 pitfalls). -/// -[CollectionDefinition("AppFixture")] -public sealed class AppFixtureCollection : ICollectionFixture; diff --git a/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppiumTestBase.cs b/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppiumTestBase.cs new file mode 100644 index 0000000..a203d16 --- /dev/null +++ b/test/DemaConsulting.SysML2Workbench.IntegrationTests/AppiumTestBase.cs @@ -0,0 +1,209 @@ +using OpenQA.Selenium.Appium; + +namespace DemaConsulting.SysML2Workbench.IntegrationTests; + +/// +/// Base class for Appium integration test classes, giving every individual [Fact] its own dedicated +/// application launch and WebDriver session rather than sharing one session across a whole test class or +/// collection. +/// +/// +/// xUnit constructs a fresh instance of a test class for every [Fact]/[Theory] case by +/// design, so a plain field owned directly by this base class (rather than +/// injected via [Collection]/ICollectionFixture<T>) already gives every test method its +/// own process launch and session with zero custom xUnit lifecycle plumbing - quits +/// it again once that one test finishes, whether it passed or failed. additionally +/// lets a single test method relaunch with different startup arguments mid-test (quitting/disposing any +/// previous session first), for scenarios that need to prove real process-level restart behavior rather +/// than just an in-app "reset" action (for example, a "Close All" menu item test could instead relaunch and +/// assert the workspace comes back empty, exercising the same guarantee a real user restart would rely on). +/// +/// This intentionally trades the speed of one shared, long-lived session (avoiding per-test launch/attach +/// overhead) for full test isolation: no test can leak dialog/panel/workspace state into a later test, and +/// no test needs its own bespoke cleanup (CloseAllSources, "restore the panel to its original open +/// state", TryCloseAnyOpenDialogWithEscape-after-failure reasoning, etc.) purely to protect +/// shared-session neighbors it has no other relationship with. +/// +/// +public abstract class AppiumTestBase : IDisposable +{ + private AppFixture? _fixture; + + /// + /// The live WebDriver session from the most recent call (including the + /// no-argument default call made by this base class's constructor). + /// + protected AppiumDriver Session => + _fixture?.Driver ?? throw new InvalidOperationException($"Call {nameof(StartApp)}() before using {nameof(Session)}."); + + /// + /// Launches the application with no startup arguments, giving every derived test class a ready-to-use + /// without requiring an explicit call in the common case. + /// + protected AppiumTestBase() + { + StartApp(); + } + + /// + /// Launches (or relaunches) the application with the given startup arguments, replacing + /// . Any previous session started by this test is quit/disposed first, so calling + /// this more than once within a single test method exercises a genuine process-level quit/relaunch + /// round trip, not just an in-app reset. + /// + /// + /// Command-line arguments forwarded to the launched process via 's per-session + /// appArguments/arguments capability - see App.axaml.cs's + /// ApplyStartupSourceArgumentsForTesting for the recognized --startup-source <path> + /// format (repeatable, one path - file or folder - per occurrence). + /// + protected void StartApp(string startupArguments = "") + { + _fixture?.Dispose(); + _fixture = new AppFixture(startupArguments); + } + + /// + public void Dispose() + { + _fixture?.Dispose(); + } + + /// + /// Opens the named top-level menu once, locates each child item by automation id, asserts each is + /// displayed and enabled, then closes the menu once via Escape without clicking any item - the + /// multi-item counterpart to for checking several + /// items in the same menu without reopening it once per item. + /// + /// The top-level menu's display name (e.g. "File", "View"). + /// The child menu items' AutomationProperties.AutomationId values. + protected void AssertMenuItemsAreDiscoverableAndEnabled(string topLevelMenuName, params string[] automationIds) + { + // Arrange + var topLevelMenu = Session.FindElement(MobileBy.Name(topLevelMenuName)); + topLevelMenu.Click(); + + // Act / Assert + OpenQA.Selenium.IWebElement? lastMenuItem = null; + foreach (var automationId in automationIds) + { + var menuItem = Session.FindElement(MobileBy.AccessibilityId(automationId)); + Assert.True(menuItem.Displayed); + Assert.True(menuItem.Enabled); + lastMenuItem = menuItem; + } + + // Close the menu without picking any item so this test leaves no side effect behind it. + lastMenuItem?.SendKeys(OpenQA.Selenium.Keys.Escape); + } + + /// + /// Opens the named top-level menu, locates the child item by automation id, asserts it is displayed and + /// enabled, then closes the menu via Escape without clicking the item itself - proving the item is + /// reachable through the real accessibility tree without triggering whatever side effect (dialog, panel + /// toggle) clicking it would cause. + /// + /// The top-level menu's display name (e.g. "File", "View", "Query"). + /// The child menu item's AutomationProperties.AutomationId value. + protected void AssertMenuItemIsDiscoverableAndEnabled(string topLevelMenuName, string automationId) + { + // Arrange + var topLevelMenu = Session.FindElement(MobileBy.Name(topLevelMenuName)); + topLevelMenu.Click(); + + // Act + var menuItem = Session.FindElement(MobileBy.AccessibilityId(automationId)); + + // Assert + Assert.True(menuItem.Displayed); + Assert.True(menuItem.Enabled); + + // Close the menu without picking the item so this test leaves no side effect behind it. + menuItem.SendKeys(OpenQA.Selenium.Keys.Escape); + } + + /// + /// Drives one full close/reopen round trip through a View-menu panel-toggle item. Confirms the toggle + /// behavior purely via the panel's own control () becoming + /// absent then present again, rather than reading the menu item's checked state through UI Automation: + /// Avalonia's Win32 automation bridge does not currently surface the UIA Toggle pattern for + /// MenuItem to native automation clients (its cross-platform MenuItemAutomationPeer + /// implements IToggleProvider internally, but that provider is never reachable from a real UI + /// Automation client such as NovaWindows/System.Windows.Automation, which always throws "Unsupported + /// Pattern" attempting to read it) - so content presence is the only reliable ground truth available. + /// + /// The top-level menu's display name (e.g. "View"). + /// The child menu item's AutomationProperties.AutomationId value. + /// + /// An automation id unique to a control that is always present while the panel is open and the active + /// tab (not conditionally hidden by panel content state, e.g. an always-visible toolbar button), used to + /// confirm the panel is genuinely shown or hidden. + /// + protected void AssertMenuItemTogglesPanel(string topLevelMenuName, string menuItemAutomationId, string panelControlAutomationId) + { + // Act 1 - click once; the panel starts open, so this closes it. + ClickMenuItem(topLevelMenuName, menuItemAutomationId); + var closed = WaitUntil(() => Session.FindElements(MobileBy.AccessibilityId(panelControlAutomationId)).Count == 0); + Assert.True(closed, $"The panel control '{panelControlAutomationId}' was still present after closing it via '{menuItemAutomationId}'."); + + // Act 2 - click again; the panel is now closed, so this reopens and focuses it. + ClickMenuItem(topLevelMenuName, menuItemAutomationId); + var reopened = WaitUntil(() => Session.FindElements(MobileBy.AccessibilityId(panelControlAutomationId)).Count > 0); + Assert.True(reopened, $"The panel control '{panelControlAutomationId}' did not reappear after reopening it via '{menuItemAutomationId}'."); + Assert.True(Session.FindElement(MobileBy.AccessibilityId(panelControlAutomationId)).Displayed); + } + + /// + /// Opens the named top-level menu, clicks the child item by automation id (which also closes the menu, + /// matching normal menu-click behavior), then returns. + /// + /// The top-level menu's display name (e.g. "View"). + /// The child menu item's AutomationProperties.AutomationId value. + protected void ClickMenuItem(string topLevelMenuName, string menuItemAutomationId) + { + Session.FindElement(MobileBy.Name(topLevelMenuName)).Click(); + Session.FindElement(MobileBy.AccessibilityId(menuItemAutomationId)).Click(); + } + + /// + /// Best-effort cleanup used when a dialog-opening test fails before it reaches its own close-button + /// click: sends Escape at the driver level (rather than to a specific, possibly-not-found element) so a + /// stray modal dialog does not stay open and mask the real assertion failure with a spurious one from + /// xUnit's own teardown. Swallows any exception of its own, since this only runs while another exception + /// is already propagating and must never mask it. + /// + protected void TryCloseAnyOpenDialogWithEscape() + { + try + { + new OpenQA.Selenium.Interactions.Actions(Session).SendKeys(OpenQA.Selenium.Keys.Escape).Perform(); + } + catch + { + // Best-effort only - the original exception is what the caller should see. + } + } + + /// + /// Polls every 100ms until it returns or + /// elapses (default 3 seconds), returning whether it succeeded. Used to + /// wait for UI-thread-driven, binding-triggered visual changes (for example a chip appearing after a + /// selection click) that Appium's synchronous Click() does not itself wait for, without adding a + /// Selenium.Support package dependency just for WebDriverWait. + /// + protected static bool WaitUntil(Func condition, TimeSpan? timeout = null) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(3)); + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return true; + } + + Thread.Sleep(100); + } + + return condition(); + } +} diff --git a/test/DemaConsulting.SysML2Workbench.IntegrationTests/DemaConsulting.SysML2Workbench.IntegrationTests.csproj b/test/DemaConsulting.SysML2Workbench.IntegrationTests/DemaConsulting.SysML2Workbench.IntegrationTests.csproj index 57324c9..5e9a1d6 100644 --- a/test/DemaConsulting.SysML2Workbench.IntegrationTests/DemaConsulting.SysML2Workbench.IntegrationTests.csproj +++ b/test/DemaConsulting.SysML2Workbench.IntegrationTests/DemaConsulting.SysML2Workbench.IntegrationTests.csproj @@ -14,6 +14,12 @@ + + + + +