diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8b8e5762f6b2..1e2d1d9c9a9d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -997,11 +997,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didSetupJumpUnreadUITest = false private var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)? private var jumpUnreadFocusObserver: NSObjectProtocol? - private var didSetupTerminalCmdClickUITest = false private var didSetupGotoSplitUITest = false private var didSetupBonsplitTabDragUITest = false private var didSetupTerminalViewportUITest = false - private var terminalCmdClickUITestPoller: DispatchSourceTimer? private var bonsplitTabDragUITestRecorder: DispatchSourceTimer? private var terminalViewportUITestRecorder: TerminalViewportUITestRecorder? private var gotoSplitUITestRecorder: DispatchSourceTimer? @@ -1044,6 +1042,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self?.uiTestDiagnosticsWriter.write(stage: stage) } ) + private lazy var terminalCmdClickUITestCoordinator = TerminalCmdClickUITestCoordinator( + dependencies: .init( + environment: ProcessInfo.processInfo.environment, + notificationCenter: .default, + tabManager: { [weak self] in self?.tabManager }, + window: { [weak self] tabManager in + guard let self, + let windowId = self.windowId(for: tabManager) else { return nil } + return self.mainWindow(for: windowId) + }, + managerToken: { [weak self] manager in + self?.debugManagerToken(manager) ?? "nil" + }, + sendText: { [weak self] text, workspace, beforeSend in + self?.sendTextWhenReady(text, to: workspace, beforeSend: beforeSend) + } + ) + ) private var didSetupPortalStatsUITestDiagnostics = false private var portalStatsUITestObservers: [NSObjectProtocol] = [] private lazy var uiTestDiagnosticsWriter = UITestDiagnosticsWriter( @@ -2277,7 +2293,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent startSessionAutosaveTimerIfNeeded() #if DEBUG setupJumpUnreadUITestIfNeeded() - setupTerminalCmdClickUITestIfNeeded() + terminalCmdClickUITestCoordinator.startIfNeeded() setupGotoSplitUITestIfNeeded() setupBonsplitTabDragUITestIfNeeded() setupTerminalViewportUITestIfNeeded() @@ -2327,807 +2343,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } -#if DEBUG - private func setupTerminalCmdClickUITestIfNeeded() { - guard !didSetupTerminalCmdClickUITest else { return } - - let env = ProcessInfo.processInfo.environment - guard env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_SETUP"] == "1" else { - cmuxDebugLog("cmdclick.ui.setup skip reason=env_missing tag=\(env["CMUX_TAG"] ?? "nil")") - return - } - guard let manifestPath = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_PATH"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !manifestPath.isEmpty else { - cmuxDebugLog("cmdclick.ui.setup skip reason=missing_manifest_path") - return - } - didSetupTerminalCmdClickUITest = true - guard let fixtureDirectory = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_FIXTURE_DIR"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !fixtureDirectory.isEmpty else { - cmuxDebugLog("cmdclick.ui.setup error reason=missing_fixture_dir manifest=\(manifestPath)") - writeTerminalCmdClickUITestData(at: manifestPath, updates: [ - "setupError": "Missing fixture directory" - ]) - return - } - let commandPath = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_COMMAND_PATH"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let screenshotDirectory = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_SCREENSHOT_DIR"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let displayMode = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_DISPLAY_MODE"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let lineFormat = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_LINE_FORMAT"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let linePrefix = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_LINE_PREFIX"] ?? "" - let displaySuffix = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_DISPLAY_SUFFIX"] ?? "" - let displayAsAbsolutePath = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_DISPLAY_AS_ABSOLUTE_PATH"] == "1" - if let rawOpenSupportedFiles = env["CMUX_UI_TEST_OPEN_SUPPORTED_FILES_IN_CMUX"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !rawOpenSupportedFiles.isEmpty { - FileRouteSettingsStore(defaults: .standard).setSupportedFileRouteEnabled(rawOpenSupportedFiles == "1") - } - if let rawOpenMarkdown = env["CMUX_UI_TEST_OPEN_MARKDOWN_IN_CMUX_VIEWER"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !rawOpenMarkdown.isEmpty { - FileRouteSettingsStore(defaults: .standard).setMarkdownRouteEnabled(rawOpenMarkdown == "1") - } - let extraFileNamesJSON = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_EXTRA_FILE_NAMES_JSON"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - - let fileName = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_FILE_NAME"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedFileName = (fileName?.isEmpty == false) ? fileName! : "Cmd Click Fixture.txt" - let fixtureDirectoryURL = URL(fileURLWithPath: fixtureDirectory, isDirectory: true) - let expectedFileURL = fixtureDirectoryURL.appendingPathComponent(resolvedFileName) - let siblingFileURL = fixtureDirectoryURL.appendingPathComponent("OtherFile") - let extraFileNames: [String] - if let extraFileNamesJSON, - let data = extraFileNamesJSON.data(using: .utf8), - let values = try? JSONSerialization.jsonObject(with: data) as? [String] { - extraFileNames = values - } else { - extraFileNames = [] - } - let escapedToken = resolvedFileName.replacingOccurrences(of: " ", with: "\\ ") - let baseDisplayToken = displayAsAbsolutePath ? expectedFileURL.path : resolvedFileName - let resolvedDisplayMode = (displayMode == "raw") ? "raw" : "escaped" - let resolvedLineFormat: String - switch lineFormat { - case "log": - resolvedLineFormat = "log" - case "alt_screen_log": - resolvedLineFormat = "alt_screen_log" - case "osc8": - resolvedLineFormat = "osc8" - default: - resolvedLineFormat = "grid" - } - cmuxDebugLog( - "cmdclick.ui.setup start manifest=\(manifestPath) fixture=\(fixtureDirectory) " + - "command=\(commandPath ?? "nil") display=\(resolvedDisplayMode) " + - "lineFormat=\(resolvedLineFormat) " + - "file=\(resolvedFileName)" - ) - func singleQuotedShellLiteral(_ text: String) -> String { - text.replacingOccurrences(of: "'", with: "'\"'\"'") - } - let displayToken: String - let shellCommand: String - switch resolvedLineFormat { - case "osc8": - displayToken = resolvedFileName - let escapedDisplayToken = singleQuotedShellLiteral(displayToken) - let escapedURL = singleQuotedShellLiteral(expectedFileURL.absoluteString) - shellCommand = "clear\rfor i in $(seq 1 48); do printf '\\033]8;;%s\\033\\\\%s\\033]8;;\\033\\\\\\n' '\(escapedURL)' '\(escapedDisplayToken)'; done\r" - case "log": - displayToken = "\(baseDisplayToken)\(displaySuffix)" - let blockLine = "\(linePrefix)\(displayToken)" - let shellBlockLine = singleQuotedShellLiteral(blockLine) - shellCommand = "clear\rfor i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" - case "alt_screen_log": - displayToken = "\(baseDisplayToken)\(displaySuffix)" - let blockLine = "\(linePrefix)\(displayToken)" - let shellBlockLine = singleQuotedShellLiteral(blockLine) - shellCommand = "clear\rprintf '\\033[?1049h\\033[H\\033[2J'; for i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" - default: - switch resolvedDisplayMode { - case "raw": - displayToken = "\(baseDisplayToken)\(displaySuffix)" - let blockLine = "\(displayToken) OtherFile" - let shellBlockLine = singleQuotedShellLiteral(blockLine) - shellCommand = "clear\rfor i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" - default: - displayToken = "\(escapedToken)\(displaySuffix)" - let blockLine = Array(repeating: displayToken, count: 3).joined(separator: " ") - let shellBlockLine = singleQuotedShellLiteral(blockLine) - shellCommand = "clear\rfor i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" - } - } - let deadline = Date().addingTimeInterval((commandPath?.isEmpty == false) ? 60.0 : 20.0) - var seeded = false - var resolved = false - var tokenPointPayload: [String: Any]? - var observers: [NSObjectProtocol] = [] - var lastHandledCommandID: String? - var screenshotSequence = 0 - - func rectPayload(_ rect: CGRect) -> [String: Double] { - [ - "x": rect.origin.x, - "y": rect.origin.y, - "width": rect.size.width, - "height": rect.size.height - ] - } - - func pointPayload(x: CGFloat, yFromTop: CGFloat) -> [String: Double] { - [ - "x": x, - "y": yFromTop - ] - } - - func doubleValue(_ value: Any?) -> Double? { - if let value = value as? Double { - return value - } - if let value = value as? NSNumber { - return value.doubleValue - } - return nil - } - - func pointFromPayload(_ key: String, in terminalPanel: TerminalPanel) -> NSPoint? { - guard let payload = tokenPointPayload?[key] as? [String: Any], - let x = doubleValue(payload["x"]), - let yFromTop = doubleValue(payload["y"]) else { - return nil - } - - let clampedX = min(max(CGFloat(x), 1), max(terminalPanel.hostedView.bounds.width - 1, 1)) - let clampedYFromTop = min( - max(CGFloat(yFromTop), 1), - max(terminalPanel.hostedView.bounds.height - 1, 1) - ) - return NSPoint( - x: clampedX, - y: terminalPanel.hostedView.bounds.height - clampedYFromTop - ) - } - - func pointForTokenColumnOffset(_ offset: Int, in terminalPanel: TerminalPanel) -> NSPoint? { - guard let selectionStart = pointFromPayload("tokenSelectionStartInTerminal", in: terminalPanel), - let tokenCellMetrics = tokenPointPayload?["tokenCellMetrics"] as? [String: Any], - let cellWidth = doubleValue(tokenCellMetrics["cellWidth"]) else { - return nil - } - - let unclampedX = selectionStart.x + (CGFloat(offset) * CGFloat(cellWidth)) - let clampedX = min(max(unclampedX, 1), max(terminalPanel.hostedView.bounds.width - 1, 1)) - return NSPoint(x: clampedX, y: selectionStart.y) - } - - func commandPoint( - from command: [String: Any], - defaultPayloadKey: String, - in terminalPanel: TerminalPanel - ) -> NSPoint? { - if let tokenColumnOffset = command["tokenColumnOffset"] as? Int { - return pointForTokenColumnOffset(tokenColumnOffset, in: terminalPanel) - } - if let tokenColumnOffset = command["tokenColumnOffset"] as? NSNumber { - return pointForTokenColumnOffset(tokenColumnOffset.intValue, in: terminalPanel) - } - return pointFromPayload(defaultPayloadKey, in: terminalPanel) - } - - func loadCommand(at path: String) -> [String: Any]? { - let url = URL(fileURLWithPath: path) - guard let data = try? Data(contentsOf: url), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil - } - return object - } - - func tokenPoints(in terminalPanel: TerminalPanel, visibleText: String) -> [String: Any]? { - guard let surface = terminalPanel.surface.surface else { return nil } - let bounds = terminalPanel.hostedView.bounds - guard bounds.width > 0, bounds.height > 0 else { return nil } - - let size = ghostty_surface_size(surface) - let rows = max(Int(size.rows), 1) - let cols = max(Int(size.columns), 1) - let debugCellSize = terminalPanel.hostedView.debugCellSize - let cellWidth = debugCellSize.width > 0 ? debugCellSize.width : CGFloat(size.cell_width_px) - let cellHeight = debugCellSize.height > 0 ? debugCellSize.height : CGFloat(size.cell_height_px) - guard cellWidth > 0, cellHeight > 0 else { return nil } - - let xInset = max(0, (bounds.width - (CGFloat(cols) * cellWidth)) / 2) - let yInset = max(0, (bounds.height - (CGFloat(rows) * cellHeight)) / 2) - let pointClampX: (CGFloat) -> CGFloat = { x in - min(bounds.width - 4, max(4, x)) - } - let pointClampY: (CGFloat) -> CGFloat = { y in - min(bounds.height - 4, max(4, y)) - } - - let rawVisibleLines = visibleText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - let visibleLines = rawVisibleLines.count > rows ? Array(rawVisibleLines.suffix(rows)) : rawVisibleLines - let rowOffset = max(0, rows - visibleLines.count) - - var matchedRowFromTop: Int? - var matchedColumnStart: Int? - var matchedColumnEnd: Int? - var matchedLine = "" - var matchingLines: [(lineIndex: Int, line: String, ranges: [Range])] = [] - - for (lineIndex, line) in visibleLines.enumerated() { - var searchStart = line.startIndex - var ranges: [Range] = [] - while searchStart < line.endIndex, - let range = line.range(of: displayToken, range: searchStart.. String { - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.")) - let scalars = label.unicodeScalars.map { allowed.contains($0) ? Character($0) : "-" } - let cleaned = String(scalars).trimmingCharacters(in: CharacterSet(charactersIn: "-_.")) - return cleaned.isEmpty ? "capture" : cleaned - } - - @MainActor - func captureWindowSnapshotIfRequested(label: String, window: NSWindow) -> String? { - guard let screenshotDirectory, - !screenshotDirectory.isEmpty, - let contentView = window.contentView else { - return nil - } - let bounds = contentView.bounds - guard !bounds.isEmpty, - let bitmap = contentView.bitmapImageRepForCachingDisplay(in: bounds) else { - return nil - } - contentView.cacheDisplay(in: bounds, to: bitmap) - guard let data = bitmap.representation(using: .png, properties: [:]) else { - return nil - } - do { - let directoryURL = URL(fileURLWithPath: screenshotDirectory, isDirectory: true) - try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) - let sequence = String(format: "%03d", screenshotSequence) - screenshotSequence += 1 - let fileURL = directoryURL - .appendingPathComponent("\(sequence)-\(safeScreenshotLabel(label)).png") - try data.write(to: fileURL, options: .atomic) - return fileURL.path - } catch { - cmuxDebugLog("cmdclick.ui.snapshot failed label=\(label) error=\(error.localizedDescription)") - return nil - } - } - - func cmdClickUITestTerminalPanel(in workspace: Workspace?) -> TerminalPanel? { - guard let workspace else { return nil } - if let inputPanel = workspace.focusedTerminalInputTarget()?.panel { - return inputPanel - } - return workspace.panels.values - .compactMap { $0 as? TerminalPanel } - .first { panel in - panel.surface.isViewInWindow && - panel.hostedView.debugPortalVisibleInUI && - !panel.hostedView.debugPortalFrameInWindow.isEmpty - } - } - - @MainActor - func executePendingCommandIfNeeded( - workspace: Workspace, - terminalPanel: TerminalPanel, - window: NSWindow - ) { - guard let commandPath, - !commandPath.isEmpty, - let command = loadCommand(at: commandPath), - let commandID = command["id"] as? String, - commandID != lastHandledCommandID else { - return - } - - let action = (command["action"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - var payload: [String: Any] = [ - "lastCommandId": commandID, - "lastCommandAction": action, - "lastCommandSucceeded": "0" - ] - - switch action { - case "hover_token": - guard let hitPoint = commandPoint( - from: command, - defaultPayloadKey: "tokenHitPointInTerminal", - in: terminalPanel - ) else { - payload["lastCommandError"] = "Missing command point" - break - } - - let result = terminalPanel.hostedView.debugSimulateCommandHoverDetails(at: hitPoint) - payload["lastCommandResult"] = result - payload["lastCommandHoverActive"] = result["hoverActive"] - if let resolvedPath = result["resolvedPath"] as? String { - payload["lastCommandResolvedPath"] = resolvedPath - payload["lastCommandSucceeded"] = "1" - } else if let error = result["error"] as? String { - payload["lastCommandError"] = error - } else { - payload["lastCommandError"] = "Command hover did not resolve a path" - } - - case "cmd_click_token": - guard let hitPoint = commandPoint( - from: command, - defaultPayloadKey: "tokenHitPointInTerminal", - in: terminalPanel - ) else { - payload["lastCommandError"] = "Missing command point" - break - } - - let result = terminalPanel.hostedView.debugSimulateCommandClick(at: hitPoint) - payload["lastCommandResult"] = result - if let openedPath = result["openedPath"] as? String { - payload["lastCommandOpenedPath"] = openedPath - let canonicalOpenedPath = (openedPath as NSString).resolvingSymlinksInPath - let openedInFilePreview = workspace.panels.values.contains { panel in - guard let filePreview = panel as? FilePreviewPanel else { return false } - return (filePreview.filePath as NSString).resolvingSymlinksInPath == canonicalOpenedPath - } - let openedInMarkdownViewer = workspace.panels.values.contains { panel in - guard let markdown = panel as? MarkdownPanel else { return false } - return (markdown.filePath as NSString).resolvingSymlinksInPath == canonicalOpenedPath - } - payload["lastCommandOpenedInFilePreview"] = openedInFilePreview ? "1" : "0" - payload["lastCommandOpenedInMarkdownViewer"] = openedInMarkdownViewer ? "1" : "0" - payload["lastCommandSucceeded"] = "1" - } else if let error = result["error"] as? String { - payload["lastCommandError"] = error - } else { - payload["lastCommandError"] = "Command click did not open a path" - } - - case "stationary_cmd_click_token": - guard let hitPoint = commandPoint( - from: command, - defaultPayloadKey: "tokenHitPointInTerminal", - in: terminalPanel - ) else { - payload["lastCommandError"] = "Missing command point" - break - } - - let capturePath = ProcessInfo.processInfo.environment["CMUX_UI_TEST_CAPTURE_OPEN_URL_PATH"] - let beforeURLCount = capturePath.flatMap { try? String(contentsOfFile: $0, encoding: .utf8) }? - .split(separator: "\n").count ?? 0 - let result = terminalPanel.hostedView.debugSimulateStationaryCommandClick(at: hitPoint) - payload["lastCommandResult"] = result - let openedURLs = capturePath.flatMap { try? String(contentsOfFile: $0, encoding: .utf8) }? - .split(separator: "\n").map(String.init) ?? [] - if openedURLs.count > beforeURLCount, let openedURL = openedURLs.last { - payload["lastCommandOpenedURL"] = openedURL - payload["lastCommandSucceeded"] = "1" - } else if let error = result["error"] as? String { - payload["lastCommandError"] = error - } else { - payload["lastCommandError"] = "Stationary command click did not open a URL" - } - - case "select_token_and_hold_command": - guard let selectionStart = pointFromPayload("tokenSelectionStartInTerminal", in: terminalPanel), - let selectionEnd = pointFromPayload("tokenSelectionEndInTerminal", in: terminalPanel) else { - payload["lastCommandError"] = "Missing token selection points" - break - } - - let selectionActive = terminalPanel.hostedView.debugSimulateSelection( - from: selectionStart, - to: selectionEnd - ) - let hoverSuppressed = terminalPanel.hostedView.debugSimulateCommandHover(at: selectionEnd) - payload["lastCommandSelectionActive"] = selectionActive ? "1" : "0" - payload["lastCommandHoverSuppressed"] = hoverSuppressed ? "1" : "0" - if selectionActive && hoverSuppressed { - payload["lastCommandSucceeded"] = "1" - } else { - payload["lastCommandError"] = "Selection or hover suppression failed" - } - - case "capture_window": - let label = (command["label"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let path = captureWindowSnapshotIfRequested( - label: label?.isEmpty == false ? label! : "capture", - window: window - ) { - payload["lastCommandScreenshotPath"] = path - payload["lastCommandSucceeded"] = "1" - } else { - payload["lastCommandError"] = "Window screenshot capture unavailable" - } - - default: - payload["lastCommandError"] = "Unknown command action: \(action)" - } - - writeState( - terminalPanel: terminalPanel, - window: window, - ready: true, - additionalPayload: payload - ) - lastHandledCommandID = commandID - } - - @MainActor - func evaluate() { - guard !resolved else { return } - let currentTabManager = self.tabManager - let workspace = currentTabManager?.selectedWorkspace ?? currentTabManager?.tabs.first - let terminalPanel = cmdClickUITestTerminalPanel(in: workspace) - let mainWindow = terminalPanel?.surface.uiWindow - ?? currentTabManager.flatMap { self.windowId(for: $0).flatMap { self.mainWindow(for: $0) } } - if Date() >= deadline { - let textSnapshot = terminalPanel - .flatMap { TerminalController.shared.readTerminalTextForSnapshot(terminalPanel: $0, lineLimit: 200) } ?? "" - var timeoutPayload: [String: Any] = [:] - if let currentTabManager { - timeoutPayload["tabManager"] = debugManagerToken(currentTabManager) - timeoutPayload["workspaceCount"] = currentTabManager.tabs.count - } - let waitingFor = [ - workspace == nil ? "workspace" : nil, - terminalPanel == nil ? "terminalPanel" : nil, - mainWindow == nil ? "mainWindow" : nil - ] - .compactMap { $0 } - .joined(separator: ",") - if !waitingFor.isEmpty { - timeoutPayload["waitingFor"] = waitingFor - } - writeState( - terminalPanel: terminalPanel, - window: mainWindow, - ready: false, - setupError: "Timed out waiting for terminal cmd-click setup. text=\(textSnapshot)", - additionalPayload: timeoutPayload - ) - resolved = true - cleanup() - return - } - - if currentTabManager == nil { - writeTerminalCmdClickUITestData(at: manifestPath, updates: [ - "ready": "0", - "setupError": "Waiting for tab manager" - ]) - return - } - - guard let workspace, - let terminalPanel, - let mainWindow else { - var waitingPayload: [String: Any] = [ - "ready": "0", - "setupError": "Waiting for terminal workspace" - ] - if let currentTabManager { - waitingPayload["tabManager"] = debugManagerToken(currentTabManager) - waitingPayload["workspaceCount"] = currentTabManager.tabs.count - } - let waitingFor = [ - workspace == nil ? "workspace" : nil, - terminalPanel == nil ? "terminalPanel" : nil, - mainWindow == nil ? "mainWindow" : nil - ] - .compactMap { $0 } - .joined(separator: ",") - if !waitingFor.isEmpty { - waitingPayload["waitingFor"] = waitingFor - } - writeTerminalCmdClickUITestData(at: manifestPath, updates: waitingPayload) - return - } - - resizeWindowIfNeeded(mainWindow) - mainWindow.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - terminalPanel.focus() - - do { - try FileManager.default.createDirectory( - at: fixtureDirectoryURL, - withIntermediateDirectories: true - ) - try FileManager.default.createDirectory( - at: expectedFileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - if !FileManager.default.fileExists(atPath: expectedFileURL.path) { - try "fixture\n".write(to: expectedFileURL, atomically: true, encoding: .utf8) - } - if !FileManager.default.fileExists(atPath: siblingFileURL.path) { - try "fixture\n".write(to: siblingFileURL, atomically: true, encoding: .utf8) - } - for extraFileName in extraFileNames where !extraFileName.isEmpty { - let extraFileURL = fixtureDirectoryURL.appendingPathComponent(extraFileName) - try FileManager.default.createDirectory( - at: extraFileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - if !FileManager.default.fileExists(atPath: extraFileURL.path) { - try "fixture\n".write(to: extraFileURL, atomically: true, encoding: .utf8) - } - } - } catch { - writeState( - terminalPanel: terminalPanel, - window: mainWindow, - ready: false, - setupError: "Failed to create fixture: \(error.localizedDescription)" - ) - resolved = true - cleanup() - return - } - - workspace.updatePanelDirectory(panelId: terminalPanel.id, directory: fixtureDirectoryURL.path) - - let terminalFrame = terminalPanel.hostedView.debugPortalFrameInWindow - let terminalReady = terminalPanel.surface.surface != nil - let terminalVisible = terminalPanel.surface.isViewInWindow && - terminalPanel.hostedView.debugPortalVisibleInUI && - !terminalFrame.isEmpty && - terminalFrame.width > 0 && - terminalFrame.height > 0 - - if terminalReady && terminalVisible && !seeded { - seeded = true - sendTextWhenReady(shellCommand, to: workspace, beforeSend: { - workspace.updatePanelDirectory(panelId: terminalPanel.id, directory: fixtureDirectoryURL.path) - }) - } - - let visibleText = TerminalController.shared.readTerminalTextForSnapshot( - terminalPanel: terminalPanel, - lineLimit: 200 - ) ?? "" - let renderedTokenCount = max(0, visibleText.components(separatedBy: displayToken).count - 1) - let hasRenderedToken = renderedTokenCount >= 6 - if hasRenderedToken, - (tokenPointPayload?["tokenLayoutMatch"] as? String) != "1" { - tokenPointPayload = tokenPoints(in: terminalPanel, visibleText: visibleText) - } - let tokenLayoutReady = (tokenPointPayload?["tokenLayoutMatch"] as? String) == "1" - - writeState( - terminalPanel: terminalPanel, - window: mainWindow, - ready: terminalReady && terminalVisible && hasRenderedToken && tokenLayoutReady, - additionalPayload: [ - "seeded": seeded ? "1" : "0", - "hasRenderedToken": hasRenderedToken ? "1" : "0", - "renderedTokenCount": renderedTokenCount, - "visibleTextTail": String(visibleText.suffix(1200)) - ] - ) - - guard terminalReady, terminalVisible, hasRenderedToken, tokenLayoutReady else { return } - if commandPath?.isEmpty == false { - executePendingCommandIfNeeded( - workspace: workspace, - terminalPanel: terminalPanel, - window: mainWindow - ) - return - } - resolved = true - cleanup() - } - - observers.append(NotificationCenter.default.addObserver( - forName: NSWindow.didUpdateNotification, - object: nil, - queue: .main - ) { _ in - Task { @MainActor in evaluate() } - }) - observers.append(NotificationCenter.default.addObserver( - forName: .terminalSurfaceHostedViewDidMoveToWindow, - object: nil, - queue: .main - ) { _ in - Task { @MainActor in evaluate() } - }) - observers.append(NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: nil, - queue: .main - ) { _ in - Task { @MainActor in evaluate() } - }) - observers.append(NotificationCenter.default.addObserver( - forName: .ghosttyDidFocusSurface, - object: nil, - queue: .main - ) { _ in - Task { @MainActor in evaluate() } - }) - let poller = DispatchSource.makeTimerSource(queue: .main) - poller.schedule(deadline: .now(), repeating: .milliseconds(100)) - poller.setEventHandler { - Task { @MainActor in evaluate() } - } - terminalCmdClickUITestPoller = poller - cmuxDebugLog("cmdclick.ui.setup poller_started manifest=\(manifestPath)") - poller.resume() - } - - private func writeTerminalCmdClickUITestData(at path: String, updates: [String: Any]) { - let url = URL(fileURLWithPath: path) - var payload: [String: Any] = [:] - if let data = try? Data(contentsOf: url), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - payload = object - } - for (key, value) in updates { - payload[key] = value - } - guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else { - cmuxDebugLog("cmdclick.ui.write skip reason=json path=\(path)") - return - } - do { - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try data.write(to: url, options: .atomic) - } catch { - cmuxDebugLog("cmdclick.ui.write error path=\(path) error=\(error.localizedDescription)") - } - } +#if DEBUG private func scheduleUITestSocketSanityCheckIfNeeded() { let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_SOCKET_SANITY"] == "1" else { return payload } diff --git a/Sources/Debug/UITests/TerminalCmdClickUITestConfiguration.swift b/Sources/Debug/UITests/TerminalCmdClickUITestConfiguration.swift new file mode 100644 index 000000000000..4e621f478bbb --- /dev/null +++ b/Sources/Debug/UITests/TerminalCmdClickUITestConfiguration.swift @@ -0,0 +1,18 @@ +#if DEBUG +import Foundation + +/// Captures the environment used to configure the terminal Cmd-click fixture. +struct TerminalCmdClickUITestConfiguration { + let environment: [String: String] + + init(environment: [String: String]) { + self.environment = environment + } + + var isEnabled: Bool { environment["CMUX_UI_TEST_TERMINAL_CMD_CLICK_SETUP"] == "1" } + + func value(for key: String) -> String? { + environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines) + } +} +#endif diff --git a/Sources/Debug/UITests/TerminalCmdClickUITestCoordinator.swift b/Sources/Debug/UITests/TerminalCmdClickUITestCoordinator.swift new file mode 100644 index 000000000000..642e2b3eb638 --- /dev/null +++ b/Sources/Debug/UITests/TerminalCmdClickUITestCoordinator.swift @@ -0,0 +1,810 @@ +#if DEBUG +import AppKit +import Foundation + +/// Coordinates the terminal Cmd-click UI-test fixture and its command protocol. +@MainActor +final class TerminalCmdClickUITestCoordinator { + struct Dependencies { + let environment: [String: String] + let notificationCenter: NotificationCenter + let tabManager: () -> TabManager? + let window: (TabManager) -> NSWindow? + let managerToken: (TabManager?) -> String + let sendText: (String, Workspace, (() -> Void)?) -> Void + } + + private let dependencies: Dependencies + private let manifestWriter: TerminalCmdClickUITestManifestWriter + private var didStart = false + private var pollerTimer: DispatchSourceTimer? + + init( + dependencies: Dependencies, + manifestWriter: TerminalCmdClickUITestManifestWriter = TerminalCmdClickUITestManifestWriter() + ) { + self.dependencies = dependencies + self.manifestWriter = manifestWriter + } + + deinit { + pollerTimer?.cancel() + } + + func startIfNeeded() { + guard !didStart else { return } + + let configuration = TerminalCmdClickUITestConfiguration(environment: dependencies.environment) + let env = configuration.environment + guard configuration.isEnabled else { + cmuxDebugLog("cmdclick.ui.setup skip reason=env_missing tag=\(env["CMUX_TAG"] ?? "nil")") + return + } + guard let manifestPath = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_PATH"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !manifestPath.isEmpty else { + cmuxDebugLog("cmdclick.ui.setup skip reason=missing_manifest_path") + return + } + didStart = true + guard let fixtureDirectory = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_FIXTURE_DIR"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !fixtureDirectory.isEmpty else { + cmuxDebugLog("cmdclick.ui.setup error reason=missing_fixture_dir manifest=\(manifestPath)") + manifestWriter.write(updates: [ + "setupError": "Missing fixture directory" + ], at: manifestPath) + return + } + let commandPath = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_COMMAND_PATH"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let screenshotDirectory = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_SCREENSHOT_DIR"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let displayMode = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_DISPLAY_MODE"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let lineFormat = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_LINE_FORMAT"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let linePrefix = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_LINE_PREFIX"] ?? "" + let displaySuffix = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_DISPLAY_SUFFIX"] ?? "" + let displayAsAbsolutePath = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_DISPLAY_AS_ABSOLUTE_PATH"] == "1" + if let rawOpenSupportedFiles = env["CMUX_UI_TEST_OPEN_SUPPORTED_FILES_IN_CMUX"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !rawOpenSupportedFiles.isEmpty { + FileRouteSettingsStore(defaults: .standard).setSupportedFileRouteEnabled(rawOpenSupportedFiles == "1") + } + if let rawOpenMarkdown = env["CMUX_UI_TEST_OPEN_MARKDOWN_IN_CMUX_VIEWER"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !rawOpenMarkdown.isEmpty { + FileRouteSettingsStore(defaults: .standard).setMarkdownRouteEnabled(rawOpenMarkdown == "1") + } + let extraFileNamesJSON = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_EXTRA_FILE_NAMES_JSON"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + + let fileName = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_FILE_NAME"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedFileName = (fileName?.isEmpty == false) ? fileName! : "Cmd Click Fixture.txt" + let fixtureDirectoryURL = URL(fileURLWithPath: fixtureDirectory, isDirectory: true) + let expectedFileURL = fixtureDirectoryURL.appendingPathComponent(resolvedFileName) + let siblingFileURL = fixtureDirectoryURL.appendingPathComponent("OtherFile") + let extraFileNames: [String] + if let extraFileNamesJSON, + let data = extraFileNamesJSON.data(using: .utf8), + let values = try? JSONSerialization.jsonObject(with: data) as? [String] { + extraFileNames = values + } else { + extraFileNames = [] + } + let escapedToken = resolvedFileName.replacingOccurrences(of: " ", with: "\\ ") + let baseDisplayToken = displayAsAbsolutePath ? expectedFileURL.path : resolvedFileName + let resolvedDisplayMode = (displayMode == "raw") ? "raw" : "escaped" + let resolvedLineFormat: String + switch lineFormat { + case "log": + resolvedLineFormat = "log" + case "alt_screen_log": + resolvedLineFormat = "alt_screen_log" + case "osc8": + resolvedLineFormat = "osc8" + default: + resolvedLineFormat = "grid" + } + cmuxDebugLog( + "cmdclick.ui.setup start manifest=\(manifestPath) fixture=\(fixtureDirectory) " + + "command=\(commandPath ?? "nil") display=\(resolvedDisplayMode) " + + "lineFormat=\(resolvedLineFormat) " + + "file=\(resolvedFileName)" + ) + func singleQuotedShellLiteral(_ text: String) -> String { + text.replacingOccurrences(of: "'", with: "'\"'\"'") + } + let displayToken: String + let shellCommand: String + switch resolvedLineFormat { + case "osc8": + displayToken = resolvedFileName + let escapedDisplayToken = singleQuotedShellLiteral(displayToken) + let escapedURL = singleQuotedShellLiteral(expectedFileURL.absoluteString) + shellCommand = "clear\rfor i in $(seq 1 48); do printf '\\033]8;;%s\\033\\\\%s\\033]8;;\\033\\\\\\n' '\(escapedURL)' '\(escapedDisplayToken)'; done\r" + case "log": + displayToken = "\(baseDisplayToken)\(displaySuffix)" + let blockLine = "\(linePrefix)\(displayToken)" + let shellBlockLine = singleQuotedShellLiteral(blockLine) + shellCommand = "clear\rfor i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" + case "alt_screen_log": + displayToken = "\(baseDisplayToken)\(displaySuffix)" + let blockLine = "\(linePrefix)\(displayToken)" + let shellBlockLine = singleQuotedShellLiteral(blockLine) + shellCommand = "clear\rprintf '\\033[?1049h\\033[H\\033[2J'; for i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" + default: + switch resolvedDisplayMode { + case "raw": + displayToken = "\(baseDisplayToken)\(displaySuffix)" + let blockLine = "\(displayToken) OtherFile" + let shellBlockLine = singleQuotedShellLiteral(blockLine) + shellCommand = "clear\rfor i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" + default: + displayToken = "\(escapedToken)\(displaySuffix)" + let blockLine = Array(repeating: displayToken, count: 3).joined(separator: " ") + let shellBlockLine = singleQuotedShellLiteral(blockLine) + shellCommand = "clear\rfor i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" + } + } + let deadline = Date().addingTimeInterval((commandPath?.isEmpty == false) ? 60.0 : 20.0) + var seeded = false + var resolved = false + var tokenPointPayload: [String: Any]? + var observers: [NSObjectProtocol] = [] + var lastHandledCommandID: String? + var screenshotSequence = 0 + + func rectPayload(_ rect: CGRect) -> [String: Double] { + [ + "x": rect.origin.x, + "y": rect.origin.y, + "width": rect.size.width, + "height": rect.size.height + ] + } + + func pointPayload(x: CGFloat, yFromTop: CGFloat) -> [String: Double] { + [ + "x": x, + "y": yFromTop + ] + } + + func doubleValue(_ value: Any?) -> Double? { + if let value = value as? Double { + return value + } + if let value = value as? NSNumber { + return value.doubleValue + } + return nil + } + + func pointFromPayload(_ key: String, in terminalPanel: TerminalPanel) -> NSPoint? { + guard let payload = tokenPointPayload?[key] as? [String: Any], + let x = doubleValue(payload["x"]), + let yFromTop = doubleValue(payload["y"]) else { + return nil + } + + let clampedX = min(max(CGFloat(x), 1), max(terminalPanel.hostedView.bounds.width - 1, 1)) + let clampedYFromTop = min( + max(CGFloat(yFromTop), 1), + max(terminalPanel.hostedView.bounds.height - 1, 1) + ) + return NSPoint( + x: clampedX, + y: terminalPanel.hostedView.bounds.height - clampedYFromTop + ) + } + + func pointForTokenColumnOffset(_ offset: Int, in terminalPanel: TerminalPanel) -> NSPoint? { + guard let selectionStart = pointFromPayload("tokenSelectionStartInTerminal", in: terminalPanel), + let tokenCellMetrics = tokenPointPayload?["tokenCellMetrics"] as? [String: Any], + let cellWidth = doubleValue(tokenCellMetrics["cellWidth"]) else { + return nil + } + + let unclampedX = selectionStart.x + (CGFloat(offset) * CGFloat(cellWidth)) + let clampedX = min(max(unclampedX, 1), max(terminalPanel.hostedView.bounds.width - 1, 1)) + return NSPoint(x: clampedX, y: selectionStart.y) + } + + func commandPoint( + from command: [String: Any], + defaultPayloadKey: String, + in terminalPanel: TerminalPanel + ) -> NSPoint? { + if let tokenColumnOffset = command["tokenColumnOffset"] as? Int { + return pointForTokenColumnOffset(tokenColumnOffset, in: terminalPanel) + } + if let tokenColumnOffset = command["tokenColumnOffset"] as? NSNumber { + return pointForTokenColumnOffset(tokenColumnOffset.intValue, in: terminalPanel) + } + return pointFromPayload(defaultPayloadKey, in: terminalPanel) + } + + func loadCommand(at path: String) -> [String: Any]? { + let url = URL(fileURLWithPath: path) + guard let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return object + } + + func tokenPoints(in terminalPanel: TerminalPanel, visibleText: String) -> [String: Any]? { + guard let surface = terminalPanel.surface.surface else { return nil } + let bounds = terminalPanel.hostedView.bounds + guard bounds.width > 0, bounds.height > 0 else { return nil } + + let size = ghostty_surface_size(surface) + let rows = max(Int(size.rows), 1) + let cols = max(Int(size.columns), 1) + let debugCellSize = terminalPanel.hostedView.debugCellSize + let cellWidth = debugCellSize.width > 0 ? debugCellSize.width : CGFloat(size.cell_width_px) + let cellHeight = debugCellSize.height > 0 ? debugCellSize.height : CGFloat(size.cell_height_px) + guard cellWidth > 0, cellHeight > 0 else { return nil } + + let xInset = max(0, (bounds.width - (CGFloat(cols) * cellWidth)) / 2) + let yInset = max(0, (bounds.height - (CGFloat(rows) * cellHeight)) / 2) + let pointClampX: (CGFloat) -> CGFloat = { x in + min(bounds.width - 4, max(4, x)) + } + let pointClampY: (CGFloat) -> CGFloat = { y in + min(bounds.height - 4, max(4, y)) + } + + let rawVisibleLines = visibleText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let visibleLines = rawVisibleLines.count > rows ? Array(rawVisibleLines.suffix(rows)) : rawVisibleLines + let rowOffset = max(0, rows - visibleLines.count) + + var matchedRowFromTop: Int? + var matchedColumnStart: Int? + var matchedColumnEnd: Int? + var matchedLine = "" + var matchingLines: [(lineIndex: Int, line: String, ranges: [Range])] = [] + + for (lineIndex, line) in visibleLines.enumerated() { + var searchStart = line.startIndex + var ranges: [Range] = [] + while searchStart < line.endIndex, + let range = line.range(of: displayToken, range: searchStart.. String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.")) + let scalars = label.unicodeScalars.map { allowed.contains($0) ? Character($0) : "-" } + let cleaned = String(scalars).trimmingCharacters(in: CharacterSet(charactersIn: "-_.")) + return cleaned.isEmpty ? "capture" : cleaned + } + + @MainActor + func captureWindowSnapshotIfRequested(label: String, window: NSWindow) -> String? { + guard let screenshotDirectory, + !screenshotDirectory.isEmpty, + let contentView = window.contentView else { + return nil + } + let bounds = contentView.bounds + guard !bounds.isEmpty, + let bitmap = contentView.bitmapImageRepForCachingDisplay(in: bounds) else { + return nil + } + contentView.cacheDisplay(in: bounds, to: bitmap) + guard let data = bitmap.representation(using: .png, properties: [:]) else { + return nil + } + do { + let directoryURL = URL(fileURLWithPath: screenshotDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + let sequence = String(format: "%03d", screenshotSequence) + screenshotSequence += 1 + let fileURL = directoryURL + .appendingPathComponent("\(sequence)-\(safeScreenshotLabel(label)).png") + try data.write(to: fileURL, options: .atomic) + return fileURL.path + } catch { + cmuxDebugLog("cmdclick.ui.snapshot failed label=\(label) error=\(error.localizedDescription)") + return nil + } + } + + func cmdClickUITestTerminalPanel(in workspace: Workspace?) -> TerminalPanel? { + guard let workspace else { return nil } + if let inputPanel = workspace.focusedTerminalInputTarget()?.panel { + return inputPanel + } + return workspace.panels.values + .compactMap { $0 as? TerminalPanel } + .first { panel in + panel.surface.isViewInWindow && + panel.hostedView.debugPortalVisibleInUI && + !panel.hostedView.debugPortalFrameInWindow.isEmpty + } + } + + @MainActor + func executePendingCommandIfNeeded( + workspace: Workspace, + terminalPanel: TerminalPanel, + window: NSWindow + ) { + guard let commandPath, + !commandPath.isEmpty, + let command = loadCommand(at: commandPath), + let commandID = command["id"] as? String, + commandID != lastHandledCommandID else { + return + } + + let action = (command["action"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + var payload: [String: Any] = [ + "lastCommandId": commandID, + "lastCommandAction": action, + "lastCommandSucceeded": "0" + ] + + switch action { + case "hover_token": + guard let hitPoint = commandPoint( + from: command, + defaultPayloadKey: "tokenHitPointInTerminal", + in: terminalPanel + ) else { + payload["lastCommandError"] = "Missing command point" + break + } + + let result = terminalPanel.hostedView.debugSimulateCommandHoverDetails(at: hitPoint) + payload["lastCommandResult"] = result + payload["lastCommandHoverActive"] = result["hoverActive"] + if let resolvedPath = result["resolvedPath"] as? String { + payload["lastCommandResolvedPath"] = resolvedPath + payload["lastCommandSucceeded"] = "1" + } else if let error = result["error"] as? String { + payload["lastCommandError"] = error + } else { + payload["lastCommandError"] = "Command hover did not resolve a path" + } + + case "cmd_click_token": + guard let hitPoint = commandPoint( + from: command, + defaultPayloadKey: "tokenHitPointInTerminal", + in: terminalPanel + ) else { + payload["lastCommandError"] = "Missing command point" + break + } + + let result = terminalPanel.hostedView.debugSimulateCommandClick(at: hitPoint) + payload["lastCommandResult"] = result + if let openedPath = result["openedPath"] as? String { + payload["lastCommandOpenedPath"] = openedPath + let canonicalOpenedPath = (openedPath as NSString).resolvingSymlinksInPath + let openedInFilePreview = workspace.panels.values.contains { panel in + guard let filePreview = panel as? FilePreviewPanel else { return false } + return (filePreview.filePath as NSString).resolvingSymlinksInPath == canonicalOpenedPath + } + let openedInMarkdownViewer = workspace.panels.values.contains { panel in + guard let markdown = panel as? MarkdownPanel else { return false } + return (markdown.filePath as NSString).resolvingSymlinksInPath == canonicalOpenedPath + } + payload["lastCommandOpenedInFilePreview"] = openedInFilePreview ? "1" : "0" + payload["lastCommandOpenedInMarkdownViewer"] = openedInMarkdownViewer ? "1" : "0" + payload["lastCommandSucceeded"] = "1" + } else if let error = result["error"] as? String { + payload["lastCommandError"] = error + } else { + payload["lastCommandError"] = "Command click did not open a path" + } + + case "stationary_cmd_click_token": + guard let hitPoint = commandPoint( + from: command, + defaultPayloadKey: "tokenHitPointInTerminal", + in: terminalPanel + ) else { + payload["lastCommandError"] = "Missing command point" + break + } + + let capturePath = dependencies.environment["CMUX_UI_TEST_CAPTURE_OPEN_URL_PATH"] + let beforeURLCount = capturePath.flatMap { try? String(contentsOfFile: $0, encoding: .utf8) }? + .split(separator: "\n").count ?? 0 + let result = terminalPanel.hostedView.debugSimulateStationaryCommandClick(at: hitPoint) + payload["lastCommandResult"] = result + let openedURLs = capturePath.flatMap { try? String(contentsOfFile: $0, encoding: .utf8) }? + .split(separator: "\n").map(String.init) ?? [] + if openedURLs.count > beforeURLCount, let openedURL = openedURLs.last { + payload["lastCommandOpenedURL"] = openedURL + payload["lastCommandSucceeded"] = "1" + } else if let error = result["error"] as? String { + payload["lastCommandError"] = error + } else { + payload["lastCommandError"] = "Stationary command click did not open a URL" + } + + case "select_token_and_hold_command": + guard let selectionStart = pointFromPayload("tokenSelectionStartInTerminal", in: terminalPanel), + let selectionEnd = pointFromPayload("tokenSelectionEndInTerminal", in: terminalPanel) else { + payload["lastCommandError"] = "Missing token selection points" + break + } + + let selectionActive = terminalPanel.hostedView.debugSimulateSelection( + from: selectionStart, + to: selectionEnd + ) + let hoverSuppressed = terminalPanel.hostedView.debugSimulateCommandHover(at: selectionEnd) + payload["lastCommandSelectionActive"] = selectionActive ? "1" : "0" + payload["lastCommandHoverSuppressed"] = hoverSuppressed ? "1" : "0" + if selectionActive && hoverSuppressed { + payload["lastCommandSucceeded"] = "1" + } else { + payload["lastCommandError"] = "Selection or hover suppression failed" + } + + case "capture_window": + let label = (command["label"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let path = captureWindowSnapshotIfRequested( + label: label?.isEmpty == false ? label! : "capture", + window: window + ) { + payload["lastCommandScreenshotPath"] = path + payload["lastCommandSucceeded"] = "1" + } else { + payload["lastCommandError"] = "Window screenshot capture unavailable" + } + + default: + payload["lastCommandError"] = "Unknown command action: \(action)" + } + + writeState( + terminalPanel: terminalPanel, + window: window, + ready: true, + additionalPayload: payload + ) + lastHandledCommandID = commandID + } + + @MainActor + func evaluate() { + guard !resolved else { return } + let currentTabManager = dependencies.tabManager() + let workspace = currentTabManager?.selectedWorkspace ?? currentTabManager?.tabs.first + let terminalPanel = cmdClickUITestTerminalPanel(in: workspace) + let mainWindow = terminalPanel?.surface.uiWindow + ?? currentTabManager.flatMap { dependencies.window($0) } + if Date() >= deadline { + let textSnapshot = terminalPanel + .flatMap { TerminalController.shared.readTerminalTextForSnapshot(terminalPanel: $0, lineLimit: 200) } ?? "" + var timeoutPayload: [String: Any] = [:] + if let currentTabManager { + timeoutPayload["tabManager"] = dependencies.managerToken(currentTabManager) + timeoutPayload["workspaceCount"] = currentTabManager.tabs.count + } + let waitingFor = [ + workspace == nil ? "workspace" : nil, + terminalPanel == nil ? "terminalPanel" : nil, + mainWindow == nil ? "mainWindow" : nil + ] + .compactMap { $0 } + .joined(separator: ",") + if !waitingFor.isEmpty { + timeoutPayload["waitingFor"] = waitingFor + } + writeState( + terminalPanel: terminalPanel, + window: mainWindow, + ready: false, + setupError: "Timed out waiting for terminal cmd-click setup. text=\(textSnapshot)", + additionalPayload: timeoutPayload + ) + resolved = true + cleanup() + return + } + + if currentTabManager == nil { + manifestWriter.write(updates: [ + "ready": "0", + "setupError": "Waiting for tab manager" + ], at: manifestPath) + return + } + + guard let workspace, + let terminalPanel, + let mainWindow else { + var waitingPayload: [String: Any] = [ + "ready": "0", + "setupError": "Waiting for terminal workspace" + ] + if let currentTabManager { + waitingPayload["tabManager"] = dependencies.managerToken(currentTabManager) + waitingPayload["workspaceCount"] = currentTabManager.tabs.count + } + let waitingFor = [ + workspace == nil ? "workspace" : nil, + terminalPanel == nil ? "terminalPanel" : nil, + mainWindow == nil ? "mainWindow" : nil + ] + .compactMap { $0 } + .joined(separator: ",") + if !waitingFor.isEmpty { + waitingPayload["waitingFor"] = waitingFor + } + manifestWriter.write(updates: waitingPayload, at: manifestPath) + return + } + + resizeWindowIfNeeded(mainWindow) + mainWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + terminalPanel.focus() + + do { + try FileManager.default.createDirectory( + at: fixtureDirectoryURL, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: expectedFileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if !FileManager.default.fileExists(atPath: expectedFileURL.path) { + try "fixture\n".write(to: expectedFileURL, atomically: true, encoding: .utf8) + } + if !FileManager.default.fileExists(atPath: siblingFileURL.path) { + try "fixture\n".write(to: siblingFileURL, atomically: true, encoding: .utf8) + } + for extraFileName in extraFileNames where !extraFileName.isEmpty { + let extraFileURL = fixtureDirectoryURL.appendingPathComponent(extraFileName) + try FileManager.default.createDirectory( + at: extraFileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if !FileManager.default.fileExists(atPath: extraFileURL.path) { + try "fixture\n".write(to: extraFileURL, atomically: true, encoding: .utf8) + } + } + } catch { + writeState( + terminalPanel: terminalPanel, + window: mainWindow, + ready: false, + setupError: "Failed to create fixture: \(error.localizedDescription)" + ) + resolved = true + cleanup() + return + } + + workspace.updatePanelDirectory(panelId: terminalPanel.id, directory: fixtureDirectoryURL.path) + + let terminalFrame = terminalPanel.hostedView.debugPortalFrameInWindow + let terminalReady = terminalPanel.surface.surface != nil + let terminalVisible = terminalPanel.surface.isViewInWindow && + terminalPanel.hostedView.debugPortalVisibleInUI && + !terminalFrame.isEmpty && + terminalFrame.width > 0 && + terminalFrame.height > 0 + + if terminalReady && terminalVisible && !seeded { + seeded = true + dependencies.sendText(shellCommand, workspace, { + workspace.updatePanelDirectory(panelId: terminalPanel.id, directory: fixtureDirectoryURL.path) + }) + } + + let visibleText = TerminalController.shared.readTerminalTextForSnapshot( + terminalPanel: terminalPanel, + lineLimit: 200 + ) ?? "" + let renderedTokenCount = max(0, visibleText.components(separatedBy: displayToken).count - 1) + let hasRenderedToken = renderedTokenCount >= 6 + if hasRenderedToken, + (tokenPointPayload?["tokenLayoutMatch"] as? String) != "1" { + tokenPointPayload = tokenPoints(in: terminalPanel, visibleText: visibleText) + } + let tokenLayoutReady = (tokenPointPayload?["tokenLayoutMatch"] as? String) == "1" + + writeState( + terminalPanel: terminalPanel, + window: mainWindow, + ready: terminalReady && terminalVisible && hasRenderedToken && tokenLayoutReady, + additionalPayload: [ + "seeded": seeded ? "1" : "0", + "hasRenderedToken": hasRenderedToken ? "1" : "0", + "renderedTokenCount": renderedTokenCount, + "visibleTextTail": String(visibleText.suffix(1200)) + ] + ) + + guard terminalReady, terminalVisible, hasRenderedToken, tokenLayoutReady else { return } + if commandPath?.isEmpty == false { + executePendingCommandIfNeeded( + workspace: workspace, + terminalPanel: terminalPanel, + window: mainWindow + ) + return + } + resolved = true + cleanup() + } + + observers.append(dependencies.notificationCenter.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + observers.append(dependencies.notificationCenter.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + observers.append(dependencies.notificationCenter.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + observers.append(dependencies.notificationCenter.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + let poller = DispatchSource.makeTimerSource(queue: .main) + poller.schedule(deadline: .now(), repeating: .milliseconds(100)) + poller.setEventHandler { + Task { @MainActor in evaluate() } + } + pollerTimer = poller + cmuxDebugLog("cmdclick.ui.setup poller_started manifest=\(manifestPath)") + poller.resume() + } +} +#endif diff --git a/Sources/Debug/UITests/TerminalCmdClickUITestManifestWriter.swift b/Sources/Debug/UITests/TerminalCmdClickUITestManifestWriter.swift new file mode 100644 index 000000000000..5ab16220be68 --- /dev/null +++ b/Sources/Debug/UITests/TerminalCmdClickUITestManifestWriter.swift @@ -0,0 +1,31 @@ +#if DEBUG +import Foundation + +/// Merges Cmd-click fixture state into the JSON manifest consumed by UI tests. +final class TerminalCmdClickUITestManifestWriter { + func write(updates: [String: Any], at path: String) { + let url = URL(fileURLWithPath: path) + var payload: [String: Any] = [:] + if let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + payload = object + } + for (key, value) in updates { + payload[key] = value + } + guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else { + cmuxDebugLog("cmdclick.ui.write skip reason=json path=\(path)") + return + } + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: url, options: .atomic) + } catch { + cmuxDebugLog("cmdclick.ui.write error path=\(path) error=\(error.localizedDescription)") + } + } +} +#endif diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 456724be470b..ce7c52d97f3e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2257,6 +2257,9 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B8835A000000000000000003 /* TerminalClipboardInputSequencerTests+Replay.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8835A000000000000000004 /* TerminalClipboardInputSequencerTests+Replay.swift */; }; B8835700000000000000000D /* TerminalClipboardInputSequencerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8835700000000000000000E /* TerminalClipboardInputSequencerTests.swift */; }; B88357000000000000000013 /* TerminalClipboardRequestSurfaceIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88357000000000000000014 /* TerminalClipboardRequestSurfaceIdentity.swift */; }; + E7C100000000000000000003 /* TerminalCmdClickUITestConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C100000000000000000004 /* TerminalCmdClickUITestConfiguration.swift */; }; + E7C100000000000000000001 /* TerminalCmdClickUITestCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C100000000000000000002 /* TerminalCmdClickUITestCoordinator.swift */; }; + E7C100000000000000000005 /* TerminalCmdClickUITestManifestWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C100000000000000000006 /* TerminalCmdClickUITestManifestWriter.swift */; }; C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; F87910190000000000000001 /* TerminalConfigurationReloadCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87910190000000000000002 /* TerminalConfigurationReloadCoordinator.swift */; }; F87930000000000000000001 /* TerminalConfigurationReloadEnqueueResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87930000000000000000002 /* TerminalConfigurationReloadEnqueueResult.swift */; }; @@ -5020,6 +5023,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B8835A000000000000000004 /* TerminalClipboardInputSequencerTests+Replay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalClipboardInputSequencerTests+Replay.swift"; sourceTree = ""; }; B8835700000000000000000E /* TerminalClipboardInputSequencerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalClipboardInputSequencerTests.swift; sourceTree = ""; }; B88357000000000000000014 /* TerminalClipboardRequestSurfaceIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalClipboardRequestSurfaceIdentity.swift; sourceTree = ""; }; + E7C100000000000000000004 /* TerminalCmdClickUITestConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Debug/UITests/TerminalCmdClickUITestConfiguration.swift"; sourceTree = ""; }; + E7C100000000000000000002 /* TerminalCmdClickUITestCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Debug/UITests/TerminalCmdClickUITestCoordinator.swift"; sourceTree = ""; }; + E7C100000000000000000006 /* TerminalCmdClickUITestManifestWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Debug/UITests/TerminalCmdClickUITestManifestWriter.swift"; sourceTree = ""; }; C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; F87910190000000000000002 /* TerminalConfigurationReloadCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalConfigurationReloadCoordinator.swift; sourceTree = ""; }; F87930000000000000000002 /* TerminalConfigurationReloadEnqueueResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalConfigurationReloadEnqueueResult.swift; sourceTree = ""; }; @@ -6865,6 +6871,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5001090 /* AppDelegate.swift */, D9E700000000000000000002 /* SessionSnapshotPersistenceWriter.swift */, FEE012340000000000000002 /* FeedSidebarUITestCoordinator.swift */, + E7C100000000000000000002 /* TerminalCmdClickUITestCoordinator.swift */, + E7C100000000000000000004 /* TerminalCmdClickUITestConfiguration.swift */, + E7C100000000000000000006 /* TerminalCmdClickUITestManifestWriter.swift */, FEE012340000000000000004 /* FeedSidebarUITestPushClient.swift */, FEE012340000000000000006 /* FeedSidebarUITestRecorder.swift */, D9A1B2C3D4E5000000000002 /* UITestDiagnosticsWriter.swift */, @@ -10324,6 +10333,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B8835A000000000000000001 /* TerminalClipboardInputSequencer+State.swift in Sources */, B88357000000000000000005 /* TerminalClipboardInputSequencer.swift in Sources */, B88357000000000000000013 /* TerminalClipboardRequestSurfaceIdentity.swift in Sources */, + E7C100000000000000000003 /* TerminalCmdClickUITestConfiguration.swift in Sources */, + E7C100000000000000000001 /* TerminalCmdClickUITestCoordinator.swift in Sources */, + E7C100000000000000000005 /* TerminalCmdClickUITestManifestWriter.swift in Sources */, F87910190000000000000001 /* TerminalConfigurationReloadCoordinator.swift in Sources */, F87930000000000000000001 /* TerminalConfigurationReloadEnqueueResult.swift in Sources */, F87920000000000000000005 /* TerminalConfigurationReloadPhase.swift in Sources */,