Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e82ed63
Improve accessibility with AutomationProperties support
TheJoeFin Jul 29, 2026
7899af7
Add AutomationIds to settings controls for accessibility
TheJoeFin Jul 29, 2026
9fefc46
Add AutomationId attributes for accessibility and UI tests
TheJoeFin Jul 29, 2026
898ebc5
Integrate AutomationProfile for profile-based isolation
TheJoeFin Jul 29, 2026
042f885
Add automation diagnostics, profile, and settings provider
TheJoeFin Jul 29, 2026
c2e91cd
Add automation profile support and settings isolation
TheJoeFin Jul 29, 2026
16d89a5
Add tests for automation profile and UI contracts
TheJoeFin Jul 29, 2026
f6970ec
Add interactive UI test workflow for Windows runners
TheJoeFin Jul 29, 2026
0966e22
Add WPF AutomationHost for deterministic UI testing
TheJoeFin Jul 29, 2026
24c4448
Add SystemIntegrationHelper console app for Windows APIs
TheJoeFin Jul 29, 2026
179801f
Add TextGrab.AutomationHost project to solution
TheJoeFin Jul 29, 2026
0a9ec81
Add PowerShell UI automation and capability test suite
TheJoeFin Jul 29, 2026
e34e2bb
Add UI automation & coverage validation system
TheJoeFin Jul 29, 2026
decac7d
Update .gitignore and README for UI test infra
TheJoeFin Jul 29, 2026
0684b49
Redirect classic settings into active automation profile
TheJoeFin Jul 29, 2026
0d3a6bf
Add FullscreenGrab UI test suite
TheJoeFin Jul 29, 2026
68bca23
Resolve interactive-UI lane matrix in a dedicated job
TheJoeFin Jul 29, 2026
b2e35da
Allow gh run list/view in local Claude permissions
TheJoeFin Jul 29, 2026
87971c7
Serialize AutomationSettingsProviderTests with settings tests
TheJoeFin Jul 29, 2026
34598a5
Ensure FullscreenGrab overlay is always maximized
TheJoeFin Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
Expand Down
198 changes: 198 additions & 0 deletions .github/workflows/interactive-ui.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ BenchmarkDotNet.Artifacts/
project.lock.json
project.fragment.lock.json
artifacts/
UiTests/artifacts/

# StyleCop
StyleCopReport.xml
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
105 changes: 105 additions & 0 deletions Tests/AutomationProfileTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading