staysfixed 0.3.1 → 0.6.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +159 -3
  2. package/README.md +611 -402
  3. package/package.json +8 -3
  4. package/src/cli/index.js +14 -0
  5. package/src/v2/adapters/android-driver.js +1705 -0
  6. package/src/v2/adapters/android.js +1117 -0
  7. package/src/v2/adapters/contract.js +643 -0
  8. package/src/v2/adapters/electron.js +1594 -0
  9. package/src/v2/adapters/http.js +734 -0
  10. package/src/v2/adapters/ios-driver.js +1551 -0
  11. package/src/v2/adapters/ios.js +989 -0
  12. package/src/v2/adapters/isolate.js +739 -0
  13. package/src/v2/adapters/process.js +931 -0
  14. package/src/v2/adapters/source.js +1292 -0
  15. package/src/v2/adapters/web-driver.js +1532 -0
  16. package/src/v2/adapters/web.js +1009 -0
  17. package/src/v2/adapters/windows.js +1329 -0
  18. package/src/v2/browsers.js +1203 -0
  19. package/src/v2/cause.js +371 -0
  20. package/src/v2/check.js +1429 -0
  21. package/src/v2/ci.js +1209 -0
  22. package/src/v2/cli.js +670 -0
  23. package/src/v2/cluster.js +372 -0
  24. package/src/v2/coverage.js +1124 -0
  25. package/src/v2/detect.js +1199 -0
  26. package/src/v2/doctor.js +1702 -0
  27. package/src/v2/escalate.js +679 -0
  28. package/src/v2/init.js +1394 -0
  29. package/src/v2/intent.js +659 -0
  30. package/src/v2/journeys/from-routes.js +500 -0
  31. package/src/v2/journeys/from-suite.js +988 -0
  32. package/src/v2/journeys/index.js +651 -0
  33. package/src/v2/journeys/record.js +516 -0
  34. package/src/v2/mcp/server.js +374 -0
  35. package/src/v2/mcp/tools.js +1571 -0
  36. package/src/v2/normalise.js +783 -0
  37. package/src/v2/observation.js +938 -0
  38. package/src/v2/rank.js +672 -0
  39. package/src/v2/reference.js +1051 -0
  40. package/src/v2/remote.js +910 -0
  41. package/src/v2/run.js +1080 -0
  42. package/src/v2/sealed.js +568 -0
  43. package/src/v2/selfcheck.js +729 -0
  44. package/src/v2/ship.js +684 -0
  45. package/src/v2/store.js +703 -0
  46. package/src/v2/types.js +509 -0
  47. package/src/v2/waiver.js +511 -0
  48. package/src/v2/watch/focus.js +215 -0
@@ -0,0 +1,1329 @@
1
+ /**
2
+ * Native Windows applications, driven over the machine somebody already has.
3
+ *
4
+ * ── WHAT WAS FOUND BEFORE ANY OF THIS WAS WRITTEN ──────────────────────────────────────────
5
+ *
6
+ * The design costed this platform at a week and a half and assumed a small .NET probe built on
7
+ * FlaUI, shipped as one executable. That was checked against a real Windows 11 machine on
8
+ * 2026-08-29 before a line was written, and it is the wrong answer. Three measurements settled it.
9
+ *
10
+ * 1. The machine has the .NET 8 RUNTIME but no SDK. A FlaUI probe could not be built there,
11
+ * so it would have to be cross-built elsewhere and copied over as an unsigned executable —
12
+ * which is the definition of "installing something on somebody's machine", and which
13
+ * SmartScreen is entitled to block.
14
+ * 2. Windows PowerShell 5.1 is already there, and it can load `UIAutomationClient` from the
15
+ * .NET Framework that ships with the operating system. That is the same UI Automation that
16
+ * FlaUI wraps. It read a real File Explorer window — 148 controls with names, roles, states
17
+ * and rectangles — in 303 milliseconds. Nothing was installed to do it.
18
+ * 3. So the probe is PowerShell, sent down the connection at the start of a run, living only
19
+ * in the memory of the process running it, leaving nothing behind. FlaUI remains the honest
20
+ * upgrade path if a window ever turns out to be too big for this to read quickly, and that
21
+ * is a decision to make with a measurement in hand rather than in advance.
22
+ *
23
+ * ── WHAT IT WATCHES ────────────────────────────────────────────────────────────────────────
24
+ *
25
+ * meaning The window tree from UI Automation: what each control IS (a button, a checkbox,
26
+ * a list), what it is CALLED, its automation id, whether it is on, off, checked,
27
+ * expanded or selected, and what it says it can DO. This is the channel that
28
+ * answers "what does the screen say this control now does", and it is the reason
29
+ * the platform is worth covering at all.
30
+ * effects Programs it started, and files that changed in the folders it was told to watch.
31
+ * complaints Windows Error Reporting entries, application hangs, and anything the program
32
+ * logged to the Windows event log while it was running. Plus whether it exited.
33
+ * results What a console program printed, and the titles of the windows it opened.
34
+ * counters How many windows, how many controls, and how long things took, all in buckets.
35
+ * pixels A picture of each window, as evidence for something another channel already found.
36
+ *
37
+ * ── WHAT IT CANNOT DO, SAID PLAINLY ────────────────────────────────────────────────────────
38
+ *
39
+ * TWO BUILDS CANNOT RUN AT ONCE. Not "should not" — cannot, in principle. UI Automation reads
40
+ * whatever desktop is in front of it, and Windows has one. So runs are strictly sequential, and
41
+ * the same-machine guarantee is weaker here than anywhere else: the desktop is shared with
42
+ * whatever else that person has open, and a notification popping up during a run is a real
43
+ * source of difference that no amount of freezing removes. The wobble measurement absorbs some
44
+ * of it. It does not absorb all of it, and this adapter reports a run on a busy desktop as
45
+ * exactly that.
46
+ *
47
+ * THERE IS NO SAFETY BOUNDARY AT THE WIRE. The CLI adapter can watch a program ask to reach the
48
+ * internet and refuse it, because it loads a watcher inside a Node child. There is no equivalent
49
+ * for a compiled Windows application without administrator rights, and the account this runs
50
+ * under does not have them. So a journey marked irreversible is REFUSED OUTRIGHT here rather
51
+ * than walked carefully — it is reported as missing coverage, and it never runs.
52
+ *
53
+ * FILES AND NETWORK ARE SAMPLED, NOT CAPTURED. Everything that would really capture them —
54
+ * Process Monitor, an ETW kernel session, pktmon — needs administrator. What is left works and
55
+ * is worth having: the folders this adapter is told to watch are compared before and after, and
56
+ * connections belonging to the program are sampled while it runs. A file written outside those
57
+ * folders is not seen. A connection that opens and closes between two samples is not seen. Both
58
+ * are reported as holes, in those words, and never as "it did not do anything".
59
+ *
60
+ * MOST WINDOWS PRODUCTS DO NOT NEED THIS. If the Windows build is Electron — and most desktop
61
+ * products are, including the one this tool was written alongside — it is already covered from
62
+ * any machine over its debug port by the Electron adapter, in full, with two builds able to run
63
+ * side by side. This adapter DECLINES a Chromium window on purpose and says where to go instead.
64
+ * Reading an Electron window through UI Automation would also switch on Chromium's accessibility
65
+ * engine, which changes the timing and behaviour of the very thing being measured.
66
+ *
67
+ * AN EMPTY TREE IS NEVER A PASS. A minimised window on that machine returned zero controls from
68
+ * the fast cached read while a plain tree walk still found children in it. A tool that reported
69
+ * that as "this window has no controls" would compare zero against zero on the next run and call
70
+ * it unchanged. So every read is cross-checked, and a tree that comes back empty or suddenly
71
+ * much smaller is recorded as unchecked with the reason attached.
72
+ */
73
+
74
+ import fsp from 'node:fs/promises';
75
+ import path from 'node:path';
76
+ import { spawn } from 'node:child_process';
77
+ import {
78
+ countBucket, defineAdapter, joinPath, notCovered, observation, sizeBucket, timeBucket,
79
+ trimForStorage,
80
+ } from './contract.js';
81
+ import { RemoteLinkLost, remoteRunner } from '../remote.js';
82
+
83
+ /** @typedef {import('./contract.js').Build} Build */
84
+ /** @typedef {import('./contract.js').PreparedBuild} PreparedBuild */
85
+ /** @typedef {import('./contract.js').RunContext} RunContext */
86
+ /** @typedef {import('./contract.js').AdapterProject} AdapterProject */
87
+ /** @typedef {import('./contract.js').Detection} Detection */
88
+ /** @typedef {import('./contract.js').Missing} Missing */
89
+ /** @typedef {import('../types.js').Journey} Journey */
90
+ /** @typedef {import('../types.js').Observation} Observation */
91
+
92
+ /**
93
+ * Window classes that mean "this is not really a native Windows app".
94
+ *
95
+ * Chromium-based shells all report one of these. Seeing one is not a failure — it is the
96
+ * adapter finding out that a better tool for the job already exists and saying so.
97
+ */
98
+ export const CHROMIUM_CLASSES = ['Chrome_WidgetWin_0', 'Chrome_WidgetWin_1'];
99
+
100
+ /** How long to let an app get its first window up before calling it a no-show. */
101
+ const WINDOW_WAIT_MS = 20_000;
102
+
103
+ /** Controls past this many, and the tree is stored as a summary with a fingerprint instead. */
104
+ const MAX_TREE_NODES = 4000;
105
+
106
+ /** A window picture bigger than this is dropped rather than carried back inline. */
107
+ const MAX_SHOT_BYTES = 4 * 1024 * 1024;
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // The probe
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /**
114
+ * The PowerShell program that runs on the Windows side for the length of one run.
115
+ *
116
+ * It is a request-and-reply loop over standard input and standard output, one JSON object per
117
+ * line, each reply carrying the sentinel the transport looks for. It is written as one string
118
+ * here rather than kept as a .ps1 file for one reason that matters: nothing is ever written to
119
+ * that machine's disk, so there is nothing to leave behind, nothing to go stale, and nothing
120
+ * for the person who owns the machine to find later and wonder about.
121
+ *
122
+ * Two rules are enforced inside the probe itself rather than in JavaScript, because this is the
123
+ * only side that can enforce them:
124
+ *
125
+ * - It only ever stops a process it started. `$ours` is the whole list, and `stop` checks it.
126
+ * Somebody's real work is on that desktop.
127
+ * - Every tree read is taken twice through two different mechanisms — the fast cached read and
128
+ * a plain walker count — and reports both. Disagreement is what tells the engine the read
129
+ * was not trustworthy, and it is the only defence against a confidently empty answer.
130
+ *
131
+ * @returns {string} PowerShell, ready to be base64ed onto the wire
132
+ */
133
+ export function windowsProbeScript() {
134
+ return `
135
+ $ErrorActionPreference = 'Stop'
136
+ [Console]::OutputEncoding = [Text.Encoding]::UTF8
137
+ Add-Type -AssemblyName UIAutomationClient
138
+ Add-Type -AssemblyName UIAutomationTypes
139
+ Add-Type -AssemblyName System.Drawing
140
+ Add-Type -AssemblyName System.Windows.Forms
141
+ Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public struct SFRect { public int L, T, R, B; } public class SFNative { [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags); [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out SFRect r); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); }'
142
+
143
+ $AE = [System.Windows.Automation.AutomationElement]
144
+ $TC = [System.Windows.Automation.Condition]::TrueCondition
145
+ $ours = New-Object 'System.Collections.Generic.HashSet[int]'
146
+
147
+ function Emit($o) {
148
+ [Console]::Out.WriteLine('#SF#' + ($o | ConvertTo-Json -Compress -Depth 20))
149
+ [Console]::Out.Flush()
150
+ }
151
+
152
+ function CacheFor() {
153
+ $cr = New-Object System.Windows.Automation.CacheRequest
154
+ foreach ($p in @($AE::NameProperty, $AE::ControlTypeProperty, $AE::AutomationIdProperty,
155
+ $AE::ClassNameProperty, $AE::IsEnabledProperty, $AE::IsOffscreenProperty,
156
+ $AE::BoundingRectangleProperty, $AE::HelpTextProperty,
157
+ $AE::IsKeyboardFocusableProperty, $AE::HasKeyboardFocusProperty)) { $cr.Add($p) }
158
+ $cr.TreeScope = [System.Windows.Automation.TreeScope]::Subtree
159
+ $cr.TreeFilter = [System.Windows.Automation.Automation]::ControlViewCondition
160
+ return $cr
161
+ }
162
+
163
+ function StateOf($el) {
164
+ $bits = @()
165
+ try { $t = $null; if ($el.TryGetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern, [ref] $t)) { $bits += 'toggle=' + $t.Current.ToggleState } } catch {}
166
+ try { $x = $null; if ($el.TryGetCurrentPattern([System.Windows.Automation.ExpandCollapsePattern]::Pattern, [ref] $x)) { $bits += 'expand=' + $x.Current.ExpandCollapseState } } catch {}
167
+ try { $s = $null; if ($el.TryGetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern, [ref] $s)) { $bits += 'selected=' + $s.Current.IsSelected } } catch {}
168
+ try { $v = $null; if ($el.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref] $v)) { $bits += 'value=' + $v.Current.Value; $bits += 'readonly=' + $v.Current.IsReadOnly } } catch {}
169
+ try { $r = $null; if ($el.TryGetCurrentPattern([System.Windows.Automation.RangeValuePattern]::Pattern, [ref] $r)) { $bits += 'range=' + $r.Current.Value } } catch {}
170
+ return ($bits -join ' ')
171
+ }
172
+
173
+ function CanDo($el) {
174
+ $names = @()
175
+ try { foreach ($p in $el.GetSupportedPatterns()) { $names += $p.ProgrammaticName.Replace('PatternIdentifiers.Pattern', '') } } catch {}
176
+ return ($names | Sort-Object)
177
+ }
178
+
179
+ function WalkCount($el) {
180
+ $w = [System.Windows.Automation.TreeWalker]::ControlViewWalker
181
+ $n = 0
182
+ $stack = New-Object 'System.Collections.Generic.Stack[object]'
183
+ $stack.Push($el)
184
+ while ($stack.Count -gt 0 -and $n -lt 20000) {
185
+ $cur = $stack.Pop()
186
+ $n++
187
+ try { $c = $w.GetFirstChild($cur); while ($c -ne $null) { $stack.Push($c); $c = $w.GetNextSibling($c) } } catch {}
188
+ }
189
+ return $n
190
+ }
191
+
192
+ function TopWindows($processId) {
193
+ $out = @()
194
+ foreach ($k in $AE::RootElement.FindAll([System.Windows.Automation.TreeScope]::Children, $TC)) {
195
+ try {
196
+ $c = $k.Current
197
+ if ($processId -ge 0 -and $c.ProcessId -ne $processId) { continue }
198
+ $h = [IntPtr] $c.NativeWindowHandle
199
+ $out += @{ name = $c.Name; cls = $c.ClassName; pid = $c.ProcessId; hwnd = [int64] $c.NativeWindowHandle;
200
+ type = $c.ControlType.ProgrammaticName; visible = [SFNative]::IsWindowVisible($h) }
201
+ } catch {}
202
+ }
203
+ return $out
204
+ }
205
+
206
+ function FindByHandle($hwnd) {
207
+ $cond = New-Object System.Windows.Automation.PropertyCondition($AE::NativeWindowHandleProperty, [int] $hwnd)
208
+ return $AE::RootElement.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
209
+ }
210
+
211
+ function ReadTree($win, $limit) {
212
+ $cr = CacheFor
213
+ $act = $cr.Activate()
214
+ try { $set = $win.FindAll([System.Windows.Automation.TreeScope]::Subtree, $TC) } finally { $act.Dispose() }
215
+ $nodes = @()
216
+ $i = 0
217
+ foreach ($e in $set) {
218
+ if ($i -ge $limit) { break }
219
+ $i++
220
+ $cc = $e.Cached
221
+ $r = $cc.BoundingRectangle
222
+ $nodes += @{
223
+ type = $cc.ControlType.ProgrammaticName.Replace('ControlType.', '')
224
+ name = $cc.Name
225
+ aid = $cc.AutomationId
226
+ cls = $cc.ClassName
227
+ help = $cc.HelpText
228
+ on = $cc.IsEnabled
229
+ hidden = $cc.IsOffscreen
230
+ focusable = $cc.IsKeyboardFocusable
231
+ focused = $cc.HasKeyboardFocus
232
+ w = [int] $r.Width
233
+ h = [int] $r.Height
234
+ state = (StateOf $e)
235
+ can = (CanDo $e)
236
+ }
237
+ }
238
+ return @{ nodes = $nodes; cached = $set.Count; walked = (WalkCount $win) }
239
+ }
240
+
241
+ function Shot($hwnd) {
242
+ $h = [IntPtr] $hwnd
243
+ $r = New-Object SFRect
244
+ [void] [SFNative]::GetWindowRect($h, [ref] $r)
245
+ $w = $r.R - $r.L; $ht = $r.B - $r.T
246
+ if ($w -le 0 -or $ht -le 0) { return @{ ok = $false; why = 'that window has no size on screen right now' } }
247
+ $bmp = New-Object System.Drawing.Bitmap $w, $ht
248
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
249
+ $dc = $g.GetHdc()
250
+ $ok = [SFNative]::PrintWindow($h, $dc, 2)
251
+ $g.ReleaseHdc($dc); $g.Dispose()
252
+ $lit = 0
253
+ $rand = New-Object Random 7
254
+ for ($i = 0; $i -lt 200; $i++) { $px = $bmp.GetPixel($rand.Next($w), $rand.Next($ht)); if ($px.R + $px.G + $px.B -gt 30) { $lit++ } }
255
+ $ms = New-Object System.IO.MemoryStream
256
+ $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
257
+ $bmp.Dispose()
258
+ return @{ ok = $ok; w = $w; h = $ht; lit = $lit; bytes = $ms.Length; png = [Convert]::ToBase64String($ms.ToArray()) }
259
+ }
260
+
261
+ function SnapDir($dir) {
262
+ $files = @{}
263
+ if (-not (Test-Path $dir)) { return $files }
264
+ foreach ($f in (Get-ChildItem -Path $dir -Recurse -File -Force -ErrorAction SilentlyContinue | Select-Object -First 5000)) {
265
+ $files[$f.FullName.Substring($dir.Length).TrimStart('\\')] = $f.Length
266
+ }
267
+ return $files
268
+ }
269
+
270
+ $os = Get-CimInstance Win32_OperatingSystem
271
+ $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
272
+ Emit @{
273
+ id = 'hello'; ok = $true; kind = 'windows'
274
+ host = $env:COMPUTERNAME; user = $env:USERNAME
275
+ windows = $os.Caption + ' ' + $os.Version
276
+ ps = $PSVersionTable.PSVersion.ToString()
277
+ session = (Get-Process -Id $PID).SessionId
278
+ locked = [bool] (Get-Process LogonUI -ErrorAction SilentlyContinue)
279
+ loggedIn = [bool] (Get-Process explorer -ErrorAction SilentlyContinue)
280
+ screens = [System.Windows.Forms.Screen]::AllScreens.Count
281
+ screen = '' + $vs.Width + 'x' + $vs.Height
282
+ temp = $env:TEMP
283
+ admin = (New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
284
+ }
285
+
286
+ while ($true) {
287
+ $line = [Console]::In.ReadLine()
288
+ if ($null -eq $line) { break }
289
+ if ($line.Trim() -eq '') { continue }
290
+ try { $req = $line | ConvertFrom-Json } catch { Emit @{ id = '?'; ok = $false; error = 'that was not readable json' }; continue }
291
+ $watch = [Diagnostics.Stopwatch]::StartNew()
292
+ try {
293
+ switch ($req.op) {
294
+
295
+ 'bye' { Emit @{ id = $req.id; ok = $true }; exit 0 }
296
+
297
+ 'ping' { Emit @{ id = $req.id; ok = $true; ms = $watch.ElapsedMilliseconds } }
298
+
299
+ 'launch' {
300
+ $si = @{ FilePath = $req.exe; PassThru = $true }
301
+ if ($req.args) { $si.ArgumentList = $req.args }
302
+ if ($req.cwd) { $si.WorkingDirectory = $req.cwd }
303
+ $before = @(Get-CimInstance Win32_Process -Property ProcessId | ForEach-Object { $_.ProcessId })
304
+ $p = Start-Process @si
305
+ [void] $ours.Add($p.Id)
306
+ Emit @{ id = $req.id; ok = $true; pid = $p.Id; before = $before.Count; startedAt = (Get-Date).ToString('o'); ms = $watch.ElapsedMilliseconds }
307
+ }
308
+
309
+ 'windows' { $w = TopWindows ([int] $req.pid); Emit @{ id = $req.id; ok = $true; windows = $w; count = $w.Count; ms = $watch.ElapsedMilliseconds } }
310
+
311
+ 'tree' {
312
+ $win = FindByHandle $req.hwnd
313
+ if ($null -eq $win) { Emit @{ id = $req.id; ok = $false; error = 'that window is not on the desktop any more' }; break }
314
+ $limit = if ($req.limit) { [int] $req.limit } else { 4000 }
315
+ $t = ReadTree $win $limit
316
+ Emit @{ id = $req.id; ok = $true; nodes = $t.nodes; cached = $t.cached; walked = $t.walked; ms = $watch.ElapsedMilliseconds }
317
+ }
318
+
319
+ 'settle' {
320
+ $win = FindByHandle $req.hwnd
321
+ if ($null -eq $win) { Emit @{ id = $req.id; ok = $false; error = 'that window is not on the desktop any more' }; break }
322
+ $limit = if ($req.limit) { [int] $req.limit } else { 4000 }
323
+ $tries = if ($req.tries) { [int] $req.tries } else { 6 }
324
+ $gap = if ($req.gapMs) { [int] $req.gapMs } else { 250 }
325
+ $last = $null; $lastKey = ''; $agreed = $false; $n = 0
326
+ while ($n -lt $tries) {
327
+ $n++
328
+ $t = ReadTree $win $limit
329
+ $key = ($t.nodes | ForEach-Object { $_.type + '/' + $_.name + '/' + $_.aid + '/' + $_.on + '/' + $_.state }) -join '|'
330
+ if ($key -eq $lastKey -and $n -gt 1) { $agreed = $true; $last = $t; break }
331
+ $lastKey = $key; $last = $t
332
+ Start-Sleep -Milliseconds $gap
333
+ }
334
+ Emit @{ id = $req.id; ok = $true; agreed = $agreed; reads = $n; nodes = $last.nodes; cached = $last.cached; walked = $last.walked; ms = $watch.ElapsedMilliseconds }
335
+ }
336
+
337
+ 'shot' { $s = Shot $req.hwnd; $s.id = $req.id; $s.ms = $watch.ElapsedMilliseconds; Emit $s }
338
+
339
+ 'spawned' {
340
+ $rows = @()
341
+ foreach ($p in (Get-CimInstance Win32_Process -Property ProcessId, ParentProcessId, Name, CommandLine, CreationDate)) {
342
+ if ($ours.Contains([int] $p.ParentProcessId) -or $ours.Contains([int] $p.ProcessId)) {
343
+ $rows += @{ name = $p.Name; parent = $p.ParentProcessId; pid = $p.ProcessId; cmd = $p.CommandLine }
344
+ }
345
+ }
346
+ Emit @{ id = $req.id; ok = $true; procs = $rows; ms = $watch.ElapsedMilliseconds }
347
+ }
348
+
349
+ 'net' {
350
+ $rows = @()
351
+ try {
352
+ foreach ($c in (Get-NetTCPConnection -ErrorAction SilentlyContinue)) {
353
+ if (-not $ours.Contains([int] $c.OwningProcess)) { continue }
354
+ $rows += @{ remote = $c.RemoteAddress; port = $c.RemotePort; state = '' + $c.State }
355
+ }
356
+ Emit @{ id = $req.id; ok = $true; conns = $rows; sampled = $true; ms = $watch.ElapsedMilliseconds }
357
+ } catch { Emit @{ id = $req.id; ok = $false; error = 'connections could not be listed on this account' } }
358
+ }
359
+
360
+ 'snap' {
361
+ $out = @{}
362
+ foreach ($d in $req.dirs) { $out[$d] = (SnapDir $d) }
363
+ Emit @{ id = $req.id; ok = $true; dirs = $out; ms = $watch.ElapsedMilliseconds }
364
+ }
365
+
366
+ 'events' {
367
+ $since = [DateTime]::Parse($req.since)
368
+ $rows = @()
369
+ foreach ($log in @('Application', 'System')) {
370
+ try {
371
+ foreach ($e in (Get-WinEvent -FilterHashtable @{ LogName = $log; StartTime = $since } -MaxEvents 200 -ErrorAction SilentlyContinue)) {
372
+ if ($e.LevelDisplayName -eq 'Information') { continue }
373
+ $rows += @{ log = $log; id = $e.Id; level = $e.LevelDisplayName; source = $e.ProviderName; text = ('' + $e.Message).Substring(0, [Math]::Min(400, ('' + $e.Message).Length)) }
374
+ }
375
+ } catch {}
376
+ }
377
+ Emit @{ id = $req.id; ok = $true; events = $rows; ms = $watch.ElapsedMilliseconds }
378
+ }
379
+
380
+ 'alive' {
381
+ $p = Get-Process -Id ([int] $req.pid) -ErrorAction SilentlyContinue
382
+ Emit @{ id = $req.id; ok = $true; running = ($null -ne $p); exit = $(if ($p) { $null } else { 'gone' }); ms = $watch.ElapsedMilliseconds }
383
+ }
384
+
385
+ 'stop' {
386
+ $target = [int] $req.pid
387
+ if (-not $ours.Contains($target)) {
388
+ Emit @{ id = $req.id; ok = $false; error = 'refusing to stop a process this run did not start' }
389
+ break
390
+ }
391
+ $p = Get-Process -Id $target -ErrorAction SilentlyContinue
392
+ $code = $null
393
+ if ($p) {
394
+ [void] $p.CloseMainWindow()
395
+ if (-not $p.WaitForExit(4000)) { Stop-Process -Id $target -Force -ErrorAction SilentlyContinue; $code = 'forced' }
396
+ else { $code = $p.ExitCode }
397
+ }
398
+ [void] $ours.Remove($target)
399
+ Emit @{ id = $req.id; ok = $true; exit = $code; ms = $watch.ElapsedMilliseconds }
400
+ }
401
+
402
+ default { Emit @{ id = $req.id; ok = $false; error = 'nothing here knows how to do ' + $req.op } }
403
+ }
404
+ } catch {
405
+ Emit @{ id = $req.id; ok = $false; error = ('' + $_.Exception.Message) }
406
+ }
407
+ }
408
+ `;
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Turning replies into observations
413
+ // ---------------------------------------------------------------------------
414
+
415
+ /**
416
+ * @typedef {object} TreeNode
417
+ * @property {string} type
418
+ * @property {string} name
419
+ * @property {string} aid
420
+ * @property {string} cls
421
+ * @property {boolean} on
422
+ * @property {boolean} hidden
423
+ * @property {boolean} [focused]
424
+ * @property {number} w
425
+ * @property {number} h
426
+ * @property {string} state
427
+ * @property {string[]} can
428
+ */
429
+
430
+ /**
431
+ * The address one control lives at.
432
+ *
433
+ * Built from what the control IS and what it is CALLED, never from where it sits in the tree.
434
+ * An index would be stable right up until somebody adds a control above it, at which point
435
+ * every control below would report as changed and the real change would be buried in the noise.
436
+ * Where a control has neither a name nor an automation id — and classic Windows dialogs are
437
+ * full of those — the numeric automation id is used, which is what those dialogs actually have.
438
+ *
439
+ * @param {TreeNode} node
440
+ * @param {number} index Only used when a control has no identity of its own.
441
+ * @returns {string}
442
+ */
443
+ export function controlAddress(node, index) {
444
+ const called = node.aid || node.name;
445
+ if (called) return `${node.type.toLowerCase()}:${called}`;
446
+ if (node.cls) return `${node.type.toLowerCase()}:${node.cls}#${index}`;
447
+ return `${node.type.toLowerCase()}#${index}`;
448
+ }
449
+
450
+ /**
451
+ * What one control says it is, in one line, and it is the only thing compared.
452
+ *
453
+ * Deliberately excludes the rectangle. A window that opens two pixels lower is not a
454
+ * difference anybody wants reported, and a control that MOVED without changing what it is or
455
+ * does is a pixel finding, not a meaning one. What is included is what would make somebody say
456
+ * the product behaves differently: what it is, whether it works, whether it is showing, what it
457
+ * is set to, and what it can be asked to do.
458
+ *
459
+ * @param {TreeNode} node
460
+ * @returns {string}
461
+ */
462
+ export function controlMeaning(node) {
463
+ const parts = [node.type];
464
+ if (node.name) parts.push(`called "${node.name}"`);
465
+ parts.push(node.on ? 'enabled' : 'disabled');
466
+ if (node.hidden) parts.push('not showing');
467
+ if (node.state) parts.push(node.state);
468
+ if (node.can && node.can.length > 0) parts.push(`can ${node.can.join(', ')}`);
469
+ return parts.join(', ');
470
+ }
471
+
472
+ /**
473
+ * Turn one window's tree into observations, or into a hole if the read cannot be trusted.
474
+ *
475
+ * The cross-check is the important part and it is why `cached` and `walked` both come back from
476
+ * the probe. They are two different mechanisms counting the same tree. When they agree, the read
477
+ * is good. When the fast one returns nothing and the slow one finds controls, the fast one is
478
+ * lying, and reporting its answer would put a confident zero into the reference — after which
479
+ * every later run would compare zero against zero and agree that nothing had changed.
480
+ *
481
+ * @param {object} spec
482
+ * @param {Journey} spec.journey
483
+ * @param {string} spec.window Plain name of the window, for the path.
484
+ * @param {TreeNode[]} spec.nodes
485
+ * @param {number} spec.cached How many the fast read found.
486
+ * @param {number} spec.walked How many a plain walk found.
487
+ * @param {boolean} [spec.settled] Did two reads in a row agree.
488
+ * @returns {Observation[]}
489
+ */
490
+ export function meaningFromTree(spec) {
491
+ const { journey, window: windowName, nodes, cached, walked } = spec;
492
+ const head = ['screen', windowName];
493
+
494
+ if (cached === 0 && walked > 1) {
495
+ return [notCovered({
496
+ channel: 'meaning',
497
+ path: joinPath(...head, 'controls'),
498
+ reason: 'not supported here',
499
+ says: `The window "${windowName}" would not give up its controls: the fast read found none while a plain `
500
+ + `walk found ${walked}. That happens when a window is minimised or its content has been put away. `
501
+ + 'Nothing is recorded for it, because recording "no controls" would make the next run agree that nothing changed.',
502
+ })];
503
+ }
504
+ if (nodes.length === 0) {
505
+ return [notCovered({
506
+ channel: 'meaning',
507
+ path: joinPath(...head, 'controls'),
508
+ reason: 'not supported here',
509
+ says: `The window "${windowName}" reported no controls at all. Either it draws itself without telling `
510
+ + 'Windows what it is showing, or there was nothing on it yet. Either way it is unchecked, not empty.',
511
+ })];
512
+ }
513
+
514
+ /** @type {Observation[]} */
515
+ const out = [];
516
+ /** @type {Map<string, number>} */
517
+ const usedNames = new Map();
518
+ nodes.forEach((node, index) => {
519
+ let address = controlAddress(node, index);
520
+ // Two controls can honestly share a name — two "Close" buttons in two panels. Number the
521
+ // repeats rather than let the second quietly overwrite the first.
522
+ const seen = usedNames.get(address) ?? 0;
523
+ usedNames.set(address, seen + 1);
524
+ if (seen > 0) address = `${address}~${seen + 1}`;
525
+ out.push(observation({
526
+ channel: 'meaning',
527
+ path: joinPath(...head, address),
528
+ value: controlMeaning(node),
529
+ says: `On "${windowName}", ${controlMeaning(node)}.`,
530
+ journey: journey.name,
531
+ surface: 'windows',
532
+ }));
533
+ });
534
+
535
+ out.push(observation({
536
+ channel: 'counters',
537
+ path: joinPath('count', windowName, 'controls'),
538
+ value: countBucket(nodes.length),
539
+ says: `"${windowName}" is showing ${nodes.length} control${nodes.length === 1 ? '' : 's'}.`,
540
+ journey: journey.name,
541
+ surface: 'windows',
542
+ }));
543
+
544
+ if (spec.settled === false) {
545
+ out.push(notCovered({
546
+ channel: 'meaning',
547
+ path: joinPath(...head, 'settled'),
548
+ reason: 'timed out',
549
+ says: `"${windowName}" never held still: two readings in a row never matched. What was recorded is one `
550
+ + 'snapshot of something still moving, so a difference found in it may be the movement rather than the change.',
551
+ }));
552
+ }
553
+ if (nodes.length >= MAX_TREE_NODES) {
554
+ out.push(notCovered({
555
+ channel: 'meaning',
556
+ path: joinPath(...head, 'all of it'),
557
+ reason: 'too big',
558
+ says: `"${windowName}" has more than ${MAX_TREE_NODES} controls, so only the first ${MAX_TREE_NODES} were `
559
+ + 'recorded. Anything past that is unchecked.',
560
+ }));
561
+ }
562
+ return out;
563
+ }
564
+
565
+ /**
566
+ * Programs the app started, as observations.
567
+ *
568
+ * The command line is kept and compared, because "it now launches the updater with a different
569
+ * flag" is exactly the kind of change no screenshot has ever caught.
570
+ *
571
+ * @param {Journey} journey
572
+ * @param {{name: string, pid: number, parent: number, cmd: string|null}[]} procs
573
+ * @param {number} ownPid
574
+ * @returns {Observation[]}
575
+ */
576
+ export function spawnedObservations(journey, procs, ownPid) {
577
+ const children = procs.filter((p) => p.pid !== ownPid);
578
+ /** @type {Observation[]} */
579
+ const out = children
580
+ .map((p) => ({ name: p.name, cmd: p.cmd ?? '(no command line visible)' }))
581
+ .sort((a, b) => (a.name + a.cmd < b.name + b.cmd ? -1 : 1))
582
+ .map((p, index) => observation({
583
+ channel: 'effects',
584
+ path: joinPath('proc', journey.name, `${p.name}#${index}`),
585
+ value: p.cmd,
586
+ says: `It started ${p.name}. That is a program running because this app ran.`,
587
+ journey: journey.name,
588
+ surface: 'windows',
589
+ }));
590
+ out.push(observation({
591
+ channel: 'counters',
592
+ path: joinPath('count', journey.name, 'programs started'),
593
+ value: countBucket(children.length),
594
+ says: `It started ${children.length} other program${children.length === 1 ? '' : 's'}.`,
595
+ journey: journey.name,
596
+ surface: 'windows',
597
+ }));
598
+ return out;
599
+ }
600
+
601
+ /**
602
+ * What changed on disk in the folders we were told to watch.
603
+ *
604
+ * Sizes rather than contents, because reading every file back over a network connection would
605
+ * cost more than the whole rest of the run. A file that changed size changed; a file that was
606
+ * rewritten with the same length is missed, and that is said out loud rather than hidden.
607
+ *
608
+ * @param {Journey} journey
609
+ * @param {Record<string, Record<string, number>>} before
610
+ * @param {Record<string, Record<string, number>>} after
611
+ * @returns {Observation[]}
612
+ */
613
+ export function fileObservations(journey, before, after) {
614
+ /** @type {Observation[]} */
615
+ const out = [];
616
+ for (const dir of Object.keys(after).sort()) {
617
+ const was = before[dir] ?? {};
618
+ const now = after[dir] ?? {};
619
+ const names = [...new Set([...Object.keys(was), ...Object.keys(now)])].sort();
620
+ let touched = 0;
621
+ for (const name of names) {
622
+ const oldSize = was[name];
623
+ const newSize = now[name];
624
+ if (oldSize === newSize) continue;
625
+ touched++;
626
+ out.push(observation({
627
+ channel: 'effects',
628
+ path: joinPath('file', journey.name, name),
629
+ value: newSize === undefined ? 'deleted' : oldSize === undefined ? `written, ${sizeBucket(newSize)}` : `changed to ${sizeBucket(newSize)}`,
630
+ says: newSize === undefined
631
+ ? `It deleted ${name}.`
632
+ : oldSize === undefined
633
+ ? `It wrote ${name}, ${sizeBucket(newSize)}.`
634
+ : `It changed ${name}; it is now ${sizeBucket(newSize)}.`,
635
+ journey: journey.name,
636
+ surface: 'windows',
637
+ }));
638
+ }
639
+ out.push(observation({
640
+ channel: 'counters',
641
+ path: joinPath('count', journey.name, 'files touched'),
642
+ value: countBucket(touched),
643
+ says: `${touched} file${touched === 1 ? '' : 's'} changed under ${dir}.`,
644
+ journey: journey.name,
645
+ surface: 'windows',
646
+ }));
647
+ }
648
+ out.push(notCovered({
649
+ channel: 'effects',
650
+ path: joinPath('file', journey.name, 'everywhere else'),
651
+ reason: 'missing tool',
652
+ says: 'Only the folders this check was told to watch were compared. Watching everything a program writes '
653
+ + 'needs administrator rights on Windows, which this account does not have, so a file written anywhere '
654
+ + 'else was not seen. That is a hole, not a clean result.',
655
+ }));
656
+ return out;
657
+ }
658
+
659
+ /**
660
+ * Crashes, hangs and anything the program complained about into the Windows event log.
661
+ *
662
+ * The event ids are the ones Windows itself uses and they are worth knowing: 1000 is an
663
+ * application crash, 1002 is an application hang, 1001 is the report Windows filed about it.
664
+ * Everything else is kept too, at warning level and above, because an application that has
665
+ * started logging a new warning every run has changed even if nothing on screen has.
666
+ *
667
+ * @param {Journey} journey
668
+ * @param {{log: string, id: number, level: string, source: string, text: string}[]} events
669
+ * @param {string} appHint Something to recognise this app by in a log line.
670
+ * @returns {Observation[]}
671
+ */
672
+ export function complaintObservations(journey, events, appHint) {
673
+ const hint = appHint.toLowerCase();
674
+ const mine = events.filter((e) => `${e.source} ${e.text}`.toLowerCase().includes(hint));
675
+ const crashes = mine.filter((e) => [1000, 1001, 1002].includes(e.id));
676
+ /** @type {Observation[]} */
677
+ const out = crashes.map((e, index) => observation({
678
+ channel: 'complaints',
679
+ path: joinPath('log', journey.name, 'crash', String(index)),
680
+ value: `${e.id === 1002 ? 'stopped responding' : 'crashed'}: ${trimForStorage(e.text, 400).text}`,
681
+ says: e.id === 1002
682
+ ? 'Windows recorded that this app stopped responding.'
683
+ : 'Windows recorded that this app crashed.',
684
+ journey: journey.name,
685
+ surface: 'windows',
686
+ }));
687
+ out.push(observation({
688
+ channel: 'complaints',
689
+ path: joinPath('log', journey.name, 'complaints'),
690
+ value: countBucket(mine.length),
691
+ says: mine.length === 0
692
+ ? 'Windows logged nothing about this app while it ran.'
693
+ : `Windows logged ${mine.length} thing${mine.length === 1 ? '' : 's'} about this app while it ran.`,
694
+ journey: journey.name,
695
+ surface: 'windows',
696
+ }));
697
+ return out;
698
+ }
699
+
700
+ /**
701
+ * Connections the app had open, plus the honest note about what sampling misses.
702
+ *
703
+ * @param {Journey} journey
704
+ * @param {{remote: string, port: number, state: string}[]} conns
705
+ * @returns {Observation[]}
706
+ */
707
+ export function networkObservations(journey, conns) {
708
+ const reachable = conns
709
+ .filter((c) => c.remote !== '0.0.0.0' && c.remote !== '::' && c.remote !== '127.0.0.1')
710
+ .map((c) => `${c.remote}:${c.port}`)
711
+ .sort();
712
+ const unique = [...new Set(reachable)];
713
+ /** @type {Observation[]} */
714
+ const out = unique.map((where, index) => observation({
715
+ channel: 'effects',
716
+ path: joinPath('net', journey.name, String(index)),
717
+ value: where,
718
+ says: `While it was running it had a connection open to ${where}.`,
719
+ journey: journey.name,
720
+ surface: 'windows',
721
+ }));
722
+ out.push(notCovered({
723
+ channel: 'effects',
724
+ path: joinPath('net', journey.name, 'everything it asked for'),
725
+ reason: 'missing tool',
726
+ says: 'Connections were sampled while the app ran, not captured. A request that opened and finished '
727
+ + 'between two samples was not seen, and nothing here could have stopped one. Capturing every call '
728
+ + 'needs administrator rights on Windows, which this account does not have.',
729
+ }));
730
+ return out;
731
+ }
732
+
733
+ // ---------------------------------------------------------------------------
734
+ // Finding the app
735
+ // ---------------------------------------------------------------------------
736
+
737
+ /**
738
+ * Where the Windows build is, and whether it is already on the far machine.
739
+ *
740
+ * Two honest modes, and which one a project is in changes what a run costs by minutes:
741
+ *
742
+ * `there` The config names a path that already exists on the Windows machine. Free.
743
+ * `push` The build is here and has to be copied over. Real, and reported with its size,
744
+ * because a person who does not know a run copies 200 megabytes over ssh every time
745
+ * will reasonably conclude the tool is broken when it takes four minutes.
746
+ *
747
+ * @param {AdapterProject} project
748
+ * @returns {{mode: 'there'|'push'|'none', exe: string|null, local: string|null, why: string}}
749
+ */
750
+ export function findWindowsBuild(project) {
751
+ const config = project.config ?? {};
752
+ if (typeof config.remoteExe === 'string' && config.remoteExe.trim() !== '') {
753
+ return {
754
+ mode: 'there',
755
+ exe: config.remoteExe,
756
+ local: null,
757
+ why: `The Windows build is already on that machine at ${config.remoteExe}, so nothing is copied.`,
758
+ };
759
+ }
760
+ if (typeof config.exe === 'string' && config.exe.trim() !== '') {
761
+ const local = path.isAbsolute(config.exe) ? config.exe : path.join(project.root, config.exe);
762
+ return {
763
+ mode: 'push',
764
+ exe: null,
765
+ local,
766
+ why: `The Windows build is here at ${local} and has to be copied to the Windows machine before each run.`,
767
+ };
768
+ }
769
+ return {
770
+ mode: 'none',
771
+ exe: null,
772
+ local: null,
773
+ why: 'No Windows build was named, so there is nothing to open.',
774
+ };
775
+ }
776
+
777
+ /**
778
+ * Is this window really a native one, or a Chromium shell wearing a Windows title bar.
779
+ * @param {string} className
780
+ * @returns {boolean}
781
+ */
782
+ export function isChromiumWindow(className) {
783
+ return CHROMIUM_CLASSES.includes(className);
784
+ }
785
+
786
+ /**
787
+ * Make a list out of whatever PowerShell sent.
788
+ *
789
+ * Not defensive programming — a real and famous behaviour of the thing on the other end.
790
+ * `ConvertTo-Json` turns a list of three into a JSON array, a list of one into a BARE OBJECT,
791
+ * and a list of none into `null`. So a window tree with a single control arrives shaped like a
792
+ * single control, and any code that maps over it dies. PowerShell 5.1 has no `-AsArray` to fix
793
+ * this at the source, so it is fixed here, once, and every reply is read through it.
794
+ *
795
+ * @template T
796
+ * @param {unknown} value
797
+ * @returns {T[]}
798
+ */
799
+ export function asList(value) {
800
+ if (value === null || value === undefined) return [];
801
+ return Array.isArray(value) ? /** @type {T[]} */ (value) : [/** @type {T} */ (value)];
802
+ }
803
+
804
+ /**
805
+ * Copy a build over to the far machine, and say how long it took and how big it was.
806
+ *
807
+ * Streamed through tar rather than scp so it is one connection and one pass, and so a folder
808
+ * of thousands of small files does not become thousands of round trips at a second each.
809
+ *
810
+ * @param {string} host
811
+ * @param {string} localDir
812
+ * @param {string} remoteDir
813
+ * @returns {Promise<{ok: boolean, ms: number, why: string}>}
814
+ */
815
+ export async function pushBuild(host, localDir, remoteDir) {
816
+ const started = Date.now();
817
+ return await new Promise((resolve) => {
818
+ const tar = spawn('tar', ['-cf', '-', '-C', path.dirname(localDir), path.basename(localDir)]);
819
+ const ssh = spawn('ssh', ['-o', 'BatchMode=yes', host, `mkdir -p '${remoteDir}' && tar -xf - -C '${remoteDir}'`]);
820
+ let trouble = '';
821
+ tar.stdout.pipe(ssh.stdin);
822
+ ssh.stderr.on('data', (d) => { trouble += String(d); });
823
+ tar.stderr.on('data', (d) => { trouble += String(d); });
824
+ ssh.on('close', (code) => {
825
+ const ms = Date.now() - started;
826
+ resolve(code === 0
827
+ ? { ok: true, ms, why: `Copied to ${host} in ${timeBucket(ms)}.` }
828
+ : { ok: false, ms, why: `Copying to ${host} failed: ${trouble.trim().slice(0, 300) || `tar exited ${code}`}` });
829
+ });
830
+ ssh.on('error', (e) => resolve({ ok: false, ms: Date.now() - started, why: `Could not start the copy: ${e.message}` }));
831
+ });
832
+ }
833
+
834
+ // ---------------------------------------------------------------------------
835
+ // The adapter
836
+ // ---------------------------------------------------------------------------
837
+
838
+ /** The one connection this adapter holds while a run is going on. */
839
+ let link = /** @type {import('../remote.js').RemoteRunner|null} */ (null);
840
+
841
+ /** Everything this run started over there, so teardown can put it back and nothing else. */
842
+ /** @type {number[]} */
843
+ let startedHere = [];
844
+
845
+ /**
846
+ * Open the Windows machine, once, and keep it.
847
+ * @param {string} host
848
+ * @param {(m: string) => void} [log]
849
+ */
850
+ async function connect(host, log) {
851
+ if (link && link.alive) return link;
852
+ link = remoteRunner({ host, kind: 'windows', surface: 'windows', agent: windowsProbeScript(), log });
853
+ await link.open();
854
+ return link;
855
+ }
856
+
857
+ export const windowsAdapter = defineAdapter({
858
+ name: 'windows',
859
+ title: 'native Windows apps',
860
+ describe:
861
+ 'Opens a native Windows program on a real Windows desktop reached over ssh, reads what every control on '
862
+ + 'screen says it is and does through UI Automation, and watches what it starts, writes and complains '
863
+ + 'about. It cannot run two builds at once — Windows has one desktop — and it declines Electron apps, '
864
+ + 'which are covered better and in pairs over their debug port.',
865
+ channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
866
+
867
+ /**
868
+ * @param {AdapterProject} project
869
+ * @returns {Promise<Detection>}
870
+ */
871
+ async detect(project) {
872
+ const config = project.config ?? {};
873
+ const host = typeof config.host === 'string' ? config.host : null;
874
+ const build = findWindowsBuild(project);
875
+ /** @type {Missing[]} */
876
+ const missing = [];
877
+
878
+ if (!host) {
879
+ missing.push({
880
+ what: 'the name of a machine with a Windows desktop on it',
881
+ unlocks: 'checking a native Windows app at all — a Windows window can only be read from Windows',
882
+ howToGet: 'Put {"host": "the-ssh-host-name"} under "windows" in the config. Any ssh host that gets you a '
883
+ + 'shell on a Windows machine works, including a WSL shell on one — the tool finds powershell.exe from '
884
+ + 'there by itself. Nothing needs installing on that machine.',
885
+ blocking: true,
886
+ });
887
+ }
888
+ if (build.mode === 'none') {
889
+ missing.push({
890
+ what: 'the built Windows program',
891
+ unlocks: 'opening the app and reading what is on its screen',
892
+ howToGet: 'Either put {"remoteExe": "C:\\\\path\\\\to\\\\YourApp.exe"} under "windows" in the config if the '
893
+ + 'build already lives on that machine — much faster — or {"exe": "dist/win/YourApp.exe"} to have it '
894
+ + 'copied over before each run.',
895
+ blocking: true,
896
+ });
897
+ }
898
+ if (!Array.isArray(config.watchDirs) || config.watchDirs.length === 0) {
899
+ missing.push({
900
+ what: 'the folders this app writes into',
901
+ unlocks: 'seeing what it saved, which is otherwise invisible — Windows will not let this account watch '
902
+ + 'the whole disk without administrator rights',
903
+ howToGet: 'Put {"watchDirs": ["C:\\\\Users\\\\you\\\\AppData\\\\Roaming\\\\YourApp"]} under "windows" in the config.',
904
+ });
905
+ }
906
+
907
+ let electronish = false;
908
+ try {
909
+ const pkg = JSON.parse(await fsp.readFile(path.join(project.root, 'package.json'), 'utf8'));
910
+ electronish = Boolean(pkg.dependencies?.electron || pkg.devDependencies?.electron || pkg.build?.appId);
911
+ } catch { /* a built app somebody pointed at need not have a package.json */ }
912
+
913
+ if (electronish) {
914
+ return {
915
+ applies: false,
916
+ confidence: 0,
917
+ why: 'This is an Electron app, so the Electron adapter covers its Windows build properly — over the debug '
918
+ + 'port, from any machine, with two builds able to run side by side. This adapter would be strictly worse: '
919
+ + 'one build at a time, on one shared desktop, and reading the window would switch on Chromium\'s '
920
+ + 'accessibility engine and change the timing of the thing being measured.',
921
+ missing: [],
922
+ notes: ['Nothing is missing. There is simply a better tool for this app already in the box.'],
923
+ };
924
+ }
925
+
926
+ const applies = Boolean(host) && build.mode !== 'none';
927
+ return {
928
+ applies,
929
+ confidence: applies ? 0.9 : 0,
930
+ why: applies
931
+ ? `${build.why} It will be opened on the desktop behind "${host}", read, and closed again — one build at a `
932
+ + 'time, because Windows shows one desktop and two cannot be up at once.'
933
+ : 'A native Windows app needs a Windows machine and a built program, and one of those is not named yet.',
934
+ missing,
935
+ notes: [
936
+ 'Nothing is installed on the Windows machine. The program that reads the screen is sent down the ssh '
937
+ + 'connection each run and disappears when it closes.',
938
+ 'Two builds can never run at the same time here. That is a property of Windows, not of this tool, and it '
939
+ + 'makes the same-machine comparison weaker on this platform than on any other.',
940
+ 'The desktop is shared with whoever uses that machine. A notification or an update prompt appearing '
941
+ + 'mid-run is a real difference this cannot tell from a real one, beyond what running twice subtracts.',
942
+ 'Nothing irreversible can be stopped here. There is no way to refuse a compiled program\'s network call '
943
+ + 'without administrator rights, so a journey marked irreversible is refused outright instead of walked.',
944
+ ],
945
+ };
946
+ },
947
+
948
+ /**
949
+ * @param {AdapterProject} project
950
+ * @returns {Promise<Journey[]>}
951
+ */
952
+ async journeys(project) {
953
+ const config = project.config ?? {};
954
+ const build = findWindowsBuild(project);
955
+ if (build.mode === 'none') return [];
956
+
957
+ /** @type {Journey[]} */
958
+ const journeys = [{
959
+ name: 'open-the-app',
960
+ describe: 'open the Windows app and read every control it puts on screen',
961
+ source: 'code',
962
+ surface: 'windows',
963
+ from: 'the built program named in the config',
964
+ channels: ['meaning', 'effects', 'complaints', 'counters', 'pixels'],
965
+ steps: [{ act: 'launch' }, { act: 'settle' }, { act: 'read' }],
966
+ timeoutMs: 120_000,
967
+ }];
968
+
969
+ // Anything else has to be described by somebody who knows the app. Read out of the config
970
+ // rather than invented here: an adapter that guesses which buttons to press on an unknown
971
+ // native program is an adapter that will one day press "Delete account".
972
+ for (const extra of Array.isArray(config.journeys) ? config.journeys : []) {
973
+ if (!extra || typeof extra.name !== 'string') continue;
974
+ journeys.push({
975
+ name: extra.name,
976
+ describe: typeof extra.describe === 'string' ? extra.describe : `walk "${extra.name}"`,
977
+ source: 'recorded',
978
+ surface: 'windows',
979
+ from: 'the project config',
980
+ channels: ['meaning', 'effects', 'complaints', 'counters', 'pixels'],
981
+ steps: Array.isArray(extra.steps) ? extra.steps : [],
982
+ irreversible: Boolean(extra.irreversible),
983
+ timeoutMs: 120_000,
984
+ });
985
+ }
986
+ return journeys;
987
+ },
988
+
989
+ /**
990
+ * @param {Build} build
991
+ * @param {RunContext} ctx
992
+ * @returns {Promise<PreparedBuild>}
993
+ */
994
+ async prepare(build, ctx) {
995
+ const config = ctx.config ?? {};
996
+ const host = typeof config.host === 'string' ? config.host : null;
997
+ if (!host) {
998
+ return { build, root: build.root, ready: false, why: 'No Windows machine is named in the config, so there is nowhere to open this.', dispose: async () => {} };
999
+ }
1000
+ const where = findWindowsBuild({ root: build.root, config });
1001
+ if (where.mode === 'none') {
1002
+ return { build, root: build.root, ready: false, why: where.why, dispose: async () => {} };
1003
+ }
1004
+
1005
+ let runner;
1006
+ try {
1007
+ runner = await connect(host, ctx.log);
1008
+ } catch (error) {
1009
+ return {
1010
+ build,
1011
+ root: build.root,
1012
+ ready: false,
1013
+ why: error instanceof RemoteLinkLost
1014
+ ? `${error.message}. Nothing was checked on Windows.`
1015
+ : `Could not reach ${host}: ${String(error)}`,
1016
+ dispose: async () => {},
1017
+ };
1018
+ }
1019
+
1020
+ const facts = runner.facts;
1021
+ if (facts.loggedIn === false) {
1022
+ return {
1023
+ build,
1024
+ root: build.root,
1025
+ ready: false,
1026
+ why: `Nobody is signed in on ${host}, and there is nothing to read on a desktop nobody has signed into. `
1027
+ + 'Sign in there once and leave the session running; locking the screen afterwards is fine.',
1028
+ dispose: async () => {},
1029
+ };
1030
+ }
1031
+
1032
+ let exe = where.exe;
1033
+ /** @type {string[]} */
1034
+ const notes = [];
1035
+ if (where.mode === 'push' && where.local) {
1036
+ const remoteDir = `/tmp/staysfixed-${build.id}`;
1037
+ const pushed = await pushBuild(host, path.dirname(where.local), remoteDir);
1038
+ if (!pushed.ok) {
1039
+ return { build, root: build.root, ready: false, why: pushed.why, dispose: async () => {} };
1040
+ }
1041
+ notes.push(pushed.why);
1042
+ // The copy lands on the Linux side; Windows reaches it through the UNC path WSL publishes.
1043
+ const distro = String(facts.host ?? 'Ubuntu');
1044
+ exe = `\\\\wsl.localhost\\${distro}${remoteDir.replace(/\//g, '\\')}\\${path.basename(path.dirname(where.local))}\\${path.basename(where.local)}`;
1045
+ notes.push('Starting a program from a copied folder is slower than one already on that machine. Naming '
1046
+ + '"remoteExe" instead, once, removes this from every run.');
1047
+ }
1048
+
1049
+ return {
1050
+ build,
1051
+ root: build.root,
1052
+ ready: Boolean(exe),
1053
+ why: exe
1054
+ ? `${where.why} ${notes.join(' ')}`.trim()
1055
+ : 'The Windows program could not be placed on that machine.',
1056
+ facts: {
1057
+ exe: exe ?? undefined,
1058
+ host,
1059
+ locked: facts.locked,
1060
+ desktop: typeof facts.screen === 'string' ? facts.screen : undefined,
1061
+ },
1062
+ dispose: async () => { /* nothing was installed, so there is nothing to undo */ },
1063
+ };
1064
+ },
1065
+
1066
+ /**
1067
+ * @param {Journey} journey
1068
+ * @param {PreparedBuild} prepared
1069
+ * @param {RunContext} ctx
1070
+ * @returns {Promise<Observation[]>}
1071
+ */
1072
+ async run(journey, prepared, ctx) {
1073
+ const config = ctx.config ?? {};
1074
+ const exe = String(prepared.facts?.exe ?? '');
1075
+ const host = String(prepared.facts?.host ?? '');
1076
+
1077
+ if (!prepared.ready || !exe) {
1078
+ return [notCovered({
1079
+ channel: 'meaning',
1080
+ path: joinPath('screen', journey.name, 'anything at all'),
1081
+ reason: 'missing tool',
1082
+ says: `"${journey.describe}" was not walked: ${prepared.why}`,
1083
+ })];
1084
+ }
1085
+
1086
+ // Refused outright rather than walked carefully. There is no wire boundary on Windows
1087
+ // without administrator rights, so "watch it ask and stop it" is not available, and a
1088
+ // careful walk of an irreversible journey is a walk that really does the irreversible thing.
1089
+ if (journey.irreversible) {
1090
+ return [notCovered({
1091
+ channel: 'effects',
1092
+ path: joinPath('screen', journey.name, 'refused'),
1093
+ reason: 'irreversible',
1094
+ says: `"${journey.describe}" would spend money, send a message or destroy data, and on Windows there is no `
1095
+ + 'way to let it ask and then stop it — that needs administrator rights this account does not have. '
1096
+ + 'It was not run at all. This is a hole in what was checked, not a pass.',
1097
+ })];
1098
+ }
1099
+
1100
+ let runner;
1101
+ try {
1102
+ runner = await connect(host, ctx.log);
1103
+ } catch (error) {
1104
+ return [notCovered({
1105
+ channel: 'meaning',
1106
+ path: joinPath('screen', journey.name, 'anything at all'),
1107
+ reason: 'timed out',
1108
+ says: `"${journey.describe}" was not walked: ${error instanceof Error ? error.message : String(error)}.`,
1109
+ })];
1110
+ }
1111
+
1112
+ /** @type {Observation[]} */
1113
+ const seen = [];
1114
+ /** @type {string[]} */
1115
+ const watchDirs = Array.isArray(config.watchDirs) ? config.watchDirs.map(String) : [];
1116
+ let pid = 0;
1117
+ const startedAt = new Date().toISOString();
1118
+
1119
+ try {
1120
+ const before = watchDirs.length > 0 ? await runner.call('snap', { dirs: watchDirs }) : { dirs: {} };
1121
+
1122
+ const launched = await runner.call('launch', {
1123
+ exe,
1124
+ args: Array.isArray(config.args) ? config.args : undefined,
1125
+ cwd: typeof config.cwd === 'string' ? config.cwd : undefined,
1126
+ }, { timeoutMs: 60_000 });
1127
+ if (!launched.ok) {
1128
+ return [notCovered({
1129
+ channel: 'meaning',
1130
+ path: joinPath('screen', journey.name, 'anything at all'),
1131
+ reason: 'crashed',
1132
+ says: `The app would not start on ${host}: ${launched.error}.`,
1133
+ })];
1134
+ }
1135
+ pid = Number(launched.pid);
1136
+ startedHere.push(pid);
1137
+
1138
+ // Wait for a window rather than sleeping a fixed time. A machine under load takes longer,
1139
+ // and a fixed sleep would turn that into a difference in the report.
1140
+ /** @type {{name: string, cls: string, hwnd: number, pid: number, visible: boolean}[]} */
1141
+ let windows = [];
1142
+ const deadline = Date.now() + WINDOW_WAIT_MS;
1143
+ while (Date.now() < deadline) {
1144
+ const reply = await runner.call('windows', { pid });
1145
+ windows = asList(reply.windows).filter((/** @type {any} */ w) => Boolean(w.visible));
1146
+ if (windows.length > 0) break;
1147
+ await new Promise((r) => setTimeout(r, 400));
1148
+ }
1149
+
1150
+ if (windows.length === 0) {
1151
+ seen.push(notCovered({
1152
+ channel: 'meaning',
1153
+ path: joinPath('screen', journey.name, 'a window'),
1154
+ reason: 'timed out',
1155
+ says: `The app started on ${host} but put no window on screen within ${timeBucket(WINDOW_WAIT_MS)}. `
1156
+ + 'Nothing about its screen was checked. It may be a background program, or it may have failed silently.',
1157
+ }));
1158
+ }
1159
+
1160
+ const chromium = windows.filter((w) => isChromiumWindow(w.cls));
1161
+ if (chromium.length > 0 && chromium.length === windows.length) {
1162
+ seen.push(notCovered({
1163
+ channel: 'meaning',
1164
+ path: joinPath('screen', journey.name, 'controls'),
1165
+ reason: 'not supported here',
1166
+ says: 'Every window this app opened is a Chromium one, so it is an Electron app after all. It is not read '
1167
+ + 'here: the Electron adapter covers it properly over its debug port, from any machine, with two builds '
1168
+ + 'able to run at once. Reading it here would also switch on Chromium\'s accessibility engine and change '
1169
+ + 'the timing of what is being measured.',
1170
+ }));
1171
+ } else {
1172
+ for (const window of windows.filter((w) => !isChromiumWindow(w.cls))) {
1173
+ const label = window.name || window.cls || 'a window with no title';
1174
+ const tree = await runner.call('settle', { hwnd: window.hwnd, limit: MAX_TREE_NODES }, { timeoutMs: 90_000 });
1175
+ if (!tree.ok) {
1176
+ seen.push(notCovered({
1177
+ channel: 'meaning',
1178
+ path: joinPath('screen', label, 'controls'),
1179
+ reason: 'crashed',
1180
+ says: `"${label}" could not be read: ${tree.error}.`,
1181
+ }));
1182
+ continue;
1183
+ }
1184
+ seen.push(...meaningFromTree({
1185
+ journey,
1186
+ window: label,
1187
+ nodes: asList(tree.nodes),
1188
+ cached: Number(tree.cached ?? 0),
1189
+ walked: Number(tree.walked ?? 0),
1190
+ settled: Boolean(tree.agreed),
1191
+ }));
1192
+ seen.push(observation({
1193
+ channel: 'results',
1194
+ path: joinPath('screen', label, 'title'),
1195
+ value: window.name,
1196
+ says: `A window is open called "${window.name}".`,
1197
+ journey: journey.name,
1198
+ surface: 'windows',
1199
+ }));
1200
+
1201
+ // Pixels last, and only as evidence. A picture is written to the evidence folder and
1202
+ // pointed at; it is never the thing compared.
1203
+ const shot = await runner.call('shot', { hwnd: window.hwnd }, { timeoutMs: 45_000 });
1204
+ if (shot.ok && shot.png && Number(shot.bytes) <= MAX_SHOT_BYTES) {
1205
+ const file = path.join(ctx.evidenceDir, `windows-${journey.name}-${label.replace(/[^a-z0-9]+/gi, '-')}.png`);
1206
+ await fsp.writeFile(file, Buffer.from(String(shot.png), 'base64'));
1207
+ seen.push(observation({
1208
+ channel: 'pixels',
1209
+ path: joinPath('screen', label, 'looks like'),
1210
+ value: `${shot.w} by ${shot.h}`,
1211
+ says: `A picture of "${label}" was kept as evidence. It is not compared — it is there to show a person `
1212
+ + 'something another channel already found.',
1213
+ evidence: file,
1214
+ journey: journey.name,
1215
+ surface: 'windows',
1216
+ }));
1217
+ if (Number(shot.lit) === 0) {
1218
+ seen.push(notCovered({
1219
+ channel: 'pixels',
1220
+ path: joinPath('screen', label, 'picture is usable'),
1221
+ reason: 'not supported here',
1222
+ says: 'The picture came back completely black. On a locked Windows desktop that is expected for '
1223
+ + 'anything drawn by the graphics card. Every other channel still works; only the picture is lost.',
1224
+ }));
1225
+ }
1226
+ }
1227
+ }
1228
+ }
1229
+
1230
+ const spawned = await runner.call('spawned', {});
1231
+ seen.push(...spawnedObservations(journey, asList(spawned.procs), pid));
1232
+
1233
+ const net = await runner.call('net', {});
1234
+ if (net.ok) seen.push(...networkObservations(journey, asList(net.conns)));
1235
+
1236
+ if (watchDirs.length > 0) {
1237
+ const after = await runner.call('snap', { dirs: watchDirs }, { timeoutMs: 90_000 });
1238
+ seen.push(...fileObservations(journey, before.dirs ?? {}, after.dirs ?? {}));
1239
+ } else {
1240
+ seen.push(notCovered({
1241
+ channel: 'effects',
1242
+ path: joinPath('file', journey.name, 'anything written'),
1243
+ reason: 'needs a sample',
1244
+ says: 'Nothing was watched on disk, because no folders were named. Add "watchDirs" under "windows" in the '
1245
+ + 'config and what this app saves becomes visible.',
1246
+ }));
1247
+ }
1248
+
1249
+ const alive = await runner.call('alive', { pid });
1250
+ seen.push(observation({
1251
+ channel: 'complaints',
1252
+ path: joinPath('proc', journey.name, 'still running'),
1253
+ value: Boolean(alive.running),
1254
+ says: alive.running
1255
+ ? 'The app was still running when the check finished, which is what a window app should do.'
1256
+ : 'The app had already exited by the time the check finished. For a window app that usually means it fell over.',
1257
+ journey: journey.name,
1258
+ surface: 'windows',
1259
+ }));
1260
+
1261
+ const events = await runner.call('events', { since: startedAt }, { timeoutMs: 60_000 });
1262
+ seen.push(...complaintObservations(journey, asList(events.events), path.basename(exe).replace(/\.exe$/i, '')));
1263
+
1264
+ return seen;
1265
+ } catch (error) {
1266
+ // The machine went away part way through. Keep everything really seen, and say plainly
1267
+ // that the rest is unchecked. Never let a short run look like a clean one.
1268
+ return [...seen, notCovered({
1269
+ channel: 'meaning',
1270
+ path: joinPath('screen', journey.name, 'the rest of it'),
1271
+ reason: 'timed out',
1272
+ says: `"${journey.describe}" stopped part way through on ${host}: `
1273
+ + `${error instanceof Error ? error.message : String(error)}. Everything after that point is unchecked, `
1274
+ + 'not unchanged.',
1275
+ })];
1276
+ } finally {
1277
+ if (pid && link && link.alive) {
1278
+ try { await link.call('stop', { pid }, { timeoutMs: 15_000 }); } catch { /* the link is gone; teardown says so */ }
1279
+ startedHere = startedHere.filter((p) => p !== pid);
1280
+ }
1281
+ }
1282
+ },
1283
+
1284
+ /**
1285
+ * Put the machine back the way it was found.
1286
+ *
1287
+ * Only ever stops what this run started — the probe itself refuses any other pid, and that
1288
+ * refusal is the last line of defence for somebody's real work sitting on that desktop.
1289
+ */
1290
+ async teardown() {
1291
+ if (link) {
1292
+ for (const pid of startedHere.slice()) {
1293
+ try { await link.call('stop', { pid }, { timeoutMs: 10_000 }); } catch { /* going away anyway */ }
1294
+ }
1295
+ startedHere = [];
1296
+ try { await link.close(); } catch { /* already closed */ }
1297
+ link = null;
1298
+ }
1299
+ },
1300
+ });
1301
+
1302
+ /**
1303
+ * One paragraph about what this adapter can do on a given machine, for `doctor` and for an
1304
+ * agent reading the tool's own description of itself.
1305
+ *
1306
+ * @param {import('../remote.js').RemoteDescription} remote
1307
+ * @returns {string}
1308
+ */
1309
+ export function describeWindows(remote) {
1310
+ if (!remote.reachable) {
1311
+ return `No Windows desktop answers through "${remote.host}", so a native Windows app cannot be checked from `
1312
+ + 'here. If the Windows product is Electron — most desktop products are — it is already covered over its '
1313
+ + 'debug port and nothing is missing.';
1314
+ }
1315
+ if (!remote.windows) {
1316
+ return `"${remote.host}" answers, but there is no Windows behind it. A native Windows window can only be read `
1317
+ + 'from Windows itself.';
1318
+ }
1319
+ if (remote.desktopLoggedIn === false) {
1320
+ return `"${remote.host}" is a Windows machine, but nobody is signed in on it. There is nothing on a desktop `
1321
+ + 'nobody has signed into, so signing in once and leaving the session running is what turns this on.';
1322
+ }
1323
+ const locked = remote.desktopLocked
1324
+ ? ' The screen is locked, which is fine — controls read correctly, only full-screen pictures come back black.'
1325
+ : '';
1326
+ return `${remote.windowsVersion ?? 'Windows'} is reachable through "${remote.host}" and its desktop is signed in, `
1327
+ + `so a native Windows app can be opened there and read.${locked} One build at a time, always: Windows shows `
1328
+ + 'one desktop and two cannot be up at once.';
1329
+ }