diff --git a/.github/macos-ui-test-quarantine.txt b/.github/macos-ui-test-quarantine.txt index ae462e89d5..2ab5fab48c 100644 --- a/.github/macos-ui-test-quarantine.txt +++ b/.github/macos-ui-test-quarantine.txt @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 641b5d02a1..2d8a64022f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Models/Query/ResultStatusModel.swift b/TablePro/Models/Query/ResultStatusModel.swift index 16bd52ca01..fb033f9bbc 100644 --- a/TablePro/Models/Query/ResultStatusModel.swift +++ b/TablePro/Models/Query/ResultStatusModel.swift @@ -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 @@ -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 diff --git a/TablePro/Views/Results/ResultStatusBar.swift b/TablePro/Views/Results/ResultStatusBar.swift index b9e58d872f..42e3cef9d6 100644 --- a/TablePro/Views/Results/ResultStatusBar.swift +++ b/TablePro/Views/Results/ResultStatusBar.swift @@ -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) } @@ -214,12 +210,7 @@ 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) @@ -227,6 +218,24 @@ struct ResultStatusBar: View { } } + 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: "·") diff --git a/TableProTests/Core/Services/Infrastructure/InspectorToolbarPlacementTests.swift b/TableProTests/Core/Services/Infrastructure/InspectorToolbarPlacementTests.swift new file mode 100644 index 0000000000..7afbce71df --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/InspectorToolbarPlacementTests.swift @@ -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 + } +} diff --git a/TableProTests/Helpers/OffscreenConnectionWindow.swift b/TableProTests/Helpers/OffscreenConnectionWindow.swift new file mode 100644 index 0000000000..87d5da7e73 --- /dev/null +++ b/TableProTests/Helpers/OffscreenConnectionWindow.swift @@ -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) + } + } +} diff --git a/TableProTests/Models/ResultStatusModelTests.swift b/TableProTests/Models/ResultStatusModelTests.swift index 5f486666c1..34db9ca454 100644 --- a/TableProTests/Models/ResultStatusModelTests.swift +++ b/TableProTests/Models/ResultStatusModelTests.swift @@ -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( diff --git a/TableProTests/Views/Main/EditorTabStripWindowDragTests.swift b/TableProTests/Views/Main/EditorTabStripWindowDragTests.swift new file mode 100644 index 0000000000..3b71e4f623 --- /dev/null +++ b/TableProTests/Views/Main/EditorTabStripWindowDragTests.swift @@ -0,0 +1,67 @@ +// +// EditorTabStripWindowDragTests.swift +// TableProTests +// + +import AppKit +import Foundation +import Testing + +@testable import TablePro + +@Suite("Editor tab strip window drag", .serialized) +@MainActor +struct EditorTabStripWindowDragTests { + private static let tabCount = 3 + + @Test("A press anywhere on a tab reaches a view that never moves the window") + func pressOnATabNeverMovesTheWindow() throws { + let fixture = try OffscreenConnectionWindow(size: CGSize(width: 1_200, height: 800)) + defer { fixture.tearDown() } + let strip = try showStrip(in: fixture) + + #expect(strip.interaction.displayedIds.count == Self.tabCount) + for index in strip.interaction.displayedIds.indices { + let placement = try #require(strip.interaction.run.placement(at: index)) + for x in [placement.frame.minX + 1, placement.frame.midX, placement.frame.maxX - 1] { + let hit = viewUnderPress(atContent: CGPoint(x: x, y: placement.frame.midY), of: strip, in: fixture) + #expect(hit === strip, "Tab \(index) at \(x) reached \(String(describing: hit))") + #expect(hit?.mouseDownCanMoveWindow == false, "Tab \(index) at \(x) can move the window") + } + } + } + + @Test("The titlebar above the toolbar items still moves the window") + func titlebarStillMovesTheWindow() throws { + let fixture = try OffscreenConnectionWindow(size: CGSize(width: 1_200, height: 800)) + defer { fixture.tearDown() } + let strip = try showStrip(in: fixture) + + let point = CGPoint(x: fixture.window.frame.width / 2, y: fixture.window.frame.height - 2) + let hit = try #require(fixture.themeFrame?.hitTest(point)) + #expect(hit !== strip) + #expect(hit.mouseDownCanMoveWindow, "\(hit) takes the press from the window") + } + + private func showStrip(in fixture: OffscreenConnectionWindow) throws -> EditorTabInteractionView { + let pane = fixture.workspace.panes.tabStrip + let strip = try #require(pane.view as? EditorTabInteractionView) + pane.interaction.adopt(tabIds: (0 ..< Self.tabCount).map { _ in UUID() }, overflow: .scroll) + fixture.split.tabStripAccessory.setBandVisible(true) + fixture.window.layoutIfNeeded() + return strip + } + + private func viewUnderPress( + atContent point: CGPoint, + of strip: EditorTabInteractionView, + in fixture: OffscreenConnectionWindow + ) -> NSView? { + let local = CGPoint( + x: point.x - strip.interaction.contentOffset + + EditorTabStripLayout.stripInset + EditorTabStripLayout.trackPadding, + y: point.y + EditorTabStripLayout.trackPadding + ) + return fixture.themeFrame?.hitTest(strip.convert(local, to: nil)) + } +} diff --git a/TableProUITests/ConnectionWindowChromeUITests.swift b/TableProUITests/ConnectionWindowChromeUITests.swift index 8666a4867a..dc1b79b469 100644 --- a/TableProUITests/ConnectionWindowChromeUITests.swift +++ b/TableProUITests/ConnectionWindowChromeUITests.swift @@ -213,7 +213,7 @@ final class ConnectionWindowChromeUITests: UITestCase { /// The runner's screen is 1024pt wide, and a window pinned wider than its screen overflows the /// toolbar's items into the overflow menu, where none of them is in the toolbar to find. That is - /// unmeasurable rather than wrong, so it skips, the way `InspectorToolbarPlacementUITests` does. + /// unmeasurable rather than wrong, so it skips. private func skipUnlessTheScreenFitsThePinnedWindow() throws { let width = NSScreen.main?.frame.width ?? 0 try XCTSkipUnless( diff --git a/TableProUITests/InspectorToolbarPlacementUITests.swift b/TableProUITests/InspectorToolbarPlacementUITests.swift deleted file mode 100644 index 74d59d365d..0000000000 --- a/TableProUITests/InspectorToolbarPlacementUITests.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// InspectorToolbarPlacementUITests.swift -// TableProUITests -// -// The inspector toggle used to ride the inspector's divider: a tracking separator splits the -// toolbar into pane-aligned sections and leaves the items inside one leading-aligned, so opening -// the pane walked the button inward by the width of the pane. Only geometry catches that, which -// is why the identifier-order unit tests are not enough on their own. -// - -import AppKit -import XCTest - -final class InspectorToolbarPlacementUITests: UITestCase { - /// The button is 44pt wide and the toolbar insets the trailing edge by a few points. The - /// inspector is 270pt, so anything near that is the pane's width rather than padding. - private let trailingEdgeTolerance: CGFloat = 80 - - /// How far the grid has to narrow or widen before the pane counts as having changed state. - /// The inspector's minimum thickness is 270pt. - private let paneTravel: CGFloat = 100 - - /// The window size is pinned because the measurement is a distance from the window's trailing - /// edge: a restored frame narrow enough to overflow the toolbar would move the last item for - /// reasons that have nothing to do with the inspector. - private let pinnedWindowSize = CGSize(width: 1512, height: 861) - - private var pinnedEnvironment: [String: String] { - ["TABLEPRO_SCREENSHOT_FRAME": "\(Int(pinnedWindowSize.width))x\(Int(pinnedWindowSize.height))"] - } - - /// The only handle on the toggle is the label AppKit gives its own standard item, which is - /// localized, so the app has to run in a known language. `AppleLanguages` is a defaults key - /// rather than an environment variable, and this test used to pass it in the environment, where - /// nothing reads it: the match worked only because both machines happened to be English. - private let pinnedArguments = ["-AppleLanguages", "(en)"] - - /// The inspector remembers whether it was open, so the starting state is whatever the previous - /// launch left. Both directions are asserted rather than assuming one. - func testTheInspectorToggleHoldsTheTrailingEdgeThroughBothTransitions() throws { - try skipUnlessTheScreenFitsThePinnedWindow() - let app = try launchWithSampleDatabase( - environment: pinnedEnvironment, - arguments: pinnedArguments - ) - let window = try mainWindow(of: app) - let toggle = try inspectorToggle(in: window) - - let initialGap = window.frame.maxX - toggle.frame.maxX - XCTAssertLessThan( - initialGap, - trailingEdgeTolerance, - "The inspector toggle must start at the window's trailing edge" - ) - - let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "The sample database produced no data grid") - - for transition in 1 ... 2 { - let widthBefore = grid.frame.width - app.typeKey("i", modifierFlags: [.command, .option]) - - XCTAssertTrue( - waitForPredicate(timeout: 15) { abs(grid.frame.width - widthBefore) > self.paneTravel }, - "Transition \(transition): Command Option I did not move the inspector" - ) - - let gap = window.frame.maxX - toggle.frame.maxX - XCTAssertLessThan( - gap, - trailingEdgeTolerance, - "Transition \(transition): the toggle drifted in from the window's trailing edge" - ) - XCTAssertEqual( - gap, - initialGap, - accuracy: 1, - "Transition \(transition): moving the inspector must not move its own toggle" - ) - } - } - - // MARK: - Helpers - - /// 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 at - /// all. That is unmeasurable rather than wrong, and it is what the CI runner is: a 1024x768 - /// virtual machine, where this reported "No inspector toggle in the toolbar" on every run while - /// passing on any real display. - private func skipUnlessTheScreenFitsThePinnedWindow() throws { - /// Width only, and against the screen's own frame rather than its visible one. The failure - /// this guards is horizontal, and the menu bar and Dock take height, not toolbar room. - let width = NSScreen.main?.frame.width ?? 0 - try XCTSkipUnless( - width >= pinnedWindowSize.width, - """ - Needs a screen at least \(Int(pinnedWindowSize.width))pt wide to hold the pinned window; \ - this one is \(Int(width))pt. Anything narrower overflows the toolbar's trailing items \ - into its menu, and the toggle is then not in the toolbar to measure. - """ - ) - } - - private func mainWindow(of app: XCUIApplication) throws -> XCUIElement { - let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch - XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") - return window - } - - /// AppKit builds this item itself and labels it "Inspector"; the app never vends it, so there - /// is no accessibility identifier of ours to match on. - private func inspectorToggle(in window: XCUIElement) throws -> XCUIElement { - let toggle = window.toolbars.buttons["Inspector"].firstMatch - XCTAssertTrue(toggle.waitToExist(timeout: 20), "No inspector toggle in the toolbar") - return toggle - } -} diff --git a/TableProUITests/WindowExecutionIndicatorUITests.swift b/TableProUITests/WindowExecutionIndicatorUITests.swift index 0ecaa81c22..a73278d881 100644 --- a/TableProUITests/WindowExecutionIndicatorUITests.swift +++ b/TableProUITests/WindowExecutionIndicatorUITests.swift @@ -5,23 +5,15 @@ import XCTest -/// #2342: opening a SQLite database and clicking a table left the toolbar on "Executing…" with a +/// #2342: opening a SQLite database and clicking a table left the window on "Executing…" with a /// live Stop control and no rows, and pressing Stop was the only way out. -/// -/// The moment the indicator is raised is not observable from here, because a local SQLite query -/// finishes in well under XCUITest's polling interval. The moment it is meant to be lowered is, -/// and that is the half the report is about: once the result has landed, nothing in the toolbar may -/// still claim a query is running. final class WindowExecutionIndicatorUITests: UITestCase { - /// Scoped to the toolbar rather than the window. An identifier lookup that has to walk a window - /// holding a loaded data grid is the expensive query shape in this suite, and this one runs - /// inside a poll. private func executionIndicator(in window: XCUIElement) -> XCUIElement { - window.toolbars.descendants(matching: .any)["execution-indicator"].firstMatch + window.activityIndicators["execution-indicator"].firstMatch } private func executionStop(in window: XCUIElement) -> XCUIElement { - window.toolbars.descendants(matching: .any)["execution-stop"].firstMatch + window.buttons["execution-stop"].firstMatch } func testTheExecutingIndicatorClearsOnceEachTableHasLoaded() throws { @@ -41,39 +33,33 @@ final class WindowExecutionIndicatorUITests: UITestCase { !executionIndicator(in: window).exists && !executionStop(in: window).exists } - XCTAssertTrue(settled, "\(table): the toolbar still reports a query that has already finished") + XCTAssertTrue(settled, "\(table): the status bar still reports a query that has already finished") } } - /// The other half, and the reason the test above is not vacuous: an indicator wired to something - /// that never becomes true would pass it. A query slow enough to observe proves the toolbar - /// follows the execution registry in both directions. func testTheExecutingIndicatorAppearsWhileAQueryRunsAndClearsAfterIt() throws { let app = try launchWithSampleDatabase() let window = app.windows.firstMatch app.typeKey("t", modifierFlags: .command) - let editor = editorTextView(in: app) - XCTAssertTrue(editor.waitToExist(timeout: 10)) - editor.click() - app.typeText( - "WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < 20000000) SELECT count(*) FROM c;" + typeQuery( + "WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < 20000000) SELECT count(*) FROM c;", + in: app ) app.typeKey(.return, modifierFlags: .command) - let indicator = executionIndicator(in: window) XCTAssertTrue( - indicator.waitToExist(timeout: 15), - "The toolbar must report a query that is running" + executionStop(in: window).waitToExist(timeout: 15), + "A running query must offer Stop in the status bar" ) XCTAssertTrue( - executionStop(in: window).exists, - "A running query must offer Stop" + executionIndicator(in: window).exists, + "The status bar must report a query that is running" ) let settled = waitForPredicate(timeout: 90) { - !executionIndicator(in: window).exists + !executionIndicator(in: window).exists && !executionStop(in: window).exists } - XCTAssertTrue(settled, "The toolbar must go back to idle once the query has finished") + XCTAssertTrue(settled, "The status bar must go back to idle once the query has finished") } }