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 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 [![Gem Version](https://badge.fury.io/rb/gui_harness.svg)](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,11 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ desc 'Run the test suite'
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << 'test'
7
+ t.libs << 'lib'
8
+ t.test_files = FileList['test/**/*_test.rb']
9
+ end
10
+
11
+ task default: :test
@@ -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)
@@ -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
@@ -0,0 +1,3 @@
1
+ module GuiHarness
2
+ VERSION = '0.1.0'.freeze unless defined? VERSION
3
+ end
@@ -0,0 +1,2 @@
1
+ require_relative 'gui_harness/version'
2
+ require_relative 'gui_harness/live_launcher'
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: false
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe GuiHarness do
6
+ it 'has a version constant' do
7
+ expect(described_class::VERSION).to be_a(String)
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ require 'rspec'
2
+ require_relative '../lib/gui_harness'
3
+
4
+ RSpec.configure do |config|
5
+ config.order = :random
6
+ end
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
+ ...