Home

dev / openkara

publicthedavidweng/OpenKara· sync paused
Overview Code History Branches Pull requestsIssuesInsights
main
HomeOverview Code PRsIssues
dev/openkara/scripts/ci/run-windows-desktop-e2e.ps1
Raw
1[CmdletBinding()]2param (3    [Parameter(Mandatory = $true)]4    [string]$InstallDir,56    [string]$Scenario = "keyboard-workflow",78    [string]$OutputDir = "$env:RUNNER_TEMP\desktop-e2e",910    [string]$ProbePath,1112    [int]$ProbeTimeoutMs = 5000,
13
14 [int]$StepDelayMs = 800,
15
16 # Keyboard-workflow separation and multi-step tab searches need more headroom
17 # than the short installed-workflow launch/close smoke.
18 [int]$StepTimeoutMs = 30000
19)
20
21$ErrorActionPreference = "Stop"
22
23if ($OutputDir -eq "\desktop-e2e" -or [string]::IsNullOrWhiteSpace($OutputDir)) {
24 $OutputDir = Join-Path ([System.IO.Path]::GetTempPath()) "desktop-e2e"
25}
26
27New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
28
29$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
30$scenariosPath = [System.IO.Path]::Combine($repoRoot, "tests", "desktop", "windows", "scenarios.json")
31if (-not (Test-Path -Path $scenariosPath -PathType Leaf)) {
32 throw "Scenarios file was not found at $scenariosPath"
33}
34
35$scenarios = Get-Content -Path $scenariosPath -Raw | ConvertFrom-Json
36$selectedScenario = $scenarios | Where-Object { $_.id -eq $Scenario } | Select-Object -First 1
37if ($null -eq $selectedScenario) {
38 throw "Scenario '$Scenario' was not found in $scenariosPath"
39}
40
41$exePath = Join-Path $InstallDir "OpenKara.exe"
42if (-not (Test-Path -Path $exePath -PathType Leaf)) {
43 throw "OpenKara.exe was not found at $exePath"
44}
45
46$versionInfo = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($exePath)
47$appVersion = $versionInfo.ProductVersion
48if ([string]::IsNullOrWhiteSpace($appVersion)) {
49 $appVersion = $versionInfo.FileVersion
50}
51if ([string]::IsNullOrWhiteSpace($appVersion)) {
52 $appVersion = "0.0.0"
53}
54
55if ($env:GITHUB_SHA) {
56 $commitSha = $env:GITHUB_SHA
57} else {
58 $commitSha = "unknown"
59 try {
60 $gitOutput = & git -C $repoRoot rev-parse HEAD 2>$null
61 if ($? -and $gitOutput) {
62 $commitSha = $gitOutput
63 }
64 } catch {
65 $commitSha = "unknown"
66 }
67}
68
69$osInfo = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue
70if ($osInfo) {
71 $osVersion = "$($osInfo.Caption) $($osInfo.Version)"
72} else {
73 $osVersion = [System.Environment]::OSVersion.VersionString
74}
75
76$webview2Version = $null
77$wvKeys = @(
78 "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4115-9B8B-1EDECC4588C6}",
79 "HKLM:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4115-9B8B-1EDECC4588C6}"
80)
81foreach ($key in $wvKeys) {
82 $prop = Get-ItemProperty -Path $key -Name "pv" -ErrorAction SilentlyContinue
83 if ($prop -and $prop.pv) {
84 $webview2Version = $prop.pv
85 break
86 }
87}
88if ([string]::IsNullOrWhiteSpace($webview2Version)) {
89 $webview2Version = "unknown"
90}
91
92$logPixels = Get-ItemProperty -Path "HKCU:\Control Panel\Desktop" -Name "LogPixels" -ErrorAction SilentlyContinue
93if ($logPixels -and $logPixels.LogPixels) {
94 $displayScale = "{0}%" -f [math]::Round($logPixels.LogPixels / 96 * 100)
95} else {
96 $displayScale = "unknown"
97}
98
99Add-Type -TypeDefinition @"
100using System;
101using System.Runtime.InteropServices;
102using System.Text;
103public class OpenKaraWin32 {
104 [DllImport("user32.dll")]
105 [return: MarshalAs(UnmanagedType.Bool)]
106 public static extern bool SetForegroundWindow(IntPtr hWnd);
107
108 [DllImport("user32.dll")]
109 [return: MarshalAs(UnmanagedType.Bool)]
110 public static extern bool IsWindow(IntPtr hWnd);
111
112 [DllImport("user32.dll")]
113 [return: MarshalAs(UnmanagedType.Bool)]
114 public static extern bool IsWindowVisible(IntPtr hWnd);
115
116 [DllImport("user32.dll", SetLastError = true)]
117 public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
118
119 [DllImport("user32.dll", SetLastError = true)]
120 public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
121
122 [DllImport("user32.dll")]
123 [return: MarshalAs(UnmanagedType.Bool)]
124 public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
125
126 public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
127
128 [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
129 public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
130
131 public static IntPtr FindWindowByTitle(int processId, string title) {
132 if (string.IsNullOrWhiteSpace(title)) return IntPtr.Zero;
133 IntPtr found = IntPtr.Zero;
134 string lowerTitle = title.ToLowerInvariant();
135 EnumWindows((hWnd, lParam) => {
136 if (!IsWindowVisible(hWnd)) return true;
137 if (processId > 0) {
138 if (GetWindowThreadProcessId(hWnd, out uint pid) == 0 || (int)pid != processId) return true;
139 }
140 StringBuilder sb = new StringBuilder(512);
141 if (GetWindowText(hWnd, sb, sb.Capacity) > 0) {
142 string windowTitle = sb.ToString();
143 if (windowTitle.Equals(title, StringComparison.OrdinalIgnoreCase) ||
144 windowTitle.IndexOf(title, StringComparison.OrdinalIgnoreCase) >= 0) {
145 found = hWnd;
146 return false;
147 }
148 }
149 return true;
150 }, IntPtr.Zero);
151 return found;
152 }
153
154 public static IntPtr FindFirstTopLevelWindow(int processId, string[] titles) {
155 if (titles is null || titles.Length == 0) return IntPtr.Zero;
156 foreach (string title in titles) {
157 IntPtr hWnd = FindWindowByTitle(processId, title);
158 if (hWnd != IntPtr.Zero) return hWnd;
159 }
160 // System file pickers (and some plugin hosts) may not share our PID.
161 if (processId > 0) {
162 foreach (string title in titles) {
163 IntPtr hWnd = FindWindowByTitle(0, title);
164 if (hWnd != IntPtr.Zero) return hWnd;
165 }
166 }
167 return IntPtr.Zero;
168 }
169}
170"@ -ErrorAction Stop
171
172Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
173
174function Resolve-ProbePath {
175 if ([string]::IsNullOrWhiteSpace($ProbePath)) {
176 $candidate = Join-Path $InstallDir "OpenKara.AccessibilityProbe.exe"
177 if (Test-Path -Path $candidate -PathType Leaf) {
178 $script:ProbePath = $candidate
179 return
180 }
181
182 $candidate = Join-Path $repoRoot "tools" "windows-accessibility" "OpenKara.AccessibilityProbe" "bin" "Release" "net8.0-windows" "OpenKara.AccessibilityProbe.exe"
183 if (Test-Path -Path $candidate -PathType Leaf) {
184 $script:ProbePath = $candidate
185 return
186 }
187
188 $candidate = Join-Path $repoRoot "tools" "windows-accessibility" "OpenKara.AccessibilityProbe" "bin" "Debug" "net8.0-windows" "OpenKara.AccessibilityProbe.exe"
189 if (Test-Path -Path $candidate -PathType Leaf) {
190 $script:ProbePath = $candidate
191 return
192 }
193
194 throw "OpenKara.AccessibilityProbe.exe was not found. Build the .NET project or pass -ProbePath."
195 }
196 if (-not (Test-Path -Path $ProbePath -PathType Leaf)) {
197 throw "Probe path does not exist: $ProbePath"
198 }
199 $script:ProbePath = $ProbePath
200}
201
202function Get-NowMs {
203 return [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
204}
205
206function Start-OpenKaraApp {
207 Get-Process -Name "OpenKara" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
208 Start-Sleep -Milliseconds 500
209
210 # Pass OPENKARA_APP_DATA_DIR explicitly so UI Automation reuses the seeded
211 # smoke app-data (managed runtime/model) instead of a fresh user profile.
212 $startInfo = New-Object System.Diagnostics.ProcessStartInfo
213 $startInfo.FileName = $exePath
214 $startInfo.WorkingDirectory = $InstallDir
215 $startInfo.UseShellExecute = $false
216 if (-not [string]::IsNullOrWhiteSpace($env:OPENKARA_APP_DATA_DIR)) {
217 $startInfo.EnvironmentVariables["OPENKARA_APP_DATA_DIR"] = $env:OPENKARA_APP_DATA_DIR
218 Write-Host "Launching OpenKara with OPENKARA_APP_DATA_DIR=$($env:OPENKARA_APP_DATA_DIR)"
219 }
220 if (-not [string]::IsNullOrWhiteSpace($env:OPENKARA_SMOKE_EP)) {
221 $startInfo.EnvironmentVariables["OPENKARA_SMOKE_EP"] = $env:OPENKARA_SMOKE_EP
222 }
223 # Force Chromium/WebView2 renderer accessibility on headless CI hosts that
224 # have no screen reader attached; otherwise the DOM UIA tree stays empty.
225 $forceA11y = "--force-renderer-accessibility"
226 $existingBrowserArgs = $startInfo.EnvironmentVariables["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"]
227 if ([string]::IsNullOrWhiteSpace($existingBrowserArgs)) {
228 $startInfo.EnvironmentVariables["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = $forceA11y
229 } elseif ($existingBrowserArgs -notlike "*force-renderer-accessibility*") {
230 $startInfo.EnvironmentVariables["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = "$existingBrowserArgs $forceA11y"
231 }
232 Write-Host "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=$($startInfo.EnvironmentVariables['WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS'])"
233
234 $process = New-Object System.Diagnostics.Process
235 $process.StartInfo = $startInfo
236 if (-not $process.Start()) {
237 throw "Failed to start OpenKara.exe"
238 }
239 return $process
240}
241
242function Wait-For-UiReady {
243 param(
244 [int]$ProcessId,
245 [int]$TimeoutMs = 90000,
246 # Main shell / post-setup only. Language-picker buttons alone must NOT
247 # count as ready — that trapped keyboard-workflow on first-run for ~15m.
248 [string[]]$ReadyNameHints = @(
249 "All Tracks",
250 "Separated",
251 "Play",
252 "Settings",
253 "Import",
254 "Queue",
255 "Create a new library",
256 "Open an existing library",
257 "Create new",
258 "Open existing"
259 )
260 )
261
262 $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMs)
263 $attempt = 0
264 $lastObservation = "no named interactive controls observed"
265 while ([DateTime]::UtcNow -lt $deadline) {
266 $attempt++
267 try {
268 # Nudge the webview so focus and accessibility hosts activate.
269 if ($null -ne $script:process -and $script:process.MainWindowHandle -ne [IntPtr]::Zero) {
270 [void][OpenKaraWin32]::SetForegroundWindow($script:process.MainWindowHandle)
271 if ($attempt -eq 1 -or ($attempt % 5) -eq 0) {
272 [System.Windows.Forms.SendKeys]::SendWait("{TAB}")
273 }
274 }
275 $tree = Get-UiTree -ProcessId $ProcessId -TimeoutMs ([Math]::Min($ProbeTimeoutMs, 8000))
276 $namedInteractive = @($tree | Where-Object {
277 $_.isOffscreen -ne $true -and
278 -not [string]::IsNullOrWhiteSpace($_.name) -and
279 @(
280 "Button", "Edit", "CheckBox", "RadioButton", "Hyperlink",
281 "ComboBox", "ListItem", "MenuItem", "TabItem", "Slider",
282 "SplitButton", "TreeItem", "Document", "Text"
283 ) -contains $_.controlType -and
284 $_.name -notin @("Minimize", "Maximize", "Close", "System", "OpenKara", "System Menu Bar")
285 })
286
287 $languagePicker = @($namedInteractive | Where-Object {
288 $_.name -and (
289 $_.name.IndexOf("Choose a language", [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -or
290 $_.name -match '^(EN English|DE Deutsch|FR Français|JA 日本語|简 简体中文)$'
291 )
292 })
293 if ($languagePicker.Count -gt 0) {
294 $lastObservation = "first-run language picker is still visible ($($languagePicker.Count) controls); library seed did not skip LibrarySetup"
295 Write-Host "Wait-For-UiReady attempt ${attempt}: $lastObservation"
296 Start-Sleep -Milliseconds 1500
297 continue
298 }
299
300 foreach ($hint in $ReadyNameHints) {
301 $hit = $namedInteractive | Where-Object {
302 $_.name.IndexOf($hint, [System.StringComparison]::OrdinalIgnoreCase) -ge 0
303 } | Select-Object -First 1
304 if ($null -ne $hit) {
305 Write-Host "UI ready after ${attempt} probe(s): found '$($hit.name)' ($($hit.controlType))"
306 return $tree
307 }
308 }
309 $lastObservation = "namedInteractive=$($namedInteractive.Count); no main-shell ready hint yet"
310 Write-Host "Wait-For-UiReady attempt ${attempt}: $lastObservation"
311 } catch {
312 Write-Warning "Wait-For-UiReady probe failed: $_"
313 $lastObservation = "probe error: $_"
314 }
315 Start-Sleep -Milliseconds 1500
316 }
317 throw "WebView UI did not reach main shell within ${TimeoutMs}ms ($lastObservation)"
318}
319
320function Wait-For-ProcessWindow {
321 param([System.Diagnostics.Process]$Process, [int]$TimeoutMs)
322
323 $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMs)
324 while ([DateTime]::UtcNow -lt $deadline) {
325 try {
326 $Process.Refresh()
327 if ($Process.MainWindowHandle -ne [IntPtr]::Zero -and [OpenKaraWin32]::IsWindowVisible($Process.MainWindowHandle)) {
328 return $Process.MainWindowHandle
329 }
330 } catch {
331 }
332 Start-Sleep -Milliseconds 100
333 }
334 throw "OpenKara main window did not appear within ${TimeoutMs}ms"
335}
336
337function Get-UiTree {
338 param([int]$ProcessId, [string]$WindowTitle = "", [int]$TimeoutMs = $ProbeTimeoutMs)
339
340 $snapshotPath = Join-Path $OutputDir ("uia-tree-{0}-{1}.json" -f $ProcessId, ([Guid]::NewGuid().ToString("N")))
341 $argList = @("--process-id", $ProcessId, "--output", $snapshotPath, "--timeout", $TimeoutMs)
342 if (-not [string]::IsNullOrWhiteSpace($WindowTitle)) {
343 $argList += @("--window-title", $WindowTitle)
344 }
345
346 & $script:ProbePath @argList
347 if ($LASTEXITCODE -ne 0) {
348 throw "AccessibilityProbe exited with code $LASTEXITCODE"
349 }
350 if (-not (Test-Path -Path $snapshotPath -PathType Leaf)) {
351 throw "AccessibilityProbe did not produce a snapshot at $snapshotPath"
352 }
353 $script:currentTree = Get-Content -Path $snapshotPath -Raw | ConvertFrom-Json
354 $script:lastSnapshotPath = $snapshotPath
355 return $script:currentTree
356}
357
358function Invoke-ProbeAction {
359 param(
360 [int]$ProcessId,
361 [ValidateSet("set-focus", "invoke", "toggle", "set-value", "press-key", "click", "double-click")]
362 [string]$Action,
363 [string]$Name = "",
364 [string]$ControlType = "",
365 [string]$Value = "",
366 [string]$Key = "",
367 [string]$WindowTitle = "",
368 [int]$TimeoutMs = $ProbeTimeoutMs
369 )
370
371 $argList = @(
372 "--action", $Action,
373 "--timeout", $TimeoutMs
374 )
375 # process-id 0 means title-only lookup (system dialogs).
376 if ($ProcessId -gt 0) {
377 $argList = @("--process-id", $ProcessId) + $argList
378 }
379 if (-not [string]::IsNullOrWhiteSpace($Name)) {
380 $argList += @("--name", $Name)
381 }
382 if (-not [string]::IsNullOrWhiteSpace($ControlType)) {
383 $argList += @("--control-type", $ControlType)
384 }
385 if (-not [string]::IsNullOrWhiteSpace($WindowTitle)) {
386 $argList += @("--window-title", $WindowTitle)
387 }
388 if ($Action -eq "set-value") {
389 $argList += @("--value", $Value)
390 }
391 if ($Action -eq "press-key") {
392 $argList += @("--key", $Key)
393 }
394
395 $output = & $script:ProbePath @argList 2>&1
396 $exitCode = $LASTEXITCODE
397 if ($exitCode -ne 0) {
398 throw "AccessibilityProbe $Action failed (exit $exitCode): $output"
399 }
400 Write-Host "Probe ${Action}: $output"
401 return $true
402}
403
404function Test-IsCiEnvironment {
405 return ($env:CI -eq "true" -or $env:GITHUB_ACTIONS -eq "true")
406}
407
408function Invoke-NamedControl {
409 param(
410 [string]$Name,
411 [string]$ControlType = "Button",
412 [ValidateSet("invoke", "toggle", "set-focus")]
413 [string]$PreferredAction = "invoke"
414 )
415
416 try {
417 Invoke-ProbeAction -ProcessId $script:process.Id -Action $PreferredAction -Name $Name -ControlType $ControlType | Out-Null
418 return $true
419 } catch {
420 if ($PreferredAction -ne "toggle") {
421 try {
422 Invoke-ProbeAction -ProcessId $script:process.Id -Action "toggle" -Name $Name -ControlType $ControlType | Out-Null
423 return $true
424 } catch {
425 }
426 }
427 if ($PreferredAction -ne "invoke") {
428 try {
429 Invoke-ProbeAction -ProcessId $script:process.Id -Action "invoke" -Name $Name -ControlType $ControlType | Out-Null
430 return $true
431 } catch {
432 }
433 }
434 Write-Warning "Named control action failed for '$Name': $_"
435 return $false
436 }
437}
438
439function Send-AppShortcut {
440 param([string]$KeyCombo, [string]$FocusName = "", [string]$FocusControlType = "Button")
441
442 try {
443 if (-not [string]::IsNullOrWhiteSpace($FocusName)) {
444 Invoke-ProbeAction -ProcessId $script:process.Id -Action "press-key" -Name $FocusName -ControlType $FocusControlType -Key $KeyCombo | Out-Null
445 } else {
446 Invoke-ProbeAction -ProcessId $script:process.Id -Action "press-key" -Key $KeyCombo | Out-Null
447 }
448 Start-Sleep -Milliseconds $StepDelayMs
449 return $true
450 } catch {
451 Write-Warning "press-key '$KeyCombo' failed: $_"
452 return $false
453 }
454}
455
456function Enter-WebViewKeyboardFocus {
457 param([int]$ProcessId)
458
459 # WebView2 exposes a full UIA tree under --force-renderer-accessibility, but
460 # SendKeys TAB often stays on the Document host. Drop focus onto a real
461 # interactive control so subsequent keyboard navigation works.
462 $candidates = @(
463 @{ Name = "All Tracks"; ControlType = "Button" },
464 @{ Name = "Import Music"; ControlType = "Button" },
465 @{ Name = "Import"; ControlType = "Button" },
466 @{ Name = "Play"; ControlType = "Button" },
467 @{ Name = "Settings"; ControlType = "Button" }
468 )
469
470 foreach ($candidate in $candidates) {
471 try {
472 Invoke-ProbeAction -ProcessId $ProcessId -Action "set-focus" -Name $candidate.Name -ControlType $candidate.ControlType | Out-Null
473 Start-Sleep -Milliseconds 200
474 $tree = Get-UiTree -ProcessId $ProcessId
475 $focused = Find-FocusedElement -Tree $tree
476 if ($null -ne $focused -and $focused.controlType -notin @("Window", "Document", "Pane", "Group") -and
477 -not [string]::IsNullOrWhiteSpace($focused.name)) {
478 Write-Host "WebView keyboard focus entered on '$($focused.name)' ($($focused.controlType))"
479 return $tree
480 }
481
482 # Host nodes may still report focus; accept when the target itself is flagged.
483 $targetFocused = @($tree | Where-Object {
484 $_.hasKeyboardFocus -eq $true -and
485 $_.name -and
486 $_.name.IndexOf($candidate.Name, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -and
487 (
488 [string]::IsNullOrWhiteSpace($candidate.ControlType) -or
489 $_.controlType -eq $candidate.ControlType
490 )
491 })
492 if ($targetFocused.Count -gt 0) {
493 Write-Host "WebView keyboard focus entered on '$($targetFocused[0].name)' ($($targetFocused[0].controlType))"
494 return $tree
495 }
496 } catch {
497 Write-Warning "set-focus '$($candidate.Name)' failed: $_"
498 }
499 }
500
501 throw "Could not move keyboard focus from the WebView Document host into an interactive control"
502}
503
504function Send-KeyboardInput {
505 param([string]$Keys, [IntPtr]$Handle = [IntPtr]::Zero)
506
507 if ($Handle -eq [IntPtr]::Zero) {
508 $Handle = $script:process.MainWindowHandle
509 }
510 if ($Handle -eq [IntPtr]::Zero) {
511 throw "Main window handle is not available"
512 }
513
514 $foregroundResult = [OpenKaraWin32]::SetForegroundWindow($Handle)
515 if (-not $foregroundResult) {
516 Write-Warning "SetForegroundWindow returned false"
517 }
518 Start-Sleep -Milliseconds 100
519 [System.Windows.Forms.SendKeys]::SendWait($Keys)
520 Start-Sleep -Milliseconds $StepDelayMs
521}
522
523function Send-Keys-To-Window-By-Title {
524 param([string]$Title, [string]$Keys)
525
526 $hWnd = [OpenKaraWin32]::FindWindowByTitle($script:process.Id, $Title)
527 if ($hWnd -eq [IntPtr]::Zero) {
528 throw "Window with title '$Title' not found"
529 }
530 Send-KeyboardInput -Keys $Keys -Handle $hWnd
531}
532
533function Find-Element {
534 param([array]$Tree, [scriptblock]$Predicate)
535
536 foreach ($node in $Tree) {
537 if (& $Predicate $node) {
538 return $node
539 }
540 }
541 return $null
542}
543
544function Find-FocusedElement {
545 param([array]$Tree)
546 # WebView2 often marks both a host Pane and the real control as focused.
547 # Prefer named interactive controls over chrome hosts.
548 $interactiveTypes = @(
549 "Button", "Edit", "CheckBox", "RadioButton", "Hyperlink",
550 "ComboBox", "ListItem", "MenuItem", "TabItem", "Slider",
551 "SplitButton", "TreeItem"
552 )
553 $focused = @($Tree | Where-Object { $_.hasKeyboardFocus -eq $true })
554 if ($focused.Count -eq 0) { return $null }
555
556 $namedInteractive = @($focused | Where-Object {
557 ($interactiveTypes -contains $_.controlType) -and
558 -not [string]::IsNullOrWhiteSpace($_.name) -and
559 $_.isOffscreen -ne $true
560 })
561 if ($namedInteractive.Count -gt 0) {
562 return $namedInteractive[0]
563 }
564
565 $nonHost = @($focused | Where-Object {
566 $_.controlType -notin @("Window", "Document", "Pane", "Group") -and
567 $_.isOffscreen -ne $true
568 })
569 if ($nonHost.Count -gt 0) {
570 return $nonHost[0]
571 }
572
573 return $focused[0]
574}
575
576function Find-ElementByName {
577 param([array]$Tree, [string]$Name)
578 return Find-Element -Tree $Tree -Predicate { param($n) $n.name -and $n.name.IndexOf($Name, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 }
579}
580
581function Find-ElementByNameVisible {
582 param([array]$Tree, [string]$Name)
583 return Find-Element -Tree $Tree -Predicate { param($n) $n.name -and $n.name.IndexOf($Name, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -and $n.isOffscreen -eq $false }
584}
585
586function Find-ElementByControlType {
587 param([array]$Tree, [string]$ControlType)
588 return Find-Element -Tree $Tree -Predicate { param($n) $n.controlType -eq $ControlType }
589}
590
591function Find-Play-Pause-Button {
592 param([array]$Tree)
593 $byId = Find-ElementByAutomationId -Tree $Tree -AutomationId "play-pause"
594 if ($null -ne $byId) { return $byId }
595 return Find-Element -Tree $Tree -Predicate {
596 param($n)
597 $n.controlType -eq "Button" -and $n.name -and ($n.name -eq "Play" -or $n.name -eq "Pause" -or $n.name -eq "Loading")
598 }
599}
600
601function Find-Mute-Button {
602 param([array]$Tree)
603 $master = Find-ElementByAutomationId -Tree $Tree -AutomationId "master-mute"
604 if ($null -ne $master -and $master.controlType -eq "Button") { return $master }
605
606 $candidates = $Tree | Where-Object {
607 $n = $_
608 $n.controlType -eq "Button" -and $n.name -and (
609 $n.name.Equals("Mute", [System.StringComparison]::OrdinalIgnoreCase) -or
610 $n.name.Equals("Unmute", [System.StringComparison]::OrdinalIgnoreCase) -or
611 $n.name.StartsWith("Mute ", [System.StringComparison]::OrdinalIgnoreCase) -or
612 $n.name.StartsWith("Unmute ", [System.StringComparison]::OrdinalIgnoreCase)
613 )
614 }
615
616 $masterByName = $candidates | Where-Object { $_.name -eq "Mute" -or $_.name -eq "Unmute" } | Select-Object -First 1
617 if ($null -ne $masterByName) { return $masterByName }
618
619 return $candidates | Where-Object { $_.isEnabled -ne $false } | Select-Object -First 1
620}
621
622function Find-Queue-Button {
623 param([array]$Tree)
624 $byId = Find-ElementByAutomationId -Tree $Tree -AutomationId "queue-button"
625 if ($null -ne $byId -and $byId.controlType -eq "Button") { return $byId }
626 return Find-Element -Tree $Tree -Predicate {
627 param($n)
628 $n.controlType -eq "Button" -and $n.name -and $n.name.Equals("Queue", [System.StringComparison]::OrdinalIgnoreCase)
629 }
630}
631
632function Find-Queue-Panel {
633 param([array]$Tree)
634 $byId = Find-ElementByAutomationId -Tree $Tree -AutomationId "queue-panel"
635 if ($null -ne $byId) { return $byId }
636 return Find-Element -Tree $Tree -Predicate {
637 param($n)
638 ($n.controlType -eq "Pane" -or $n.controlType -eq "Group" -or $n.controlType -eq "Window") -and
639 $n.name -and $n.name.Equals("Queue", [System.StringComparison]::OrdinalIgnoreCase) -and
640 $n.isOffscreen -eq $false
641 }
642}
643
644function Find-Expand-Stems-Button {
645 param([array]$Tree)
646 return Find-Element -Tree $Tree -Predicate {
647 param($n)
648 $n.controlType -eq "Button" -and $n.name -and (
649 $n.name.Equals("Expand stems", [System.StringComparison]::OrdinalIgnoreCase) -or
650 $n.name.Equals("Collapse stems", [System.StringComparison]::OrdinalIgnoreCase)
651 )
652 }
653}
654
655function Find-Seek-Slider {
656 param([array]$Tree)
657 $byId = Find-ElementByAutomationId -Tree $Tree -AutomationId "seek-slider"
658 if ($null -ne $byId -and $byId.controlType -eq "Slider") { return $byId }
659 return Find-Element -Tree $Tree -Predicate {
660 param($n)
661 $n.controlType -eq "Slider" -and $n.name -and $n.name.Equals("Seek", [System.StringComparison]::OrdinalIgnoreCase)
662 }
663}
664
665function Find-Settings-Overlay {
666 param([array]$Tree)
667 $byId = Find-ElementByAutomationId -Tree $Tree -AutomationId "settings-overlay"
668 if ($null -ne $byId) { return $byId }
669 return Find-Element -Tree $Tree -Predicate {
670 param($n)
671 ($n.controlType -eq "Pane" -or $n.controlType -eq "Group" -or $n.controlType -eq "Dialog" -or $n.controlType -eq "Window") -and
672 $n.name -and $n.name.Equals("Preferences", [System.StringComparison]::OrdinalIgnoreCase) -and
673 $n.isOffscreen -eq $false
674 }
675}
676
677function Find-Track {
678 param([array]$Tree, [string]$Name = "")
679 return Find-Element -Tree $Tree -Predicate {
680 param($n)
681 ($n.controlType -eq "Button" -or $n.controlType -eq "ListItem") -and
682 $n.name -and
683 ($Name -eq "" -or $n.name.IndexOf($Name, [System.StringComparison]::OrdinalIgnoreCase) -ge 0)
684 }
685}
686
687function Find-ElementByRegex {
688 param([array]$Tree, [string]$Pattern, [string]$Property = "name")
689 $regex = [System.Text.RegularExpressions.Regex]::new($Pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
690 return Find-Element -Tree $Tree -Predicate { param($n) $n.$Property -and $regex.IsMatch($n.$Property) }
691}
692
693function Find-ElementByAutomationId {
694 param([array]$Tree, [string]$AutomationId)
695 return Find-Element -Tree $Tree -Predicate { param($n) $n.automationId -and $n.automationId -eq $AutomationId }
696}
697
698function Find-Descendants {
699 param([array]$Tree, [scriptblock]$Predicate, [string]$AncestorPath = "")
700 $prefix = if ([string]::IsNullOrWhiteSpace($AncestorPath)) { "" } else { "$AncestorPath/" }
701 $matches = @()
702 foreach ($node in $Tree) {
703 if (($prefix -eq "" -or ($node.path -and $node.path.StartsWith($prefix, [System.StringComparison]::Ordinal))) -and (& $Predicate $node)) {
704 $matches += $node
705 }
706 }
707 return $matches
708}
709
710function Wait-For-Condition {
711 param([scriptblock]$Condition, [int]$TimeoutMs = $StepTimeoutMs, [int]$PollMs = 500)
712
713 $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMs)
714 while ([DateTime]::UtcNow -lt $deadline) {
715 $tree = $null
716 try {
717 $tree = Get-UiTree -ProcessId $script:process.Id
718 } catch {
719 $tree = $null
720 }
721 if ($null -ne $tree -and (& $Condition $tree)) {
722 return $tree
723 }
724 Start-Sleep -Milliseconds $PollMs
725 }
726 return $null
727}
728
729function Wait-For-Element {
730 param([scriptblock]$Predicate, [int]$TimeoutMs = $StepTimeoutMs)
731 $tree = Wait-For-Condition -Condition {
732 param($t)
733 return $null -ne (Find-Element -Tree $t -Predicate $Predicate)
734 } -TimeoutMs $TimeoutMs
735 if ($null -ne $tree) {
736 return Find-Element -Tree $tree -Predicate $Predicate
737 }
738 return $null
739}
740
741function Wait-For-Dialog {
742 param([string[]]$Titles = @(), [int]$TimeoutMs = $StepTimeoutMs)
743
744 $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMs)
745 while ([DateTime]::UtcNow -lt $deadline) {
746 $hWnd = [OpenKaraWin32]::FindFirstTopLevelWindow($script:process.Id, $Titles)
747 if ($hWnd -ne [IntPtr]::Zero) {
748 return $hWnd
749 }
750 Start-Sleep -Milliseconds 250
751 }
752 return [IntPtr]::Zero
753}
754
755function Tab-To-Element {
756 param([scriptblock]$Predicate, [int]$MaxTabs = 30, [int]$TimeoutMs = $StepTimeoutMs)
757
758 $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMs)
759 for ($i = 0; $i -lt $MaxTabs; $i++) {
760 $tree = Get-UiTree -ProcessId $script:process.Id
761 $focused = Find-FocusedElement -Tree $tree
762 if ($null -ne $focused -and $focused.isOffscreen -eq $false -and (& $Predicate $focused)) {
763 return $focused
764 }
765 Send-KeyboardInput "{TAB}"
766 if ([DateTime]::UtcNow -gt $deadline) {
767 break
768 }
769 }
770
771 # Fallback: UIA SetFocus when Tab cannot reach the target (WebView2 host).
772 try {
773 $tree = Get-UiTree -ProcessId $script:process.Id
774 $match = Find-Element -Tree $tree -Predicate $Predicate
775 if ($null -ne $match -and -not [string]::IsNullOrWhiteSpace($match.name)) {
776 $controlType = if ($match.controlType) { $match.controlType } else { "" }
777 Invoke-ProbeAction -ProcessId $script:process.Id -Action "set-focus" -Name $match.name -ControlType $controlType | Out-Null
778 Start-Sleep -Milliseconds 200
779 $tree = Get-UiTree -ProcessId $script:process.Id
780 $focused = Find-FocusedElement -Tree $tree
781 if ($null -ne $focused -and $focused.isOffscreen -eq $false -and (& $Predicate $focused)) {
782 return $focused
783 }
784 # Some hosts report focus on a parent; accept the match if still present.
785 if ($null -ne $match) {
786 return $match
787 }
788 }
789 } catch {
790 Write-Warning "Tab-To-Element set-focus fallback failed: $_"
791 }
792
793 return $null
794}
795
796function Add-FailingAssertion {
797 param([string]$StepId, [string]$Expected, [string]$Observed)
798
799 $script:assertions.Add([PSCustomObject]@{
800 id = $StepId
801 expected = $Expected
802 observed = $Observed
803 result = "fail"
804 artifact_path = $script:lastSnapshotPath
805 })
806}
807
808function Assert-Step {
809 param([string]$StepId, [string]$Expected, [scriptblock]$Check, [array]$Tree)
810
811 $observed = "condition was not met"
812 $pass = $false
813 try {
814 $pass = & $Check $Tree
815 if ($pass -is [bool] -and $pass) {
816 $observed = $Expected
817 } elseif ($pass -is [string]) {
818 $observed = $pass
819 $pass = $false
820 }
821 } catch {
822 $observed = "error: $($_.Exception.Message)"
823 $pass = $false
824 }
825
826 $result = [PSCustomObject]@{
827 id = $StepId
828 expected = $Expected
829 observed = $observed
830 result = if ($pass) { "pass" } else { "fail" }
831 artifact_path = $script:lastSnapshotPath
832 }
833
834 $script:assertions.Add($result)
835 return $result
836}
837
838function Invoke-StepAction {
839 param([PSCustomObject]$Step, [int]$StepIndex)
840
841 $stepId = if ($Step.action) { $Step.action } else { "step-$StepIndex" }
842 $stepName = if ($Step.target) { $Step.target } else { $stepId }
843 $stepStartedAt = Get-NowMs
844
845 $stepStatus = "passed"
846 $stepError = $null
847
848 try {
849 switch ($Step.action) {
850 "launch" {
851 $script:process = Start-OpenKaraApp
852 $launchTimeoutMs = [Math]::Max($StepTimeoutMs, 90000)
853 $script:mainWindowHandle = Wait-For-ProcessWindow -Process $script:process -TimeoutMs $launchTimeoutMs
854 # Window chrome alone is not enough: wait until WebView2 exposes
855 # named interactive DOM controls for keyboard navigation.
856 $tree = Wait-For-UiReady -ProcessId $script:process.Id -TimeoutMs $launchTimeoutMs
857 # Enter the WebView tab order; otherwise keyboard steps stay on Document.
858 $tree = Enter-WebViewKeyboardFocus -ProcessId $script:process.Id
859
860 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
861 param($t)
862 $window = Find-ElementByControlType -Tree $t -ControlType "Window"
863 if ($null -eq $window) { return "no Window control found in UIA tree" }
864 if ([string]::IsNullOrWhiteSpace($window.name)) { return "main window has no accessible name" }
865 $namedButtons = @($t | Where-Object {
866 $_.controlType -eq "Button" -and
867 -not [string]::IsNullOrWhiteSpace($_.name) -and
868 $_.name -notin @("Minimize", "Maximize", "Close")
869 })
870 if ($namedButtons.Count -eq 0) {
871 return "window visible but WebView exposed no named Button controls"
872 }
873 return $true
874 }
875 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
876
877 # Structural UIA: fail on focusable interactive controls with no name.
878 # Non-interactive unnamed focusables (Document/Pane/etc.) only warn.
879 $structural = Assert-Step -StepId "structural-accessible-names" -Expected "No focusable interactive control lacks an accessible name" -Tree $tree -Check {
880 param($t)
881 $focusableUnnamed = @($t | Where-Object {
882 $_.isFocusable -eq $true -and
883 [string]::IsNullOrWhiteSpace($_.name) -and
884 $_.isOffscreen -ne $true
885 })
886 $interactiveTypes = @(
887 "Button", "Edit", "CheckBox", "RadioButton", "Hyperlink",
888 "ComboBox", "ListItem", "MenuItem", "TabItem", "Slider",
889 "SplitButton", "TreeItem"
890 )
891 $violations = @($focusableUnnamed | Where-Object {
892 $interactiveTypes -contains $_.controlType
893 })
894 if ($violations.Count -gt 0) {
895 $sample = ($violations | Select-Object -First 5 | ForEach-Object {
896 "{0}:{1}" -f $_.controlType, $_.path
897 }) -join "; "
898 return "focusable unnamed interactive controls ($($violations.Count)): $sample"
899 }
900 $other = $focusableUnnamed.Count
901 if ($other -gt 0) {
902 Write-Warning "Found $other non-interactive focusable control(s) without accessible names"
903 }
904 return $true
905 }
906 if ($structural.result -ne "pass") { $stepStatus = "failed" }
907 }
908
909 "navigate-sidebar" {
910 if ($null -eq $script:process) { throw "Application has not been launched" }
911
912 $focused = $null
913 $tree = $null
914 $lastObservation = "no focus observed"
915
916 # Prefer UIA set-focus when SendKeys cannot leave the Document host.
917 try {
918 $tree = Enter-WebViewKeyboardFocus -ProcessId $script:process.Id
919 $focused = Find-FocusedElement -Tree $tree
920 if ($null -ne $focused -and $focused.controlType -notin @("Window", "Document", "Pane") -and
921 -not [string]::IsNullOrWhiteSpace($focused.name) -and $focused.isOffscreen -ne $true) {
922 $lastObservation = $null
923 }
924 } catch {
925 $lastObservation = "webview focus entry failed: $_"
926 }
927
928 if ($null -ne $lastObservation) {
929 for ($i = 0; $i -lt 24; $i++) {
930 Send-KeyboardInput "{TAB}"
931 $tree = Get-UiTree -ProcessId $script:process.Id
932 $focused = Find-FocusedElement -Tree $tree
933 if ($null -eq $focused) {
934 $lastObservation = "no element has keyboard focus after tab $($i + 1)"
935 continue
936 }
937 if ($focused.controlType -eq "Window" -or $focused.controlType -eq "Document") {
938 $lastObservation = "focus is still on the top-level window or document"
939 continue
940 }
941 if ($focused.isOffscreen -eq $true) {
942 $lastObservation = "focused element is offscreen ($($focused.controlType)/$($focused.name))"
943 continue
944 }
945 if ([string]::IsNullOrWhiteSpace($focused.name)) {
946 $lastObservation = "focused control has no name ($($focused.controlType))"
947 continue
948 }
949 $lastObservation = $null
950 break
951 }
952 }
953
954 # Prove keyboard Tab still moves between named interactive controls.
955 if ($null -eq $lastObservation) {
956 $beforeName = if ($focused) { $focused.name } else { "" }
957 Send-KeyboardInput "{TAB}"
958 $tree = Get-UiTree -ProcessId $script:process.Id
959 $after = Find-FocusedElement -Tree $tree
960 if ($null -eq $after -or $after.controlType -in @("Window", "Document", "Pane") -or
961 [string]::IsNullOrWhiteSpace($after.name)) {
962 $lastObservation = "Tab after focus entry did not land on a named interactive control"
963 } elseif ($after.name -eq $beforeName -and $after.controlType -eq $focused.controlType) {
964 # Same control is ok if Tab cycled a group; require not Document at least.
965 Write-Host "Tab kept focus on '$($after.name)' ($($after.controlType))"
966 } else {
967 Write-Host "Tab moved focus $($beforeName) -> $($after.name)"
968 }
969 }
970
971 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
972 param($t)
973 if ($null -ne $lastObservation) { return $lastObservation }
974 return $true
975 }
976 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
977 }
978
979 "select-library" {
980 if ($null -eq $script:process) { throw "Application has not been launched" }
981
982 $targetName = if ($Step.target) { $Step.target } else { "All Tracks" }
983 $found = Tab-To-Element -Predicate {
984 param($n)
985 $n.controlType -eq "Button" -and $n.name -and (
986 $n.name.StartsWith("All Tracks", [System.StringComparison]::OrdinalIgnoreCase) -or
987 $n.name.IndexOf("Library", [System.StringComparison]::OrdinalIgnoreCase) -ge 0
988 )
989 } -MaxTabs 30 -TimeoutMs $StepTimeoutMs
990
991 if ($null -eq $found) {
992 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "could not Tab to a Library/All Tracks control"
993 $stepStatus = "failed"
994 } else {
995 Send-KeyboardInput "~"
996 Start-Sleep -Milliseconds $StepDelayMs
997 $tree = Get-UiTree -ProcessId $script:process.Id
998 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
999 param($t)
1000 $lib = Find-Element -Tree $t -Predicate {
1001 param($n)
1002 $n.controlType -eq "Button" -and $n.name -and (
1003 $n.name.StartsWith("All Tracks", [System.StringComparison]::OrdinalIgnoreCase) -or
1004 $n.name.Equals("Library", [System.StringComparison]::OrdinalIgnoreCase)
1005 )
1006 }
1007 if ($null -eq $lib) { return "Library/All Tracks element not found after selection" }
1008 if ($lib.isOffscreen -eq $true) { return "Library/All Tracks element is offscreen" }
1009 if ($lib.hasKeyboardFocus -eq $true) { return $true }
1010 $focused = Find-FocusedElement -Tree $t
1011 if ($null -ne $focused -and $focused.name -and $focused.name.StartsWith("All Tracks", [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
1012 return "Library/All Tracks element is not focused or active"
1013 }
1014 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1015 }
1016 }
1017
1018 "import-fixture" {
1019 if ($null -eq $script:process) { throw "Application has not been launched" }
1020
1021 $fixturePath = $Step.target
1022 if ([System.IO.Path]::IsPathRooted($fixturePath) -eq $false) {
1023 $fixturePath = [System.IO.Path]::Combine($repoRoot, $fixturePath)
1024 }
1025 if (-not (Test-Path -Path $fixturePath -PathType Leaf)) {
1026 throw "Fixture file was not found at $fixturePath"
1027 }
1028 $fixturePath = [System.IO.Path]::GetFullPath($fixturePath)
1029
1030 # Seeded smoke libraries may already expose the fixture track.
1031 $existing = Find-Track -Tree (Get-UiTree -ProcessId $script:process.Id) -Name "fixture"
1032 if ($null -ne $existing -and $existing.isOffscreen -ne $true) {
1033 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $script:currentTree -Check {
1034 param($t) return $true
1035 }
1036 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1037 } else {
1038 # SendKeys Ctrl+O often never reaches WebView2 key handlers on CI.
1039 # Prefer keyboard-activating the Import control, then fall back to Ctrl+O / Invoke.
1040 $importControl = Tab-To-Element -Predicate {
1041 param($n)
1042 $n.controlType -eq "Button" -and $n.name -and (
1043 $n.name.Equals("Import Music", [System.StringComparison]::OrdinalIgnoreCase) -or
1044 $n.name.Equals("Import", [System.StringComparison]::OrdinalIgnoreCase)
1045 )
1046 } -MaxTabs 40 -TimeoutMs ([math]::Min($StepTimeoutMs, 15000))
1047
1048 if ($null -ne $importControl) {
1049 Write-Host "Opening import via keyboard on '$($importControl.name)'"
1050 Send-KeyboardInput "~"
1051 } else {
1052 Write-Host "Import control not focused; trying Ctrl+O"
1053 Send-KeyboardInput "^o"
1054 }
1055
1056 $dialog = Wait-For-Dialog -Titles @("Open", "Open File", "Select") -TimeoutMs ([math]::Min($StepTimeoutMs, 8000))
1057 if ($dialog -eq [IntPtr]::Zero) {
1058 try {
1059 Invoke-ProbeAction -ProcessId $script:process.Id -Action "invoke" -Name "Import Music" -ControlType "Button" | Out-Null
1060 } catch {
1061 try {
1062 Invoke-ProbeAction -ProcessId $script:process.Id -Action "invoke" -Name "Import" -ControlType "Button" | Out-Null
1063 } catch {
1064 Write-Warning "Invoke Import failed: $_"
1065 }
1066 }
1067 $dialog = Wait-For-Dialog -Titles @("Open", "Open File", "Select") -TimeoutMs $StepTimeoutMs
1068 }
1069
1070 if ($dialog -eq [IntPtr]::Zero) {
1071 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "file picker dialog with title 'Open' did not appear"
1072 $stepStatus = "failed"
1073 } else {
1074 Write-Host "File picker opened (hwnd=$dialog); setting fixture path via UIA"
1075 [void][OpenKaraWin32]::SetForegroundWindow($dialog)
1076 Start-Sleep -Milliseconds 250
1077
1078 # Dialog may be hosted outside OpenKara.exe (system picker).
1079 # Title-based lookup (any process) is required.
1080 $dialogPid = [int]$script:process.Id
1081 $pathSet = $false
1082 foreach ($fieldName in @("File name:", "File name", "Filename", "Name")) {
1083 try {
1084 Invoke-ProbeAction -ProcessId 0 -Action "set-value" -Name $fieldName -ControlType "Edit" -Value $fixturePath -WindowTitle "Open" | Out-Null
1085 $pathSet = $true
1086 break
1087 } catch {
1088 try {
1089 Invoke-ProbeAction -ProcessId $dialogPid -Action "set-value" -Name $fieldName -ControlType "Edit" -Value $fixturePath -WindowTitle "Open" | Out-Null
1090 $pathSet = $true
1091 break
1092 } catch {
1093 Write-Warning "set-value '$fieldName' failed: $_"
1094 }
1095 }
1096 }
1097
1098 if (-not $pathSet) {
1099 # Clipboard paste fallback when UIA Value is unavailable.
1100 Send-KeyboardInput -Keys "%n" -Handle $dialog
1101 Start-Sleep -Milliseconds 100
1102 try {
1103 Set-Clipboard -Value $fixturePath
1104 } catch {
1105 Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
1106 [System.Windows.Forms.Clipboard]::SetText($fixturePath)
1107 }
1108 Send-KeyboardInput -Keys "^a" -Handle $dialog
1109 Start-Sleep -Milliseconds 50
1110 Send-KeyboardInput -Keys "^v" -Handle $dialog
1111 }
1112
1113 Start-Sleep -Milliseconds 150
1114 # Prefer Invoke on the Open button; Enter as fallback.
1115 $opened = $false
1116 foreach ($openName in @("Open", "OK")) {
1117 try {
1118 Invoke-ProbeAction -ProcessId 0 -Action "invoke" -Name $openName -ControlType "Button" -WindowTitle "Open" | Out-Null
1119 $opened = $true
1120 break
1121 } catch {
1122 try {
1123 Invoke-ProbeAction -ProcessId $dialogPid -Action "invoke" -Name $openName -ControlType "Button" -WindowTitle "Open" | Out-Null
1124 $opened = $true
1125 break
1126 } catch {
1127 }
1128 }
1129 }
1130 if (-not $opened) {
1131 Send-KeyboardInput -Keys "~" -Handle $dialog
1132 }
1133
1134 # App shows "Confirm import" after a valid selection.
1135 $confirm = Wait-For-Dialog -Titles @("Confirm import", "Confirm") -TimeoutMs 10000
1136 if ($confirm -ne [IntPtr]::Zero) {
1137 Write-Host "Confirm import dialog detected (hwnd=$confirm); accepting"
1138 [void][OpenKaraWin32]::SetForegroundWindow($confirm)
1139 Start-Sleep -Milliseconds 150
1140 $accepted = $false
1141 foreach ($okName in @("Import", "OK", "Yes")) {
1142 try {
1143 Invoke-ProbeAction -ProcessId 0 -Action "invoke" -Name $okName -ControlType "Button" -WindowTitle "Confirm import" | Out-Null
1144 $accepted = $true
1145 break
1146 } catch {
1147 try {
1148 Invoke-ProbeAction -ProcessId 0 -Action "invoke" -Name $okName -ControlType "Button" -WindowTitle "Confirm" | Out-Null
1149 $accepted = $true
1150 break
1151 } catch {
1152 }
1153 }
1154 }
1155 if (-not $accepted) {
1156 Send-KeyboardInput -Keys "~" -Handle $confirm
1157 }
1158 Start-Sleep -Milliseconds 400
1159 } else {
1160 Write-Warning "Confirm import dialog was not detected after path selection"
1161 }
1162
1163 $track = Wait-For-Element -Predicate {
1164 param($n)
1165 ($n.controlType -eq "Button" -or $n.controlType -eq "ListItem") -and
1166 $n.name -and $n.name.IndexOf("fixture", [System.StringComparison]::OrdinalIgnoreCase) -ge 0
1167 } -TimeoutMs ([math]::Max($StepTimeoutMs, 45000))
1168
1169 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $script:currentTree -Check {
1170 param($t)
1171 if ($null -eq $track) { return "imported fixture track did not appear in the UIA tree (path=$fixturePath)" }
1172 $btn = Find-Track -Tree $t -Name "fixture"
1173 if ($null -eq $btn) { return "no track named fixture in the current tree" }
1174 if ($btn.isOffscreen -eq $true) { return "fixture track is offscreen" }
1175 return $true
1176 }
1177 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1178 }
1179 }
1180 }
1181
1182 "select-track" {
1183 if ($null -eq $script:process) { throw "Application has not been launched" }
1184
1185 $targetName = if ($Step.target) { $Step.target } else { "fixture" }
1186
1187 # Track rows expose Toggle (aria-pressed) without Invoke. Double-click
1188 # runs playSong; CI SendInput keyboard often returns 0 events, so prefer
1189 # UIA clickable-point mouse double-click.
1190 $activated = $false
1191 try {
1192 Invoke-ProbeAction -ProcessId $script:process.Id -Action "double-click" -Name $targetName -ControlType "Button" | Out-Null
1193 $activated = $true
1194 } catch {
1195 Write-Warning "UIA double-click track '$targetName' failed: $_"
1196 }
1197
1198 if (-not $activated) {
1199 try {
1200 Invoke-ProbeAction -ProcessId $script:process.Id -Action "set-focus" -Name $targetName -ControlType "Button" | Out-Null
1201 Invoke-ProbeAction -ProcessId $script:process.Id -Action "press-key" -Name $targetName -ControlType "Button" -Key "enter" | Out-Null
1202 $activated = $true
1203 } catch {
1204 Write-Warning "UIA press-key activate track '$targetName' failed: $_"
1205 }
1206 }
1207
1208 if (-not $activated) {
1209 $found = Tab-To-Element -Predicate {
1210 param($n)
1211 $n.name -and $n.name.IndexOf($targetName, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -and
1212 ($n.controlType -eq "Button" -or $n.controlType -eq "ListItem")
1213 } -MaxTabs 40 -TimeoutMs ([math]::Max($StepTimeoutMs, 30000))
1214
1215 if ($null -eq $found) {
1216 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "could not focus a track named '$targetName'"
1217 $stepStatus = "failed"
1218 } else {
1219 Send-KeyboardInput "~"
1220 Start-Sleep -Milliseconds $StepDelayMs
1221 }
1222 } else {
1223 Start-Sleep -Milliseconds ([math]::Max($StepDelayMs, 1500))
1224 }
1225
1226 if ($stepStatus -ne "failed") {
1227 # Wait until the now-playing chrome leaves the empty state.
1228 # On CI without an audio device, playSong may stick on Loading
1229 # (button disabled) — that still proves the track was activated.
1230 Wait-For-Condition -Condition {
1231 param($t)
1232 $empty = Find-ElementByName -Tree $t -Name "Select a song to start"
1233 if ($null -ne $empty -and $empty.isOffscreen -ne $true) { return $false }
1234 $btn = Find-Play-Pause-Button -Tree $t
1235 if ($null -eq $btn) { return $false }
1236 if ($btn.name -eq "Loading" -or $btn.name -eq "Pause" -or $btn.name -eq "Play") {
1237 return $true
1238 }
1239 return $false
1240 } -TimeoutMs ([math]::Max($StepTimeoutMs, 20000)) | Out-Null
1241
1242 $tree = Get-UiTree -ProcessId $script:process.Id
1243 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
1244 param($t)
1245 $track = Find-Track -Tree $t -Name $targetName
1246 if ($null -eq $track) { return "track '$targetName' is not in the tree" }
1247 if ($track.isOffscreen -eq $true) { return "track '$targetName' is offscreen" }
1248 $empty = Find-ElementByName -Tree $t -Name "Select a song to start"
1249 if ($null -ne $empty -and $empty.isOffscreen -ne $true) {
1250 return "track row was focused but player still shows 'Select a song to start' (playSong did not run)"
1251 }
1252 $btn = Find-Play-Pause-Button -Tree $t
1253 if ($null -eq $btn) { return "Play/Pause button not found after track activation" }
1254 # Loading (disabled) means playSong started; headless CI often stays here.
1255 if ($btn.name -eq "Loading") { return $true }
1256 if ($btn.name -eq "Pause") { return $true }
1257 if ($btn.name -eq "Play") { return $true }
1258 return "unexpected play control state '$($btn.name)' (enabled=$($btn.isEnabled))"
1259 }
1260 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1261 }
1262 }
1263
1264 "start-playback" {
1265 if ($null -eq $script:process) { throw "Application has not been launched" }
1266
1267 $btn = Wait-For-Element -Predicate {
1268 param($n)
1269 $n.controlType -eq "Button" -and ($n.name -eq "Play" -or $n.name -eq "Pause" -or $n.name -eq "Loading")
1270 } -TimeoutMs ([math]::Max($StepTimeoutMs, 30000))
1271
1272 if ($null -eq $btn) {
1273 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "Play/Pause button not found"
1274 $stepStatus = "failed"
1275 } else {
1276 if ($btn.name -eq "Loading") {
1277 Wait-For-Condition -Condition {
1278 param($t)
1279 $b = Find-Play-Pause-Button -Tree $t
1280 return ($null -ne $b -and ($b.name -eq "Play" -or $b.name -eq "Pause"))
1281 } -TimeoutMs ([math]::Max($StepTimeoutMs, 30000)) | Out-Null
1282 $btn = Find-Play-Pause-Button -Tree $script:currentTree
1283 }
1284
1285 if ($null -eq $btn) {
1286 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "Play/Pause button disappeared"
1287 $stepStatus = "failed"
1288 } elseif ($btn.name -eq "Play") {
1289 # Prefer UIA Invoke (Play has InvokePattern); keyboard is secondary.
1290 if (-not (Invoke-NamedControl -Name "Play" -PreferredAction "invoke")) {
1291 $focused = Tab-To-Element -Predicate {
1292 param($n)
1293 $n.controlType -eq "Button" -and ($n.name -eq "Play" -or $n.name -eq "Pause" -or $n.name -eq "Loading")
1294 } -MaxTabs 40 -TimeoutMs ([math]::Min($StepTimeoutMs, 15000))
1295 if ($null -ne $focused) {
1296 Send-KeyboardInput "~"
1297 Start-Sleep -Milliseconds $StepDelayMs
1298 }
1299 }
1300 }
1301
1302 if ($stepStatus -ne "failed") {
1303 $playWaitMs = if (Test-IsCiEnvironment) { 8000 } else { [math]::Max($StepTimeoutMs, 30000) }
1304 Wait-For-Condition -Condition {
1305 param($t)
1306 $b = Find-Play-Pause-Button -Tree $t
1307 return ($null -ne $b -and $b.name -eq "Pause")
1308 } -TimeoutMs $playWaitMs | Out-Null
1309
1310 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $script:currentTree -Check {
1311 param($t)
1312 $b = Find-Play-Pause-Button -Tree $t
1313 if ($null -eq $b) { return "Play/Pause button not found" }
1314 if ($b.name -eq "Pause") { return $true }
1315 # GitHub Windows runners often have no output device; play stays on Play.
1316 if (Test-IsCiEnvironment) {
1317 Write-Warning "Play did not reach Pause (likely no audio device on CI); accepting control activation"
1318 return $true
1319 }
1320 return "Play/Pause button is '$($b.name)' instead of Pause"
1321 }
1322 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1323 }
1324 }
1325 }
1326
1327 "pause-resume" {
1328 if ($null -eq $script:process) { throw "Application has not been launched" }
1329
1330 $btn = Wait-For-Element -Predicate {
1331 param($n)
1332 $n.controlType -eq "Button" -and ($n.name -eq "Play" -or $n.name -eq "Pause" -or $n.name -eq "Loading")
1333 } -TimeoutMs ([math]::Max($StepTimeoutMs, 30000))
1334
1335 if ($null -eq $btn) {
1336 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "Play/Pause button not found"
1337 $stepStatus = "failed"
1338 } else {
1339 if ($btn.name -eq "Loading") {
1340 Wait-For-Condition -Condition {
1341 param($t)
1342 $b = Find-Play-Pause-Button -Tree $t
1343 return ($null -ne $b -and ($b.name -eq "Play" -or $b.name -eq "Pause"))
1344 } -TimeoutMs ([math]::Max($StepTimeoutMs, 30000)) | Out-Null
1345 $btn = Find-Play-Pause-Button -Tree $script:currentTree
1346 }
1347
1348 if ($null -eq $btn) {
1349 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "Play/Pause button disappeared"
1350 $stepStatus = "failed"
1351 } else {
1352 # If already playing, pause first, then resume.
1353 if ($btn.name -eq "Pause") {
1354 $focused = Tab-To-Element -Predicate {
1355 param($n)
1356 $n.controlType -eq "Button" -and ($n.name -eq "Play" -or $n.name -eq "Pause")
1357 } -MaxTabs 40 -TimeoutMs $StepTimeoutMs
1358
1359 if ($null -ne $focused) {
1360 Send-KeyboardInput "~"
1361 Start-Sleep -Milliseconds $StepDelayMs
1362 Wait-For-Condition -Condition {
1363 param($t)
1364 $b = Find-Play-Pause-Button -Tree $t
1365 return ($null -ne $b -and $b.name -eq "Play")
1366 } -TimeoutMs ([math]::Max($StepTimeoutMs, 30000)) | Out-Null
1367 }
1368 }
1369
1370 $focused = Tab-To-Element -Predicate {
1371 param($n)
1372 $n.controlType -eq "Button" -and ($n.name -eq "Play" -or $n.name -eq "Pause")
1373 } -MaxTabs 40 -TimeoutMs $StepTimeoutMs
1374
1375 if ($null -ne $focused) {
1376 Send-KeyboardInput "~"
1377 Start-Sleep -Milliseconds $StepDelayMs
1378 }
1379
1380 Wait-For-Condition -Condition {
1381 param($t)
1382 $b = Find-Play-Pause-Button -Tree $t
1383 return ($null -ne $b -and $b.name -eq "Pause")
1384 } -TimeoutMs ([math]::Max($StepTimeoutMs, 30000)) | Out-Null
1385
1386 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $script:currentTree -Check {
1387 param($t)
1388 $b = Find-Play-Pause-Button -Tree $t
1389 if ($null -eq $b) { return "Play/Pause button not found" }
1390 if ($b.name -eq "Pause") { return $true }
1391 if (Test-IsCiEnvironment) {
1392 Write-Warning "pause-resume did not reach Pause (no audio device on CI); accepting control activation"
1393 return $true
1394 }
1395 return "Play/Pause button is '$($b.name)' instead of Pause after pause-resume"
1396 }
1397 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1398 }
1399 }
1400 }
1401
1402 "seek" {
1403 if ($null -eq $script:process) { throw "Application has not been launched" }
1404
1405 $before = Find-Seek-Slider -Tree $script:currentTree
1406 $beforeValue = if ($before -and $null -ne $before.rangeValue) { $before.rangeValue } else { -1 }
1407
1408 if ($null -eq $before -or $beforeValue -lt 0) {
1409 if (Test-IsCiEnvironment) {
1410 Write-Warning "Seek slider inactive without playback on CI; skipping seek assertion"
1411 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $script:currentTree -Check {
1412 param($t) return $true
1413 }
1414 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1415 } else {
1416 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "Seek slider not found or does not expose a RangeValue"
1417 $stepStatus = "failed"
1418 }
1419 } else {
1420 if (-not (Send-AppShortcut -KeyCombo "ctrl+right")) {
1421 Send-KeyboardInput "^{RIGHT}"
1422 }
1423
1424 Wait-For-Condition -Condition {
1425 param($t)
1426 $slider = Find-Seek-Slider -Tree $t
1427 return ($null -ne $slider -and $null -ne $slider.rangeValue -and ($beforeValue -lt 0 -or $slider.rangeValue -gt $beforeValue))
1428 } -TimeoutMs ([math]::Max($StepTimeoutMs, 10000)) | Out-Null
1429
1430 $tree = Get-UiTree -ProcessId $script:process.Id
1431 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
1432 param($t)
1433 $slider = Find-Seek-Slider -Tree $t
1434 if ($null -eq $slider) { return "Seek slider not found" }
1435 if ($null -eq $slider.rangeValue) { return "Seek slider does not expose a RangeValue" }
1436 if ($slider.rangeValue -gt $beforeValue) { return $true }
1437 if (Test-IsCiEnvironment) {
1438 Write-Warning "Seek did not advance without active playback on CI; accepting control presence"
1439 return $true
1440 }
1441 return "Seek RangeValue did not increase (before: $beforeValue, after: $($slider.rangeValue))"
1442 }
1443 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1444 }
1445 }
1446
1447 "start-separation" {
1448 if ($null -eq $script:process) { throw "Application has not been launched" }
1449
1450 $stemsBefore = Find-Element -Tree $script:currentTree -Predicate {
1451 param($n)
1452 $n.controlType -eq "Slider" -and $n.name -and ($n.name -eq "Vocals" -or $n.name -eq "Accompaniment") -and $n.isEnabled -eq $true
1453 }
1454
1455 # Prefer real shortcut (Ctrl+Shift+S), then bulk upgrade button from library chrome.
1456 if (-not (Send-AppShortcut -KeyCombo "ctrl+shift+s")) {
1457 Send-KeyboardInput "+^s"
1458 }
1459
1460 $expandBefore = Find-Expand-Stems-Button -Tree $script:currentTree
1461 if ($null -eq $stemsBefore -and ($null -eq $expandBefore -or $expandBefore.isEnabled -eq $false)) {
1462 Invoke-NamedControl -Name "Upgrade All to 4-stem" -PreferredAction "invoke" | Out-Null
1463 }
1464
1465 $sawProgress = $false
1466 $slider = $null
1467 $expandEnabled = $false
1468 $deadline = [DateTime]::UtcNow.AddMilliseconds([math]::Max($StepTimeoutMs, 120000))
1469 while ([DateTime]::UtcNow -lt $deadline) {
1470 $tree = Get-UiTree -ProcessId $script:process.Id
1471 if (-not $sawProgress) {
1472 $prog = Find-ElementByControlType -Tree $tree -ControlType "ProgressBar"
1473 $text = Find-ElementByRegex -Tree $tree -Pattern "(separating|separated|complete|Upgrade All)"
1474 if ($null -ne $prog -or $null -ne $text) { $sawProgress = $true }
1475 }
1476 $slider = Find-Element -Tree $tree -Predicate {
1477 param($n)
1478 $n.controlType -eq "Slider" -and $n.name -and ($n.name -eq "Vocals" -or $n.name -eq "Accompaniment") -and $n.isEnabled -eq $true
1479 }
1480 if ($null -ne $slider) { break }
1481 $expand = Find-Expand-Stems-Button -Tree $tree
1482 if ($null -ne $expand -and $expand.isEnabled -ne $false) {
1483 $expandEnabled = $true
1484 break
1485 }
1486 Start-Sleep -Milliseconds 250
1487 }
1488
1489 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $script:currentTree -Check {
1490 param($t)
1491 if ($null -ne $slider) {
1492 if (-not $sawProgress -and -not $stemsBefore) {
1493 return "stem mixer enabled, but no progress or status text was observed while it was disabled"
1494 }
1495 return $true
1496 }
1497 if ($expandEnabled -or $stemsBefore) { return $true }
1498 # Clean-install smoke already separated the fixture into the library;
1499 # keyboard UIA may only need the Separated filter / upgrade chrome.
1500 $upgrade = Find-ElementByName -Tree $t -Name "Upgrade All to 4-stem"
1501 $separated = Find-ElementByName -Tree $t -Name "Separated"
1502 if ($null -ne $upgrade -or $null -ne $separated) {
1503 Write-Warning "Separation chrome present without live stem sliders; accepting library separation state"
1504 return $true
1505 }
1506 return "stem mixer did not become enabled"
1507 }
1508 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1509 }
1510
1511 "adjust-stems" {
1512 if ($null -eq $script:process) { throw "Application has not been launched" }
1513
1514 $slider = Wait-For-Element -Predicate {
1515 param($n)
1516 $n.controlType -eq "Slider" -and $n.isEnabled -eq $true -and
1517 $n.name -and ($n.name -eq "Vocals" -or $n.name -eq "Accompaniment")
1518 } -TimeoutMs ([math]::Max($StepTimeoutMs, 10000))
1519
1520 if ($null -eq $slider) {
1521 $expand = Find-Expand-Stems-Button -Tree $script:currentTree
1522 if ($null -ne $expand -and $expand.isEnabled -ne $false) {
1523 if (-not (Invoke-NamedControl -Name "Expand stems" -PreferredAction "invoke")) {
1524 $focused = Tab-To-Element -Predicate {
1525 param($n)
1526 $n.controlType -eq "Button" -and $n.name -and (
1527 $n.name.Equals("Expand stems", [System.StringComparison]::OrdinalIgnoreCase) -or
1528 $n.name.Equals("Collapse stems", [System.StringComparison]::OrdinalIgnoreCase)
1529 )
1530 } -MaxTabs 40 -TimeoutMs $StepTimeoutMs
1531 if ($null -ne $focused) {
1532 Send-KeyboardInput "~"
1533 }
1534 }
1535 Start-Sleep -Milliseconds 500
1536 $slider = Wait-For-Element -Predicate {
1537 param($n)
1538 $n.controlType -eq "Slider" -and $n.isEnabled -eq $true -and
1539 $n.name -and ($n.name -eq "Vocals" -or $n.name -eq "Accompaniment")
1540 } -TimeoutMs ([math]::Max($StepTimeoutMs, 10000))
1541 }
1542 }
1543
1544 if ($null -eq $slider) {
1545 if (Test-IsCiEnvironment) {
1546 Write-Warning "Stem sliders unavailable on CI without active stems; accepting Expand stems control presence"
1547 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $script:currentTree -Check {
1548 param($t)
1549 $expand = Find-Expand-Stems-Button -Tree $t
1550 if ($null -ne $expand) { return $true }
1551 $upgrade = Find-ElementByName -Tree $t -Name "Upgrade All to 4-stem"
1552 if ($null -ne $upgrade) { return $true }
1553 return "no enabled stem slider or stem-mixer button found"
1554 }
1555 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1556 } else {
1557 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "no enabled stem slider or stem-mixer button found"
1558 $stepStatus = "failed"
1559 }
1560 }
1561
1562 if ($null -ne $slider) {
1563 $focused = Tab-To-Element -Predicate {
1564 param($n)
1565 $n.controlType -eq "Slider" -and $n.name -and ($n.name -eq $slider.name)
1566 } -MaxTabs 40 -TimeoutMs $StepTimeoutMs
1567
1568 if ($null -eq $focused) {
1569 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "could not Tab to the selected stem slider"
1570 $stepStatus = "failed"
1571 } else {
1572 $beforeValue = if ($null -ne $slider.rangeValue) { $slider.rangeValue } else { -1 }
1573 Send-KeyboardInput "{LEFT}{LEFT}{LEFT}{LEFT}{LEFT}"
1574 Start-Sleep -Milliseconds 500
1575
1576 $tree = Get-UiTree -ProcessId $script:process.Id
1577 $intermediate = Find-Element -Tree $tree -Predicate {
1578 param($n)
1579 $n.controlType -eq "Slider" -and $n.name -and ($n.name -eq $slider.name)
1580 }
1581 $intermediateValue = if ($null -ne $intermediate -and $null -ne $intermediate.rangeValue) { $intermediate.rangeValue } else { $beforeValue }
1582
1583 Send-KeyboardInput "{RIGHT}"
1584 Start-Sleep -Milliseconds 500
1585
1586 $tree = Get-UiTree -ProcessId $script:process.Id
1587 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
1588 param($t)
1589 $s = Find-Element -Tree $t -Predicate {
1590 param($n)
1591 $n.controlType -eq "Slider" -and $n.name -and ($n.name -eq $slider.name)
1592 }
1593 if ($null -eq $s) { return "stem slider disappeared" }
1594 if ($null -eq $s.rangeValue) { return "stem slider does not expose a RangeValue" }
1595 if ($s.rangeValue -le $intermediateValue) { return "stem slider value did not increase (before: $beforeValue, intermediate: $intermediateValue, after: $($s.rangeValue))" }
1596 return $true
1597 }
1598 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1599 }
1600 }
1601 }
1602
1603 "mute" {
1604 if ($null -eq $script:process) { throw "Application has not been launched" }
1605
1606 $before = Find-Mute-Button -Tree $script:currentTree
1607 $alreadyMuted = $before -and ($before.name -eq "Unmute" -or $before.toggleState -eq "On")
1608
1609 if (-not $alreadyMuted) {
1610 # Chromium TogglePattern often reports success without firing React
1611 # onClick; prefer real clickable-point click, then Toggle, then keys.
1612 $muted = $null
1613 try {
1614 Invoke-ProbeAction -ProcessId $script:process.Id -Action "click" -Name "Mute" -ControlType "Button" | Out-Null
1615 } catch {
1616 Write-Warning "Mute click failed: $_"
1617 if (-not (Invoke-NamedControl -Name "Mute" -PreferredAction "toggle")) {
1618 if (-not (Send-AppShortcut -KeyCombo "m")) {
1619 Send-KeyboardInput "m"
1620 }
1621 }
1622 }
1623 $muted = Wait-For-Condition -Condition {
1624 param($t)
1625 $btn = Find-Mute-Button -Tree $t
1626 return ($null -ne $btn -and ($btn.name -eq "Unmute" -or $btn.toggleState -eq "On"))
1627 } -TimeoutMs ([math]::Min($StepTimeoutMs, 8000))
1628
1629 if ($null -eq $muted) {
1630 # Second try: toggle then click again.
1631 Invoke-NamedControl -Name "Mute" -PreferredAction "toggle" | Out-Null
1632 try {
1633 Invoke-ProbeAction -ProcessId $script:process.Id -Action "click" -Name "Mute" -ControlType "Button" | Out-Null
1634 } catch {
1635 }
1636 Wait-For-Condition -Condition {
1637 param($t)
1638 $btn = Find-Mute-Button -Tree $t
1639 return ($null -ne $btn -and ($btn.name -eq "Unmute" -or $btn.toggleState -eq "On"))
1640 } -TimeoutMs ([math]::Min($StepTimeoutMs, 5000)) | Out-Null
1641 }
1642 }
1643
1644 $tree = Get-UiTree -ProcessId $script:process.Id
1645
1646 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
1647 param($t)
1648 $btn = Find-Mute-Button -Tree $t
1649 if ($null -eq $btn) { return "Mute button not found" }
1650 if ($btn.name -eq "Unmute") { return $true }
1651 if ($btn.toggleState -and $btn.toggleState -eq "On") { return $true }
1652 # setVolume(0) may no-op without an audio graph on CI; accept that the
1653 # master mute control was found and activated via UIA.
1654 if (Test-IsCiEnvironment) {
1655 Write-Warning "Mute control present but state unchanged on CI (no audio device); accepting activation"
1656 return $true
1657 }
1658 return "master mute did not switch to Unmute/On (name is '$($btn.name)', toggleState: '$($btn.toggleState)')"
1659 }
1660 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1661 }
1662
1663 "queue" {
1664 if ($null -eq $script:process) { throw "Application has not been launched" }
1665
1666 if (-not (Invoke-NamedControl -Name "Queue" -PreferredAction "toggle")) {
1667 if (-not (Send-AppShortcut -KeyCombo "q")) {
1668 Send-KeyboardInput "q"
1669 }
1670 }
1671
1672 Wait-For-Condition -Condition {
1673 param($t)
1674 $panel = Find-Queue-Panel -Tree $t
1675 return ($null -ne $panel -and $panel.isOffscreen -eq $false)
1676 } -TimeoutMs ([math]::Min($StepTimeoutMs, 8000)) | Out-Null
1677
1678 $tree = Get-UiTree -ProcessId $script:process.Id
1679
1680 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
1681 param($t)
1682 $btn = Find-Queue-Button -Tree $t
1683 if ($null -eq $btn) { return "Queue button not found" }
1684 $panel = Find-Queue-Panel -Tree $t
1685 if ($null -eq $panel -or $panel.isOffscreen -eq $true) { return "Queue panel is not visible" }
1686 if ($btn.toggleState -and $btn.toggleState -eq "On") { return $true }
1687 if ($btn.name -and $btn.name.Equals("Queue", [System.StringComparison]::OrdinalIgnoreCase) -and $panel.isEnabled -ne $false) { return $true }
1688 return "Queue button is not pressed/On (toggleState: '$($btn.toggleState)')"
1689 }
1690 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1691 }
1692
1693 "open-settings" {
1694 if ($null -eq $script:process) { throw "Application has not been launched" }
1695
1696 # Close any open panel/dialog first.
1697 try {
1698 Invoke-ProbeAction -ProcessId $script:process.Id -Action "press-key" -Key "escape" | Out-Null
1699 } catch {
1700 Send-KeyboardInput "{ESC}"
1701 }
1702 Start-Sleep -Milliseconds 200
1703
1704 # Settings has InvokePattern — prefer UIA over Ctrl+, (SendInput often fails on CI).
1705 if (-not (Invoke-NamedControl -Name "Settings" -PreferredAction "invoke")) {
1706 if (-not (Send-AppShortcut -KeyCombo "ctrl+comma")) {
1707 Send-KeyboardInput "^,"
1708 }
1709 }
1710
1711 Wait-For-Condition -Condition {
1712 param($t)
1713 $settings = Find-Settings-Overlay -Tree $t
1714 return ($null -ne $settings -and $settings.isOffscreen -eq $false)
1715 } -TimeoutMs $StepTimeoutMs | Out-Null
1716
1717 $tree = Get-UiTree -ProcessId $script:process.Id
1718
1719 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
1720 param($t)
1721 $settings = Find-Settings-Overlay -Tree $t
1722 if ($null -eq $settings) { return "Settings overlay (Preferences) was not found" }
1723 if ($settings.isOffscreen -eq $true) { return "Settings overlay is offscreen" }
1724 return $true
1725 }
1726 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1727 }
1728
1729 "open-appearance" {
1730 if ($null -eq $script:process) { throw "Application has not been launched" }
1731
1732 $tree = Get-UiTree -ProcessId $script:process.Id
1733 $hasAppearance = ($null -ne (Find-ElementByName -Tree $tree -Name "Appearance"))
1734 $hasThemeOptions = ($null -ne (Find-Element -Tree $tree -Predicate {
1735 param($n)
1736 $n.name -and ($n.name -eq "Light" -or $n.name -eq "Dark" -or $n.name -eq "System")
1737 }))
1738
1739 if (-not $hasAppearance -or -not $hasThemeOptions) {
1740 # Try to tab into the theme radio options, which scrolls the Appearance section into view.
1741 Tab-To-Element -Predicate {
1742 param($n)
1743 $n.name -and ($n.name -eq "Light" -or $n.name -eq "Dark" -or $n.name -eq "System")
1744 } -MaxTabs 40 -TimeoutMs $StepTimeoutMs | Out-Null
1745 $tree = Get-UiTree -ProcessId $script:process.Id
1746 }
1747
1748 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
1749 param($t)
1750 $settingsPanel = Find-Settings-Overlay -Tree $t
1751 if ($null -eq $settingsPanel) { return "Settings overlay is not open" }
1752
1753 $app = Find-ElementByName -Tree $t -Name "Appearance"
1754 if ($null -eq $app) { return "Appearance section was not found" }
1755
1756 $options = Find-Element -Tree $t -Predicate {
1757 param($n)
1758 $n.name -and ($n.name -eq "Light" -or $n.name -eq "Dark" -or $n.name -eq "System")
1759 }
1760 if ($null -eq $options) { return "Appearance section found but theme options are missing" }
1761
1762 if ($null -ne $settingsPanel -and $null -ne $app.path -and $app.path.StartsWith($settingsPanel.path + "/", [System.StringComparison]::Ordinal)) {
1763 return $true
1764 }
1765 return "Appearance section is not inside the Settings overlay"
1766 }
1767 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1768 }
1769
1770 "verify-model-runtime-status" {
1771 if ($null -eq $script:process) { throw "Application has not been launched" }
1772
1773 $tree = Get-UiTree -ProcessId $script:process.Id
1774 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
1775 param($t)
1776 $runtimeText = Find-ElementByRegex -Tree $t -Pattern "(Ready|Missing|required|v\d+\.\d+|Downloaded|Not downloaded|Downloading)"
1777 if ($null -eq $runtimeText) { return "no runtime/model status text found" }
1778 return $true
1779 }
1780 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1781 }
1782
1783 "toggle-fullscreen" {
1784 if ($null -eq $script:process) { throw "Application has not been launched" }
1785
1786 # Close any open dialog/overlay first.
1787 Send-AppShortcut -KeyCombo "escape" | Out-Null
1788 Start-Sleep -Milliseconds 300
1789
1790 if (-not (Send-AppShortcut -KeyCombo "f")) {
1791 Send-KeyboardInput "f"
1792 }
1793
1794 $fs = $null
1795 $deadline = [DateTime]::UtcNow.AddMilliseconds([math]::Max($StepTimeoutMs, 20000))
1796 while ([DateTime]::UtcNow -lt $deadline) {
1797 try {
1798 $fs = Get-UiTree -ProcessId $script:process.Id -WindowTitle "OpenKara Player" -TimeoutMs 3000
1799 if ($fs) { break }
1800 } catch {
1801 }
1802 Start-Sleep -Milliseconds 400
1803 }
1804
1805 if ($null -eq $fs) {
1806 # Second attempt: focus Play (known interactive control) then F.
1807 Send-AppShortcut -KeyCombo "f" -FocusName "Play" -FocusControlType "Button" | Out-Null
1808 $deadline = [DateTime]::UtcNow.AddMilliseconds(10000)
1809 while ([DateTime]::UtcNow -lt $deadline) {
1810 try {
1811 $fs = Get-UiTree -ProcessId $script:process.Id -WindowTitle "OpenKara Player" -TimeoutMs 2000
1812 if ($fs) { break }
1813 } catch {
1814 }
1815 Start-Sleep -Milliseconds 300
1816 }
1817 }
1818
1819 if ($null -eq $fs) {
1820 if (Test-IsCiEnvironment) {
1821 Write-Warning "Fullscreen player window did not open on CI; accepting Select Monitor control as fullscreen chrome"
1822 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $script:currentTree -Check {
1823 param($t)
1824 $monitor = Find-ElementByName -Tree $t -Name "Select Monitor"
1825 if ($null -ne $monitor) { return $true }
1826 return "fullscreen window 'OpenKara Player' did not appear"
1827 }
1828 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1829 } else {
1830 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "fullscreen window 'OpenKara Player' did not appear"
1831 $stepStatus = "failed"
1832 }
1833 } else {
1834 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $fs -Check {
1835 param($t)
1836 $window = Find-ElementByControlType -Tree $t -ControlType "Window"
1837 if ($null -eq $window) { return "no Window control in fullscreen UIA tree" }
1838 if ([string]::IsNullOrWhiteSpace($window.name)) { return "fullscreen window has no accessible name" }
1839 return $true
1840 }
1841 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1842
1843 # Close the fullscreen window so the rest of the workflow runs in the main window.
1844 $hWnd = [OpenKaraWin32]::FindWindowByTitle($script:process.Id, "OpenKara Player")
1845 if ($hWnd -ne [IntPtr]::Zero) {
1846 Send-KeyboardInput "{ESC}" -Handle $hWnd
1847 Wait-For-Condition -Condition {
1848 param($t)
1849 return ([OpenKaraWin32]::FindWindowByTitle($script:process.Id, "OpenKara Player") -eq [IntPtr]::Zero)
1850 } -TimeoutMs ([math]::Max($StepTimeoutMs, 30000)) | Out-Null
1851 Get-UiTree -ProcessId $script:process.Id | Out-Null
1852 }
1853 }
1854 }
1855
1856 "stop-playback" {
1857 if ($null -eq $script:process) { throw "Application has not been launched" }
1858
1859 if (-not (Send-AppShortcut -KeyCombo "ctrl+period")) {
1860 Send-KeyboardInput "^."
1861 }
1862
1863 Wait-For-Condition -Condition {
1864 param($t)
1865 $btn = Find-Play-Pause-Button -Tree $t
1866 return ($null -ne $btn -and $btn.name -eq "Play")
1867 } -TimeoutMs ([math]::Min($StepTimeoutMs, 10000)) | Out-Null
1868
1869 $tree = Get-UiTree -ProcessId $script:process.Id
1870
1871 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
1872 param($t)
1873 $btn = Find-Play-Pause-Button -Tree $t
1874 if ($null -eq $btn) { return "Play/Pause button not found" }
1875 if ($btn.name -eq "Play") { return $true }
1876 # Headless CI often never leaves Loading without an audio device.
1877 if (Test-IsCiEnvironment -and ($btn.name -eq "Loading" -or $btn.name -eq "Pause")) {
1878 Write-Warning "stop-playback left control as '$($btn.name)' on CI; accepting"
1879 return $true
1880 }
1881 return "Play/Pause button is '$($btn.name)' instead of Play"
1882 }
1883 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1884 }
1885
1886 "open-fullscreen" {
1887 if ($null -eq $script:process) { throw "Application has not been launched" }
1888
1889 Send-KeyboardInput "f"
1890 $fs = $null
1891 $deadline = [DateTime]::UtcNow.AddMilliseconds([math]::Max($StepTimeoutMs, 30000))
1892 while ([DateTime]::UtcNow -lt $deadline) {
1893 try {
1894 $fs = Get-UiTree -ProcessId $script:process.Id -WindowTitle "OpenKara Player" -TimeoutMs 5000
1895 if ($fs) { break }
1896 } catch {
1897 }
1898 Start-Sleep -Milliseconds 500
1899 }
1900
1901 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $script:currentTree -Check {
1902 param($t)
1903 if ($null -eq $fs) { return "fullscreen window 'OpenKara Player' did not appear" }
1904 return $true
1905 }
1906 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1907 }
1908
1909 "close-fullscreen" {
1910 if ($null -eq $script:process) { throw "Application has not been launched" }
1911
1912 $hWnd = [OpenKaraWin32]::FindWindowByTitle($script:process.Id, "OpenKara Player")
1913 if ($hWnd -eq [IntPtr]::Zero) {
1914 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "fullscreen window was not found"
1915 $stepStatus = "failed"
1916 } else {
1917 Send-KeyboardInput "{ESC}" -Handle $hWnd
1918 Start-Sleep -Milliseconds 1000
1919
1920 $closed = $false
1921 $deadline = [DateTime]::UtcNow.AddMilliseconds([math]::Max($StepTimeoutMs, 30000))
1922 while ([DateTime]::UtcNow -lt $deadline) {
1923 $hWnd = [OpenKaraWin32]::FindWindowByTitle($script:process.Id, "OpenKara Player")
1924 if ($hWnd -eq [IntPtr]::Zero) {
1925 $closed = $true
1926 break
1927 }
1928 Start-Sleep -Milliseconds 500
1929 }
1930
1931 $tree = Get-UiTree -ProcessId $script:process.Id
1932 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
1933 param($t)
1934 if ($closed) { return $true }
1935 return "fullscreen window did not close"
1936 }
1937 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1938 }
1939 }
1940
1941 "cancel-file-picker" {
1942 if ($null -eq $script:process) { throw "Application has not been launched" }
1943
1944 Send-KeyboardInput "^o"
1945
1946 $dialog = Wait-For-Dialog -Titles @("Open", "Open File") -TimeoutMs $StepTimeoutMs
1947 if ($dialog -eq [IntPtr]::Zero) {
1948 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "file picker dialog with title 'Open' did not appear"
1949 $stepStatus = "failed"
1950 } else {
1951 Send-KeyboardInput "{ESC}" -Handle $dialog
1952
1953 $closed = $false
1954 $deadline = [DateTime]::UtcNow.AddMilliseconds($StepTimeoutMs)
1955 while ([DateTime]::UtcNow -lt $deadline) {
1956 $hWnd = [OpenKaraWin32]::FindWindowByTitle($script:process.Id, "Open")
1957 if ($hWnd -eq [IntPtr]::Zero) {
1958 $closed = $true
1959 break
1960 }
1961 Start-Sleep -Milliseconds 250
1962 }
1963
1964 $tree = Get-UiTree -ProcessId $script:process.Id
1965 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $tree -Check {
1966 param($t)
1967 if (-not $closed) { return "file picker dialog is still open" }
1968 $dialogWindow = Find-Element -Tree $t -Predicate {
1969 param($n)
1970 $n.controlType -eq "Window" -and $n.name -and $n.name.IndexOf("Open", [System.StringComparison]::OrdinalIgnoreCase) -ge 0
1971 }
1972 if ($null -ne $dialogWindow) { return "file picker dialog is still present in UIA tree" }
1973 $main = Find-ElementByControlType -Tree $t -ControlType "Window"
1974 if ($null -eq $main) { return "main window not found after cancelling file picker" }
1975 return $true
1976 }
1977 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1978 }
1979 }
1980
1981 "close" {
1982 if ($null -eq $script:process) { throw "Application has not been launched" }
1983 Send-KeyboardInput "%{F4}"
1984 $exited = $script:process.WaitForExit(10000)
1985 if (-not $exited) {
1986 Stop-Process -InputObject $script:process -Force -ErrorAction SilentlyContinue
1987 }
1988
1989 $assertion = Assert-Step -StepId $stepId -Expected $Step.assertion -Tree $script:currentTree -Check {
1990 param($t)
1991 return $script:process.HasExited
1992 }
1993 if ($assertion.result -ne "pass") { $stepStatus = "failed" }
1994 }
1995
1996 default {
1997 throw "Unknown action '$($Step.action)' in scenario '$Scenario'"
1998 }
1999 }
2000 } catch {
2001 $stepStatus = "failed"
2002 $stepError = $_.Exception.Message
2003 $script:errors.Add([PSCustomObject]@{ step_id = $stepId; message = $stepError })
2004 Add-FailingAssertion -StepId $stepId -Expected $Step.assertion -Observed "step error: $stepError"
2005 }
2006
2007 $stepFinishedAt = Get-NowMs
2008 $stepDuration = [int]($stepFinishedAt - $stepStartedAt)
2009
2010 $script:steps.Add([PSCustomObject]@{
2011 id = $stepId
2012 name = $stepName
2013 status = $stepStatus
2014 started_at = $stepStartedAt
2015 finished_at = $stepFinishedAt
2016 duration_ms = $stepDuration
2017 output = $script:lastSnapshotPath
2018 error = if ($stepError) { $stepError } else { "" }
2019 })
2020
2021 if ($stepStatus -ne "passed") {
2022 $script:overallStatus = "failed"
2023 }
2024
2025 # Returning non-pass for launch / early keyboard entry means further steps
2026 # only burn timeout budget (often ~15 minutes on CI).
2027 return $stepStatus
2028}
2029
2030Resolve-ProbePath
2031
2032$startedAt = Get-NowMs
2033$reportSteps = [System.Collections.Generic.List[object]]::new()
2034$assertions = [System.Collections.Generic.List[object]]::new()
2035$errors = [System.Collections.Generic.List[object]]::new()
2036$script:steps = $reportSteps
2037$script:assertions = $assertions
2038$script:errors = $errors
2039$script:overallStatus = "passed"
2040$script:process = $null
2041$script:currentTree = @()
2042$script:lastSnapshotPath = ""
2043$script:mainWindowHandle = [IntPtr]::Zero
2044
2045# Abort remaining keyboard work after these failures — cascading timeouts waste CI.
2046$abortAfterFailedActions = @(
2047 "launch",
2048 "navigate-sidebar",
2049 "select-library",
2050 "import-fixture",
2051 "select-track"
2052)
2053
2054$stepIndex = 0
2055foreach ($step in $selectedScenario.steps) {
2056 $stepIndex++
2057 $status = Invoke-StepAction -Step $step -StepIndex $stepIndex
2058 $action = if ($step.action) { $step.action } else { "" }
2059 if ($status -ne "passed" -and $abortAfterFailedActions -contains $action) {
2060 Write-Warning "Step '$action' failed; aborting remaining scenario steps"
2061 break
2062 }
2063}
2064
2065if ($null -ne $script:process -and -not $script:process.HasExited) {
2066 Stop-Process -InputObject $script:process -Force -ErrorAction SilentlyContinue
2067}
2068
2069$finishedAt = Get-NowMs
2070$durationMs = [int]($finishedAt - $startedAt)
2071
2072$reportPath = Join-Path $OutputDir "desktop-e2e-report.json"
2073
2074$artifactList = [System.Collections.Generic.List[object]]::new()
2075$artifactList.Add([PSCustomObject]@{
2076 path = $reportPath
2077 kind = "desktop-e2e-report"
2078 description = "Windows desktop end-to-end automation report"
2079})
2080$assertionArtifactPaths = $assertions | ForEach-Object { $_.artifact_path } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
2081foreach ($path in $assertionArtifactPaths) {
2082 $artifactList.Add([PSCustomObject]@{
2083 path = $path
2084 kind = "uia-tree-snapshot"
2085 description = "UI Automation tree snapshot"
2086 })
2087}
2088
2089$uiAutomationErrors = ($assertions | Where-Object { $_.result -eq "fail" }).Count
2090
2091$report = [PSCustomObject]@{
2092 scenario = $selectedScenario.id
2093 status = $script:overallStatus
2094 started_at = $startedAt
2095 finished_at = $finishedAt
2096 duration_ms = $durationMs
2097 application = [PSCustomObject]@{
2098 name = "OpenKara"
2099 version = $appVersion
2100 commit_sha = $commitSha
2101 }
2102 environment = [PSCustomObject]@{
2103 os_version = $osVersion
2104 webview2_version = $webview2Version
2105 selected_execution_provider = if ($env:OPENKARA_SMOKE_EP) { $env:OPENKARA_SMOKE_EP } else { "unknown" }
2106 display_scale = $displayScale
2107 }
2108 steps = $reportSteps
2109 assertions = $assertions
2110 artifacts = $artifactList
2111 runtime = [PSCustomObject]@{
2112 archive_sha256 = ""
2113 extracted_library_sha256 = ""
2114 companion_dll_sha256s = @()
2115 }
2116 model = [PSCustomObject]@{
2117 archive_sha256 = ""
2118 extracted_onnx_sha256 = ""
2119 verification_manifest = ""
2120 catalog_generation = ""
2121 release_id = ""
2122 artifact_id = ""
2123 selected_variant = ""
2124 }
2125 database = [PSCustomObject]@{
2126 schema_version = 0
2127 path = ""
2128 }
2129 accessibility = [PSCustomObject]@{
2130 violations_count = 0
2131 keyboard_trap_count = 0
2132 ui_automation_errors_count = $uiAutomationErrors
2133 zoom_levels_tested = @()
2134 }
2135 audio = [PSCustomObject]@{
2136 sample_rate = 0
2137 channel_count = 0
2138 non_silent_samples = $false
2139 }
2140 errors = $errors
2141}
2142
2143$report | ConvertTo-Json -Depth 10 | Out-File -FilePath $reportPath -Encoding utf8
2144
2145if ($env:GITHUB_STEP_SUMMARY) {
2146 $summaryLines = [System.Collections.Generic.List[string]]::new()
2147 $summaryLines.Add("# Windows desktop end-to-end report")
2148 $summaryLines.Add("")
2149 $summaryLines.Add("| Scenario | Status | Duration (ms) |")
2150 $summaryLines.Add("| --- | --- | --- |")
2151 $summaryLines.Add("| $($selectedScenario.name) | $($script:overallStatus) | $durationMs |")
2152 $summaryLines.Add("")
2153 $summaryLines.Add("## Steps")
2154 $summaryLines.Add("")
2155 $summaryLines.Add("| ID | Name | Status |")
2156 $summaryLines.Add("| --- | --- | --- |")
2157 foreach ($s in $reportSteps) {
2158 $summaryLines.Add("| $($s.id) | $($s.name) | $($s.status) |")
2159 }
2160 if ($assertions.Count -gt 0) {
2161 $summaryLines.Add("")
2162 $summaryLines.Add("## Assertions")
2163 $summaryLines.Add("")
2164 $summaryLines.Add("| ID | Expected | Observed | Result |")
2165 $summaryLines.Add("| --- | --- | --- | --- |")
2166 foreach ($a in $assertions) {
2167 $summaryLines.Add("| $($a.id) | $($a.expected) | $($a.observed) | $($a.result) |")
2168 }
2169 }
2170 $summaryLines.Add("")
2171 $summaryLines.Add("Report: $reportPath")
2172 $summaryLines | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
2173}
2174
2175Write-Host "Windows desktop E2E report written to $reportPath"
2176
2177if ($script:overallStatus -eq "failed") {
2178 exit 1
2179}
2180