From 73105255a49eeeb9c333457f29bfcfa00bee156f Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 20 Oct 2026 14:01:28 -0300 Subject: [PATCH 1/2] test: cover the single-pane tab strip rule and hidden-strip controls --- programaTests/WindowAndDragTests.swift | 165 +++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/programaTests/WindowAndDragTests.swift b/programaTests/WindowAndDragTests.swift index 0af2129d8..5e0b71250 100644 --- a/programaTests/WindowAndDragTests.swift +++ b/programaTests/WindowAndDragTests.swift @@ -276,6 +276,171 @@ final class WindowGlassEffectTests: XCTestCase { throw XCTSkip("Native Liquid Glass requires the macOS 26 SDK and runtime") } + /// A focused pane whose strip is hidden must hide the new-tab and split controls. Lending + /// them to another visible pane would make them create tabs and splits in a pane the user + /// is not working in. + func testFocusedPaneWithHiddenStripHidesControlsInsteadOfLendingThem() throws { + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + _ = NSApplication.shared + let root = NSView(frame: NSRect(x: 0, y: 0, width: 640, height: 320)) + let terminalHost = WindowTerminalHostView(frame: root.bounds) + let focusedAnchor = NSView(frame: NSRect(x: 0, y: 270, width: 300, height: 40)) + let otherAnchor = NSView(frame: NSRect(x: 320, y: 270, width: 300, height: 40)) + root.addSubview(focusedAnchor) + root.addSubview(otherAnchor) + root.addSubview(terminalHost, positioned: .above, relativeTo: otherAnchor) + let window = NSWindow( + contentRect: root.bounds, + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + window.contentView = root + + let bridge = WindowPaneChromePortalRegistry.bridge(for: window) + let focusedPane = PaneID() + let otherPane = PaneID() + var newTabPanes: [PaneID] = [] + func publish(_ paneID: PaneID, anchor: NSView, focused: Bool, visible: Bool) { + let tabs = [BonsplitPaneChromeTabDescriptor( + id: TabID(), title: "Tab", icon: "terminal", iconImageData: nil, + isSelected: true, isPinned: false, isDirty: false, + showsNotificationBadge: false, accessibilityValue: "", menuItems: [] + )] + bridge.updatePaneChrome(BonsplitPaneChromeDescriptor( + paneID: paneID, anchorView: anchor, tabs: tabs, isFocused: focused, + isVisible: visible, leadingInset: 0, showsSplitButtons: true, + onSelect: { _ in }, onClose: { _ in }, onContextAction: { _, _ in }, + dragPasteboardData: { _ in nil }, onDragStateChanged: { _, _ in }, + validatedDropIndex: { _ in nil }, onDropTab: { _ in false }, + onNewTab: { newTabPanes.append(paneID) }, onSplitRight: {}, onSplitDown: {} + )) + root.layoutSubtreeIfNeeded() + } + // The clusters' glass carries a plain button container; tab pills carry an NSControl. + func visibleControlGlass() -> [NSGlassEffectView] { + bridge.nativeGlassViewsForTesting().filter { + !($0.contentView is NSControl) && !$0.isHiddenOrHasHiddenAncestor + } + } + + publish(otherPane, anchor: otherAnchor, focused: false, visible: true) + publish(focusedPane, anchor: focusedAnchor, focused: true, visible: false) + XCTAssertTrue( + visibleControlGlass().isEmpty, + "Controls must hide with the focused pane's strip, not move to the unfocused pane" + ) + + publish(focusedPane, anchor: focusedAnchor, focused: true, visible: true) + let shown = visibleControlGlass() + XCTAssertEqual(shown.count, 2, "New-tab and split controls return with the focused strip") + for glass in shown { + let frame = bridge.hostViewForTesting.convert(glass.bounds, from: glass) + XCTAssertLessThanOrEqual(frame.maxX, focusedAnchor.frame.maxX + 0.5, + "Controls sit on the focused pane's strip") + } + return + } + #endif + + throw XCTSkip("Native Liquid Glass requires the macOS 26 SDK and runtime") + } + + /// The only pane of a workspace hides its tab strip while it has one tab (nothing to switch + /// between) and shows it again at two tabs. Once split, every pane keeps its strip because it + /// carries the controls that undo the split. A strip that doubles as the titlebar row + /// (nonzero leading inset) stays so the window remains draggable beside the traffic lights. + func testSinglePaneTabStripHidesAtOneTabAndReturnsAtTwoOrAfterSplit() throws { + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + @MainActor final class RecordingBridge: BonsplitPaneChromePortalBridge { + let supportsNativePaneChrome = true + var latest: [PaneID: BonsplitPaneChromeDescriptor] = [:] + func updatePaneChrome(_ descriptor: BonsplitPaneChromeDescriptor) { + latest[descriptor.paneID] = descriptor + } + func removePaneChrome(for paneID: PaneID, anchorView: NSView) { + if latest[paneID]?.anchorView === anchorView { latest[paneID] = nil } + } + } + + _ = NSApplication.shared + func host(leadingInset: CGFloat) -> (BonsplitController, RecordingBridge, NSWindow) { + var configuration = BonsplitConfiguration.default + configuration.appearance.tabBarLiquidGlassEnabled = true + configuration.appearance.tabBarLeadingInset = leadingInset + let controller = BonsplitController(configuration: configuration) + let bridge = RecordingBridge() + controller.setPaneChromePortalBridge(bridge) + let hostingView = NSHostingView( + rootView: BonsplitView(controller: controller) { _, _ in Color.clear } + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 500), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.contentView = hostingView + hostingView.frame = window.contentLayoutRect + return (controller, bridge, window) + } + func settle(until condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(2) + while Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) + if condition() { return true } + } + return condition() + } + + let (controller, bridge, window) = host(leadingInset: 0) + defer { window.orderOut(nil) } + XCTAssertTrue(controller.isInteractive) + let pane = try XCTUnwrap(controller.allPaneIds.first) + XCTAssertEqual(controller.tabs(inPane: pane).count, 1) + XCTAssertTrue( + settle { bridge.latest[pane]?.isVisible == false }, + "A lone pane with one tab hides its strip; got isVisible=\(String(describing: bridge.latest[pane]?.isVisible))" + ) + + let second = try XCTUnwrap(controller.createTab(title: "Second", inPane: pane)) + XCTAssertTrue(settle { bridge.latest[pane]?.isVisible == true }, + "The strip returns when the pane gets a second tab") + XCTAssertEqual(bridge.latest[pane]?.tabs.count, 2) + + XCTAssertTrue(controller.closeTab(second, inPane: pane)) + XCTAssertTrue(settle { bridge.latest[pane]?.isVisible == false }, + "Back to one tab hides the strip again") + + let newPane = try XCTUnwrap(controller.splitPane(pane, orientation: .horizontal)) + XCTAssertNotNil(controller.createTab(title: "Right", inPane: newPane)) + XCTAssertTrue( + settle { + controller.tabs(inPane: newPane).count == 1 + && bridge.latest[pane]?.isVisible == true + && bridge.latest[newPane]?.isVisible == true + }, + "After a split, each single-tab pane keeps its strip and the controls that undo the split" + ) + + let (insetController, insetBridge, insetWindow) = host(leadingInset: 80) + defer { insetWindow.orderOut(nil) } + let insetPane = try XCTUnwrap(insetController.allPaneIds.first) + XCTAssertEqual(insetController.tabs(inPane: insetPane).count, 1) + XCTAssertTrue( + settle { insetBridge.latest[insetPane]?.isVisible == true }, + "A strip that doubles as the titlebar row stays at one tab" + ) + return + } + #endif + + throw XCTSkip("Native Liquid Glass requires the macOS 26 SDK and runtime") + } + func testNativePaneTabInsertionIndicatorStaysInsideDocumentBounds() throws { #if compiler(>=6.2) let bounds = NSRect(x: 0, y: 0, width: 240, height: 28) From 8885bfe274a2bda428c0198d967086bcaa1bd7a2 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 20 Oct 2026 14:01:28 -0300 Subject: [PATCH 2/2] test: measure idle CPU of a focused quiet terminal nightly --- .github/workflows/nightly-extended-tests.yml | 76 ++++++++++ tests_v2/test_idle_cpu.py | 137 +++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 tests_v2/test_idle_cpu.py diff --git a/.github/workflows/nightly-extended-tests.yml b/.github/workflows/nightly-extended-tests.yml index aea0073d7..08f6e1914 100644 --- a/.github/workflows/nightly-extended-tests.yml +++ b/.github/workflows/nightly-extended-tests.yml @@ -4,6 +4,7 @@ name: Extended tests # gate every PR, run nightly against main: # - the tests_v2 files listed in tests_v2/nightly_subset.txt # - the XCUITest classes listed in scripts/nightly-ui-classes.txt +# - tests_v2/test_idle_cpu.py, the idle CPU budget of one focused quiet terminal # Failures here do not block merges; read the job summaries each morning. # # On-demand runs take explicit lists, which is also how a new test is triaged @@ -39,6 +40,10 @@ on: description: Run the UI test job type: boolean default: true + run_idle_cpu: + description: Run the idle CPU measurement job + type: boolean + default: true v2_attempts: description: Attempts per tests_v2 file; a pass on a later attempt is reported as FLAKY type: string @@ -288,6 +293,77 @@ jobs: fi rm -f /tmp/create-virtual-display + # Its own job, not a nightly_subset.txt line: a measurement must run once, alone on + # the runner, with no retry that would hide a slow first attempt. + # Informational while the budget is calibrated against runner numbers (Debug build, + # shared runner); drop continue-on-error once a few nights show a stable range. + idle-cpu: + needs: build-app + if: github.event_name != 'workflow_dispatch' || inputs.run_idle_cpu + runs-on: macos-26 + timeout-minutes: 20 + continue-on-error: true + env: + PROGRAMA_DERIVED_DATA_DIR: ${{ github.workspace }}/.ci-derived-data-idle-cpu + # Lets the app honor the socket path the runner script hands it in any build. + PROGRAMA_ALLOW_SOCKET_OVERRIDE: "1" + PROGRAMA_IDLE_CPU_BUDGET_PCT: "8.0" + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Download build artifact + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: programa-app-build + path: . + + - name: Extract build artifact + run: | + set -euo pipefail + mkdir -p "$PROGRAMA_DERIVED_DATA_DIR" + tar -xzf app-build.tar.gz -C "$PROGRAMA_DERIVED_DATA_DIR" + + - name: Create virtual display + run: | + set -euo pipefail + clang -framework Foundation -framework CoreGraphics \ + -o /tmp/create-virtual-display scripts/create-virtual-display.m + /tmp/create-virtual-display & + VDISPLAY_PID=$! + echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV" + sleep 3 + kill -0 "$VDISPLAY_PID" + + - name: Measure idle CPU + env: + PROGRAMA_TESTS_V2_SUBSET_FILE: ${{ runner.temp }}/idle-cpu-subset.txt + PROGRAMA_TESTS_V2_ATTEMPTS: "1" + PROGRAMA_TESTS_V2_TEST_TIMEOUT: "180" + PROGRAMA_IDLE_CPU_RESULT_FILE: ${{ runner.temp }}/idle-cpu-result.txt + run: | + set -euo pipefail + echo "test_idle_cpu.py" > "$PROGRAMA_TESTS_V2_SUBSET_FILE" + ./scripts/run-tests-v2-ci.sh + + - name: Summarize measurement + if: always() + run: | + { + echo "### Idle CPU (focused quiet terminal, Debug build)" + echo '```' + cat "$RUNNER_TEMP/idle-cpu-result.txt" 2>/dev/null || echo "no measurement recorded" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Cleanup virtual display + if: always() + run: | + if [ -n "${VDISPLAY_PID:-}" ]; then + kill "$VDISPLAY_PID" >/dev/null 2>&1 || true + fi + rm -f /tmp/create-virtual-display + ui-tests: needs: build-app if: github.event_name != 'workflow_dispatch' || inputs.run_ui diff --git a/tests_v2/test_idle_cpu.py b/tests_v2/test_idle_cpu.py new file mode 100644 index 000000000..02238eb53 --- /dev/null +++ b/tests_v2/test_idle_cpu.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +Idle CPU budget: one focused, visible terminal at a quiet prompt. + +Why: a focused terminal with nothing to draw must not keep the app busy. The +ghostty fork parks the display link after 250 ms with nothing to draw (PR #430); +before that, a focused quiet terminal woke on every screen refresh. Runaway +animations, polling timers and unpaused TimelineViews show up here too. + +What it measures: the app process's CPU time over a fixed window +(`ps -o time=` delta / wall delta, via test_cpu_occlusion_throttle.sample_cpu), +not `ps -o %cpu`, which on macOS is a decayed average since launch. + +Exit codes: 0 within budget, 1 over budget, 2 harness error (the scenario could +not be set up, so no number was measured). The CI job is informational +(continue-on-error) until the budget is calibrated on runner numbers. + +Environment: + PROGRAMA_SOCKET_PATH tagged dev socket (required) + PROGRAMA_IDLE_CPU_BUDGET_PCT budget for app CPU %, default 8.0 + PROGRAMA_IDLE_CPU_DURATION_S sample window, default 30 + PROGRAMA_IDLE_CPU_SETTLE_S quiet time before sampling, default 5 + PROGRAMA_IDLE_CPU_RESULT_FILE receives the result line for the CI job summary +""" + +from __future__ import annotations + +import os +import sys +import time +from typing import Optional + +_TESTS_V2_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, _TESTS_V2_DIR) +from programa_client import ProgramaClient, ProgramaClientError # noqa: E402 +from test_cpu_occlusion_throttle import ( # noqa: E402 + resolve_app_pid, + resolve_target_socket, + sample_cpu, +) + +BUDGET_PCT = float(os.environ.get("PROGRAMA_IDLE_CPU_BUDGET_PCT", "8.0")) +DURATION_S = float(os.environ.get("PROGRAMA_IDLE_CPU_DURATION_S", "30.0")) +SETTLE_S = float(os.environ.get("PROGRAMA_IDLE_CPU_SETTLE_S", "5.0")) +RESULT_FILE = os.environ.get("PROGRAMA_IDLE_CPU_RESULT_FILE", "") + +FOCUS_KEYS = ("inWindow", "windowIsKey", "windowOcclusionVisible", "appIsActive", "isActive", "isFirstResponder") + + +def write_result(line: str) -> None: + print(line) + if RESULT_FILE: + with open(RESULT_FILE, "a", encoding="utf-8") as fh: + fh.write(line + "\n") + + +def focus_state(client: ProgramaClient, surface_id: str) -> dict: + stats = client.render_stats(surface_id) + return {key: bool(stats.get(key)) for key in FOCUS_KEYS} + + +def is_focused_and_visible(state: dict) -> bool: + return state["inWindow"] and state["windowIsKey"] and state["windowOcclusionVisible"] and state["isActive"] + + +def prepare_focused_terminal(client: ProgramaClient) -> tuple[str, dict]: + """Leave exactly one focused, visible terminal surface in the selected workspace.""" + surfaces = client.list_surfaces() + if len(surfaces) != 1: + raise ProgramaClientError(f"expected one surface in the selected workspace, found {len(surfaces)}") + surface_id = surfaces[0][1] + + state: dict = {} + deadline = time.time() + 10.0 + while time.time() < deadline: + client.activate_app() + client.focus_surface(surface_id) + time.sleep(0.25) + state = focus_state(client, surface_id) + if is_focused_and_visible(state): + break + return surface_id, state + + +def main() -> int: + print("=" * 64) + print(f"Idle CPU: focused quiet terminal, budget {BUDGET_PCT:.1f}% app CPU") + print("=" * 64) + + client: Optional[ProgramaClient] = None + try: + socket_path = resolve_target_socket(None) + app_pid = resolve_app_pid(socket_path, None) + client = ProgramaClient(socket_path=socket_path) + client.connect() + + surface_id, state = prepare_focused_terminal(client) + # An unfocused or occluded terminal never runs the display link, so its number + # says nothing about the focused-terminal idle path; label it rather than pass it. + scenario = "focused" if is_focused_and_visible(state) else "unfocused" + print(f"app pid {app_pid}, surface {surface_id}, scenario {scenario}, state {state}") + + # Close the socket so this connection adds no wakeups to the window. + client.close() + client = None + + time.sleep(SETTLE_S) + sample = sample_cpu(app_pid, DURATION_S) + + client = ProgramaClient(socket_path=socket_path) + client.connect() + if not client.ping(): + raise ProgramaClientError("app stopped answering the socket during the sample window") + after = focus_state(client, surface_id) + if scenario == "focused" and not is_focused_and_visible(after): + scenario = "focus-lost" + except ProgramaClientError as e: + write_result(f"IDLE_CPU_ERROR {e}") + return 2 + finally: + if client is not None: + client.close() + + within = sample.app_cpu_pct <= BUDGET_PCT + write_result( + f"IDLE_CPU_APP_PCT={sample.app_cpu_pct:.2f} budget={BUDGET_PCT:.1f} " + f"result={'PASS' if within else 'OVER'} scenario={scenario} " + f"window_s={sample.wall_s:.1f} total_cpu_pct={sample.total_cpu_pct:.2f} " + + " ".join(f"{key}={int(value)}" for key, value in state.items()) + ) + if scenario != "focused": + print("WARN: the terminal was not focused and visible for the whole window; the number does not cover the focused idle path") + return 0 if within else 1 + + +if __name__ == "__main__": + raise SystemExit(main())