cmux/GhosttyTabsUITests/MenuKeyEquivalentRoutingUITests.swift
Lawrence Chen 50f0dd334d
Fix frozen terminals after split churn (#12)
* Fix blank terminal after split operations and add visual tests

## Blank Terminal Fix
- Add `needsRefreshAfterWindowChange` flag in GhosttyTerminalView
- Force terminal refresh when view is added to window, even if size unchanged
- Add `ghostty_surface_refresh()` call in attachToView for same-view reattachment
- Add debug logging for surface attachment lifecycle (DEBUG builds only)

## Bonsplit Migration
- Add bonsplit as local Swift package (vendor/bonsplit submodule)
- Replace custom SplitTree with BonsplitController
- Add Panel protocol with TerminalPanel and BrowserPanel implementations
- Add SidebarTab as main tab container with BonsplitController
- Remove old Splits/ directory (SplitTree, SplitView, TerminalSplitTreeView)

## Visual Screenshot Tests
- Add test_visual_screenshots.py for automated visual regression testing
- Uses in-app screenshot API (CGWindowListCreateImage) - no screen recording needed
- Generates HTML report with before/after comparisons
- Tests: splits, browser panels, focus switching, close operations, rapid cycles
- Includes annotation fields for easy feedback

## Browser Shortcut (⌘⇧B)
- Add keyboard shortcut to open browser panel in current pane
- Add openBrowser() method to TabManager
- Add shortcut configuration in KeyboardShortcutSettings

## Screenshot Command
- Add 'screenshot' command to TerminalController for in-app window capture
- Returns OK with screenshot ID and path

## Other
- Add tests/visual_output/ and tests/visual_report.html to .gitignore

* Add browser title subscription and set tab height to 30px

- Subscribe to BrowserPanel.$pageTitle changes to update bonsplit tabs
- Update tab titles in real-time as page navigation occurs
- Clean up subscriptions when panels are removed
- Set bonsplit tab bar and tab height to 30px (in submodule)

* Fix socket API regressions in list_surfaces, list_bonsplit_tabs, focus_pane

- list_surfaces: Remove [terminal]/[browser] suffix to keep UUID-only format
  that clients and tests expect for parsing
- list_bonsplit_tabs --pane: Properly look up pane by UUID instead of
  creating a new PaneID (requires bonsplit PaneID.id to be public)
- focus_pane: Accept both UUID strings and integer indices as documented

* Fix browser panel stability and keyboard shortcuts

- Prevent WKWebView focus lifecycle crashes during split/view reshuffles
- Match bracket shortcuts via keyCode (Cmd+Shift+[ / ], Cmd+Ctrl+[ / ])
- Support Ghostty config goto_split:* keybinds when WebView is focused
- Add focus_webview/is_webview_focused socket commands and regression tests
- Rename SidebarTab to Workspace and update docs

* Make ctrl+enter keybind test skippable

Skip when the Ghostty keybind isn't configured or when osascript can't send keystrokes (no Accessibility permission), so VM runs stay green.

* Auto-focus browser omnibar when blank

When a browser surface is focused but no URL is loaded yet, focus the address bar instead of the WKWebView.

* Stabilize socket surface indexing

* Focus browser omnibar escape; add webview keybind UI tests

- Escape in omnibar now returns focus to WKWebView\n- Add UI tests for Cmd+Ctrl+H pane navigation with WebKit focused (including Ghostty config)\n- Avoid flaky element screenshots in UpdatePillUITests on the UTM VM

* Fix browser drag-to-split blanks and socket parsing

* Fix webview-focused shortcuts and stabilize browser splits

- Match ctrl/shift shortcuts by keyCode where needed (Ctrl+H, bracket keys)
- Load Ghostty goto_split triggers reliably and refresh on config load
- Add debug socket helpers: set_shortcut + simulate_shortcut for tests
- Convert browser goto_split/keybind tests to socket-based injection (no osascript)
- Bump bonsplit for drag-to-split fixes

* Fix split layout collapse and harden socket pane APIs

* Stabilize OSC 99 notification test timing

* Fix terminal focus routing after split reparent

* Support simulate_shortcut enter for focus routing test

* Stabilize terminal focus routing test

* Fix frozen new terminal tabs after many splits

* Fix frozen new terminal tabs after splits

* Fix terminal freeze on launch/new tabs

* Update ghostty submodule

* Fix terminal focus/render stalls after split churn

* Fix nested split collapsing existing pane

* Fix nested split collapse + stabilize new-surface focus

* Update bonsplit submodule

* Fix SIGINT test flake

* Remove bonsplit tab-switch crossfade

* Remove PROJECTS.md

* Remove bonsplit tab selection animation

* Ignore generated test reports

* Middle click closes tab

* Revert unintended .gitignore change

* Fix build after main merge

* Revert "Fix build after main merge"

This reverts commit 16bf9816d0856b5385d52f886aa5eb50f3c9d9a4.

* Revert "Merge remote-tracking branch 'origin/main' into fix/blank-terminal-and-visual-tests"

This reverts commit 7c20fb53fd71fea7a19a3673f2dd73e5f0c783c4, reversing
changes made to 0aff107d787bc9d8bbc28220090b4ca7af72e040.

* Remove tab close fade animation

* Use terminal.fill icon

* Make terminal tab icon smaller

* Match browser globe tab icon size

* Bonsplit: tab min width 48 and tighter close button

* Bonsplit: smaller tab title font

* Show unread notification badge in bonsplit tabs and improve UI polish

Sync unread notification state to bonsplit tab badges (blue dot).
Improve EmptyPanelView with Terminal/Browser buttons and shortcut hints.
Add tooltips to close tab button and search overlay buttons.

* Fix reload.sh single-instance safety check on macOS

Replace GNU-only `ps -o etimes=` with portable `ps -o etime=` and
parse the dd-hh:mm:ss format manually for macOS compatibility.

* Centralize keyboard shortcut definitions into Action enum

Replace per-shortcut boilerplate with a single Action enum that holds
the label, defaults key, and default binding for each shortcut. All
call sites now use shortcut(for:). Settings UI is data-driven via
ForEach(Action.allCases). Titlebar tooltips update dynamically when
shortcuts are changed. Remove duplicate .keyboardShortcut() modifiers
from menu items that are already handled by the event monitor.

* Fix WKWebView consuming app menu shortcuts and close panel confirmation

Add CmuxWebView subclass that routes key equivalents through the main
menu before WebKit, so Cmd+N/Cmd+W/tab switching work when a browser
pane is focused. Fix Cmd+W close-panel path: bypass Bonsplit delegate
gating after the user confirms the running-process dialog by tracking
forceCloseTabIds. Add unit tests (CmuxWebViewKeyEquivalentTests) and
UI test scaffolding (MenuKeyEquivalentRoutingUITests) with a new
cmux-unit Xcode scheme.

* Update CLAUDE.md and PROJECTS.md with recent changes

CLAUDE.md: enforce --tag for reload commands, add cleanup safety rules.
PROJECTS.md: log notification badge, reload.sh fix, Cmd+W fix, WebView
key equiv fix, and centralized shortcuts work.

* Keep selection index stable on close

* Add concepts page documenting terminology hierarchy

New docs page explaining Window > Workspace > Pane > Surface > Panel
hierarchy with aligned ASCII diagram. Updated tabs.mdx and splits.mdx
to use consistent terminology (workspace instead of tab, surface
instead of panel) and corrected outdated CLI command references.

* Update bonsplit submodule

* WIP: improve split close stability and UI regressions

* Close terminal panel on child exit; hide terminal dirty dot

* Fix split close/focus regressions and stabilize UI tests

* Add unread Dock/Cmd+Tab badge with settings toggle

* Fix browser-surface shortcuts and Cmd+L browser opening

* Snapshot current workspace state before regression fixes

* Update bonsplit submodule snapshot

* Stabilize split-close regression capture and sidebar resize assertions

* Change default Show Notifications shortcut from Cmd+Shift+I to Cmd+I

* Fix update check readiness race, enable release update logging, and improve checking spinner

* Restore terminal file drop, fix browser omnibar click focus, and add panel workspace ID mutation for surface moves

* Add Cmd+digit workspace hints, titlebar shortcut pills, sidebar drag-reorder, and workspace placement settings

* Add v2 browser automation API, surface move/reorder commands, and short-handle ref system to TerminalController

* Add CLI browser command surface, --id-format flag, and move/reorder commands

* Extend test clients with move/reorder APIs, ref-handle support, and increased timeouts

* Harden test runner scripts with deterministic builds, retry logic, and robust socket readiness

* Stabilize existing test suites with focus-wait helpers, increased timeouts, and API shape updates

* Add terminal file drop e2e regression test

* Add v2 browser API, CLI ref resolution, and surface move/reorder test suites

* Add unit tests for shortcut hints, workspace reorder, drop planner, and update UI test stabilization

* Add cmux-debug-windows skill with snapshot script and agent config

* Update project docs: mark browser parity and move/reorder phases complete, add parallel agent workflow guidelines

* Update bonsplit submodule: re-entrant setPosition guard, tab shortcut hints, and moveTab/reorderTab API

* Add browser agent UX improvements: snapshot refs, placement reuse, diagnostics, and skill docs

- Upgrade browser.snapshot to emit accessibility tree text with element refs (eN)
- Add right-sibling pane reuse policy for browser.open_split placement
- Add rich not_found diagnostics with retry logic for selector actions
- Support --snapshot-after for post-action verification on mutating commands
- Allow browser fill with empty text for clearing inputs
- Default CLI --id-format to refs-first (UUIDs opt-in via --id-format uuids|both)
- Format legacy new-pane/new-surface output with short surface refs
- Add skills/cmuxterm-browser/ and skills/cmuxterm/ end-user skill docs
- Add regression tests for placement policy, snapshot refs, diagnostics, and ID defaults

* Update bonsplit submodule: keep raster favicons in color when inactive
2026-02-13 16:45:31 -08:00

1027 lines
44 KiB
Swift

import XCTest
import Foundation
import CoreGraphics
import ImageIO
import Darwin
final class MenuKeyEquivalentRoutingUITests: XCTestCase {
private var gotoSplitPath = ""
private var keyequivPath = ""
private var socketPath = ""
override func setUp() {
super.setUp()
continueAfterFailure = false
gotoSplitPath = "/tmp/cmux-ui-test-goto-split-\(UUID().uuidString).json"
keyequivPath = "/tmp/cmux-ui-test-keyequiv-\(UUID().uuidString).json"
socketPath = "/tmp/cmux-ui-test-socket-\(UUID().uuidString).sock"
try? FileManager.default.removeItem(atPath: gotoSplitPath)
try? FileManager.default.removeItem(atPath: keyequivPath)
try? FileManager.default.removeItem(atPath: socketPath)
}
func testCmdNWorksWhenWebViewFocusedAfterTabSwitch() {
let app = launchWithBrowserSetup()
// Simulate the repro: switch away and back.
app.typeKey("[", modifierFlags: [.command, .shift])
app.typeKey("]", modifierFlags: [.command, .shift])
// Force WebKit to become first responder again (Cmd+L then Escape).
refocusWebView(app: app)
let baseline = loadKeyequiv()["addTabInvocations"].flatMap(Int.init) ?? 0
app.typeKey("n", modifierFlags: [.command])
XCTAssertTrue(
waitForKeyequivInt(key: "addTabInvocations", toBeAtLeast: baseline + 1, timeout: 5.0),
"Expected Cmd+N to reach app menu and create a new tab even when WKWebView is first responder"
)
}
func testCmdWWorksWhenWebViewFocusedAfterTabSwitch() {
let app = launchWithBrowserSetup()
// Simulate the repro: switch away and back.
app.typeKey("[", modifierFlags: [.command, .shift])
app.typeKey("]", modifierFlags: [.command, .shift])
// Force WebKit to become first responder again (Cmd+L then Escape).
refocusWebView(app: app)
let baseline = loadKeyequiv()["closePanelInvocations"].flatMap(Int.init) ?? 0
app.typeKey("w", modifierFlags: [.command])
XCTAssertTrue(
waitForKeyequivInt(key: "closePanelInvocations", toBeAtLeast: baseline + 1, timeout: 5.0),
"Expected Cmd+W to reach app menu and close the focused tab even when WKWebView is first responder"
)
}
func testCmdShiftWWorksWhenWebViewFocusedAfterTabSwitch() {
let app = launchWithBrowserSetup()
// Simulate the repro: switch away and back.
app.typeKey("[", modifierFlags: [.command, .shift])
app.typeKey("]", modifierFlags: [.command, .shift])
// Force WebKit to become first responder again (Cmd+L then Escape).
refocusWebView(app: app)
let baseline = loadKeyequiv()["closeTabInvocations"].flatMap(Int.init) ?? 0
app.typeKey("w", modifierFlags: [.command, .shift])
XCTAssertTrue(
waitForKeyequivInt(key: "closeTabInvocations", toBeAtLeast: baseline + 1, timeout: 6.0),
"Expected Cmd+Shift+W to reach app menu and close the current workspace even when WKWebView is first responder"
)
}
private func launchWithBrowserSetup() -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = gotoSplitPath
app.launchEnvironment["CMUX_UI_TEST_KEYEQUIV_PATH"] = keyequivPath
app.launch()
app.activate()
XCTAssertTrue(
waitForGotoSplit(keys: ["browserPanelId", "webViewFocused"], timeout: 10.0),
"Expected goto_split setup data to be written"
)
if let setup = loadGotoSplit() {
XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test setup")
}
return app
}
private func refocusWebView(app: XCUIApplication) {
// Cmd+L focuses the omnibar (so WebKit is no longer first responder).
app.typeKey("l", modifierFlags: [.command])
XCTAssertTrue(
waitForGotoSplitMatch(timeout: 5.0) { data in
data["webViewFocusedAfterAddressBarFocus"] == "false"
},
"Expected Cmd+L to focus omnibar (WebKit not first responder)"
)
// Escape should leave the omnibar and focus WebKit again.
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
XCTAssertTrue(
waitForGotoSplitMatch(timeout: 5.0) { data in
data["webViewFocusedAfterAddressBarExit"] == "true"
},
"Expected Escape to return focus to WebKit"
)
}
private func waitForGotoSplit(keys: [String], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadGotoSplit(), keys.allSatisfy({ data[$0] != nil }) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadGotoSplit(), keys.allSatisfy({ data[$0] != nil }) {
return true
}
return false
}
private func waitForGotoSplitMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadGotoSplit(), predicate(data) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadGotoSplit(), predicate(data) {
return true
}
return false
}
private func waitForKeyequivInt(key: String, toBeAtLeast expected: Int, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0
if value >= expected {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0
return value >= expected
}
private func loadGotoSplit() -> [String: String]? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: gotoSplitPath)) else {
return nil
}
return (try? JSONSerialization.jsonObject(with: data)) as? [String: String]
}
private func loadKeyequiv() -> [String: String] {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: keyequivPath)),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
return [:]
}
return object
}
}
final class SplitCloseRightBlankRegressionUITests: XCTestCase {
private var dataPath = ""
private var socketPath = ""
private var diagnosticsPath = ""
private var screenshotDir = ""
private var socketClient: ControlSocketClient?
override func setUp() {
super.setUp()
continueAfterFailure = false
dataPath = "/tmp/cmux-ui-test-split-close-right-\(UUID().uuidString).json"
socketPath = "/tmp/cmux-ui-test-socket-\(UUID().uuidString).sock"
diagnosticsPath = "/tmp/cmux-ui-test-diagnostics-\(UUID().uuidString).json"
// Prefer a globally accessible dir so we can pull screenshots from the VM for debugging.
// If sandbox rules prevent this, fall back to the runner's container temp dir.
let leaf = "cmux-ui-test-split-close-right-shots-\(UUID().uuidString)"
let preferredURL = URL(fileURLWithPath: "/private/tmp").appendingPathComponent(leaf)
let fallbackURL = FileManager.default.temporaryDirectory.appendingPathComponent(leaf)
// Attempt to create the preferred dir; if it fails, use fallback.
if (try? FileManager.default.createDirectory(at: preferredURL, withIntermediateDirectories: true)) != nil {
screenshotDir = preferredURL.path
} else {
screenshotDir = fallbackURL.path
}
try? FileManager.default.removeItem(atPath: dataPath)
try? FileManager.default.removeItem(atPath: socketPath)
try? FileManager.default.removeItem(atPath: diagnosticsPath)
try? FileManager.default.removeItem(atPath: screenshotDir)
try? FileManager.default.createDirectory(atPath: screenshotDir, withIntermediateDirectories: true)
}
func testClosingBothRightSplitsDoesNotLeaveBlankPane() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATH"] = dataPath
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath
app.launch()
app.activate()
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
guard let data = waitForSettledData(timeout: 10.0) else {
XCTFail("Missing split-close-right test data after waiting for settle")
return
}
if let setupError = data["setupError"], !setupError.isEmpty {
XCTFail("Test setup failed: \(setupError)")
return
}
let finalPaneCount = Int(data["finalPaneCount"] ?? "") ?? -1
let missingSelected = Int(data["missingSelectedTabCount"] ?? "") ?? -1
let missingMapping = Int(data["missingPanelMappingCount"] ?? "") ?? -1
let emptyPanels = Int(data["emptyPanelAppearCount"] ?? "") ?? -1
let selectedTerminalCount = Int(data["selectedTerminalCount"] ?? "") ?? -1
let selectedTerminalAttached = Int(data["selectedTerminalAttachedCount"] ?? "") ?? -1
let selectedTerminalZeroSize = Int(data["selectedTerminalZeroSizeCount"] ?? "") ?? -1
let selectedTerminalSurfaceNil = Int(data["selectedTerminalSurfaceNilCount"] ?? "") ?? -1
let preTerminalAttached = Int(data["preTerminalAttached"] ?? "") ?? -1
let preTerminalSurfaceNil = Int(data["preTerminalSurfaceNil"] ?? "") ?? -1
// Expected correct behavior: after closing the two right panes, we should have a clean 1x2 stack,
// and both panes should have a selected bonsplit tab that maps to an existing Panel.
XCTAssertEqual(preTerminalAttached, 1, "Expected the initial terminal view to be attached to a window before the repro runs")
XCTAssertEqual(preTerminalSurfaceNil, 0, "Expected the initial terminal to have a non-nil ghostty_surface before the repro runs")
XCTAssertEqual(finalPaneCount, 2, "Expected 2 panes after closing both right splits")
XCTAssertEqual(missingSelected, 0, "Expected no pane to have a nil selected tab")
XCTAssertEqual(missingMapping, 0, "Expected no selected bonsplit tab to be missing its Panel mapping")
XCTAssertEqual(emptyPanels, 0, "Expected no Empty Panel views to appear during the close sequence")
XCTAssertEqual(selectedTerminalCount, 2, "Expected both remaining panes to be terminal panels")
XCTAssertEqual(selectedTerminalAttached, 2, "Expected both remaining terminal views to be attached to a window")
XCTAssertEqual(selectedTerminalZeroSize, 0, "Expected no remaining terminal view to have a zero-ish size")
XCTAssertEqual(selectedTerminalSurfaceNil, 0, "Expected no remaining terminal to have a nil ghostty_surface")
}
func testReproBlankAfterClosingRightSplitsViaShortcuts() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] = "1"
// The regression can be a single compositor frame; capture enough post-close frames to
// deterministically catch it.
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_ITERATIONS"] = "12"
// Close quickly (closer to how a user can click two close buttons back-to-back).
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_CLOSE_DELAY_MS"] = "0"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_BURST_FRAMES"] = "32"
// Repro order that still flashes for users: split left/right first, then split top/down.
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATTERN"] = "close_right_lrtd"
app.launch()
app.activate()
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
// Wait for the app-side repro loop to finish.
let doneDeadline = Date().addingTimeInterval(90.0)
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
return
}
if let setupError = data["setupError"], !setupError.isEmpty {
XCTFail("Test setup failed: \(setupError)")
return
}
let lastIter = Int(data["visualLastIteration"] ?? "") ?? 0
XCTAssertGreaterThan(lastIter, 0, "Expected at least one visual iteration. data=\(data)")
let blankSeen = (data["blankFrameSeen"] ?? "") == "1"
let sizeMismatchSeen = (data["sizeMismatchSeen"] ?? "") == "1"
let trace = data["timelineTrace"] ?? ""
XCTAssertFalse(
blankSeen,
"Transient blank frame detected. at=\(data["blankObservedAt"] ?? "") iter=\(data["blankObservedIteration"] ?? "") trace=\(trace)"
)
XCTAssertFalse(
sizeMismatchSeen,
"Transient IOSurface size mismatch detected (stretched text). at=\(data["sizeMismatchObservedAt"] ?? "") iter=\(data["sizeMismatchObservedIteration"] ?? "") trace=\(trace)"
)
}
func testReproBlankAfterClosingBottomSplitsViaShortcuts() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_ITERATIONS"] = "12"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_CLOSE_DELAY_MS"] = "0"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_BURST_FRAMES"] = "32"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATTERN"] = "close_bottom"
app.launch()
app.activate()
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0)
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
return
}
if let setupError = data["setupError"], !setupError.isEmpty {
XCTFail("Test setup failed: \(setupError)")
return
}
let lastIter = Int(data["visualLastIteration"] ?? "") ?? 0
XCTAssertGreaterThan(lastIter, 0, "Expected at least one visual iteration. data=\(data)")
let blankSeen = (data["blankFrameSeen"] ?? "") == "1"
let sizeMismatchSeen = (data["sizeMismatchSeen"] ?? "") == "1"
let trace = data["timelineTrace"] ?? ""
XCTAssertFalse(
blankSeen,
"Transient blank frame detected. at=\(data["blankObservedAt"] ?? "") iter=\(data["blankObservedIteration"] ?? "") trace=\(trace)"
)
XCTAssertFalse(
sizeMismatchSeen,
"Transient IOSurface size mismatch detected (stretched text). at=\(data["sizeMismatchObservedAt"] ?? "") iter=\(data["sizeMismatchObservedIteration"] ?? "") trace=\(trace)"
)
}
func testReproBlankAfterClosingRightSplitsTopFirstWithGap() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_ITERATIONS"] = "14"
// Reproduce manual close cadence: close top-right, observe one frame, then close bottom-right.
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_CLOSE_DELAY_MS"] = "120"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_BURST_FRAMES"] = "40"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATTERN"] = "close_right_lrtd"
app.launch()
app.activate()
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0)
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
return
}
if let setupError = data["setupError"], !setupError.isEmpty {
XCTFail("Test setup failed: \(setupError)")
return
}
let lastIter = Int(data["visualLastIteration"] ?? "") ?? 0
XCTAssertGreaterThan(lastIter, 0, "Expected at least one visual iteration. data=\(data)")
let blankSeen = (data["blankFrameSeen"] ?? "") == "1"
let sizeMismatchSeen = (data["sizeMismatchSeen"] ?? "") == "1"
let trace = data["timelineTrace"] ?? ""
XCTAssertFalse(
blankSeen,
"Transient blank frame detected. at=\(data["blankObservedAt"] ?? "") iter=\(data["blankObservedIteration"] ?? "") trace=\(trace)"
)
XCTAssertFalse(
sizeMismatchSeen,
"Transient IOSurface size mismatch detected (stretched text). at=\(data["sizeMismatchObservedAt"] ?? "") iter=\(data["sizeMismatchObservedIteration"] ?? "") trace=\(trace)"
)
}
func testReproBlankAfterClosingRightSplitsBottomFirstViaShortcuts() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_ITERATIONS"] = "12"
// Keep a short but non-zero delay so we sample the transient frame after BR closes
// and before TR closes (the user-visible stretched-text repro window).
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_CLOSE_DELAY_MS"] = "120"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_BURST_FRAMES"] = "40"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATTERN"] = "close_right_lrtd_bottom_first"
app.launch()
app.activate()
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0)
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
return
}
if let setupError = data["setupError"], !setupError.isEmpty {
XCTFail("Test setup failed: \(setupError)")
return
}
let lastIter = Int(data["visualLastIteration"] ?? "") ?? 0
XCTAssertGreaterThan(lastIter, 0, "Expected at least one visual iteration. data=\(data)")
let blankSeen = (data["blankFrameSeen"] ?? "") == "1"
let sizeMismatchSeen = (data["sizeMismatchSeen"] ?? "") == "1"
let trace = data["timelineTrace"] ?? ""
XCTAssertFalse(
blankSeen,
"Transient blank frame detected. at=\(data["blankObservedAt"] ?? "") iter=\(data["blankObservedIteration"] ?? "") trace=\(trace)"
)
XCTAssertFalse(
sizeMismatchSeen,
"Transient IOSurface size mismatch detected (stretched text). at=\(data["sizeMismatchObservedAt"] ?? "") iter=\(data["sizeMismatchObservedIteration"] ?? "") trace=\(trace)"
)
}
func testReproBlankAfterClosingRightSplitsWithoutFocusingRightPanes() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_ITERATIONS"] = "16"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_CLOSE_DELAY_MS"] = "0"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_BURST_FRAMES"] = "36"
app.launchEnvironment["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATTERN"] = "close_right_lrtd_unfocused"
app.launch()
app.activate()
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0)
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
return
}
if let setupError = data["setupError"], !setupError.isEmpty {
XCTFail("Test setup failed: \(setupError)")
return
}
let lastIter = Int(data["visualLastIteration"] ?? "") ?? 0
XCTAssertGreaterThan(lastIter, 0, "Expected at least one visual iteration. data=\(data)")
let blankSeen = (data["blankFrameSeen"] ?? "") == "1"
let sizeMismatchSeen = (data["sizeMismatchSeen"] ?? "") == "1"
let trace = data["timelineTrace"] ?? ""
XCTAssertFalse(
blankSeen,
"Transient blank frame detected. at=\(data["blankObservedAt"] ?? "") iter=\(data["blankObservedIteration"] ?? "") trace=\(trace)"
)
XCTAssertFalse(
sizeMismatchSeen,
"Transient IOSurface size mismatch detected (stretched text). at=\(data["sizeMismatchObservedAt"] ?? "") iter=\(data["sizeMismatchObservedIteration"] ?? "") trace=\(trace)"
)
}
// MARK: - Screenshot-Based Blank Detection
private struct CropStats {
let sampleCount: Int
let uniqueQuantized: Int
let lumaStdDev: Double
let modeFraction: Double
let fingerprint: UInt64
var isProbablyBlank: Bool {
// Tuned for "terminal went visually blank": near-uniform region, very low contrast.
// (The exact thresholds are conservative; we also require consecutive blank samples below.)
return lumaStdDev < 2.5 && modeFraction > 0.992
}
}
private func assertPaneRendersAndUpdates(
app: XCUIApplication,
window: XCUIElement,
paneCenter: CGVector,
blankCrop: CGRect,
updateCrop: CGRect,
label: String
) {
// We want to catch:
// 1) pane visually blank (uniform background)
// 2) pane visually frozen (doesn't update after printing)
//
// We deliberately avoid relying on "Empty Panel" accessibility text, since the regression
// you're reporting is a blank terminal surface, not necessarily the EmptyPanelView.
func takeStats(_ name: String, crop: CGRect) -> (String, CropStats)? {
guard let path = writeScreenshot(window: window, name: name),
let png = try? Data(contentsOf: URL(fileURLWithPath: path)),
let stats = cropStats(pngData: png, normalizedCrop: crop) else {
return nil
}
return (path, stats)
}
// Capture a baseline frame.
guard let (preBlankPath, preBlankStats) = takeStats("\(label)-pre-blank", crop: blankCrop),
let (preUpdatePath, preUpdateStats) = takeStats("\(label)-pre-update", crop: updateCrop) else {
XCTFail("Failed to capture pre screenshot for \(label). shots=\(screenshotDir)")
return
}
// Trigger a visible update in the pane.
window.coordinate(withNormalizedOffset: paneCenter).click()
// Print a lot of lines so the update is visible in a screenshot diff even with subsampling.
let token = String(UUID().uuidString.prefix(8))
app.typeText("yes CMUX_AFTER_CLOSE_\(label)_\(token) | head -n 30\n")
RunLoop.current.run(until: Date().addingTimeInterval(0.7))
guard let (postBlankPath, postBlankStats) = takeStats("\(label)-post-blank", crop: blankCrop),
let (postUpdatePath, postUpdateStats) = takeStats("\(label)-post-update", crop: updateCrop) else {
XCTFail("Failed to capture post screenshot for \(label). shots=\(screenshotDir)")
return
}
if postBlankStats.isProbablyBlank {
addKeptScreenshot(path: preBlankPath, name: "\(label)-pre-blank")
addKeptScreenshot(path: postBlankPath, name: "\(label)-post-blank")
XCTFail("Pane looks blank after close. label=\(label) pre=\(preBlankStats) post=\(postBlankStats) shots=\(screenshotDir)")
return
}
// Fingerprints can collide on terminal content (white glyphs on dark background with similar
// layout). Use a mean absolute luma diff threshold to detect a truly frozen surface.
// Compare only the pane area to avoid false positives from other UI movement.
if let prePng = try? Data(contentsOf: URL(fileURLWithPath: preUpdatePath)),
let postPng = try? Data(contentsOf: URL(fileURLWithPath: postUpdatePath)),
let diff = meanAbsLumaDiff(pngA: prePng, pngB: postPng, normalizedCrop: updateCrop),
diff < 1.0 {
addKeptScreenshot(path: preUpdatePath, name: "\(label)-pre-update")
addKeptScreenshot(path: postUpdatePath, name: "\(label)-post-update")
XCTFail("Pane looks frozen (no visual change after printing). label=\(label) diff=\(diff) pre=\(preUpdateStats) post=\(postUpdateStats) shots=\(screenshotDir)")
return
}
// Also guard against a delayed blanking: watch for ~1.5s and fail if it goes blank for sustained streak.
let deadline = Date().addingTimeInterval(1.5)
var blankStreak = 0
var sampleIndex = 0
while Date() < deadline {
sampleIndex += 1
guard let (path, stats) = takeStats("\(label)-watch-\(String(format: "%02d", sampleIndex))", crop: blankCrop) else {
RunLoop.current.run(until: Date().addingTimeInterval(0.17))
continue
}
if stats.isProbablyBlank {
blankStreak += 1
} else {
blankStreak = 0
}
if blankStreak >= 6 { // ~1s
addKeptScreenshot(path: path, name: "\(label)-watch-last")
XCTFail("Pane became blank for sustained period after close. label=\(label) stats=\(stats) shots=\(screenshotDir)")
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.17))
}
}
@discardableResult
private func writeScreenshot(window: XCUIElement, name: String) -> String? {
let shot = window.screenshot()
let path = "\(screenshotDir)/\(name).png"
do {
try shot.pngRepresentation.write(to: URL(fileURLWithPath: path))
return path
} catch {
return nil
}
}
private func addKeptScreenshot(path: String, name: String) {
let attachment = XCTAttachment(contentsOfFile: URL(fileURLWithPath: path))
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
private func meanAbsLumaDiff(pngA: Data, pngB: Data, normalizedCrop: CGRect) -> Double? {
guard let imageA = cgImage(from: pngA),
let imageB = cgImage(from: pngB) else {
return nil
}
guard imageA.width == imageB.width, imageA.height == imageB.height else { return nil }
let width = imageA.width
let height = imageA.height
if width <= 0 || height <= 0 { return nil }
let cropPx = CGRect(
x: max(0, min(CGFloat(width - 1), normalizedCrop.origin.x * CGFloat(width))),
y: max(0, min(CGFloat(height - 1), normalizedCrop.origin.y * CGFloat(height))),
width: max(1, min(CGFloat(width), normalizedCrop.width * CGFloat(width))),
height: max(1, min(CGFloat(height), normalizedCrop.height * CGFloat(height)))
).integral
let x0 = Int(cropPx.minX)
let y0 = Int(cropPx.minY)
let x1 = Int(min(CGFloat(width), cropPx.maxX))
let y1 = Int(min(CGFloat(height), cropPx.maxY))
if x1 <= x0 || y1 <= y0 { return nil }
guard let bufA = decodeRGBA(imageA), let bufB = decodeRGBA(imageB) else { return nil }
let bytesPerPixel = 4
let bytesPerRow = width * bytesPerPixel
let step = 3
var total = 0.0
var count = 0
for y in stride(from: y0, to: y1, by: step) {
let row = y * bytesPerRow
for x in stride(from: x0, to: x1, by: step) {
let i = row + x * bytesPerPixel
let ar = Double(bufA[i])
let ag = Double(bufA[i + 1])
let ab = Double(bufA[i + 2])
let br = Double(bufB[i])
let bg = Double(bufB[i + 1])
let bb = Double(bufB[i + 2])
let al = 0.2126 * ar + 0.7152 * ag + 0.0722 * ab
let bl = 0.2126 * br + 0.7152 * bg + 0.0722 * bb
total += abs(al - bl)
count += 1
}
}
return count > 0 ? (total / Double(count)) : nil
}
private func cgImage(from pngData: Data) -> CGImage? {
guard let source = CGImageSourceCreateWithData(pngData as CFData, nil) else {
return nil
}
return CGImageSourceCreateImageAtIndex(source, 0, nil)
}
private func decodeRGBA(_ image: CGImage) -> [UInt8]? {
let width = image.width
let height = image.height
let bytesPerPixel = 4
let bytesPerRow = width * bytesPerPixel
var buf = [UInt8](repeating: 0, count: height * bytesPerRow)
// Important: pass the *pixel buffer* pointer, not the Array object address.
// Also pin the pixel format so our [r,g,b,a] indexing matches reality.
let ok = buf.withUnsafeMutableBytes { raw -> Bool in
guard let base = raw.baseAddress else { return false }
let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue
guard let ctx = CGContext(
data: base,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: bitmapInfo
) else { return false }
// Note: do not flip the context here.
// With CGImage decoded from XCUI screenshots, the bitmap memory we get from a plain
// draw() already matches the "top-left origin" expectation used by our normalized crops.
ctx.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
return true
}
return ok ? buf : nil
}
private func cropStats(pngData: Data, normalizedCrop: CGRect) -> CropStats? {
guard let image = cgImage(from: pngData) else {
return nil
}
let width = image.width
let height = image.height
if width <= 0 || height <= 0 { return nil }
let cropPx = CGRect(
x: max(0, min(CGFloat(width - 1), normalizedCrop.origin.x * CGFloat(width))),
y: max(0, min(CGFloat(height - 1), normalizedCrop.origin.y * CGFloat(height))),
width: max(1, min(CGFloat(width), normalizedCrop.width * CGFloat(width))),
height: max(1, min(CGFloat(height), normalizedCrop.height * CGFloat(height)))
).integral
// Render into a known RGBA8 buffer with top-left origin.
guard let buf = decodeRGBA(image) else { return nil }
let bytesPerPixel = 4
let bytesPerRow = width * bytesPerPixel
let x0 = Int(cropPx.minX)
let y0 = Int(cropPx.minY)
let x1 = Int(min(CGFloat(width), cropPx.maxX))
let y1 = Int(min(CGFloat(height), cropPx.maxY))
if x1 <= x0 || y1 <= y0 { return nil }
// Sample every N pixels to keep this cheap and stable.
let step = 3
var lumas = [Double]()
lumas.reserveCapacity(((x1 - x0) / step) * ((y1 - y0) / step))
// Quantize RGB to 4 bits/channel and track uniqueness + mode.
var hist = [UInt16: Int]()
hist.reserveCapacity(256)
var count = 0
var fnv: UInt64 = 1469598103934665603 // FNV-1a offset basis
for y in stride(from: y0, to: y1, by: step) {
let rowBase = y * bytesPerRow
for x in stride(from: x0, to: x1, by: step) {
let i = rowBase + x * bytesPerPixel
let r = Double(buf[i])
let g = Double(buf[i + 1])
let b = Double(buf[i + 2])
let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b
lumas.append(luma)
let rq = UInt16(UInt8(buf[i]) >> 4)
let gq = UInt16(UInt8(buf[i + 1]) >> 4)
let bq = UInt16(UInt8(buf[i + 2]) >> 4)
let key = (rq << 8) | (gq << 4) | bq
hist[key, default: 0] += 1
count += 1
// Fingerprint based on quantized luma (coarse) plus position order.
let lq = UInt8(max(0, min(63, Int(luma / 4.0)))) // ~6 bits
fnv ^= UInt64(lq)
fnv &*= 1099511628211
}
}
guard count > 0 else { return nil }
// stddev of luma
let mean = lumas.reduce(0.0, +) / Double(lumas.count)
let variance = lumas.reduce(0.0) { $0 + ($1 - mean) * ($1 - mean) } / Double(lumas.count)
let stddev = sqrt(variance)
// mode fraction
let modeCount = hist.values.max() ?? 0
let modeFrac = Double(modeCount) / Double(count)
return CropStats(
sampleCount: count,
uniqueQuantized: hist.count,
lumaStdDev: stddev,
modeFraction: modeFrac,
fingerprint: fnv
)
}
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) {
return true
}
return false
}
private func waitForAnyData(timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if loadData() != nil {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return loadData() != nil
}
private func waitForSettledData(timeout: TimeInterval) -> [String: String]? {
let deadline = Date().addingTimeInterval(timeout)
var last: [String: String]?
while Date() < deadline {
if let data = loadData() {
last = data
if let setupError = data["setupError"], !setupError.isEmpty {
return data
}
let finalPaneCount = Int(data["finalPaneCount"] ?? "") ?? -1
let missingSelected = Int(data["missingSelectedTabCount"] ?? "") ?? -1
let missingMapping = Int(data["missingPanelMappingCount"] ?? "") ?? -1
let emptyPanels = Int(data["emptyPanelAppearCount"] ?? "") ?? -1
let selectedTerminalCount = Int(data["selectedTerminalCount"] ?? "") ?? -1
let selectedTerminalAttached = Int(data["selectedTerminalAttachedCount"] ?? "") ?? -1
let selectedTerminalZeroSize = Int(data["selectedTerminalZeroSizeCount"] ?? "") ?? -1
let selectedTerminalSurfaceNil = Int(data["selectedTerminalSurfaceNilCount"] ?? "") ?? -1
let settled =
finalPaneCount == 2 &&
missingSelected == 0 &&
missingMapping == 0 &&
emptyPanels == 0 &&
selectedTerminalCount == 2 &&
selectedTerminalAttached == 2 &&
selectedTerminalZeroSize == 0 &&
selectedTerminalSurfaceNil == 0
if settled {
return data
}
// `recordSplitCloseRightFinalState` streams attempts; give it time to converge.
// If the bug is present it will never converge to "settled".
let attempt = Int(data["finalAttempt"] ?? "") ?? -1
if attempt >= 20 {
return data
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return last
}
private func loadData() -> [String: String]? {
guard let raw = try? Data(contentsOf: URL(fileURLWithPath: dataPath)) else {
return nil
}
return (try? JSONSerialization.jsonObject(with: raw)) as? [String: String]
}
private func loadDiagnostics() -> [String: String]? {
guard let raw = try? Data(contentsOf: URL(fileURLWithPath: diagnosticsPath)) else {
return nil
}
return (try? JSONSerialization.jsonObject(with: raw)) as? [String: String]
}
// MARK: - Automation Socket Client (UI Tests)
private func waitForSocketPong(timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if socketCommand("ping") == "PONG" {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return socketCommand("ping") == "PONG"
}
private func socketCommand(_ cmd: String) -> String? {
if socketClient == nil {
socketClient = ControlSocketClient(path: socketPath)
}
if let v = socketClient?.sendLine(cmd) {
return v
}
// Fallback: use `nc -U` (more tolerant of Darwin sockaddr_un quirks across OS versions).
return socketCommandViaNetcat(cmd)
}
private func socketCommandViaNetcat(_ cmd: String) -> String? {
let nc = "/usr/bin/nc"
guard FileManager.default.isExecutableFile(atPath: nc) else { return nil }
let proc = Process()
proc.executableURL = URL(fileURLWithPath: nc)
proc.arguments = ["-U", socketPath, "-w", "2"]
let inPipe = Pipe()
let outPipe = Pipe()
let errPipe = Pipe()
proc.standardInput = inPipe
proc.standardOutput = outPipe
proc.standardError = errPipe
do {
try proc.run()
} catch {
return nil
}
if let data = (cmd + "\n").data(using: .utf8) {
inPipe.fileHandleForWriting.write(data)
}
inPipe.fileHandleForWriting.closeFile()
proc.waitUntilExit()
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
guard let outStr = String(data: outData, encoding: .utf8) else { return nil }
if let first = outStr.split(separator: "\n", maxSplits: 1).first {
return String(first).trimmingCharacters(in: .whitespacesAndNewlines)
}
let trimmed = outStr.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private final class ControlSocketClient {
private let path: String
init(path: String) {
self.path = path
}
func sendLine(_ line: String) -> String? {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
defer { close(fd) }
var addr = sockaddr_un()
// Zero-init is important because we compute a variable sockaddr length and
// the kernel may validate `sun_len` on some macOS versions.
memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
addr.sun_family = sa_family_t(AF_UNIX)
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
let bytes = Array(path.utf8CString) // includes null terminator
guard bytes.count <= maxLen else { return nil }
withUnsafeMutablePointer(to: &addr.sun_path) { p in
let raw = UnsafeMutableRawPointer(p).assumingMemoryBound(to: CChar.self)
memset(raw, 0, maxLen)
for i in 0..<bytes.count {
raw[i] = bytes[i]
}
}
// Darwin expects a sockaddr length that includes only the fields up to the pathname.
let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
let addrLen = socklen_t(pathOffset + bytes.count)
#if os(macOS)
// `sun_len` exists on Darwin/BSD.
addr.sun_len = UInt8(min(Int(addrLen), 255))
#endif
let ok = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
connect(fd, sa, addrLen)
}
}
guard ok == 0 else { return nil }
let payload = line + "\n"
let wrote: Bool = payload.withCString { cstr in
var remaining = strlen(cstr)
var p = UnsafeRawPointer(cstr)
while remaining > 0 {
let n = write(fd, p, remaining)
if n <= 0 { return false }
remaining -= n
p = p.advanced(by: n)
}
return true
}
guard wrote else { return nil }
var buf = [UInt8](repeating: 0, count: 4096)
var accum = ""
while true {
let n = read(fd, &buf, buf.count)
if n <= 0 { break }
if let chunk = String(bytes: buf[0..<n], encoding: .utf8) {
accum.append(chunk)
if let idx = accum.firstIndex(of: "\n") {
return String(accum[..<idx])
}
}
}
return accum.isEmpty ? nil : accum.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
}