macOS: add regression tests for intrinsicContentSize race (#11256)

Tests that validate intrinsicContentSize returns a correct value when
TerminalController.windowDidLoad() reads it. Currently fail, proving
the race condition where @FocusedValue hasn't propagated
lastFocusedSurface before the 40ms timer fires.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-10 13:45:44 +01:00
parent 703d11c642
commit cfedda1a0e

View File

@@ -0,0 +1,467 @@
import AppKit
import Combine
import SwiftUI
import Testing
@testable import Ghostty
// MARK: - Test helpers
/// Mimics TerminalView's .frame(idealWidth:idealHeight:) pattern where
/// values come from lastFocusedSurface?.value?.initialSize, which may
/// be nil before @FocusedValue propagates.
private struct OptionalIdealSizeView: View {
let idealWidth: CGFloat?
let idealHeight: CGFloat?
let titlebarStyle: String
var body: some View {
VStack(spacing: 0) {
Color.clear
.frame(idealWidth: idealWidth, idealHeight: idealHeight)
}
// Matches TerminalView line 108: hidden style extends into titlebar
.ignoresSafeArea(.container, edges: titlebarStyle == "hidden" ? .top : [])
}
}
private let minReasonableWidth: CGFloat = 100
private let minReasonableHeight: CGFloat = 50
/// All macos-titlebar-style values that map to different window nibs.
private let allTitlebarStyles = ["native", "hidden", "transparent", "tabs"]
/// Window style masks that roughly correspond to each titlebar style.
/// In real Ghostty these come from different nib files; in tests we
/// approximate with NSWindow style masks.
private func styleMask(for titlebarStyle: String) -> NSWindow.StyleMask {
switch titlebarStyle {
case "hidden":
return [.titled, .resizable, .fullSizeContentView]
case "transparent", "tabs":
return [.titled, .resizable, .fullSizeContentView]
default:
return [.titled, .resizable]
}
}
// MARK: - Tests
/// Regression tests for Issue #11256: incorrect intrinsicContentSize
/// race condition in TerminalController.windowDidLoad().
///
/// The contentIntrinsicSize branch of DefaultSize reads
/// intrinsicContentSize after a 40ms delay. But intrinsicContentSize
/// depends on @FocusedValue propagating lastFocusedSurface, which is
/// async and may not complete in time producing a tiny window.
///
/// These tests cover the matrix of:
/// - With/without window-width/window-height (initialSize set vs nil)
/// - All macos-titlebar-style values (native, hidden, transparent, tabs)
@Suite(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "Incorrect intrinsicContentSize with native titlebar"))
struct IntrinsicSizeTimingTests {
// MARK: - Bug: nil ideal sizes tiny window
/// When window-width/height is set, defaultSize returns .contentIntrinsicSize.
/// Before @FocusedValue propagates, idealWidth/idealHeight are nil and
/// intrinsicContentSize returns a tiny value.
@Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "intrinsicContentSize too small before @FocusedValue propagates"),
arguments: allTitlebarStyles)
func intrinsicSizeTooSmallWithNilIdealSize(titlebarStyle: String) async throws {
let expectedSize = NSSize(width: 600, height: 400)
// nil ideal sizes = @FocusedValue hasn't propagated lastFocusedSurface
let container = await TerminalViewContainer {
OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle)
}
// TODO: Fix #11256 set initialContentSize on the container so
// intrinsicContentSize returns the correct value immediately.
// await MainActor.run {
// container.initialContentSize = expectedSize
// }
let window = await NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: styleMask(for: titlebarStyle),
backing: .buffered,
defer: false
)
await MainActor.run {
window.contentView = container
}
let size = await container.intrinsicContentSize
#expect(
size.width >= minReasonableWidth && size.height >= minReasonableHeight,
"[\(titlebarStyle)] intrinsicContentSize is too small: \(size). Expected at least \(minReasonableWidth)x\(minReasonableHeight)"
)
await MainActor.run { window.close() }
}
/// Verifies that DefaultSize.contentIntrinsicSize.apply() produces a
/// too-small window when intrinsicContentSize is based on nil ideal sizes.
@Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "apply() sets wrong window size due to racy intrinsicContentSize"),
arguments: allTitlebarStyles)
func applyProducesWrongSizeWithNilIdealSize(titlebarStyle: String) async throws {
let container = await TerminalViewContainer {
OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle)
}
// TODO: Fix #11256 set initialContentSize on the container.
// await MainActor.run {
// container.initialContentSize = NSSize(width: 600, height: 400)
// }
let window = await NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: styleMask(for: titlebarStyle),
backing: .buffered,
defer: false
)
let contentLayoutSize = await MainActor.run {
window.contentView = container
let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize
defaultSize.apply(to: window)
// Use contentLayoutRect the usable area excluding titlebar
return window.contentLayoutRect.size
}
#expect(
contentLayoutSize.width >= minReasonableWidth && contentLayoutSize.height >= minReasonableHeight,
"[\(titlebarStyle)] Window content layout size is too small after apply: \(contentLayoutSize)"
)
await MainActor.run { window.close() }
}
/// Replicates the exact pattern from TerminalController.windowDidLoad():
/// 1. Set window.contentView = container (with nil ideal sizes, simulating
/// @FocusedValue not yet propagated)
/// 2. DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(40))
/// 3. Inside the callback: defaultSize.apply(to: window)
///
/// This is the core race condition: 40ms is not enough for @FocusedValue
/// to propagate, so intrinsicContentSize is still tiny when apply() runs.
@Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "40ms async delay reads intrinsicContentSize before @FocusedValue propagates"),
arguments: allTitlebarStyles)
func asyncAfterDelayProducesWrongSizeWithNilIdealSize(titlebarStyle: String) async throws {
let container = await TerminalViewContainer {
OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle)
}
// TODO: Fix #11256 set initialContentSize on the container so
// intrinsicContentSize returns the correct value immediately,
// eliminating the need for the async delay.
// await MainActor.run {
// container.initialContentSize = NSSize(width: 600, height: 400)
// }
let window = await NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: styleMask(for: titlebarStyle),
backing: .buffered,
defer: false
)
// Replicate TerminalController.windowDidLoad() exactly:
// 1. Set contentView
// 2. DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(40))
// 3. apply() inside the callback
let contentLayoutSize: NSSize = await withCheckedContinuation { continuation in
DispatchQueue.main.async {
window.contentView = container
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(40)) {
let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize
defaultSize.apply(to: window)
continuation.resume(returning: window.contentLayoutRect.size)
}
}
}
#expect(
contentLayoutSize.width >= minReasonableWidth && contentLayoutSize.height >= minReasonableHeight,
"[\(titlebarStyle)] After 40ms async delay, content layout size is too small: \(contentLayoutSize)"
)
await MainActor.run { window.close() }
}
/// Verifies that applying synchronously (without the async delay) also
/// fails when ideal sizes are nil. This proves the fix must provide a
/// fallback value, not just adjust timing.
@Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "Synchronous apply also fails without fallback"),
arguments: allTitlebarStyles)
func synchronousApplyAlsoFailsWithNilIdealSize(titlebarStyle: String) async throws {
let container = await TerminalViewContainer {
OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle)
}
// TODO: Fix #11256 set initialContentSize on the container.
// await MainActor.run {
// container.initialContentSize = NSSize(width: 600, height: 400)
// }
let window = await NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: styleMask(for: titlebarStyle),
backing: .buffered,
defer: false
)
let contentLayoutSize = await MainActor.run {
window.contentView = container
// Apply immediately no async delay at all
let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize
defaultSize.apply(to: window)
return window.contentLayoutRect.size
}
#expect(
contentLayoutSize.width >= minReasonableWidth && contentLayoutSize.height >= minReasonableHeight,
"[\(titlebarStyle)] Synchronous apply with nil ideal sizes: content layout size too small: \(contentLayoutSize)"
)
await MainActor.run { window.close() }
}
// MARK: - Happy path: ideal sizes available (contentIntrinsicSize path)
/// When @FocusedValue HAS propagated (ideal sizes are set), intrinsicContentSize
/// should be correct for every titlebar style. This is the "happy path" that
/// works today when the 40ms delay is sufficient.
@Test(arguments: allTitlebarStyles)
func intrinsicSizeCorrectWhenIdealSizesAvailable(titlebarStyle: String) async throws {
let expectedSize = NSSize(width: 600, height: 400)
let container = await TerminalViewContainer {
OptionalIdealSizeView(
idealWidth: expectedSize.width,
idealHeight: expectedSize.height,
titlebarStyle: titlebarStyle
)
}
let window = await NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: styleMask(for: titlebarStyle),
backing: .buffered,
defer: false
)
await MainActor.run {
window.contentView = container
}
// Wait for SwiftUI layout
try await Task.sleep(nanoseconds: 100_000_000)
let size = await container.intrinsicContentSize
// intrinsicContentSize should be at least the ideal size.
// With fullSizeContentView styles it may be slightly larger
// due to safe area, but should never be smaller.
#expect(
size.width >= expectedSize.width && size.height >= expectedSize.height,
"[\(titlebarStyle)] intrinsicContentSize (\(size)) should be >= expected \(expectedSize)"
)
await MainActor.run { window.close() }
}
/// Verifies that apply() sets a correctly sized window when ideal sizes
/// are available, for each titlebar style.
@Test(arguments: allTitlebarStyles)
func applyProducesCorrectSizeWhenIdealSizesAvailable(titlebarStyle: String) async throws {
let expectedSize = NSSize(width: 600, height: 400)
let container = await TerminalViewContainer {
OptionalIdealSizeView(
idealWidth: expectedSize.width,
idealHeight: expectedSize.height,
titlebarStyle: titlebarStyle
)
}
let window = await NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: styleMask(for: titlebarStyle),
backing: .buffered,
defer: false
)
await MainActor.run {
window.contentView = container
}
// Wait for SwiftUI layout before apply
try await Task.sleep(nanoseconds: 100_000_000)
let contentLayoutSize = await MainActor.run {
let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize
defaultSize.apply(to: window)
// contentLayoutRect gives the usable area, excluding titlebar
return window.contentLayoutRect.size
}
// The usable content area should be at least the expected size.
#expect(
contentLayoutSize.width >= expectedSize.width && contentLayoutSize.height >= expectedSize.height,
"[\(titlebarStyle)] Content layout size (\(contentLayoutSize)) should be >= expected \(expectedSize) after apply"
)
await MainActor.run { window.close() }
}
/// Same async delay pattern but with ideal sizes available (happy path).
/// This should always pass it validates the delay works when @FocusedValue
/// has already propagated.
@Test(arguments: allTitlebarStyles)
func asyncAfterDelayProducesCorrectSizeWhenIdealSizesAvailable(titlebarStyle: String) async throws {
let expectedSize = NSSize(width: 600, height: 400)
let container = await TerminalViewContainer {
OptionalIdealSizeView(
idealWidth: expectedSize.width,
idealHeight: expectedSize.height,
titlebarStyle: titlebarStyle
)
}
let window = await NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: styleMask(for: titlebarStyle),
backing: .buffered,
defer: false
)
// Replicate the exact TerminalController.windowDidLoad() pattern
let contentLayoutSize: NSSize = await withCheckedContinuation { continuation in
DispatchQueue.main.async {
window.contentView = container
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(40)) {
let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize
defaultSize.apply(to: window)
continuation.resume(returning: window.contentLayoutRect.size)
}
}
}
#expect(
contentLayoutSize.width >= expectedSize.width && contentLayoutSize.height >= expectedSize.height,
"[\(titlebarStyle)] Content layout size (\(contentLayoutSize)) should be >= expected \(expectedSize) after 40ms delay"
)
await MainActor.run { window.close() }
}
// MARK: - Without window-width/window-height (frame path)
/// Without window-width/height config, defaultSize returns .frame or nil
/// (never .contentIntrinsicSize). The window uses its initial frame.
/// This should work for all titlebar styles regardless of the bug.
@Test(arguments: allTitlebarStyles)
func framePathWorksWithoutWindowSize(titlebarStyle: String) async throws {
let expectedFrame = NSRect(x: 100, y: 100, width: 800, height: 600)
let container = await TerminalViewContainer {
Color.clear
}
let window = await NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: styleMask(for: titlebarStyle),
backing: .buffered,
defer: false
)
await MainActor.run {
window.contentView = container
let defaultSize = TerminalController.DefaultSize.frame(expectedFrame)
defaultSize.apply(to: window)
}
let frame = await MainActor.run { window.frame }
#expect(
frame == expectedFrame,
"[\(titlebarStyle)] Window frame (\(frame)) should match expected \(expectedFrame)"
)
await MainActor.run { window.close() }
}
// MARK: - isChanged
/// Verifies isChanged correctly detects mismatch for contentIntrinsicSize
/// across titlebar styles when ideal sizes are available.
@Test(arguments: allTitlebarStyles)
func isChangedDetectsMismatch(titlebarStyle: String) async throws {
let expectedSize = NSSize(width: 600, height: 400)
let container = await TerminalViewContainer {
OptionalIdealSizeView(
idealWidth: expectedSize.width,
idealHeight: expectedSize.height,
titlebarStyle: titlebarStyle
)
}
let window = await NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: styleMask(for: titlebarStyle),
backing: .buffered,
defer: false
)
await MainActor.run {
window.contentView = container
}
try await Task.sleep(nanoseconds: 100_000_000)
let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize
let changedBefore = await MainActor.run { defaultSize.isChanged(for: window) }
#expect(changedBefore, "[\(titlebarStyle)] isChanged should return true before apply")
await MainActor.run { defaultSize.apply(to: window) }
let changedAfter = await MainActor.run { defaultSize.isChanged(for: window) }
#expect(!changedAfter, "[\(titlebarStyle)] isChanged should return false after apply")
await MainActor.run { window.close() }
}
/// Verifies isChanged for the .frame path.
@Test func isChangedForFramePath() async throws {
let expectedFrame = NSRect(x: 100, y: 100, width: 800, height: 600)
let window = await NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
styleMask: [.titled, .resizable],
backing: .buffered,
defer: false
)
let defaultSize = TerminalController.DefaultSize.frame(expectedFrame)
let changedBefore = await MainActor.run { defaultSize.isChanged(for: window) }
#expect(changedBefore, "isChanged should return true before apply")
await MainActor.run { defaultSize.apply(to: window) }
let changedAfter = await MainActor.run { defaultSize.isChanged(for: window) }
#expect(!changedAfter, "isChanged should return false after apply")
await MainActor.run { window.close() }
}
}