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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .claude/skills/fix-issue/scripts/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> = ["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
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import CloudKit
import Foundation
import os
import Security

public struct PullResult: Sendable {
public let changedRecords: [CKRecord]
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ private final class SpyLiveActivityHandle: LiveActivityHandle {
var holdsEndUntilReleased = false
private(set) var isEndParked = false
private var endGate: CheckedContinuation<Void, Never>?
private var parkWaiters: [CheckedContinuation<Void, Never>] = []

init(id: String, state: QueryActivityAttributes.ContentState) {
self.id = id
Expand All @@ -29,6 +30,8 @@ private final class SpyLiveActivityHandle: LiveActivityHandle {
await withCheckedContinuation {
endGate = $0
isEndParked = true
parkWaiters.forEach { $0.resume() }
parkWaiters.removeAll()
}
}
self.state = state
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions TableProMobile/TableProMobileTests/Support/ObservedCondition.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,42 @@ private final class LayoutProbe {
var markerFrame: CGRect = .zero
var listInsets = EdgeInsets()
private var unseenChanges = 0
private var waiter: CheckedContinuation<Void, Never>?
private var waiter: CheckedContinuation<Bool, Never>?
private var quietTimer: Task<Void, Never>?

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 {
Expand All @@ -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() {
Expand Down
Loading
Loading