diff --git a/.github/e2e-policy.json b/.github/e2e-policy.json new file mode 100644 index 000000000..91ba7e956 --- /dev/null +++ b/.github/e2e-policy.json @@ -0,0 +1,1081 @@ +{ + "schemaVersion": 1, + "base": ["send_1", "send_2", "lightning_1", "transfer_2", "boost_2"], + "groups": { + "send": [ + "send_3", + "onchain_1", + "onchain_2", + "onchain_3", + "numberpad_1", + "numberpad_2", + "numberpad_3", + "numberpad_4", + "boost_1", + "lnurl_1" + ], + "fees": ["boost_1", "onchain_3", "settings_03"], + "addresses": [ + "multi_address_1", + "multi_address_3", + "multi_address_4", + "onchain_1", + "onchain_2", + "onchain_3", + "boost_1" + ], + "receive": [ + "receive_1", + "onchain_1", + "onchain_2", + "onchain_3", + "numberpad_1", + "numberpad_2", + "numberpad_3", + "numberpad_4", + "lnurl_1" + ], + "lightning": [ + "lnurl_1", + "send_3", + "multi_address_3", + "multi_address_4", + "settings_09" + ], + "hardware": ["hardware_wallet_1", "hardware_wallet_2", "hardware_wallet_4"], + "recovery": [ + "backup_1", + "onboarding_1", + "onboarding_2", + "security_1", + "settings_07" + ], + "security": ["security_1", "settings_06", "settings_07"], + "widgets": ["widgets_1", "widgets_2", "widgets_3", "settings_12"], + "currency": [ + "numberpad_1", + "numberpad_2", + "numberpad_3", + "numberpad_4", + "settings_01", + "settings_02" + ], + "pubky-paykit": [ + "pubky_profile_1", + "pubky_profile_2", + "pubky_profile_3", + "pubky_profile_4", + "pubky_profile_5", + "paykit_1" + ], + "settings": [ + "settings_01", + "settings_02", + "settings_03", + "settings_04", + "settings_05", + "settings_06", + "settings_07", + "settings_09", + "settings_10", + "settings_11", + "settings_12", + "settings_13", + "settings_14" + ], + "electrum": ["settings_10"], + "gossip": ["settings_11"], + "tags": ["settings_04", "receive_1", "onchain_1"], + "status": ["settings_14"], + "support": ["settings_05"], + "devtools": ["settings_13"], + "lnurl": ["lnurl_1"], + "activity": ["onchain_1", "onchain_2", "onchain_3"] + }, + "rules": [ + { + "name": "app-lifecycle-and-wallet-core", + "groups": [], + "files": [ + "Bitkit/AppScene.swift", + "Bitkit/BitkitApp.swift", + "Bitkit/ContentView.swift", + "Bitkit/MainNavView.swift", + "Bitkit/SceneDelegate.swift", + "Bitkit/Services/CoreService.swift", + "Bitkit/Services/LightningService.swift", + "Bitkit/Services/ServiceQueue.swift", + "Bitkit/Managers/SessionManager.swift", + "Bitkit/Managers/BalanceManager.swift", + "Bitkit/ViewModels/AppViewModel.swift", + "Bitkit/ViewModels/WalletViewModel.swift", + "Bitkit/ViewModels/NavigationViewModel.swift", + "Bitkit/ViewModels/SheetViewModel.swift", + "Bitkit/Utilities/StartupHandler.swift", + "Bitkit/Utilities/AppReset.swift", + "Bitkit/Utilities/MainNavRouting.swift", + "Bitkit/Utilities/StateLocker.swift", + "Bitkit/Utilities/BlockingQueue.swift", + "Bitkit/Utilities/Crypto.swift", + "Bitkit/Models/BalanceState.swift", + "Bitkit/Models/NodeLifecycleState.swift", + "Bitkit/Models/WalletType.swift", + "Bitkit/Constants/Env.swift" + ], + "prefixes": [], + "full": true, + "reason": "Shared wallet/node lifecycle, account state and persistence used across all lanes." + }, + { + "name": "build-and-e2e-infrastructure", + "groups": [], + "files": [ + "Bitkit/Info.plist", + "Bitkit/Bitkit.entitlements", + ".github/workflows/e2e-tests.yml", + ".github/workflows/e2e-staging.yml", + ".github/workflows/e2e_migration.yml", + ".github/scripts/namespace-stack.sh", + ".github/actions/swift-packages/action.yml", + "scripts/shallow-source-packages.sh" + ], + "prefixes": ["Bitkit.xcodeproj/"], + "full": true, + "reason": "App dependency/build identity or orchestration shared by all lanes." + }, + { + "name": "hardware-directories", + "groups": ["hardware"], + "files": [], + "prefixes": [ + "Bitkit/Services/Trezor/", + "Bitkit/Services/Jade/", + "Bitkit/ViewModels/Trezor/", + "Bitkit/Components/Trezor/", + "Bitkit/Views/Trezor/", + "Bitkit/Views/Transfer/Hardware/" + ], + "reason": "Feature-owned source directory." + }, + { + "name": "widgets-directories", + "groups": ["widgets"], + "files": [], + "prefixes": [ + "Bitkit/Services/Widgets/", + "Bitkit/ViewModels/Widgets/", + "Bitkit/Components/Widgets/", + "Bitkit/Views/Widgets/", + "BitkitWidget/" + ], + "reason": "Feature-owned source directory." + }, + { + "name": "send-directories", + "groups": ["send"], + "files": [], + "prefixes": [ + "Bitkit/Views/Wallets/Send/", + "Bitkit/Views/Settings/Quickpay/" + ], + "reason": "Feature-owned source directory." + }, + { + "name": "receive-directories", + "groups": ["receive"], + "files": [], + "prefixes": ["Bitkit/Views/Wallets/Receive/"], + "reason": "Feature-owned source directory." + }, + { + "name": "lightning-directories", + "groups": ["lightning"], + "files": [], + "prefixes": ["Bitkit/Views/Transfer/", "Bitkit/Views/Offline/"], + "reason": "Feature-owned source directory." + }, + { + "name": "lnurl-directories", + "groups": ["lnurl"], + "files": [], + "prefixes": ["Bitkit/Views/Wallets/LnurlWithdraw/"], + "reason": "Feature-owned source directory." + }, + { + "name": "recovery-directories", + "groups": ["recovery"], + "files": [], + "prefixes": [ + "Bitkit/Views/Backup/", + "Bitkit/Views/Onboarding/", + "Bitkit/Views/Recovery/" + ], + "reason": "Feature-owned source directory." + }, + { + "name": "pubky-paykit-directories", + "groups": ["pubky-paykit"], + "files": [], + "prefixes": [ + "Bitkit/Views/Profile/", + "Bitkit/Views/Contacts/", + "Bitkit/Views/Shop/", + "Bitkit/Views/Subscriptions/", + "Bitkit/Views/PaymentRequests/" + ], + "reason": "Feature-owned source directory." + }, + { + "name": "security-directories", + "groups": ["security"], + "files": [], + "prefixes": ["Bitkit/Views/Settings/Security/", "Bitkit/Views/Security/"], + "reason": "Feature-owned source directory." + }, + { + "name": "fees-directories", + "groups": ["fees"], + "files": [], + "prefixes": ["Bitkit/Views/Settings/TransactionSpeed/"], + "reason": "Feature-owned source directory." + }, + { + "name": "support-directories", + "groups": ["support"], + "files": [], + "prefixes": ["Bitkit/Views/Settings/Support/"], + "reason": "Feature-owned source directory." + }, + { + "name": "devtools-directories", + "groups": ["devtools"], + "files": [], + "prefixes": ["Bitkit/Views/Settings/ProbingTool/"], + "reason": "Feature-owned source directory." + }, + { + "name": "tags-directories", + "groups": ["tags"], + "files": [], + "prefixes": ["Bitkit/Components/Tags/"], + "reason": "Feature-owned source directory." + }, + { + "name": "activity-directories", + "groups": ["activity"], + "files": [], + "prefixes": [ + "Bitkit/Components/Activity/", + "Bitkit/Views/Wallets/Activity/" + ], + "reason": "Feature-owned source directory." + }, + { + "name": "files-activity", + "groups": ["activity"], + "files": [ + "Bitkit/Models/ActivityDisplayConstants.swift", + "Bitkit/ViewModels/ActivityItemViewModel.swift", + "Bitkit/ViewModels/ActivityListViewModel.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-addresses", + "groups": ["addresses"], + "files": [ + "Bitkit/Extensions/LDKNode+AddressType.swift", + "Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift", + "Bitkit/Views/Settings/Advanced/AddressViewer.swift", + "Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift", + "Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-currency", + "groups": ["currency"], + "files": [ + "Bitkit/Components/AmountSlider.swift", + "Bitkit/Components/AvailableAmount.swift", + "Bitkit/Components/MoneyCell.swift", + "Bitkit/Components/MoneyStack.swift", + "Bitkit/Components/MoneyText.swift", + "Bitkit/Components/NumberPad.swift", + "Bitkit/Components/NumberPadActionButton.swift", + "Bitkit/Components/NumberPadTextField.swift", + "Bitkit/Extensions/FixedWidthInteger+Saturating.swift", + "Bitkit/Models/Currency.swift", + "Bitkit/Services/CurrencyService.swift", + "Bitkit/Utilities/CurrencyFormatter.swift", + "Bitkit/ViewModels/CurrencyViewModel.swift", + "Bitkit/Views/Settings/General/DefaultUnitSettingsView.swift", + "Bitkit/Views/Settings/General/LocalCurrencySettingsView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-devtools", + "groups": ["devtools"], + "files": [ + "Bitkit/Views/Settings/DevSettingsView.swift", + "Bitkit/Views/Settings/LdkDebugScreen.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-electrum", + "groups": ["electrum"], + "files": [ + "Bitkit/Models/ElectrumServer.swift", + "Bitkit/Services/ElectrumConfigService.swift", + "Bitkit/ViewModels/Extensions/SettingsViewModel+Electrum.swift", + "Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-fees", + "groups": ["fees"], + "files": [ + "Bitkit/Components/FeeDisplayRow.swift", + "Bitkit/Components/FeeItem.swift", + "Bitkit/Extensions/BroadcastError+Connectivity.swift", + "Bitkit/Managers/FeeEstimatesManager.swift", + "Bitkit/Models/TransactionSpeed.swift", + "Bitkit/Views/Wallets/Send/SendFeeCustom.swift", + "Bitkit/Views/Wallets/Send/SendFeeRate.swift", + "Bitkit/Views/Wallets/Sheets/BoostSheet.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-gossip", + "groups": ["gossip"], + "files": [ + "Bitkit/Services/RgsConfigService.swift", + "Bitkit/ViewModels/Extensions/SettingsViewModel+Rgs.swift", + "Bitkit/Views/Settings/Advanced/RgsSettingsScreen.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-hardware", + "groups": ["hardware"], + "files": [ + "Bitkit/Components/HardwareWalletsGrid.swift", + "Bitkit/Components/RemoveHwWalletDialog.swift", + "Bitkit/Extensions/HwError+Cancellation.swift", + "Bitkit/Extensions/HwWalletVendor+UI.swift", + "Bitkit/Extensions/JadeError+Cancellation.swift", + "Bitkit/Extensions/LDKNode+Jade.swift", + "Bitkit/Extensions/TrezorDevice+DisplayName.swift", + "Bitkit/Extensions/TrezorError+Cancellation.swift", + "Bitkit/Extensions/TrezorPublicKeyResponse+StoredKey.swift", + "Bitkit/Managers/HwDeviceSessioning.swift", + "Bitkit/Managers/HwWalletManager.swift", + "Bitkit/Managers/JadeManager.swift", + "Bitkit/Managers/TrezorManager.swift", + "Bitkit/Models/HwFunding.swift", + "Bitkit/Models/HwWallet.swift", + "Bitkit/Models/HwWalletId.swift", + "Bitkit/Models/HwWalletVendor.swift", + "Bitkit/Models/JadeDevice.swift", + "Bitkit/Services/HwKnownDeviceMatching.swift", + "Bitkit/Services/HwKnownDeviceStorage.swift", + "Bitkit/Services/HwSnapshotMerge.swift", + "Bitkit/Services/OnChainHwService.swift", + "Bitkit/Services/WatchOnlyAccountService.swift", + "Bitkit/Utilities/HwDevicePath.swift", + "Bitkit/Utilities/HwEngagedSession.swift", + "Bitkit/Utilities/HwErrorPresenter.swift", + "Bitkit/Utilities/TrezorErrorPresenter.swift", + "Bitkit/ViewModels/HwFundingSigner.swift", + "Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift", + "Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift", + "Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift", + "Bitkit/Views/Sheets/HardwareConnect/HwPairCodeView.swift", + "Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift", + "Bitkit/Views/Sheets/HardwareConnect/HwSearchingAnimation.swift", + "Bitkit/Views/Sheets/HardwareConnect/HwSearchingView.swift", + "Bitkit/Views/Sheets/HardwareConnectSheet.swift", + "Bitkit/Views/Sheets/HardwarePairingSheet.swift", + "Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift", + "Bitkit/Views/Wallets/HardwareWalletScreen.swift", + "Bitkit/Views/Wallets/Send/HwSendSignView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-hardware-recovery", + "groups": ["hardware", "recovery"], + "files": [ + "Bitkit/Services/HwActivityTagBackup.swift", + "Bitkit/Views/Sheets/HardwareConnect/HwPassphraseView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-lightning", + "groups": ["lightning"], + "files": [ + "Bitkit/Components/IncomingTransfer.swift", + "Bitkit/Components/LightningChannel.swift", + "Bitkit/Components/Trezor/HwWalletConnectionIcon.swift", + "Bitkit/Extensions/ChannelDetails+Extensions.swift", + "Bitkit/Extensions/IBtChannel+Mock.swift", + "Bitkit/Extensions/LightningBalance+Extensions.swift", + "Bitkit/Extensions/NodeStatus.swift", + "Bitkit/Extensions/ShortChannelId.swift", + "Bitkit/Managers/NetworkMonitor.swift", + "Bitkit/Managers/TransferTrackingManager.swift", + "Bitkit/Models/LnPeer.swift", + "Bitkit/Models/Transfer.swift", + "Bitkit/Models/TransferType.swift", + "Bitkit/Services/BoltzService.swift", + "Bitkit/Services/SamRockService.swift", + "Bitkit/Services/TransferService.swift", + "Bitkit/Services/TransferStorage.swift", + "Bitkit/Utilities/LightningAmountConversion.swift", + "Bitkit/Utilities/NetworkValidationHelper.swift", + "Bitkit/Utilities/NodeRestarter.swift", + "Bitkit/ViewModels/BlocktankViewModel.swift", + "Bitkit/ViewModels/ChannelDetailsViewModel.swift", + "Bitkit/ViewModels/Extensions/ChannelDetails.swift", + "Bitkit/ViewModels/TransferViewModel.swift", + "Bitkit/Views/Settings/Advanced/ChannelDisplayable.swift", + "Bitkit/Views/Settings/Advanced/CloseConnectionConfirmation.swift", + "Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift", + "Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift", + "Bitkit/Views/Settings/BlocktankRegtestView.swift", + "Bitkit/Views/Settings/ChannelOrders.swift", + "Bitkit/Views/Settings/SwapsListView.swift", + "Bitkit/Views/Sheets/BTCPayConnectionSheet.swift", + "Bitkit/Views/Sheets/ForceTransferSheet.swift", + "Bitkit/Views/Sheets/TransferUnavailableSheet.swift", + "Bitkit/Views/Wallets/Receive/ReceiveTransferUnavailable.swift", + "Bitkit/Views/Wallets/Sheets/ConnectionClosedSheet.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-lnurl", + "groups": ["lnurl"], + "files": [ + "Bitkit/Extensions/LnurlPayData+Amount.swift", + "Bitkit/Utilities/Lnurl.swift", + "Bitkit/Views/Sheets/LnurlAuth/LnurlAuthSheet.swift", + "Bitkit/Views/Transfer/LnurlChannel.swift", + "Bitkit/Views/Wallets/Send/LnurlPayAmount.swift", + "Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-pubky-paykit", + "groups": ["pubky-paykit"], + "files": [ + "Bitkit/Components/CenteredProfileHeader.swift", + "Bitkit/Components/ContactAvatarLetter.swift", + "Bitkit/Components/PaykitAcceptedMethods.swift", + "Bitkit/Components/PaykitAmountText.swift", + "Bitkit/Components/PaymentRequestInvoiceNote.swift", + "Bitkit/Components/PaymentRequestSummary.swift", + "Bitkit/Components/ProfileEditFormView.swift", + "Bitkit/Components/ProfileNameField.swift", + "Bitkit/Components/PubkyChoiceRow.swift", + "Bitkit/Components/PubkyContactAvatar.swift", + "Bitkit/Components/PubkyContactRow.swift", + "Bitkit/Components/PubkyImage.swift", + "Bitkit/Components/ShopWebView.swift", + "Bitkit/Extensions/Activity+Contact.swift", + "Bitkit/FeatureFlags/PaykitFeatureFlags.swift", + "Bitkit/Managers/ContactsManager.swift", + "Bitkit/Managers/PubkyProfileManager.swift", + "Bitkit/Managers/ShopContactImporter.swift", + "Bitkit/Models/PaykitAmount.swift", + "Bitkit/Models/PaykitRequestPricing.swift", + "Bitkit/Models/PaykitUsdt.swift", + "Bitkit/Models/PubkyAuthRequest.swift", + "Bitkit/Models/PubkyContactLink.swift", + "Bitkit/Models/PubkyProfile.swift", + "Bitkit/Models/PubkyPublicKeyFormat.swift", + "Bitkit/Services/ContactPaymentsService.swift", + "Bitkit/Services/PaykitAllowance.swift", + "Bitkit/Services/PaykitAllowanceExecutor.swift", + "Bitkit/Services/PaykitAllowanceManager.swift", + "Bitkit/Services/PaykitBitcoinRequestPricing.swift", + "Bitkit/Services/PaykitIssuerInterop.swift", + "Bitkit/Services/PaykitPaymentProofService.swift", + "Bitkit/Services/PaykitPaymentRequestService.swift", + "Bitkit/Services/PaykitReceivedPaymentContacts.swift", + "Bitkit/Services/PaykitSubscription.swift", + "Bitkit/Services/PaykitSubscriptionProposal.swift", + "Bitkit/Services/PaykitUsdtPaymentService.swift", + "Bitkit/Services/PrivatePaykitAddressReservationStore.swift", + "Bitkit/Services/PrivatePaykitService+Contacts.swift", + "Bitkit/Services/PrivatePaykitService+Endpoints.swift", + "Bitkit/Services/PrivatePaykitService+Errors.swift", + "Bitkit/Services/PrivatePaykitService+Invoices.swift", + "Bitkit/Services/PrivatePaykitService+Models.swift", + "Bitkit/Services/PrivatePaykitService+Payments.swift", + "Bitkit/Services/PrivatePaykitService+State.swift", + "Bitkit/Services/PrivatePaykitService.swift", + "Bitkit/Services/PubkyService.swift", + "Bitkit/Services/PublicPaykitService.swift", + "Bitkit/Utilities/PaykitAmountEntryConversion.swift", + "Bitkit/Utilities/PaykitPaymentActivity.swift", + "Bitkit/Utilities/ShopOrigin.swift", + "Bitkit/Utilities/ShopPaymentRequest.swift", + "Bitkit/Utilities/SubscriptionClock.swift", + "Bitkit/Utilities/Testing/ContactImportUITestFixture.swift", + "Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift", + "Bitkit/Views/Wallets/Activity/AssignActivityContactView.swift", + "Bitkit/Views/Wallets/Send/InitialSubscriptionPaymentProgress.swift", + "Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift", + "Bitkit/Views/Wallets/Send/SendContactSelectView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-pubky-paykit-recovery", + "groups": ["pubky-paykit", "recovery"], + "files": [ + "Bitkit/Models/PaykitAllowanceStateBackup.swift", + "Bitkit/Models/PaykitPaymentStateBackup.swift", + "Bitkit/Models/PaykitUsdtStateBackup.swift", + "Bitkit/Services/PrivatePaykitService+Backup.swift", + "Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-pubky-paykit-security", + "groups": ["pubky-paykit", "security"], + "files": ["Bitkit/Utilities/SharedPubkyKeychain.swift"], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-receive", + "groups": ["receive"], + "files": [ + "Bitkit/Components/CopyAddressCard.swift", + "Bitkit/Components/Scanner.swift", + "Bitkit/Managers/CameraManager.swift", + "Bitkit/Managers/ScannerManager.swift", + "Bitkit/Models/ReceiveLiquidityDecision.swift", + "Bitkit/Utilities/QRCodePayload.swift", + "Bitkit/Views/Onboarding/SeedQRCodeScannerView.swift", + "Bitkit/Views/Scanner/ScannerScreen.swift", + "Bitkit/Views/Scanner/ScannerSheet.swift", + "Bitkit/Views/Settings/ProbingTool/ProbingToolScannerSheet.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-recovery", + "groups": ["recovery"], + "files": [ + "Bitkit/Components/OnboardingContent.swift", + "Bitkit/Components/SeedInputAccessory.swift", + "Bitkit/Components/SeedTextField.swift", + "Bitkit/Managers/TimedSheets/BackupTimedSheet.swift", + "Bitkit/Models/BackupCategory.swift", + "Bitkit/Models/BackupItemStatus.swift", + "Bitkit/Models/BackupPayloads.swift", + "Bitkit/Models/SettingsBackupConfig.swift", + "Bitkit/Services/AndroidBackupJson.swift", + "Bitkit/Services/BackupFieldMigration.swift", + "Bitkit/Services/BackupService.swift", + "Bitkit/Services/MigrationsService.swift", + "Bitkit/Services/RNBackupClient.swift", + "Bitkit/Services/UsdtRecoveryBackupStore.swift", + "Bitkit/Services/VssBackupClient.swift", + "Bitkit/Services/VssStoreIdProvider.swift", + "Bitkit/Utilities/AppDataMigrations.swift", + "Bitkit/Utilities/InstallationMarker.swift", + "Bitkit/Utilities/SeedQRCodeDecoder.swift", + "Bitkit/ViewModels/BackupViewModel.swift", + "Bitkit/Views/OnboardingView.swift", + "Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift", + "Bitkit/Views/Settings/DevSettings/VssDebugScreen.swift", + "Bitkit/Views/Settings/Security/DataBackupsScreen.swift", + "Bitkit/Views/Transfer/Hardware/HwPassphrasePromptSheet.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-security", + "groups": ["security"], + "files": [ + "Bitkit/Components/PinInput.swift", + "Bitkit/Components/ScreenshotPreventMask.swift", + "Bitkit/Utilities/BiometricAuth.swift", + "Bitkit/Utilities/Keychain.swift", + "Bitkit/ViewModels/Extensions/SettingsViewModel+PIN.swift", + "Bitkit/Views/Settings/SecuritySettingsView.swift", + "Bitkit/Views/Sheets/ForgotPinSheet.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-send", + "groups": ["send"], + "files": [ + "Bitkit/Components/PaymentAddressInput.swift", + "Bitkit/Components/PaymentCelebration.swift", + "Bitkit/Components/PaymentReviewIllustration.swift", + "Bitkit/Components/SendSectionView.swift", + "Bitkit/Extensions/PaymentDetails.swift", + "Bitkit/Extensions/PaymentFailureReason+UserMessage.swift", + "Bitkit/Managers/TimedSheets/QuickpayTimedSheet.swift", + "Bitkit/Services/OnchainSendAttemptService.swift", + "Bitkit/Utilities/Bip21Utils.swift", + "Bitkit/Utilities/PaymentNavigationHelper.swift", + "Bitkit/Utilities/QuickPayLimits.swift", + "Bitkit/Utilities/QuickPayPaymentCoordinator.swift", + "Bitkit/Utilities/QuickPaySpendStore.swift", + "Bitkit/ViewModels/AmountInputViewModel.swift", + "Bitkit/Views/Sheets/QuickpaySheet.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-settings", + "groups": ["settings"], + "files": [ + "Bitkit/Components/SettingsRow.swift", + "Bitkit/ViewModels/SettingsViewModel.swift", + "Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift", + "Bitkit/Views/Settings/GeneralSettingsView.swift", + "Bitkit/Views/Settings/MainSettingsScreen.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-status", + "groups": ["status"], + "files": [ + "Bitkit/Components/AppStatus.swift", + "Bitkit/Utilities/AppStatusHelper.swift", + "Bitkit/Views/Settings/AppStatusView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-support", + "groups": ["support"], + "files": [ + "Bitkit/Services/LogService.swift", + "Bitkit/Services/ZipService.swift", + "Bitkit/Utilities/Logger.swift", + "Bitkit/Views/Settings/LogView.swift", + "Bitkit/Views/Settings/SupportScreen.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-tags", + "groups": ["tags"], + "files": [ + "Bitkit/Components/NoteTextEditor.swift", + "Bitkit/Managers/TagManager.swift", + "Bitkit/Views/Settings/General/TagSettingsView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-widgets", + "groups": ["widgets"], + "files": [ + "Bitkit/Constants/WidgetEnv.swift", + "Bitkit/Managers/CalculatorInputManager.swift", + "Bitkit/Managers/SuggestionsManager.swift", + "Bitkit/Models/BitcoinFacts.swift", + "Bitkit/Models/BlocksWidgetData.swift", + "Bitkit/Models/BlocksWidgetFields.swift", + "Bitkit/Models/BlocksWidgetOptions.swift", + "Bitkit/Models/CalculatorWidgetData.swift", + "Bitkit/Models/NewsWidgetData.swift", + "Bitkit/Models/NewsWidgetOptions.swift", + "Bitkit/Models/PriceWidgetData.swift", + "Bitkit/Models/PriceWidgetOptions.swift", + "Bitkit/Models/WeatherWidgetData.swift", + "Bitkit/Models/WeatherWidgetOptions.swift", + "Bitkit/ViewModels/WidgetsViewModel.swift", + "Bitkit/Views/Home/HomeWidgetsView.swift", + "Bitkit/Views/Settings/General/WidgetsSettingsScreen.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-widgets-recovery", + "groups": ["widgets", "recovery"], + "files": [ + "Bitkit/Components/WidgetsOnboardingView.swift", + "Bitkit/Utilities/WidgetsBackupConverter.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "usdt", + "groups": [], + "files": [ + "Bitkit/Managers/TimedSheets/UsdtIntroTimedSheet.swift", + "Bitkit/Managers/UsdtWalletManager.swift", + "Bitkit/Models/PaykitUsdt.swift", + "Bitkit/Models/PaykitUsdtStateBackup.swift", + "Bitkit/Models/UsdtDisplayBalanceCache.swift", + "Bitkit/Models/UsdtPreference.swift", + "Bitkit/Models/UsdtPreparedQuote.swift", + "Bitkit/Services/PaykitUsdtPaymentService.swift", + "Bitkit/Services/UsdtRecoveryBackupStore.swift", + "Bitkit/Views/Sheets/UsdtIntroSheet.swift", + "Bitkit/Views/Wallets/UsdtActivityDetail.swift", + "Bitkit/Views/Wallets/UsdtDepositHistoryView.swift", + "Bitkit/Views/Wallets/UsdtNetworkPicker.swift", + "Bitkit/Views/Wallets/UsdtReceiveAmountView.swift", + "Bitkit/Views/Wallets/UsdtReceiveView.swift", + "Bitkit/Views/Wallets/UsdtSendView.swift", + "Bitkit/Views/Wallets/UsdtSupportActions.swift", + "Bitkit/Views/Wallets/UsdtWalletScreen.swift" + ], + "prefixes": [], + "reason": "Keep Bitcoin regression gate; no existing automated iOS USDT case.", + "coverageNote": "USDT changes: existing Bitcoin/Paykit cases do not prove USDT behavior; relevant native/integration coverage is required." + }, + { + "name": "presentation-and-resources", + "groups": [], + "files": [], + "prefixes": [ + "Bitkit/Styles/", + "Bitkit/Assets.xcassets/", + "Bitkit/Preview Content/", + "Bitkit/Resources/", + "Bitkit/Fonts/", + "Bitkit/Components/Button/", + "Bitkit/Components/TabBar/" + ], + "reason": "Presentation changes run the base wallet/payment gate; dedicated native checks retain their coverage." + }, + { + "name": "non-app-checks", + "groups": [], + "files": [ + ".gitignore", + ".swiftformat", + "LICENSE", + "Justfile", + "manually-test-payments.sh", + "run.sh", + "ExportOptions.plist", + "scripts/pre-commit", + "scripts/collect-changelog.sh", + "scripts/preview-changelog.sh", + "scripts/setup-hooks.sh", + "scripts/test-plural-localizations.swift", + "scripts/validate-translations.js", + "scripts/build-usdt-local.sh" + ], + "prefixes": [ + "Docs/", + "journeys/", + "changelog.d/", + "BitkitTests/", + "BitkitUITests/", + ".agents/", + ".claude/", + ".cursor/", + "test-push-server/" + ], + "skip": true, + "reason": "Documentation, native tests or developer tooling: existing native/lint checks apply; no app source change." + }, + { + "name": "selection-policy", + "groups": [], + "files": [ + ".github/e2e-policy.json", + ".github/scripts/e2e-selection.cjs", + ".github/scripts/e2e-selection.test.cjs", + ".github/workflows/e2e-selection.yml" + ], + "prefixes": [], + "skip": true, + "reason": "Cheap policy/selection tests validate rule changes; device validation is dispatched explicitly." + }, + { + "name": "other-ci", + "groups": [], + "files": [ + ".github/ISSUE_TEMPLATE/bug_report.yml", + ".github/ISSUE_TEMPLATE/feature_request.yml", + ".github/copilot-instructions.md", + ".github/dependabot.yml", + ".github/pull_request_template.md", + ".github/scripts/retarget-execution.cjs", + ".github/scripts/retarget-execution.test.cjs", + ".github/scripts/retarget-tests.cjs", + ".github/scripts/retarget-tests.test.cjs", + ".github/scripts/test-fixtures/retarget-execution.cjs", + ".github/workflows/ai-device-tests.yml", + ".github/workflows/claude-code-review.yml", + ".github/workflows/claude.yml", + ".github/workflows/e2e-retarget.yml", + ".github/workflows/integration-tests.yml", + ".github/workflows/lint.yml", + ".github/workflows/retarget-execution-cleanup.yml", + ".github/workflows/unit-tests.yml", + ".github/workflows/validate-translations.yml" + ], + "prefixes": [], + "skip": true, + "reason": "Other CI and repository metadata do not change the app; their own checks apply." + }, + { + "name": "notification-extension", + "groups": [], + "files": [ + "BitkitNotification/BitkitNotification.entitlements", + "BitkitNotification/Info.plist", + "BitkitNotification/NotificationService.swift" + ], + "prefixes": [], + "reason": "Base payment regression; notification-specific behavior remains native/device coverage." + }, + { + "name": "localization-build", + "groups": [], + "files": ["scripts/generate-plural-localizations.swift"], + "prefixes": [], + "reason": "Base gate and existing translation/build checks." + }, + { + "name": "widget-entitlements", + "groups": ["widgets"], + "files": ["BitkitWidgetExtension.entitlements"], + "prefixes": [] + }, + { + "name": "shared-retry-and-lifecycle", + "groups": [], + "files": [ + "Bitkit/Utilities/RetryHelper.swift", + "Bitkit/Utilities/BackgroundTaskScheduling.swift", + "Bitkit/Utilities/Errors.swift" + ], + "prefixes": [], + "full": true, + "reason": "Shared error/retry/background lifecycle can affect wallet startup, payments and recovery." + }, + { + "name": "payment-encoding", + "groups": ["send", "pubky-paykit", "hardware"], + "files": [ + "Bitkit/Extensions/HexBytes.swift", + "Bitkit/Utilities/URLValidationPattern.swift" + ], + "prefixes": [], + "reason": "Payment/key encoding and external request validation shared across payment routes." + }, + { + "name": "lnurl-string-parsing", + "groups": ["lnurl"], + "files": ["Bitkit/Extensions/String+Utilities.swift"], + "prefixes": [], + "reason": "removingLightningSchemes parses LNURL inputs." + }, + { + "name": "scanner-sheet", + "groups": ["receive", "pubky-paykit", "electrum", "hardware"], + "files": ["Bitkit/Components/QR.swift"], + "prefixes": ["Bitkit/Views/Scanner/"], + "reason": "ScannerSheet/ScannerScreen and manual prompt share main/send/addContact/electrum routes; new scanner entry views retain every existing domain." + }, + { + "name": "tag-sheet", + "groups": ["tags"], + "files": ["Bitkit/Views/Wallets/Sheets/AddTagSheet.swift"], + "prefixes": [] + }, + { + "name": "shared-presentation", + "groups": [], + "files": [ + "Bitkit/Components/ActivityIndicator.swift", + "Bitkit/Components/BlurView.swift", + "Bitkit/Components/BottomActionBar.swift", + "Bitkit/Components/CheckboxRow.swift", + "Bitkit/Components/CircularIcon.swift", + "Bitkit/Components/ClipboardCopyPopup.swift", + "Bitkit/Components/CustomSlider.swift", + "Bitkit/Components/Divider.swift", + "Bitkit/Components/DrawerView.swift", + "Bitkit/Components/EllipseLoader.swift", + "Bitkit/Components/EmptyStateView.swift", + "Bitkit/Components/Header.swift", + "Bitkit/Components/IconButton.swift", + "Bitkit/Components/InsetHeaderScrollView.swift", + "Bitkit/Components/LabeledDetailCell.swift", + "Bitkit/Components/NavigationBar.swift", + "Bitkit/Components/NodeStateView.swift", + "Bitkit/Components/NotificationPreview.swift", + "Bitkit/Components/RadioGroup.swift", + "Bitkit/Components/RectangleButton.swift", + "Bitkit/Components/SegmentedControl.swift", + "Bitkit/Components/ShareSheet.swift", + "Bitkit/Components/SheetIntro.swift", + "Bitkit/Components/SnapCarousel.swift", + "Bitkit/Components/Social.swift", + "Bitkit/Components/StatusRow.swift", + "Bitkit/Components/SuggestionCard.swift", + "Bitkit/Components/SwipeButton.swift", + "Bitkit/Components/TabViewDots.swift", + "Bitkit/Components/TextField.swift", + "Bitkit/Components/ToastView.swift", + "Bitkit/Components/Tooltip.swift", + "Bitkit/Components/WalletBalanceView.swift", + "Bitkit/Components/WrappingHStack.swift", + "Bitkit/Extensions/IBtOrder+Mock.swift", + "Bitkit/Extensions/TextEditor+DismissOnReturn.swift", + "Bitkit/Extensions/UINavigationController+SwipeBack.swift", + "Bitkit/Extensions/UIScreen.swift", + "Bitkit/Extensions/View+Accessibility.swift", + "Bitkit/Extensions/View+SafeArea.swift", + "Bitkit/Extensions/View+SwipeGestures.swift", + "Bitkit/LaunchScreen.storyboard", + "Bitkit/Managers/KeyboardManager.swift", + "Bitkit/Managers/LanguageManager.swift", + "Bitkit/Managers/PushNotificationManager.swift", + "Bitkit/Managers/TimedSheets/AppUpdateTimedSheet.swift", + "Bitkit/Managers/TimedSheets/HighBalanceTimedSheet.swift", + "Bitkit/Managers/TimedSheets/NotificationsTimedSheet.swift", + "Bitkit/Managers/TimedSheets/TimedSheetManager.swift", + "Bitkit/Managers/ToastWindowManager.swift", + "Bitkit/Models/SupportedLanguage.swift", + "Bitkit/Models/Toast.swift", + "Bitkit/Services/AppUpdateService.swift", + "Bitkit/Utilities/BlockExplorerHelper.swift", + "Bitkit/Utilities/ClipboardPrompt.swift", + "Bitkit/Utilities/DateFormatterHelpers.swift", + "Bitkit/Utilities/Haptics.swift", + "Bitkit/Utilities/LocalizeHelpers.swift", + "Bitkit/Views/AppUpdateScreen.swift", + "Bitkit/Views/BuyBitcoinView.swift", + "Bitkit/Views/ComingSoon.swift", + "Bitkit/Views/Home/HomeWalletView.swift", + "Bitkit/Views/HomeScreen.swift", + "Bitkit/Views/Settings/General/LanguageSettingsScreen.swift", + "Bitkit/Views/Settings/Notifications/NotificationsIntro.swift", + "Bitkit/Views/Settings/Notifications/NotificationsSettings.swift", + "Bitkit/Views/Sheets/AppUpdateSheet.swift", + "Bitkit/Views/Sheets/HighBalanceSheet.swift", + "Bitkit/Views/Sheets/NotificationsSheet.swift", + "Bitkit/Views/Sheets/Sheet.swift", + "Bitkit/Views/SplashView.swift", + "Bitkit/Views/Wallets/SavingsWalletScreen.swift", + "Bitkit/Views/Wallets/Sheets/ReceivedTx.swift", + "Bitkit/Views/Wallets/SpendingWalletScreen.swift" + ], + "prefixes": [], + "reason": "Reviewed display/notification/update/UI-shell files: base gate checks app access and wallet/payment interaction; native tests cover detailed presentation. New files require classification." + }, + { + "name": "shared-keychain", + "groups": [], + "files": ["Bitkit/Utilities/Keychain.swift"], + "prefixes": [], + "reason": "Owns mnemonic/passphrase, PIN and Pubky/Paykit persistence used across wallet, recovery and profile lanes.", + "full": true + }, + { + "name": "shared-scanner-routes", + "groups": ["receive", "pubky-paykit", "electrum", "hardware"], + "files": [ + "Bitkit/Managers/ScannerManager.swift", + "Bitkit/Managers/CameraManager.swift", + "Bitkit/Utilities/QRCodePayload.swift", + "Bitkit/Components/Scanner.swift", + "Bitkit/Views/Scanner/ManualScanPrompt.swift" + ], + "prefixes": [], + "reason": "ScannerManager routes main/send/addContact/electrum input; pubky_profile_3/_5 and settings_10 assert routes not covered by Bitcoin cases; hardware sending also accepts scanned recipients." + }, + { + "name": "wallet-backup-profile-state", + "groups": ["pubky-paykit"], + "files": [ + "Bitkit/Services/BackupService.swift", + "Bitkit/Services/VssBackupClient.swift", + "Bitkit/Services/VssStoreIdProvider.swift", + "Bitkit/Services/AndroidBackupJson.swift", + "Bitkit/Services/BackupFieldMigration.swift", + "Bitkit/Models/BackupPayloads.swift", + "Bitkit/Utilities/AppDataMigrations.swift", + "Bitkit/Views/Onboarding/RestoreWalletView.swift", + "Bitkit/Views/Onboarding/InitializingWalletView.swift", + "Bitkit/Views/Recovery/RecoveryScreen.swift" + ], + "prefixes": [], + "reason": "Shared wallet backup transport/payload/restore retains Pubky session and contacts; pubky_profile_2/_4 assert this persistence." + }, + { + "name": "shared-paykit-payment-flow", + "groups": ["pubky-paykit"], + "files": [ + "Bitkit/Services/OnchainSendAttemptService.swift", + "Bitkit/Utilities/PaymentNavigationHelper.swift", + "Bitkit/Views/Wallets/Send/SendAmountView.swift", + "Bitkit/Views/Wallets/Send/SendConfirmationView.swift", + "Bitkit/Views/Wallets/Send/SendSheet.swift", + "Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift", + "Bitkit/Components/NumberPad.swift", + "Bitkit/Components/NumberPadTextField.swift", + "Bitkit/ViewModels/AmountInputViewModel.swift", + "Bitkit/Utilities/CurrencyFormatter.swift" + ], + "prefixes": [], + "reason": "paykit_1 enters and pays a public on-chain contact through the shared amount/confirmation/dispatch path." + }, + { + "name": "shared-hardware-send-flow", + "groups": ["hardware"], + "files": [ + "Bitkit/Views/Wallets/Send/SendAmountView.swift", + "Bitkit/Views/Wallets/Send/SendConfirmationView.swift", + "Bitkit/Views/Wallets/Send/SendSheet.swift", + "Bitkit/Views/Wallets/Send/SendFeeCustom.swift", + "Bitkit/Views/Wallets/Send/SendFeeRate.swift", + "Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift", + "Bitkit/Utilities/PaymentNavigationHelper.swift" + ], + "prefixes": [], + "reason": "hardware_wallet_4 uses the shared send flow and fee review before device signing." + } + ] +} diff --git a/.github/scripts/e2e-selection.cjs b/.github/scripts/e2e-selection.cjs new file mode 100644 index 000000000..204ede308 --- /dev/null +++ b/.github/scripts/e2e-selection.cjs @@ -0,0 +1,342 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const { execFileSync } = require("node:child_process"); +const POLICY_PATH = ".github/e2e-policy.json"; +const policy = require("../e2e-policy.json"); + +function validatePolicy(p, cases) { + if ( + p.schemaVersion !== 1 || + !Array.isArray(p.base) || + !p.base.length || + !p.groups || + !Array.isArray(p.rules) + ) + throw Error("Invalid iOS E2E policy"); + const available = new Map( + cases + .filter( + (c) => c.kind === "test" && c.platforms.ios.disposition === "required", + ) + .map((c) => [c.id, c]), + ); + for (const [name, ids] of Object.entries({ base: p.base, ...p.groups })) { + if (!Array.isArray(ids) || !ids.length || new Set(ids).size !== ids.length) + throw Error(`Invalid case group: ${name}`); + for (const id of ids) { + if (!available.has(id)) + throw Error(`Policy group ${name} requires unavailable case: ${id}`); + if ( + name === "base" && + available.get(id).platforms.ios.backend !== "local" + ) + throw Error(`Non-local base case: ${id}`); + } + } + const names = new Set(); + for (const r of p.rules) { + if ( + !r.name || + names.has(r.name) || + !Array.isArray(r.files) || + !Array.isArray(r.prefixes) || + !Array.isArray(r.groups) + ) + throw Error("Invalid/duplicate policy rule"); + names.add(r.name); + if (!r.files.length && !r.prefixes.length) + throw Error(`Empty path rule: ${r.name}`); + for (const f of [...r.files, ...r.prefixes]) { + if ( + typeof f !== "string" || + !f || + f.startsWith("/") || + f.includes("..") || + f.includes("*") || + f.includes("\0") + ) + throw Error(`Invalid policy path: ${f}`); + } + if (r.prefixes.some((f) => !f.endsWith("/"))) + throw Error(`Prefix must end with /: ${r.name}`); + if ( + (r.full !== undefined && typeof r.full !== "boolean") || + (r.skip !== undefined && typeof r.skip !== "boolean") + ) + throw Error(`Invalid policy action: ${r.name}`); + if (r.skip && (r.full || r.groups.length)) + throw Error(`Skip rule selects tests: ${r.name}`); + for (const g of r.groups) + if (!p.groups[g]) throw Error(`Unknown case group: ${g}`); + } +} + +function matchingRules(p, file) { + if (file.endsWith(".md")) + return [{ name: "documentation", skip: true, groups: [] }]; + return p.rules.filter( + (r) => + r.files.includes(file) || + r.prefixes.some((prefix) => file.startsWith(prefix)), + ); +} + +function validateTree(p, files, cases) { + validatePolicy(p, cases); + const unknown = files.filter((f) => !matchingRules(p, f).length); + const stale = p.rules.flatMap((r) => [ + ...r.files.filter((f) => !files.includes(f)), + ...r.prefixes.filter((prefix) => !files.some((f) => f.startsWith(prefix))), + ]); + if (unknown.length || stale.length) + throw Error( + `Update ${POLICY_PATH} in this iOS PR.\nUnclassified paths: ${unknown.join(", ") || "none"}\nStale policy paths: ${[...new Set(stale)].join(", ") || "none"}`, + ); +} + +function select( + cases, + { + mode = "auto", + files = [], + shards = 1, + release = false, + currentPolicy = policy, + previousPolicy, + } = {}, +) { + if (!["auto", "base", "full", "nightly"].includes(mode)) + throw Error("Invalid selection mode"); + if (![1, 2].includes(shards)) + throw Error("Only one or two shards are supported"); + if ( + !Array.isArray(files) || + files.some((f) => typeof f !== "string" || !f || f.includes("\0")) + ) + throw Error("Invalid changed paths"); + validatePolicy(currentPolicy, cases); + if (previousPolicy) validatePolicy(previousPolicy, cases); + const policies = previousPolicy + ? [currentPolicy, previousPolicy] + : [currentPolicy]; + const runnable = cases.filter( + (c) => + c.kind === "test" && + c.platforms.ios.disposition === "required" && + c.platforms.ios.pack !== "migration", + ); + const local = runnable.filter((c) => c.platforms.ios.backend === "local"); + const selected = new Set(currentPolicy.base); + const reasons = [], + coverageNotes = new Set(); + let full = mode === "full" || mode === "nightly"; + let code = mode !== "auto" || release; + if (mode === "auto") { + const unknown = []; + for (const file of files) { + let matched = false; + for (const [index, p] of policies.entries()) { + for (const rule of matchingRules(p, file)) { + matched = true; + reasons.push({ + file, + rule: rule.name, + policy: index ? "base" : "tested", + action: rule.skip ? "skip" : rule.full ? "full" : "base+groups", + groups: rule.groups, + }); + if (rule.skip) continue; + code = true; + full ||= !!rule.full; + for (const g of rule.groups) + for (const id of p.groups[g]) selected.add(id); + if (rule.coverageNote) coverageNotes.add(rule.coverageNote); + } + } + if (!matched) unknown.push(file); + } + if (unknown.length) + throw Error( + `Unclassified changed paths: ${unknown.join(", ")}. Update ${POLICY_PATH} in the iOS PR; no paid jobs were selected.`, + ); + // A policy edit cannot silently waive an existing base case for an app-code change. + if (code && previousPolicy) + for (const id of previousPolicy.base) selected.add(id); + } + if (full) for (const c of runnable) selected.add(c.id); + const expected = code ? runnable.filter((c) => selected.has(c.id)) : []; + const ids = expected + .filter((c) => c.platforms.ios.backend === "local") + .map((c) => c.id) + .sort(); + const bins = Array.from({ length: shards }, () => []); + for (const file of [ + ...new Set(local.filter((c) => ids.includes(c.id)).map((c) => c.file)), + ].sort()) { + const group = local + .filter((c) => c.file === file && ids.includes(c.id)) + .map((c) => c.id); + bins.sort((a, b) => a.length - b.length); + bins[0].push(...group); + } + return { + schemaVersion: 1, + platform: "ios", + mode, + code, + base: currentPolicy.base, + ids, + matrix: bins + .filter((b) => b.length) + .map((ids, i) => { + // The hosted 42-case pack reached the widget cases at minute 90. + const largePack = ids.length > 30; + const attemptTimeoutMinutes = largePack ? 110 : ids.length > 5 ? 90 : 40; + return { + name: `local-${i + 1}`, + ids: ids.sort(), + attemptTimeoutMinutes, + jobTimeoutMinutes: 3 * attemptTimeoutMinutes + 15, + backendLifetime: largePack ? "6h" : ids.length > 5 ? "5h" : "4h", + }; + }), + staging: expected.some( + (c) => + c.platforms.ios.backend === "regtest" && + c.platforms.ios.pack !== "migration", + ), + reasons, + coverageNotes: [...coverageNotes], + gaps: cases + .filter((c) => c.platforms.ios.disposition === "gap") + .map((c) => ({ id: c.id, ...c.platforms.ios })), + }; +} + +function currentPrMerge(number, parents, api) { + if (!/^[1-9][0-9]*$/.test(String(number))) throw Error("Invalid PR number"); + const pr = api(`pulls/${number}`); + if (pr.state !== "open") throw Error("PR is no longer open"); + const base = api(`branches/${encodeURIComponent(pr.base.ref)}`).commit.sha; + if (parents !== `${base} ${pr.head.sha}`) + throw Error("PR merge does not match current base and head"); + return { pr, base }; +} + +function main() { + const app = path.resolve(__dirname, "../.."); + const e2e = path.resolve( + process.env.E2E_TEST_ROOT || path.join(app, "../bitkit-e2e-tests"), + ); + const git = (...args) => + execFileSync("git", ["-C", app, ...args], { encoding: "utf8" }).trim(); + const inventory = JSON.parse( + fs.readFileSync(path.join(e2e, "test/inventory.json"), "utf8"), + ); + const tracked = execFileSync("git", ["-C", app, "ls-files", "-z"], { + encoding: "utf8", + }) + .split("\0") + .filter(Boolean); + validateTree(policy, tracked, inventory.cases); + if (process.argv[2] === "check") { + console.log( + `iOS policy: all ${tracked.length} tracked paths classified; all case references valid`, + ); + return; + } + const tested = git("rev-parse", "HEAD"); + const event = JSON.parse( + fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"), + ); + const prNumber = process.env.E2E_MANUAL_PR || event.pull_request?.number; + const api = (resource) => JSON.parse(execFileSync( + "gh", ["api", `repos/${process.env.GITHUB_REPOSITORY}/${resource}`], { encoding: "utf8" }, + )); + const { pr, base } = prNumber + ? currentPrMerge(prNumber, git("show", "-s", "--format=%P", "HEAD"), api) + : {}; + const files = base + ? execFileSync( + "git", + ["-C", app, "diff", "--no-renames", "--name-only", "-z", base, tested], + { encoding: "utf8" }, + ) + .split("\0") + .filter(Boolean) + : []; + let previousPolicy; + // Only the initial policy introduction has no base policy. A Git error must fail. + if (base && git("ls-tree", base, "--", POLICY_PATH)) + previousPolicy = JSON.parse(git("show", `${base}:${POLICY_PATH}`)); + const mode = + process.env.GITHUB_EVENT_NAME === "schedule" + ? "nightly" + : process.env.GITHUB_EVENT_NAME === "pull_request" + ? "auto" + : process.env.E2E_SELECTION || "full"; + if (mode === "nightly" && process.env.GITHUB_EVENT_NAME !== "schedule") + throw Error("Nightly mode requires a scheduled event"); + if (mode === "auto" && !base) + throw Error("Automatic manual selection requires a PR number"); + const result = select(inventory.cases, { + mode, + files, + previousPolicy, + shards: + process.env.GITHUB_EVENT_NAME === "workflow_dispatch" + ? Number(process.env.E2E_SHARDS || 1) + : 1, + release: + /^release-/.test(pr?.head?.ref || "") || + /^release-/.test(pr?.base?.ref || ""), + }); + result.appSha = tested; + result.e2eSha = execFileSync("git", ["-C", e2e, "rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); + result.policy = { + path: POLICY_PATH, + appSha: tested, + sha256: crypto + .createHash("sha256") + .update(fs.readFileSync(path.join(app, POLICY_PATH))) + .digest("hex"), + }; + fs.writeFileSync("selection.json", JSON.stringify(result, null, 2) + "\n"); + if (process.env.GITHUB_OUTPUT) + for (const key of [ + "code", + "matrix", + "staging", + "appSha", + "e2eSha", + ]) + fs.appendFileSync( + process.env.GITHUB_OUTPUT, + `${key}=${typeof result[key] === "object" ? JSON.stringify(result[key]) : result[key]}\n`, + ); + if (process.env.GITHUB_STEP_SUMMARY) + fs.appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `## iOS E2E selection\n\nApp/policy: ${tested}; tests: ${result.e2eSha}\n\nLocal cases (${result.ids.length}): ${result.ids.join(", ") || "none"}\n\nStaging: ${result.staging}; shards: ${result.matrix.length}\n\n${result.reasons.map((r) => `- ${r.file}: ${r.rule} (${r.policy})`).join("\n")}\n\nCoverage notes: ${result.coverageNotes.join("; ") || "none"}\n\nKnown gaps: ${result.gaps.map((g) => g.id).join(", ")}\n`, + ); + console.log(JSON.stringify(result, null, 2)); +} +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} +module.exports = { + currentPrMerge, + policy, + select, + matchingRules, + validatePolicy, + validateTree, +}; diff --git a/.github/scripts/e2e-selection.test.cjs b/.github/scripts/e2e-selection.test.cjs new file mode 100644 index 000000000..4510befbd --- /dev/null +++ b/.github/scripts/e2e-selection.test.cjs @@ -0,0 +1,394 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync, spawnSync } = require("node:child_process"); +const { + policy, + select, + validatePolicy, + validateTree, + currentPrMerge, +} = require("./e2e-selection.cjs"); +const app = path.resolve(__dirname, "../.."); +const e2e = path.resolve( + process.env.E2E_TEST_ROOT || path.join(app, "../bitkit-e2e-tests"), +); +const cases = JSON.parse( + fs.readFileSync(path.join(e2e, "test/inventory.json"), "utf8"), +).cases; +const tracked = execFileSync("git", ["-C", app, "ls-files", "-z"], { + encoding: "utf8", +}) + .split("\0") + .filter(Boolean); +const pick = (files, options = {}) => select(cases, { files, ...options }); + +test("the E2E workflow has no migration job, dependency, output or reusable-workflow call", () => { + const workflows = path.join(app, ".github/workflows"); + const e2e = fs.readFileSync(path.join(workflows, "e2e-tests.yml"), "utf8"); + const migration = fs.readFileSync(path.join(workflows, "e2e_migration.yml"), "utf8"); + assert.doesNotMatch(e2e, /migration/i); + assert.doesNotMatch(migration, /^ workflow_call:/m); + assert.match(migration, /^ schedule:/m); + assert.match(migration, /^ push:\n branches: \["release-\*"\]/m); +}); +test("policy covers the actual iOS tree and contains no stale paths or case IDs", () => + validateTree(policy, tracked, cases)); +test("base remains exactly the five existing cases", () => + assert.deepEqual([...policy.base].sort(), [ + "boost_2", + "lightning_1", + "send_1", + "send_2", + "transfer_2", + ])); + +for (const [file, added, staging] of [ + [ + "Bitkit/Services/Widgets/NewsService.swift", + ["widgets_1", "widgets_2", "widgets_3", "settings_12"], + false, + ], + [ + "Bitkit/Models/NewsWidgetData.swift", + ["widgets_1", "widgets_2", "widgets_3", "settings_12"], + false, + ], + ["Bitkit/Services/PaykitPaymentRequestService.swift", [], true], + ["Bitkit/Managers/ContactsManager.swift", [], true], + [ + "Bitkit/Services/Trezor/TrezorService.swift", + ["hardware_wallet_1", "hardware_wallet_2", "hardware_wallet_4"], + false, + ], + [ + "Bitkit/Managers/FeeEstimatesManager.swift", + ["boost_1", "onchain_3", "settings_03"], + false, + ], + [ + "Bitkit/Services/BackupService.swift", + ["backup_1", "onboarding_1", "onboarding_2", "security_1", "settings_07"], + true, + ], + [ + "Bitkit/Services/ElectrumConfigService.swift", + ["settings_10"], + false, + ], + ["Bitkit/Services/RgsConfigService.swift", ["settings_11"], false], +]) + test(`actual ${file} selects exact domain coverage`, () => { + assert.ok(tracked.includes(file), "Example must exist in the app"); + const s = pick([file]); + assert.deepEqual(s.ids, [...new Set([...policy.base, ...added])].sort()); + assert.equal(s.staging, staging); + assert.equal(Object.hasOwn(s, "migration"), false); + }); + +test("on-chain service retains public Paykit dispatch coverage without migration", () => { + const s = pick(["Bitkit/Services/OnchainSendAttemptService.swift"]); + assert.deepEqual( + s.ids, + [...new Set([...policy.base, ...policy.groups.send])].sort(), + ); + assert.equal(s.staging, true); + assert.equal(Object.hasOwn(s, "migration"), false); +}); +for (const f of [ + "Bitkit/Services/CoreService.swift", + "Bitkit/Utilities/Keychain.swift", + "Bitkit/ViewModels/WalletViewModel.swift", + "Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", +]) + test(`explicit shared dependency ${f} expands local and staging coverage`, () => { + const s = pick([f]); + assert.equal(s.ids.length, 42); + assert.equal(s.staging, true); + assert.equal(Object.hasOwn(s, "migration"), false); + }); +test("scanner entry points retain Pubky routes, Electrum input and hardware recipients", () => { + for (const file of [ + "Bitkit/Managers/ScannerManager.swift", + "Bitkit/Views/Scanner/ScannerSheet.swift", + "Bitkit/Views/Scanner/ScannerScreen.swift", + "Bitkit/Views/Scanner/ManualScanPrompt.swift", + "Bitkit/Views/Scanner/NewScannerEntry.swift", + "Bitkit/Utilities/QRCodePayload.swift", + ]) { + const s = pick([file]); + assert.ok(s.ids.includes("settings_10")); + assert.ok(s.ids.includes("hardware_wallet_4")); + assert.equal(s.staging, true, "pubky_profile_3/_5 exercise scanner routes"); + assert.equal(Object.hasOwn(s, "migration"), false); + } +}); +test("backup transport, payload and restore retain profile persistence assertions", () => { + for (const file of [ + "Bitkit/Services/VssBackupClient.swift", + "Bitkit/Models/BackupPayloads.swift", + "Bitkit/Views/Onboarding/RestoreWalletView.swift", + ]) { + const s = pick([file]); + assert.equal( + s.staging, + true, + "pubky_profile_2/_4 restore profile/contact state", + ); + assert.equal(Object.hasOwn(s, "migration"), false); + } +}); +test("shared send entry/confirmation retains Paykit and hardware consumers", () => { + for (const file of [ + "Bitkit/Utilities/PaymentNavigationHelper.swift", + "Bitkit/Views/Wallets/Send/SendAmountView.swift", + "Bitkit/Views/Wallets/Send/SendConfirmationView.swift", + ]) { + const s = pick([file]); + assert.equal(s.staging, true, "paykit_1 pays through this shared flow"); + assert.ok(s.ids.includes("hardware_wallet_4")); + assert.equal(Object.hasOwn(s, "migration"), false); + } +}); +test("unknown service/new area fails cheaply, never silently selects base or full", () => { + for (const f of [ + "Bitkit/Services/NewService.swift", + "Bitkit/NewArea/Payment.swift", + ]) + assert.throws(() => pick([f]), /Unclassified.*e2e-policy/s); +}); +test("new files inside a feature directory inherit its classification", () => { + assert.deepEqual( + pick(["Bitkit/Services/Widgets/NewService.swift"]).ids, + pick(["Bitkit/Services/Widgets/NewsService.swift"]).ids, + ); +}); +test("removed paths and obsolete IDs cannot leave stale rules unnoticed", () => { + assert.throws( + () => + validateTree( + policy, + tracked.filter((f) => f !== "Bitkit/Services/CoreService.swift"), + cases, + ), + /Stale policy paths.*CoreService/s, + ); + assert.throws( + () => + validateTree( + policy, + [...tracked, "Bitkit/Services/NewService.swift"], + cases, + ), + /Unclassified paths.*NewService/s, + ); + assert.throws( + () => + validatePolicy( + policy, + cases.filter((c) => c.id !== "send_2"), + ), + /unavailable case: send_2/, + ); +}); +test("renames preserve old domain and include new domain with base policy", () => { + const old = structuredClone(policy), + current = structuredClone(policy); + const f = "Bitkit/Services/PaykitPaymentRequestService.swift"; + for (const r of current.rules) r.files = r.files.filter((x) => x !== f); + const s = pick([f, "Bitkit/Services/Widgets/Request.swift"], { + currentPolicy: current, + previousPolicy: old, + }); + assert.equal(s.staging, true); + assert.ok(s.ids.includes("widgets_1")); + assert.ok(s.reasons.some((r) => r.file === f && r.policy === "base")); +}); +test("changing the policy cannot waive existing coverage on the same code diff", () => { + const current = structuredClone(policy); + for (const r of current.rules) + if (r.files.includes("Bitkit/Services/PaykitPaymentRequestService.swift")) + r.groups = []; + const s = pick(["Bitkit/Services/PaykitPaymentRequestService.swift"], { + currentPolicy: current, + previousPolicy: policy, + }); + assert.equal(s.staging, true); +}); +test("docs/native-test/policy-only changes need no paid jobs", () => { + for (const f of [ + "Docs/e2e-ci.md", + "BitkitTests/CurrencyTests.swift", + ".github/e2e-policy.json", + ]) { + const s = pick([f]); + assert.equal(s.code, false); + assert.deepEqual(s.matrix, []); + } +}); +test("USDT selection records missing coverage instead of pretending all Bitcoin tests cover it", () => { + const s = pick(["Bitkit/Managers/UsdtWalletManager.swift"]); + assert.deepEqual(s.ids, [...policy.base].sort()); + assert.ok(s.coverageNotes[0].includes("USDT")); +}); +test("reruns verify the live branch tip and open PR head, including stacked PRs", () => { + const pr = { state: "open", base: {ref: "feature/stack", sha: "old"}, head: {sha: "head"} }; + const api = (path) => { + if (path === "pulls/42") return pr; + assert.equal(path, "branches/feature%2Fstack"); + return {commit: {sha: "current"}}; + }; + assert.deepEqual(currentPrMerge(42, "current head", api), {pr, base: "current"}); + assert.throws(() => currentPrMerge(42, "old head", api), /current base and head/); + assert.throws(() => currentPrMerge(42, "current stale-head", api), /current base and head/); + pr.state = "closed"; + assert.throws(() => currentPrMerge(42, "current head", api), /no longer open/); +}); +test("migration is excluded from all PR gates even when the base policy requests it", () => { + const previousPolicy = structuredClone(policy); + previousPolicy.groups.recovery.push("migration_rn_upgrade"); + const files = ["Bitkit/Services/MigrationsService.swift", ".github/workflows/e2e_migration.yml"]; + for (const mode of ["auto", "base", "full", "nightly"]) { + for (const release of [false, true]) { + const result = pick(files, { mode, pr: true, release, previousPolicy }); + assert.equal(Object.hasOwn(result, "migration"), false); + assert.equal(result.staging, mode !== "base"); + assert.equal(result.ids.length, mode === "base" ? 5 : 42); + } + } +}); +test("nightly and manual full select local/staging only; gaps stay explicit", () => { + for (const mode of ["full", "nightly"]) { + const s = pick([], { mode }); + assert.equal(s.ids.length, 42); + assert.equal(s.staging, true); + assert.equal(Object.hasOwn(s, "migration"), false); + assert.ok(s.gaps.some((g) => g.id === "transfer_1")); + assert.ok(!s.ids.includes("transfer_1")); + } +}); +test("two shards partition cases exactly and retain spec lifecycle grouping", () => { + const s = pick([], { mode: "full", shards: 2 }); + assert.equal(s.matrix.length, 2); + assert.deepEqual(s.matrix.flatMap((x) => x.ids).sort(), s.ids); + for (const f of new Set(cases.map((c) => c.file))) { + const ids = cases.filter((c) => c.file === f).map((c) => c.id); + assert.ok( + s.matrix.filter((b) => b.ids.some((id) => ids.includes(id))).length <= 1, + ); + } +}); + +test("actual CLI uses tested app policy and pinned E2E inventory; rejects drift before output", (t) => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "ios-selection-")); + t.after(() => fs.rmSync(temp, { recursive: true, force: true })); + const root = path.join(temp, "app"); + fs.mkdirSync(path.join(root, ".github/scripts"), { recursive: true }); + fs.copyFileSync( + path.join(__dirname, "e2e-selection.cjs"), + path.join(root, ".github/scripts/e2e-selection.cjs"), + ); + // A tiny app-owned policy proves the CLI does not load rules from the E2E checkout. + const p = { + schemaVersion: 1, + base: policy.base, + groups: { widgets: policy.groups.widgets }, + rules: [ + { + name: "widget-source", + files: ["Widget.swift"], + prefixes: [], + groups: ["widgets"], + }, + { + name: "policy", + files: [".github/e2e-policy.json", ".github/scripts/e2e-selection.cjs"], + prefixes: [], + groups: [], + skip: true, + }, + ], + }; + fs.writeFileSync( + path.join(root, ".github/e2e-policy.json"), + JSON.stringify(p), + ); + fs.writeFileSync(path.join(root, "Widget.swift"), "// fixture"); + execFileSync("git", ["init", "-q", root]); + execFileSync("git", ["-C", root, "add", "."]); + // Only need a tree/HEAD for identity; use the already signed app commit as HEAD + // in a shared object repository, rather than create an unsigned test commit. + const objects = execFileSync( + "git", + ["-C", app, "rev-parse", "--path-format=absolute", "--git-path", "objects"], + { encoding: "utf8" }, + ).trim(); + fs.mkdirSync(path.join(root, ".git/objects/info"), { recursive: true }); + fs.writeFileSync( + path.join(root, ".git/objects/info/alternates"), + objects + "\n", + ); + const head = execFileSync("git", ["-C", app, "rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); + execFileSync("git", ["-C", root, "update-ref", "HEAD", head]); + const event = path.join(temp, "event.json"); + fs.writeFileSync(event, "{}"); + const env = { + ...process.env, + E2E_TEST_ROOT: e2e, + GITHUB_EVENT_PATH: event, + GITHUB_EVENT_NAME: "workflow_dispatch", + E2E_SELECTION: "base", + }; + const run = () => + spawnSync( + process.execPath, + [path.join(root, ".github/scripts/e2e-selection.cjs")], + { cwd: temp, env, encoding: "utf8" }, + ); + env.E2E_SELECTION = "nightly"; + const manualNightly = run(); + assert.notEqual(manualNightly.status, 0); + assert.match(manualNightly.stderr, /requires a scheduled event/); + env.GITHUB_EVENT_NAME = "schedule"; + assert.equal(run().status, 0); + assert.ok(!Object.hasOwn(JSON.parse(fs.readFileSync(path.join(temp, "selection.json"))), "migration")); + env.GITHUB_EVENT_NAME = "workflow_dispatch"; + env.E2E_SELECTION = "base"; + const r = run(); + assert.equal(r.status, 0, r.stderr); + const s = JSON.parse( + fs.readFileSync(path.join(temp, "selection.json"), "utf8"), + ); + assert.equal(s.policy.appSha, head); + assert.equal(s.policy.path, ".github/e2e-policy.json"); + assert.deepEqual(s.ids, [...policy.base].sort()); + fs.unlinkSync(path.join(temp, "selection.json")); + fs.writeFileSync(path.join(root, "Unknown.swift"), "// new"); + execFileSync("git", ["-C", root, "add", "Unknown.swift"]); + const bad = run(); + assert.notEqual(bad.status, 0); + assert.match(bad.stderr, /Unclassified paths: Unknown/); + assert.ok(!fs.existsSync(path.join(temp, "selection.json"))); +}); + +test("local budgets cover three attempts plus setup, with a backend that outlives the job", () => { + const base = select(cases, { mode: "base" }).matrix[0]; + assert.equal(base.attemptTimeoutMinutes, 40); + assert.equal(base.jobTimeoutMinutes, 135); + assert.equal(base.backendLifetime, "4h"); + for (const shards of [1, 2]) { + const full = select(cases, { mode: "full", shards }); + assert.equal(full.matrix.length, shards); + for (const shard of full.matrix) { + assert.equal(shard.attemptTimeoutMinutes, shards === 1 ? 110 : 90); + assert.equal(shard.jobTimeoutMinutes, shards === 1 ? 345 : 285); + assert.equal(shard.backendLifetime, shards === 1 ? "6h" : "5h"); + assert.ok(shard.jobTimeoutMinutes > 3 * shard.attemptTimeoutMinutes); + assert.ok(parseInt(shard.backendLifetime) * 60 > shard.jobTimeoutMinutes); + } + } +}); diff --git a/.github/scripts/namespace-stack.sh b/.github/scripts/namespace-stack.sh index ee2266276..da384919b 100755 --- a/.github/scripts/namespace-stack.sh +++ b/.github/scripts/namespace-stack.sh @@ -26,7 +26,12 @@ up() { install_compose # The runner sets DOCKER_DEFAULT_PLATFORM=linux/arm64, so the instance must be arm64. - nsc create --bare --machine_type linux/arm64:8x16 --duration 4h --cidfile "$instance_file" + local duration="${NAMESPACE_STACK_DURATION:-4h}" + case "$duration" in + 4h|5h|6h) ;; + *) echo "Unsupported Namespace stack lifetime: $duration" >&2; return 1 ;; + esac + nsc create --bare --machine_type linux/arm64:8x16 --duration "$duration" --cidfile "$instance_file" # `attach-context --background` times out before its socket appears; the foreground form works. nohup nsc docker attach-context --to "$(cat "$instance_file")" --name namespace-stack \ diff --git a/.github/workflows/e2e-selection.yml b/.github/workflows/e2e-selection.yml new file mode 100644 index 000000000..ecdd30efe --- /dev/null +++ b/.github/workflows/e2e-selection.yml @@ -0,0 +1,63 @@ +name: E2E selection policy +on: + pull_request: + push: + branches: [master] + workflow_dispatch: +permissions: + contents: read + pull-requests: read +concurrency: + group: e2e-policy-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +env: + GIT_LFS_SKIP_SMUDGE: "1" +jobs: + e2e-branch: + uses: synonymdev/bitkit-e2e-tests/.github/workflows/determine-e2e-branch.yml@main + with: + app_branch: ${{ github.head_ref || github.ref_name }} + e2e_branch_input: default-feature-branch + policy: + needs: e2e-branch + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + path: app-ci + fetch-depth: 0 + - uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main + id: e2e-ref + with: + selected: ${{ needs.e2e-branch.outputs.branch }} + e2e_branch_input: default-feature-branch + - uses: actions/checkout@v7 + with: + repository: synonymdev/bitkit-e2e-tests + ref: ${{ steps.e2e-ref.outputs.ref }} + path: e2e-ci + sparse-checkout: test/inventory.json + sparse-checkout-cone-mode: false + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Check app paths and test references + env: + E2E_TEST_ROOT: ${{ github.workspace }}/e2e-ci + run: node app-ci/.github/scripts/e2e-selection.cjs check + - name: Test selection against the real iOS tree + env: + E2E_TEST_ROOT: ${{ github.workspace }}/e2e-ci + run: node --test app-ci/.github/scripts/e2e-selection.test.cjs + - name: Preview selection for this revision + env: + E2E_TEST_ROOT: ${{ github.workspace }}/e2e-ci + E2E_SELECTION: base + GH_TOKEN: ${{ github.token }} + run: node app-ci/.github/scripts/e2e-selection.cjs + - uses: actions/upload-artifact@v7 + with: + name: e2e-policy-selection + path: selection.json + retention-days: 7 diff --git a/.github/workflows/e2e-staging.yml b/.github/workflows/e2e-staging.yml index 2c3fba80b..fdf0758e7 100644 --- a/.github/workflows/e2e-staging.yml +++ b/.github/workflows/e2e-staging.yml @@ -1,44 +1,57 @@ name: E2E Staging (iOS) - on: - schedule: - - cron: "0 4 * * *" workflow_dispatch: inputs: e2e_branch: - description: "Branch of synonymdev/bitkit-e2e-tests to use (main | default-feature-branch | custom branch name)" + description: Branch of synonymdev/bitkit-e2e-tests to use (main | default-feature-branch | custom branch name) required: false - default: "default-feature-branch" + default: default-feature-branch post_to_slack: - description: "Post E2E staging summary to Slack (#bitkit-staging-nightly)" + description: Post E2E staging summary to Slack (#bitkit-staging-nightly) type: boolean default: false required: false - + workflow_call: + inputs: + app_ref: + type: string + required: true + e2e_ref: + type: string + required: true + app_artifact: + type: string + default: "" + post_to_slack: + type: boolean + default: false env: TERM: xterm-256color FORCE_COLOR: 1 GIT_LFS_SKIP_SMUDGE: "1" - SIMULATOR_NAME: "iPhone 17" + SIMULATOR_NAME: iPhone 17 IOS_VERSION: "26.2" - concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + group: e2e-staging-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true - jobs: build-staging: - runs-on: macos-latest - + runs-on: ${{ inputs.app_artifact != '' && 'ubuntu-latest' || 'macos-latest' }} steps: - name: Checkout uses: actions/checkout@v7 - + with: + ref: ${{ inputs.app_ref || github.sha }} + if: inputs.app_artifact == '' + - name: Record selected revision + id: app + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + if: inputs.app_artifact == '' - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "26.2" - + if: inputs.app_artifact == '' - name: System Information run: | echo "=== System Information ===" @@ -47,20 +60,20 @@ jobs: echo "" echo "Xcode Version:" xcodebuild -version - + if: inputs.app_artifact == '' - name: Install xcbeautify run: | brew install xcbeautify - + if: inputs.app_artifact == '' - name: Swift packages uses: ./.github/actions/swift-packages - + if: inputs.app_artifact == '' - name: Pre-start simulator run: | echo "⏱️ Starting simulator at $(date)" xcrun simctl boot "${{ env.SIMULATOR_NAME }}" || true echo "✅ Simulator started at $(date)" - + if: inputs.app_artifact == '' - name: Build iOS app (regtest) env: GITHUB_ACTOR: ${{ github.actor }} @@ -90,72 +103,102 @@ jobs: SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ -allowProvisioningUpdates \ build - + if: inputs.app_artifact == '' - name: Prepare app for E2E tests (regtest) run: | mkdir -p e2e-app cp -r DerivedData/Build/Products/Debug-iphonesimulator/Bitkit.app e2e-app/bitkit.app - + if: inputs.app_artifact == '' + - name: Record app build identity + env: + BUILD_PLATFORM: ios + BUILD_BACKEND: regtest + run: |- + python3 - <<'PY' + import json, os, subprocess + from pathlib import Path + Path('e2e-app/e2e-build.json').write_text(json.dumps({'appSha': subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip(), 'platform': os.environ['BUILD_PLATFORM'], 'backend': os.environ['BUILD_BACKEND']})) + PY + if: inputs.app_artifact == '' - name: Upload iOS app (regtest) uses: actions/upload-artifact@v7 with: name: bitkit-e2e-ios-staging_${{ github.run_number }} path: e2e-app/ - + retention-days: 7 + if: inputs.app_artifact == '' + - name: Reuse the network app build + if: inputs.app_artifact != '' + env: + APP_ARTIFACT: ${{ inputs.app_artifact }} + run: echo "Using app artifact $APP_ARTIFACT" + timeout-minutes: 40 + outputs: + appSha: ${{ inputs.app_ref || steps.app.outputs.sha }} e2e-branch: uses: synonymdev/bitkit-e2e-tests/.github/workflows/determine-e2e-branch.yml@main with: app_branch: ${{ github.head_ref || github.ref_name }} - e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - + e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} e2e-tests-staging: runs-on: macos-latest - timeout-minutes: 120 - needs: [build-staging, e2e-branch] - + # Up to three attempts, retrying failed cases, plus setup and artifact collection. + timeout-minutes: 285 + needs: + - build-staging + - e2e-branch strategy: fail-fast: false matrix: shard: - - { name: multi_address, grep: "@multi_address_staging" } - - { name: pubky, grep: "@pubky_staging" } - - { name: transfer, grep: "@transfer_staging" } - - { name: ios_nightly_ui, grep: "@ios_nightly" } - + - name: pubky-paykit + ids: + - paykit_1 + - pubky_profile_1 + - pubky_profile_2 + - pubky_profile_3 + - pubky_profile_4 + - pubky_profile_5 name: e2e-tests-staging - ${{ matrix.shard.name }} - env: BACKEND: regtest - + PLATFORM: ios + AUT_FILENAME: bitkit.app + E2E_APP_SHA: ${{ needs.build-staging.outputs.appSha }} + E2E_RUN_ID: ${{ format('{0}-{1}-staging-{2}', github.run_id, github.run_attempt, matrix.shard.name) }} + E2E_PLAN: artifacts/reporting/plan.json + E2E_BACKEND_REVISION: regtest-stag0 + E2E_VARIANT: network-regtest + E2E_CASE_IDS: ${{ toJSON(matrix.shard.ids) }} + E2E_ATTEMPT_TIMEOUT_MINUTES: "90" + RECORD_VIDEO: "true" + SIMULATOR_NAME: iPhone 17 + SIMULATOR_OS_VERSION: "26.2" steps: - - name: Resolve E2E tests ref + - name: Resolve standalone E2E ref id: e2e-ref + if: inputs.e2e_ref == '' uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main with: selected: ${{ needs.e2e-branch.outputs.branch }} - e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - + e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} - name: Clone E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests path: bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref }} - + ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref }} - name: Download iOS app (regtest) uses: actions/download-artifact@v8 with: - name: bitkit-e2e-ios-staging_${{ github.run_number }} + name: ${{ inputs.app_artifact || format('bitkit-e2e-ios-staging_{0}', github.run_number) }} path: bitkit-e2e-tests/aut - - name: List iOS app directory contents run: ls -l bitkit-e2e-tests/aut - - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 22 - - name: Cache npm cache uses: actions/cache@v6 with: @@ -163,17 +206,14 @@ jobs: key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - - name: Install dependencies working-directory: bitkit-e2e-tests run: npm ci - - name: Install ffmpeg run: | echo "Installing ffmpeg with Homebrew..." brew install ffmpeg echo "ffmpeg version: $(ffmpeg -version | head -1)" - - name: Boot Simulator run: | echo "Erasing simulator..." @@ -193,70 +233,53 @@ jobs: echo "Verifying simulator state..." xcrun simctl list devices booted - - - name: Run E2E Tests 1 (${{ matrix.shard.name }}) - continue-on-error: true + - name: Prepare exact run plan + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts prepare + - name: Run E2E Tests 1 id: test1 + continue-on-error: true working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep '${{ matrix.shard.grep }}' + run: node --experimental-strip-types scripts/ci-run.ts attempt env: - SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} - SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} - RECORD_VIDEO: true - ATTEMPT: 1 - - - name: Run E2E Tests 2 (${{ matrix.shard.name }}) - continue-on-error: true - if: steps.test1.outcome != 'success' + ATTEMPT: "1" + RECORD_VIDEO: "true" + - name: Run E2E Tests 2 id: test2 + continue-on-error: true + if: steps.test1.outcome == 'failure' working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.shard.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts attempt env: - SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} - SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} - RECORD_VIDEO: true - ATTEMPT: 2 - - - name: Reset iOS Simulator before attempt 3 - if: steps.test1.outcome != 'success' && steps.test2.outcome != 'success' - run: | - echo "🔁 Resetting iOS simulator before attempt 3..." - xcrun simctl shutdown all || true - xcrun simctl erase "${{ env.SIMULATOR_NAME }}" || true - xcrun simctl boot "${{ env.SIMULATOR_NAME }}" || true - xcrun simctl bootstatus "${{ env.SIMULATOR_NAME }}" -b - open -a Simulator - sleep 15 - xcrun simctl list devices booted - - - name: Run E2E Tests 3 (${{ matrix.shard.name }}) - if: steps.test1.outcome != 'success' && steps.test2.outcome != 'success' + ATTEMPT: "2" + RECORD_VIDEO: "true" + - name: Run E2E Tests 3 id: test3 + continue-on-error: true + if: steps.test1.outcome == 'failure' && steps.test2.outcome == 'failure' working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.shard.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts attempt env: - SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} - SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} - RECORD_VIDEO: true - ATTEMPT: 3 - + ATTEMPT: "3" + RECORD_VIDEO: "true" + - name: Report all attempts + if: always() + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts report - name: Upload E2E Artifacts (${{ matrix.shard.name }}) - # Preserve failed-attempt evidence even when a retry passes. if: always() uses: actions/upload-artifact@v7 with: name: e2e-artifacts-staging_${{ matrix.shard.name }}_${{ github.run_number }} path: bitkit-e2e-tests/artifacts/ retention-days: 7 - slack-report: - if: >- - always() && - (github.event_name == 'schedule' || - (github.event_name == 'workflow_dispatch' && inputs.post_to_slack == true)) + if: always() && inputs.post_to_slack == true runs-on: ubuntu-latest - needs: [build-staging, e2e-branch, e2e-tests-staging] - + needs: + - build-staging + - e2e-branch + - e2e-tests-staging steps: - name: Resolve E2E tests ref id: e2e-ref @@ -265,13 +288,12 @@ jobs: with: selected: ${{ needs.e2e-branch.outputs.branch }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - + if: inputs.e2e_ref == '' - name: Checkout E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref || 'main' }} - + ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref || 'main' }} - name: Post Slack summary env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_STAGING }} @@ -284,3 +306,4 @@ jobs: RUN_ATTEMPT: ${{ github.run_attempt }} E2E_TESTS_REF: ${{ steps.e2e-ref.outputs.ref }} run: python3 scripts/slack_summary.py e2e-staging + timeout-minutes: 135 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 6877cd5fb..7f0468602 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1,89 +1,146 @@ name: e2e-tests -run-name: >- - ${{ inputs.retarget_run && format('Retarget E2E for PR #{0}', inputs.pr) || github.event.pull_request.title || github.event.head_commit.message || github.workflow }} - +run-name: "${{ inputs.retarget_run && format('Retarget E2E for PR #{0}', inputs.pr) || github.event.pull_request.title || github.event.head_commit.message || github.workflow }}" on: workflow_dispatch: inputs: e2e_branch: - description: "Branch of synonymdev/bitkit-e2e-tests to use (main | default-feature-branch | custom branch name)" + description: Branch of synonymdev/bitkit-e2e-tests to use (main | default-feature-branch | custom branch name) required: false - default: "default-feature-branch" + default: default-feature-branch pr: - description: "PR number to test as merged into master" + description: PR number to test as merged into master required: false default: "" merge_commit: - description: "Pinned PR merge commit for retarget validation" + description: Pinned PR merge commit for retarget validation default: "" head_sha: - description: "PR head verified by the retarget dispatcher" + description: PR head verified by the retarget dispatcher default: "" app_branch: - description: "PR branch for selecting matching E2E coverage" + description: PR branch for selecting matching E2E coverage default: "" check_run_id: - description: "PR check owned by this retarget validation" + description: PR check owned by this retarget validation default: "" retarget_run: - description: "Retarget status owner (set by e2e-retarget.yml)" + description: Retarget status owner (set by e2e-retarget.yml) default: "" workflow_ref: - description: "Workflow source recorded for this validation generation" + description: Workflow source recorded for this validation generation default: master helper_ref: - description: "Recovery helper branch or tag" + description: Recovery helper branch or tag default: master - # PRs stacked on another branch run no E2E until they target master; e2e-retarget.yml starts it on retarget + selection: + description: "auto: PR changes; base: five cases; full: all local/staging cases" + type: choice + options: + - full + - auto + - base + default: full + shards: + description: Manual local-shard cost experiment; automatic runs use one + type: choice + options: + - "1" + - "2" + default: "1" pull_request: - types: [opened, synchronize, reopened, ready_for_review] - branches: [master] - + types: + - opened + - synchronize + - reopened + - ready_for_review + branches: + - master + schedule: + - cron: 0 3 * * * env: TERM: xterm-256color FORCE_COLOR: 1 GIT_LFS_SKIP_SMUDGE: "1" - concurrency: group: e2e-tests-${{ github.event.pull_request.number || inputs.pr || github.ref }} cancel-in-progress: true - jobs: detect-changes: - if: github.event.pull_request.draft == false + needs: + - e2e-branch + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: read outputs: - code: ${{ (github.event_name == 'workflow_dispatch' && !inputs.pr) || steps.filter.outputs.code == 'true' }} + code: ${{ steps.selection.outputs.code }} + matrix: ${{ steps.selection.outputs.matrix }} + staging: ${{ steps.selection.outputs.staging }} + appSha: ${{ steps.selection.outputs.appSha }} + e2eSha: ${{ steps.selection.outputs.e2eSha }} steps: - - uses: actions/checkout@v7 + - name: Verify manual PR before checking out its code + id: manual-pr + if: github.event_name == 'workflow_dispatch' && inputs.pr != '' + uses: actions/github-script@v9 + env: + PR_NUMBER: ${{ inputs.pr }} + EXPECTED_HEAD: ${{ inputs.head_sha }} + EXPECTED_MERGE: ${{ inputs.merge_commit }} with: - ref: ${{ inputs.merge_commit || (inputs.pr && format('refs/pull/{0}/merge', inputs.pr)) || '' }} - fetch-depth: 2 - - name: Resolve tested PR merge commits - if: inputs.pr != '' - id: merge-base - run: | - echo "base=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" - echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - uses: dorny/paths-filter@v4 - # Retarget filters the exact tested merge against its first parent. - if: github.event_name == 'pull_request' || inputs.pr - id: filter + result-encoding: string + script: | + const n = process.env.PR_NUMBER; + if (!/^[1-9][0-9]*$/.test(n)) throw new Error('Invalid PR number'); + const {owner, repo} = context.repo; + const {data: pr} = await github.rest.pulls.get({owner, repo, pull_number: Number(n)}); + if (pr.state !== 'open' || pr.head.repo?.id !== context.payload.repository.id) + throw new Error('Manual PR validation requires an open PR from this repository'); + if (!pr.merge_commit_sha || (process.env.EXPECTED_MERGE && process.env.EXPECTED_MERGE !== pr.merge_commit_sha) || + (process.env.EXPECTED_HEAD && process.env.EXPECTED_HEAD !== pr.head.sha)) + throw new Error('Manual PR merge or head changed'); + return pr.merge_commit_sha; + - name: Checkout tested app + uses: actions/checkout@v7 with: - base: ${{ steps.merge-base.outputs.base || 'master' }} - ref: ${{ steps.merge-base.outputs.head || '' }} - filters: | - code: - - 'Bitkit/**' - - 'BitkitNotification/**' - - 'Bitkit.xcodeproj/**' - - 'Package.swift' - - '.github/workflows/e2e-tests.yml' - - '.github/scripts/**' - + ref: ${{ steps.manual-pr.outputs.result || github.sha }} + path: app-ci + fetch-depth: 0 + - name: Resolve E2E tests ref + id: e2e-ref + uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main + with: + selected: ${{ needs.e2e-branch.outputs.branch }} + e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} + - name: Checkout test selection + uses: actions/checkout@v7 + with: + repository: synonymdev/bitkit-e2e-tests + ref: ${{ steps.e2e-ref.outputs.ref }} + path: e2e-ci + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Select iOS gate and affected packs + id: selection + env: + E2E_TEST_ROOT: ${{ format('{0}/e2e-ci', github.workspace) }} + E2E_SELECTION: ${{ inputs.retarget_run != '' && 'auto' || inputs.selection || 'full' }} + E2E_SHARDS: ${{ inputs.shards || '1' }} + E2E_MANUAL_PR: ${{ inputs.pr }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node app-ci/.github/scripts/e2e-selection.cjs + - name: Upload selection + if: always() + uses: actions/upload-artifact@v7 + with: + name: e2e-selection + path: selection.json + retention-days: 7 build-local: needs: detect-changes - # PR code must not publish dependency caches into the dispatch ref's scope. cache-mode: read permissions: contents: read @@ -91,15 +148,13 @@ jobs: pull-requests: read checks: write actions: write - if: github.event.pull_request.draft == false && needs.detect-changes.outputs.code == 'true' + if: needs.detect-changes.outputs.code == 'true' runs-on: macos-latest - steps: - name: Checkout uses: actions/checkout@v7 with: - ref: ${{ inputs.merge_commit || (inputs.pr && format('refs/pull/{0}/merge', inputs.pr)) || '' }} - + ref: ${{ needs.detect-changes.outputs.appSha }} - name: Checkout retarget reporter if: inputs.retarget_run != '' uses: actions/checkout@v7 @@ -121,12 +176,10 @@ jobs: script: | const {reportRetargetResult} = require('./retarget-ci/.github/scripts/retarget-tests.cjs'); await reportRetargetResult({github, context, core, name: 'e2e-status', phase: 'start'}); - - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "26.2" - - name: System Information run: | echo "=== System Information ===" @@ -135,43 +188,22 @@ jobs: echo "" echo "Xcode Version:" xcodebuild -version - - - name: Verify current PR merge checkout - if: github.event_name == 'pull_request' || inputs.pr - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }} - run: | - [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || exit 1 - tested=$(git rev-parse HEAD) - head=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq 'select(.base.ref == "master") | .head.sha') - base=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/master" --jq '.object.sha') - expected="$base $head" - actual=$(gh api "repos/$GITHUB_REPOSITORY/commits/$tested" --jq '[.parents[].sha] | join(" ")') - if [[ ! "$expected" =~ ^[0-9a-f]{40}\ [0-9a-f]{40}$ || "$actual" != "$expected" ]]; then - echo "::error::Checked-out merge does not match the PR's current base and head" - exit 1 - fi - - name: Install xcbeautify run: | brew install xcbeautify - - name: Swift packages uses: ./.github/actions/swift-packages - - name: Pre-start simulator run: | echo "⏱️ Starting simulator at $(date)" xcrun simctl boot "iPhone 17" || true echo "✅ Simulator started at $(date)" - - name: Build iOS app (local) env: GITHUB_ACTOR: ${{ github.actor }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CHATWOOT_API: ${{ secrets.CHATWOOT_API }} - SIMULATOR_NAME: "iPhone 17" + SIMULATOR_NAME: iPhone 17 OS_VERSION: "26.2" run: | echo "=== Building iOS app (local) ===" @@ -198,27 +230,34 @@ jobs: TREZOR_BRIDGE_URL=http://127.0.0.1:21325 \ -allowProvisioningUpdates \ build - - name: Prepare app for E2E tests (local) run: | # Copy the .app bundle to the expected location and name mkdir -p e2e-app cp -r DerivedData/Build/Products/Debug-iphonesimulator/Bitkit.app e2e-app/bitkit.app - + - name: Record app build identity + env: + BUILD_PLATFORM: ios + BUILD_BACKEND: local + run: |- + python3 - <<'PY' + import json, os, subprocess + from pathlib import Path + Path('e2e-app/e2e-build.json').write_text(json.dumps({'appSha': subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip(), 'platform': os.environ['BUILD_PLATFORM'], 'backend': os.environ['BUILD_BACKEND']})) + PY - name: Upload iOS app (local) uses: actions/upload-artifact@v7 with: name: bitkit-e2e-ios_${{ github.run_number }} path: e2e-app/ - + retention-days: 7 + timeout-minutes: 40 e2e-branch: - needs: detect-changes - if: github.event.pull_request.draft == false && needs.detect-changes.outputs.code == 'true' + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false uses: synonymdev/bitkit-e2e-tests/.github/workflows/determine-e2e-branch.yml@main with: app_branch: ${{ inputs.app_branch || github.head_ref || github.ref_name }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - e2e-tests-local: cache-mode: read permissions: @@ -227,32 +266,39 @@ jobs: pull-requests: read checks: write actions: write - if: github.event.pull_request.draft == false && needs.detect-changes.outputs.code == 'true' + if: needs.detect-changes.outputs.code == 'true' runs-on: namespace-profile-mac - needs: [detect-changes, build-local, e2e-branch] + needs: + - detect-changes + - build-local + - e2e-branch env: - SIMULATOR_NAME: "iPhone 17" + SIMULATOR_NAME: iPhone 17 SIMULATOR_OS_VERSION: "26.2" - + PLATFORM: ios + BACKEND: local + AUT_FILENAME: bitkit.app + E2E_APP_SHA: ${{ needs.detect-changes.outputs.appSha }} + E2E_RUN_ID: ${{ format('{0}-{1}-local-{2}', github.run_id, github.run_attempt, matrix.shard.name) }} + E2E_PLAN: artifacts/reporting/plan.json + E2E_BACKEND_REVISION: local-docker + E2E_VARIANT: local-trezor + E2E_CASE_IDS: ${{ toJSON(matrix.shard.ids) }} + E2E_ATTEMPT_TIMEOUT_MINUTES: ${{ matrix.shard.attemptTimeoutMinutes }} + NAMESPACE_STACK_DURATION: ${{ matrix.shard.backendLifetime }} + RECORD_VIDEO: "true" strategy: fail-fast: false matrix: - shard: - # - { name: onboarding_backup_numberpad, grep: "@onboarding|@backup|@numberpad" } - # - { name: onchain_boost_receive_widgets, grep: "@onchain|@boost|@receive|@widgets" } - # - { name: settings, grep: "@settings" } - # - { name: security, grep: "@security" } - - { name: e2e, grep: '@ios_gate' } - + shard: ${{ fromJSON(needs.detect-changes.outputs.matrix) }} + max-parallel: 2 name: e2e-tests-local - ${{ matrix.shard.name }} - steps: - name: Checkout stack scripts uses: actions/checkout@v7 with: - ref: ${{ inputs.merge_commit || '' }} + ref: ${{ needs.detect-changes.outputs.appSha }} sparse-checkout: .github/scripts - - name: Checkout retarget reporter if: inputs.retarget_run != '' uses: actions/checkout@v7 @@ -274,35 +320,23 @@ jobs: script: | const {reportRetargetResult} = require('./retarget-ci/.github/scripts/retarget-tests.cjs'); await reportRetargetResult({github, context, core, name: 'e2e-status', phase: 'start'}); - - - name: Resolve E2E tests ref - id: e2e-ref - uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main - with: - selected: ${{ needs.e2e-branch.outputs.branch }} - e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - - name: Clone E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests path: bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref }} - + ref: ${{ needs.detect-changes.outputs.e2eSha }} - name: Download iOS app uses: actions/download-artifact@v8 with: name: bitkit-e2e-ios_${{ github.run_number }} path: bitkit-e2e-tests/aut - - name: List iOS app directory contents run: ls -l bitkit-e2e-tests/aut - - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 22 - - name: Cache npm cache uses: actions/cache@v6 with: @@ -310,17 +344,14 @@ jobs: key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - - name: Install dependencies working-directory: bitkit-e2e-tests run: npm ci - - name: Run regtest setup run: | .github/scripts/namespace-stack.sh up bitkit-e2e-tests/docker (cd bitkit-e2e-tests/docker && docker compose up --force-recreate -d) .github/scripts/namespace-stack.sh forward 43782 39388 60001 8080 9735 10009 3003 9001 9002 21325 21328 - - name: Wait for electrum server run: | for _ in $(seq 1 60); do @@ -328,7 +359,6 @@ jobs: sleep 5 done exit 1 - - name: Copy LND credentials working-directory: bitkit-e2e-tests/docker run: | @@ -339,13 +369,11 @@ jobs: done docker cp lnd:/home/lnd/.lnd/tls.cert lnd/tls.cert test -s lnd/data/chain/bitcoin/regtest/admin.macaroon - - name: Clear previous E2E artifacts working-directory: bitkit-e2e-tests run: | rm -rf artifacts/ rm -rf /tmp/lock/ - - name: Clear iOS Simulator environment run: | udid=$(xcrun simctl list devices available -j | python3 -c ' @@ -359,77 +387,85 @@ jobs: xcrun simctl boot "$udid" xcrun simctl bootstatus "$udid" -b defaults write com.apple.iphonesimulator DisableAllNotifications -bool true - - - name: Run E2E Tests 1 (${{ matrix.shard.name }}) - continue-on-error: true + - name: Prepare exact run plan + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts prepare + - name: Run E2E Tests 1 id: test1 + continue-on-error: true working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep '${{ matrix.shard.grep }}' + run: node --experimental-strip-types scripts/ci-run.ts attempt env: - RECORD_VIDEO: true - ATTEMPT: 1 - - - name: Run E2E Tests 2 (${{ matrix.shard.name }}) - continue-on-error: true - if: steps.test1.outcome != 'success' + ATTEMPT: "1" + RECORD_VIDEO: "true" + - name: Run E2E Tests 2 id: test2 + continue-on-error: true + if: steps.test1.outcome == 'failure' working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.shard.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts attempt env: - RECORD_VIDEO: true - ATTEMPT: 2 - - - name: Run E2E Tests 3 (${{ matrix.shard.name }}) - if: steps.test1.outcome != 'success' && steps.test2.outcome != 'success' + ATTEMPT: "2" + RECORD_VIDEO: "true" + - name: Run E2E Tests 3 id: test3 + continue-on-error: true + if: steps.test1.outcome == 'failure' && steps.test2.outcome == 'failure' working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.shard.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts attempt env: - RECORD_VIDEO: true - ATTEMPT: 3 - + ATTEMPT: "3" + RECORD_VIDEO: "true" + - name: Report all attempts + if: always() + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts report - name: Upload E2E Artifacts (${{ matrix.shard.name }}) - # Preserve failed-attempt evidence even when a retry passes. if: always() uses: actions/upload-artifact@v7 with: name: e2e-artifacts_${{ matrix.shard.name }}_${{ github.run_number }} path: bitkit-e2e-tests/artifacts/ retention-days: 7 - - name: Tear down regtest stack if: always() run: .github/scripts/namespace-stack.sh down - + timeout-minutes: ${{ matrix.shard.jobTimeoutMinutes }} e2e-status: - if: always() && github.event.pull_request.draft == false + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) name: e2e-status runs-on: ubuntu-latest - needs: [detect-changes, e2e-tests-local] + needs: + - detect-changes + - build-local + - e2e-tests-local + - staging + - build-network steps: - - name: Verify change detection succeeded - if: needs.detect-changes.result != 'success' - run: | - echo "Change detection failed or did not complete." - exit 1 - - - name: E2E skipped - no code changes - if: needs.detect-changes.outputs.code != 'true' - run: echo "✅ E2E skipped - no code changes" - - - name: Verify all E2E shards succeeded - if: needs.detect-changes.outputs.code == 'true' - run: | - if [ "${{ needs.e2e-tests-local.result }}" != "success" ]; then - echo "❌ Some E2E shards failed." - exit 1 - fi - echo "✅ All E2E shards passed!" - + - name: Require all selected jobs + env: + RESULTS: ${{ toJSON(needs) }} + run: |- + python3 - <<'PY' + import json, os + j=json.loads(os.environ['RESULTS']) + assert j['detect-changes']['result']=='success', 'Selection did not succeed' + s=j['detect-changes']['outputs'] + required=['build-local','e2e-tests-local'] if s['code']=='true' else [] + if s['staging']=='true': required += ['build-network', 'staging'] + failed={k:j[k]['result'] for k in required if j[k]['result']!='success'} + assert not failed, f'Selected jobs did not pass: {failed}' + print('All selected E2E jobs passed' if required else 'No app code changes') + PY report-retarget-result: name: Report retarget E2E result if: always() && inputs.retarget_run != '' - needs: [detect-changes, build-local, e2e-branch, e2e-tests-local, e2e-status] + needs: + - detect-changes + - build-local + - e2e-branch + - e2e-tests-local + - e2e-status runs-on: ubuntu-latest permissions: contents: read @@ -455,3 +491,105 @@ jobs: const {reportRetargetResult, e2eRetargetResult} = require('./retarget-ci/.github/scripts/retarget-tests.cjs'); const result = e2eRetargetResult(JSON.parse(process.env.TEST_RESULTS)); await reportRetargetResult({github, context, core, name: 'e2e-status', phase: 'complete', result}); + staging: + needs: + - detect-changes + - build-network + if: needs.detect-changes.outputs.staging == 'true' + uses: ./.github/workflows/e2e-staging.yml + with: + app_ref: ${{ needs.detect-changes.outputs.appSha }} + e2e_ref: ${{ needs.detect-changes.outputs.e2eSha }} + app_artifact: bitkit-e2e-ios-network_${{ github.run_number }} + post_to_slack: ${{ github.event_name == 'schedule' }} + secrets: inherit + build-network: + env: + SIMULATOR_NAME: iPhone 17 + IOS_VERSION: "26.2" + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ needs.detect-changes.outputs.appSha }} + - name: Record selected revision + id: app + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "26.2" + - name: System Information + run: | + echo "=== System Information ===" + echo "macOS Version:" + sw_vers + echo "" + echo "Xcode Version:" + xcodebuild -version + - name: Install xcbeautify + run: | + brew install xcbeautify + - name: Swift packages + uses: ./.github/actions/swift-packages + - name: Pre-start simulator + run: | + echo "⏱️ Starting simulator at $(date)" + xcrun simctl boot "${{ env.SIMULATOR_NAME }}" || true + echo "✅ Simulator started at $(date)" + - name: Build iOS app (regtest) + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + E2E_BACKEND: network + E2E_NETWORK: regtest + run: | + echo "=== Building iOS app (regtest) ===" + echo "Using simulator: ${{ env.SIMULATOR_NAME }} (iOS ${{ env.IOS_VERSION }})" + + if xcodebuild -showsdks | grep -q "iOS Simulator"; then + echo "✅ iOS Simulator platform already installed" + else + echo "⚙️ iOS Simulator platform not found — downloading..." + xcodebuild -downloadPlatform iOS + fi + + xcodebuild -workspace Bitkit.xcodeproj/project.xcworkspace \ + -scheme Bitkit \ + -configuration Debug \ + -destination "platform=iOS Simulator,name=${{ env.SIMULATOR_NAME }},OS=${{ env.IOS_VERSION }}" \ + -derivedDataPath DerivedData \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES" \ + -disablePackageRepositoryCache \ + -disableAutomaticPackageResolution \ + -skipPackageUpdates \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ + -allowProvisioningUpdates \ + build + - name: Prepare app for E2E tests (regtest) + run: | + mkdir -p e2e-app + cp -r DerivedData/Build/Products/Debug-iphonesimulator/Bitkit.app e2e-app/bitkit.app + - name: Record app build identity + env: + BUILD_PLATFORM: ios + BUILD_BACKEND: regtest + run: |- + python3 - <<'PY' + import json, os, subprocess + from pathlib import Path + Path('e2e-app/e2e-build.json').write_text(json.dumps({'appSha': subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip(), 'platform': os.environ['BUILD_PLATFORM'], 'backend': os.environ['BUILD_BACKEND']})) + PY + - name: Upload iOS app (regtest) + uses: actions/upload-artifact@v7 + with: + name: bitkit-e2e-ios-network_${{ github.run_number }} + path: e2e-app/ + retention-days: 7 + timeout-minutes: 40 + outputs: + appSha: ${{ steps.app.outputs.sha }} + needs: + - detect-changes + if: needs.detect-changes.outputs.staging == 'true' diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index 9153cbcc5..1dbf1c56b 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -1,55 +1,54 @@ name: E2E Migration (iOS) - on: + schedule: + - cron: "0 3 * * *" + push: + branches: ["release-*"] workflow_dispatch: inputs: e2e_branch: - description: "Branch of synonymdev/bitkit-e2e-tests to use (main | default-feature-branch | custom branch name)" + description: Branch of synonymdev/bitkit-e2e-tests to use (main | default-feature-branch | custom branch name) required: false - default: "default-feature-branch" + default: default-feature-branch previous_native_version: - description: "Archived native release tag (empty uses the pinned baseline)" + description: Archived native release tag (empty uses the pinned baseline) required: false default: "" extended_rn: - description: "Also run RN 1.1.6 passphrase and legacy-address cases" + description: Also run RN 1.1.6 passphrase and legacy-address cases type: boolean default: false post_to_slack: - description: "Post E2E migration summary to Slack (#bitkit-staging-nightly)" + description: Post E2E migration summary to Slack (#bitkit-staging-nightly) type: boolean default: false required: false - schedule: - - cron: "0 3 * * *" - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - env: TERM: xterm-256color FORCE_COLOR: 1 GIT_LFS_SKIP_SMUDGE: "1" - SIMULATOR_NAME: "iPhone 17" + MIGRATION_EXTENDED: ${{ github.event_name == 'schedule' || github.event_name == 'push' || inputs.extended_rn || false }} + SIMULATOR_NAME: iPhone 17 IOS_VERSION: "26.2" - concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + group: e2e-migration-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true - jobs: build: - if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) + if: github.event_name != 'push' || !github.event.deleted runs-on: macos-latest - steps: - name: Checkout uses: actions/checkout@v7 - + with: + ref: ${{ github.sha }} + - name: Record selected revision + id: app + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "26.2" - - name: System Information run: | echo "=== System Information ===" @@ -58,20 +57,16 @@ jobs: echo "" echo "Xcode Version:" xcodebuild -version - - name: Install xcbeautify run: | brew install xcbeautify - - name: Swift packages uses: ./.github/actions/swift-packages - - name: Pre-start simulator run: | echo "⏱️ Starting simulator at $(date)" xcrun simctl boot "${{ env.SIMULATOR_NAME }}" || true echo "✅ Simulator started at $(date)" - - name: Build iOS app (regtest) env: GITHUB_ACTOR: ${{ github.actor }} @@ -102,29 +97,36 @@ jobs: SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) E2E_BUILD' \ -allowProvisioningUpdates \ build - - name: Prepare app for E2E tests (regtest) run: | # Copy the .app bundle to the expected location and name mkdir -p e2e-app cp -r DerivedData/Build/Products/Debug-iphonesimulator/Bitkit.app e2e-app/bitkit.app - + - name: Record app build identity + run: |- + python3 - <<'PY' + import json, subprocess + from pathlib import Path + Path('e2e-app/e2e-build.json').write_text(json.dumps({'appSha': subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip(), 'platform':'ios', 'backend':'regtest'})) + PY - name: Upload iOS app (regtest) uses: actions/upload-artifact@v7 with: - name: bitkit-e2e-ios_${{ github.run_number }} + name: bitkit-e2e-migration-ios_${{ github.run_number }} path: e2e-app/ - + retention-days: 7 + timeout-minutes: 40 + outputs: + appSha: ${{ steps.app.outputs.sha }} e2e-branch: - if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) + if: github.event_name != 'push' || !github.event.deleted uses: synonymdev/bitkit-e2e-tests/.github/workflows/determine-e2e-branch.yml@main with: app_branch: ${{ github.head_ref || github.ref_name }} - e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - + e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} resolve-e2e-ref: - if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) - needs: [e2e-branch] + needs: + - e2e-branch runs-on: ubuntu-latest outputs: ref: ${{ steps.e2e-ref.outputs.ref }} @@ -135,16 +137,17 @@ jobs: with: selected: ${{ needs.e2e-branch.outputs.branch }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - + timeout-minutes: 135 migration-plan: - if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) - needs: [e2e-branch] + needs: + - e2e-branch runs-on: ubuntu-latest outputs: version: ${{ steps.plan.outputs.version }} all: ${{ steps.plan.outputs.all }} rn: ${{ steps.plan.outputs.rn }} native: ${{ steps.plan.outputs.native }} + e2eSha: ${{ steps.tests-sha.outputs.sha }} steps: - name: Resolve E2E tests ref id: e2e-ref @@ -156,17 +159,23 @@ jobs: uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref }} + ref: ${{ steps.e2e-ref.outputs.ref || 'main' }} - name: Resolve migration cases and baseline id: plan run: python3 scripts/migration-matrix.py ios env: PREVIOUS_NATIVE_VERSION: ${{ inputs.previous_native_version }} - EXTENDED_RN: ${{ inputs.extended_rn || false }} - + EXTENDED_RN: ${{ env.MIGRATION_EXTENDED }} + - name: Pin resolved E2E source + id: tests-sha + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + timeout-minutes: 135 prepare-wallets: - needs: [e2e-branch, resolve-e2e-ref, migration-plan] - uses: synonymdev/bitkit-e2e-tests/.github/workflows/migration-wallet-setup.yml@main + needs: + - e2e-branch + - resolve-e2e-ref + - migration-plan + uses: synonymdev/bitkit-e2e-tests/.github/workflows/migration-wallet-setup.yml@e49cfab3c25b8bfe18c59ae471405d0b6d45bdfc strategy: fail-fast: false matrix: @@ -177,61 +186,57 @@ jobs: rn_version: v1.1.6 setup_type: ${{ matrix.scenario.setup_type }} scenario_name: ${{ matrix.scenario.name }} - + e2e_sha: ${{ needs.migration-plan.outputs.e2eSha }} + diagnostics_retention_days: 7 rn-tests: - if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) runs-on: macos-latest - needs: [build, e2e-branch, migration-plan, prepare-wallets] - + needs: + - build + - e2e-branch + - migration-plan + - prepare-wallets strategy: fail-fast: false matrix: scenario: ${{ fromJSON(needs.migration-plan.outputs.rn) }} - name: ios RN - ${{ matrix.scenario.version }} - ${{ matrix.scenario.name }} env: BACKEND: regtest - MIGRATION_EXTENDED: ${{ inputs.extended_rn || false }} - + PLATFORM: ios + AUT_FILENAME: bitkit.app + SIMULATOR_NAME: iPhone 17 + SIMULATOR_OS_VERSION: "26.2" + E2E_APP_SHA: ${{ needs.build.outputs.appSha }} + E2E_RUN_ID: ${{ format('{0}-{1}-migration-{2}', github.run_id, github.run_attempt, matrix.scenario.name) }} + E2E_PLAN: artifacts/reporting/plan.json + E2E_BACKEND_REVISION: regtest-stag0 + E2E_VARIANT: network-regtest-migration + E2E_CASE_TAG: ${{ matrix.scenario.grep }} steps: - - name: Resolve E2E tests ref - id: e2e-ref - uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main - with: - selected: ${{ needs.e2e-branch.outputs.branch }} - e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - - name: Clone E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests path: bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref }} - + ref: ${{ needs.migration-plan.outputs.e2eSha }} - name: Download iOS app uses: actions/download-artifact@v8 with: - name: bitkit-e2e-ios_${{ github.run_number }} + name: bitkit-e2e-migration-ios_${{ github.run_number }} path: bitkit-e2e-tests/aut - - name: Download migration env uses: actions/download-artifact@v8 with: name: migration-env_${{ matrix.scenario.version }}_${{ matrix.scenario.name }} path: bitkit-e2e-tests/artifacts - - # Restore uses the Android-prepared seed directly in the native target. - # Its source provenance is in the matching migration-setup-logs artifact. - name: Download migration source if: matrix.scenario.name != 'rn_restore' working-directory: bitkit-e2e-tests run: python3 scripts/download-migration-app.py ios '${{ matrix.scenario.source }}' '${{ matrix.scenario.version }}' - - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 22 - - name: Cache npm cache uses: actions/cache@v6 with: @@ -239,17 +244,14 @@ jobs: key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - - name: Install dependencies working-directory: bitkit-e2e-tests run: npm ci - - name: Install ffmpeg run: | echo "Installing ffmpeg..." brew install ffmpeg || brew upgrade ffmpeg || true echo "ffmpeg version: $(ffmpeg -version | head -1)" - - name: Boot Simulator run: | echo "Erasing simulator..." @@ -269,7 +271,6 @@ jobs: echo "Verifying simulator state..." xcrun simctl list devices booted - - name: Start Appium Server working-directory: bitkit-e2e-tests run: | @@ -289,62 +290,61 @@ jobs: done curl --fail --silent --show-error http://127.0.0.1:4723/status > /dev/null - - name: Load independent wallet for attempt 1 working-directory: bitkit-e2e-tests run: cat 'artifacts/wallet-attempt-1/migration_setup_${{ matrix.scenario.setup_type }}.env' >> "$GITHUB_ENV" - + - name: Prepare exact migration plan + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts prepare - name: Run E2E Tests 1 (${{ matrix.scenario.name }}) continue-on-error: true id: test1 working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep '${{ matrix.scenario.grep }}' + run: node --experimental-strip-types scripts/ci-run.ts attempt env: SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} RECORD_VIDEO: true ATTEMPT: 1 - - name: Load independent wallet for attempt 2 if: steps.test1.outcome != 'success' working-directory: bitkit-e2e-tests run: cat 'artifacts/wallet-attempt-2/migration_setup_${{ matrix.scenario.setup_type }}.env' >> "$GITHUB_ENV" - - name: Run E2E Tests 2 (${{ matrix.scenario.name }}) continue-on-error: true if: steps.test1.outcome != 'success' id: test2 working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.scenario.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts attempt env: SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} RECORD_VIDEO: true ATTEMPT: 2 - - name: Load independent wallet for attempt 3 if: steps.test1.outcome != 'success' && steps.test2.outcome != 'success' working-directory: bitkit-e2e-tests run: cat 'artifacts/wallet-attempt-3/migration_setup_${{ matrix.scenario.setup_type }}.env' >> "$GITHUB_ENV" - - name: Run E2E Tests 3 (${{ matrix.scenario.name }}) if: steps.test1.outcome != 'success' && steps.test2.outcome != 'success' id: test3 working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.scenario.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts attempt env: SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} RECORD_VIDEO: true ATTEMPT: 3 - - name: Copy Appium logs to artifacts if: always() working-directory: bitkit-e2e-tests run: | mkdir -p artifacts cp appium.log artifacts/ || true - + - name: Report all migration attempts + if: always() + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts report - name: Upload E2E Artifacts (${{ matrix.scenario.name }}) if: always() uses: actions/upload-artifact@v7 @@ -353,52 +353,50 @@ jobs: path: | bitkit-e2e-tests/artifacts/ !bitkit-e2e-tests/artifacts/**/*.env - + retention-days: 7 + timeout-minutes: 135 native-tests: - if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) runs-on: macos-latest - needs: [build, e2e-branch, migration-plan] - + needs: + - build + - e2e-branch + - migration-plan strategy: fail-fast: false matrix: scenario: ${{ fromJSON(needs.migration-plan.outputs.native) }} - name: ios native - ${{ matrix.scenario.version }} - ${{ matrix.scenario.name }} env: BACKEND: regtest - MIGRATION_EXTENDED: ${{ inputs.extended_rn || false }} - + PLATFORM: ios + AUT_FILENAME: bitkit.app + SIMULATOR_NAME: iPhone 17 + SIMULATOR_OS_VERSION: "26.2" + E2E_APP_SHA: ${{ needs.build.outputs.appSha }} + E2E_RUN_ID: ${{ format('{0}-{1}-migration-{2}', github.run_id, github.run_attempt, matrix.scenario.name) }} + E2E_PLAN: artifacts/reporting/plan.json + E2E_BACKEND_REVISION: regtest-stag0 + E2E_VARIANT: network-regtest-migration + E2E_CASE_TAG: ${{ matrix.scenario.grep }} steps: - - name: Resolve E2E tests ref - id: e2e-ref - uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main - with: - selected: ${{ needs.e2e-branch.outputs.branch }} - e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - - name: Clone E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests path: bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref }} - + ref: ${{ needs.migration-plan.outputs.e2eSha }} - name: Download iOS app uses: actions/download-artifact@v8 with: - name: bitkit-e2e-ios_${{ github.run_number }} + name: bitkit-e2e-migration-ios_${{ github.run_number }} path: bitkit-e2e-tests/aut - - name: Download migration source working-directory: bitkit-e2e-tests run: python3 scripts/download-migration-app.py ios '${{ matrix.scenario.source }}' '${{ matrix.scenario.version }}' - - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 22 - - name: Cache npm cache uses: actions/cache@v6 with: @@ -406,17 +404,14 @@ jobs: key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - - name: Install dependencies working-directory: bitkit-e2e-tests run: npm ci - - name: Install ffmpeg run: | echo "Installing ffmpeg..." brew install ffmpeg || brew upgrade ffmpeg || true echo "ffmpeg version: $(ffmpeg -version | head -1)" - - name: Boot Simulator run: | echo "Erasing simulator..." @@ -436,7 +431,6 @@ jobs: echo "Verifying simulator state..." xcrun simctl list devices booted - - name: Start Appium Server working-directory: bitkit-e2e-tests run: | @@ -456,48 +450,50 @@ jobs: done curl --fail --silent --show-error http://127.0.0.1:4723/status > /dev/null - + - name: Prepare exact migration plan + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts prepare - name: Run E2E Tests 1 (${{ matrix.scenario.name }}) continue-on-error: true id: test1 working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep '${{ matrix.scenario.grep }}' + run: node --experimental-strip-types scripts/ci-run.ts attempt env: SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} RECORD_VIDEO: true ATTEMPT: 1 - - name: Run E2E Tests 2 (${{ matrix.scenario.name }}) continue-on-error: true if: steps.test1.outcome != 'success' id: test2 working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.scenario.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts attempt env: SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} RECORD_VIDEO: true ATTEMPT: 2 - - name: Run E2E Tests 3 (${{ matrix.scenario.name }}) if: steps.test1.outcome != 'success' && steps.test2.outcome != 'success' id: test3 working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.scenario.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts attempt env: SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} RECORD_VIDEO: true ATTEMPT: 3 - - name: Copy Appium logs to artifacts if: always() working-directory: bitkit-e2e-tests run: | mkdir -p artifacts cp appium.log artifacts/ || true - + - name: Report all migration attempts + if: always() + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts report - name: Upload E2E Artifacts (${{ matrix.scenario.name }}) if: always() uses: actions/upload-artifact@v7 @@ -506,10 +502,18 @@ jobs: path: | bitkit-e2e-tests/artifacts/ !bitkit-e2e-tests/artifacts/**/*.env - + retention-days: 7 + timeout-minutes: 135 migration-result: - if: always() && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-')))) - needs: [build, e2e-branch, resolve-e2e-ref, migration-plan, prepare-wallets, rn-tests, native-tests] + if: always() && (github.event_name != 'push' || !github.event.deleted) + needs: + - build + - e2e-branch + - resolve-e2e-ref + - migration-plan + - prepare-wallets + - rn-tests + - native-tests runs-on: ubuntu-latest steps: - name: Require all migration jobs to succeed @@ -523,14 +527,19 @@ jobs: print(f'Migration failed or required jobs did not run: {failed}') sys.exit(1) PYTHON - + timeout-minutes: 135 slack-report: - if: >- - always() && - (github.event_name == 'schedule' || - (github.event_name == 'workflow_dispatch' && inputs.post_to_slack == true)) + if: always() && (github.event_name == 'schedule' || inputs.post_to_slack == true) runs-on: ubuntu-latest - needs: [build, e2e-branch, resolve-e2e-ref, migration-plan, prepare-wallets, rn-tests, native-tests, migration-result] + needs: + - build + - e2e-branch + - resolve-e2e-ref + - migration-plan + - prepare-wallets + - rn-tests + - native-tests + - migration-result steps: - name: Resolve E2E tests ref id: e2e-ref @@ -539,13 +548,11 @@ jobs: with: selected: ${{ needs.e2e-branch.outputs.branch }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - - name: Checkout E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests ref: ${{ steps.e2e-ref.outputs.ref || 'main' }} - - name: Post Slack summary env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_STAGING }} @@ -559,3 +566,4 @@ jobs: RUN_ATTEMPT: ${{ github.run_attempt }} E2E_TESTS_REF: ${{ steps.e2e-ref.outputs.ref }} run: python3 scripts/slack_summary.py e2e-migration + timeout-minutes: 135 diff --git a/Docs/e2e-ci.md b/Docs/e2e-ci.md new file mode 100644 index 000000000..c836ff6b6 --- /dev/null +++ b/Docs/e2e-ci.md @@ -0,0 +1,54 @@ +# iOS E2E CI + +`e2e-tests.yml` is the entry point for PR, manual and nightly E2E selection. This repo owns [the path policy](../.github/e2e-policy.json), [selector](../.github/scripts/e2e-selection.cjs) and [policy tests](../.github/scripts/e2e-selection.test.cjs). The shared E2E repo owns case definitions, inventory, fixtures and execution/reporting, not iOS paths. + +The base gate is exactly `send_1`, `send_2`, `lightning_1`, `transfer_2`, `boost_2`. Changed files automatically add risk coverage. The required `e2e-status` aggregates every selected job. Existing unit and native integration workflows remain separate and unchanged. Android is unchanged. + +## Policy ownership and maintenance + +The selector loads `.github/e2e-policy.json` from the **tested app checkout**, not the E2E branch. `selection.json` records that app/policy SHA, the policy file digest, E2E SHA, exact case IDs and matching rules. Feature directories inherit their domain classification; services, managers, models and utilities use explicit files. There is no directory-wide `Services/` or `Models/` escalation. + +| Actual changed path | Coverage beyond the five-case base | +| --- | --- | +| `Bitkit/Services/Widgets/NewsService.swift` | `widgets_1–3`, `settings_12` | +| `Bitkit/Services/PaykitPaymentRequestService.swift` | Six Pubky/Paykit staging cases; no migration | +| `Bitkit/Services/Trezor/TrezorService.swift` | Three active hardware cases; no staging/migration | +| `Bitkit/Managers/FeeEstimatesManager.swift` | `boost_1`, `onchain_3`, `settings_03` | +| `Bitkit/Services/BackupService.swift` | Five onboarding/backup/security cases plus six Pubky/Paykit staging cases | +| `Bitkit/Services/CoreService.swift` | All local and staging cases | + +Scanner routing explicitly includes Pubky/Electrum and hardware coverage. Backup transport/payload/restore includes profile persistence. The global Keychain is an explicit full-coverage dependency because it owns wallet secrets and Pubky/Paykit state. Shared amount/confirmation and on-chain dispatch include the existing Paykit contact-payment case. These mappings follow actual callers and assertions, not filename similarity. + +Only named app-wide wallet/lifecycle files and build/dependency/E2E orchestration configuration select all PR-eligible local and staging cases. At the policy introduction, this means 28 of 619 `Bitkit` Swift files, including three of 76 service files (`CoreService`, `LightningService`, `ServiceQueue`). These are a source-tree audit, not a runtime or billing estimate. Changes to several domains union their coverage and execute overlapping cases once. + +Every PR, including drafts and stacks, gets a cheap Linux `E2E selection policy` check. It verifies every tracked path has a classification, explicit file/prefix entries still exist, and referenced cases are runnable in the selected E2E inventory. Main selection runs the same validation before any paid build. A new unclassified service or moved file fails with its path and a request to update the policy in the same iOS PR. It does not silently select base or launch every lane. Old and new rename paths contribute rules from both the base and tested policy, retaining coverage when a path mapping changes during a refactor. + +Documentation, native-test and selection-policy-only changes need no device jobs; existing native/lint checks and policy tests cover them. Presentation/resource changes run the base gate. USDT-specific paths record the lack of existing USDT E2E coverage and keep Bitcoin regression coverage; running unrelated Bitcoin tests would not prove USDT behavior. Native/integration tests must cover those feature changes. + +When adding/moving code, edit this JSON in the same PR, use an existing feature directory or add an explicit file classification with its reason, then run: + +```sh +E2E_TEST_ROOT=../bitkit-e2e-tests node .github/scripts/e2e-selection.cjs check +E2E_TEST_ROOT=../bitkit-e2e-tests node --test .github/scripts/e2e-selection.test.cjs +``` + +The E2E checkout must contain the matching inventory. Add a real-path regression example when introducing a new domain mapping. New coverage belongs in the shared E2E repo; app paths do not. + +## Execution + +Two independent workflows run at 03:00 UTC. `e2e-tests.yml` runs 42 local and six staging cases. `e2e_migration.yml` builds its own network app and runs all six migrations with its own result. The migration workflow also runs on each push to `release-*` and supports its own manual dispatch for diagnosis. + +Manual validation uses the branch containing the changed workflow: + +```sh +gh workflow run e2e-tests.yml --ref codex/264-inventory-shadow \ + -f e2e_branch=codex/264-inventory-shadow -f selection=base -f shards=1 +``` + +To test an open same-repository PR merge, including a stacked PR, also pass `-f pr=NUMBER -f selection=auto`. The merge must match the PR's actual base and head. `selection=full` runs the local and staging suites. Manual E2E and PR merge runs use local and staging coverage. The optional `shards=2` experiment applies only to manual local execution; automatic runs use one shard. Each shard has isolated infrastructure and shares the same built local app. The staging job uses its network app build. + +Local and staging jobs have three visible steps: `Run E2E Tests 1`, `2`, and `3`. They stop on success; `ciIt()` skips cases passed earlier in the same run so only failed or uncompleted cases repeat. The final report combines current-run passes, retains first-failure evidence, and fails the job when all three attempts fail. Missing selected cases or failed selected jobs fail the aggregate status. Builds are capped at 40 minutes; execution budgets depend on the selected pack as described below. Diagnostic artifacts, including RN wallet preparation logs, expire after seven days; prepared wallet environments expire after one day. Compare end-to-end duration and billed Mac/backend usage before making two shards automatic. + +Land the E2E producer first. Validate these branches before merging this consumer. The migration fixture workflow is pinned to the producer commit that supports immutable source selection, allowing full branch validation before either PR merges. There is no shadow workflow and no additional smoke-test replacement. + +Local shards with 6–30 cases allow 90 minutes per attempt within a 285-minute job and five-hour backend expiry. Larger shards allow 110 minutes per attempt within a 345-minute job and six-hour backend expiry. Both sizes allow three attempts. Base-sized local selections retain 40-minute attempts and a 135-minute job; the six-case staging pack also uses 90-minute attempts within 285 minutes. Individual migration cases retain 40-minute attempts and 135-minute jobs. The iOS selector owns these budgets and the shared runner seals its attempt limit into the run plan. Limits are ceilings, not reservations: success stops retries and cleanup destroys the backend immediately.