The suite never finished. A SIGSEGV around 272 of 975 tests killed the runner, so xcodebuild only ever reported one shard and roughly seventy percent of the tests were not being executed at all. Underneath it sat four failures, two of which had been red since the day they were written, and three tests whose outcome was decided by state they did not own. The crash: `RemoteWindowTitleTests` closed a window whose `isReleasedWhenClosed` still defaults to true, releasing one ARC also owned. The second release landed on freed memory during an autorelease pool drain, surfacing as `-[_NSWindowTransformAnimation dealloc]` and charged to whichever test happened to be running. Closing it also risked terminating the test host, which is the real application and whose delegate quits once the last window closes. Ordering the window out instead settles both. Never green since written: - `conversationOwnedImportHonorsAttachmentLimitBeforeStartingTasks` staged twenty chips that differed only by label, and `attach` deduplicates on kind and content, so one went in and the limit under test was never reached. - `failedCompactionSaveDoesNotChangeTheInMemoryContext` asked for a cut keeping two tokens after a last turn worth one, so the search walked past it and `compactionSpan` returned nil. Both turns are now sized in the unit the cut is chosen in, sharing `filler(tokens:)`. Stale or ambient: - `ColorSystemTests` read the domain value `colorTag: .blue` as a display colour. The scan predates tag colours by a day. - The Chinese text for a finished run changed in 0b67497; the assertion did not follow. - `conversationOwnedFileImportFinishesAndKeepsFilename` yielded rather than slept, which does not wait for a read off the main actor. - `shiftedPunctuationMatchesItsUnshiftedShortcutKey` rests on AppKit re-deriving a character through the current input source, which it declines for a synthesized event about a third of the time. It now asserts when AppKit answers and stands down when it does not. - `FileURLDropTargetTests` reached `hitTest`, which reads `NSApp.currentEvent` and the machine-wide drag pasteboard for itself. That decision moves to `participates(at:currentEventType:pasteboard:)` with both inputs passed in; `hitTest` keeps its behaviour and the tests keep their own pasteboard. Verified with eight consecutive full runs at 975 of 975.
114 lines
4.4 KiB
Swift
114 lines
4.4 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import GhosttyTerminal
|
|
import Testing
|
|
|
|
@testable import Termind
|
|
|
|
@Suite(.serialized)
|
|
@MainActor
|
|
struct RemoteWindowTitleTests {
|
|
@Test
|
|
func sanitizerKeepsOnePrintableLineAndRejectsEmptyTitles() {
|
|
#expect(RemoteWindowTitle.sanitized(" host: /srv ") == "host: /srv")
|
|
#expect(RemoteWindowTitle.sanitized("host\r\n/srv\u{7}") == "host /srv")
|
|
#expect(RemoteWindowTitle.sanitized("\u{1B}[1;32mgreen\u{1B}[0m") == "green")
|
|
#expect(RemoteWindowTitle.sanitized("\n\u{7}\u{1B}[31m") == nil)
|
|
}
|
|
|
|
@Test
|
|
func menuFollowsOnlyTheKeyWindowAndUsesKittyTitleFormat() throws {
|
|
let mainMenu = NSMenu()
|
|
let help = NSMenuItem(title: "Help", action: nil, keyEquivalent: "")
|
|
help.submenu = NSMenu(title: "Help")
|
|
mainMenu.addItem(help)
|
|
let firstWindow = NSWindow()
|
|
let secondWindow = NSWindow()
|
|
var keyWindow: NSWindow? = firstWindow
|
|
let coordinator = RemoteWindowTitleMenuCoordinator(
|
|
mainMenuProvider: { mainMenu }, keyWindowProvider: { keyWindow },
|
|
observesKeyWindowChanges: false
|
|
)
|
|
let firstToken = UUID()
|
|
let secondToken = UUID()
|
|
|
|
coordinator.report(
|
|
title: "kaede@lima-default:/Users/kaede",
|
|
for: firstWindow, token: firstToken
|
|
)
|
|
#expect(mainMenu.items.count == 2)
|
|
#expect(mainMenu.items[0] === help)
|
|
#expect(mainMenu.items[1].title.isEmpty)
|
|
#expect(
|
|
try #require(mainMenu.items[1].submenu).title
|
|
== " :: kaede@lima-default:/Users/kaede"
|
|
)
|
|
#expect(mainMenu.items[1].submenu?.items.isEmpty == true)
|
|
|
|
coordinator.report(title: "second:/tmp", for: secondWindow, token: secondToken)
|
|
#expect(mainMenu.items[1].submenu?.title == " :: kaede@lima-default:/Users/kaede")
|
|
|
|
keyWindow = secondWindow
|
|
coordinator.refresh()
|
|
#expect(mainMenu.items[1].submenu?.title == " :: second:/tmp")
|
|
|
|
coordinator.withdraw(token: secondToken)
|
|
#expect(mainMenu.items == [help])
|
|
}
|
|
|
|
@Test(
|
|
.disabled(
|
|
if: !ghosttyHasActiveDisplay,
|
|
"Ghostty's Metal surface requires an active display"
|
|
))
|
|
func hostManagedGhosttySurfaceParsesOSCZeroAndTwo() async throws {
|
|
let backend = InMemoryTerminalSession(write: { _ in }, resize: { _ in })
|
|
let state = TerminalViewState(configSource: .none)
|
|
state.configuration = TerminalSurfaceOptions(backend: .inMemory(backend))
|
|
let terminal = TerminalView(frame: NSRect(x: 0, y: 0, width: 640, height: 400))
|
|
terminal.delegate = state
|
|
terminal.configuration = state.configuration
|
|
terminal.controller = state.controller
|
|
let window = NSWindow(
|
|
contentRect: terminal.frame, styleMask: [.titled],
|
|
backing: .buffered, defer: false
|
|
)
|
|
window.contentView = terminal
|
|
window.makeKeyAndOrderFront(nil)
|
|
// Ordered out rather than closed, for two reasons. `close()` on a window
|
|
// whose `isReleasedWhenClosed` still defaults to true releases one ARC
|
|
// also owns, and the second release lands on freed memory — a SIGSEGV in
|
|
// `-[_NSWindowTransformAnimation dealloc]`, charged to whichever test is
|
|
// running when the pool drains. And the test host is the real app, whose
|
|
// delegate terminates it once the last window closes, which ends the run
|
|
// wherever it has got to. Letting ARC take the window is enough.
|
|
defer { window.orderOut(nil) }
|
|
|
|
backend.receive(Data("\u{1B}]0;zero-title\u{7}".utf8))
|
|
try await waitUntil { state.title == "zero-title" }
|
|
|
|
backend.receive(Data("\u{1B}]2;two-title\u{7}".utf8))
|
|
try await waitUntil { state.title == "two-title" }
|
|
}
|
|
|
|
private func waitUntil(
|
|
timeout: Duration = .seconds(2), condition: @MainActor () -> Bool
|
|
) async throws {
|
|
let clock = ContinuousClock()
|
|
let deadline = clock.now.advanced(by: timeout)
|
|
while !condition() {
|
|
guard clock.now < deadline else {
|
|
Issue.record("Timed out waiting for terminal state")
|
|
return
|
|
}
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
}
|
|
}
|
|
|
|
private nonisolated let ghosttyHasActiveDisplay: Bool = {
|
|
var count: UInt32 = 0
|
|
guard CGGetActiveDisplayList(0, nil, &count) == .success else { return false }
|
|
return count > 0
|
|
}()
|