diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c4fdeaa7..ced235c2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -39,7 +39,9 @@ "Bash(\"/c/Users/josep/.claude/skills/pdm/bin/pdm\" story-map *)", "Bash(\"/c/Users/josep/.claude/skills/pdm/bin/pdm\" type *)", "Bash(\"/c/Users/josep/.claude/skills/pdm/bin/pdm\" ui-element *)", - "Bash(\"/c/Users/josep/.claude/skills/pdm/bin/pdm\" api *)" + "Bash(\"/c/Users/josep/.claude/skills/pdm/bin/pdm\" api *)", + "Bash(gh run list *)", + "Bash(gh run view *)" ], "deny": [] } diff --git a/.github/workflows/interactive-ui.yml b/.github/workflows/interactive-ui.yml new file mode 100644 index 00000000..aaf36bf1 --- /dev/null +++ b/.github/workflows/interactive-ui.yml @@ -0,0 +1,198 @@ +name: Interactive UI CI + +on: + workflow_dispatch: + inputs: + lane: + description: "Lane to run. all-enabled runs only provisioned specialized lanes." + required: true + default: smoke-unpackaged-x64 + type: choice + options: + - smoke-unpackaged-x64 + - system-integration + - display-mixed-dpi + - packaged + - arm64 + - copilot-plus-winai + - all-enabled + configuration: + description: Build configuration + required: true + default: Debug + type: choice + options: [Debug, Release] + record: + description: Capture WinApp recordings where supported + required: true + default: true + type: boolean + run_xunit: + description: Run the normal xUnit suite before UI automation + required: true + default: true + type: boolean + dry_run: + description: Print the selected lane configuration without building or automating + required: true + default: false + type: boolean + schedule: + - cron: "17 4 * * 2-6" + +permissions: + contents: read + +# This workflow intentionally has no pull_request trigger and is not a PR gate. +# An interactive runner must be a dedicated logged-in desktop, not a runner service. +# Specialized runners declare TEXT_GRAB_INTERACTIVE_CAPABILITIES themselves; the +# disposable package runner must expose TEXT_GRAB_DISPOSABLE_VM=1 itself. The +# workflow never manufactures that destructive-lifecycle confirmation. Its +# current user must be allowed to trust and remove the per-run test signer. +concurrency: + group: interactive-ui-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + # Lane selection cannot live in the interactive-ui job's `if:` because the + # `matrix` context is not available there (it is only expanded afterwards). + # This job resolves the lane catalog against the event, dispatch inputs, and + # repository variables, then emits a dynamic matrix the real job consumes. + select-lanes: + name: Select lanes + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.select.outputs.matrix }} + has_lanes: ${{ steps.select.outputs.has_lanes }} + steps: + - id: select + shell: pwsh + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_LANE: ${{ inputs.lane }} + # All repository variables, so per-lane enable flags can be resolved by name. + REPO_VARS: ${{ toJSON(vars) }} + run: | + # The full lane catalog. `scheduled`, `default_enabled`, and + # `enable_variable` drive selection only; the remaining fields are + # consumed by the interactive-ui job via the emitted matrix. + $lanes = @( + [ordered]@{ lane = 'smoke-unpackaged-x64'; desktop = 'text-grab-ui-x64'; runner_labels = '["self-hosted","Windows","X64","text-grab-ui","text-grab-ui-x64"]'; required_capabilities = ''; require_mixed_dpi = 'false'; destructive = 'false'; scheduled = 'true'; default_enabled = 'true'; enable_variable = 'INTERACTIVE_CI_SMOKE_ENABLED' } + [ordered]@{ lane = 'system-integration'; desktop = 'text-grab-ui-system'; runner_labels = '["self-hosted","Windows","X64","text-grab-ui","text-grab-ui-system"]'; required_capabilities = 'system-integration'; require_mixed_dpi = 'false'; destructive = 'true'; scheduled = 'false'; default_enabled = 'false'; enable_variable = 'INTERACTIVE_CI_SYSTEM_INTEGRATION_ENABLED' } + [ordered]@{ lane = 'display-mixed-dpi'; desktop = 'text-grab-ui-mixed-dpi'; runner_labels = '["self-hosted","Windows","X64","text-grab-ui","text-grab-ui-mixed-dpi"]'; required_capabilities = 'mixed-dpi'; require_mixed_dpi = 'true'; destructive = 'false'; scheduled = 'false'; default_enabled = 'false'; enable_variable = 'INTERACTIVE_CI_MIXED_DPI_ENABLED' } + [ordered]@{ lane = 'packaged'; desktop = 'text-grab-ui-packaged'; runner_labels = '["self-hosted","Windows","X64","text-grab-ui","text-grab-ui-packaged"]'; required_capabilities = 'packaged,disposable-vm'; require_mixed_dpi = 'false'; destructive = 'true'; scheduled = 'false'; default_enabled = 'false'; enable_variable = 'INTERACTIVE_CI_PACKAGED_ENABLED' } + [ordered]@{ lane = 'arm64'; desktop = 'text-grab-ui-arm64'; runner_labels = '["self-hosted","Windows","ARM64","text-grab-ui","text-grab-ui-arm64"]'; required_capabilities = 'arm64,winrt-ocr'; require_mixed_dpi = 'false'; destructive = 'false'; scheduled = 'false'; default_enabled = 'false'; enable_variable = 'INTERACTIVE_CI_ARM64_ENABLED' } + [ordered]@{ lane = 'copilot-plus-winai'; desktop = 'text-grab-ui-copilot-plus'; runner_labels = '["self-hosted","Windows","ARM64","text-grab-ui","text-grab-ui-copilot-plus"]'; required_capabilities = 'arm64,winrt-ocr,windows-ai,packaged'; require_mixed_dpi = 'false'; destructive = 'false'; scheduled = 'false'; default_enabled = 'false'; enable_variable = 'INTERACTIVE_CI_COPILOT_PLUS_WINAI_ENABLED' } + ) + + $eventName = $env:EVENT_NAME + $inputLane = $env:INPUT_LANE + + # Repository variable names are case-insensitive; resolve them that way. + $rawVars = ($env:REPO_VARS | ConvertFrom-Json -AsHashtable) + $repoVars = [System.Collections.Hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + if ($null -ne $rawVars) { + foreach ($key in $rawVars.Keys) { $repoVars[$key] = $rawVars[$key] } + } + + $selected = $lanes | Where-Object { + if ($eventName -eq 'schedule') { + return $_.scheduled -eq 'true' + } + if ($eventName -eq 'workflow_dispatch') { + if ($inputLane -eq $_.lane) { return $true } + if ($inputLane -eq 'all-enabled') { + if ($_.default_enabled -eq 'true') { return $true } + return ($repoVars[$_.enable_variable] -eq 'true') + } + } + return $false + } + + $selected = @($selected) + $matrixJson = @{ include = $selected } | ConvertTo-Json -Depth 5 -Compress + $hasLanes = if ($selected.Count -gt 0) { 'true' } else { 'false' } + + Write-Host "Selected $($selected.Count) lane(s): $(($selected | ForEach-Object { $_.lane }) -join ', ')" + Add-Content -Path $env:GITHUB_OUTPUT -Value "matrix=$matrixJson" + Add-Content -Path $env:GITHUB_OUTPUT -Value "has_lanes=$hasLanes" + + interactive-ui: + name: ${{ matrix.lane }} + needs: select-lanes + if: needs.select-lanes.outputs.has_lanes == 'true' + runs-on: ${{ fromJSON(matrix.runner_labels) }} + timeout-minutes: 90 + concurrency: + # desktop is an exclusive label: provision one runner with each desktop label. + group: text-grab-interactive-desktop-${{ matrix.desktop }} + cancel-in-progress: false + strategy: + fail-fast: false + # Matrix entries are intentionally serialized, including all-enabled dispatches. + max-parallel: 1 + matrix: ${{ fromJSON(needs.select-lanes.outputs.matrix) }} + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Prepare artifacts + shell: pwsh + run: New-Item -ItemType Directory -Path UiTests\artifacts\ci\${{ matrix.lane }} -Force | Out-Null + + - name: Verify interactive runner + shell: pwsh + run: >- + .\UiTests\Test-InteractiveCiPreflight.ps1 + -OutputPath UiTests\artifacts\ci\${{ matrix.lane }}\preflight.json + -RequiredCapability "${{ matrix.required_capabilities }}".Split(',', [System.StringSplitOptions]::RemoveEmptyEntries) + -RequireDisplay + -RequireMixedDpi:${{ matrix.require_mixed_dpi }} + -Destructive:${{ matrix.destructive }} + -MinimumFreeDiskGB 25 + + - name: Build and run ${{ matrix.lane }} + shell: pwsh + run: | + $arguments = @( + '-NoProfile', '-File', '.\UiTests\Invoke-InteractiveCiLane.ps1', + '-Lane', '${{ matrix.lane }}', + '-ArtifactRoot', '.\UiTests\artifacts\ci\${{ matrix.lane }}', + '-Configuration', '${{ inputs.configuration || 'Debug' }}' + ) + if ('${{ inputs.record }}' -ne 'false') { $arguments += '-Record' } + if ('${{ inputs.run_xunit }}' -eq 'false') { $arguments += '-SkipXunit' } + if ('${{ inputs.dry_run }}' -eq 'true') { $arguments += '-DryRun' } + & pwsh @arguments + exit $LASTEXITCODE + + - name: Publish UI test summary + if: always() + shell: pwsh + run: | + $junit = Get-ChildItem UiTests\artifacts\ci\${{ matrix.lane }} -Filter junit.xml -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $junit) { + "## ${{ matrix.lane }}`nNo UI JUnit report was produced." >> $env:GITHUB_STEP_SUMMARY + exit 0 + } + [xml]$report = Get-Content -LiteralPath $junit.FullName + $suite = $report.testsuite + @( + "## ${{ matrix.lane }}", + "| Tests | Failures | Skipped | Duration |", + "| ---: | ---: | ---: | ---: |", + "| $($suite.tests) | $($suite.failures) | $($suite.skipped) | $($suite.time)s |", + "", + "JUnit: ``$($junit.FullName)``" + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + + - name: Upload interactive UI artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: interactive-ui-${{ matrix.lane }}-${{ github.run_attempt }} + path: UiTests\artifacts\ci\${{ matrix.lane }} + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 05541a71..826fafa3 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ BenchmarkDotNet.Artifacts/ project.lock.json project.fragment.lock.json artifacts/ +UiTests/artifacts/ # StyleCop StyleCopReport.xml diff --git a/README.md b/README.md index 1c7fb8ea..cab9c6a5 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,10 @@ Get the code: - Run tests with `dotnet test Tests\Tests.csproj` - In VS Code, press `F5` to launch with the included debug configuration. +### UI automation + +The Windows UI automation inventory, safe local release sign-off command, runner requirements, and opt-in system/package lanes are documented in [UiTests/README.md](UiTests/README.md). + ## Choose from Four Modes ### 1. Full-Screen Mode (basis of [Text Extractor](https://learn.microsoft.com/en-us/windows/powertoys/text-extractor)) diff --git a/Tests/AutomationProfileTests.cs b/Tests/AutomationProfileTests.cs new file mode 100644 index 00000000..38977531 --- /dev/null +++ b/Tests/AutomationProfileTests.cs @@ -0,0 +1,105 @@ +using System.IO; +using Text_Grab; +using Text_Grab.Utilities; + +namespace Tests; + +public class AutomationProfileTests +{ + [Fact] + public void TryCreate_UsesEnvironmentProfileAndKeepsIntegrationDisabled() + { + const string profilePath = @"C:\UiRuns\environment-profile"; + + AutomationProfile? profile = AutomationProfile.TryCreate( + ["Text-Grab.exe"], + name => name == AutomationProfile.ProfileEnvironmentVariable ? profilePath : null); + + Assert.NotNull(profile); + Assert.Equal(profilePath, profile.RootPath); + Assert.False(profile.AllowsSystemIntegration); + Assert.Equal(Path.Combine(profilePath, "history"), profile.HistoryDirectory); + Assert.Equal(Path.Combine(profilePath, "settings", "classic-settings.json"), profile.ClassicSettingsFilePath); + } + + [Fact] + public void TryCreate_CommandLineProfileAndIntegrationOverrideEnvironment() + { + const string environmentProfile = @"C:\UiRuns\environment-profile"; + const string commandLineProfile = @"C:\UiRuns\command-line-profile"; + + AutomationProfile? profile = AutomationProfile.TryCreate( + [ + "Text-Grab.exe", + "--automation-profile", + commandLineProfile, + "--automation-system-integration" + ], + name => name == AutomationProfile.ProfileEnvironmentVariable ? environmentProfile : null); + + Assert.NotNull(profile); + Assert.Equal(commandLineProfile, profile.RootPath); + Assert.True(profile.AllowsSystemIntegration); + Assert.False(profile.AllowsPersistentRegistration); + Assert.Equal(Path.Combine(commandLineProfile, "temp"), profile.TemporaryDirectory); + } + + [Fact] + public void TryCreate_PersistentRegistrationRequiresSystemAndDisposableOptIn() + { + AutomationProfile? ordinarySystemProfile = AutomationProfile.TryCreate( + ["Text-Grab.exe", "--automation-profile", @"C:\UiRuns\system", "--automation-system-integration"], + _ => null); + AutomationProfile? disposableProfile = AutomationProfile.TryCreate( + [ + "Text-Grab.exe", + "--automation-profile", @"C:\UiRuns\disposable", + "--automation-system-integration", + "--automation-disposable-registration" + ], + name => name == AutomationProfile.DisposableVmEnvironmentVariable ? "1" : null); + AutomationProfile? incompleteProfile = AutomationProfile.TryCreate( + ["Text-Grab.exe", "--automation-profile", @"C:\UiRuns\incomplete", "--automation-disposable-registration"], + _ => null); + AutomationProfile? nonDisposableProfile = AutomationProfile.TryCreate( + [ + "Text-Grab.exe", + "--automation-profile", @"C:\UiRuns\non-disposable", + "--automation-system-integration", + "--automation-disposable-registration" + ], + _ => null); + + Assert.NotNull(ordinarySystemProfile); + Assert.NotNull(disposableProfile); + Assert.NotNull(incompleteProfile); + Assert.NotNull(nonDisposableProfile); + Assert.False(ordinarySystemProfile.AllowsPersistentRegistration); + Assert.True(disposableProfile.AllowsPersistentRegistration); + Assert.False(incompleteProfile.AllowsPersistentRegistration); + Assert.False(nonDisposableProfile.AllowsPersistentRegistration); + } + + [Fact] + public void TryCreate_ReturnsNullWithoutProfile() + { + AutomationProfile? profile = AutomationProfile.TryCreate(["Text-Grab.exe"], _ => null); + + Assert.Null(profile); + } + + [Fact] + public void ParseStartupArguments_IgnoresAutomationProfileArguments() + { + App.StartupArguments startupArguments = App.ParseStartupArguments( + [ + "--automation-profile", + @"C:\UiRuns\run-1", + "--automation-system-integration", + "--automation-disposable-registration", + "Settings" + ]); + + Assert.Equal("Settings", startupArguments.PrimaryArgument); + } +} diff --git a/Tests/AutomationSettingsProviderTests.cs b/Tests/AutomationSettingsProviderTests.cs new file mode 100644 index 00000000..f04f0318 --- /dev/null +++ b/Tests/AutomationSettingsProviderTests.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Text_Grab; +using Text_Grab.Properties; +using Text_Grab.Services; +using Text_Grab.Utilities; + +namespace Tests; + +// Shares the "Settings isolation" collection so it never runs in parallel with other +// tests that touch Settings.Default: OverrideCurrentForTests flips process-global +// AutomationProfile state, which would otherwise redirect a concurrent Save into this +// test's temporary profile directory. +[Collection("Settings isolation")] +public class AutomationSettingsProviderTests +{ + [Fact] + public void Save_UnderProfile_WritesClassicSettingsIntoProfileDirectory() + { + using TempProfile temp = TempProfile.Create(); + using IDisposable scope = AutomationProfile.OverrideCurrentForTests(temp.Profile); + + Settings settings = new(); + settings.DefaultLaunch = "GrabFrame"; + settings.ShowToast = false; + settings.Save(); + + Assert.True(File.Exists(temp.Profile.ClassicSettingsFilePath)); + + // The classic store must land inside the profile, never the real user.config. + Dictionary persisted = ReadClassicSettings(temp.Profile.ClassicSettingsFilePath); + Assert.Equal("GrabFrame", persisted[nameof(Settings.DefaultLaunch)]); + Assert.Equal("False", persisted[nameof(Settings.ShowToast)]); + + Settings reloaded = new(); + Assert.Equal("GrabFrame", reloaded.DefaultLaunch); + Assert.False(reloaded.ShowToast); + } + + [Fact] + public void Reads_AreScopedToTheActiveProfile() + { + using TempProfile first = TempProfile.Create(); + using TempProfile second = TempProfile.Create(); + + using (AutomationProfile.OverrideCurrentForTests(first.Profile)) + { + Settings settings = new(); + settings.DefaultLaunch = "GrabFrame"; + settings.Save(); + } + + // A different profile must not see the first profile's saved value. + using (AutomationProfile.OverrideCurrentForTests(second.Profile)) + { + Settings settings = new(); + Assert.NotEqual("GrabFrame", settings.DefaultLaunch); + Assert.False(File.Exists(second.Profile.ClassicSettingsFilePath)); + } + } + + [Fact] + public void SettingsService_UnderProfile_SeedsClassicSettingsFileOnce() + { + using TempProfile temp = TempProfile.Create(); + using IDisposable scope = AutomationProfile.OverrideCurrentForTests(temp.Profile); + + Assert.False(File.Exists(temp.Profile.ClassicSettingsFilePath)); + + Settings first = new(); + using (new SettingsService(first, localSettings: null)) + { + // The seed is applied and persisted into the isolated profile file. + Assert.True(File.Exists(temp.Profile.ClassicSettingsFilePath)); + Assert.False(first.FirstRun); + Assert.Equal(TextGrabMode.EditText.ToString(), first.DefaultLaunch); + + // Mutate through the live service so every backing store (classic file and + // sidecar) stays consistent before the next run reads them back. + first.DefaultLaunch = "GrabFrame"; + first.Save(); + } + + // A second run finds the profile file already present and must not reseed: + // the value changed after seeding survives instead of being reset to the seed. + Settings second = new(); + using (new SettingsService(second, localSettings: null)) + Assert.Equal("GrabFrame", second.DefaultLaunch); + } + + private static Dictionary ReadClassicSettings(string path) => + JsonSerializer.Deserialize>(File.ReadAllText(path)) + ?? throw new InvalidOperationException("Classic settings file was empty."); + + private sealed class TempProfile : IDisposable + { + private TempProfile(string rootPath, AutomationProfile profile) + { + RootPath = rootPath; + Profile = profile; + } + + internal string RootPath { get; } + internal AutomationProfile Profile { get; } + + internal static TempProfile Create() + { + string root = Path.Combine(Path.GetTempPath(), $"tg-ui-tests-{Guid.NewGuid():N}"); + AutomationProfile profile = AutomationProfile.TryCreate( + ["Text-Grab.exe"], + name => name == AutomationProfile.ProfileEnvironmentVariable ? root : null) + ?? throw new InvalidOperationException("Failed to create automation profile for test."); + + return new TempProfile(root, profile); + } + + public void Dispose() + { + try + { + if (Directory.Exists(RootPath)) + Directory.Delete(RootPath, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup; a leaked temp directory should not fail the test. + } + } + } +} diff --git a/Tests/FullscreenGrabWindowLayoutTests.cs b/Tests/FullscreenGrabWindowLayoutTests.cs index ebde44fc..50b61a43 100644 --- a/Tests/FullscreenGrabWindowLayoutTests.cs +++ b/Tests/FullscreenGrabWindowLayoutTests.cs @@ -16,4 +16,24 @@ public void GetFullscreenClipBounds_UsesRenderedWindowSize(double width, double Assert.Equal(expected, actual); } + + [Theory] + [InlineData(WindowState.Normal, 1920, 1080)] // not maximized -> force + [InlineData(WindowState.Minimized, 1920, 1080)] // not maximized -> force + [InlineData(WindowState.Maximized, 40, 40)] // tiny despite maximized -> force + [InlineData(WindowState.Maximized, 1920, 100)] // too short -> force + [InlineData(WindowState.Maximized, 100, 1080)] // too narrow -> force + public void ShouldForceMaximize_ReturnsTrue_WhenOverlayIsNotFullScreen(WindowState state, double width, double height) + { + Assert.True(FullscreenGrab.ShouldForceMaximize(state, width, height)); + } + + [Theory] + [InlineData(1920, 1080)] + [InlineData(1366, 768)] + [InlineData(200, 200)] + public void ShouldForceMaximize_ReturnsFalse_WhenMaximizedAndLargeEnough(double width, double height) + { + Assert.False(FullscreenGrab.ShouldForceMaximize(WindowState.Maximized, width, height)); + } } diff --git a/Tests/UiAutomationContractTests.cs b/Tests/UiAutomationContractTests.cs new file mode 100644 index 00000000..6ab95f8b --- /dev/null +++ b/Tests/UiAutomationContractTests.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace Tests; + +public class UiAutomationContractTests +{ + // Runtime pattern checks belong in the future WinApp fixture harness. This + // source-level contract deliberately keeps selector regressions detectable + // in the existing test suite without starting the application. + [Fact] + public void RequiredAutomationIds_ArePresentAndUniqueInXaml() + { + string repositoryRoot = FindRepositoryRoot(); + IReadOnlyDictionary requiredIds = new Dictionary + { + ["Views\\FirstRunWindow.xaml"] = ["FirstRunWindow", "FirstRun.StartButton", "FirstRun.DefaultFullscreenRadio", "FirstRun.BackgroundToggle"], + ["Views\\SettingsWindow.xaml"] = ["SettingsWindow", "Settings.Navigation", "Settings.Nav.General", "Settings.Nav.Danger"], + ["Views\\EditTextWindow.xaml"] = ["EditTextWindow", "EditText.Editor", "EditText.StatusText", "EditText.LoadingStatus", "EditText.Menu.ClipboardWatcher"], + ["Views\\QuickSimpleLookup.xaml"] = ["QuickLookupWindow", "QuickLookup.Search", "QuickLookup.ResultsGrid", "QuickLookup.CopySelectedButton", "QuickLookup.ErrorStatus"], + ["Views\\FullscreenGrab.xaml"] = ["FullscreenGrabWindow", "FullscreenGrab.SelectionCanvas", "FullscreenGrab.Language", "FullscreenGrab.AcceptSelectionButton"], + ["Views\\GrabFrame.xaml"] = ["GrabFrameWindow", "GrabFrame.ZoomSurface", "GrabFrame.WordBordersCanvas", "GrabFrame.GrabButton", "GrabFrame.Status"], + ["Controls\\NotifyIconWindow.xaml"] = ["NotifyIconWindow", "NotifyIcon", "NotifyIcon.Menu.Settings", "NotifyIcon.Menu.Close"], + ["Controls\\FindAndReplaceWindow.xaml"] = ["FindReplaceDialog", "FindReplace.Search", "FindReplace.Results"], + ["Controls\\RegexEditorDialog.xaml"] = ["RegexEditorDialog", "RegexEditor.Pattern", "RegexEditor.Error"], + ["Controls\\PatternMatchModeDialog.xaml"] = ["PatternMatchDialog", "PatternMatch.Indices", "PatternMatch.IndicesError"], + ["Pages\\KeysSettings.xaml"] = ["Settings.ShortcutsPage", "Settings.Shortcuts.GlobalHotkeysToggle", "Settings.Shortcuts.FullscreenGrab"], + }; + + Dictionary> occurrences = []; + foreach (string xamlPath in Directory.EnumerateFiles(Path.Combine(repositoryRoot, "Text-Grab"), "*.xaml", SearchOption.AllDirectories)) + { + XDocument document = XDocument.Load(xamlPath, LoadOptions.SetLineInfo); + foreach (XAttribute attribute in document.Descendants().Attributes().Where(attribute => + attribute.Name.LocalName == "AutomationId" + || attribute.Name.LocalName.EndsWith(".AutomationId", StringComparison.Ordinal))) + { + if (!occurrences.TryGetValue(attribute.Value, out List? locations)) + { + locations = []; + occurrences.Add(attribute.Value, locations); + } + + locations.Add(xamlPath); + } + } + + foreach ((string relativePath, string[] ids) in requiredIds) + { + foreach (string id in ids) + { + Assert.True(occurrences.TryGetValue(id, out List? locations), + $"Required AutomationId '{id}' is missing from {relativePath}."); + Assert.Single(locations!); + Assert.EndsWith(relativePath, locations![0], StringComparison.OrdinalIgnoreCase); + } + } + + foreach ((string id, List locations) in occurrences) + Assert.True(locations.Count == 1, $"AutomationId '{id}' must be unique; found in {string.Join(", ", locations)}."); + } + + [Fact] + public void WordBorders_ExposeValuePatternThroughDedicatedAutomationPeer() + { + string source = File.ReadAllText(Path.Combine(FindRepositoryRoot(), "Text-Grab", "Controls", "WordBorder.xaml.cs")); + + Assert.Contains("OnCreateAutomationPeer", source, StringComparison.Ordinal); + Assert.Contains("IValueProvider", source, StringComparison.Ordinal); + Assert.Contains("PatternInterface.Value", source, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeAutomationSelectors_AreDerivedFromStableOwners() + { + string root = FindRepositoryRoot(); + string shortcutSource = File.ReadAllText(Path.Combine(root, "Text-Grab", "Controls", "ShortcutControl.xaml.cs")); + string wordBorderSource = File.ReadAllText(Path.Combine(root, "Text-Grab", "Controls", "WordBorder.xaml.cs")); + + Assert.Contains("$\"{automationId}.Record\"", shortcutSource, StringComparison.Ordinal); + Assert.Contains("$\"{automationId}.Enabled\"", shortcutSource, StringComparison.Ordinal); + Assert.Contains("$\"WordBorder.{ResultRowID}.{ResultColumnID}\"", wordBorderSource, StringComparison.Ordinal); + } + + private static string FindRepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "Text-Grab.sln"))) + return directory.FullName; + + directory = directory.Parent; + } + + return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); + } +} diff --git a/Text-Grab.sln b/Text-Grab.sln index 6fb9cde8..4c6992d1 100644 --- a/Text-Grab.sln +++ b/Text-Grab.sln @@ -14,6 +14,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{4BD477B7-FFAB-4864-81F2-18B00130C8E5}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TextGrab.AutomationHost", "UiTests\TextGrab.AutomationHost\TextGrab.AutomationHost.csproj", "{51D9D3FA-2722-4203-AE21-AEA1B11C761D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -66,6 +68,18 @@ Global {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Release|x64.Build.0 = Release|x64 {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Release|x86.ActiveCfg = Release|x86 {4BD477B7-FFAB-4864-81F2-18B00130C8E5}.Release|x86.Build.0 = Release|x86 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|ARM64.Build.0 = Debug|ARM64 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|x64.ActiveCfg = Debug|x64 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|x64.Build.0 = Debug|x64 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|x86.ActiveCfg = Debug|x86 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Debug|x86.Build.0 = Debug|x86 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|ARM64.ActiveCfg = Release|ARM64 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|ARM64.Build.0 = Release|ARM64 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|x64.ActiveCfg = Release|x64 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|x64.Build.0 = Release|x64 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|x86.ActiveCfg = Release|x86 + {51D9D3FA-2722-4203-AE21-AEA1B11C761D}.Release|x86.Build.0 = Release|x86 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index f3cbd7c0..a1b96440 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -33,6 +33,7 @@ internal readonly record struct StartupArguments( #region Fields + private static readonly AutomationProfile? _automationProfile = AutomationProfile.Current; private static readonly Settings _defaultSettings = AppUtilities.TextGrabSettings; private static RegistryMonitor? _themeRegistryMonitor; private static RegistryKey? _themeRegistryKey; @@ -258,8 +259,18 @@ internal static StartupArguments ParseStartupArguments(IEnumerable args) string? primaryArgument = null; string? grabFramePath = null; - foreach (string arg in args) + string[] startupArgs = [.. args]; + for (int index = 0; index < startupArgs.Length; index++) { + string arg = startupArgs[index]; + if (AutomationProfile.IsAutomationArgument(arg)) + { + if (string.Equals(arg, "--automation-profile", StringComparison.OrdinalIgnoreCase)) + index++; + + continue; + } + if (string.Equals(arg, "--windowless", StringComparison.OrdinalIgnoreCase)) { isQuiet = true; @@ -558,6 +569,7 @@ private static async Task TryOpenGrabFrameFileAsync(string path, bool isQu private void appExit(object sender, ExitEventArgs e) { + AutomationDiagnostics.Record("exit", new { e.ApplicationExitCode }); TextGrabIcon?.Close(); NotifyIconUtilities.UnregisterHotkeys(this); @@ -572,13 +584,23 @@ private void appExit(object sender, ExitEventArgs e) private async void appStartup(object sender, StartupEventArgs e) { + if (_automationProfile is not null) + { + AutomationDiagnostics.Initialize(_automationProfile); + AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; + } + NumberOfRunningInstances = Process.GetProcessesByName("Text-Grab").Length; Current.DispatcherUnhandledException += CurrentDispatcherUnhandledException; // Per-user text-grab:// and .tggf registration for unpackaged installs // (packaged installs register these via the MSIX manifest). - ProtocolUtilities.EnsureProtocolRegistration(); - FileAssociationUtilities.EnsureGrabFrameFileAssociation(); + if (_automationProfile is null || _automationProfile.AllowsPersistentRegistration) + { + ProtocolUtilities.EnsureProtocolRegistration(); + FileAssociationUtilities.EnsureGrabFrameFileAssociation(); + } // Register COM server and activator type bool handledArgument = false; @@ -608,6 +630,7 @@ private async void appStartup(object sender, StartupEventArgs e) // so don't show firstRun dialog or the default launch window _defaultSettings.FirstRun = false; _defaultSettings.Save(); + AutomationDiagnostics.RecordReady(handledArgument, suppressDefaultLaunch); return; } @@ -615,21 +638,30 @@ private async void appStartup(object sender, StartupEventArgs e) { _defaultSettings.CorrectToLatin = LanguageUtilities.IsCurrentLanguageLatinBased(); ShowAndSetFirstRun(); + AutomationDiagnostics.RecordReady(handledArgument, suppressDefaultLaunch); return; } DefaultLaunch(); + AutomationDiagnostics.RecordReady(handledArgument, suppressDefaultLaunch); } private void CurrentDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) { // unhandled exceptions thrown from UI thread Debug.WriteLine($"Unhandled exception: {e.Exception}"); + AutomationDiagnostics.RecordUnhandledException("dispatcher", e.Exception); e.Handled = true; + + if (_automationProfile is not null) + Current.Dispatcher.BeginInvoke(() => Shutdown(-1)); } private bool HandleNotifyIcon() { + if (_automationProfile is { AllowsSystemIntegration: false }) + return false; + if (_defaultSettings.RunInTheBackground && NumberOfRunningInstances < 2) { NotifyIconUtilities.SetupNotifyIcon(); @@ -654,5 +686,16 @@ private void LaunchFromToast(ToastNotificationActivatedEventArgsCompat toastArgs mtw.Show(); }); } + + private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) + { + if (e.ExceptionObject is Exception exception) + AutomationDiagnostics.RecordUnhandledException("app-domain", exception); + } + + private static void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + AutomationDiagnostics.RecordUnhandledException("task-scheduler", e.Exception); + } #endregion Methods } diff --git a/Text-Grab/Controls/AddOrRemoveWindow.xaml b/Text-Grab/Controls/AddOrRemoveWindow.xaml index afb2a4fa..07b1d115 100644 --- a/Text-Grab/Controls/AddOrRemoveWindow.xaml +++ b/Text-Grab/Controls/AddOrRemoveWindow.xaml @@ -7,6 +7,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="Add or remove text on each line" + AutomationProperties.AutomationId="AddRemoveDialog" Width="330" Height="320" Background="{DynamicResource ApplicationBackgroundBrush}" @@ -53,6 +54,7 @@ --> diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml b/Text-Grab/Controls/NotifyIconWindow.xaml index 10ee70fe..d637461a 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml +++ b/Text-Grab/Controls/NotifyIconWindow.xaml @@ -8,6 +8,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wpfui="http://schemas.lepo.co/wpfui/2022/xaml" Title="NotifyIconWindow" + AutomationProperties.AutomationId="NotifyIconWindow" Width="0" Height="0" Background="Transparent" @@ -24,6 +25,8 @@ @@ -41,6 +45,7 @@ @@ -49,6 +54,7 @@ @@ -58,6 +64,7 @@ @@ -66,6 +73,7 @@ @@ -74,6 +82,7 @@ @@ -83,6 +92,7 @@ @@ -91,6 +101,7 @@ @@ -99,6 +110,7 @@ @@ -107,6 +119,7 @@ @@ -116,6 +129,7 @@ diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml.cs b/Text-Grab/Controls/NotifyIconWindow.xaml.cs index 035b7613..0579877e 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml.cs +++ b/Text-Grab/Controls/NotifyIconWindow.xaml.cs @@ -221,7 +221,7 @@ private void OpenClipboardImageGrabFrame_Click(object sender, RoutedEventArgs e) if (bitmapSource is null) return; - string tempPath = Path.Combine(Path.GetTempPath(), $"TextGrab_Clipboard_{Guid.NewGuid()}.png"); + string tempPath = Path.Combine(AutomationProfile.GetTemporaryDirectory(), $"TextGrab_Clipboard_{Guid.NewGuid()}.png"); using (FileStream fileStream = new(tempPath, FileMode.Create)) { diff --git a/Text-Grab/Controls/PatternMatchModeDialog.xaml b/Text-Grab/Controls/PatternMatchModeDialog.xaml index 5194543e..9530e092 100644 --- a/Text-Grab/Controls/PatternMatchModeDialog.xaml +++ b/Text-Grab/Controls/PatternMatchModeDialog.xaml @@ -6,6 +6,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="Pattern Match Options" + AutomationProperties.AutomationId="PatternMatchDialog" Width="420" Height="450" MinWidth="380" @@ -34,12 +35,14 @@ + AutomationProperties.AutomationId="PatternMatch.ModeOptions" Margin="0,0,0,12"> @@ -82,23 +86,28 @@ + AutomationProperties.AutomationId="PatternMatch.Separator" Text=", " /> - + diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml b/Text-Grab/Controls/PostGrabActionEditor.xaml index 3c5d0095..214face3 100644 --- a/Text-Grab/Controls/PostGrabActionEditor.xaml +++ b/Text-Grab/Controls/PostGrabActionEditor.xaml @@ -7,6 +7,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="Post-Grab Actions Settings" + AutomationProperties.AutomationId="PostGrabActionsDialog" Width="900" Height="700" Background="{DynamicResource ApplicationBackgroundBrush}" @@ -73,6 +74,7 @@ @@ -106,6 +108,7 @@ VerticalAlignment="Center"> @@ -211,6 +218,7 @@ @@ -297,6 +306,7 @@ @@ -307,6 +317,7 @@ @@ -317,6 +328,7 @@ @@ -331,6 +343,7 @@ @@ -452,12 +465,14 @@ Orientation="Horizontal"> diff --git a/Text-Grab/Controls/QrCodeWindow.xaml b/Text-Grab/Controls/QrCodeWindow.xaml index 5989fb92..c1b991f6 100644 --- a/Text-Grab/Controls/QrCodeWindow.xaml +++ b/Text-Grab/Controls/QrCodeWindow.xaml @@ -7,6 +7,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="Text Grab QR Code" + AutomationProperties.AutomationId="QrCodeDialog" Width="400" Height="600" Closing="FluentWindow_Closing" @@ -33,23 +34,27 @@ Icon="{StaticResource TextGrabIcon}" /> @@ -64,6 +69,7 @@ Text="Error Correction Level" /> @@ -78,6 +84,7 @@ diff --git a/Text-Grab/Controls/QrCodeWindow.xaml.cs b/Text-Grab/Controls/QrCodeWindow.xaml.cs index 085df232..276274cb 100644 --- a/Text-Grab/Controls/QrCodeWindow.xaml.cs +++ b/Text-Grab/Controls/QrCodeWindow.xaml.cs @@ -173,7 +173,7 @@ private void SetQrCodeToText(string textOfCode = "") UiTitleBar.Title = $"QR Code: {TextOfCode.Truncate(30)}"; int trimLength = TextOfCode.Length < maxLength ? TextOfCode.Length : maxLength; qrCodeFileName = $"QR-{TextOfCode[..trimLength].ReplaceReservedCharacters()}"; - tempPath = Path.Combine(Path.GetTempPath(), qrCodeFileName + ".png"); + tempPath = Path.Combine(AutomationProfile.GetTemporaryDirectory(), qrCodeFileName + ".png"); QrBitmap.Save(tempPath, ImageFormat.Png); hBitmap = QrBitmap.GetHbitmap(); diff --git a/Text-Grab/Controls/RegexEditorDialog.xaml b/Text-Grab/Controls/RegexEditorDialog.xaml index a9889dd8..5596a0d6 100644 --- a/Text-Grab/Controls/RegexEditorDialog.xaml +++ b/Text-Grab/Controls/RegexEditorDialog.xaml @@ -6,6 +6,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="Edit Regex Pattern" + AutomationProperties.AutomationId="RegexEditorDialog" Width="600" Height="400" MinWidth="500" @@ -35,12 +36,14 @@ - + diff --git a/Text-Grab/Controls/RegexManager.xaml b/Text-Grab/Controls/RegexManager.xaml index 2d21f3aa..a71fbd3a 100644 --- a/Text-Grab/Controls/RegexManager.xaml +++ b/Text-Grab/Controls/RegexManager.xaml @@ -7,6 +7,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="Patterns Manager" + AutomationProperties.AutomationId="RegexManagerDialog" Width="900" Height="600" MinWidth="700" @@ -41,6 +42,7 @@ Background="{ui:ThemeResource SolidBackgroundFillColorBaseAltBrush}"> diff --git a/Text-Grab/Controls/ShortcutControl.xaml b/Text-Grab/Controls/ShortcutControl.xaml index 348e44ab..b03edfaf 100644 --- a/Text-Grab/Controls/ShortcutControl.xaml +++ b/Text-Grab/Controls/ShortcutControl.xaml @@ -7,12 +7,14 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" x:Name="ShortcutUserControl" + AutomationProperties.Name="{Binding ElementName=ShortcutUserControl, Path=ShortcutName}" Margin="0,0,0,3" HorizontalAlignment="Stretch" d:DataContext="{d:DesignInstance Type=local:ShortcutControl}" d:DesignHeight="60" d:DesignWidth="500" BorderThickness="3" + Loaded="ShortcutControl_Loaded" PreviewKeyDown="ShortcutControl_PreviewKeyDown" PreviewKeyUp="ShortcutControl_PreviewKeyUp" mc:Ignorable="d"> @@ -52,7 +54,7 @@ Grid.Column="1" VerticalAlignment="Center" Orientation="Horizontal"> - + new WordBorderAutomationPeer(this); + #endregion Events #region Properties @@ -162,6 +171,35 @@ public string DisplayText set { SetValue(DisplayTextProperty, value); } } + internal sealed class WordBorderAutomationPeer(WordBorder owner) : FrameworkElementAutomationPeer(owner), IValueProvider + { + private WordBorder WordBorder => (WordBorder)Owner; + + public bool IsReadOnly => !WordBorder.IsEnabled; + + public string Value => WordBorder.DisplayText; + + protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Edit; + + protected override string GetClassNameCore() => nameof(WordBorder); + + protected override string GetNameCore() => WordBorder.DisplayText; + + public override object? GetPattern(PatternInterface patternInterface) => + patternInterface == PatternInterface.Value ? this : base.GetPattern(patternInterface); + + public void SetValue(string value) + { + if (IsReadOnly) + throw new ElementNotEnabledException(); + + WordBorder.DisplayText = value; + } + + internal void RaiseValueChanged(string oldValue, string newValue) => + RaisePropertyChangedEvent(ValuePatternIdentifiers.ValueProperty, oldValue, newValue); + } + public double DisplayLineHeight { get { return (double)GetValue(DisplayLineHeightProperty); } diff --git a/Text-Grab/Pages/DangerSettings.xaml b/Text-Grab/Pages/DangerSettings.xaml index e66fa6d8..4ef01d91 100644 --- a/Text-Grab/Pages/DangerSettings.xaml +++ b/Text-Grab/Pages/DangerSettings.xaml @@ -8,6 +8,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="DangerSettings" + AutomationProperties.AutomationId="Settings.DangerPage" d:DesignHeight="450" d:DesignWidth="800" Loaded="Page_Loaded" @@ -21,6 +22,7 @@ @@ -74,6 +80,7 @@ Icon="{ui:SymbolIcon ArrowImport24}"> @@ -121,6 +129,7 @@ Icon="{ui:SymbolIcon BrainCircuit24}"> @@ -134,6 +143,7 @@ Icon="{ui:SymbolIcon ArrowReset24}"> - + - + - + - + - + - + - + - + @@ -135,6 +139,7 @@ Text="Font family:" /> @@ -150,6 +155,7 @@ Text="Font size:" /> - + - + diff --git a/Text-Grab/Pages/FullscreenGrabSettings.xaml b/Text-Grab/Pages/FullscreenGrabSettings.xaml index 37243803..c1963321 100644 --- a/Text-Grab/Pages/FullscreenGrabSettings.xaml +++ b/Text-Grab/Pages/FullscreenGrabSettings.xaml @@ -7,6 +7,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wpfui="http://schemas.lepo.co/wpfui/2022/xaml" Title="FullscreenGrabSettings" + AutomationProperties.AutomationId="Settings.FullscreenGrabPage" d:DesignHeight="450" d:DesignWidth="800" Loaded="Page_Loaded" @@ -24,18 +25,21 @@ Default (Standard) Single Line (S) Table (T) @@ -50,6 +54,7 @@ @@ -59,6 +64,7 @@ @@ -68,6 +74,7 @@ @@ -77,6 +84,7 @@ @@ -91,14 +99,14 @@ Description="Automatically route capture text into the Edit Text Window (same as pressing E)." HeaderText="Send output to Edit Text Window by default" Icon="{wpfui:SymbolIcon WindowEdit20}"> - + - + @@ -108,13 +116,14 @@ HeaderText="Insert captured text into the focused app" Icon="{wpfui:SymbolIcon ClipboardPaste24}"> - + @@ -145,12 +155,14 @@ - + winget install -e --id UB-Mannheim.TesseractOCR diff --git a/Text-Grab/Pages/VoiceOutputSettings.xaml b/Text-Grab/Pages/VoiceOutputSettings.xaml index 02aba3a8..089a51f2 100644 --- a/Text-Grab/Pages/VoiceOutputSettings.xaml +++ b/Text-Grab/Pages/VoiceOutputSettings.xaml @@ -7,6 +7,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="VoiceOutputSettings" + AutomationProperties.AutomationId="Settings.VoiceOutputPage" d:DesignHeight="700" d:DesignWidth="800" Loaded="Page_Loaded" @@ -24,6 +25,7 @@ Icon="{ui:SymbolIcon PersonVoice24}"> @@ -35,6 +37,7 @@ @@ -57,12 +61,14 @@ @@ -91,6 +98,7 @@ Icon="{ui:SymbolIcon Play24}">