From 3ea5ea6a9b38e6a88ce001559d66c6678d30564d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 19 Sep 2026 02:00:59 +0700 Subject: [PATCH 1/5] fix(docs): check heading anchors with the slug Mintlify generates --- .claude/skills/fix-issue/scripts/verify.sh | 11 ++-- CLAUDE.md | 2 +- docs/scripts/check-links.py | 75 ++++++++++++++++++++-- 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/.claude/skills/fix-issue/scripts/verify.sh b/.claude/skills/fix-issue/scripts/verify.sh index a82b6e5a28..40092d7380 100755 --- a/.claude/skills/fix-issue/scripts/verify.sh +++ b/.claude/skills/fix-issue/scripts/verify.sh @@ -512,13 +512,14 @@ case "$STEP" in ;; docs) - # The two checks that actually read docs/. Neither runs anywhere else in this script, and + # The three checks that actually read docs/. None runs anywhere else in this script, and # CI runs them in the "Validate docs" job, so a local run is the only way to see a failure - # before the push. + # before the push. check-links.py was missing here, so a link to a heading that does not + # exist reached main on a green local run (#2988). log="$(new_log docs)" : > "$log" code=0 - for check in "check-writing-style.sh" "check-docs-against-source.py"; do + for check in "check-writing-style.sh" "check-docs-against-source.py" "check-links.py"; do script="$REPO_ROOT/docs/scripts/$check" if [ ! -f "$script" ]; then note "missing: docs/scripts/$check" @@ -533,12 +534,12 @@ case "$STEP" in if [ "$STATUS" != "INCONCLUSIVE" ]; then if [ $code -eq 0 ]; then STATUS=PASS - note "docs/: house style and source claims both agree" + note "docs/: house style, source claims and links all agree" else STATUS=FAIL # The scripts print one line per check, most of them "ok". Show the failing check # and the file:line under it, not the twenty passes above it. - note "$(grep -A 2 -E '^FAIL' "$log" 2> /dev/null | sed 's/^/ /' | head -15)" + note "$(grep -A 2 -E '^ *FAIL' "$log" 2> /dev/null | sed 's/^/ /' | head -15)" note "$(grep -E 'contradict|house style' "$log" 2> /dev/null | sed 's/^/ /' | head -3)" fi fi diff --git a/CLAUDE.md b/CLAUDE.md index 288ad81b79..08b1fe8294 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -337,7 +337,7 @@ These are **non-negotiable**, never skip them: - Settings changes → `docs/customization/settings.mdx` - Database driver changes → `docs/databases/*.mdx` - **`docs/STYLE.md` is the spec for how a page is written, and it is binding.** Read it before writing, not after. Then run `.claude/skills/fix-issue/scripts/verify.sh docs`, which runs the two checks that actually read `docs/`. The `agent docs:` line in `verify.sh lint` is a different check over `CLAUDE.md` and `.claude/`; it never opens `docs/`. `.claude/rules/docs-authoring.md` lists the STYLE.md rules no script enforces, each with the defect that produced it. + **`docs/STYLE.md` is the spec for how a page is written, and it is binding.** Read it before writing, not after. Then run `.claude/skills/fix-issue/scripts/verify.sh docs`, which runs the three checks that actually read `docs/`. The `agent docs:` line in `verify.sh lint` is a different check over `CLAUDE.md` and `.claude/`; it never opens `docs/`. `.claude/rules/docs-authoring.md` lists the STYLE.md rules no script enforces, each with the defect that produced it. 4. **Tests**: Every change with testable behavior must include or update unit/function tests. UI and user-flow changes should add or update `TableProUITests` UI automation where the flow runs deterministically; if it can't, note why in the PR description. When tests fail, fix the source code, never adjust tests to match incorrect output. Tests define expected behavior. diff --git a/docs/scripts/check-links.py b/docs/scripts/check-links.py index 6cdb1a3ac2..262bf367f4 100755 --- a/docs/scripts/check-links.py +++ b/docs/scripts/check-links.py @@ -10,11 +10,78 @@ import re import sys from pathlib import Path +from urllib.parse import quote, unquote DOCS = Path(__file__).resolve().parent.parent LINK = re.compile(r"\]\((/[^)\s]*?)(?:\s+\"[^\"]*\")?\)") SRC = re.compile(r'src=\{?"(/[^"]+)"') FENCE = re.compile(r"```.*?```", re.S) +HEADING = re.compile(r"^#{1,4} +(.+?)\s*$", re.M) +CODE_SPAN = re.compile(r"(`[^`]*`)") +INLINE_LINK = re.compile(r"!?\[([^\]]*)\]\([^)]*\)") +EMPHASIS = re.compile(r"(\*\*|\*)(.+?)\1") +TAG = re.compile(r"<[^>]+>") + + +def heading_text(raw): + """The heading as Mintlify reads it: the text of its nodes, without the markdown around them.""" + text = [] + for part in CODE_SPAN.split(raw): + if len(part) >= 2 and part.startswith("`") and part.endswith("`"): + text.append(part[1:-1]) + continue + part = INLINE_LINK.sub(r"\1", part) + part = EMPHASIS.sub(r"\2", part) + text.append(TAG.sub("", part)) + return "".join(text) + + +def clean_heading_id(anchor): + """Both sides of an anchor comparison, normalised the way `mint broken-links` does it. + + Mirrors `cleanHeadingId` in @mintlify/common: percent-decode, then drop the punctuation the + rendered page leaves out of its ids. So `#pl%2Fsql` and the heading "PL/SQL" both become + `pl/sql`, and a lone `%` is taken literally rather than rejected. + """ + escaped = re.sub(r"%(?![0-9A-Fa-f]{2})", "%25", anchor) + try: + decoded = unquote(escaped, errors="strict") + except UnicodeDecodeError: + return anchor + return re.sub(r"""[?,;:!'"()\[\]{}]""", "", decoded) + + +def heading_slug(title): + """The slug Mintlify gives a heading, before `clean_heading_id`. + + Mirrors `slugify` in @mintlify/common over @sindresorhus/slugify. A title is lowercased, its + whitespace becomes `-` and it is percent-encoded. When encoding escaped anything, the escapes + are kept, so "PL/SQL" is `pl%2Fsql`, not the `pl-sql` a plain alphanumeric slug would give. + """ + unicode_id = quote(re.sub(r"\s+", "-", title.lower().strip()), safe="-_.!~*'()") + kept = "a-zA-Z0-9%_" if re.search(r"%[0-9A-F]{2}", unicode_id) else "a-z0-9_" + slug = re.sub(f"[^{kept}]+", "-", unicode_id) + slug = re.sub(r"([a-zA-Z\d]+)-([ts])(-|$)", r"\1\2\3", slug) + return re.sub(r"-{2,}", "-", slug).strip("-") + + +def page_anchors(body): + seen = {} + anchors = set() + for raw in HEADING.findall(body): + slug = heading_slug(heading_text(raw)) + if not slug: + continue + count = seen.get(slug, 0) + seen[slug] = count + 1 + if count: + suffix = count + 1 + while f"{slug}-{suffix}" in seen: + suffix += 1 + slug = f"{slug}-{suffix}" + seen[slug] = 1 + anchors.add(clean_heading_id(slug)) + return anchors def nav_pages(node, out, collecting=False): @@ -98,11 +165,7 @@ def main() -> int: if "node_modules" in path.parts: continue slug = "/" + str(path.relative_to(DOCS).with_suffix("")) - body = FENCE.sub("", path.read_text()) - anchors[slug] = { - "#" + re.sub(r"[^a-z0-9]+", "-", h.lower()).strip("-") - for h in re.findall(r"^#{2,4} +(.+?)\s*$", body, re.M) - } + anchors[slug] = page_anchors(FENCE.sub("", path.read_text())) for path in sorted(DOCS.rglob("*.mdx")): if "node_modules" in path.parts: @@ -124,7 +187,7 @@ def main() -> int: bare = page.lstrip("/") if bare not in on_disk: failures.append(f"{rel}:{line_no} links to {target}, which does not resolve") - elif anchor and "#" + anchor not in anchors.get(page, set()): + elif anchor and clean_heading_id(anchor) not in anchors.get(page, set()): failures.append(f"{rel}:{line_no} links to {target}, but that heading does not exist") for asset in SRC.findall(line): if not (DOCS / asset.lstrip("/")).exists(): From 7b4034f2c5416eb58ec31e73808c95b7115a135a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 19 Sep 2026 02:01:00 +0700 Subject: [PATCH 2/5] fix(ios): read the iCloud entitlement before creating a CloudKit container in the simulator --- .../CloudKitEntitlement.swift | 52 +++++++++++++++++++ .../CloudKitSyncEngine.swift | 8 +-- .../CloudKitEntitlementTests.swift | 52 +++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProSyncTransport/CloudKitEntitlement.swift create mode 100644 Packages/TableProCore/Tests/TableProSyncTests/CloudKitEntitlementTests.swift diff --git a/Packages/TableProCore/Sources/TableProSyncTransport/CloudKitEntitlement.swift b/Packages/TableProCore/Sources/TableProSyncTransport/CloudKitEntitlement.swift new file mode 100644 index 0000000000..762b8cdacf --- /dev/null +++ b/Packages/TableProCore/Sources/TableProSyncTransport/CloudKitEntitlement.swift @@ -0,0 +1,52 @@ +import Foundation +#if targetEnvironment(simulator) +import MachO +#endif +#if os(macOS) +import Security +#endif + +enum CloudKitEntitlement { + static let servicesKey = "com.apple.developer.icloud-services" + private static let grantingServices: Set = ["CloudKit", "CloudKit-Anonymous"] + + static func grants(servicesValue value: Any?) -> Bool { + guard let services = value as? [String] else { return false } + return !grantingServices.isDisjoint(with: services) + } + + static func grants(entitlementsPropertyList data: Data) -> Bool { + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) + guard let entitlements = plist as? [String: Any] else { return false } + return grants(servicesValue: entitlements[servicesKey]) + } + + static func isGrantedToCurrentProcess() -> Bool { + #if os(macOS) + guard let task = SecTaskCreateFromSelf(nil) else { return false } + return grants(servicesValue: SecTaskCopyValueForEntitlement(task, servicesKey as CFString, nil)) + #elseif targetEnvironment(simulator) + guard let data = mainExecutableSection(segment: "__TEXT", section: "__entitlements") else { return false } + return grants(entitlementsPropertyList: data) + #else + return true + #endif + } + + #if targetEnvironment(simulator) + private static func mainExecutableSection(segment: String, section: String) -> Data? { + for index in 0..<_dyld_image_count() { + guard let header = _dyld_get_image_header(index), header.pointee.filetype == UInt32(MH_EXECUTE) else { + continue + } + var size: UInt = 0 + let bytes = header.withMemoryRebound(to: mach_header_64.self, capacity: 1) { + getsectiondata($0, segment, section, &size) + } + guard let bytes, size > 0 else { return nil } + return Data(bytes: bytes, count: Int(size)) + } + return nil + } + #endif +} diff --git a/Packages/TableProCore/Sources/TableProSyncTransport/CloudKitSyncEngine.swift b/Packages/TableProCore/Sources/TableProSyncTransport/CloudKitSyncEngine.swift index a7971a439c..194cc697c9 100644 --- a/Packages/TableProCore/Sources/TableProSyncTransport/CloudKitSyncEngine.swift +++ b/Packages/TableProCore/Sources/TableProSyncTransport/CloudKitSyncEngine.swift @@ -1,7 +1,6 @@ import CloudKit import Foundation import os -import Security public struct PullResult: Sendable { public let changedRecords: [CKRecord] @@ -28,12 +27,7 @@ public actor CloudKitSyncEngine { private static let maxRetries = 3 public static func hasICloudEntitlement() -> Bool { - #if os(macOS) - guard let task = SecTaskCreateFromSelf(nil) else { return false } - return SecTaskCopyValueForEntitlement(task, "com.apple.developer.icloud-services" as CFString, nil) != nil - #else - return true - #endif + CloudKitEntitlement.isGrantedToCurrentProcess() } public init(containerIdentifier: String = defaultContainerID) { diff --git a/Packages/TableProCore/Tests/TableProSyncTests/CloudKitEntitlementTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/CloudKitEntitlementTests.swift new file mode 100644 index 0000000000..9c51cd801a --- /dev/null +++ b/Packages/TableProCore/Tests/TableProSyncTests/CloudKitEntitlementTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing + +@testable import TableProSyncTransport + +@Suite("CloudKit entitlement") +struct CloudKitEntitlementTests { + private func propertyList(_ entitlements: [String: Any]) throws -> Data { + try PropertyListSerialization.data(fromPropertyList: entitlements, format: .xml, options: 0) + } + + @Test("CloudKit and CloudKit-Anonymous each grant the container", arguments: ["CloudKit", "CloudKit-Anonymous"]) + func grantingService(_ service: String) { + #expect(CloudKitEntitlement.grants(servicesValue: [service])) + #expect(CloudKitEntitlement.grants(servicesValue: ["CloudDocuments", service])) + } + + @Test("iCloud Documents alone does not grant CloudKit") + func documentsAlone() { + #expect(!CloudKitEntitlement.grants(servicesValue: ["CloudDocuments"])) + } + + @Test("A missing, empty or mistyped value grants nothing") + func missingOrMalformed() { + #expect(!CloudKitEntitlement.grants(servicesValue: nil)) + #expect(!CloudKitEntitlement.grants(servicesValue: [String]())) + #expect(!CloudKitEntitlement.grants(servicesValue: "CloudKit")) + #expect(!CloudKitEntitlement.grants(servicesValue: ["CloudKit": true])) + } + + @Test("The entitlements a signed simulator build embeds grant CloudKit") + func signedSimulatorEntitlements() throws { + let data = try propertyList([ + "application-identifier": "TEAMID.com.TablePro.TableProMobile", + "com.apple.developer.icloud-container-identifiers": ["iCloud.com.TablePro"], + CloudKitEntitlement.servicesKey: ["CloudKit"] + ]) + #expect(CloudKitEntitlement.grants(entitlementsPropertyList: data)) + } + + @Test("Entitlements without iCloud services grant nothing") + func entitlementsWithoutICloud() throws { + let data = try propertyList(["keychain-access-groups": ["TEAMID.com.TablePro.shared"]]) + #expect(!CloudKitEntitlement.grants(entitlementsPropertyList: data)) + } + + @Test("An empty or unreadable section grants nothing") + func unreadableSection() { + #expect(!CloudKitEntitlement.grants(entitlementsPropertyList: Data())) + #expect(!CloudKitEntitlement.grants(entitlementsPropertyList: Data("not a plist".utf8))) + } +} From 2fcf6ff558a615f289e1ef9292b5ce8926c1d6de Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 19 Sep 2026 03:07:49 +0700 Subject: [PATCH 3/5] test(ui): stop the sample database wait from starving the app, and send Back and Forward by key equivalent --- .../NavigationHistoryUITests.swift | 30 ++++++++++---- TableProUITests/Support/UITestCase.swift | 41 +++++++++++-------- .../Support/XCUIElementWaiting.swift | 13 +++++- 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/TableProUITests/NavigationHistoryUITests.swift b/TableProUITests/NavigationHistoryUITests.swift index e12ec1b3c6..c305965718 100644 --- a/TableProUITests/NavigationHistoryUITests.swift +++ b/TableProUITests/NavigationHistoryUITests.swift @@ -2,8 +2,10 @@ import AppKit import XCTest /// Issue #2316. Following a reference used to be one-way: the view you came from was gone, with no -/// control anywhere that brought it back. Back is driven here through the menu bar rather than the -/// toolbar button, because that is the path the key equivalent takes too. +/// control anywhere that brought it back. Back and Forward are driven here through the View menu's +/// own items rather than the toolbar buttons: the item has to validate as enabled, and then its key +/// equivalent goes through `NSMenu.performKeyEquivalent`, which validates it again and sends its +/// action. final class NavigationHistoryUITests: UITestCase { func testBackReturnsToTheTableTheTabWasRetargetedAwayFrom() throws { let app = try launchWithSampleDatabase() @@ -21,7 +23,7 @@ final class NavigationHistoryUITests: UITestCase { "Clicking a second table must retarget the preview tab. Title: \(title(of: window))" ) - try clickViewMenuItem("Back", in: app) + try performViewMenuItem("Back", keyEquivalent: "[", in: app) XCTAssertTrue( waitForPredicate(timeout: 20) { title(of: window).contains("Album") }, @@ -50,10 +52,10 @@ final class NavigationHistoryUITests: UITestCase { click(row("Artist", in: window)) _ = waitForPredicate(timeout: 20) { title(of: window).contains("Artist") } - try clickViewMenuItem("Back", in: app) + try performViewMenuItem("Back", keyEquivalent: "[", in: app) XCTAssertTrue(waitForPredicate(timeout: 20) { title(of: window).contains("Album") }) - try clickViewMenuItem("Forward", in: app) + try performViewMenuItem("Forward", keyEquivalent: "]", in: app) XCTAssertTrue( waitForPredicate(timeout: 20) { title(of: window).contains("Artist") }, @@ -104,12 +106,26 @@ final class NavigationHistoryUITests: UITestCase { return item } - private func clickViewMenuItem(_ name: String, in app: XCUIApplication) throws { + /// Waits for the item to validate as enabled in the open menu, closes the menu, and sends the + /// item's key equivalent rather than clicking it. + /// + /// The View menu is 878pt tall, and on the runner's 1024x768 screen it gets 671pt, so AppKit + /// makes it scroll. Forward sits under the bottom scroll zone: XCUITest hovers it, the hover + /// scrolls the menu, and the click lands where Forward used to be, on the disabled Show + /// Previous Connection, or at no point at all once the menu has scrolled to its end. Nothing + /// fails there, the menu stays open and Forward is never sent (runs 35345089994, 35363502950 + /// and 35375539350). A key equivalent carries no geometry. + private func performViewMenuItem( + _ name: String, + keyEquivalent: String, + in app: XCUIApplication + ) throws { let item = try viewMenuItem(name, in: app) XCTAssertTrue( waitForPredicate(timeout: 10) { item.isEnabled }, "View > \(name) must be enabled once the tab has a history" ) - item.click() + app.typeKey(.escape, modifierFlags: []) + app.typeKey(keyEquivalent, modifierFlags: [.command, .control]) } } diff --git a/TableProUITests/Support/UITestCase.swift b/TableProUITests/Support/UITestCase.swift index b49b7fb327..6b860e3b12 100644 --- a/TableProUITests/Support/UITestCase.swift +++ b/TableProUITests/Support/UITestCase.swift @@ -136,27 +136,34 @@ internal class UITestCase: XCTestCase { /// at a window that had no connection yet, and every one of those misses cost an XCUITest /// retry. The object browser having rows is the cheapest proof the connection is live. /// - /// The query is built once and asks only whether a first match exists. Rebuilding - /// `app.windows.firstMatch.outlines.firstMatch` inside the poll re-resolves the chain from the - /// application element on every iteration, and `staticTexts.count` enumerates every static text - /// under the outline rather than stopping at the first. Together they cost seconds per - /// iteration once the window holds a loaded grid, so the timeout expires against the query - /// instead of against the app, and the failure reads as a launch that never finished. - /// The timeout is contention headroom, not a guess at how long opening takes. Three UI shards - /// share a runner with the unit job and both arch builds, and the tests that miss the window - /// are different on every run: this release's tag build lost `testTheBannerCanBeDismissed`, - /// `testSwitchConnectionOpensWithTheToolbarHidden` and - /// `testToggleFoldRunsWithTheCursorInsideAStatement`, and earlier runs lost an unrelated set. - /// A suite that reports a launch failure because a sibling shard had the CPU is measuring the - /// runner. Locally the wait settles in about two seconds, so the extra ceiling costs nothing - /// on a machine that is not starved. - internal func waitForSampleDatabaseWindow(in app: XCUIApplication, timeout: TimeInterval = 90) -> Bool { - let firstObject = app.children(matching: .window).firstMatch - .descendants(matching: .outline).firstMatch + /// The query is built once, asks only whether a first match exists, and never leaves the + /// sidebar. `objectBrowser(in:)` says why the last part matters: the sample opens `Track`, and + /// its 1,000 rows usually reach the grid before the table list reaches the sidebar. A search + /// for the outline that starts at the window walks the whole grid while the sidebar is still a + /// spinner, three to six seconds on the app's main thread per check, and the table list it was + /// waiting for could not load under that. The runs that reported this as "never finished + /// opening" had the sidebar spinning and the grid full, a different test each time. Locally the + /// wait settles in about two seconds. + internal func waitForSampleDatabaseWindow(in app: XCUIApplication, timeout: TimeInterval = 30) -> Bool { + let firstObject = objectBrowser(in: app.children(matching: .window).firstMatch) .descendants(matching: .staticText).firstMatch return waitForPredicate(timeout: timeout) { firstObject.exists } } + /// The connection window's object browser, found without searching the rest of the window. + /// + /// The sidebar is the first group directly under the window's split group, ahead of the + /// splitter and the detail pane: `SplitGroup > Group > ScrollView > Outline` once the tables + /// have loaded and `SplitGroup > Group > ActivityIndicator` before. A descendants search from + /// the window reaches the outline first when it exists, but when it does not yet exist the + /// search goes on into the data grid, which publishes about 12,000 elements for `Track`. + /// Stepping through direct children keeps a miss as cheap as a hit. + internal func objectBrowser(in window: XCUIElement) -> XCUIElement { + window.children(matching: .splitGroup).firstMatch + .children(matching: .group).firstMatch + .descendants(matching: .outline).firstMatch + } + /// Opens the sample database the way a person does. Only the menu contract suite needs this; /// everything else takes the launch route above. @discardableResult diff --git a/TableProUITests/Support/XCUIElementWaiting.swift b/TableProUITests/Support/XCUIElementWaiting.swift index 9a9828951a..70e6a70e85 100644 --- a/TableProUITests/Support/XCUIElementWaiting.swift +++ b/TableProUITests/Support/XCUIElementWaiting.swift @@ -13,12 +13,23 @@ import XCTest /// measured at 4.5 minutes of every CI run spent waiting for something already there. Checking /// first and polling afterwards costs nothing when the element exists and is no slower when it /// does not. +/// +/// The pause after a miss is at least as long as the check that missed. XCTest evaluates a query +/// inside the app, on its main thread, so a check that walks a loaded data grid holds that thread +/// for as long as the walk takes. A fixed 50ms pause after a five second walk left the app about +/// 0.4s of every five, and the table list the suite was waiting for took 25 to 107 seconds to load +/// instead of one to ten (runs 35345089994 and 35375539350). Matching the pause to the check's own +/// cost leaves the app at least half of its main thread whatever a query costs. internal enum UITestPoll { + private static let minimumPause: TimeInterval = 0.05 + internal static func until(timeout: TimeInterval, _ condition: () -> Bool) -> Bool { let deadline = Date(timeIntervalSinceNow: timeout) while Date() < deadline { + let checkStarted = Date() if condition() { return true } - RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) + let pause = max(minimumPause, Date().timeIntervalSince(checkStarted)) + RunLoop.current.run(until: Date(timeIntervalSinceNow: pause)) } return condition() } From 79abfef2e30a09df8641cf237c9290feddeb91ca Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 19 Sep 2026 04:01:19 +0700 Subject: [PATCH 4/5] test(ios): order the sealing check on a started signal, and bound the bar layout wait --- .../IOSConnectionExportFileDataTests.swift | 12 ++--- .../Views/BottomSafeAreaBarLayoutTests.swift | 46 +++++++++++++++---- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/TableProMobile/TableProMobileTests/IOSConnectionExportFileDataTests.swift b/TableProMobile/TableProMobileTests/IOSConnectionExportFileDataTests.swift index 2710b3525c..f95883a8de 100644 --- a/TableProMobile/TableProMobileTests/IOSConnectionExportFileDataTests.swift +++ b/TableProMobile/TableProMobileTests/IOSConnectionExportFileDataTests.swift @@ -72,23 +72,23 @@ struct IOSConnectionExportFileDataTests { func sealingLeavesMainActorFree() async throws { let envelope = makeEnvelope(password: "s3cret") let probe = SealingProbe() - let task = Task { @MainActor in - probe.hasStarted = true + let (started, signalStarted) = AsyncStream.makeStream(of: Void.self) + let sealing = Task { @MainActor in + signalStarted.yield() _ = try await IOSConnectionExportService.fileData(for: envelope, passphrase: "correct horse") probe.hasFinished = true } - while !probe.hasStarted { - await Task.yield() + for await _ in started { + break } #expect(!probe.hasFinished) - try await task.value + try await sealing.value #expect(probe.hasFinished) } } @MainActor private final class SealingProbe { - var hasStarted = false var hasFinished = false } diff --git a/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift b/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift index 5eb219a01d..6ce7d6597e 100644 --- a/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift +++ b/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift @@ -44,22 +44,42 @@ private final class LayoutProbe { var markerFrame: CGRect = .zero var listInsets = EdgeInsets() private var unseenChanges = 0 - private var waiter: CheckedContinuation? + private var waiter: CheckedContinuation? + private var quietTimer: Task? func record() { unseenChanges += 1 - waiter?.resume() - waiter = nil + resumeWaiter(changed: true) } - func nextChange() async { - if unseenChanges == 0 { - await withCheckedContinuation { waiter = $0 } + func nextChange(quietLimit: Duration) async -> Bool { + guard unseenChanges == 0 else { + unseenChanges = 0 + return true + } + let changed = await withCheckedContinuation { continuation in + waiter = continuation + quietTimer = Task { [weak self] in + try? await Task.sleep(for: quietLimit) + self?.resumeWaiter(changed: false) + } } unseenChanges = 0 + return changed + } + + private func resumeWaiter(changed: Bool) { + quietTimer?.cancel() + quietTimer = nil + waiter?.resume(returning: changed) + waiter = nil } } +private struct UnsettledLayout: Error, CustomStringConvertible { + let description: String +} + @MainActor private struct HostedTree { enum Variant { @@ -82,13 +102,23 @@ private struct HostedTree { } func settledTabBar() async throws -> UITabBar { - while true { + repeat { window.layoutIfNeeded() if let tabBar = visibleTabBar(in: window), isSettled(against: tabBar) { return tabBar } - await probe.nextChange() + } while await probe.nextChange(quietLimit: .seconds(10)) + throw UnsettledLayout(description: layoutReport()) + } + + private func layoutReport() -> String { + guard let tabBar = visibleTabBar(in: window) else { + return "No visible tab bar. List insets \(probe.listInsets), marker \(probe.markerFrame)" } + let tabBarFrame = tabBar.convert(tabBar.bounds, to: window) + return "The list's bottom inset \(probe.listInsets.bottom) never covered the tab bar band " + + "\(window.bounds.maxY - tabBarFrame.minY). Window \(window.bounds), tab bar \(tabBarFrame), " + + "marker \(probe.markerFrame), list insets \(probe.listInsets)" } func tearDown() { From 875f0903dba65f73936b7d1207582602156e79f3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 19 Sep 2026 04:28:12 +0700 Subject: [PATCH 5/5] test(ios): wait on the observed change instead of spinning on Task.yield --- .../Onboarding/ScenePresenterTests.swift | 4 +--- .../QueryActivityControllerTests.swift | 12 +++++++++--- .../QueryEditorViewModelTests.swift | 8 ++------ .../Support/ObservedCondition.swift | 16 ++++++++++++++++ 4 files changed, 28 insertions(+), 12 deletions(-) create mode 100644 TableProMobile/TableProMobileTests/Support/ObservedCondition.swift diff --git a/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift b/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift index 14cf2611c4..c98dca6a1b 100644 --- a/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift +++ b/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift @@ -98,9 +98,7 @@ struct ScenePresenterTests { #expect(presenter.isHeldByEditor) hold = nil - while presenter.isHeldByEditor { - await Task.yield() - } + await ObservedCondition.wait { !presenter.isHeldByEditor } #expect(presenter.isHeldByEditor == false) } } diff --git a/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift b/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift index 1b3fe3b85c..a19dd93ea2 100644 --- a/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift +++ b/TableProMobile/TableProMobileTests/QueryActivityControllerTests.swift @@ -13,6 +13,7 @@ private final class SpyLiveActivityHandle: LiveActivityHandle { var holdsEndUntilReleased = false private(set) var isEndParked = false private var endGate: CheckedContinuation? + private var parkWaiters: [CheckedContinuation] = [] init(id: String, state: QueryActivityAttributes.ContentState) { self.id = id @@ -29,6 +30,8 @@ private final class SpyLiveActivityHandle: LiveActivityHandle { await withCheckedContinuation { endGate = $0 isEndParked = true + parkWaiters.forEach { $0.resume() } + parkWaiters.removeAll() } } self.state = state @@ -41,6 +44,11 @@ private final class SpyLiveActivityHandle: LiveActivityHandle { endGate?.resume() endGate = nil } + + func untilEndParks() async { + guard !isEndParked else { return } + await withCheckedContinuation { parkWaiters.append($0) } + } } @MainActor @@ -335,9 +343,7 @@ struct QueryActivityControllerTests { handle?.holdsEndUntilReleased = true let ending = Task { await controller.end(token: token, outcome: .completed) } - while handle?.isEndParked == false { - await Task.yield() - } + await handle?.untilEndParks() await controller.reapOrphans() handle?.releaseEnd() await ending.value diff --git a/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift index e89e13ba27..cf2ec5998d 100644 --- a/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift @@ -46,9 +46,7 @@ struct QueryEditorViewModelTests { let vm = QueryEditorViewModel(windowCapacity: 100) let run = Task { await vm.run(driver: driver, query: "SELECT 1") } - while !vm.isRunning { - await Task.yield() - } + await ObservedCondition.wait { vm.isRunning } vm.stop() await gate.open() await run.value @@ -73,9 +71,7 @@ struct QueryEditorViewModelTests { let vm = QueryEditorViewModel(windowCapacity: 100) let run = Task { await vm.run(driver: driver, query: "UPDATE t SET a = 1") } - while !vm.isRunning { - await Task.yield() - } + await ObservedCondition.wait { vm.isRunning } await vm.handlePressure(.critical) await gate.open() await run.value diff --git a/TableProMobile/TableProMobileTests/Support/ObservedCondition.swift b/TableProMobile/TableProMobileTests/Support/ObservedCondition.swift new file mode 100644 index 0000000000..43eb2d6c97 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Support/ObservedCondition.swift @@ -0,0 +1,16 @@ +import Observation + +@MainActor +enum ObservedCondition { + static func wait(until condition: @escaping @MainActor () -> Bool) async { + while !condition() { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = condition() + } onChange: { + continuation.resume() + } + } + } + } +}