diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e07dbb..2cccb70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ building, testing, or releasing the app. ### Developer Experience +- Include both subscription-utilization journeys in the release manifest so + manual iOS validation can start and verify all 53 journeys per device family. + Retain the last verified-renewal predicate state if native expiration + validation fails, without changing its waits or acceptance conditions. + ([#523](https://github.com/hemsoft-dev/codexbar-ios/issues/523)) + - Trace native StoreKit status delivery on the existing test receiver, preserve status and clock diagnostics without transaction identifiers, and verify stable test-store cleanup. ([#470](https://github.com/hemsoft-dev/codexbar-ios/issues/470)) - Record the App Review submission of 2.0.0 build 5 and keep the shipped diff --git a/CodexBarIOSTests/StoreKitProSubscriptionTests.swift b/CodexBarIOSTests/StoreKitProSubscriptionTests.swift index e66ea30a..b17f892a 100644 --- a/CodexBarIOSTests/StoreKitProSubscriptionTests.swift +++ b/CodexBarIOSTests/StoreKitProSubscriptionTests.swift @@ -194,10 +194,51 @@ final class StoreKitProSubscriptionTests: XCTestCase { let service = try await purchaseMonthly(context) let transaction = try XCTUnwrap(context.session.allTransactions().first { $0.productIdentifier == ProProductID.monthly.rawValue }) try context.session.disableAutoRenewForTransaction(identifier: transaction.identifier) - try await storeKitEventually("Verified auto-renew-off did not arrive before natural expiration.", timeout: 25) { - let snapshot = await context.source.snapshot() - return snapshot.subscriptions.contains { $0.renewalVerified && $0.willAutoRenew == false } - && service.commerce.lifetimeAction == .available + let waitStarted = ProcessInfo.processInfo.systemUptime + var lastSnapshot: ProStoreSnapshot? + var lastAction: ProLifetimeAction? + var lastOff = false + var lastWallDate: Date? + var lastElapsed: TimeInterval? + var lastServiceStatusAvailable: Bool? + do { + try await storeKitEventually("Verified auto-renew-off did not arrive before natural expiration.", timeout: 25) { + let snapshot = await context.source.snapshot() + let off = snapshot.subscriptions.contains { $0.renewalVerified && $0.willAutoRenew == false } + // Preserve the original short circuit: inspect the lifetime action only after verified off. + let action: ProLifetimeAction? = off ? service.commerce.lifetimeAction : nil + lastSnapshot = snapshot + lastAction = action + lastOff = off + lastWallDate = Date() + lastElapsed = ProcessInfo.processInfo.systemUptime - waitStarted + lastServiceStatusAvailable = service.subscriptionStatusAvailable + return off && action == .available + } + } catch let primaryError { + do { + let statuses = lastSnapshot?.subscriptions ?? [] + let rows = statuses.prefix(3).map { + "product=\($0.purchase.product.rawValue),state=\($0.state),verified=\($0.renewalVerified)" + + ",renewing=\(String(describing: $0.willAutoRenew)),purchased=\($0.purchase.purchasedAt)" + + ",expires=\(String(describing: $0.purchase.expiresAt)),signed=\($0.signedAt)" + } + try storeKitEvent("auto-renew-off-timeout-last-observation", [ + "observationPresent": String(lastSnapshot != nil), + "elapsedSinceWaitInvocation": String(describing: lastElapsed), + "observedWallDate": String(describing: lastWallDate), + "sourceStatusAvailable": String(describing: lastSnapshot?.subscriptionStatusAvailable), + "sourceStatuses": rows.joined(separator: ";"), + "sourceStatusCount": String(statuses.count), + "sourceStatusesCapped": String(statuses.count > 3), + "verifiedOffPredicate": String(lastOff), + "lifetimeAction": lastAction.map { String(describing: $0) } ?? "not-evaluated", + "serviceStatusAvailable": String(describing: lastServiceStatusAvailable), + ]) + } catch { + print("Unable to preserve auto-renew-off diagnostics; retaining the primary failure.") + } + throw primaryError } try storeKitEvent("auto-renew-off-before-natural-expiry", await storeKitState(context, service: service)) do { diff --git a/UI-TESTING.md b/UI-TESTING.md index 909e1025..a1b6afd4 100644 --- a/UI-TESTING.md +++ b/UI-TESTING.md @@ -21,8 +21,8 @@ attachments and the accessibility hierarchy from the result bundle. Open the ## Complete release phases The base command above still executes exactly 29 original journeys. The -[phase manifest](scripts/pro-release-phases.json) requires 51 user journeys per -family for a release: original 29, supplemental 6, synthetic commerce 14, and +[phase manifest](scripts/pro-release-phases.json) requires 53 user journeys per +family for a release: original 29, supplemental 8, synthetic commerce 14, and native purchase/restore and purchase-error journeys 1 each. The separate marketing capture method is excluded. Normal preparation, purchase-error preparation and a final normal reset add three separate XCTest invocations; @@ -61,7 +61,7 @@ fails qualification even if the journeys passed. Each phase enumerates the built tests and checks exact identities, passing counts, zero skips/expected failures and one expected destination. It records source and binary hashes, signing, commands, timestamps, summaries and xcresults. -The supplemental six reuse the verified base build; native hosts are separately +The supplemental eight reuse the verified base build; native hosts are separately built with coverage, the Debug isolation flag and ad hoc signatures. Each preparation exits before its consumer starts. Every native scheme execution is explicitly filtered. Fresh DerivedData lives inside each unique output directory; @@ -420,7 +420,7 @@ SHA changes, dispatch a new run. The two families run on independent manual workers, each with a 90-minute limit and its own artifact. Fail-fast is disabled. An always-run aggregate fails unless both workers succeed, including when a worker is cancelled or -skipped. Each worker requires all 51 exact journey identities plus the three +skipped. Each worker requires all 53 exact journey identities plus the three preparation/reset calls. The original runner retains its separate 29-pass contract. Every phase requires zero skips or expected failures. GitHub retains both destinations' result bundles, logs, summaries, and exported failure screenshots for 14 days. See diff --git a/scripts/pro-release-phases.json b/scripts/pro-release-phases.json index 88bba368..34ef7662 100644 --- a/scripts/pro-release-phases.json +++ b/scripts/pro-release-phases.json @@ -39,14 +39,16 @@ }, "ui-supplemental": { "scheme": "CodexBarIOSUITests", - "count": 6, + "count": 8, "tests": [ "CodexBarIOSUITests/FreeTierJourneyTests/testDowngradeAndUpgradeRestoreAccountsAndSavedAlertPreference", "CodexBarIOSUITests/FreeTierJourneyTests/testFreeHistoryRangesAreLockedAndDoNotLeaveTheSevenDayChart", "CodexBarIOSUITests/FreeTierJourneyTests/testFreeLimitAndAlertsUseTheSharedProDestination", "CodexBarIOSUITests/FreeTierJourneyTests/testPausedAccountsKeepValuesAndCanSwitchAtLargeTextSize", "CodexBarIOSUITests/GrokSignInUITests/testCursorSubscriptionPillsAreAccountScoped", - "CodexBarIOSUITests/GrokSignInUITests/testXSubscriptionGuidedSetupUsesOneXAISignIn" + "CodexBarIOSUITests/GrokSignInUITests/testXSubscriptionGuidedSetupUsesOneXAISignIn", + "CodexBarIOSUITests/SubscriptionUtilizationEvidenceUITests/testMissingGooglePoolShowsUnavailableSUS", + "CodexBarIOSUITests/SubscriptionUtilizationEvidenceUITests/testWeeklyBandsAndFleetScore" ], "kind": "journey" }, diff --git a/scripts/release_evidence.py b/scripts/release_evidence.py index 335f1fa4..a9f45a98 100644 --- a/scripts/release_evidence.py +++ b/scripts/release_evidence.py @@ -108,11 +108,11 @@ def load_manifest(path=MANIFEST): if not all(TEST_ID.fullmatch(test) for test in tests): raise ValueError(f'Only exact target/class/method selectors are allowed: {name}') journeys = [test for name in manifest['ui_journeys'] for test in manifest['phases'][name]['tests']] - if len(journeys) != 51 or len(set(journeys)) != 51: - raise ValueError('The UI partition must contain exactly 51 distinct user journeys') + if len(journeys) != 53 or len(set(journeys)) != 53: + raise ValueError('The UI partition must contain exactly 53 distinct user journeys') if set(journeys) & set(manifest['excluded_marketing']): raise ValueError('Marketing capture cannot be part of release journeys') - if [manifest['phases'][name]['count'] for name in manifest['ui_journeys']] != [29, 6, 14, 1, 1]: + if [manifest['phases'][name]['count'] for name in manifest['ui_journeys']] != [29, 8, 14, 1, 1]: raise ValueError('UI journey partition changed; reconcile the release contract') if [manifest['phases'][name]['count'] for name in manifest['native_phases']] != [1, 25, 1, 1]: raise ValueError('Native phase partition changed; reconcile the release contract') diff --git a/scripts/tests/test_pro_release_validation.py b/scripts/tests/test_pro_release_validation.py index 7a64f70a..442e7c44 100644 --- a/scripts/tests/test_pro_release_validation.py +++ b/scripts/tests/test_pro_release_validation.py @@ -122,7 +122,7 @@ def test_explicit_partitions_match_current_source(self): for name in manifest['ui_journeys']: declared.update(manifest['phases'][name]['tests']) self.assertEqual(discovered, declared) - self.assertEqual(len(discovered), 52) + self.assertEqual(len(discovered), 54) self.assertEqual(len(manifest['shared_pro_tests']), 197) self.assertEqual(len(manifest['shared_pro_sources']), 22)