diff --git a/.github/workflows/macos-tests.yml b/.github/workflows/macos-tests.yml index ad0a22b59..4bb43c74d 100644 --- a/.github/workflows/macos-tests.yml +++ b/.github/workflows/macos-tests.yml @@ -148,15 +148,18 @@ jobs: - name: Run editor package tests run: swift test --package-path LocalPackages/CodeEditTextView - # Budget on a macos-26 runner: ~7 min to generate and compile every plugin, ~14 min to build - # and run the unit suite, ~17 min for the UI suite. A slow runner stretches all three, and one - # UI retry costs another minute or two, so 40 left no headroom and killed the job mid-suite. + # Budget on a macos-26 runner, measured on run 32391276238: ~3 min of setup and package + # resolution, ~5 min to compile every plugin, ~21 min for the unit step (17 of them building the + # app and both test bundles, 4.5 running 11,500 tests), and ~43 min for the UI suite, which + # launches the app once per test at roughly 30 seconds each. That is ~72 min, so 60 killed the + # job with ten UI suites still to run. A slow runner stretches all of it and one UI retry costs + # another minute or two, so keep real headroom here rather than trimming to the last measurement. app-tests: name: macOS App Tests needs: changes if: needs.changes.outputs.run == 'true' runs-on: macos-26 - timeout-minutes: 60 + timeout-minutes: 90 steps: - uses: actions/checkout@v7 diff --git a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift index 061f4c7aa..df0df9853 100644 --- a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift +++ b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift @@ -439,8 +439,8 @@ internal final class EtcdHttpClient: @unchecked Sendable { let candidates = ["v3", "v3beta", "v3alpha"] let session = try lock.withLock { () -> URLSession in - guard let session else { throw EtcdError.notConnected } - return session + guard let currentSession = self.session else { throw EtcdError.notConnected } + return currentSession } for candidate in candidates { @@ -930,7 +930,7 @@ internal final class EtcdHttpClient: @unchecked Sendable { // MARK: - TLS Delegates - private class InsecureTlsDelegate: NSObject, URLSessionDelegate { + private final class InsecureTlsDelegate: NSObject, URLSessionDelegate { func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge, @@ -945,7 +945,7 @@ internal final class EtcdHttpClient: @unchecked Sendable { } } - private class EtcdTlsDelegate: NSObject, URLSessionDelegate { + private final class EtcdTlsDelegate: NSObject, URLSessionDelegate { private let caCertPath: String? private let clientCertPath: String? private let clientKeyPath: String? diff --git a/TablePro/Core/Services/Infrastructure/DatabaseFileWatcher.swift b/TablePro/Core/Services/Infrastructure/DatabaseFileWatcher.swift index bb17b8c8e..78786c21e 100644 --- a/TablePro/Core/Services/Infrastructure/DatabaseFileWatcher.swift +++ b/TablePro/Core/Services/Infrastructure/DatabaseFileWatcher.swift @@ -71,14 +71,14 @@ final class DatabaseFileWatcher { queue: .global(qos: .utility) ) - source.setEventHandler { [weak self] in + source.setEventHandler { @Sendable [weak self] in Task { @MainActor [weak self] in guard let self else { return } self.handleEvent(connectionId: connectionId) } } - source.setCancelHandler { + source.setCancelHandler { @Sendable in close(fd) } diff --git a/TablePro/Resources/ThirdPartyLicenses/licenses.yml b/TablePro/Resources/ThirdPartyLicenses/licenses.yml index aa8c1e5aa..4a6ad28f9 100644 --- a/TablePro/Resources/ThirdPartyLicenses/licenses.yml +++ b/TablePro/Resources/ThirdPartyLicenses/licenses.yml @@ -112,24 +112,22 @@ pinned OpenSSL.' - id: duckdb name: DuckDB - version: v1.5.3 + version: v1.5.2 spdx: MIT copyrights: - Copyright 2018-2025 Stichting DuckDB Foundation homepageURL: https://duckdb.org - licenseTextURL: https://raw.githubusercontent.com/duckdb/duckdb/v1.5.3/LICENSE + licenseTextURL: https://raw.githubusercontent.com/duckdb/duckdb/v1.5.2/LICENSE textFile: texts/duckdb.txt source: sh:DUCKDB_VERSION patched: false - notes: 'Built from the libduckdb-src.zip amalgamation attached to the v1.5.3 GitHub release (build-duckdb.sh:24). - The amalgamation compiles the whole tree including third_party/, which at v1.5.3 holds 30 vendored - projects (brotli, catch, concurrentqueue, fast_float, fastpforlib, fmt, fsst, httplib, hyperloglog, - imdb, jaro_winkler, jemalloc, libpg_query, lz4, mbedtls, miniz, parquet, pcg, pdqsort, re2, ska_sort, - skiplist, snappy, snowball, tdigest, thrift, utf8proc, vergesort, yyjson, zstd), each under its own - licence. Those sub-licences are NOT enumerated here: UNVERIFIED. Build-script defect worth flagging: - DUCKDB_SHA256 at scripts/build-duckdb.sh:11 is still the literal placeholder "REPLACE_WITH_libduckdb-src.zip_SHA256_FOR_v1.5.3", - so the checksum gate at line 25 cannot pass as committed. scripts/build-duckdb-ios.sh:37 defaults - to the same v1.5.3 for the iOS xcframework.' + notes: 'Built from the v1.5.2 git tag with CMake (build-duckdb.sh:25), linking core_functions, json, + parquet, icu and autocomplete statically (scripts/duckdb-macos-extensions.cmake). The build compiles + the whole tree including third_party/, which holds 30 vendored projects (brotli, catch, concurrentqueue, + fast_float, fastpforlib, fmt, fsst, httplib, hyperloglog, imdb, jaro_winkler, jemalloc, libpg_query, + lz4, mbedtls, miniz, parquet, pcg, pdqsort, re2, ska_sort, skiplist, snappy, snowball, tdigest, thrift, + utf8proc, vergesort, yyjson, zstd), each under its own licence. Those sub-licences are NOT enumerated + here: UNVERIFIED. scripts/build-duckdb-ios.sh pins the same tag for the iOS xcframework.' - id: freetds name: FreeTDS version: 1.4.22 diff --git a/TablePro/Views/Connection/ConnectionFieldRow.swift b/TablePro/Views/Connection/ConnectionFieldRow.swift index 4a72e00d9..b4a88a37a 100644 --- a/TablePro/Views/Connection/ConnectionFieldRow.swift +++ b/TablePro/Views/Connection/ConnectionFieldRow.swift @@ -11,6 +11,11 @@ struct ConnectionFieldRow: View { @Binding var value: String var body: some View { + control + .accessibilityIdentifier("connection-field-\(field.id)") + } + + @ViewBuilder private var control: some View { if field.dynamicOptions == .awsProfiles { LabeledContent(field.label) { AWSProfileField(placeholder: field.placeholder, value: $value) diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index 921f932a4..0a275b01b 100644 --- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -142,6 +142,7 @@ struct GeneralPaneView: View { defaultPort: type.defaultPort, value: networkFieldBinding(for: field) ) + .accessibilityIdentifier("connection-field-\(field.id)") } } } else { @@ -150,12 +151,14 @@ struct GeneralPaneView: View { text: $coordinator.network.host, prompt: Text("localhost") ) + .accessibilityIdentifier("connection-form-host") .disabled(usesForwardSocket) TextField( String(localized: "Port"), text: $coordinator.network.port, prompt: Text(defaultPortString) ) + .accessibilityIdentifier("connection-form-port") .disabled(usesForwardSocket) } if coordinator.ssh.state.enabled { diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index 841cc72b0..fb7af408d 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -21,6 +21,12 @@ final class DataGridCellFactory { private static let headerPadding: CGFloat = 48 private static let headerCharWidthRatio: CGFloat = 0.75 + private struct ColumnWidthBudget { + let cap: CGFloat + let measuredCharLimit: Int + let sampledRows: Int + } + static func fitToContentCap(availableWidth: CGFloat) -> CGFloat { let proportional = availableWidth * fitToContentViewportFraction return min(max(proportional, minFitToContentWidth), maxColumnWidth) @@ -45,9 +51,11 @@ final class DataGridCellFactory { databaseType: databaseType, isLargeDataset: isLargeDataset, nullDisplayString: nullDisplayString, - cap: Self.maxColumnWidth, - measuredCharLimit: Self.maxMeasureChars, - sampledRows: Self.automaticSampleRowCount(columnCount: tableRows.columns.count) + budget: ColumnWidthBudget( + cap: Self.maxColumnWidth, + measuredCharLimit: Self.maxMeasureChars, + sampledRows: Self.automaticSampleRowCount(columnCount: tableRows.columns.count) + ) ) } @@ -76,9 +84,11 @@ final class DataGridCellFactory { databaseType: databaseType, isLargeDataset: isLargeDataset, nullDisplayString: nullDisplayString, - cap: cap, - measuredCharLimit: measuredCharLimit, - sampledRows: Self.fitSampleRowCount(fittedColumnCount: fittedColumnCount) + budget: ColumnWidthBudget( + cap: cap, + measuredCharLimit: measuredCharLimit, + sampledRows: Self.fitSampleRowCount(fittedColumnCount: fittedColumnCount) + ) ) } @@ -105,16 +115,14 @@ final class DataGridCellFactory { databaseType: DatabaseType?, isLargeDataset: Bool, nullDisplayString: String?, - cap: CGFloat, - measuredCharLimit: Int, - sampledRows: Int + budget: ColumnWidthBudget ) -> CGFloat { let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth let headerCharCount = (columnName as NSString).length var maxWidth = CGFloat(headerCharCount) * charWidth * Self.headerCharWidthRatio + Self.headerPadding let totalRows = tableRows.count - let step = max(1, totalRows / max(1, sampledRows)) + let step = max(1, totalRows / max(1, budget.sampledRows)) let columnType = columnIndex < tableRows.columnTypes.count ? tableRows.columnTypes[columnIndex] @@ -137,15 +145,15 @@ final class DataGridCellFactory { nullDisplayString: resolvedNullDisplayString ) - let charCount = min((value as NSString).length, measuredCharLimit) + let charCount = min((value as NSString).length, budget.measuredCharLimit) maxWidth = max(maxWidth, CGFloat(charCount) * charWidth + accessory.measurementPadding) - if maxWidth >= cap { - return cap + if maxWidth >= budget.cap { + return budget.cap } } - return min(max(maxWidth, Self.minColumnWidth), cap) + return min(max(maxWidth, Self.minColumnWidth), budget.cap) } } diff --git a/TableProTests/Core/Execution/TabExecutionSettleGuardTests.swift b/TableProTests/Core/Execution/TabExecutionSettleGuardTests.swift index 88fc870f9..0da50090a 100644 --- a/TableProTests/Core/Execution/TabExecutionSettleGuardTests.swift +++ b/TableProTests/Core/Execution/TabExecutionSettleGuardTests.swift @@ -8,6 +8,10 @@ // unused result, and this trap has been walked into three times (#2055, #2068, #2120), so it is // worth failing the build over. // +// The scan is keyed on the receiver, not on the bare method name: `MCPHandlerOutcomeGate.settle` +// is an unrelated resume-once continuation that returns nothing, so there is no answer to consume +// there. `registryIsOnlyReachedThroughTheScannedPropertyName` keeps that narrowing fail-closed. +// import Foundation @testable import TablePro @@ -28,6 +32,19 @@ struct TabExecutionSettleGuardTests { ) } + @Test("The registry is only reached through the property name the guard scans") + func registryIsOnlyReachedThroughTheScannedPropertyName() throws { + let references = try Self.registryReferences() + #expect(!references.isEmpty) + #expect( + references.allSatisfy { $0.text.contains("var tabExecution") }, + """ + The settle guard scans for `tabExecution.settle(`. A registry reached under another \ + name escapes it, so widen the scan: \(references.map(\.description).sorted()) + """ + ) + } + private struct CallSite { let file: String let line: Int @@ -50,6 +67,17 @@ struct TabExecutionSettleGuardTests { } private static func settleCallSites() throws -> [CallSite] { + try sourceLines(containing: "tabExecution.settle(") + } + + private static func registryReferences() throws -> [CallSite] { + try sourceLines(containing: "TabExecutionRegistry").filter { + $0.file != "TabExecutionRegistry.swift" + && !$0.text.trimmingCharacters(in: .whitespaces).hasPrefix("//") + } + } + + private static func sourceLines(containing needle: String) throws -> [CallSite] { let sourceRoot = try repoRoot().appendingPathComponent("TablePro") guard let enumerator = FileManager.default.enumerator( at: sourceRoot, @@ -60,7 +88,7 @@ struct TabExecutionSettleGuardTests { for case let url as URL in enumerator where url.pathExtension == "swift" { let text = try String(contentsOf: url, encoding: .utf8) for (offset, line) in text.components(separatedBy: .newlines).enumerated() - where line.contains(".settle(") { + where line.contains(needle) { sites.append(CallSite(file: url.lastPathComponent, line: offset + 1, text: line)) } } diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 3a8d19e4f..f9af990c5 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -475,9 +475,9 @@ struct DatabaseMenuCommandTests { func deferredCommandsArePresent() { let titles = (databaseMenu()?.items ?? []).map(\.title) for expected in [ - String(localized: "New Database..."), + String(localized: "New Database…"), String(localized: "Show Table Structure"), - String(localized: "Edit View Definition..."), + String(localized: "Edit View Definition…"), String(localized: "Table Maintenance"), String(localized: "Disconnect"), String(localized: "Reconnect") diff --git a/TableProTests/Core/Services/CellDisplayFormatterTests.swift b/TableProTests/Core/Services/CellDisplayFormatterTests.swift index f49273d61..deab87b52 100644 --- a/TableProTests/Core/Services/CellDisplayFormatterTests.swift +++ b/TableProTests/Core/Services/CellDisplayFormatterTests.swift @@ -69,7 +69,7 @@ struct CellDisplayFormatterTests { func longTextTruncated() { let longString = String(repeating: "a", count: CellDisplayFormatter.maxDisplayLength + 100) let result = CellDisplayFormatter.format(.text(longString), columnType: nil) - let expected = String(repeating: "a", count: CellDisplayFormatter.maxDisplayLength) + "..." + let expected = String(repeating: "a", count: CellDisplayFormatter.maxDisplayLength) + "…" #expect(result == expected) } diff --git a/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift b/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift index 4e70193dd..a8c97c752 100644 --- a/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift +++ b/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift @@ -41,13 +41,32 @@ struct URLClassifierTests { #expect(routed == csvURL) } - @Test("CSV returns nil when no inspector plugin registers the extension") - func returnsNilWhenExtensionMissing() { + @Test("CSV falls back to a DuckDB connection when no inspector plugin registers the extension") + func routesCSVToDuckDBWhenExtensionMissing() { let csvURL = URL(fileURLWithPath: "/tmp/sample.csv") let intent = withInspectorState(lazy: [:]) { URLClassifier.classify(csvURL) } - #expect(intent == nil) + guard case .some(.success(.openDatabaseFile(let routed, let dbType))) = intent else { + Issue.record("Expected .openDatabaseFile, got \(String(describing: intent))") + return + } + #expect(routed == csvURL) + #expect(dbType == .duckdb) + } + + @Test("Analytics files with no inspector route to DuckDB", arguments: ["parquet", "json", "ndjson"]) + func routesDuckDBFileKinds(ext: String) { + let fileURL = URL(fileURLWithPath: "/tmp/export.\(ext)") + let intent = withInspectorState(lazy: [:]) { + URLClassifier.classify(fileURL) + } + guard case .some(.success(.openDatabaseFile(let routed, let dbType))) = intent else { + Issue.record("Expected .openDatabaseFile, got \(String(describing: intent))") + return + } + #expect(routed == fileURL) + #expect(dbType == .duckdb) } @Test("SQL file routes to openSQLFile", arguments: ["sql", "psql", "pgsql", "PSQL"]) diff --git a/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift b/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift index 6e9813dad..08ff42797 100644 --- a/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift +++ b/TableProTests/Core/Storage/PreferenceKeysGuardTests.swift @@ -36,6 +36,7 @@ struct PreferenceKeysGuardTests { "AppleLanguages": "Apple system default written when switching app language", "NSTableViewDefaultSizeMode": "Apple system default read by the workspace rail for Sidebar icon size, never written", "blink": "CALayer animation key in VimCursorManager, not a preference", + "extensions": "MCP capabilities field stripped by BridgeProxy for legacy clients, not a preference", "preConnectScript": "additionalFields dictionary key in ConnectionFormCoordinator, not a preference", ] diff --git a/TableProTests/Models/AIConversationTests.swift b/TableProTests/Models/AIConversationTests.swift index 08aaeade0..feeeb1f3c 100644 --- a/TableProTests/Models/AIConversationTests.swift +++ b/TableProTests/Models/AIConversationTests.swift @@ -21,7 +21,7 @@ struct AIConversationTests { messages: [makeUserTurn(String(repeating: "a", count: 60))] ) conv.updateTitle() - #expect(conv.title.hasSuffix("...")) + #expect(conv.title.hasSuffix("…")) } @Test("updateTitle keeps short content") diff --git a/TableProTests/Models/UI/JSONTreeParserTests.swift b/TableProTests/Models/UI/JSONTreeParserTests.swift index 867fed695..c978cbc3d 100644 --- a/TableProTests/Models/UI/JSONTreeParserTests.swift +++ b/TableProTests/Models/UI/JSONTreeParserTests.swift @@ -29,7 +29,7 @@ struct JSONTreeParserTests { #expect(messageNode.valueType == .string) #expect(messageNode.rawValue == longString) #expect(messageNode.displayValue == "\"\(longString)\"") - #expect(!messageNode.displayValue.contains("...")) + #expect(!messageNode.displayValue.contains("…")) } @Test("Tree parser still rejects oversized documents") diff --git a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift index 6ee158d52..4a641f1eb 100644 --- a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift +++ b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift @@ -218,9 +218,9 @@ struct CommandActionsBulkCloseTests { } #expect(schemaEngine.actions.closeTabsForOtherDatabasesTitle == "Close Tabs for Other Schemas") - #expect(schemaEngine.actions.openContainerSwitcherTitle == "Open Schema...") + #expect(schemaEngine.actions.openContainerSwitcherTitle == "Open Schema…") #expect(databaseEngine.actions.closeTabsForOtherDatabasesTitle == "Close Tabs for Other Databases") - #expect(databaseEngine.actions.openContainerSwitcherTitle == "Open Database...") + #expect(databaseEngine.actions.openContainerSwitcherTitle == "Open Database…") } // MARK: - Enablement diff --git a/TableProUITests/AuxiliaryWindowCloseUITests.swift b/TableProUITests/AuxiliaryWindowCloseUITests.swift index 73cd58d45..0628d748e 100644 --- a/TableProUITests/AuxiliaryWindowCloseUITests.swift +++ b/TableProUITests/AuxiliaryWindowCloseUITests.swift @@ -36,12 +36,12 @@ final class AuxiliaryWindowCloseUITests: UITestCase { func testCommandWClosesTheSettingsWindow() throws { let app = try launchShowingWelcome() - assertCommandWCloses(openWindow("settings", from: "Settings...", in: app), in: app) + assertCommandWCloses(openWindow("settings", from: "Settings…", in: app), in: app) } func testCommandWClosesTheIntegrationsActivityWindow() throws { let app = try launchShowingWelcome() - assertCommandWCloses(openWindow("integrations-activity", from: "Integrations...", in: app), in: app) + assertCommandWCloses(openWindow("integrations-activity", from: "Integrations…", in: app), in: app) } /// The welcome window used to answer the editor's own close command to get Command W back. diff --git a/TableProUITests/DataSettingsUITests.swift b/TableProUITests/DataSettingsUITests.swift index 3fa91cfe4..e85da3888 100644 --- a/TableProUITests/DataSettingsUITests.swift +++ b/TableProUITests/DataSettingsUITests.swift @@ -5,7 +5,7 @@ final class DataSettingsUITests: UITestCase { let app = try launchApp() XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) - let settingsMenuItem = app.menuBars.menuItems["Settings..."] + let settingsMenuItem = app.menuBars.menuItems["Settings…"] XCTAssertTrue(settingsMenuItem.waitForExistence(timeout: 10)) settingsMenuItem.click() diff --git a/TableProUITests/NewConnectionCommandUITests.swift b/TableProUITests/NewConnectionCommandUITests.swift index 85cf8476e..98ea68bd0 100644 --- a/TableProUITests/NewConnectionCommandUITests.swift +++ b/TableProUITests/NewConnectionCommandUITests.swift @@ -11,7 +11,7 @@ final class NewConnectionCommandUITests: UITestCase { "The welcome window should close, which is the state that broke New Connection" ) - let newConnection = app.menuBars.menuItems["New Connection..."] + let newConnection = app.menuBars.menuItems["New Connection…"] XCTAssertTrue(newConnection.waitForExistence(timeout: 5)) XCTAssertTrue(newConnection.isEnabled) newConnection.click() diff --git a/TableProUITests/OpenQuicklyCommandUITests.swift b/TableProUITests/OpenQuicklyCommandUITests.swift index 882f5b484..9aa0895c4 100644 --- a/TableProUITests/OpenQuicklyCommandUITests.swift +++ b/TableProUITests/OpenQuicklyCommandUITests.swift @@ -187,7 +187,7 @@ final class OpenQuicklyCommandUITests: UITestCase { XCTAssertTrue(fileMenu.waitForExistence(timeout: 10)) fileMenu.click() - let openQuickly = app.menuBars.menuItems["Open Quickly..."] + let openQuickly = app.menuBars.menuItems["Open Quickly…"] XCTAssertTrue(openQuickly.waitForExistence(timeout: 5)) XCTAssertTrue(waitUntilHittable(openQuickly, timeout: 5)) openQuickly.click() diff --git a/TableProUITests/RedisConnectionModeUITests.swift b/TableProUITests/RedisConnectionModeUITests.swift index 4d6cb4d36..682d0001c 100644 --- a/TableProUITests/RedisConnectionModeUITests.swift +++ b/TableProUITests/RedisConnectionModeUITests.swift @@ -3,72 +3,90 @@ import XCTest /// The Redis connection form swaps whole groups of fields when the mode changes, and the fields it /// swaps live in two different panes. Nothing below the view models proves the swap actually /// reaches the screen, so this drives the real form. +/// +/// Every element here is reached by accessibility identifier. The form renders plugin fields as +/// hosted SwiftUI controls, which arrive with their text in `value` and no label of their own, so +/// a query by visible title matches nothing at all. final class RedisConnectionModeUITests: UITestCase { + private let modePicker = "connection-field-redisMode" + private let sentinelNodes = "connection-field-redisSentinelHosts" + private let sentinelGroupName = "connection-field-redisSentinelMasterName" + private let clusterNodes = "connection-field-redisClusterHosts" + func testSwitchingConnectionModeShowsOnlyThatModesFields() throws { let app = try launchApp() XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) - try openRedisConnectionForm(in: app) - - let window = app.windows.firstMatch - let modePicker = window.popUpButtons["Connection Mode"] + let window = try openRedisConnectionForm(in: app) + let picker = window.popUpButtons[modePicker] XCTAssertTrue( - modePicker.waitForExistence(timeout: 10), + picker.waitForExistence(timeout: 10), "The Redis form should offer a Connection Mode picker" ) - XCTAssertTrue(window.textFields["Host"].exists, "Standalone shows Host and Port") - XCTAssertFalse(window.staticTexts["Sentinel Nodes"].exists) - XCTAssertFalse(window.staticTexts["Cluster Seed Nodes"].exists) + XCTAssertTrue(window.textFields["connection-form-host"].exists, "Standalone shows Host and Port") + XCTAssertFalse(hasField(sentinelNodes, in: window)) + XCTAssertFalse(hasField(clusterNodes, in: window)) - select(option: "Sentinel", in: modePicker) + select(option: "Sentinel", in: picker) XCTAssertTrue( - window.staticTexts["Sentinel Nodes"].waitForExistence(timeout: 5), + waitForPredicate(timeout: 5) { hasField(sentinelNodes, in: window) }, "Sentinel mode replaces Host and Port with the Sentinel node list" ) - XCTAssertTrue(window.textFields["Primary Group Name"].waitForExistence(timeout: 5)) - XCTAssertFalse(window.staticTexts["Cluster Seed Nodes"].exists) - XCTAssertFalse(window.textFields["Host"].exists) + XCTAssertTrue(window.textFields[sentinelGroupName].waitForExistence(timeout: 5)) + XCTAssertFalse(hasField(clusterNodes, in: window)) + XCTAssertFalse(window.textFields["connection-form-host"].exists) - select(option: "Cluster", in: modePicker) + select(option: "Cluster", in: picker) XCTAssertTrue( - window.staticTexts["Cluster Seed Nodes"].waitForExistence(timeout: 5), + waitForPredicate(timeout: 5) { hasField(clusterNodes, in: window) }, "Cluster mode shows its own seed node list" ) - XCTAssertFalse(window.staticTexts["Sentinel Nodes"].exists) - XCTAssertFalse(window.textFields["Primary Group Name"].exists) + XCTAssertFalse(hasField(sentinelNodes, in: window)) + XCTAssertFalse(window.textFields[sentinelGroupName].exists) - select(option: "Standalone", in: modePicker) + select(option: "Standalone", in: picker) XCTAssertTrue( - window.textFields["Host"].waitForExistence(timeout: 5), + window.textFields["connection-form-host"].waitForExistence(timeout: 5), "Going back to Standalone restores Host and Port" ) - XCTAssertFalse(window.staticTexts["Sentinel Nodes"].exists) - XCTAssertFalse(window.staticTexts["Cluster Seed Nodes"].exists) + XCTAssertFalse(hasField(sentinelNodes, in: window)) + XCTAssertFalse(hasField(clusterNodes, in: window)) + } + + /// A host list is a whole subtree rather than one control, so its identifier lands on every + /// element inside it and any one of them proves the list is on screen. + private func hasField(_ identifier: String, in window: XCUIElement) -> Bool { + window.descendants(matching: .any).matching(identifier: identifier).count > 0 } - private func openRedisConnectionForm(in app: XCUIApplication) throws { - let newConnection = app.menuBars.menuItems["New Connection..."] + private func openRedisConnectionForm(in app: XCUIApplication) throws -> XCUIElement { + let newConnection = app.menuBars.menuItems["New Connection…"] XCTAssertTrue(newConnection.waitForExistence(timeout: 10)) newConnection.click() - let redis = app.windows.firstMatch.buttons["Redis"] + let chooser = app.windows.firstMatch + let search = chooser.searchFields.firstMatch + XCTAssertTrue(search.waitForExistence(timeout: 10), "The chooser should offer its search field") + search.click() + app.typeText("Redis") + + let redis = chooser.outlines.firstMatch.staticTexts + .matching(NSPredicate(format: "value == %@", "Redis")) + .firstMatch XCTAssertTrue(redis.waitForExistence(timeout: 10), "The chooser should list Redis") - waitUntilHittable(redis) - redis.click() + redis.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() + + let form = app.windows["connection-form"] + XCTAssertTrue(form.waitForExistence(timeout: 10), "Choosing Redis should open the connection form") + return form } private func select(option: String, in picker: XCUIElement) { - waitUntilHittable(picker) + XCTAssertTrue(waitUntilHittable(picker, timeout: 10)) picker.click() let item = picker.menuItems[option] XCTAssertTrue(item.waitForExistence(timeout: 5), "The mode picker should offer \(option)") item.click() } - - private func waitUntilHittable(_ element: XCUIElement) { - let hittable = NSPredicate(format: "isHittable == true") - let expectation = XCTNSPredicateExpectation(predicate: hittable, object: element) - XCTAssertEqual(XCTWaiter().wait(for: [expectation], timeout: 10), .completed) - } } diff --git a/TableProUITests/ResultStatusBarUITests.swift b/TableProUITests/ResultStatusBarUITests.swift index 42ce8cde2..f3f59077d 100644 --- a/TableProUITests/ResultStatusBarUITests.swift +++ b/TableProUITests/ResultStatusBarUITests.swift @@ -68,12 +68,12 @@ final class ResultStatusBarUITests: UITestCase { let window = app.windows.firstMatch for table in ["Album", "Artist", "Track"] { - let row = window.outlines.firstMatch.staticTexts[table].firstMatch + let row = objectBrowserRow(table, in: window) guard row.waitForExistence(timeout: 15) else { XCTFail("The object browser must list \(table)") return } - row.click() + clickAtCenter(row) let readout = window.staticTexts["result-status-readout"].firstMatch XCTAssertTrue(readout.waitForExistence(timeout: 15), "\(table): the readout must survive the switch") diff --git a/TableProUITests/SettingsWindowTitleUITests.swift b/TableProUITests/SettingsWindowTitleUITests.swift index b30567205..bf2164e4b 100644 --- a/TableProUITests/SettingsWindowTitleUITests.swift +++ b/TableProUITests/SettingsWindowTitleUITests.swift @@ -7,7 +7,7 @@ final class SettingsWindowTitleUITests: UITestCase { let app = try launchApp() XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) - let settingsMenuItem = app.menuBars.menuItems["Settings..."] + let settingsMenuItem = app.menuBars.menuItems["Settings…"] XCTAssertTrue(settingsMenuItem.waitForExistence(timeout: 10)) settingsMenuItem.click() diff --git a/TableProUITests/StructureTabIdentityUITests.swift b/TableProUITests/StructureTabIdentityUITests.swift index 151f858a0..25bdf7d39 100644 --- a/TableProUITests/StructureTabIdentityUITests.swift +++ b/TableProUITests/StructureTabIdentityUITests.swift @@ -14,15 +14,18 @@ final class StructureTabIdentityUITests: UITestCase { let app = try launchWithSampleDatabase() let window = app.windows.firstMatch - let row = window.outlines.firstMatch.staticTexts["Album"].firstMatch + let row = objectBrowserRow("Album", in: window) XCTAssertTrue(row.waitForExistence(timeout: 20), "The object browser must list Album") - row.click() + clickAtCenter(row) showStructure(in: window) let indexes = subTab(named: "Indexes", in: window) XCTAssertTrue(indexes.waitForExistence(timeout: 20), "The structure editor must offer Indexes") indexes.click() - XCTAssertTrue(indexes.isSelected, "The first tab is on Indexes") + XCTAssertTrue( + waitForPredicate(timeout: 10) { isSelected(indexes) }, + "The first tab is on Indexes" + ) row.rightClick() let openInNewTab = app.menuItems["Open in New Tab"].firstMatch @@ -33,11 +36,17 @@ final class StructureTabIdentityUITests: UITestCase { let columns = subTab(named: "Columns", in: window) XCTAssertTrue(columns.waitForExistence(timeout: 20), "The second tab must have its own structure editor") XCTAssertTrue( - columns.isSelected, + waitForPredicate(timeout: 10) { isSelected(columns) }, "The second tab opens on Columns rather than inheriting the first tab's Indexes" ) } + /// A radio button in a hosted picker reports `AXSelected` as nil and answers with `AXValue` + /// instead, so `isSelected` reads as false however the picker is set. + private func isSelected(_ segment: XCUIElement) -> Bool { + (segment.value as? NSNumber)?.intValue == 1 + } + private func showStructure(in window: XCUIElement) { let modePicker = window.radioGroups["results-view-mode-picker"].firstMatch XCTAssertTrue(modePicker.waitForExistence(timeout: 20), "The result must expose its view modes") diff --git a/TableProUITests/Support/UITestCase.swift b/TableProUITests/Support/UITestCase.swift index 740c81ca8..a62fb3d7f 100644 --- a/TableProUITests/Support/UITestCase.swift +++ b/TableProUITests/Support/UITestCase.swift @@ -143,6 +143,25 @@ internal class UITestCase: XCTestCase { waitForPredicate(timeout: timeout) { element.exists && element.isHittable } } + /// The object browser draws its rows as hosted cells, so a row's name arrives as the static + /// text's `value`, carrying the object kind the row reads out to VoiceOver, rather than as a + /// label or an identifier. Matching on `value` is what finds them. + internal func objectBrowserRow( + _ name: String, + kind: String = "Table", + in window: XCUIElement + ) -> XCUIElement { + window.outlines.firstMatch.staticTexts + .matching(NSPredicate(format: "value == %@", "\(kind): \(name)")) + .firstMatch + } + + /// AppKit reports those rows as disabled, so they never become hittable and a plain `click()` + /// waits for a state that cannot arrive. Clicking through a coordinate reaches them. + internal func clickAtCenter(_ element: XCUIElement) { + element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() + } + /// A defaults suite is a file in the user's preferences directory, so removing the sandbox /// directory alone would leave one behind for every test that ever ran. private func removeDefaultsSuite(forSandboxAt root: URL) { diff --git a/scripts/build-duckdb-ios.sh b/scripts/build-duckdb-ios.sh index 8577f3301..b1d02694d 100755 --- a/scripts/build-duckdb-ios.sh +++ b/scripts/build-duckdb-ios.sh @@ -32,9 +32,9 @@ set -euo pipefail # # Usage: # scripts/build-duckdb-ios.sh [duckdb-version] -# DUCKDB_VERSION=v1.5.3 scripts/build-duckdb-ios.sh +# DUCKDB_VERSION=v1.5.2 scripts/build-duckdb-ios.sh -DUCKDB_VERSION="${1:-${DUCKDB_VERSION:-v1.5.3}}" +DUCKDB_VERSION="${1:-${DUCKDB_VERSION:-v1.5.2}}" DEPLOYMENT_TARGET="${DEPLOYMENT_TARGET:-15.0}" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" diff --git a/scripts/build-duckdb.sh b/scripts/build-duckdb.sh index abf6b8106..a8d43d560 100755 --- a/scripts/build-duckdb.sh +++ b/scripts/build-duckdb.sh @@ -22,7 +22,7 @@ set -euo pipefail # Libs/checksums.sha256 by hand): # scripts/publish-libs.sh libduckdb_arm64.a libduckdb_x86_64.a libduckdb_universal.a libduckdb.a -DUCKDB_VERSION="${DUCKDB_VERSION:-v1.5.2}" +DUCKDB_VERSION="v1.5.2" DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-14.0}" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"