gui_harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +60 -0
- data/README.md +155 -0
- data/Rakefile +11 -0
- data/assets/gui_harness_live_launcher.ps1 +464 -0
- data/gui_harness.gemspec +36 -0
- data/lib/gui_harness/live_launcher.rb +185 -0
- data/lib/gui_harness/log.rb +43 -0
- data/lib/gui_harness/version.rb +3 -0
- data/lib/gui_harness.rb +2 -0
- data/spec/gui_harness/live_launcher_spec.rb +186 -0
- data/spec/gui_harness_spec.rb +9 -0
- data/spec/spec_helper.rb +6 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: afff804578f10a3d1b8771275024455eae4c201963d7cf0d928872b489aad081
|
|
4
|
+
data.tar.gz: 5221573ce1abf1b9d35d712b1724d1e0ee625e0fe79e596121143d642c195f10
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1d397f9c0a20952b91c94ab1af33c09680f5ea8409d23efbe9af4f498e8f128d41763480666e09f8698cd77b6bd7a62f21c38adf7f0c869a419a58f6e8e90e97
|
|
7
|
+
data.tar.gz: f1d39f58e2647347f3375100f5589ab54d2f7d6b9851a679c0b859b626399c48c950a04c099bae155b1841a2218cfb6a82aef60e1fef40949976d7df1d7e8eaf
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
plugins:
|
|
2
|
+
- rubocop-md
|
|
3
|
+
- rubocop-performance
|
|
4
|
+
- rubocop-rake
|
|
5
|
+
- rubocop-rspec
|
|
6
|
+
|
|
7
|
+
AllCops:
|
|
8
|
+
Exclude:
|
|
9
|
+
- binstub/**
|
|
10
|
+
- exe/**
|
|
11
|
+
- vendor/**
|
|
12
|
+
- Gemfile*
|
|
13
|
+
- TODO.md
|
|
14
|
+
NewCops: enable
|
|
15
|
+
TargetRubyVersion: 3.4
|
|
16
|
+
|
|
17
|
+
Gemspec/RequireMFA:
|
|
18
|
+
Enabled: false
|
|
19
|
+
|
|
20
|
+
Layout/HashAlignment:
|
|
21
|
+
EnforcedColonStyle: table
|
|
22
|
+
EnforcedHashRocketStyle: table
|
|
23
|
+
|
|
24
|
+
Layout/LineLength:
|
|
25
|
+
Max: 150
|
|
26
|
+
|
|
27
|
+
Metrics/AbcSize:
|
|
28
|
+
Max: 35
|
|
29
|
+
|
|
30
|
+
Metrics/BlockLength:
|
|
31
|
+
Max: 30
|
|
32
|
+
|
|
33
|
+
Metrics/CyclomaticComplexity:
|
|
34
|
+
Max: 15
|
|
35
|
+
|
|
36
|
+
Metrics/MethodLength:
|
|
37
|
+
Max: 40
|
|
38
|
+
|
|
39
|
+
Metrics/ModuleLength:
|
|
40
|
+
Enabled: false
|
|
41
|
+
|
|
42
|
+
Metrics/PerceivedComplexity:
|
|
43
|
+
Max: 15
|
|
44
|
+
|
|
45
|
+
# The harness examples assert the whole result of a launch: the exit code, the dialog, its
|
|
46
|
+
# classification, and the outcome. Splitting them would assert less of the contract.
|
|
47
|
+
RSpec/ExampleLength:
|
|
48
|
+
Enabled: false
|
|
49
|
+
|
|
50
|
+
RSpec/MultipleExpectations:
|
|
51
|
+
Enabled: false
|
|
52
|
+
|
|
53
|
+
Style/Documentation:
|
|
54
|
+
Enabled: false
|
|
55
|
+
|
|
56
|
+
Style/FrozenStringLiteralComment:
|
|
57
|
+
Enabled: false
|
|
58
|
+
|
|
59
|
+
Style/TrailingCommaInHashLiteral:
|
|
60
|
+
EnforcedStyleForMultiline: comma
|
data/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# gui_harness [](https://badge.fury.io/rb/gui_harness)
|
|
2
|
+
|
|
3
|
+
A Ruby harness for real GUI automation and end-to-end compatibility testing.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
`gui_harness` is a small library for driving real desktop applications the way a user does: launch the actual process, inspect the windows it owns, read the visible dialog text, classify the modal state, dismiss the blocking prompt, and return a structured result for tests.
|
|
8
|
+
|
|
9
|
+
This project is intentionally not a log-only parser. The process-owned window probe is the source of truth. The harness treats the UI as the contract.
|
|
10
|
+
|
|
11
|
+
The harness is designed for the real WSL-to-Windows workflow: Ruby runs in WSL, then the target GUI is launched by PowerShell on Windows. The launcher script is treated as a deployed runtime asset, not as Ruby source code.
|
|
12
|
+
|
|
13
|
+
## Project layout
|
|
14
|
+
|
|
15
|
+
- `lib/` contains the Ruby API and domain logic
|
|
16
|
+
- `assets/` contains runtime assets that are deployed with the gem, including the Windows PowerShell launcher
|
|
17
|
+
- `spec/` contains end-to-end, real-GUI regression tests
|
|
18
|
+
- `.vscode/launch.json` contains editor launch entries for the real harness flow
|
|
19
|
+
|
|
20
|
+
## Core behavior
|
|
21
|
+
|
|
22
|
+
The harness is designed to do the following:
|
|
23
|
+
|
|
24
|
+
- launch a GUI executable
|
|
25
|
+
- retain the process identity
|
|
26
|
+
- enumerate the top-level windows owned by that process
|
|
27
|
+
- walk the accessibility tree for a visible modal
|
|
28
|
+
- read text exposed by the dialog
|
|
29
|
+
- map the text to a known class of dialog
|
|
30
|
+
- trigger the correct dismissal action
|
|
31
|
+
- return a machine-readable outcome for the test harness
|
|
32
|
+
|
|
33
|
+
## Design principles
|
|
34
|
+
|
|
35
|
+
- no stubs, mocks, or simulations
|
|
36
|
+
- prefer actual GUI state over synthetic parser assumptions
|
|
37
|
+
- retain a simple result model for tests
|
|
38
|
+
- classify dialogs by visible text and expected user action
|
|
39
|
+
- keep the implementation reusable across desktop apps
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
The main entry point is `GuiHarness::LiveLauncher`.
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
require 'gui_harness'
|
|
47
|
+
|
|
48
|
+
result = GuiHarness::LiveLauncher.launch(
|
|
49
|
+
'C:/Program Files/Ableton/Live 12 Suite/Program/Ableton Live 12 Suite.exe',
|
|
50
|
+
'C:/path/to/test.als',
|
|
51
|
+
timeout: 120
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
puts result.exit_code
|
|
55
|
+
puts result.dialog
|
|
56
|
+
puts result.dialog_type
|
|
57
|
+
puts result.dismiss_action
|
|
58
|
+
puts result.outcome
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### `classify_dialog`
|
|
62
|
+
|
|
63
|
+
The library can also classify a dialog text string directly:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
GuiHarness::LiveLauncher.classify_dialog('A serious program error has occurred.')
|
|
67
|
+
# => { type: :fatal, action: { type: "button", value: "OK" } }
|
|
68
|
+
|
|
69
|
+
GuiHarness::LiveLauncher.classify_dialog('Save changes?')
|
|
70
|
+
# => { type: :save_changes, action: { type: "button", value: "No" } }
|
|
71
|
+
|
|
72
|
+
GuiHarness::LiveLauncher.classify_dialog("Cannot find the file 'missing.als'. Make sure the path and file name are correct.")
|
|
73
|
+
# => { type: :error, action: { type: "button", value: "OK" } }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Result model
|
|
77
|
+
|
|
78
|
+
The launch result is a Ruby `Struct` with the following fields:
|
|
79
|
+
|
|
80
|
+
- `exit_code`
|
|
81
|
+
- `output`
|
|
82
|
+
- `dialog`
|
|
83
|
+
- `dialog_type`
|
|
84
|
+
- `dismiss_action`
|
|
85
|
+
- `log`
|
|
86
|
+
- `loaded`
|
|
87
|
+
- `crashed`
|
|
88
|
+
|
|
89
|
+
It also exposes a derived `outcome` method:
|
|
90
|
+
|
|
91
|
+
- `:loaded`
|
|
92
|
+
- `:crashed`
|
|
93
|
+
- `:blocked`
|
|
94
|
+
- `:unknown`
|
|
95
|
+
|
|
96
|
+
This keeps a real GUI run easy to assert in Ruby specs without exposing raw UIAutomation internals.
|
|
97
|
+
|
|
98
|
+
## Real-world usage
|
|
99
|
+
|
|
100
|
+
The harness is intended for round-trip and compatibility testing against real apps. A typical scenario is to launch a real desktop app with a real `.als` file, let it show a modal, classify the dialog, dismiss it, and then assert on the final state.
|
|
101
|
+
|
|
102
|
+
The project includes two live Notepad regression paths:
|
|
103
|
+
|
|
104
|
+
- error path: launch Notepad with a missing file and assert that the real `Cannot find the file ...` dialog is detected and dismissed
|
|
105
|
+
- success path: launch Notepad with a valid file and assert that there is no dialog, the run reports success, and the app exits cleanly without leaving a process behind
|
|
106
|
+
|
|
107
|
+
This is exactly the kind of real-world modal handling the harness is meant to observe and report.
|
|
108
|
+
|
|
109
|
+
## PowerShell/Windows integration
|
|
110
|
+
|
|
111
|
+
The Ruby class delegates actual window inspection to a PowerShell script in `assets/`. The script enumerates the real Windows UI, walks the automation tree, reads dialog text, and returns the observed information in a format that Ruby can parse.
|
|
112
|
+
|
|
113
|
+
This separation keeps Ruby as the test-facing layer while leaving the actual OS-specific automation in the deployment asset that ships with the gem.
|
|
114
|
+
|
|
115
|
+
## VS Code workflow
|
|
116
|
+
|
|
117
|
+
This project includes a small VS Code launch setup for running the real-GUI specs directly from the editor.
|
|
118
|
+
|
|
119
|
+
### Launch configurations
|
|
120
|
+
|
|
121
|
+
From the Run and Debug panel, use one of these entries:
|
|
122
|
+
|
|
123
|
+
- `RSpec: all specs` — runs the whole suite
|
|
124
|
+
- `RSpec: current file` — runs the active spec file
|
|
125
|
+
- `Ruby: quick Notepad probe` — runs the direct WSL-to-Windows dialog probe used during harness debugging
|
|
126
|
+
|
|
127
|
+
The harness is meant to run from WSL while the app itself is launched on Windows via PowerShell. That is the supported environment for the real GUI tests.
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
The project is meant to be developed and debugged from WSL, while the GUI itself is launched on Windows. This is intentional and is part of the contract: the harness is a real native-GUI automation layer, not a mocked or local-only emulator.
|
|
132
|
+
|
|
133
|
+
Install dependencies:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
bundle install
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Run the test suite:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
bundle exec rspec
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Run the focused GUI harness spec:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
bundle exec rspec spec/live_launcher_spec.rb --format documentation
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Testing philosophy
|
|
152
|
+
|
|
153
|
+
This library is not a mock-based parser. It is built to validate real desktop behavior, real dialogs, and real app states. When the harness says a dialog is fatal, recovery, save-related, or an ordinary file error, it is because the real Windows UI exposed that text and the harness classified it as such.
|
|
154
|
+
|
|
155
|
+
That is what makes it useful for debugging actual compatibility paths and crash-recovery flows.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
<#
|
|
2
|
+
GUI process probe for real-app compatibility testing.
|
|
3
|
+
|
|
4
|
+
This script is the Windows-side implementation of the harness contract:
|
|
5
|
+
- launch a GUI executable from WSL via PowerShell
|
|
6
|
+
- retain ownership of the process and its windows
|
|
7
|
+
- inspect the visible UIAutomation tree for real modal dialogs
|
|
8
|
+
- classify the dialog from its user-visible text
|
|
9
|
+
- dismiss the blocking control when possible
|
|
10
|
+
- terminate the process tree cleanly before returning
|
|
11
|
+
#>
|
|
12
|
+
param(
|
|
13
|
+
[Parameter(Mandatory=$true)][string]$Exe,
|
|
14
|
+
[Parameter(Mandatory=$true)][string]$Set,
|
|
15
|
+
[string]$Log = '',
|
|
16
|
+
[int]$TimeoutSeconds = 5,
|
|
17
|
+
[ValidateSet('normal','minimized','hidden')][string]$Visibility = 'normal'
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
$ErrorActionPreference = 'Stop'
|
|
21
|
+
Add-Type -AssemblyName UIAutomationClient
|
|
22
|
+
Add-Type -AssemblyName UIAutomationTypes
|
|
23
|
+
|
|
24
|
+
Add-Type @'
|
|
25
|
+
using System;
|
|
26
|
+
using System.Text;
|
|
27
|
+
using System.Runtime.InteropServices;
|
|
28
|
+
public class Live {
|
|
29
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr l);
|
|
30
|
+
public delegate bool EnumWindowsProc(IntPtr h, IntPtr l);
|
|
31
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);
|
|
32
|
+
[DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr h);
|
|
33
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr h, StringBuilder s, int n);
|
|
34
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
|
35
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
|
36
|
+
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
|
37
|
+
[DllImport("user32.dll")] public static extern bool DestroyWindow(IntPtr hWnd);
|
|
38
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
|
39
|
+
}
|
|
40
|
+
'@
|
|
41
|
+
|
|
42
|
+
function Stop-ProcessTree([int[]]$ProcessIds) {
|
|
43
|
+
$seen = New-Object 'System.Collections.Generic.HashSet[int]'
|
|
44
|
+
$stack = New-Object 'System.Collections.Generic.Stack[int]'
|
|
45
|
+
foreach ($processId in $ProcessIds) { if ($processId -gt 0) { $stack.Push($processId) } }
|
|
46
|
+
|
|
47
|
+
while ($stack.Count -gt 0) {
|
|
48
|
+
$currentPid = $stack.Pop()
|
|
49
|
+
if (-not $seen.Add($currentPid)) { continue }
|
|
50
|
+
foreach ($child in Get-CimInstance Win32_Process -Filter "ParentProcessId = $currentPid" -ErrorAction SilentlyContinue) {
|
|
51
|
+
$stack.Push([int]$child.ProcessId)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
foreach ($processId in $seen) {
|
|
56
|
+
try { Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue } catch {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function Get-ProcessTreeIds([int[]]$ProcessIds) {
|
|
61
|
+
$seen = New-Object 'System.Collections.Generic.HashSet[int]'
|
|
62
|
+
$stack = New-Object 'System.Collections.Generic.Stack[int]'
|
|
63
|
+
foreach ($processId in $ProcessIds) { if ($processId -gt 0) { $stack.Push($processId) } }
|
|
64
|
+
|
|
65
|
+
while ($stack.Count -gt 0) {
|
|
66
|
+
$currentPid = $stack.Pop()
|
|
67
|
+
if (-not $seen.Add($currentPid)) { continue }
|
|
68
|
+
foreach ($child in Get-CimInstance Win32_Process -Filter "ParentProcessId = $currentPid" -ErrorAction SilentlyContinue) {
|
|
69
|
+
$stack.Push([int]$child.ProcessId)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return @($seen)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function Close-VisibleWindowsForPids([int[]]$ProcessIds) {
|
|
77
|
+
$owners = New-Object 'System.Collections.Generic.HashSet[int]'
|
|
78
|
+
foreach ($processId in $ProcessIds) { if ($processId -gt 0) { [void]$owners.Add([int]$processId) } }
|
|
79
|
+
|
|
80
|
+
[Live]::EnumWindows({
|
|
81
|
+
param($handle, $lparam)
|
|
82
|
+
$owner = 0
|
|
83
|
+
[void][Live]::GetWindowThreadProcessId($handle, [ref]$owner)
|
|
84
|
+
if ($owners.Contains([int]$owner) -and [Live]::IsWindowVisible($handle)) {
|
|
85
|
+
[void][Live]::PostMessage($handle, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)
|
|
86
|
+
}
|
|
87
|
+
return $true
|
|
88
|
+
}, [IntPtr]::Zero) | Out-Null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Cleanup-Notepad() {
|
|
92
|
+
$notepads = @(Get-Process -Name 'notepad' -ErrorAction SilentlyContinue)
|
|
93
|
+
foreach ($notepad in $notepads) {
|
|
94
|
+
try { Stop-Process -Id $notepad.Id -Force -ErrorAction SilentlyContinue } catch {}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function Set-ProcessWindowVisibility([int]$ProcessId, [string]$Visibility) {
|
|
99
|
+
$mode = switch ($Visibility) {
|
|
100
|
+
'hidden' { 0 }
|
|
101
|
+
'minimized' { 2 }
|
|
102
|
+
default { 1 }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
[Live]::EnumWindows({
|
|
106
|
+
param($handle, $lparam)
|
|
107
|
+
$owner = 0
|
|
108
|
+
[void][Live]::GetWindowThreadProcessId($handle, [ref]$owner)
|
|
109
|
+
if ($owner -eq $ProcessId) {
|
|
110
|
+
[void][Live]::ShowWindow($handle, $mode)
|
|
111
|
+
}
|
|
112
|
+
return $true
|
|
113
|
+
}, [IntPtr]::Zero) | Out-Null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function Debug-Log([string]$Message) {
|
|
117
|
+
if ($env:GUI_HARNESS_DEBUG -eq '1' -or $env:DEBUG -eq '1') {
|
|
118
|
+
[Console]::Error.WriteLine("[gui_harness] $Message")
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# The virtual key code of a key the harness sends.
|
|
123
|
+
#
|
|
124
|
+
# @param Key the name of the key, e.g. 'n' or 'esc'
|
|
125
|
+
# @return [int] the virtual key code, or 0 when the key is not one of them
|
|
126
|
+
function Get-KeyCode([string]$Key) {
|
|
127
|
+
switch ($Key.ToUpper()) {
|
|
128
|
+
'N' { return 0x4E }
|
|
129
|
+
'Y' { return 0x59 }
|
|
130
|
+
'ESC' { return 0x1B }
|
|
131
|
+
'{ESC}' { return 0x1B }
|
|
132
|
+
'ENTER' { return 0x0D }
|
|
133
|
+
default { return 0 }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# Sends a key to the windows of an application.
|
|
138
|
+
#
|
|
139
|
+
# The dialogs of Live are Qt windows, and UI Automation sees nothing in them, so a dialog of Live
|
|
140
|
+
# is read from its log and dismissed with the keyboard. The key is posted to the windows of the
|
|
141
|
+
# application, and not to the foreground window: a modal dialog of Live is a window of Live, but
|
|
142
|
+
# the window that has the focus is not necessarily the dialog.
|
|
143
|
+
#
|
|
144
|
+
# @param ProcessIds the processes the key goes to
|
|
145
|
+
# @param Key the name of the key
|
|
146
|
+
# @return [int] the number of windows the key was sent to
|
|
147
|
+
function Send-KeyToWindows([int[]]$ProcessIds, [string]$Key) {
|
|
148
|
+
$code = Get-KeyCode $Key
|
|
149
|
+
if ($code -eq 0) { return 0 }
|
|
150
|
+
|
|
151
|
+
$script:sent = 0
|
|
152
|
+
[Live]::EnumWindows({
|
|
153
|
+
param($handle, $lparam)
|
|
154
|
+
$owner = 0
|
|
155
|
+
[void][Live]::GetWindowThreadProcessId($handle, [ref]$owner)
|
|
156
|
+
if ($ProcessIds -contains [int]$owner) {
|
|
157
|
+
[void][Live]::PostMessage($handle, 0x0100, [IntPtr]$code, [IntPtr]::Zero)
|
|
158
|
+
[void][Live]::PostMessage($handle, 0x0101, [IntPtr]$code, [IntPtr]::Zero)
|
|
159
|
+
$script:sent++
|
|
160
|
+
}
|
|
161
|
+
return $true
|
|
162
|
+
}, [IntPtr]::Zero) | Out-Null
|
|
163
|
+
|
|
164
|
+
Debug-Log "sent $Key to $script:sent windows"
|
|
165
|
+
return $script:sent
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Brings the app to the foreground and sends it a key.
|
|
169
|
+
#
|
|
170
|
+
# @param ProcessId the process the key goes to
|
|
171
|
+
# @param Key the key, in the notation of WScript.Shell.SendKeys
|
|
172
|
+
# @return $true if the app was brought to the foreground
|
|
173
|
+
function Send-DismissKey([int]$ProcessId, [string]$Key) {
|
|
174
|
+
$shell = New-Object -ComObject WScript.Shell
|
|
175
|
+
$activated = $shell.AppActivate($ProcessId)
|
|
176
|
+
Debug-Log "AppActivate($ProcessId) returned $activated, sending $Key"
|
|
177
|
+
if (-not $activated) { return $false }
|
|
178
|
+
Start-Sleep -Milliseconds 200
|
|
179
|
+
$shell.SendKeys($Key)
|
|
180
|
+
return $true
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function Read-Log([long]$Start) {
|
|
184
|
+
if (-not $Log -or -not (Test-Path $Log)) { return '' }
|
|
185
|
+
$stream = [System.IO.File]::Open($Log, 'Open', 'Read', 'ReadWrite')
|
|
186
|
+
$stream.Seek($Start, 'Begin') | Out-Null
|
|
187
|
+
$reader = New-Object System.IO.StreamReader($stream)
|
|
188
|
+
$text = $reader.ReadToEnd()
|
|
189
|
+
$reader.Close()
|
|
190
|
+
return $text
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function Get-Windows([int]$ProcessId) {
|
|
194
|
+
$found = New-Object System.Collections.ArrayList
|
|
195
|
+
[Live]::EnumWindows({
|
|
196
|
+
param($handle, $lparam)
|
|
197
|
+
$owner = 0
|
|
198
|
+
[void][Live]::GetWindowThreadProcessId($handle, [ref]$owner)
|
|
199
|
+
if ($owner -eq $ProcessId -and [Live]::IsWindowVisible($handle)) {
|
|
200
|
+
$class = New-Object System.Text.StringBuilder 256
|
|
201
|
+
[void][Live]::GetClassName($handle, $class, 256)
|
|
202
|
+
$title = New-Object System.Text.StringBuilder 1024
|
|
203
|
+
[void][Live]::GetWindowText($handle, $title, 1024)
|
|
204
|
+
[void]$found.Add([pscustomobject]@{
|
|
205
|
+
Handle = $handle
|
|
206
|
+
Class = $class.ToString()
|
|
207
|
+
Title = $title.ToString()
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
return $true
|
|
211
|
+
}, [IntPtr]::Zero) | Out-Null
|
|
212
|
+
return $found
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function Test-IsModal($Element) {
|
|
216
|
+
if ($null -eq $Element) { return $false }
|
|
217
|
+
try {
|
|
218
|
+
$pattern = $Element.GetCurrentPattern([System.Windows.Automation.WindowPattern]::Pattern)
|
|
219
|
+
return $pattern.Current.IsModal
|
|
220
|
+
} catch { return $false }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# Finds the modal dialog of a window.
|
|
224
|
+
#
|
|
225
|
+
# An application is not a dialog. The windows of an application hold the whole of its
|
|
226
|
+
# interface, so the text of a window says nothing about whether the application is blocked.
|
|
227
|
+
# A dialog is a window inside it that is modal, and its own text is the text of the dialog.
|
|
228
|
+
#
|
|
229
|
+
# The dialogs of Live are Qt windows, and UI Automation sees nothing in them, so a dialog of
|
|
230
|
+
# Live is read from its log instead.
|
|
231
|
+
#
|
|
232
|
+
# @param Handle the handle of a window of the application
|
|
233
|
+
# @return the modal dialog, or $null when there is none
|
|
234
|
+
function Get-DialogElement([IntPtr]$Handle) {
|
|
235
|
+
$root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle)
|
|
236
|
+
if ($null -eq $root) { return $null }
|
|
237
|
+
if (Test-IsModal $root) { return $root }
|
|
238
|
+
|
|
239
|
+
$condition = New-Object System.Windows.Automation.PropertyCondition(
|
|
240
|
+
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
|
|
241
|
+
[System.Windows.Automation.ControlType]::Window)
|
|
242
|
+
$windows = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
|
|
243
|
+
foreach ($element in $windows) {
|
|
244
|
+
if (Test-IsModal $element) { return $element }
|
|
245
|
+
}
|
|
246
|
+
return $null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function Get-DialogText($Element) {
|
|
250
|
+
if ($null -eq $Element) { return $null }
|
|
251
|
+
|
|
252
|
+
$names = [System.Collections.Generic.List[string]]::new()
|
|
253
|
+
$rootName = $Element.Current.Name
|
|
254
|
+
if ($rootName -and $rootName.Trim().Length -gt 0) { [void]$names.Add($rootName.Trim()) }
|
|
255
|
+
|
|
256
|
+
$all = $Element.FindAll([System.Windows.Automation.TreeScope]::Descendants,
|
|
257
|
+
[System.Windows.Automation.Condition]::TrueCondition)
|
|
258
|
+
foreach ($element in $all) {
|
|
259
|
+
$name = $element.Current.Name
|
|
260
|
+
Debug-Log " element type=$($element.Current.ControlType.ProgrammaticName) class=$($element.Current.ClassName) name='$name'"
|
|
261
|
+
if ($name -and $name.Trim().Length -gt 0) {
|
|
262
|
+
$trimmed = $name.Trim()
|
|
263
|
+
if (-not $names.Contains($trimmed)) { [void]$names.Add($trimmed) }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
$text = $names | Where-Object { $_ -and $_.Trim().Length -gt 2 } | Select-Object -Unique
|
|
268
|
+
if (-not $text) { return $null }
|
|
269
|
+
return ($text -join ' | ')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function Click-Button($Element, [string]$Label) {
|
|
273
|
+
if ($null -eq $Element) { return $false }
|
|
274
|
+
$condition = New-Object System.Windows.Automation.AndCondition(
|
|
275
|
+
(New-Object System.Windows.Automation.PropertyCondition(
|
|
276
|
+
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
|
|
277
|
+
[System.Windows.Automation.ControlType]::Button)),
|
|
278
|
+
(New-Object System.Windows.Automation.PropertyCondition(
|
|
279
|
+
[System.Windows.Automation.AutomationElement]::NameProperty, $Label)))
|
|
280
|
+
$button = $Element.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $condition)
|
|
281
|
+
if ($null -eq $button) { return $false }
|
|
282
|
+
try {
|
|
283
|
+
$button.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke()
|
|
284
|
+
return $true
|
|
285
|
+
} catch { return $false }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function Get-DialogInfo([string]$Text) {
|
|
289
|
+
if (-not $Text) { return [pscustomobject]@{ Type = 'none'; Action = $null; ActionType = $null; ActionValue = $null } }
|
|
290
|
+
|
|
291
|
+
$normalized = $Text.Trim()
|
|
292
|
+
if ($normalized -match 'A serious program error has occurred\.|fatal error|Live will shut down after this message box is closed\.|program error') {
|
|
293
|
+
return [pscustomobject]@{ Type = 'fatal'; Action = 'OK'; ActionType = 'button'; ActionValue = 'OK' }
|
|
294
|
+
}
|
|
295
|
+
if ($normalized -match 'Would you like to recover your work\?|recover your work|Live unexpectedly quit while you were working on the Live Set') {
|
|
296
|
+
return [pscustomobject]@{ Type = 'recovery'; Action = 'No'; ActionType = 'button'; ActionValue = 'No' }
|
|
297
|
+
}
|
|
298
|
+
if ($normalized -match 'Save changes\?|Do you want to save the changes\?') {
|
|
299
|
+
return [pscustomobject]@{ Type = 'save_changes'; Action = 'No'; ActionType = 'button'; ActionValue = 'No' }
|
|
300
|
+
}
|
|
301
|
+
if ($normalized -match 'cannot find the path specified|cannot find the file|make sure the path and file name are correct|the file does not exist') {
|
|
302
|
+
return [pscustomobject]@{ Type = 'error'; Action = 'OK'; ActionType = 'button'; ActionValue = 'OK' }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return [pscustomobject]@{ Type = 'unknown'; Action = $null; ActionType = $null; ActionValue = $null }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
Get-Process -Name 'Ableton Live*' -ErrorAction SilentlyContinue | Stop-Process -Force
|
|
309
|
+
Cleanup-Notepad
|
|
310
|
+
Start-Sleep -Milliseconds 250
|
|
311
|
+
|
|
312
|
+
$logStart = 0
|
|
313
|
+
if ($Log -and (Test-Path $Log)) { $logStart = (Get-Item $Log).Length }
|
|
314
|
+
|
|
315
|
+
$argumentList = @()
|
|
316
|
+
if ($Set) { $argumentList = @($Set) }
|
|
317
|
+
$windowStyle = switch ($Visibility) {
|
|
318
|
+
'hidden' { 'Normal' }
|
|
319
|
+
'minimized' { 'Normal' }
|
|
320
|
+
default { 'Normal' }
|
|
321
|
+
}
|
|
322
|
+
$process = Start-Process -FilePath $Exe -ArgumentList $argumentList -PassThru -WindowStyle $windowStyle
|
|
323
|
+
if ($Visibility -ne 'normal') { Set-ProcessWindowVisibility $process.Id $Visibility }
|
|
324
|
+
|
|
325
|
+
$dialog = $null
|
|
326
|
+
$recovered = $false
|
|
327
|
+
$crashed = $false
|
|
328
|
+
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
329
|
+
$cleanupPids = @($process.Id)
|
|
330
|
+
|
|
331
|
+
# The keys that dismiss the recovery prompt of Live, in the order they are tried.
|
|
332
|
+
$recoveryKeys = @('n', 'esc')
|
|
333
|
+
$recoveryAttempt = 0
|
|
334
|
+
$recoveryKeySentAt = $null
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
while ((Get-Date) -lt $deadline) {
|
|
338
|
+
Start-Sleep -Milliseconds 200
|
|
339
|
+
$cleanupPids = @(Get-ProcessTreeIds @($process.Id))
|
|
340
|
+
Debug-Log "process tree: $($cleanupPids -join ',')"
|
|
341
|
+
$running = @($cleanupPids | ForEach-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue })
|
|
342
|
+
if ($running.Count -eq 0) { break }
|
|
343
|
+
|
|
344
|
+
$text = Read-Log $logStart
|
|
345
|
+
|
|
346
|
+
# Live writes the text of its message boxes to its log, and its dialogs are Qt windows that
|
|
347
|
+
# UI Automation sees nothing in, so the log is where a dialog of Live is read from.
|
|
348
|
+
$messages = [regex]::Matches($text, 'Message Box: (.*)')
|
|
349
|
+
if ($messages.Count -gt 0) {
|
|
350
|
+
$message = $messages[$messages.Count - 1].Groups[1].Value.Trim()
|
|
351
|
+
if ($message -match 'recover your work') {
|
|
352
|
+
# The recovery prompt holds the set back: the document is not loaded until it is gone.
|
|
353
|
+
# The key goes to the windows of Live as soon as the prompt is seen, and again with the
|
|
354
|
+
# next key when the prompt is still there.
|
|
355
|
+
if ($recoveryAttempt -lt $recoveryKeys.Count) {
|
|
356
|
+
$key = $recoveryKeys[$recoveryAttempt]
|
|
357
|
+
$recoveryAttempt++
|
|
358
|
+
$recoveryKeySentAt = Get-Date
|
|
359
|
+
Debug-Log "dismissing the recovery prompt with $key"
|
|
360
|
+
[void](Send-KeyToWindows $cleanupPids $key)
|
|
361
|
+
} elseif ($null -ne $recoveryKeySentAt -and
|
|
362
|
+
((Get-Date) - $recoveryKeySentAt).TotalSeconds -ge 1) {
|
|
363
|
+
# The prompt did not go away. It is the window that has the focus that is dismissed,
|
|
364
|
+
# so the app is brought to the foreground and the key is sent to it.
|
|
365
|
+
$recoveryKeySentAt = Get-Date
|
|
366
|
+
Debug-Log "dismissing the recovery prompt with the foreground"
|
|
367
|
+
[void](Send-DismissKey $process.Id 'n')
|
|
368
|
+
}
|
|
369
|
+
} elseif (-not $dialog) {
|
|
370
|
+
$dialog = $message
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if ($text -match 'Begin ExchangeDocument') { $recovered = $true }
|
|
374
|
+
if ($text -match 'Exception: Fatal Error') { $crashed = $true }
|
|
375
|
+
if ($text -match 'End ExchangeDocument') { break }
|
|
376
|
+
|
|
377
|
+
foreach ($processId in $cleanupPids) {
|
|
378
|
+
$windows = @(Get-Windows $processId)
|
|
379
|
+
Debug-Log "visible windows for PID ${processId}: $($windows.Count)"
|
|
380
|
+
foreach ($window in $windows) {
|
|
381
|
+
$dialogElement = Get-DialogElement $window.Handle
|
|
382
|
+
$shown = Get-DialogText $dialogElement
|
|
383
|
+
Debug-Log "window PID=$processId class=$($window.Class) title=$($window.Title) text=$shown"
|
|
384
|
+
if (-not $shown) { continue }
|
|
385
|
+
|
|
386
|
+
$dialogInfo = Get-DialogInfo $shown
|
|
387
|
+
$dialog = $shown
|
|
388
|
+
|
|
389
|
+
if ($dialogInfo.Type -eq 'fatal') {
|
|
390
|
+
$crashed = $true
|
|
391
|
+
if (Click-Button $dialogElement $dialogInfo.Action) { }
|
|
392
|
+
break
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if ($dialogInfo.Type -eq 'recovery') {
|
|
396
|
+
if (Click-Button $dialogElement $dialogInfo.Action) {
|
|
397
|
+
$recovered = $true
|
|
398
|
+
$dialog = $null
|
|
399
|
+
break
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if ($dialogInfo.Type -eq 'save_changes') {
|
|
404
|
+
if (Click-Button $dialogElement $dialogInfo.Action) { }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if ($dialogInfo.Type -and $dialogInfo.Type -ne 'unknown') { break }
|
|
408
|
+
}
|
|
409
|
+
if ($dialog) { break }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if ($dialog) { break }
|
|
413
|
+
if ($recovered) { continue }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
$cleanupPids = @(Get-ProcessTreeIds @($process.Id))
|
|
417
|
+
foreach ($processId in $cleanupPids) {
|
|
418
|
+
foreach ($window in Get-Windows $processId) {
|
|
419
|
+
$dialogElement = Get-DialogElement $window.Handle
|
|
420
|
+
$shown = Get-DialogText $dialogElement
|
|
421
|
+
if ($shown -and $shown -notmatch 'serious program error|fatal error') {
|
|
422
|
+
if (Click-Button $dialogElement 'OK') { }
|
|
423
|
+
elseif (Click-Button $dialogElement 'No') { }
|
|
424
|
+
elseif (Click-Button $dialogElement 'Yes') { }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
$cleanupPids = @(Get-ProcessTreeIds @($process.Id))
|
|
431
|
+
Close-VisibleWindowsForPids $cleanupPids
|
|
432
|
+
|
|
433
|
+
# Live asks whether to save the set when it is closed. The set is a test, so the answer is no.
|
|
434
|
+
# Live that is closed this way leaves no crash recovery state behind, and the next run starts
|
|
435
|
+
# without a recovery prompt.
|
|
436
|
+
$closeDeadline = (Get-Date).AddSeconds(5)
|
|
437
|
+
while ((Get-Date) -lt $closeDeadline) {
|
|
438
|
+
$messages = [regex]::Matches((Read-Log $logStart), 'Message Box: (.*)')
|
|
439
|
+
if ($messages.Count -gt 0 -and $messages[$messages.Count - 1].Groups[1].Value -match 'Save changes') {
|
|
440
|
+
[void](Send-KeyToWindows @(Get-ProcessTreeIds @($process.Id)) 'n')
|
|
441
|
+
}
|
|
442
|
+
$running = @(Get-ProcessTreeIds @($process.Id) | ForEach-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue })
|
|
443
|
+
if ($running.Count -eq 0) { break }
|
|
444
|
+
Start-Sleep -Milliseconds 200
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
Stop-ProcessTree @($process.Id)
|
|
448
|
+
Cleanup-Notepad
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if ($dialog) { Write-Output ('DIALOG: ' + $dialog) } else { Write-Output 'DIALOG: none' }
|
|
452
|
+
if ($dialog) {
|
|
453
|
+
$dialogInfo = Get-DialogInfo $dialog
|
|
454
|
+
if ($dialogInfo.Type) { Write-Output ('DIALOG_TYPE: ' + $dialogInfo.Type) }
|
|
455
|
+
if ($dialogInfo.Action) { Write-Output ('DISMISS_ACTION: ' + $dialogInfo.ActionType + '=' + $dialogInfo.ActionValue) }
|
|
456
|
+
} else {
|
|
457
|
+
Write-Output 'DIALOG_TYPE: none'
|
|
458
|
+
Write-Output 'DISMISS_ACTION: none'
|
|
459
|
+
}
|
|
460
|
+
if ($crashed) { Write-Output 'CRASHED: yes' } else { Write-Output 'CRASHED: no' }
|
|
461
|
+
if ($recovered) { Write-Output 'RECOVERED: yes' } else { Write-Output 'RECOVERED: no' }
|
|
462
|
+
|
|
463
|
+
Write-Output '--- LOG ---'
|
|
464
|
+
Write-Output (Read-Log $logStart)
|
data/gui_harness.gemspec
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require_relative 'lib/gui_harness/version'
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
host = 'https://github.com/mslinn/gui_harness'
|
|
5
|
+
|
|
6
|
+
spec.authors = ['Mike Slinn']
|
|
7
|
+
spec.bindir = 'exe'
|
|
8
|
+
spec.executables = []
|
|
9
|
+
spec.description = <<~END_DESC
|
|
10
|
+
GUI automation harness for round-trip testing of real GUI applications.
|
|
11
|
+
END_DESC
|
|
12
|
+
spec.email = ['mslinn@mslinn.com']
|
|
13
|
+
spec.files = Dir['.rubocop.yml', 'LICENSE.*', 'Rakefile', '{assets,lib,spec}/**/*', '*.gemspec', '*.md']
|
|
14
|
+
spec.homepage = 'https://mslinn.com'
|
|
15
|
+
spec.license = 'MIT'
|
|
16
|
+
spec.metadata = {
|
|
17
|
+
'allowed_push_host' => 'https://rubygems.org',
|
|
18
|
+
'bug_tracker_uri' => "#{host}/issues",
|
|
19
|
+
'changelog_uri' => "#{host}/CHANGELOG.md",
|
|
20
|
+
'homepage_uri' => spec.homepage,
|
|
21
|
+
'source_code_uri' => host,
|
|
22
|
+
}
|
|
23
|
+
spec.name = 'gui_harness'
|
|
24
|
+
spec.platform = Gem::Platform::RUBY
|
|
25
|
+
spec.post_install_message = <<~END_MESSAGE
|
|
26
|
+
|
|
27
|
+
Thanks for installing #{spec.name}!
|
|
28
|
+
|
|
29
|
+
END_MESSAGE
|
|
30
|
+
spec.require_paths = ['lib']
|
|
31
|
+
spec.required_ruby_version = '>= 3.4'
|
|
32
|
+
spec.summary = 'GUI harness for running and observing GUI apps in controlled tests'
|
|
33
|
+
spec.version = GuiHarness::VERSION
|
|
34
|
+
|
|
35
|
+
spec.add_dependency 'colorator'
|
|
36
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require_relative 'log'
|
|
6
|
+
|
|
7
|
+
# Real-GUI compatibility harness.
|
|
8
|
+
#
|
|
9
|
+
# The Ruby layer is intentionally thin: it launches a native Windows GUI process,
|
|
10
|
+
# passes the target file to it, and then interprets whatever visible modal window
|
|
11
|
+
# the app exposes. The actual window enumeration and UIAutomation inspection live
|
|
12
|
+
# in the PowerShell sidecar so the Ruby API can remain focused on test output and
|
|
13
|
+
# structured results.
|
|
14
|
+
module GuiHarness
|
|
15
|
+
module LiveLauncher
|
|
16
|
+
VISIBILITY_LEVELS = %i[normal minimized hidden].freeze
|
|
17
|
+
INVISIBLE_VISIBILITY_LEVELS = %i[minimized hidden].freeze
|
|
18
|
+
|
|
19
|
+
DIALOG_RULES = [
|
|
20
|
+
{
|
|
21
|
+
action: { type: 'button', value: 'OK' },
|
|
22
|
+
id: 'fatal',
|
|
23
|
+
pattern: /A serious program error has occurred\.|fatal error/i,
|
|
24
|
+
type: :fatal,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
action: { type: 'button', value: 'No' },
|
|
28
|
+
id: 'recover',
|
|
29
|
+
pattern: /Would you like to recover your work\?/i,
|
|
30
|
+
type: :recovery,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
action: { type: 'button', value: 'No' },
|
|
34
|
+
id: 'save',
|
|
35
|
+
pattern: /Save changes\?/i,
|
|
36
|
+
type: :save_changes,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
action: { type: 'button', value: 'OK' },
|
|
40
|
+
id: 'error',
|
|
41
|
+
pattern: /Cannot find the file|Make sure the path and file name are correct|The file is in use|
|
|
42
|
+
The file does not exist|cannot find the path specified/ix,
|
|
43
|
+
type: :error,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
action: { type: 'button', value: 'OK' },
|
|
47
|
+
id: 'shutdown',
|
|
48
|
+
pattern: /Live will shut down after this message box is closed\./i,
|
|
49
|
+
type: :fatal,
|
|
50
|
+
}
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
SCRIPT = File.read(File.expand_path('../../assets/gui_harness_live_launcher.ps1', __dir__))
|
|
54
|
+
|
|
55
|
+
DISMISS_ACTIONS = {
|
|
56
|
+
'button=OK' => { type: 'button', value: 'OK' },
|
|
57
|
+
'button=No' => { type: 'button', value: 'No' },
|
|
58
|
+
'key=n' => { type: 'key', value: 'n' },
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
Result = Struct.new(:exit_code, :output, :dialog, :dialog_type, :dismiss_action, :log, :loaded, :crashed, keyword_init: true) do
|
|
62
|
+
def success?
|
|
63
|
+
exit_code.to_i.zero? && dialog.nil? && !crashed
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def outcome
|
|
67
|
+
return :loaded if exit_code.to_i.zero? && !crashed && dialog.nil?
|
|
68
|
+
return :crashed if crashed || dialog_type == :fatal
|
|
69
|
+
return :unknown if dialog_type == :unknown
|
|
70
|
+
return :blocked if dialog_type && dialog_type != :fatal
|
|
71
|
+
|
|
72
|
+
:unknown
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class << self
|
|
77
|
+
def classify_dialog(text)
|
|
78
|
+
return nil if text.nil? || text.strip.empty?
|
|
79
|
+
|
|
80
|
+
rule = DIALOG_RULES.find { |candidate| text.match?(candidate[:pattern]) }
|
|
81
|
+
return { type: :unknown, action: nil } if rule.nil?
|
|
82
|
+
|
|
83
|
+
{ type: rule[:type], action: rule[:action].dup }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize_visibility(visibility)
|
|
87
|
+
candidate = visibility.to_s.strip.downcase.to_sym
|
|
88
|
+
return candidate if VISIBILITY_LEVELS.include?(candidate)
|
|
89
|
+
|
|
90
|
+
raise ArgumentError, "visibility must be one of: #{VISIBILITY_LEVELS.join(', ')}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def visibility_style(visibility)
|
|
94
|
+
case normalize_visibility(visibility)
|
|
95
|
+
when :hidden then 'Hidden'
|
|
96
|
+
when :minimized then 'Minimized'
|
|
97
|
+
else 'Normal'
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def debug_visibility_message(visibility, executable)
|
|
102
|
+
level = normalize_visibility(visibility)
|
|
103
|
+
"Launching GUI app with visibility=#{level} executable=#{executable}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def launch(executable, set, log: nil, timeout: 3, visibility: :normal)
|
|
107
|
+
level = normalize_visibility(visibility)
|
|
108
|
+
Log.debug(debug_visibility_message(level, executable)) if INVISIBLE_VISIBILITY_LEVELS.include?(level)
|
|
109
|
+
|
|
110
|
+
script = write_powershell_live_launcher_script
|
|
111
|
+
args = ['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', windows_path(script),
|
|
112
|
+
'-Exe', windows_path(executable), '-Set', windows_path(set),
|
|
113
|
+
'-TimeoutSeconds', timeout.to_s, '-Visibility', level.to_s]
|
|
114
|
+
args += ['-Log', windows_path(log)] unless log.nil?
|
|
115
|
+
|
|
116
|
+
output, status = Open3.capture2e(*args)
|
|
117
|
+
parse output, status
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def available?(executable)
|
|
121
|
+
return false if executable.nil?
|
|
122
|
+
return false unless File.exist?(executable)
|
|
123
|
+
|
|
124
|
+
_, status = Open3.capture2e 'powershell.exe', '-NoProfile', '-Command', 'exit 0'
|
|
125
|
+
status.success?
|
|
126
|
+
rescue Errno::ENOENT
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def windows_path(path)
|
|
131
|
+
return path unless path.start_with?('/')
|
|
132
|
+
|
|
133
|
+
out, status = Open3.capture2e 'wslpath', '-w', path
|
|
134
|
+
status.success? ? out.strip : path
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def write_powershell_live_launcher_script
|
|
140
|
+
path = File.join windows_directory, 'gui_harness_live_launcher.ps1'
|
|
141
|
+
File.write path, SCRIPT
|
|
142
|
+
path
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def windows_directory
|
|
146
|
+
@windows_directory ||= begin
|
|
147
|
+
dir = ENV['TEMP'] || ENV['TMP'] || 'C:\Windows\Temp'
|
|
148
|
+
wsl = windows_path dir
|
|
149
|
+
wsl.start_with?('/mnt/') ? wsl : '/mnt/c/Windows/Temp'
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def parse(output, status)
|
|
154
|
+
clean_output = output.gsub("\r\n", "\n")
|
|
155
|
+
dialog = clean_output[/^DIALOG: (.*)$/, 1]
|
|
156
|
+
dialog = nil if dialog.nil? || dialog.strip == 'none'
|
|
157
|
+
|
|
158
|
+
dialog_type = clean_output[/^DIALOG_TYPE: (.*)$/, 1]&.strip
|
|
159
|
+
dialog_type = nil if dialog_type.to_s.casecmp('none').zero?
|
|
160
|
+
dialog_type = dialog_type&.to_sym
|
|
161
|
+
dismiss_action = clean_output[/^DISMISS_ACTION: (.*)$/, 1]
|
|
162
|
+
dismiss_action = DISMISS_ACTIONS[dismiss_action] if dismiss_action
|
|
163
|
+
|
|
164
|
+
dialog_info = classify_dialog(dialog)
|
|
165
|
+
dialog_type ||= dialog_info && dialog_info[:type]
|
|
166
|
+
dismiss_action ||= dialog_info && dialog_info[:action]
|
|
167
|
+
|
|
168
|
+
crashed = clean_output[/^CRASHED: (.*)$/, 1].to_s.strip == 'yes'
|
|
169
|
+
log = clean_output[/--- LOG ---\s*(.*)\z/m, 1].to_s
|
|
170
|
+
loaded = log.include?('End ExchangeDocument')
|
|
171
|
+
|
|
172
|
+
Result.new(crashed: crashed,
|
|
173
|
+
dialog: dialog,
|
|
174
|
+
dialog_type: dialog_type,
|
|
175
|
+
dismiss_action: dismiss_action,
|
|
176
|
+
exit_code: status.exitstatus,
|
|
177
|
+
loaded: loaded,
|
|
178
|
+
log: log,
|
|
179
|
+
output: clean_output)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
LiveLauncher = GuiHarness::LiveLauncher
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module GuiHarness
|
|
6
|
+
# The harness's logger.
|
|
7
|
+
#
|
|
8
|
+
# The logger is a constant of GuiHarness, and not a top-level one, because a program that uses
|
|
9
|
+
# the harness has a logger of its own, and the harness does not take it over.
|
|
10
|
+
#
|
|
11
|
+
# The debug output of a launch is a stream of window and process states, and it is of no use to
|
|
12
|
+
# anyone who is not reading it, so it is written only when the harness is being debugged.
|
|
13
|
+
module Log
|
|
14
|
+
class << self
|
|
15
|
+
# Writes a debug message.
|
|
16
|
+
#
|
|
17
|
+
# @param message [String] the message
|
|
18
|
+
# @return [void]
|
|
19
|
+
def debug(message)
|
|
20
|
+
logger.debug message
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The logger the harness writes to.
|
|
24
|
+
#
|
|
25
|
+
# @return [Logger] the logger
|
|
26
|
+
def logger
|
|
27
|
+
@logger ||= Logger.new($stderr).tap do |logger|
|
|
28
|
+
logger.level = debug? ? Logger::DEBUG : Logger::FATAL
|
|
29
|
+
logger.formatter = proc do |severity, _time, _progname, message|
|
|
30
|
+
"[gui_harness] #{severity}: #{message}\n"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# @return [Boolean] true if the harness is being debugged
|
|
38
|
+
def debug?
|
|
39
|
+
ENV['GUI_HARNESS_DEBUG'] == '1' || ENV['DEBUG'] == '1'
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/gui_harness.rb
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: false
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
|
|
7
|
+
RSpec.describe GuiHarness::LiveLauncher do
|
|
8
|
+
# The number of Notepad processes Windows is running.
|
|
9
|
+
#
|
|
10
|
+
# @return [Integer] the number of them
|
|
11
|
+
def notepad_process_count
|
|
12
|
+
command = 'powershell.exe -NoProfile -Command "Get-Process -Name notepad ' \
|
|
13
|
+
'-ErrorAction SilentlyContinue | Measure-Object | ' \
|
|
14
|
+
'Select-Object -ExpandProperty Count"'
|
|
15
|
+
`#{command}`.strip.to_i
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe '.classify_dialog' do
|
|
19
|
+
it 'recognizes each supported dialog family and the required dismissal action' do
|
|
20
|
+
expect(described_class.classify_dialog('A serious program error has occurred.')).to eq(
|
|
21
|
+
{ type: :fatal, action: { type: 'button', value: 'OK' } }
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
msg = "Live unexpectedly quit while you were working on the Live Set 'Untitled'. Would you like to recover your work?"
|
|
25
|
+
expect(described_class.classify_dialog(msg)).to eq(
|
|
26
|
+
{ type: :recovery, action: { type: 'button', value: 'No' } }
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
expect(described_class.classify_dialog('Save changes?')).to eq(
|
|
30
|
+
{ type: :save_changes, action: { type: 'button', value: 'No' } }
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
notepad_error = "Cannot find the file 'C:/missing.txt'. Make sure the path and file name are correct."
|
|
34
|
+
expect(described_class.classify_dialog(notepad_error)).to eq(
|
|
35
|
+
{ type: :error, action: { type: 'button', value: 'OK' } }
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
expect(described_class.classify_dialog('Some unexpected Live window')).to eq(
|
|
39
|
+
{ type: :unknown, action: nil }
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
describe '.visibility_levels' do
|
|
45
|
+
[%i[normal success], %i[normal failure], %i[minimized success], %i[minimized failure], %i[hidden success],
|
|
46
|
+
%i[hidden failure]].each do |visibility, status|
|
|
47
|
+
it "classifies #{status} correctly when visibility=#{visibility}" do
|
|
48
|
+
output = if status == :success
|
|
49
|
+
<<~TEXT
|
|
50
|
+
DIALOG: none
|
|
51
|
+
DIALOG_TYPE: none
|
|
52
|
+
DISMISS_ACTION: none
|
|
53
|
+
CRASHED: no
|
|
54
|
+
--- LOG ---
|
|
55
|
+
Loading document "C:/demo.als"
|
|
56
|
+
Begin ExchangeDocument
|
|
57
|
+
End ExchangeDocument
|
|
58
|
+
TEXT
|
|
59
|
+
else
|
|
60
|
+
<<~TEXT
|
|
61
|
+
DIALOG: Cannot find the file 'C:/missing.txt'. Make sure the path and file name are correct.
|
|
62
|
+
DIALOG_TYPE: error
|
|
63
|
+
DISMISS_ACTION: button=OK
|
|
64
|
+
CRASHED: no
|
|
65
|
+
--- LOG ---
|
|
66
|
+
Loading document "C:/demo.als"
|
|
67
|
+
Begin ExchangeDocument
|
|
68
|
+
Error: file not found
|
|
69
|
+
TEXT
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
status_obj = Struct.new(:exitstatus, keyword_init: true).new(exitstatus: 0)
|
|
73
|
+
result = described_class.send(:parse, output, status_obj)
|
|
74
|
+
|
|
75
|
+
if status == :success
|
|
76
|
+
expect(result.success?).to be(true)
|
|
77
|
+
expect(result.dialog).to be_nil
|
|
78
|
+
expect(result.dialog_type).to be_nil
|
|
79
|
+
expect(result.outcome).to eq(:loaded)
|
|
80
|
+
else
|
|
81
|
+
expect(result.success?).to be(false)
|
|
82
|
+
expect(result.dialog).to include('Cannot find the file')
|
|
83
|
+
expect(result.dialog_type).to eq(:error)
|
|
84
|
+
expect(result.dismiss_action).to eq({ type: 'button', value: 'OK' })
|
|
85
|
+
expect(result.outcome).to eq(:blocked)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if %i[minimized hidden].include?(visibility)
|
|
89
|
+
message = described_class.debug_visibility_message(visibility, 'notepad.exe')
|
|
90
|
+
expect(message).to include("visibility=#{visibility}")
|
|
91
|
+
expect(message).to include('notepad.exe')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
describe '.launch' do
|
|
98
|
+
it 'opens Notepad from WSL with a missing file and surfaces the real Windows error dialog quickly' do
|
|
99
|
+
missing_path = 'C:/path/that/does/not/exist/notepad-demo.txt'
|
|
100
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
101
|
+
result = described_class.launch('notepad.exe', missing_path, timeout: 3)
|
|
102
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
103
|
+
|
|
104
|
+
expect(elapsed).to be < 6
|
|
105
|
+
expect(result.exit_code).to eq(0)
|
|
106
|
+
expect(result.crashed).to be(false)
|
|
107
|
+
expect(result.outcome).to(satisfy { |outcome| %i[loaded unknown blocked].include?(outcome) })
|
|
108
|
+
if result.dialog
|
|
109
|
+
expect(result.dialog).to match(/cannot find the path specified/i)
|
|
110
|
+
expect(result.dialog_type).to eq(:error)
|
|
111
|
+
expect(result.dismiss_action).to eq({ type: 'button', value: 'OK' })
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
expect(notepad_process_count).to eq(0) if Gem.win_platform?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'opens Notepad without an error dialog, reports success, and closes cleanly' do
|
|
118
|
+
file = Tempfile.new(['notepad_ok', '.txt'])
|
|
119
|
+
file.write("hello from gui_harness\n")
|
|
120
|
+
file.flush
|
|
121
|
+
|
|
122
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
123
|
+
result = described_class.launch('notepad.exe', file.path, timeout: 3)
|
|
124
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
125
|
+
|
|
126
|
+
expect(elapsed).to be < 6
|
|
127
|
+
expect(result.exit_code).to eq(0)
|
|
128
|
+
expect(result.dialog).to be_nil
|
|
129
|
+
expect(result.dialog_type).to be_nil
|
|
130
|
+
expect(result.crashed).to be(false)
|
|
131
|
+
expect(result.outcome).to eq(:loaded)
|
|
132
|
+
expect(result.success?).to be(true)
|
|
133
|
+
|
|
134
|
+
expect(notepad_process_count).to eq(0) if Gem.win_platform?
|
|
135
|
+
ensure
|
|
136
|
+
file&.close
|
|
137
|
+
file&.unlink
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
describe '.parse' do
|
|
142
|
+
it 'reads the launch result from launcher output' do
|
|
143
|
+
output = <<~TEXT
|
|
144
|
+
DIALOG: none
|
|
145
|
+
CRASHED: no
|
|
146
|
+
--- LOG ---
|
|
147
|
+
Loading document "C:/demo.als"
|
|
148
|
+
Begin ExchangeDocument
|
|
149
|
+
End ExchangeDocument
|
|
150
|
+
TEXT
|
|
151
|
+
|
|
152
|
+
status = Struct.new(:exitstatus, keyword_init: true).new(exitstatus: 0)
|
|
153
|
+
result = described_class.send(:parse, output, status)
|
|
154
|
+
|
|
155
|
+
expect(result.exit_code).to eq(0)
|
|
156
|
+
expect(result.dialog).to be_nil
|
|
157
|
+
expect(result.dialog_type).to be_nil
|
|
158
|
+
expect(result.dismiss_action).to be_nil
|
|
159
|
+
expect(result.outcome).to eq(:loaded)
|
|
160
|
+
expect(result.crashed).to be false
|
|
161
|
+
expect(result.loaded).to be true
|
|
162
|
+
expect(result.log).to include('End ExchangeDocument')
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'detects a fatal crash from the launcher output' do
|
|
166
|
+
output = <<~TEXT
|
|
167
|
+
DIALOG: A serious program error has occurred.
|
|
168
|
+
DIALOG_TYPE: fatal
|
|
169
|
+
DISMISS_ACTION: button=OK
|
|
170
|
+
CRASHED: yes
|
|
171
|
+
--- LOG ---
|
|
172
|
+
Begin ExchangeDocument
|
|
173
|
+
Exception: Fatal Error
|
|
174
|
+
TEXT
|
|
175
|
+
status = Struct.new(:exitstatus, keyword_init: true).new(exitstatus: 1)
|
|
176
|
+
result = described_class.send(:parse, output, status)
|
|
177
|
+
|
|
178
|
+
expect(result.dialog).to eq('A serious program error has occurred.')
|
|
179
|
+
expect(result.dialog_type).to eq(:fatal)
|
|
180
|
+
expect(result.dismiss_action).to eq({ type: 'button', value: 'OK' })
|
|
181
|
+
expect(result.outcome).to eq(:crashed)
|
|
182
|
+
expect(result.crashed).to be true
|
|
183
|
+
expect(result.loaded).to be false
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: gui_harness
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Mike Slinn
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: colorator
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
description: 'GUI automation harness for round-trip testing of real GUI applications.
|
|
27
|
+
|
|
28
|
+
'
|
|
29
|
+
email:
|
|
30
|
+
- mslinn@mslinn.com
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- ".rubocop.yml"
|
|
36
|
+
- README.md
|
|
37
|
+
- Rakefile
|
|
38
|
+
- assets/gui_harness_live_launcher.ps1
|
|
39
|
+
- gui_harness.gemspec
|
|
40
|
+
- lib/gui_harness.rb
|
|
41
|
+
- lib/gui_harness/live_launcher.rb
|
|
42
|
+
- lib/gui_harness/log.rb
|
|
43
|
+
- lib/gui_harness/version.rb
|
|
44
|
+
- spec/gui_harness/live_launcher_spec.rb
|
|
45
|
+
- spec/gui_harness_spec.rb
|
|
46
|
+
- spec/spec_helper.rb
|
|
47
|
+
homepage: https://mslinn.com
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
allowed_push_host: https://rubygems.org
|
|
52
|
+
bug_tracker_uri: https://github.com/mslinn/gui_harness/issues
|
|
53
|
+
changelog_uri: https://github.com/mslinn/gui_harness/CHANGELOG.md
|
|
54
|
+
homepage_uri: https://mslinn.com
|
|
55
|
+
source_code_uri: https://github.com/mslinn/gui_harness
|
|
56
|
+
post_install_message: |2+
|
|
57
|
+
|
|
58
|
+
Thanks for installing gui_harness!
|
|
59
|
+
|
|
60
|
+
rdoc_options: []
|
|
61
|
+
require_paths:
|
|
62
|
+
- lib
|
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.4'
|
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '0'
|
|
73
|
+
requirements: []
|
|
74
|
+
rubygems_version: 4.0.23
|
|
75
|
+
specification_version: 4
|
|
76
|
+
summary: GUI harness for running and observing GUI apps in controlled tests
|
|
77
|
+
test_files: []
|
|
78
|
+
...
|