Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1014,7 +1014,13 @@ Persisted in `state.json`. Key settings:
| `tests/CodeShellManager.Tests/` | Unit tests (xunit) | `dotnet test tests/CodeShellManager.Tests/` |
| `tests/CodeShellManager.UITests/` | FlaUI UI tests | `dotnet test tests/CodeShellManager.UITests/` |

Unit tests cover model logic (`ShellSession`, etc.) and run headless. UI tests require the app running on a live Windows desktop.
Unit tests cover model logic (`ShellSession`, etc.) and run headless. UI tests launch the Debug build (`src/CodeShellManager/bin/Debug/…`, so build it first) with `CSM_STATE_PATH` pointed at a temp file.

**Writing UI tests: three things that silently break them.** All ten were failing for these reasons before they were fixed; nothing runs them in CI, so they drift.

- **Layout elements are invisible to UI Automation.** `Border`, `StackPanel` and `Grid` have no automation peer, so an `AutomationId` set on one is ignored and `FindFirstDescendant` never finds it. Anything a test must find uses `Views/UiaElements` (`UiaBorder` / `UiaStackPanel` / `UiaGrid`: the same element plus a Group peer). Current ids: `SidebarSessionList`, `SidebarSessionRow`, `SidebarDormantRow`, `TerminalGrid`, `TerminalPane`, `TerminalCloseBtn`, `EmptyState`.
- **Dialogs are owned windows.** UI Automation lists them under the main window, not at the top level. Use `AppActions.FindDialog` / `WaitForDialog`, not `GetAllTopLevelWindows`.
- **Press, don't click or type.** `AppActions.Press` uses the Invoke pattern. Mouse clicks move the user's real cursor, and synthesized keystrokes fail with "Access is denied" on a locked or remote-disconnected desktop; with Invoke the suite passes on a locked screen. Avoid descendant searches inside a terminal pane, which walk into the WebView2 page tree.

`ShellSession.BuildSshArgs()` is `internal` — accessible from tests via `[assembly: InternalsVisibleTo("CodeShellManager.Tests")]` in `AssemblyInfo.cs`.

Expand Down
9 changes: 5 additions & 4 deletions src/CodeShellManager/MainWindow.xaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Window x:Class="CodeShellManager.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:CodeShellManager.Views"
Title="CodeShellManager" Height="800" Width="1280" MinWidth="800" MinHeight="500"
Background="#1e1e2e" Foreground="#cdd6f4"
FontFamily="Segoe UI"
Expand Down Expand Up @@ -374,20 +375,20 @@
</Grid>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="SidebarSessionList" AutomationProperties.AutomationId="SidebarSessionList" Margin="6,6" Background="Transparent"/>
<views:UiaStackPanel x:Name="SidebarSessionList" AutomationProperties.AutomationId="SidebarSessionList" Margin="6,6" Background="Transparent"/>
</ScrollViewer>
</DockPanel>
</Grid>
</Border>

<!-- ── Terminal grid ────────────────────────────────────────────── -->
<Grid x:Name="TerminalGrid" AutomationProperties.AutomationId="TerminalGrid" Background="#1e1e2e">
<TextBlock x:Name="EmptyState"
<views:UiaGrid x:Name="TerminalGrid" AutomationProperties.AutomationId="TerminalGrid" Background="#1e1e2e">
<TextBlock x:Name="EmptyState" AutomationProperties.AutomationId="EmptyState"
Text="No sessions open.&#x0a;Press Ctrl+T or click + New Session to get started."
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="#45475a" FontSize="15" TextAlignment="Center"
LineHeight="24"/>
</Grid>
</views:UiaGrid>
</DockPanel>

<!-- ── Shutdown board ───────────────────────────────────────────────
Expand Down
11 changes: 8 additions & 3 deletions src/CodeShellManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1765,7 +1765,8 @@ private Border BuildSidebarItem(SessionViewModel vm)
// BorderBrush by default. UpdateSidebarActiveState toggles BorderBrush to the
// session accent color when active, so layout doesn't shift as items become
// active and the active session stays visually distinct from the multi-select tint.
var container = new Border
// UiaBorder (not Border) so UI Automation can see the row — see Views/UiaElements.
var container = new UiaBorder
{
Margin = new Thickness(0, 2, 0, 2),
Background = Brushes.Transparent,
Expand Down Expand Up @@ -2182,6 +2183,7 @@ static void UpdateGitText(TextBlock tb, SessionViewModel svm)
});
};

System.Windows.Automation.AutomationProperties.SetAutomationId(container, "SidebarSessionRow");
return container;
}

Expand Down Expand Up @@ -4442,12 +4444,13 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView)

// Outer "active ring" — constant 2px frame; transparent unless this session is active
// (constant thickness avoids layout shift when activating/deactivating)
var activeRing = new Border
var activeRing = new UiaBorder
{
BorderThickness = new Thickness(2),
BorderBrush = Brushes.Transparent,
Tag = accent
};
System.Windows.Automation.AutomationProperties.SetAutomationId(activeRing, "TerminalPane");

// Fires only for the thin ring/chrome AROUND the terminal — WebView2 is an
// HwndHost, so a click on the terminal itself never reaches WPF as a routed
Expand Down Expand Up @@ -4665,6 +4668,7 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView)
Padding = new Thickness(4, 2, 4, 2),
Margin = new Thickness(0)
};
System.Windows.Automation.AutomationProperties.SetAutomationId(closeBtn, "TerminalCloseBtn");
closeBtn.Click += (_, _) => vm.CloseCommand.Execute(null);

DockPanel.SetDock(closeBtn, Dock.Right);
Expand Down Expand Up @@ -5584,7 +5588,7 @@ private Border BuildDormantSidebarItem(ShellSession session)
string accentHex = GetAccentForSession(session);
var accentColor = (Color)ColorConverter.ConvertFromString(accentHex);

var container = new Border
var container = new UiaBorder
{
Margin = new Thickness(0, 2, 0, 2),
Background = Brushes.Transparent,
Expand Down Expand Up @@ -5714,6 +5718,7 @@ private Border BuildDormantSidebarItem(ShellSession session)
await WakeSessionAsync(session);
};

System.Windows.Automation.AutomationProperties.SetAutomationId(container, "SidebarDormantRow");
return container;
}

Expand Down
37 changes: 37 additions & 0 deletions src/CodeShellManager/Views/UiaElements.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Windows.Automation.Peers;
using System.Windows.Controls;

namespace CodeShellManager.Views;

// WPF layout elements (Border, StackPanel, Grid) have no automation peer, so UI Automation
// never sees them however they are tagged: AutomationProperties.AutomationId on one is
// silently ignored, and FlaUI's FindFirstDescendant can never find it. The sidebar rows,
// the session list and the terminal panes are all built from these, which is why the FlaUI
// tests waited out their timeouts on 'SidebarSessionList' and 'TerminalGrid'.
//
// These subclasses only add a peer (control type Group), so an AutomationId set on them
// becomes visible. Layout and rendering are untouched, and they remain a Border / StackPanel
// / Grid to every caller.

internal sealed class UiaBorder : Border
{
protected override AutomationPeer OnCreateAutomationPeer() => new UiaGroupPeer(this);
}

internal sealed class UiaStackPanel : StackPanel
{
protected override AutomationPeer OnCreateAutomationPeer() => new UiaGroupPeer(this);
}

internal sealed class UiaGrid : Grid
{
protected override AutomationPeer OnCreateAutomationPeer() => new UiaGroupPeer(this);
}

internal sealed class UiaGroupPeer(System.Windows.FrameworkElement owner)
: FrameworkElementAutomationPeer(owner)
{
protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Group;
protected override string GetClassNameCore() => Owner.GetType().Name;
protected override bool IsControlElementCore() => true;
}
161 changes: 94 additions & 67 deletions tests/CodeShellManager.UITests/Helpers/AppActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,63 +40,112 @@ internal static AutomationElement Require(this AutomationElement parent, string
$"UI element with AutomationId '{automationId}' was not found.");

/// <summary>
/// Opens the New Session dialog, fills folder and name, and clicks Start Session.
/// Waits for the dialog to close before returning.
/// Presses a button through UI Automation's Invoke pattern rather than a mouse click.
/// A real click moves the user's cursor and lands on whatever window happens to be on
/// top; Invoke goes straight to the button. WPF queues the click on the dispatcher, so
/// this returns even when the button opens a modal dialog.
/// </summary>
public static void CreateSession(Application app, Window window, UIA3Automation automation,
string folder = @"C:\Windows", string name = "Test")
{
// Click New Session
WaitForElement(window, "NewSessionBtn").AsButton().Click();
public static void Press(AutomationElement button) => button.AsButton().Invoke();

// Wait for dialog window
var dialogResult = Retry.WhileNull(
() => app.GetAllTopLevelWindows(automation)
.FirstOrDefault(w => w.Title == "New Session"),
DefaultTimeout);
/// <summary>
/// Finds an open dialog by title. The app's dialogs are OWNED by the main window, and
/// UI Automation lists an owned window under its owner rather than at the top level —
/// so <c>GetAllTopLevelWindows</c> alone never sees them, and every test that waited for
/// "New Session" or "Settings" that way timed out. Both places are searched.
/// </summary>
public static Window? FindDialog(Application app, Window main, UIA3Automation automation, string title)
=> main.FindAllChildren(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Window))
.Select(e => e.AsWindow())
.FirstOrDefault(w => w.Title == title)
?? app.GetAllTopLevelWindows(automation).FirstOrDefault(w => w.Title == title);

/// <summary>Waits for a dialog to open, or throws naming it.</summary>
public static Window WaitForDialog(Application app, Window main, UIA3Automation automation,
string title, TimeSpan? timeout = null)
{
var result = Retry.WhileNull(() => FindDialog(app, main, automation, title),
timeout ?? DefaultTimeout);
if (!result.Success || result.Result is null)
throw new TimeoutException($"{title} dialog did not open.");
return result.Result;
}

if (!dialogResult.Success)
throw new TimeoutException("New Session dialog did not open.");
/// <summary>Waits for a dialog to close.</summary>
public static void WaitForDialogClosed(Application app, Window main, UIA3Automation automation,
string title)
=> Retry.WhileFalse(() => FindDialog(app, main, automation, title) is null, DefaultTimeout);

var dialog = dialogResult.Result!;
/// <summary>Opens the New Session dialog and returns it.</summary>
public static Window OpenNewSessionDialog(Application app, Window window, UIA3Automation automation)
{
Press(WaitForElement(window, "NewSessionBtn"));
return WaitForDialog(app, window, automation, "New Session");
}

// Fill folder
var folderBox = dialog.Require("NewSessionFolderBox").AsTextBox();
folderBox.Text = folder;
/// <summary>Opens the Settings window and returns it.</summary>
public static Window OpenSettings(Application app, Window window, UIA3Automation automation)
{
Press(WaitForElement(window, "SettingsBtn"));
return WaitForDialog(app, window, automation, "Settings");
}

// Fill name
var nameBox = dialog.Require("NewSessionNameBox").AsTextBox();
nameBox.Text = name;
/// <summary>
/// Opens the New Session dialog, fills folder and name, and clicks Start Session.
/// Waits for the dialog to close before returning.
/// </summary>
public static void CreateSession(Application app, Window window, UIA3Automation automation,
string folder = @"C:\Windows", string name = "Test")
{
var dialog = OpenNewSessionDialog(app, window, automation);

// Click Start Session
dialog.Require("NewSessionOkBtn").AsButton().Click();
dialog.Require("NewSessionFolderBox").AsTextBox().Text = folder;
dialog.Require("NewSessionNameBox").AsTextBox().Text = name;

// Wait for dialog to close
Retry.WhileFalse(
() => app.GetAllTopLevelWindows(automation)
.All(w => w.Title != "New Session"),
DefaultTimeout);
Press(dialog.Require("NewSessionOkBtn"));
WaitForDialogClosed(app, window, automation, "New Session");
}

/// <summary>Returns the number of direct children in the sidebar session list.</summary>
public static int GetSidebarSessionCount(Window window)
{
var list = WaitForElement(window, "SidebarSessionList");
return list.FindAllChildren().Length;
}
/// <summary>
/// The live sidebar rows, top to bottom, found by their AutomationId
/// (<c>SidebarSessionRow</c>) rather than as the list's children: the list also holds
/// group and worktree-cluster headers, and in inline-headers mode the rows sit inside
/// group sections.
/// </summary>
public static AutomationElement[] GetSidebarSessionRows(Window window)
=> WaitForElement(window, "SidebarSessionList")
.FindAllDescendants(cf => cf.ByAutomationId("SidebarSessionRow"));

/// <summary>Number of live sessions in the sidebar.</summary>
public static int GetSidebarSessionCount(Window window) => GetSidebarSessionRows(window).Length;

/// <summary>Returns the number of visible children in the terminal grid.</summary>
/// <summary>
/// Number of terminal panes laid out in the grid. Only direct children are inspected —
/// a descendant search would walk into every WebView2's page tree.
/// </summary>
public static int GetTerminalGridChildCount(Window window)
=> WaitForElement(window, "TerminalGrid")
.FindAllChildren(cf => cf.ByAutomationId("TerminalPane"))
.Length;

/// <summary>
/// Closes the first pane in the grid with its toolbar ✕ (<c>TerminalCloseBtn</c>).
/// Returns false when there is no pane left. Used instead of Ctrl+W: synthesized
/// keystrokes need an unlocked, focused desktop ("Access is denied" otherwise), while
/// Invoke reaches the button regardless.
/// </summary>
public static bool CloseFirstSession(Window window)
{
var grid = WaitForElement(window, "TerminalGrid");
return grid.FindAllChildren(cf => cf.ByControlType(FlaUI.Core.Definitions.ControlType.Pane))
.Length;
var pane = WaitForElement(window, "TerminalGrid")
.FindFirstChild(cf => cf.ByAutomationId("TerminalPane"));
if (pane is null) return false;
Press(pane.Require("TerminalCloseBtn"));
return true;
}

/// <summary>Clicks a layout button by its AutomationId (e.g. "Layout_Two").</summary>
public static void SetLayout(Window window, string layoutAutomationId)
{
WaitForElement(window, layoutAutomationId).AsButton().Click();
Press(WaitForElement(window, layoutAutomationId));
System.Threading.Thread.Sleep(300); // let layout refresh
}

Expand All @@ -108,35 +157,13 @@ public static void SetLayout(Window window, string layoutAutomationId)
public static void CreateSshSession(Application app, Window window, UIA3Automation automation,
string host = "user@test-host", string name = "SSH Test")
{
WaitForElement(window, "NewSessionBtn").AsButton().Click();

var dialogResult = Retry.WhileNull(
() => app.GetAllTopLevelWindows(automation)
.FirstOrDefault(w => w.Title == "New Session"),
DefaultTimeout);

if (!dialogResult.Success)
throw new TimeoutException("New Session dialog did not open.");

var dialog = dialogResult.Result!;

// Switch to Remote mode
dialog.Require("NewSessionRemoteRadio").AsRadioButton().Click();

// Fill SSH host
var hostBox = dialog.Require("NewSessionSshHostBox").AsTextBox();
hostBox.Text = host;

// Fill session name
var nameBox = dialog.Require("NewSessionNameBox").AsTextBox();
nameBox.Text = name;
var dialog = OpenNewSessionDialog(app, window, automation);

// Click Start Session
dialog.Require("NewSessionOkBtn").AsButton().Click();
dialog.Require("NewSessionRemoteRadio").AsRadioButton().IsChecked = true;
dialog.Require("NewSessionSshHostBox").AsTextBox().Text = host;
dialog.Require("NewSessionNameBox").AsTextBox().Text = name;

Retry.WhileFalse(
() => app.GetAllTopLevelWindows(automation)
.All(w => w.Title != "New Session"),
DefaultTimeout);
Press(dialog.Require("NewSessionOkBtn"));
WaitForDialogClosed(app, window, automation, "New Session");
}
}
5 changes: 2 additions & 3 deletions tests/CodeShellManager.UITests/LayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,10 @@ public void TwoByTwo_OffscreenSession_BecomesVisibleOnSelect()
Thread.Sleep(500);

// Click the 5th sidebar item (index 4) — it's off-screen in the 2×2 grid
var sidebar = AppActions.WaitForElement(_f.MainWindow, "SidebarSessionList");
var items = sidebar.FindAllChildren();
var items = AppActions.GetSidebarSessionRows(_f.MainWindow);
Assert.True(items.Length >= 5, $"Expected ≥5 sidebar items, got {items.Length}");

items[4].Click();
items[4].Click(); // a real click: rows react to the mouse and have no Invoke pattern
Thread.Sleep(500);

// After clicking, the viewport should scroll and the 5th pane should appear in the grid
Expand Down
Loading