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
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ on:
type: string
default: ''

swiftpm_regressions:
description: Run metric tile and Claude reset tests; omit account-menu comparison in comparison mode
type: boolean
default: false

cost_source_role:
description: Source measured by an automatic-cost control
type: choice
Expand Down Expand Up @@ -246,6 +251,14 @@ jobs:
- name: Run smoke harness
run: xcrun swift run CodexBarIOSSmokeTests

- name: Run metric tile SwiftPM regressions
if: ${{ github.event_name == 'workflow_dispatch' && inputs.swiftpm_regressions }}
run: xcrun swift test --filter MetricTileContentRegressionTests

- name: Run Claude reset SwiftPM regressions
if: ${{ github.event_name == 'workflow_dispatch' && inputs.swiftpm_regressions }}
run: xcrun swift test --filter ClaudeUsageReset

- name: Verify widget reset caption layout
run: ./scripts/tests/widget-reset-layout-tests.sh

Expand Down Expand Up @@ -508,7 +521,7 @@ jobs:

account-menu-comparison:
name: Account menu comparison
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ui_validation_mode == 'account-menu-comparison' && success() }}
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ui_validation_mode == 'account-menu-comparison' && !inputs.swiftpm_regressions && success() }}
needs:
- swiftlint
- strict-concurrency
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ building, testing, or releasing the app.

### Developer Experience

- Restore filtered SwiftPM test compilation after Google provider removal, using
a provider-independent metric fixture. Manual CI can run the metric tile and
Claude reset regressions without UI journeys.
([#562](https://github.com/hemsoft-dev/codexbar-ios/issues/562))

- Ad-hoc-sign simulator unit-test hosts in manual validation so saved-account
migration tests can exercise the real Keychain without developer credentials.

Expand Down
2 changes: 1 addition & 1 deletion CodexBarIOS/Services/StoreKitProStoreSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ final class StoreKitProStoreSource: ProStoreSource {
}
}

private static func subscription(_ status: Product.SubscriptionInfo.Status) -> ProSubscriptionEvidence? {
static func subscription(_ status: Product.SubscriptionInfo.Status) -> ProSubscriptionEvidence? {
guard case let .verified(transaction) = status.transaction,
let purchase = purchase(transaction), purchase.product != .lifetime else { return nil }
guard case let .verified(renewal) = status.renewalInfo else {
Expand Down
104 changes: 98 additions & 6 deletions CodexBarIOSTests/DashboardAndSettingsTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Combine
import XCTest
@testable import CodexBarIOS

Expand Down Expand Up @@ -2413,9 +2414,19 @@ final class DashboardAndSettingsTests: XCTestCase {

@MainActor
func testWatchSnapshotCoordinatorClearsPausedAccountsWhenFreeResolvesBeforeInitialResults() async throws {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
defer { defaults.removePersistentDomain(forName: #function) }
try await assertWatchSnapshotFreeTransition(coalescingDelay: .milliseconds(5), suiteName: #function)
}

@MainActor
func testWatchSnapshotFreeTransitionWithZeroCoalescingDelay() async throws {
try await assertWatchSnapshotFreeTransition(coalescingDelay: .zero, suiteName: #function)
}

@MainActor
private func assertWatchSnapshotFreeTransition(coalescingDelay: Duration, suiteName: String) async throws {
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let (store, service, sender) = try makeWatchSnapshotAccessFixture(
defaults: defaults,
initialAccess: .unresolved
Expand All @@ -2424,16 +2435,51 @@ final class DashboardAndSettingsTests: XCTestCase {
refreshService: service,
configurationStore: store,
sender: sender,
coalescingDelay: .milliseconds(5)
coalescingDelay: coalescingDelay
)
coordinator.start()
sender.completeActivation()
XCTAssertEqual(sender.snapshots.count, 1, "Unresolved startup must preserve the previous snapshot")

let snapshotPublished = expectation(description: "Free access clears old watch accounts without provider results")
sender.onPublish = { snapshotPublished.fulfill() }
let taskEntered = expectation(description: "Free transition main-actor witness entered")
let trace = WatchTransitionTrace(label: "delay=\(coalescingDelay)")
let record: @Sendable (String) -> Void = { trace.record($0) }
record("wall anchor=\(Date().timeIntervalSince1970), test priority=\(Task.currentPriority.rawValue)")
var accessEvents = 0
let accessSubscription = store.$accountAccess.dropFirst().sink { access in
accessEvents += 1
record("access emitted: resolved=\(access.isResolved), pro=\(access.hasProAccess)")
}
defer { accessSubscription.cancel() }
sender.onPublish = {
record("sender published: snapshots=\(sender.snapshots.count)")
snapshotPublished.fulfill()
}
defer { sender.onPublish = nil }
record("applying free access")
store.applyPlanAccess(hasProAccess: false, isResolved: true)
await fulfillment(of: [snapshotPublished], timeout: 1)
record("free access committed: resolved=\(store.accountAccess.isResolved), pro=\(store.accountAccess.hasProAccess)")
DispatchQueue.main.async { record("main-queue witness entered") }
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(5)) {
record("independent main-queue timer returned")
}
let executorWitness = Task.detached(priority: Task.currentPriority) {
record("global-executor witness entered")
// One diagnostic timer, never a retry or a wait used to satisfy an assertion.
do { try await Task.sleep(for: .milliseconds(5)) } catch { return }
record("global-executor timer returned")
await MainActor.run { record("main-actor witness after global timer") }
}
defer { executorWitness.cancel() }
Task { @MainActor in
record("main-actor witness entered")
taskEntered.fulfill()
}
await fulfillment(of: [taskEntered, snapshotPublished], timeout: 1)
record("wait returned")
print("[ios564-transition \(trace.label)] \(trace.summary)")
XCTAssertEqual(accessEvents, 1, "The fixture must emit exactly one access transition: \(trace.summary)")

XCTAssertTrue(service.results.isEmpty)
XCTAssertEqual(store.accountAccess.pausedAccountIDs.count, 1)
Expand All @@ -2445,6 +2491,30 @@ final class DashboardAndSettingsTests: XCTestCase {
withExtendedLifetime(coordinator) {}
}

@MainActor
func testWatchSnapshotFreeTransitionPublishesWithoutWaitingForTheCoalescingTask() throws {
let suite = "WatchSnapshotDirectTransition.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let (store, service, sender) = try makeWatchSnapshotAccessFixture(defaults: defaults, initialAccess: .unresolved)
let coordinator = WatchSnapshotCoordinator(
refreshService: service, configurationStore: store, sender: sender
)
coordinator.start()
sender.completeActivation()
XCTAssertEqual(sender.snapshots.count, 1, "Unresolved startup preserves the retained snapshot")
store.applyPlanAccess(hasProAccess: false, isResolved: true)
coordinator.publishCurrentSnapshot()
XCTAssertTrue(service.results.isEmpty)
XCTAssertEqual(store.accountAccess.pausedAccountIDs.count, 1)
XCTAssertEqual(sender.snapshots.count, 2)
XCTAssertTrue(try XCTUnwrap(sender.snapshots.last).accounts.isEmpty)
XCTAssertEqual(sender.publishedForces, [false, false])
XCTAssertEqual(store.configurations.count, 2)
XCTAssertTrue(store.configurations.allSatisfy { store.hasSecret(for: $0) })
withExtendedLifetime(coordinator) {}
}

@MainActor
func testWatchSnapshotCoordinatorClearsPausedAccountsOnFreeStartupBeforeInitialResults() throws {
let defaults = UserDefaults(suiteName: #function)!
Expand Down Expand Up @@ -4576,6 +4646,28 @@ private final class DelayedStubCopilotAuthService: CopilotWebAuthenticating {
}
}

private final class WatchTransitionTrace: @unchecked Sendable {
private let lock = NSLock()
private let started = ContinuousClock.now
let label: String
private var events = [String]()

init(label: String) { self.label = label }

func record(_ event: String) {
let entry = "\(started.duration(to: .now)): \(event)"
lock.lock()
defer { lock.unlock() }
events.append(entry)
}

var summary: String {
lock.lock()
defer { lock.unlock() }
return events.joined(separator: " | ")
}
}

@MainActor
private final class RecordingWatchSnapshotSender: WatchSnapshotSending {
private var activationHandler: (@MainActor (_ force: Bool) -> Void)?
Expand Down
30 changes: 26 additions & 4 deletions CodexBarIOSTests/ProviderUsageCardHiddenSeverityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -393,14 +393,35 @@ final class ProviderUsageCardHiddenSeverityTests: XCTestCase {
XCTAssertTrue(labels.contains("Resets five-hour usage and weekly usage."), "\(labels)")
XCTAssertTrue(labels.contains("Paused by Claude"), "\(labels)")
XCTAssertTrue(labels.contains("Not currently available"), "\(labels)")
XCTAssertTrue(
labels.contains("Claude may require a usage limit to be reached before this reset can be used."), "\(labels)"
XCTAssertEqual(
labels.filter { $0 == "CodexBar can currently use only the reset selected by Claude." }.count, 2, "\(labels)"
)
XCTAssertTrue(labels.contains("Claude has not enabled this reset for use now."), "\(labels)")
XCTAssertEqual(probe.elements(identifier: "claude-use-reset").map(\.accessibilityLabel), ["Use one reset"])
XCTAssertNil(probe.element(identifier: "claude-reset-cooldown"))
}

@MainActor
func testSelectedResetExplainsItsLimitOrDisabledState() throws {
let now = Date()
for requiresLimit in [false, true] {
let grant = Self.resetGrant(id: "selected", requiresLimit: requiresLimit, isUsableNow: false, now: now)
let inventory = ClaudeUsageResetInventory(
isEligible: true, grants: [grant], selectedGrantID: grant.id,
cooldownUntil: nil, credentialBinding: "binding"
)
let probe = try HostedViewProbe(ClaudeUsageResetInventoryView(
inventory: inventory, accountName: "Work Claude", canRedeem: true,
onUseReset: { _, _ in ClaudeUsageResetFeedback(message: "Unused", isSuccess: true) }
))
defer { probe.close() }
let expected = requiresLimit
? "Claude may require a usage limit to be reached before this reset can be used."
: "Claude has not enabled this reset for use now."
XCTAssertTrue(probe.labels.contains(expected), "\(probe.labels)")
XCTAssertTrue(probe.elements(identifier: "claude-use-reset").isEmpty)
}
}

@MainActor
func testResetInventoryHidesActionDuringCooldownOrWhenRedemptionIsUnavailable() throws {
let now = Date()
Expand Down Expand Up @@ -532,12 +553,13 @@ final class ProviderUsageCardHiddenSeverityTests: XCTestCase {
expiresIn: TimeInterval? = 86_400,
isPaused: Bool = false,
requiresLimit: Bool = true,
isUsableNow: Bool = true,
now: Date
) -> ClaudeUsageResetGrant {
ClaudeUsageResetGrant(
id: id, title: title, remainingCount: remainingCount, startsAt: now.addingTimeInterval(-3_600),
expiresAt: expiresIn.map { now.addingTimeInterval($0) }, clears: ["seven_day", "five_hour"],
isPaused: isPaused, isUsableNow: true, requiresLimit: requiresLimit
isPaused: isPaused, isUsableNow: isUsableNow, requiresLimit: requiresLimit
)
}
}
Expand Down
Loading
Loading