From 55a7390bda33069ad648bdc58552c5ecabbb1528 Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 19 Oct 2026 13:45:47 -0300 Subject: [PATCH 1/6] fix: report tmux pane geometry relative to the window and update stale socket/palette tests tmux-compat list-panes printed a negative pane_top because pane frames are window-relative while the container starts below the titlebar; pane pixel frames are now offset by the container origin (added to pane.list) and divided by the point-sized cell, not the pixel-sized one. Tests follow deliberate contract changes: socket commands no longer steal focus (workspace.create does not select), bare indexes are rejected, focused-panel notifications are still recorded, untracked review files are "New file" rows, Codex hook ownership is an exact match, palette per-app open commands carry an app suffix. The fake agent in the screen-manifest test now clears its approval prompt so the idle prompt can win. The command palette UI class launches with a tag, the socket override and socket diagnostics, and the saved-description label is found by identifier. --- CLI/CLI+TmuxCompat.swift | 25 ++++++---- Sources/TerminalController+Pane.swift | 2 + programaUITests/SidebarHelpMenuUITests.swift | 19 +++++-- .../WorkspaceDescriptionUITests.swift | 11 ++-- .../test_agent_screen_manifest_detection.py | 3 ++ tests_v2/test_codex_install_integration.py | 12 +++-- ...test_command_palette_search_action_sync.py | 5 +- ...command_palette_search_typing_stability.py | 1 + ...test_command_palette_shortcut_hint_sync.py | 10 ++++ tests_v2/test_notifications.py | 50 +++++++++++-------- tests_v2/test_review_diff_panel.py | 6 ++- 11 files changed, 99 insertions(+), 45 deletions(-) diff --git a/CLI/CLI+TmuxCompat.swift b/CLI/CLI+TmuxCompat.swift index 480896841..ddc122d4f 100644 --- a/CLI/CLI+TmuxCompat.swift +++ b/CLI/CLI+TmuxCompat.swift @@ -709,22 +709,27 @@ extension ProgramaCLI { context["pane_width"] = String(columns) context["pane_height"] = String(rows) - let cellW = pane["cell_width_px"] as? Int ?? 0 - let cellH = pane["cell_height_px"] as? Int ?? 0 + // Frames are in layout points, so divide by the point cell size (`cell_width_px` is + // device pixels and would halve every column on a Retina display). + let cellW = (pane["cell_width"] as? NSNumber)?.doubleValue ?? 0 + let cellH = (pane["cell_height"] as? NSNumber)?.doubleValue ?? 0 guard cellW > 0, cellH > 0 else { return } + // pixel_frame is in window coordinates; tmux positions are relative to the window area. + let originX = (containerFrame?["x"] as? NSNumber)?.doubleValue ?? 0 + let originY = (containerFrame?["y"] as? NSNumber)?.doubleValue ?? 0 if let frame = pane["pixel_frame"] as? [String: Any] { - let px = frame["x"] as? Double ?? 0 - let py = frame["y"] as? Double ?? 0 - context["pane_left"] = String(Int(px) / cellW) - context["pane_top"] = String(Int(py) / cellH) + let px = (frame["x"] as? NSNumber)?.doubleValue ?? 0 + let py = (frame["y"] as? NSNumber)?.doubleValue ?? 0 + context["pane_left"] = String(max(Int(((px - originX) / cellW).rounded()), 0)) + context["pane_top"] = String(max(Int(((py - originY) / cellH).rounded()), 0)) } if let cf = containerFrame { - let cw = cf["width"] as? Double ?? 0 - let ch = cf["height"] as? Double ?? 0 - context["window_width"] = String(max(Int(cw) / cellW, 1)) - context["window_height"] = String(max(Int(ch) / cellH, 1)) + let cw = (cf["width"] as? NSNumber)?.doubleValue ?? 0 + let ch = (cf["height"] as? NSNumber)?.doubleValue ?? 0 + context["window_width"] = String(max(Int(cw / cellW), 1)) + context["window_height"] = String(max(Int(ch / cellH), 1)) } } diff --git a/Sources/TerminalController+Pane.swift b/Sources/TerminalController+Pane.swift index 6ec54c80a..c680aa40b 100644 --- a/Sources/TerminalController+Pane.swift +++ b/Sources/TerminalController+Pane.swift @@ -78,6 +78,8 @@ extension TerminalController { "window_ref": v2Ref(kind: .window, uuid: windowId) ] payload["container_frame"] = [ + "x": snapshot.containerFrame.x, + "y": snapshot.containerFrame.y, "width": snapshot.containerFrame.width, "height": snapshot.containerFrame.height ] diff --git a/programaUITests/SidebarHelpMenuUITests.swift b/programaUITests/SidebarHelpMenuUITests.swift index 3a0cd9a01..705b555b4 100644 --- a/programaUITests/SidebarHelpMenuUITests.swift +++ b/programaUITests/SidebarHelpMenuUITests.swift @@ -180,6 +180,8 @@ final class SidebarHelpMenuUITests: XCTestCase { final class CommandPaletteAllSurfacesUITests: XCTestCase { private var socketPath = "" + private var diagnosticsPath = "" + private var launchTag = "" private let hiddenSurfaceToken = "programa-command-palette-hidden-surface" private let visibleSurfaceToken = "programa-command-palette-visible-surface" private let noMatchWorkspaceQuery = "programa-command-palette-no-match" @@ -188,11 +190,15 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { super.setUp() continueAfterFailure = false socketPath = "/tmp/programa-ui-test-command-palette-\(UUID().uuidString).sock" + diagnosticsPath = "/tmp/programa-ui-test-command-palette-\(UUID().uuidString).json" + launchTag = "ui-tests-command-palette-\(UUID().uuidString.prefix(8).lowercased())" try? FileManager.default.removeItem(atPath: socketPath) + try? FileManager.default.removeItem(atPath: diagnosticsPath) } override func tearDown() { try? FileManager.default.removeItem(atPath: socketPath) + try? FileManager.default.removeItem(atPath: diagnosticsPath) super.tearDown() } @@ -206,6 +212,11 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { app.launchEnvironment["PROGRAMA_SOCKET_PATH"] = socketPath app.launchEnvironment["PROGRAMA_SOCKET_ENABLE"] = "1" app.launchEnvironment["PROGRAMA_SOCKET_MODE"] = "allowAll" + // Debug launches need a tag, and a tagged launch honors PROGRAMA_SOCKET_PATH only with the override. + app.launchEnvironment["PROGRAMA_TAG"] = launchTag + app.launchEnvironment["PROGRAMA_ALLOW_SOCKET_OVERRIDE"] = "1" + app.launchEnvironment["PROGRAMA_UI_TEST_SOCKET_SANITY"] = "1" + app.launchEnvironment["PROGRAMA_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath if showSettingsWindow { app.launchEnvironment["PROGRAMA_UI_TEST_SHOW_SETTINGS"] = "1" } @@ -222,7 +233,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { }, "Expected the main window to be visible" ) - XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)") + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath). diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") @@ -335,7 +346,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { }, "Expected the main window and Settings window to be visible" ) - XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)") + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath). diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") let secondaryWorkspaceId = try XCTUnwrap(createWorkspaceId(), "Expected workspace.create to return a workspace ID") @@ -492,7 +503,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { }, "Expected the main window and Settings window to be visible" ) - XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)") + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath). diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") @@ -580,7 +591,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { }, "Expected the main window to be visible" ) - XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)") + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath). diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") try seedWorkspaceSwitcherCorpus(workspaceCount: 96) diff --git a/programaUITests/WorkspaceDescriptionUITests.swift b/programaUITests/WorkspaceDescriptionUITests.swift index 065a12d34..393b50a8f 100644 --- a/programaUITests/WorkspaceDescriptionUITests.swift +++ b/programaUITests/WorkspaceDescriptionUITests.swift @@ -142,16 +142,19 @@ final class WorkspaceDescriptionUITests: XCTestCase { "Expected Enter to save and dismiss the workspace description editor after multiline input" ) + // The description Text is wrapped in a Group that carries the identifier and label, so + // SwiftUI may expose it as a generic element rather than a static text. let renderedDescription = app - .descendants(matching: .staticText) - .matching(NSPredicate(format: "label == %@", description)) + .descendants(matching: .any) + .matching(identifier: "SidebarWorkspaceDescriptionText") .firstMatch XCTAssertTrue( workspaceDescriptionPollUntil(timeout: 5.0) { - renderedDescription.exists + renderedDescription.exists && renderedDescription.label == description }, - "Expected the sidebar to render the saved multiline description with a newline-preserving label" + "Expected the sidebar to render the saved multiline description with a newline-preserving label. " + + "exists=\(renderedDescription.exists) label=\(renderedDescription.exists ? renderedDescription.label : "")" ) } diff --git a/tests_v2/test_agent_screen_manifest_detection.py b/tests_v2/test_agent_screen_manifest_detection.py index 5cef75b49..34b980980 100644 --- a/tests_v2/test_agent_screen_manifest_detection.py +++ b/tests_v2/test_agent_screen_manifest_detection.py @@ -52,6 +52,9 @@ sleep 8 printf '\\nDo you want to proceed?\\n❯ 1. Yes\\n' sleep 8 +# Clear the screen like a real approval box closing: blocked outranks idle while its text is +# still within the last 12 lines. +printf '\\033[2J\\033[H' printf '\\n❯ ' sleep 30 """ diff --git a/tests_v2/test_codex_install_integration.py b/tests_v2/test_codex_install_integration.py index 06bf7a420..33ad1b83c 100644 --- a/tests_v2/test_codex_install_integration.py +++ b/tests_v2/test_codex_install_integration.py @@ -37,6 +37,10 @@ "PermissionRequest", "SessionEnd", ] +# The unguarded form older installs wrote. Ownership is an exact match on a known command, so +# only this form (not an arbitrary `programa codex-hook ` line) is replaced on reinstall. +STALE_SESSION_START_COMMAND = f"{CODEX_HOOK_MARKER} session-start" +STALE_STOP_COMMAND = f"{CODEX_HOOK_MARKER} stop" def _run( @@ -141,12 +145,12 @@ def test_preserves_user_hooks_and_replaces_stale_entry(cli: str) -> None: "hooks": [{"type": "command", "command": "echo my-custom-start-hook"}], }, { - "hooks": [{"type": "command", "command": f"{CODEX_HOOK_MARKER} old-form"}], + "hooks": [{"type": "command", "command": STALE_SESSION_START_COMMAND}], }, ], "Stop": [ { - "hooks": [{"type": "command", "command": f"{CODEX_HOOK_MARKER} stop-old-format"}], + "hooks": [{"type": "command", "command": STALE_STOP_COMMAND}], } ], } @@ -184,7 +188,7 @@ def test_preserves_user_hooks_and_replaces_stale_entry(cli: str) -> None: ) _must(len(programa_groups) == 1, f"expected exactly 1 programa SessionStart group, got: {programa_groups}") _must( - "old-form" not in programa_groups[0]["hooks"][0]["command"], + programa_groups[0]["hooks"][0]["command"] != STALE_SESSION_START_COMMAND, f"stale programa SessionStart entry should be replaced, got: {programa_groups[0]}", ) @@ -192,7 +196,7 @@ def test_preserves_user_hooks_and_replaces_stale_entry(cli: str) -> None: stop_commands = _hooks_commands(hooks, "Stop") _must(len(stop_commands) == 1, f"expected exactly 1 Stop hook command, got: {stop_commands}") _must(CODEX_HOOK_MARKER in stop_commands[0], f"Stop hook should be a programa hook, got: {stop_commands}") - _must("stop-old-format" not in stop_commands[0], f"stale Stop command should be replaced, got: {stop_commands}") + _must(stop_commands[0] != STALE_STOP_COMMAND, f"stale Stop command should be replaced, got: {stop_commands}") print(" PASS: install preserves user hooks and replaces stale programa entries") diff --git a/tests_v2/test_command_palette_search_action_sync.py b/tests_v2/test_command_palette_search_action_sync.py index ec2b44a63..2ba8a27ea 100644 --- a/tests_v2/test_command_palette_search_action_sync.py +++ b/tests_v2/test_command_palette_search_action_sync.py @@ -76,7 +76,10 @@ def main(): before_rows = before.get("results") or [] if not before_rows: raise ProgramaClientError(f"no results for 'open': {before}") - if str(before_rows[0].get("command_id") or "") != "palette.terminalOpenDirectory": + # "Open Folder…" and the per-app "Open Current Directory in " commands + # (palette.terminalOpenDirectory., one per installed app) all match "open". + top_before = str(before_rows[0].get("command_id") or "") + if top_before != "palette.openFolder" and not top_before.startswith("palette.terminalOpenDirectory."): raise ProgramaClientError(f"unexpected top command for 'open': {before_rows[0]}") client.simulate_shortcut("cmd+a") diff --git a/tests_v2/test_command_palette_search_typing_stability.py b/tests_v2/test_command_palette_search_typing_stability.py index 21ddb6f1d..df66a9597 100644 --- a/tests_v2/test_command_palette_search_typing_stability.py +++ b/tests_v2/test_command_palette_search_typing_stability.py @@ -71,6 +71,7 @@ def _open_palette(client, window_id): window_id, expected_text_length=0, message="search input did not focus with empty query", + timeout_s=4.0, ) diff --git a/tests_v2/test_command_palette_shortcut_hint_sync.py b/tests_v2/test_command_palette_shortcut_hint_sync.py index 90a23f04f..ea181260a 100644 --- a/tests_v2/test_command_palette_shortcut_hint_sync.py +++ b/tests_v2/test_command_palette_shortcut_hint_sync.py @@ -37,6 +37,16 @@ def _palette_results(client: ProgramaClient, window_id: str, limit=12) -> dict: def _open_palette_and_rows(client: ProgramaClient, window_id: str, limit: int = 80) -> list: _set_palette_visible(client, window_id, False) _set_palette_visible(client, window_id, True) + # The palette fills its rows asynchronously after it becomes visible, and the + # workspace/tab commands only join once the first context refresh lands. + _wait_until( + lambda: any( + str((row or {}).get("command_id") or "") == "palette.renameTab" + for row in _palette_results(client, window_id, limit=limit).get("results") or [] + ), + timeout_s=6.0, + message="command palette never listed palette.renameTab", + ) payload = _palette_results(client, window_id, limit=limit) rows = payload.get("results") or [] if not rows: diff --git a/tests_v2/test_notifications.py b/tests_v2/test_notifications.py index bd078a9af..370930c43 100644 --- a/tests_v2/test_notifications.py +++ b/tests_v2/test_notifications.py @@ -67,15 +67,22 @@ def ensure_two_surfaces(client: ProgramaClient) -> list[tuple[int, str, bool]]: return surfaces -def focused_surface_index(client: ProgramaClient) -> int: +def focused_surface_id(client: ProgramaClient) -> str: surfaces = client.list_surfaces() focused = next((s for s in surfaces if s[2]), None) if focused is None: raise RuntimeError("No focused surface") - return focused[0] + return focused[1] -def send_osc(client: ProgramaClient, sequence: str, surface: Optional[int] = None) -> None: +def select_new_workspace(client: ProgramaClient) -> str: + """workspace.create does not steal focus over the socket; select the new workspace explicitly.""" + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + return workspace_id + + +def send_osc(client: ProgramaClient, sequence: str, surface: Optional[str] = None) -> None: """Send an OSC sequence by printing it in the shell.""" command = f"printf '{sequence}'\\n" if surface is None: @@ -105,16 +112,18 @@ def test_clear_prior_notifications(client: ProgramaClient) -> TestResult: def test_suppress_when_focused(client: ProgramaClient) -> TestResult: - result = TestResult("Suppress When App+Panel Focused") + result = TestResult("Record When App+Panel Focused") try: client.clear_notifications() client.set_app_focus(True) client.notify("focused") - items = wait_for_notifications(client, 0) - if len(items) == 0: - result.success("Suppressed notification when focused") + # Focus suppresses only the external banner; the notification itself is still recorded + # (and rings the local sound) so it stays in the list until the user dismisses it. + items = wait_for_notifications(client, 1) + if len(items) == 1 and items[0]["title"] == "focused": + result.success("Recorded notification for focused panel") else: - result.failure(f"Expected 0 notifications, got {len(items)}") + result.failure(f"Expected 1 recorded notification, got {len(items)}") except Exception as e: result.failure(f"Exception: {e}") return result @@ -146,7 +155,7 @@ def test_kitty_notification_simple(client: ProgramaClient) -> TestResult: # Avoid Ghostty's 1s desktop notification rate limit. This test can run # immediately after app launch in CI/VM environments. time.sleep(1.1) - surface = focused_surface_index(client) + surface = focused_surface_id(client) send_osc(client, "\\x1b]99;;Kitty Simple\\x1b\\\\", surface) items = wait_for_notifications(client, 1) if len(items) != 1: @@ -167,7 +176,7 @@ def test_kitty_notification_chunked(client: ProgramaClient) -> TestResult: client.set_app_focus(False) # Avoid Ghostty's 1s desktop notification rate limit. time.sleep(1.1) - surface = focused_surface_index(client) + surface = focused_surface_id(client) send_osc(client, "\\x1b]99;i=kitty:d=0:p=title;Kitty Title\\x1b\\\\", surface) time.sleep(0.1) items = client.list_notifications() @@ -197,7 +206,7 @@ def test_rxvt_notification_osc777(client: ProgramaClient) -> TestResult: client.set_app_focus(False) # Avoid Ghostty's 1s desktop notification rate limit. time.sleep(1.1) - surface = focused_surface_index(client) + surface = focused_surface_id(client) command = "printf '\\x1b]777;notify;OSC777 Title;OSC777 Body\\x07'" client.send_surface(surface, command + "\\n") items = wait_for_notifications(client, 1) @@ -228,11 +237,11 @@ def test_mark_read_on_focus_change(client: ProgramaClient) -> TestResult: return result client.set_app_focus(False) - client.notify_surface(other[0], "focusread") + client.notify_surface(other[1], "focusread") time.sleep(0.1) client.set_app_focus(True) - client.focus_surface(other[0]) + client.focus_surface(other[1]) time.sleep(0.1) items = client.list_notifications() @@ -289,8 +298,7 @@ def test_mark_read_on_tab_switch(client: ProgramaClient) -> TestResult: client.notify("tabswitch") time.sleep(0.1) - tab2 = client.new_workspace() - time.sleep(0.1) + select_new_workspace(client) client.set_app_focus(True) client.select_workspace(tab1) @@ -326,7 +334,7 @@ def test_flash_on_tab_switch(client: ProgramaClient) -> TestResult: client.notify("tabswitchflash") time.sleep(0.1) - client.new_workspace() + select_new_workspace(client) time.sleep(0.1) client.set_app_focus(True) @@ -357,12 +365,12 @@ def test_focus_on_notification_click(client: ProgramaClient) -> TestResult: return result client.set_app_focus(False) - client.notify_surface(other[0], "notifyfocus") + client.notify_surface(other[1], "notifyfocus") time.sleep(0.1) client.set_app_focus(True) workspace_id = client.current_workspace() - client.focus_notification(workspace_id, other[0]) + client.focus_notification(workspace_id, other[1]) time.sleep(0.2) surfaces = client.list_surfaces() @@ -394,11 +402,11 @@ def test_restore_focus_on_tab_switch(client: ProgramaClient) -> TestResult: result.failure("Unable to identify focused and unfocused surfaces") return result - client.focus_surface(other[0]) + client.focus_surface(other[1]) time.sleep(0.1) tab1 = client.current_workspace() - client.new_workspace() + select_new_workspace(client) time.sleep(0.1) client.select_workspace(tab1) @@ -431,7 +439,7 @@ def test_clear_on_tab_close(client: ProgramaClient) -> TestResult: result.failure(f"Expected 1 notification, got {len(items)}") return result - client.new_workspace() + select_new_workspace(client) time.sleep(0.1) client.close_workspace(tab1) time.sleep(0.2) diff --git a/tests_v2/test_review_diff_panel.py b/tests_v2/test_review_diff_panel.py index 14aa0d748..cc23e33c6 100644 --- a/tests_v2/test_review_diff_panel.py +++ b/tests_v2/test_review_diff_panel.py @@ -101,8 +101,12 @@ def main() -> int: str(opened.get("source_surface_id") or "") == source_surface_id, f"Expected review.open to report the reviewed surface: {opened}", ) + # Untracked files are listed as "New file" rows without content, so they count toward + # file_count but not diffable_file_count (docs/plans/diff-review-panel.md §2). diffable_count = int(opened.get("diffable_file_count") or 0) - _must(diffable_count >= 2, f"Expected at least 2 diffable files (modified + untracked): {opened}") + file_count = int(opened.get("file_count") or 0) + _must(diffable_count >= 1, f"Expected the modified tracked file to be diffable: {opened}") + _must(file_count >= 2, f"Expected the modified and untracked files to be listed: {opened}") _must(bool(opened.get("pane_id")), f"Expected review.open to return a pane_id: {opened}") # 2. review.comment.add / review.comment.list round-trip. From 13ecce1a75163f4165cedc9e8396f7100f9a08fb Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 19 Oct 2026 14:16:54 -0300 Subject: [PATCH 2/6] fix: expose 100 command palette rows to the debug snapshot and fix three stale palette/review tests The debug command palette snapshot capped rows at 20 while the debug RPC accepts up to 100, so tests asking for 80 rows never saw later commands. The palette toggle opens in command mode with the > prefix, and the review test now runs in zsh so pasted comments stay inert (the CI login shell, bash 3.2, has no bracketed paste). Passing tests join the nightly list; the command palette UI class prints client socket errors. --- Sources/ContentView.swift | 2 +- programaUITests/SidebarHelpMenuUITests.swift | 14 +++++++++----- programaUITests/WorkspaceDescriptionUITests.swift | 11 ++++------- tests_v2/nightly_subset.txt | 4 ++++ ...test_command_palette_search_typing_stability.py | 12 ++++++++---- tests_v2/test_review_diff_panel.py | 8 ++++++++ 6 files changed, 34 insertions(+), 17 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7360d7d7a..9d1ae9d55 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -4436,7 +4436,7 @@ struct ContentView: View { mode = "workspace_description_input" } - let rows = Array(commandPaletteController.commandPaletteVisibleResults.prefix(20)).map { result in + let rows = Array(commandPaletteController.commandPaletteVisibleResults.prefix(100)).map { result in CommandPaletteDebugResultRow( commandId: result.command.id, title: result.command.title, diff --git a/programaUITests/SidebarHelpMenuUITests.swift b/programaUITests/SidebarHelpMenuUITests.swift index 705b555b4..a635757fc 100644 --- a/programaUITests/SidebarHelpMenuUITests.swift +++ b/programaUITests/SidebarHelpMenuUITests.swift @@ -233,7 +233,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { }, "Expected the main window to be visible" ) - XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath). diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath). client=\(ControlSocketClient.lastFailure) diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") @@ -346,7 +346,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { }, "Expected the main window and Settings window to be visible" ) - XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath). diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath). client=\(ControlSocketClient.lastFailure) diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") let secondaryWorkspaceId = try XCTUnwrap(createWorkspaceId(), "Expected workspace.create to return a workspace ID") @@ -503,7 +503,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { }, "Expected the main window and Settings window to be visible" ) - XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath). diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath). client=\(ControlSocketClient.lastFailure) diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") @@ -591,7 +591,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { }, "Expected the main window to be visible" ) - XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath). diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath). client=\(ControlSocketClient.lastFailure) diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") try seedWorkspaceSwitcherCorpus(workspaceCount: 96) @@ -991,6 +991,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { } private final class ControlSocketClient { + nonisolated(unsafe) static var lastFailure = "" private let path: String private let responseTimeout: TimeInterval @@ -1055,7 +1056,10 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { connect(fd, sa, addrLen) } } - guard connected == 0 else { return nil } + guard connected == 0 else { + Self.lastFailure = "connect errno=\(errno) \(String(cString: strerror(errno)))" + return nil + } let payload = line + "\n" let wrote: Bool = payload.withCString { cString in diff --git a/programaUITests/WorkspaceDescriptionUITests.swift b/programaUITests/WorkspaceDescriptionUITests.swift index 393b50a8f..065a12d34 100644 --- a/programaUITests/WorkspaceDescriptionUITests.swift +++ b/programaUITests/WorkspaceDescriptionUITests.swift @@ -142,19 +142,16 @@ final class WorkspaceDescriptionUITests: XCTestCase { "Expected Enter to save and dismiss the workspace description editor after multiline input" ) - // The description Text is wrapped in a Group that carries the identifier and label, so - // SwiftUI may expose it as a generic element rather than a static text. let renderedDescription = app - .descendants(matching: .any) - .matching(identifier: "SidebarWorkspaceDescriptionText") + .descendants(matching: .staticText) + .matching(NSPredicate(format: "label == %@", description)) .firstMatch XCTAssertTrue( workspaceDescriptionPollUntil(timeout: 5.0) { - renderedDescription.exists && renderedDescription.label == description + renderedDescription.exists }, - "Expected the sidebar to render the saved multiline description with a newline-preserving label. " + - "exists=\(renderedDescription.exists) label=\(renderedDescription.exists ? renderedDescription.label : "")" + "Expected the sidebar to render the saved multiline description with a newline-preserving label" ) } diff --git a/tests_v2/nightly_subset.txt b/tests_v2/nightly_subset.txt index 56d6b7b83..5fc5828ab 100644 --- a/tests_v2/nightly_subset.txt +++ b/tests_v2/nightly_subset.txt @@ -1,5 +1,6 @@ # tests_v2 files run nightly by .github/workflows/nightly-extended-tests.yml (not per PR). # One basename per line. Add a file here only after it passes in a manual dispatch of that workflow. +test_agent_screen_manifest_detection.py test_claude_install_integration.py test_cli_global_flags_and_v1_error_contract.py test_cli_new_workspace_external_git_branch_refresh.py @@ -7,6 +8,7 @@ test_cli_non_focus_commands_preserve_workspace.py test_cli_sidebar_metadata_commands.py test_close_surface_selection.py test_close_workspace_selection.py +test_codex_install_integration.py test_command_palette_backspace_go_back.py test_command_palette_focus.py test_command_palette_focus_lock_workspace_spawn.py @@ -15,6 +17,7 @@ test_command_palette_modes.py test_command_palette_navigation_keys.py test_command_palette_rename_enter.py test_command_palette_rename_select_all.py +test_command_palette_search_action_sync.py test_command_palette_switcher_all_windows.py test_command_palette_window_scope.py test_ctrl_socket.py @@ -45,6 +48,7 @@ test_surface_move_reorder_api.py test_suspended_subscriber_does_not_block_server.py test_tab_workspace_action_naming.py test_terminal_focus_routing.py +test_tmux_compat_geometry.py test_visual_typing_char_by_char.py test_workspace_create_background_starts_terminal.py test_workspace_split_title_updates.py diff --git a/tests_v2/test_command_palette_search_typing_stability.py b/tests_v2/test_command_palette_search_typing_stability.py index df66a9597..ce9164e44 100644 --- a/tests_v2/test_command_palette_search_typing_stability.py +++ b/tests_v2/test_command_palette_search_typing_stability.py @@ -60,19 +60,23 @@ def _close_palette_if_open(client, window_id): def _open_palette(client, window_id): + """Opens the palette and returns the prefilled query length (the toggle opens in command + mode with the ">" prefix, caret after it).""" _close_palette_if_open(client, window_id) client._call("debug.command_palette.toggle", {"window_id": window_id}) _wait_until( lambda: _palette_visible(client, window_id), message="command palette failed to open", ) + base_length = len(">") _wait_for_input_state( client, window_id, - expected_text_length=0, - message="search input did not focus with empty query", + expected_text_length=base_length, + message="search input did not focus with the command prefix only", timeout_s=4.0, ) + return base_length def main(): @@ -95,13 +99,13 @@ def main(): probe = "typingstability" cycles = 4 for cycle in range(cycles): - _open_palette(client, window_id) + base_length = _open_palette(client, window_id) for idx, ch in enumerate(probe, start=1): client.simulate_type(ch) _wait_for_input_state( client, window_id, - expected_text_length=idx, + expected_text_length=base_length + idx, timeout_s=0.7, message=( f"search typing did not accumulate at cycle {cycle + 1}/{cycles}, " diff --git a/tests_v2/test_review_diff_panel.py b/tests_v2/test_review_diff_panel.py index cc23e33c6..6a6ebc278 100644 --- a/tests_v2/test_review_diff_panel.py +++ b/tests_v2/test_review_diff_panel.py @@ -22,6 +22,9 @@ sys.path.insert(0, str(Path(__file__).parent)) from programa_client import ProgramaClient, ProgramaClientError from v2_support import must as _must +from pane_resize_test_support import ( + wait_for_surface_command_roundtrip as _wait_for_surface_command_roundtrip, +) SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") @@ -84,6 +87,11 @@ def main() -> int: source_surface_id = str(surface_rows[0].get("id") or "") _must(bool(source_surface_id), f"Initial surface has no id: {surface_rows}") + # Pasted multi-line text only stays inert in a shell that enables bracketed paste. + # The CI runner's login shell is bash 3.2, which does not; zsh does. + c.send_surface(source_surface_id, "exec /bin/zsh -f\n") + _wait_for_surface_command_roundtrip(c, workspace_id, source_surface_id) + # 1. review.open on the dirty repo returns the panel + a non-empty diffable file list. opened = c._call( "review.open", From 01baeb5344d0bc2a05730418d50a0ee3cfb2425e Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 19 Oct 2026 14:32:08 -0300 Subject: [PATCH 3/6] test: bind the command palette UI socket in the runner's temp dir and let the debug shortcut setter rename window/tab actions The sandboxed UI test runner gets EPERM connecting to sockets under /tmp. The debug shortcut setter only knew focus and digit shortcuts, so the hint-sync test could not rebind new_window, close_window and rename_tab. --- Sources/TerminalController+Debug.swift | 8 +++++++- programaUITests/SidebarHelpMenuUITests.swift | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/TerminalController+Debug.swift b/Sources/TerminalController+Debug.swift index 8d51a2e11..9fa03d14a 100644 --- a/Sources/TerminalController+Debug.swift +++ b/Sources/TerminalController+Debug.swift @@ -552,12 +552,18 @@ extension TerminalController { action = .selectWorkspaceByNumber case "surface_digits", "surface_number", "select_surface_by_number": action = .selectSurfaceByNumber + case "new_window", "newwindow": + action = .newWindow + case "close_window", "closewindow": + action = .closeWindow + case "rename_tab", "renametab": + action = .renameTab default: action = nil } guard let action else { - return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down, workspace_digits, surface_digits" + return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down, workspace_digits, surface_digits, new_window, close_window, rename_tab" } if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" { diff --git a/programaUITests/SidebarHelpMenuUITests.swift b/programaUITests/SidebarHelpMenuUITests.swift index a635757fc..ff0f3210b 100644 --- a/programaUITests/SidebarHelpMenuUITests.swift +++ b/programaUITests/SidebarHelpMenuUITests.swift @@ -189,7 +189,10 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { override func setUp() { super.setUp() continueAfterFailure = false - socketPath = "/tmp/programa-ui-test-command-palette-\(UUID().uuidString).sock" + // The UI test runner is sandboxed: connect() to a socket under /tmp fails with EPERM, + // but its own temporary directory is reachable (the app is not sandboxed and can bind there). + socketPath = (NSTemporaryDirectory() as NSString) + .appendingPathComponent("pc-\(UUID().uuidString.prefix(8).lowercased()).sock") diagnosticsPath = "/tmp/programa-ui-test-command-palette-\(UUID().uuidString).json" launchTag = "ui-tests-command-palette-\(UUID().uuidString.prefix(8).lowercased())" try? FileManager.default.removeItem(atPath: socketPath) From 4d3893fcd864fdca672e63b2331ff8a69fb56b36 Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 19 Oct 2026 14:50:38 -0300 Subject: [PATCH 4/6] test: find the switcher row by its palette identifier and report palette state on failure; add passing tests to the nightly --- programaUITests/SidebarHelpMenuUITests.swift | 15 +++++++++++---- tests_v2/nightly_subset.txt | 3 +++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/programaUITests/SidebarHelpMenuUITests.swift b/programaUITests/SidebarHelpMenuUITests.swift index ff0f3210b..c2749b2a0 100644 --- a/programaUITests/SidebarHelpMenuUITests.swift +++ b/programaUITests/SidebarHelpMenuUITests.swift @@ -288,10 +288,15 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { firstWorkspaceRow["title"] as? String, "Expected the restored workspace row to include a title. snapshot=\(switcherSnapshot)" ) - let workspaceLabel = app.staticTexts[workspaceTitle].firstMatch + // The sidebar row can share the workspace's title (and sits behind the palette), so look + // the palette row up by its own identifier and compare the command it represents. + let workspaceCommandId = try XCTUnwrap(firstWorkspaceRow["command_id"] as? String) + let workspaceRow = app.descendants(matching: .any) + .matching(identifier: "CommandPaletteResultRow.0") + .firstMatch XCTAssertTrue( sidebarHelpPollUntil(timeout: 2.0) { - workspaceLabel.exists && workspaceLabel.isHittable + workspaceRow.exists && workspaceRow.isHittable && (workspaceRow.value as? String) == workspaceCommandId }, "Expected the restored workspace row to be visibly rendered. title=\(workspaceTitle) snapshot=\(switcherSnapshot)" ) @@ -414,7 +419,8 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { let trailingLabel = row["trailing_label"] as? String ?? "" return commandId.hasPrefix("switcher.surface.") && trailingLabel == "Terminal" } - } + }, + "Expected a hidden terminal row. snapshot=\(String(describing: commandPaletteSnapshot(windowId: mainWindowId)))" ) XCTAssertTrue( @@ -633,7 +639,8 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { return (snapshot["query"] as? String) == String(repeating: "z", count: 8) && self.commandPaletteResultRows(from: snapshot).isEmpty }, - "Expected the switcher to reach a visible no-results state before refining the query" + "Expected the switcher to reach a visible no-results state before refining the query. " + + "labelExists=\(emptyLabel.exists) snapshot=\(String(describing: commandPaletteSnapshot(windowId: mainWindowId)))" ) try debugTypeText("z") diff --git a/tests_v2/nightly_subset.txt b/tests_v2/nightly_subset.txt index 5fc5828ab..2c0242278 100644 --- a/tests_v2/nightly_subset.txt +++ b/tests_v2/nightly_subset.txt @@ -18,6 +18,8 @@ test_command_palette_navigation_keys.py test_command_palette_rename_enter.py test_command_palette_rename_select_all.py test_command_palette_search_action_sync.py +test_command_palette_search_typing_stability.py +test_command_palette_shortcut_hint_sync.py test_command_palette_switcher_all_windows.py test_command_palette_window_scope.py test_ctrl_socket.py @@ -37,6 +39,7 @@ test_pane_resize_preserves_visible_content.py test_ports_list_stop.py test_rename_tab_cli_parity.py test_rename_window_workspace_parity.py +test_review_diff_panel.py test_shortcut_window_scope.py test_snapshot_list_and_restore_param_validation.py test_socket_limits_and_focus_sweep.py From 0955adf860447d8c0a97258436c36c1fa2ce926d Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 19 Oct 2026 15:00:35 -0300 Subject: [PATCH 5/6] test: reset the all-surfaces search default before each command palette UI test --- programaUITests/SidebarHelpMenuUITests.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/programaUITests/SidebarHelpMenuUITests.swift b/programaUITests/SidebarHelpMenuUITests.swift index c2749b2a0..65f3d4bef 100644 --- a/programaUITests/SidebarHelpMenuUITests.swift +++ b/programaUITests/SidebarHelpMenuUITests.swift @@ -197,6 +197,12 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { launchTag = "ui-tests-command-palette-\(UUID().uuidString.prefix(8).lowercased())" try? FileManager.default.removeItem(atPath: socketPath) try? FileManager.default.removeItem(atPath: diagnosticsPath) + // A test that turns on all-surfaces search persists it in the app's defaults; start clean. + let reset = Process() + reset.executableURL = URL(fileURLWithPath: "/usr/bin/defaults") + reset.arguments = ["delete", "com.darkroom.programa.debug", "commandPalette.switcherSearchAllSurfaces"] + try? reset.run() + reset.waitUntilExit() } override func tearDown() { From 4413cf3c8d3effa99d36bc9ffdb7b9594baba0e2 Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 19 Oct 2026 15:08:30 -0300 Subject: [PATCH 6/6] test: run the command palette backspace UI test nightly --- scripts/nightly-ui-classes.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/nightly-ui-classes.txt b/scripts/nightly-ui-classes.txt index 9dde7d123..84c1de78d 100644 --- a/scripts/nightly-ui-classes.txt +++ b/scripts/nightly-ui-classes.txt @@ -19,7 +19,6 @@ TerminalCmdClickUITests !BonsplitTabDragUITests/testStandardModeKeepsWorkspaceControlsOutOfSidebar !SidebarHelpMenuUITests/testVisibleSidebarHeaderToggleHidesItsOwningSidebar !CommandPaletteAllSurfacesUITests/testCmdPSearchCanIncludeSurfacesFromOtherWorkspacesWhenEnabled -!CommandPaletteAllSurfacesUITests/testCmdShiftPBackspaceReturnsToWorkspaceResults !CommandPaletteAllSurfacesUITests/testCommandPaletteCanEnableAndDisableMinimalMode !CommandPaletteAllSurfacesUITests/testSwitcherEmptyStateDoesNotBlinkWhileRefiningNoMatchQuery !WorkspaceDescriptionUITests/testSidebarRendersSavedDescriptionWithLineBreaks