diff --git a/Telegram/Tests/Sources/UITests.swift b/Telegram/Tests/Sources/UITests.swift index b58771f356..275f7b5aab 100644 --- a/Telegram/Tests/Sources/UITests.swift +++ b/Telegram/Tests/Sources/UITests.swift @@ -1,4 +1,5 @@ import XCTest +import Foundation class UITests: XCTestCase { private var app: XCUIApplication! @@ -13,12 +14,24 @@ class UITests: XCTestCase { app = nil } + /// Deletes a test account so the sign-up flow can be exercised again. + /// Launches the app with `--delete-test-account` which logs in and deletes the account. + private func deleteTestAccount(phone: String) { + let cleanupApp = XCUIApplication() + cleanupApp.launchArguments += ["--ui-test", "--delete-test-account", phone] + cleanupApp.launch() + let success = cleanupApp.windows["DeleteAccount.Success"] + XCTAssert(success.waitForExistence(timeout: 30), "test account cleanup did not complete in 30s") + cleanupApp.terminate() + } + func testLaunch() throws { app.launch() XCTAssert(app.wait(for: .runningForeground, timeout: 10.0)) } func testLoginToSetName() throws { + deleteTestAccount(phone: "9996625678") app.launch() // Welcome screen — tap Start Messaging diff --git a/build-system/test-helper/Package.resolved b/build-system/test-helper/Package.resolved deleted file mode 100644 index b352e4b6a2..0000000000 --- a/build-system/test-helper/Package.resolved +++ /dev/null @@ -1,23 +0,0 @@ -{ - "pins" : [ - { - "identity" : "tdlibframework", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Swiftgram/TDLibFramework", - "state" : { - "revision" : "fdda50b9335171329237fab2381cbc2e6e3ce86c", - "version" : "1.8.60-cb863c16" - } - }, - { - "identity" : "tdlibkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Swiftgram/TDLibKit", - "state" : { - "revision" : "245888f853f5e304029f4fa423af14a045476f30", - "version" : "1.5.2-tdlib-1.8.60-cb863c16" - } - } - ], - "version" : 2 -} diff --git a/build-system/test-helper/Package.swift b/build-system/test-helper/Package.swift deleted file mode 100644 index e8635f1045..0000000000 --- a/build-system/test-helper/Package.swift +++ /dev/null @@ -1,16 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "test-helper", - platforms: [.macOS(.v13)], - dependencies: [ - .package(url: "https://github.com/Swiftgram/TDLibKit", exact: "1.5.2-tdlib-1.8.60-cb863c16"), - ], - targets: [ - .executableTarget( - name: "test-helper", - dependencies: ["TDLibKit"] - ), - ] -) diff --git a/build-system/test-helper/Sources/main.swift b/build-system/test-helper/Sources/main.swift deleted file mode 100644 index 944ee672b1..0000000000 --- a/build-system/test-helper/Sources/main.swift +++ /dev/null @@ -1,165 +0,0 @@ -import Foundation -import TDLibKit -import TDLibFramework - -// MARK: - Argument parsing - -func printUsage() -> Never { - fputs("Usage: test-helper delete-account --api-id --api-hash --phone \n", stderr) - exit(1) -} - -func parseArgs() -> (apiId: Int, apiHash: String, phone: String) { - let args = CommandLine.arguments - guard args.count >= 2, args[1] == "delete-account" else { printUsage() } - - var apiId: Int? - var apiHash: String? - var phone: String? - - var i = 2 - while i < args.count { - switch args[i] { - case "--api-id": - i += 1; guard i < args.count, let v = Int(args[i]) else { printUsage() } - apiId = v - case "--api-hash": - i += 1; guard i < args.count else { printUsage() } - apiHash = args[i] - case "--phone": - i += 1; guard i < args.count else { printUsage() } - phone = args[i] - default: - printUsage() - } - i += 1 - } - - guard let apiId, let apiHash, let phone else { printUsage() } - return (apiId, apiHash, phone) -} - -// MARK: - Phone number validation - -/// Parses a test phone number (format: 99966XYYYY or +99966XYYYY). -/// Returns (fullNumber with + prefix, verification code). -func parseTestPhone(_ phone: String) -> (fullNumber: String, code: String)? { - let digits = phone.hasPrefix("+") ? String(phone.dropFirst()) : phone - guard digits.count == 10, digits.hasPrefix("99966") else { return nil } - let dcDigit = digits[digits.index(digits.startIndex, offsetBy: 5)] - guard dcDigit >= "1", dcDigit <= "3" else { return nil } - let code = String(repeating: dcDigit, count: 5) - let fullNumber = "+\(digits)" - return (fullNumber, code) -} - -// MARK: - TDLib account deletion - -func deleteTestAccount(apiId: Int, apiHash: String, phone: String, code: String) async throws { - let tmpDir = FileManager.default.temporaryDirectory - .appendingPathComponent("test-helper-\(ProcessInfo.processInfo.processIdentifier)") - try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tmpDir) } - - // Suppress TDLib's verbose C++ logging - td_execute("{\"@type\":\"setLogVerbosityLevel\",\"new_verbosity_level\":0}") - - let manager = TDLibClientManager() - defer { manager.closeClients() } - - let authState = AsyncStream.makeStream(of: AuthorizationState.self) - - let client = manager.createClient { data, client in - guard let update = try? client.decoder.decode(Update.self, from: data) else { return } - if case .updateAuthorizationState(let s) = update { - authState.continuation.yield(s.authorizationState) - } - } - - for await state in authState.stream { - switch state { - case .authorizationStateWaitTdlibParameters: - try await client.setTdlibParameters( - apiHash: apiHash, - apiId: apiId, - applicationVersion: "1.0", - databaseDirectory: tmpDir.path, - databaseEncryptionKey: Data(), - deviceModel: "test-helper", - filesDirectory: tmpDir.appendingPathComponent("files").path, - systemLanguageCode: "en", - systemVersion: "macOS", - useChatInfoDatabase: false, - useFileDatabase: false, - useMessageDatabase: false, - useSecretChats: false, - useTestDc: true - ) - - case .authorizationStateWaitPhoneNumber: - try await client.setAuthenticationPhoneNumber( - phoneNumber: phone, - settings: PhoneNumberAuthenticationSettings?.none - ) - - case .authorizationStateWaitCode: - try await client.checkAuthenticationCode(code: code) - - case .authorizationStateWaitRegistration: - print("No account for \(phone) (sign-up requested). Nothing to delete.") - authState.continuation.finish() - return - - case .authorizationStateReady: - print("Logged in. Deleting account...") - try await client.deleteAccount(password: String?.none, reason: "test cleanup") - print("Account deleted.") - authState.continuation.finish() - return - - case .authorizationStateClosed: - authState.continuation.finish() - return - - default: - fputs("Unexpected auth state: \(state)\n", stderr) - authState.continuation.finish() - throw NSError(domain: "test-helper", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Unexpected auth state" - ]) - } - } -} - -// MARK: - Main - -let (apiId, apiHash, phone) = parseArgs() - -guard let parsed = parseTestPhone(phone) else { - fputs("Error: phone must match format 99966XYYYY (X = 1, 2, or 3)\n", stderr) - exit(1) -} - -do { - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - try await deleteTestAccount(apiId: apiId, apiHash: apiHash, phone: parsed.fullNumber, code: parsed.code) - } - group.addTask { - try await Task.sleep(for: .seconds(30)) - throw NSError(domain: "test-helper", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Timed out after 30 seconds" - ]) - } - try await group.next() - group.cancelAll() - } -} catch let error as TDLibKit.Error { - fputs("Error: [\(error.code)] \(error.message)\n", stderr) - exit(1) -} catch { - fputs("Error: \(error)\n", stderr) - exit(1) -} - -exit(0) diff --git a/docs/ui-testing.md b/docs/ui-testing.md index b8a4857d40..f7e47ccdf0 100644 --- a/docs/ui-testing.md +++ b/docs/ui-testing.md @@ -123,17 +123,23 @@ Test numbers are still subject to flood limits. If a number gets rate-limited, p ### Deleting Test Accounts -Tests that exercise the sign-up flow create an account on the test servers. Re-running the same test with the same phone number will skip sign-up because the account already exists. To get a fresh sign-up screen, delete the account first with the `test-helper` CLI: +Tests that exercise the sign-up flow create an account on the test servers. Re-running the same test with the same phone number will skip sign-up because the account already exists. To get a fresh sign-up screen, delete the account before running the test. -```bash -cd build-system/test-helper -swift run test-helper delete-account \ - --api-id --api-hash --phone 9996625678 +The app supports a `--delete-test-account ` launch argument. When combined with `--ui-test`, the app logs into the test account, deletes it, and exits — no UI is shown. The verification code is derived automatically from the phone number (DC digit repeated 5 times). + +In UI tests, use this by launching a separate app instance: + +```swift +private func deleteTestAccount(phone: String) { + let cleanupApp = XCUIApplication() + cleanupApp.launchArguments += ["--ui-test", "--delete-test-account", phone] + cleanupApp.launch() + let terminated = cleanupApp.wait(for: .notRunning, timeout: 30) + XCTAssert(terminated, "test account cleanup did not complete in 30s") +} ``` -The tool connects to the test datacenters, authenticates with the given number, and deletes the account. If no account exists, it exits successfully. Run it before any test that needs a clean sign-up flow. - -The `--api-id` and `--api-hash` are your Telegram API credentials from [my.telegram.org](https://my.telegram.org). +Call `deleteTestAccount(phone:)` at the start of any test that needs a clean sign-up flow. The phone number format is `99966XYYYY` (no `+` prefix needed). ### Security diff --git a/submodules/TelegramCore/Sources/Authorization.swift b/submodules/TelegramCore/Sources/Authorization.swift index b3703ada16..28694f3480 100644 --- a/submodules/TelegramCore/Sources/Authorization.swift +++ b/submodules/TelegramCore/Sources/Authorization.swift @@ -1623,3 +1623,146 @@ func _internal_reportMissingCode(network: Network, phoneNumber: String, phoneCod } } +public enum TestLoginAndDeleteAccountError { + case generic +} + +public func test_loginAndDeleteAccount( + rootPath: String, + accountManager: AccountManager, + networkArguments: NetworkInitializationArguments, + encryptionParameters: ValueBoxEncryptionParameters, + phoneNumber: String, + phoneCode: String +) -> Signal { + Logger.shared.logToConsole = true + + return accountManager.transaction{ transaction -> AccountRecordId? in + let record = transaction.createAuth([.environment(AccountEnvironmentAttribute(environment: .test))]) + return record?.id + } + |> castError(TestLoginAndDeleteAccountError.self) + |> mapToSignal { accountId -> Signal in + guard let accountId else { + preconditionFailure("Account not found") + } + return accountWithId( + accountManager: accountManager, + networkArguments: networkArguments, + id: accountId, + encryptionParameters: encryptionParameters, + supplementary: true, + isSupportUser: false, + rootPath: rootPath, + beginWithTestingEnvironment: true, + backupData: nil, + auxiliaryMethods: AccountAuxiliaryMethods(fetchResource: { _, _, _, _ in + return nil + }, fetchResourceMediaReferenceHash: { resource in + return .single(nil) + }, prepareSecretThumbnailData: { data in + return nil + }, backgroundUpload: { postbox, _, resource in + return .single(nil) + }) + ) + |> castError(TestLoginAndDeleteAccountError.self) + |> mapToSignal { account -> Signal in + switch account { + case .upgrading: + preconditionFailure("Unexpected account state: upgrading") + case let .unauthorized(account): + return .single(account) + case .authorized: + preconditionFailure("Unexpected account state: authorized") + } + } + } + |> mapToSignal { account -> Signal<(UnauthorizedAccount, UnauthorizedAccountStateContents), TestLoginAndDeleteAccountError> in + account.network.shouldKeepConnection.set(.single(true)) + + return sendAuthorizationCode( + accountManager: accountManager, + account: account, + phoneNumber: phoneNumber, + apiId: networkArguments.apiId, + apiHash: networkArguments.apiHash, + pushNotificationConfiguration: nil, + firebaseSecretStream: .never(), + syncContacts: false, + forcedPasswordSetupNotice: { _ in nil } + ) + |> mapError { _ -> TestLoginAndDeleteAccountError in + return .generic + } + |> mapToSignal { result -> Signal<(UnauthorizedAccount, UnauthorizedAccountStateContents), TestLoginAndDeleteAccountError> in + switch result { + case .loggedIn: + preconditionFailure("Unexpected send code state: logged in") + case let .sentCode(account): + return account.postbox.transaction { transaction -> UnauthorizedAccountStateContents? in + guard let state = transaction.getState() as? UnauthorizedAccountState else { + return nil + } + return state.contents + } + |> castError(TestLoginAndDeleteAccountError.self) + |> mapToSignal { state -> Signal<(UnauthorizedAccount, UnauthorizedAccountStateContents), TestLoginAndDeleteAccountError> in + guard let state else { + preconditionFailure("Unexpected account state: nil") + } + return .single((account, state)) + } + } + } + } + |> mapToSignal { account, state -> Signal<(UnauthorizedAccount, AuthorizeWithCodeResult), TestLoginAndDeleteAccountError> in + account.network.shouldKeepConnection.set(.single(true)) + + switch state { + case let .confirmationCodeEntry(_, type, _, _, _, _, _, _): + switch type { + case let .call(length), let .sms(length), let .otherSession(length): + if phoneCode.count != length { + preconditionFailure("Unexpected sent code length: \(length) != \(phoneCode.count)") + } + + return authorizeWithCode( + accountManager: accountManager, + account: account, + code: .phoneCode(phoneCode), + termsOfService: nil, + forcedPasswordSetupNotice: { _ in nil } + ) + |> mapError { _ -> TestLoginAndDeleteAccountError in + return .generic + } + |> mapToSignal { result -> Signal<(UnauthorizedAccount, AuthorizeWithCodeResult), TestLoginAndDeleteAccountError> in + return .single((account, result)) + } + default: + preconditionFailure("Unexpected sent code type: \(type)") + } + default: + preconditionFailure("Unexpected account state: \(state)") + } + } + |> mapToSignal { account, checkCodeResult -> Signal in + switch checkCodeResult { + case .signUp: + return .complete() + case .loggedIn: + return account.network.request(Api.functions.account.deleteAccount( + flags: 0, + reason: "", + password: nil + )) + |> mapError { _ -> TestLoginAndDeleteAccountError in + return .generic + } + |> mapToSignal { _ -> Signal in + return .complete() + } + } + } +} diff --git a/submodules/TelegramUI/Sources/AppDelegate.swift b/submodules/TelegramUI/Sources/AppDelegate.swift index 79d469e26d..9cbb14ad50 100644 --- a/submodules/TelegramUI/Sources/AppDelegate.swift +++ b/submodules/TelegramUI/Sources/AppDelegate.swift @@ -999,7 +999,35 @@ private func extractAccountManagerState(records: AccountRecordsView