Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 1 addition & 69 deletions .github/macos-ui-test-quarantine.txt
Original file line number Diff line number Diff line change
@@ -1,76 +1,8 @@
# UI tests skipped on CI, as "Suite/testMethod()". Same idea as macos-test-quarantine.txt:
# a gate that fails for reasons unrelated to the change under review is worse than no gate.
# Burn this list down.
#
# Each entry needs a reason and, where known, what would take it off the list.
# list-tests.sh fails the job on an entry that matches no enumerated case, so a line here cannot
# quietly stop meaning anything.

# --- Needs a display the runner does not have.
# The test pins a 1512x861 window and measures the inspector toggle's distance from the
# window's trailing edge. The runner's screen is 1024x768. A window pinned wider than the
# screen is placed partly off it and its toolbar collapses the trailing items into the
# overflow menu, so there is no toggle in the toolbar to measure: it reported "No inspector
# toggle in the toolbar" on every run while passing on any real display.
#
# It has an XCTSkipUnless for exactly this, so it has never failed CI. It has also never run
# there, and a skip inside the test is not visible to anyone reading the gate. Listing it here
# is what makes "this gates nothing" reviewable.
#
# Getting it back: the assertion is geometric, and geometry does not need a screen. An NSWindow
# can be made larger than the display as long as it is not ordered front, so building the
# window and its toolbar in a TableProTests case and measuring there would run at any
# resolution. That is a rewrite, not a configuration change, which is why it is not in this PR.
InspectorToolbarPlacementUITests/testTheInspectorToggleHoldsTheTrailingEdgeThroughBothTransitions()

# --- Loses the window it is watching for to the cost of watching for it.
# "The toolbar must report a query that is running": the test runs a 20-million-row recursive
# CTE so the indicator is up long enough to see, then polls
# `window.toolbars.descendants(matching: .any)["execution-indicator"]`. A `.any` descendant
# query is the most expensive shape XCUITest has, and each poll re-resolves it, so under a
# loaded runner one resolution can outlast the 15s the assertion allows. It failed four runs
# running (32556712501, 32560189239, and both attempts of 32564173480) while the sibling
# assertion in the same suite, which watches the indicator clear rather than appear, passed
# every time.
#
# It is not the indicator: `WindowBusyStateGuardTests` still fails the build if a stored
# executing flag comes back, and #2342's own regression suite
# (`SQLiteFirstTableLoadUITests`) passes. What is unproven is whether the indicator is ever
# observed at all on that runner.
#
# Getting it back: give it a query shape that costs what the sibling assertion costs. The
# indicator and Stop both carry identifiers, so matching their real element types instead of
# `.any` removes the walk. That needs one CI run to confirm the types, which is why it is not
# in this release.
WindowExecutionIndicatorUITests/testTheExecutingIndicatorAppearsWhileAQueryRunsAndClearsAfterIt()

# --- Passes locally on every run, has never once passed on CI.
# All four cases fail identically and the reported order is the pre-drag order, so the strip
# never sees the gesture. Run locally against this same commit they are 4 of 4 green in 87
# seconds, and the app reorders under a real pointer, so this is the harness not reaching the
# strip rather than reordering being broken.
#
# The suite says so itself. testDraggingTheSelectedTabReordersTheStrip is documented as "the
# control for the two above. If this fails too, the harness is not driving the strip at all and
# their results prove nothing." It is failing, so by its own design the other three prove
# nothing on CI.
#
# They have never been green here. #2472 added them, #2546 existed only to give them a gesture
# the strip can receive, and #2546 was merged with all three of its own UI shards red
# (run 33035358138). Every commit since has failed the same four.
#
# The suspect is the strip's home: it is an NSTitlebarAccessoryViewController at
# layoutAttribute .bottom, so a press-drag there competes with AppKit's own window dragging,
# and the file's header already records that a press in the leading region of the titlebar
# drags the window rather than the tab (the unfixed half of #2438). A runner with no real
# display resolves that race differently from a Mac with one.
#
# Getting them back: settle whether AppKit is taking the drag by giving the hosted strip view
# a mouseDownCanMoveWindow of false, which is the documented way to stop a titlebar accessory
# from moving the window and would close the other half of #2438 at the same time. That is an
# app change to the window-drag behaviour of the whole titlebar band, it cannot be validated on
# a machine where these already pass, and it does not belong in a release commit.
EditorTabReorderUITests/testDraggingATabReordersTheStrip()
EditorTabReorderUITests/testDraggingAnUnselectedTabReordersTheStrip()
EditorTabReorderUITests/testDraggingTheSelectedTabReordersTheStrip()
EditorTabReorderUITests/testDraggingATabReordersAnOverflowingStrip()
# The list is empty. Keep it that way: a failing case gets fixed, not listed.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- App hanging for a minute when an import stopped on a failing statement several megabytes long.
- SQL Server Windows Authentication to another realm failing when the service principal name is over 128 bytes.
- Data grid's inline cell editor and cell viewer unreachable by VoiceOver.
- No Executing indicator or Stop button in the results status bar while a query tab runs its first query.

### Security

Expand Down
2 changes: 2 additions & 0 deletions TablePro/Models/Query/ResultStatusModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ enum ResultStatusReadout: Equatable {
struct ResultStatusControls: Equatable {
var showsModeSwitcher = false
var showsReadout = false
var showsExecutionWithoutReadout = false
var showsLoadingMore = false
var showsExactCountAction = false
var showsCountInProgress = false
Expand Down Expand Up @@ -104,6 +105,7 @@ struct ResultStatusModel: Equatable {
let describesAResult = isTable ? snapshot.hasTableName : snapshot.hasColumns

controls.showsReadout = viewMode.showsResultScope && describesAResult
controls.showsExecutionWithoutReadout = viewMode.showsResultScope && !describesAResult && pagination.isLoading
controls.showsLoadingMore = controls.showsReadout && pagination.isLoadingMore

/// Withheld until nothing is still resolving the total. Offered against a total that is
Expand Down
35 changes: 22 additions & 13 deletions TablePro/Views/Results/ResultStatusBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,9 @@ struct ResultStatusBar: View {
)
}
if model.controls.showsReadout {
readoutCluster
.frame(
minWidth: 0,
idealWidth: StatusBarLayoutMetrics.readoutIdealWidth,
maxWidth: .infinity,
alignment: .leading
)
readoutZone(readoutCluster)
} else if model.controls.showsExecutionWithoutReadout {
readoutZone(executionIndicator)
} else {
Spacer(minLength: 0)
}
Expand Down Expand Up @@ -214,19 +210,32 @@ struct ResultStatusBar: View {
private var executionReadout: some View {
if execution.isActive {
separator
ExecutionIndicatorView(
isExecuting: execution.isExecuting,
lastTiming: execution.lastTiming,
canStop: execution.canStop,
onCancel: execution.onCancel
)
executionIndicator
}
if isRefreshingSchema {
DelayedProgressIndicator(isActive: true)
.accessibilityLabel(String(localized: "Refreshing"))
}
}

private var executionIndicator: some View {
ExecutionIndicatorView(
isExecuting: execution.isExecuting,
lastTiming: execution.lastTiming,
canStop: execution.canStop,
onCancel: execution.onCancel
)
}

private func readoutZone(_ content: some View) -> some View {
content.frame(
minWidth: 0,
idealWidth: StatusBarLayoutMetrics.readoutIdealWidth,
maxWidth: .infinity,
alignment: .leading
)
}

/// Punctuation, so VoiceOver must not read it as an element of its own.
private var separator: some View {
Text(verbatim: "·")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// InspectorToolbarPlacementTests.swift
// TableProTests
//

import AppKit
import Foundation
@testable import TablePro
import Testing

@Suite("Inspector toolbar placement", .serialized)
@MainActor
struct InspectorToolbarPlacementTests {
private static let pinnedWindowSize = CGSize(width: 1_512, height: 861)
private static let trailingEdgeTolerance: CGFloat = 80
private static let paneTravel: CGFloat = 100

@Test("The inspector toggle holds the window's trailing edge as the inspector opens and closes")
func toggleHoldsTheTrailingEdgeThroughBothTransitions() throws {
let fixture = try OffscreenConnectionWindow(
size: Self.pinnedWindowSize,
connectedTo: TestFixtures.makeConnection(name: "Inspector toggle", type: .mysql)
)
defer { fixture.tearDown() }

#expect(fixture.window.frame.size == Self.pinnedWindowSize)
let initialGap = try toggleGapFromTrailingEdge(in: fixture)
#expect(initialGap < Self.trailingEdgeTolerance, "The toggle starts \(initialGap)pt in from the trailing edge")

for transition in 1 ... 2 {
let detailWidthBefore = detailWidth(in: fixture)
let refreshBefore = try refreshMaxX(in: fixture)

fixture.setInspectorOpen(!fixture.split.isTrailingPaneOpen)

#expect(
abs(detailWidth(in: fixture) - detailWidthBefore) > Self.paneTravel,
"Transition \(transition): the inspector did not move"
)
#expect(
abs(try refreshMaxX(in: fixture) - refreshBefore) > Self.paneTravel,
"Transition \(transition): the toolbar did not lay out again for the new pane width"
)
let gap = try toggleGapFromTrailingEdge(in: fixture)
#expect(gap < Self.trailingEdgeTolerance, "Transition \(transition): the toggle is \(gap)pt in")
#expect(abs(gap - initialGap) <= 1, "Transition \(transition): the toggle moved with the inspector")
}
}

private func detailWidth(in fixture: OffscreenConnectionWindow) -> CGFloat {
fixture.split.splitViewItems.first { $0.behavior == .default }?.viewController.view.frame.width ?? 0
}

private func toggleGapFromTrailingEdge(in fixture: OffscreenConnectionWindow) throws -> CGFloat {
let toggle = try #require(control(sending: "toggleInspector:", in: fixture), "No inspector toggle in the toolbar")
return fixture.window.frame.width - toggle.convert(toggle.bounds, to: nil).maxX
}

private func refreshMaxX(in fixture: OffscreenConnectionWindow) throws -> CGFloat {
let refresh = try #require(control(sending: "performRefresh:", in: fixture), "No Refresh item in the toolbar")
return refresh.convert(refresh.bounds, to: nil).maxX
}

private func control(sending action: String, in fixture: OffscreenConnectionWindow) -> NSControl? {
guard let frame = fixture.themeFrame else { return nil }
return Self.firstControl(in: frame) { control in
control.action.map(NSStringFromSelector) == action && !control.isHiddenOrHasHiddenAncestor
}
}

private static func firstControl(in view: NSView, where matches: (NSControl) -> Bool) -> NSControl? {
if let control = view as? NSControl, matches(control) { return control }
for subview in view.subviews {
if let found = firstControl(in: subview, where: matches) { return found }
}
return nil
}
}
82 changes: 82 additions & 0 deletions TableProTests/Helpers/OffscreenConnectionWindow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//
// OffscreenConnectionWindow.swift
// TableProTests
//

import AppKit
import Foundation
@testable import TablePro
import Testing

@MainActor
internal struct OffscreenConnectionWindow {
let controller: TabWindowController
let window: NSWindow
let split: MainSplitViewController
let workspace: ConnectionWorkspace
private let hasInjectedSession: Bool

init(size: CGSize, connectedTo connection: DatabaseConnection? = nil) throws {
let subject = connection ?? TestFixtures.makeConnection(name: "Offscreen window")
workspace = ConnectionWorkspace(
connectionId: subject.id,
payload: nil,
autoConnect: false,
payloadConnection: subject,
session: nil,
sessionState: nil,
trailingPaneState: nil,
phase: .connecting
)
controller = TabWindowController(
payload: EditorTabPayload(connectionId: subject.id),
pinnedWindowSize: size,
adopting: workspace
)
let built = try #require(controller.window)
window = built
split = try #require(built.contentViewController as? MainSplitViewController)
hasInjectedSession = connection != nil

if hasInjectedSession {
var session = ConnectionSession(connection: subject, driver: MockDatabaseDriver(connection: subject))
session.status = .connected
DatabaseManager.shared.injectSession(session, for: subject.id)
split.refreshFromActiveSessions()
}

let toolbar = ContextValidatedToolbar(
identifier: NSToolbar.Identifier("com.TablePro.tests.offscreen.\(UUID().uuidString)")
)
let owner = MainWindowToolbar(managedToolbar: toolbar)
toolbar.autosavesConfiguration = false
split.toolbarOwner = owner
split.pointToolbar(at: nil)
resetPaneLayout()
}

var themeFrame: NSView? {
window.contentView?.superview
}

func setInspectorOpen(_ isOpen: Bool) {
split.inspectorSplitItem.isCollapsed = !isOpen
window.layoutIfNeeded()
}

func resetPaneLayout() {
split.sidebarSplitItem.isCollapsed = false
setInspectorOpen(false)
}

func tearDown() {
resetPaneLayout()
split.invalidateToolbar()
window.delegate = nil
window.contentViewController = nil
workspace.teardown()
if hasInjectedSession {
DatabaseManager.shared.removeSession(for: workspace.connectionId)
}
}
}
46 changes: 46 additions & 0 deletions TableProTests/Models/ResultStatusModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,52 @@ struct ResultStatusModelTests {
#expect(!result.controls.showsReadout)
}

@Test("A query tab running before it has any result reports the execution alone")
func firstRunReportsTheExecution() {
var running = PaginationState()
running.isLoading = true
let snapshot = makeSnapshot(
tabType: .query,
rowCount: 0,
hasColumns: false,
hasTableName: false,
pagination: running
)
let result = model(snapshot)
#expect(!result.controls.showsReadout)
#expect(result.controls.showsExecutionWithoutReadout)
}

@Test("A query tab with no result and nothing running reports nothing")
func idleTabWithoutResultReportsNoExecution() {
let snapshot = makeSnapshot(tabType: .query, rowCount: 0, hasColumns: false, hasTableName: false)
#expect(!model(snapshot).controls.showsExecutionWithoutReadout)
}

@Test("A running query with a result on screen reports the execution inside the readout")
func runWithAResultKeepsTheReadout() {
var running = PaginationState()
running.isLoading = true
let snapshot = makeSnapshot(tabType: .query, rowCount: 5, hasTableName: false, pagination: running)
let result = model(snapshot)
#expect(result.controls.showsReadout)
#expect(!result.controls.showsExecutionWithoutReadout)
}

@Test("Structure mode never reports an execution on its own")
func structureModeReportsNoBareExecution() {
var running = PaginationState()
running.isLoading = true
let snapshot = makeSnapshot(
tabType: .table,
rowCount: 0,
hasColumns: false,
hasTableName: false,
pagination: running
)
#expect(!model(snapshot, viewMode: .structure).controls.showsExecutionWithoutReadout)
}

@Test("A table with a known total reports the offset range")
func tableReportsRange() {
let snapshot = makeSnapshot(
Expand Down
Loading
Loading