1010namespace Microsoft . PowerToys . UITest . Next ;
1111
1212/// <summary>
13- /// A test session bound to a specific HWND. All <see cref="Find{T}"/>/<see cref="FindAll{T}"/>
14- /// calls route to <c>winapp ui search</c> with <c>-w <hex hwnd></c> for stable targeting.
13+ /// A test session bound to either a specific window (HWND) or a whole process (name or PID).
14+ /// All <see cref="Find{T}"/>/<see cref="FindAll{T}"/> calls route to <c>winapp ui search</c>
15+ /// scoped by <see cref="TargetFlag"/>/<see cref="TargetValue"/>.
1516/// </summary>
17+ /// <remarks>
18+ /// Two scopes are supported:
19+ /// <list type="bullet">
20+ /// <item><description><c>Window</c> (<c>-w <hwnd></c>) — the default. Use when the
21+ /// process owns multiple windows and the test needs to pin one (e.g. ColorPickerUI's
22+ /// overlay vs editor; Settings vs PopupHost).</description></item>
23+ /// <item><description><c>Process</c> (<c>-a <name|pid></c>) — simpler when the target
24+ /// process owns exactly one user-facing window. Built via <see cref="FromProcess"/>. Matches
25+ /// the pattern in <see href="https://github.com/microsoft/PowerToys/pull/48414"/>.</description></item>
26+ /// </list>
27+ /// </remarks>
1628public sealed class Session
1729{
18- /// <summary>Decimal HWND of the target window (as returned by <c>list-windows --json</c>).</summary>
30+ public enum TargetScope
31+ {
32+ /// <summary>Scope all CLI calls to a specific HWND via <c>-w</c>.</summary>
33+ Window ,
34+
35+ /// <summary>Scope all CLI calls to a process (name substring or PID) via <c>-a</c>.</summary>
36+ Process ,
37+ }
38+
39+ /// <summary>Decimal HWND of the target window, or 0 when bound by <see cref="TargetScope.Process"/>.</summary>
1940 public long WindowHandle { get ; }
2041
2142 /// <summary>String form of <see cref="WindowHandle"/> for passing to winappcli's <c>-w</c> flag.</summary>
2243 public string WindowHandleArg { get ; }
2344
45+ /// <summary>The scope these calls run against (window or process).</summary>
46+ public TargetScope Scope { get ; }
47+
48+ /// <summary>winappcli flag for the active scope (<c>-w</c> or <c>-a</c>).</summary>
49+ public string TargetFlag { get ; }
50+
51+ /// <summary>Value to pass after <see cref="TargetFlag"/> — the decimal HWND or the process name/PID.</summary>
52+ public string TargetValue { get ; }
53+
2454 public string WindowTitle { get ; }
2555
2656 public int ProcessId { get ; }
@@ -34,11 +64,61 @@ internal Session(PowerToysModule scope, long hwnd, string title, int pid, string
3464 InitScope = scope ;
3565 WindowHandle = hwnd ;
3666 WindowHandleArg = hwnd . ToString ( CultureInfo . InvariantCulture ) ;
67+ Scope = TargetScope . Window ;
68+ TargetFlag = "-w" ;
69+ TargetValue = WindowHandleArg ;
70+ WindowTitle = title ;
71+ ProcessId = pid ;
72+ ProcessName = processName ;
73+ }
74+
75+ private Session ( PowerToysModule scope , string appNameOrPid , int pid , string processName , string title )
76+ {
77+ InitScope = scope ;
78+ WindowHandle = 0 ;
79+ WindowHandleArg = "0" ;
80+ Scope = TargetScope . Process ;
81+ TargetFlag = "-a" ;
82+ TargetValue = appNameOrPid ;
3783 WindowTitle = title ;
3884 ProcessId = pid ;
3985 ProcessName = processName ;
4086 }
4187
88+ /// <summary>
89+ /// Build a session scoped to a whole process via <c>winapp ... -a <app></c>. Cheaper than
90+ /// resolving a HWND and ideal for the single-window-per-process case (e.g. Settings smoke
91+ /// tests). The first matching window's PID/name/title are captured for reporting only — all
92+ /// subsequent CLI calls re-resolve via <c>-a</c>, so window-replacement during the test
93+ /// (re-navigation, page swap) is handled transparently.
94+ /// </summary>
95+ /// <param name="appNameOrPid">Process name substring (e.g. <c>"PowerToys.Settings"</c>) or PID as a string.</param>
96+ /// <param name="attributeAs">Module label used for diagnostics only.</param>
97+ /// <param name="timeoutMS">How long to wait for the process to expose at least one UIA window.</param>
98+ public static Session FromProcess (
99+ string appNameOrPid ,
100+ PowerToysModule attributeAs = PowerToysModule . Runner ,
101+ int timeoutMS = 10_000 )
102+ {
103+ var deadline = DateTime . UtcNow + TimeSpan . FromMilliseconds ( timeoutMS ) ;
104+ while ( DateTime . UtcNow < deadline )
105+ {
106+ var windows = WindowsFinder . ListByApp ( appNameOrPid ) ;
107+ if ( windows . Count > 0 )
108+ {
109+ var w = windows [ 0 ] ;
110+ return new Session ( attributeAs , appNameOrPid , w . ProcessId , w . ProcessName , w . Title ) ;
111+ }
112+
113+ Thread . Sleep ( 250 ) ;
114+ }
115+
116+ Assert . Fail (
117+ $ "FromProcess('{ appNameOrPid } '): no UIA-visible window appeared within { timeoutMS } ms. " +
118+ $ "Is the app running? Run 'winapp ui list-windows -a { appNameOrPid } ' to confirm.") ;
119+ return null ! ;
120+ }
121+
42122 public T Find < T > ( By by , int timeoutMS = 5000 )
43123 where T : Element , new ( ) => FindUnder < T > ( by , timeoutMS ) ;
44124
@@ -136,22 +216,22 @@ public bool WaitFor(Func<bool> condition, int timeoutMS = 5000, int pollInterval
136216 return false ;
137217 }
138218
139- /// <summary>Capture a PNG of the session's window via <c>winapp ui screenshot</c>.</summary>
219+ /// <summary>Capture a PNG of the session's target via <c>winapp ui screenshot</c>.</summary>
140220 public string Screenshot ( string outputPath )
141221 {
142- WinappCli . InvokeAssertSuccess ( "ui" , "screenshot" , "-w" , WindowHandleArg , "-o" , outputPath ) ;
222+ WinappCli . InvokeAssertSuccess ( "ui" , "screenshot" , TargetFlag , TargetValue , "-o" , outputPath ) ;
143223 return outputPath ;
144224 }
145225
146226 /// <summary>
147- /// Dump the full UIA tree for this session's window via <c>winapp ui inspect --json</c>.
227+ /// Dump the full UIA tree for this session's target via <c>winapp ui inspect --json</c>.
148228 /// Returned shape: <c>{ "windows": [{ "elements": [{ "type", "name", "value", "children": [...] }] }] }</c>.
149229 /// </summary>
150230 public JsonElement Inspect ( int depth = 6 )
151231 {
152232 return WinappCli . InvokeJson (
153233 "ui" , "inspect" ,
154- "-w" , WindowHandleArg ,
234+ TargetFlag , TargetValue ,
155235 "--json" ,
156236 "-d" , depth . ToString ( CultureInfo . InvariantCulture ) ) ;
157237 }
@@ -167,7 +247,7 @@ public void Cleanup()
167247 private List < SearchHit > ExecuteSearch ( By by )
168248 {
169249 // winappcli accepts the selector text directly as the first positional argument.
170- var root = WinappCli . InvokeJson ( "ui" , "search" , by . Value , "-w" , WindowHandleArg , "--json" ) ;
250+ var root = WinappCli . InvokeJson ( "ui" , "search" , by . Value , TargetFlag , TargetValue , "--json" ) ;
171251
172252 var result = new List < SearchHit > ( ) ;
173253 if ( root . TryGetProperty ( "matches" , out var arr ) && arr . ValueKind == JsonValueKind . Array )
0 commit comments