mirror of
https://github.com/herdrdev/herdr.git
synced 2026-11-06 08:06:30 +00:00
Merge branch 'master' into terminal-browser-kitty
This commit is contained in:
@@ -19,7 +19,7 @@ maintenance-test:
|
||||
# Local interactive Windows Terminal input qualification (never runs in normal CI).
|
||||
[windows]
|
||||
test-windows-input *args:
|
||||
pwsh -NoProfile -File scripts/test_windows_input.ps1 -AllowInputInjection {{args}}
|
||||
pwsh -NoProfile -File scripts/test_windows_input.ps1 -AllowInputInjection -ClearClipboard {{args}}
|
||||
|
||||
# Run one nextest filter, e.g. `just test-one codex_stale_working`
|
||||
test-one filter:
|
||||
|
||||
@@ -17,6 +17,7 @@ param(
|
||||
[int[]] $Heights = @(24, 50),
|
||||
[string] $OutputDirectory,
|
||||
[switch] $AllowInputInjection,
|
||||
[switch] $ClearClipboard,
|
||||
[switch] $Manual,
|
||||
[switch] $MatrixOnly
|
||||
)
|
||||
@@ -47,6 +48,19 @@ if (@($selectedCases | Where-Object id -eq 'mouse-focus-refresh').Count) {
|
||||
if ($controllerWindow -eq [IntPtr]::Zero) { throw 'Controller has no foreground window for focus-cycle qualification' }
|
||||
[HerdrInputGauntlet.Desktop]::AssertNotElevated([HerdrInputGauntlet.Desktop]::Pid($controllerWindow))
|
||||
}
|
||||
$needsClipboard = $false
|
||||
foreach ($case in $selectedCases) {
|
||||
if ($case.kind -in @('paste', 'mouse-interleave', 'clipboard-image', 'clipboard-mixed') -and
|
||||
@($Modes | Where-Object { $case.expected.ContainsKey($_) }).Count) { $needsClipboard = $true; break }
|
||||
}
|
||||
$clipboardFormatsAtStart = if ($needsClipboard -and $ClearClipboard) {
|
||||
[HerdrInputGauntlet.Desktop]::ClearClipboardForRun()
|
||||
} else { [HerdrInputGauntlet.Desktop]::CountClipboardFormats() }
|
||||
if ($needsClipboard -and -not $ClearClipboard -and $clipboardFormatsAtStart -ne 0) {
|
||||
throw 'Clipboard is not empty; save its contents, then clear it or pass -ClearClipboard to discard it before running'
|
||||
}
|
||||
$clipboardFormatsCleared = if ($needsClipboard -and $ClearClipboard) { $clipboardFormatsAtStart } else { 0 }
|
||||
Write-Host "Clipboard formats before build: $clipboardFormatsAtStart; cleared for test: $clipboardFormatsCleared"
|
||||
$sourceCommit = $null
|
||||
$sourceDirty = $null
|
||||
if (-not $ExePath) {
|
||||
@@ -78,11 +92,11 @@ Write-Host "Herdr under test: $exe"
|
||||
Write-Host "SHA-256: $exeHash"
|
||||
Write-Host "Modes: $($Modes -join ', '); widths: $($Widths -join ', '); heights: $($Heights -join ', ')"
|
||||
Write-Host "Channels: $($Channels -join ', '); paths: $($Paths -join ', '); cases: $(if (@($Cases).Count) { $Cases -join ', ' } else { 'all' })"
|
||||
Write-Host "Clipboard formats at start: $([HerdrInputGauntlet.Desktop]::CountClipboardFormats()) (paste runs only when zero)"
|
||||
$document = @{ schema = 1; run = [IO.Path]::GetFileName($root); started = [DateTime]::UtcNow.ToString('O');
|
||||
source_commit = $sourceCommit; source_dirty = $sourceDirty; exe = $exe; exe_sha256 = $exeHash;
|
||||
powershell = $PSVersionTable.PSVersion.ToString(); os = [Environment]::OSVersion.VersionString;
|
||||
profile = $Profile; controller_elevated = $false; observations = @(); hosts = @(); errors = @(); cleanup_errors = @();
|
||||
clipboard_clear_requested = [bool]$ClearClipboard; clipboard_formats_at_start = $clipboardFormatsAtStart; clipboard_formats_cleared = $clipboardFormatsCleared;
|
||||
widths = $Widths; heights = $Heights; modes = $Modes; channels = $Channels; paths = $Paths;
|
||||
cases = @($selectedCases | ForEach-Object id); note = 'Desktop input evidence; native qualification still required' }
|
||||
$rawReport = Join-Path $root 'observations.json'
|
||||
|
||||
@@ -200,6 +200,33 @@ class WindowsInputGauntletTests(unittest.TestCase):
|
||||
rows = {row[0]: row[1:] for row in qualification_matrix(result)}
|
||||
self.assertEqual(rows["Shift+Enter"][0], "PARTIAL")
|
||||
|
||||
def test_direct_legacy_limit_requires_unsupported_from_every_channel(self):
|
||||
observations = [{"host": host, "case": "shift-enter", "path": "direct", "mode": "legacy", "status": status}
|
||||
for host, status in (("stable", "unsupported"), ("preview", "not_run"))]
|
||||
rows = {row[0]: row[1:] for row in qualification_matrix({"channels": ["stable", "preview"], "observations": observations})}
|
||||
self.assertEqual(rows["Shift+Enter"][1], "PARTIAL")
|
||||
|
||||
def test_qualification_matrix_keeps_proven_input_visible_when_geometry_is_unavailable(self):
|
||||
observations = [{"host": host, "case": case, "path": "herdr", "mode": "legacy", "status": "pass", "width": 120}
|
||||
for host in ("stable", "preview") for case in ("letter-a", "shift-letter")]
|
||||
observations += [{"host": host, "case": "letter-a", "path": "herdr", "mode": "legacy", "status": "not_run", "width": 160}
|
||||
for host in ("stable", "preview")]
|
||||
observations += [{"host": host, "case": "shift-enter", "path": "direct", "mode": "legacy", "status": status}
|
||||
for host in ("stable", "preview") for status in ("unsupported", "not_run")]
|
||||
observations += [{"host": "stable", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "inconclusive"},
|
||||
{"host": "preview", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "pass"},
|
||||
{"host": "preview", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "not_run"}]
|
||||
result = {"channels": ["stable", "preview"], "observations": observations}
|
||||
rows = {row[0]: row[1:] for row in qualification_matrix(result)}
|
||||
self.assertEqual(rows["Printable keys"][0], "PARTIAL")
|
||||
self.assertEqual(rows["Shift+Enter"][1:], ("X - becomes Enter", "INCONCLUSIVE"))
|
||||
self.assertEqual(rows["Multiline paste"][0], "NOT TESTED")
|
||||
self.assertEqual(rows["Resize 120 -> 80"][0], "NOT TESTED")
|
||||
observations[3]["status"] = "not_run" # Preview has no passing shift-letter observation.
|
||||
self.assertEqual({row[0]: row[1] for row in qualification_matrix(result)}["Printable keys"], "NOT TESTED")
|
||||
observations[3]["status"] = "fail"
|
||||
self.assertEqual({row[0]: row[1] for row in qualification_matrix(result)}["Printable keys"], "FAIL")
|
||||
|
||||
def test_herdr_protocol_label_requires_every_run_to_prove_transport(self):
|
||||
result = {"hosts": [{"channel": "stable", "runs": [{"nonce": "one", "path": "herdr", "mode": "native"},
|
||||
{"nonce": "two", "path": "herdr", "mode": "native"}]}],
|
||||
|
||||
@@ -108,7 +108,15 @@ namespace HerdrInputGauntlet {
|
||||
[DllImport("user32.dll", SetLastError=true)] static extern uint SendInput(uint count, Input[] events, int size);
|
||||
[DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hwnd, out Rect rect);
|
||||
[DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int width, int height, uint flags);
|
||||
[DllImport("user32.dll")] public static extern int CountClipboardFormats();
|
||||
[DllImport("user32.dll",EntryPoint="CountClipboardFormats",SetLastError=true)] static extern int NativeCountClipboardFormats();
|
||||
public static int CountClipboardFormats() {
|
||||
int count=NativeCountClipboardFormats();
|
||||
if(count==0) {
|
||||
int error=Marshal.GetLastWin32Error();
|
||||
if(error!=0) throw new Win32Exception(error,"Clipboard format count failed");
|
||||
}
|
||||
return count;
|
||||
}
|
||||
[DllImport("user32.dll")] public static extern uint GetClipboardSequenceNumber();
|
||||
[DllImport("user32.dll")] static extern IntPtr GetClipboardOwner();
|
||||
[DllImport("user32.dll")] static extern bool OpenClipboard(IntPtr owner);
|
||||
@@ -141,6 +149,14 @@ namespace HerdrInputGauntlet {
|
||||
if(result<0) Marshal.ThrowExceptionForHR(result);
|
||||
return checked((int)pid);
|
||||
}
|
||||
public static int ClearClipboardForRun() {
|
||||
if(!OpenClipboard(IntPtr.Zero)) throw new Exception("Clipboard busy; cannot start paste qualification");
|
||||
try {
|
||||
int formats=CountClipboardFormats();
|
||||
if(formats!=0 && !EmptyClipboard()) throw new Exception("Clipboard clear failed");
|
||||
return formats;
|
||||
} finally { CloseClipboard(); }
|
||||
}
|
||||
|
||||
public static uint SetEmptyClipboard(IntPtr owner,string text) {
|
||||
if(owner==IntPtr.Zero) throw new Exception("Clipboard owner is required");
|
||||
|
||||
@@ -33,11 +33,14 @@ or mouse buttons while starting a run. Normal controller cancellation runs clean
|
||||
bootstrap/probe leases also expire if the controller disappears. Lease expiry is
|
||||
not a replacement for a secure isolated desktop.
|
||||
|
||||
Clipboard tests require an **empty clipboard**. Clear it yourself only after
|
||||
saving anything you need. The runner will not replace existing text, images,
|
||||
rich formats, or files. It writes its synthetic text while holding the clipboard
|
||||
lock and clears it afterward only if its sequence number is unchanged. A later
|
||||
user clipboard update is left alone. No original clipboard contents are logged.
|
||||
`just test-windows-input` clears the clipboard once before the build when the
|
||||
selected cases use it. **Save anything you need first:** existing text, images,
|
||||
files, and other formats are discarded and cannot be restored. Direct script
|
||||
calls need `-ClearClipboard` for the same behavior; without it, a nonempty
|
||||
clipboard stops the run before the build. The runner writes its synthetic data
|
||||
while holding the clipboard lock and clears it afterward only if its sequence
|
||||
number is unchanged. A later user clipboard update is left alone. No original
|
||||
clipboard contents are logged.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -70,7 +73,7 @@ Or invoke the script directly:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File scripts/test_windows_input.ps1 `
|
||||
-ExePath 'C:\test-app\herdr.exe' -AllowInputInjection `
|
||||
-ExePath 'C:\test-app\herdr.exe' -AllowInputInjection -ClearClipboard `
|
||||
-StablePath 'C:\TerminalStable\WindowsTerminal.exe' `
|
||||
-PreviewPath 'C:\TerminalPreview\WindowsTerminal.exe'
|
||||
```
|
||||
@@ -83,7 +86,7 @@ arrays normally:
|
||||
|
||||
```powershell
|
||||
.\scripts\test_windows_input.ps1 -ExePath 'C:\test-app\herdr.exe' `
|
||||
-AllowInputInjection -Modes legacy,kitty -Cases mouse-interleave,mode-transitions `
|
||||
-AllowInputInjection -ClearClipboard -Modes legacy,kitty -Cases mouse-interleave,mode-transitions `
|
||||
-Widths 120 -Heights 30
|
||||
```
|
||||
|
||||
@@ -92,7 +95,7 @@ server with the remote clipboard bridge active. It needs no SSH host. A focused
|
||||
clipboard-image qualification is:
|
||||
|
||||
```powershell
|
||||
.\scripts\test_windows_input.ps1 -AllowInputInjection `
|
||||
.\scripts\test_windows_input.ps1 -AllowInputInjection -ClearClipboard `
|
||||
-Channels stable -Paths direct,herdr-remote -Modes legacy `
|
||||
-Cases clipboard-image,clipboard-mixed -Widths 80 -Heights 24
|
||||
```
|
||||
|
||||
@@ -419,18 +419,23 @@ def qualification_matrix(result):
|
||||
return "MANUAL"
|
||||
if {row.get("case") for row in matched} != case_ids:
|
||||
return "PARTIAL" if "pass" in statuses else "INCONCLUSIVE" if "inconclusive" in statuses else "NOT TESTED"
|
||||
if required_channels and {(row.get("host"), row.get("case")) for row in matched} != {
|
||||
(channel, case_id) for channel in required_channels for case_id in case_ids}:
|
||||
expected_pairs = {(channel, case_id) for channel in required_channels for case_id in case_ids}
|
||||
if required_channels and {(row.get("host"), row.get("case")) for row in matched} != expected_pairs:
|
||||
return "PARTIAL"
|
||||
if case_ids == {"mode-transitions"} and path == "direct" and statuses <= {"unsupported", "inconclusive"}:
|
||||
return "X - mOK ignored"
|
||||
if path == "direct" and modes == {"legacy"} and statuses == {"unsupported"}:
|
||||
if path == "direct" and modes == {"legacy"} and statuses - {"not_run"} == {"unsupported"}:
|
||||
if {(row.get("host"), row.get("case")) for row in matched if row.get("status") == "unsupported"} != expected_pairs:
|
||||
return "PARTIAL"
|
||||
return "X - becomes Enter" if case_ids == {"shift-enter"} else "X - loses modifier"
|
||||
def case_passed(case_id):
|
||||
statuses_for_case = {row.get("status") for row in matched if row.get("case") == case_id}
|
||||
def case_passed(case_id, channel=None):
|
||||
statuses_for_case = {row.get("status") for row in matched if row.get("case") == case_id
|
||||
and (channel is None or row.get("host") == channel)}
|
||||
return "pass" in statuses_for_case or (width == 80 and path == "direct" and modes == {"legacy"}
|
||||
and case_id == "shift-enter" and statuses_for_case == {"unsupported"})
|
||||
and case_id == "shift-enter" and statuses_for_case - {"not_run"} == {"unsupported"})
|
||||
per_case_passed = all(case_passed(case_id) for case_id in case_ids)
|
||||
if "not_run" in statuses and all(case_passed(case_id, channel) for channel in required_channels for case_id in case_ids):
|
||||
return "PARTIAL"
|
||||
allowed = {"pass", "inconclusive"}
|
||||
if width == 80 and path == "direct" and modes == {"legacy"}:
|
||||
allowed.add("unsupported")
|
||||
|
||||
@@ -288,7 +288,14 @@ pub(super) fn navigator_rows(
|
||||
Some(ClientNavigatorFilter::Done) => status == crate::api::schema::AgentStatus::Done,
|
||||
None => true,
|
||||
};
|
||||
let text = |value: &str| query.is_empty() || value.to_lowercase().contains(&query);
|
||||
let words = query.split_whitespace().collect::<Vec<_>>();
|
||||
let text = |value: &str| {
|
||||
if words.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let value = value.to_lowercase();
|
||||
words.iter().all(|word| value.contains(word))
|
||||
};
|
||||
let filtering = navigator.filter.is_some() || !query.is_empty();
|
||||
let federated = endpoints.len() > 1;
|
||||
let depth_offset = u8::from(federated);
|
||||
|
||||
@@ -1080,6 +1080,42 @@ fn navigator_renders_every_terminal_in_workspace_sections() {
|
||||
assert!(visible.iter().all(|row| !row.contains("second")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigator_search_matches_non_adjacent_words_without_losing_the_pane_target() {
|
||||
let mut projected = snapshot();
|
||||
projected.panes[0].label = Some("alpha beta gamma".into());
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.set_pane_surface(surface());
|
||||
state.open_navigator_overlay();
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
|
||||
panic!("navigator");
|
||||
};
|
||||
for (query, matches) in [
|
||||
("alpha gamma", true),
|
||||
(" ALP\tGAM ", true),
|
||||
("gamma alpha", true),
|
||||
("beta gamma", true),
|
||||
("alpha missing", false),
|
||||
("alphagamma", false),
|
||||
] {
|
||||
navigator.query = query.into();
|
||||
navigator.selected = None;
|
||||
let rows =
|
||||
render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
|
||||
let target =
|
||||
super::super::aggregate_navigation::selected_navigator_target(&rows, navigator);
|
||||
assert_eq!(
|
||||
target,
|
||||
matches.then(|| ClientNavigatorTarget::Pane {
|
||||
endpoint_id: state.active_endpoint_id.clone(),
|
||||
pane_id: "pane_1".into(),
|
||||
}),
|
||||
"query={query:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigator_searches_ancestor_context_and_keeps_split_agents_individually_actionable() {
|
||||
let mut projected = snapshot();
|
||||
|
||||
Reference in New Issue
Block a user