diff --git a/CLI/CLI+TmuxCompat.swift b/CLI/CLI+TmuxCompat.swift index 48089684..ddc122d4 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/ContentView.swift b/Sources/ContentView.swift index 7360d7d7..9d1ae9d5 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/Sources/TerminalController+Debug.swift b/Sources/TerminalController+Debug.swift index 8d51a2e1..9fa03d14 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/Sources/TerminalController+Pane.swift b/Sources/TerminalController+Pane.swift index 6ec54c80..c680aa40 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 3a0cd9a0..65f3d4be 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" @@ -187,12 +189,25 @@ 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) + 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() { try? FileManager.default.removeItem(atPath: socketPath) + try? FileManager.default.removeItem(atPath: diagnosticsPath) super.tearDown() } @@ -206,6 +221,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 +242,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). client=\(ControlSocketClient.lastFailure) diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") @@ -274,10 +294,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)" ) @@ -335,7 +360,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). 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") @@ -400,7 +425,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( @@ -492,7 +518,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). client=\(ControlSocketClient.lastFailure) diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") @@ -580,7 +606,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). client=\(ControlSocketClient.lastFailure) diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])") let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") try seedWorkspaceSwitcherCorpus(workspaceCount: 96) @@ -619,7 +645,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") @@ -980,6 +1007,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { } private final class ControlSocketClient { + nonisolated(unsafe) static var lastFailure = "" private let path: String private let responseTimeout: TimeInterval @@ -1044,7 +1072,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/scripts/nightly-ui-classes.txt b/scripts/nightly-ui-classes.txt index 9dde7d12..84c1de78 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 diff --git a/tests_v2/nightly_subset.txt b/tests_v2/nightly_subset.txt index 56d6b7b8..2c024227 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,9 @@ 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_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 @@ -34,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 @@ -45,6 +51,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_agent_screen_manifest_detection.py b/tests_v2/test_agent_screen_manifest_detection.py index 5cef75b4..34b98098 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 06bf7a42..33ad1b83 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 ec2b44a6..2ba8a27e 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 21ddb6f1..ce9164e4 100644 --- a/tests_v2/test_command_palette_search_typing_stability.py +++ b/tests_v2/test_command_palette_search_typing_stability.py @@ -60,18 +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(): @@ -94,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_command_palette_shortcut_hint_sync.py b/tests_v2/test_command_palette_shortcut_hint_sync.py index 90a23f04..ea181260 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 bd078a9a..370930c4 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 14aa0d74..6a6ebc27 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", @@ -101,8 +109,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.