diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f4a651054..d0abea74f 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -13,4 +13,5 @@ jobs: name: Shared checks uses: ./.github/workflows/shared-checks.yml with: - run_integration_tests: false + run_integration_tests: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'sean/Explore-E2E-tests' }} + secrets: inherit diff --git a/.github/workflows/e2e-branch-checks.yml b/.github/workflows/e2e-branch-checks.yml new file mode 100644 index 000000000..a72f69be8 --- /dev/null +++ b/.github/workflows/e2e-branch-checks.yml @@ -0,0 +1,25 @@ +name: E2E Branch Checks + +on: + workflow_dispatch: + inputs: + e2e_key_name: + description: Named .keys.json publishable key used by E2EHost tests + required: true + type: choice + default: with-email-codes + options: + - with-email-codes + - with-session-tasks-setup-mfa + +permissions: + contents: read + +jobs: + checks: + name: Shared checks with E2E + uses: ./.github/workflows/shared-checks.yml + with: + run_integration_tests: true + e2e_key_name: ${{ inputs.e2e_key_name }} + secrets: inherit diff --git a/.github/workflows/shared-checks.yml b/.github/workflows/shared-checks.yml index 7f42e8177..39cfdb337 100644 --- a/.github/workflows/shared-checks.yml +++ b/.github/workflows/shared-checks.yml @@ -8,6 +8,11 @@ on: required: false type: boolean default: false + e2e_key_name: + description: Named .keys.json publishable key used by E2EHost tests + required: false + type: string + default: with-email-codes secrets: CLERK_TEST_KEYS_JSON: required: false @@ -115,9 +120,100 @@ jobs: - name: Run ClerkKitUI tests run: make test-ui + test-e2e: + name: Run E2EHost tests on iOS + needs: format-and-lint + runs-on: macos-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Verify macOS and Xcode version + run: | + sw_vers + xcodebuild -version + + - name: Cache Swift dependencies + uses: actions/cache@v4 + with: + path: .build + key: swiftpm-e2e-${{ hashFiles('Package.resolved') }} + restore-keys: swiftpm-e2e- + + - name: Skip E2E tests + if: ${{ !inputs.run_integration_tests }} + run: echo "E2EHost tests run only when run_integration_tests is true." + + - name: Setup E2E test keys + if: inputs.run_integration_tests + env: + CLERK_TEST_KEYS_JSON: ${{ secrets.CLERK_TEST_KEYS_JSON }} + run: | + set +x + if [ -z "$CLERK_TEST_KEYS_JSON" ]; then + echo "❌ Error: CLERK_TEST_KEYS_JSON secret is not set" + echo " Please configure CLERK_TEST_KEYS_JSON secret in repository settings" + exit 1 + fi + + printf '%s\n' "$CLERK_TEST_KEYS_JSON" > .keys.json + chmod 600 .keys.json + echo "✅ Created .keys.json from CLERK_TEST_KEYS_JSON secret" + + - name: Validate E2E test keys + if: inputs.run_integration_tests + env: + CLERK_E2E_KEY_NAME: ${{ inputs.e2e_key_name }} + run: | + set -euo pipefail + + required_keys="$(printf '%s\n' \ + "with-email-codes" \ + "with-session-tasks-setup-mfa" \ + "$CLERK_E2E_KEY_NAME" \ + | sort -u)" + + missing=0 + while IFS= read -r key_name; do + if ! jq -e --arg key_name "$key_name" '.[$key_name].pk | strings | select(length > 0)' .keys.json > /dev/null; then + echo "::error::Missing $key_name.pk in CLERK_TEST_KEYS_JSON." + missing=1 + fi + done <<< "$required_keys" + + if [ "$missing" -ne 0 ]; then + echo "Add the missing key to 1Password, then sync GitHub with:" + echo " make sync-test-keys-to-github" + exit 1 + fi + + echo "✅ Required E2E test keys are configured" + + - name: Run E2EHost tests + if: inputs.run_integration_tests + env: + CLERK_E2E_KEY_NAME: ${{ inputs.e2e_key_name }} + run: | + set -euo pipefail + make test-e2e + + - name: Upload E2EHost result bundle + if: failure() + uses: actions/upload-artifact@v4 + with: + name: E2EHost.xcresult + path: build/reports/E2EHost.xcresult + if-no-files-found: ignore + + - name: Cleanup E2E test keys + if: always() && inputs.run_integration_tests + run: | + rm -f .keys.json + echo "✅ Cleaned up .keys.json" + build: name: Build ${{ matrix.platform }} - needs: [test, test-ui] + needs: [test, test-ui, test-e2e] runs-on: macos-latest strategy: matrix: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2423ce9c7..a4506e559 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,6 +79,7 @@ After running `make setup`, you're ready to start developing! - `make check` - Run both format-check and lint (for CI) - `make test` - Run `ClerkKitTests` on macOS - `make test-ui` - Run `ClerkKitUITests` on iOS Simulator +- `make test-e2e` - Run E2EHost tests on iOS Simulator - `make test-integration` - Run only integration tests (requires `.keys.json` file; Clerk employees only) - `make fetch-test-keys` - Fetch integration test keys from 1Password (optional, for Clerk employees only; auto-installs CLI if needed) @@ -120,7 +121,7 @@ SwiftLint checks for: ## Testing -This project uses **Swift Testing** (not XCTest) for all tests. Tests are organized into two categories: +This project uses **Swift Testing** for package unit and integration tests, and **XCTest/XCUITest** for app-level E2E UI automation. Tests are organized into three categories: ### Unit and UI Tests @@ -191,6 +192,47 @@ Each test method must call `configureClerkForIntegrationTesting(keyName:)` at th - Integration tests may be slower than unit tests due to real network calls - Some tests may be flaky due to network conditions - consider retrying +### E2EHost Tests + +E2E tests live in `Examples/E2EHost/` and run a dedicated SwiftUI test host app on an iOS Simulator with XCUITest. The host app exists only for release-gating E2E coverage, keeping product-facing examples such as Quickstart free of test-only controls and launch configuration. By default, these tests use the same Clerk test instance as the `with-email-codes` integration tests. + +**Running E2E tests (Clerk employees only):** +```bash +make fetch-test-keys +make test-e2e +``` + +If CI is missing a named test key, add it to the 1Password item, then sync the GitHub Actions snapshot: +```bash +make sync-test-keys-to-github +``` + +You can also provide a key directly: +```bash +CLERK_E2E_PUBLISHABLE_KEY=pk_test_... make test-e2e +``` + +To run against a different named test instance from `.keys.json`: +```bash +CLERK_E2E_KEY_NAME=with-session-tasks-setup-mfa make test-e2e +``` +If omitted, `CLERK_E2E_KEY_NAME` defaults to `with-email-codes`. +Session-task examples include `with-session-tasks`, `with-session-tasks-reset-password`, and `with-session-tasks-setup-mfa`. + +To choose a specific simulator: +```bash +IOS_SIMULATOR_DESTINATION='platform=iOS Simulator,name=iPhone 16' make test-e2e +``` + +**Requirements:** +- Network access +- Valid publishable key in `.keys.json` for `CLERK_E2E_KEY_NAME` or `CLERK_E2E_PUBLISHABLE_KEY` +- iOS Simulator available through `xcrun simctl` + +The test runner writes its result bundle to `build/reports/E2EHost.xcresult`. In CI, this bundle is uploaded on failure. AI tools may help draft page objects, test flows, and accessibility ID changes, but generated tests must use the approved accessibility identifiers and be reviewed like production code. Maestro can be useful for exploratory mobile QA, but XCUITest is the release-gating E2E layer. + +E2E cleanup uses the normal host-level delete-account control when possible. If a failure leaves the test inside an auth sheet or pending session-task screen, teardown relaunches `E2EHost` with the same keychain service and an E2E-only cleanup-on-launch flag so the app can delete the restored user without exposing a visible cleanup button. + ## Releasing (Maintainers) SDK releases can be published through the **Release SDK** GitHub Actions workflow: diff --git a/Examples/E2EHost/E2EHost/E2EConfiguration.swift b/Examples/E2EHost/E2EHost/E2EConfiguration.swift new file mode 100644 index 000000000..013d736e9 --- /dev/null +++ b/Examples/E2EHost/E2EHost/E2EConfiguration.swift @@ -0,0 +1,73 @@ +// +// E2EConfiguration.swift +// E2EHost +// + +import ClerkKit +import ClerkKitUI +import Foundation + +struct E2EConfiguration { + let publishableKey: String + let authMode: AuthView.Mode + let keychainService: String? + let cleanupOnLaunch: Bool + + init( + publishableKey: String, + authMode: AuthView.Mode, + keychainService: String?, + cleanupOnLaunch: Bool + ) { + self.publishableKey = publishableKey + self.authMode = authMode + self.keychainService = keychainService + self.cleanupOnLaunch = cleanupOnLaunch + } + + init(processInfo: ProcessInfo = .processInfo) { + let environment = processInfo.environment + + publishableKey = Self.normalized(environment["CLERK_PUBLISHABLE_KEY"]) + ?? Self.normalized(environment["CLERK_E2E_PUBLISHABLE_KEY"]) + ?? "" + authMode = Self.authMode(from: environment["CLERK_E2E_AUTH_MODE"]) + keychainService = Self.normalized(environment["CLERK_E2E_KEYCHAIN_SERVICE"]) + cleanupOnLaunch = environment["CLERK_E2E_CLEANUP_ON_LAUNCH"] == "1" + } + + var clerkOptions: Clerk.Options { + guard let keychainService else { + return Clerk.Options() + } + + return Clerk.Options( + keychainConfig: .init(service: keychainService) + ) + } + + private static func authMode(from value: String?) -> AuthView.Mode { + guard let value = normalized(value), let authMode = AuthView.Mode(rawValue: value) else { + return .signInOrUp + } + + return authMode + } + + private static func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + return value + } +} + +extension E2EConfiguration { + static let mock = E2EConfiguration( + publishableKey: "", + authMode: .signInOrUp, + keychainService: nil, + cleanupOnLaunch: false + ) +} diff --git a/Examples/E2EHost/E2EHost/E2EHostApp.swift b/Examples/E2EHost/E2EHost/E2EHostApp.swift new file mode 100644 index 000000000..bf89a299e --- /dev/null +++ b/Examples/E2EHost/E2EHost/E2EHostApp.swift @@ -0,0 +1,28 @@ +// +// E2EHostApp.swift +// E2EHost +// + +import ClerkKit +import ClerkKitUI +import SwiftUI + +@main +struct E2EHostApp: App { + private let configuration = E2EConfiguration() + + init() { + Clerk.configure( + publishableKey: configuration.publishableKey, + options: configuration.clerkOptions + ) + } + + var body: some Scene { + WindowGroup { + E2EHostView(configuration: configuration) + .prefetchClerkImages() + .environment(Clerk.shared) + } + } +} diff --git a/Examples/E2EHost/E2EHost/E2EHostView.swift b/Examples/E2EHost/E2EHost/E2EHostView.swift new file mode 100644 index 000000000..6e156d6de --- /dev/null +++ b/Examples/E2EHost/E2EHost/E2EHostView.swift @@ -0,0 +1,132 @@ +// +// E2EHostView.swift +// E2EHost +// + +import ClerkKit +import ClerkKitUI +import SwiftUI + +struct E2EHostView: View { + @Environment(Clerk.self) private var clerk + + let configuration: E2EConfiguration + + @State private var authViewIsPresented = false + @State private var cleanupOnLaunchDidComplete = false + @State private var cleanupOnLaunchDidStart = false + + init(configuration: E2EConfiguration) { + self.configuration = configuration + } + + var body: some View { + VStack(spacing: 24) { + UserButton(signedOutContent: { + Button("Sign in") { + authViewIsPresented = true + } + }) + + e2eControls + } + .task(id: clerk.user?.id) { + await cleanupAccountOnLaunchIfNeeded() + } + .sheet(isPresented: $authViewIsPresented) { + AuthView(mode: configuration.authMode) + .persistsIdentifiers(false) + } + } + + @ViewBuilder + private var e2eControls: some View { + if cleanupOnLaunchDidComplete { + Text("Cleanup complete") + .accessibilityIdentifier(E2EIdentifiers.Auth.cleanupComplete) + } + + if clerk.user != nil { + Text("Signed in") + .accessibilityIdentifier(E2EIdentifiers.Auth.signedIn) + + sessionState + + Button("Sign out") { + signOut() + } + .accessibilityIdentifier(E2EIdentifiers.Auth.signOut) + + Button("Delete account", role: .destructive) { + deleteAccount() + } + .accessibilityIdentifier(E2EIdentifiers.Auth.deleteAccount) + } else { + Text("Signed out") + .accessibilityIdentifier(E2EIdentifiers.Auth.signedOut) + } + } + + @ViewBuilder + private var sessionState: some View { + if let session = clerk.session { + Text(session.status.rawValue) + .accessibilityIdentifier(E2EIdentifiers.Auth.sessionStatus) + + switch session.status { + case .active: + Text("Session active") + .accessibilityIdentifier(E2EIdentifiers.Auth.sessionActive) + case .pending: + Text("Session pending") + .accessibilityIdentifier(E2EIdentifiers.Auth.sessionPending) + default: + EmptyView() + } + + let tasks = session.tasks ?? [] + if !tasks.isEmpty { + Text(tasks.map(\.rawValue).joined(separator: ",")) + .accessibilityIdentifier(E2EIdentifiers.Auth.pendingTasks) + } + } + } + + private func signOut() { + Task { + try? await clerk.auth.signOut() + authViewIsPresented = false + } + } + + private func deleteAccount() { + Task { + _ = try? await clerk.user?.delete() + } + } + + @MainActor + private func cleanupAccountOnLaunchIfNeeded() async { + guard configuration.cleanupOnLaunch, !cleanupOnLaunchDidStart, let user = clerk.user else { + return + } + + cleanupOnLaunchDidStart = true + if await (try? user.delete()) != nil { + try? await clerk.auth.signOut() + } + cleanupOnLaunchDidComplete = true + } +} + +#Preview("Signed Out") { + E2EHostView(configuration: .mock) + .environment(Clerk.preview { preview in + preview.isSignedIn = false + }) +} + +#Preview("Signed In") { + E2EHostView(configuration: .mock) + .environment(Clerk.preview()) +} diff --git a/Examples/E2EHost/E2EHost/E2EIdentifiers.swift b/Examples/E2EHost/E2EHost/E2EIdentifiers.swift new file mode 100644 index 000000000..49270a612 --- /dev/null +++ b/Examples/E2EHost/E2EHost/E2EIdentifiers.swift @@ -0,0 +1,18 @@ +// +// E2EIdentifiers.swift +// E2EHost +// + +enum E2EIdentifiers { + enum Auth { + static let signedIn = "e2e.auth.signedIn" + static let signedOut = "e2e.auth.signedOut" + static let sessionActive = "e2e.auth.sessionActive" + static let sessionPending = "e2e.auth.sessionPending" + static let sessionStatus = "e2e.auth.sessionStatus" + static let pendingTasks = "e2e.auth.pendingTasks" + static let cleanupComplete = "e2e.auth.cleanupComplete" + static let signOut = "e2e.auth.signOut" + static let deleteAccount = "e2e.auth.deleteAccount" + } +} diff --git a/Examples/E2EHost/E2EHostE2ETests/E2EHostE2ETests.swift b/Examples/E2EHost/E2EHostE2ETests/E2EHostE2ETests.swift new file mode 100644 index 000000000..162a1cb62 --- /dev/null +++ b/Examples/E2EHost/E2EHostE2ETests/E2EHostE2ETests.swift @@ -0,0 +1,625 @@ +// +// E2EHostE2ETests.swift +// E2EHostE2ETests +// + +import CryptoKit +import Foundation +import XCTest + +private struct E2ELaunchConfiguration { + let authMode: String + let publishableKey: String + let publishableKeyName: String + let keychainService: String +} + +final class E2EHostE2ETests: XCTestCase { + private static let defaultPublishableKeyName = "with-email-codes" + private static let setupMfaPublishableKeyName = "with-session-tasks-setup-mfa" + + private let verificationCode = "424242" + private let testPassword = "ClerkIOS2026E2ETestPassword9!" + + private var app: XCUIApplication? + private var cleanupLaunchConfiguration: E2ELaunchConfiguration? + + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + if let app, let testRun, testRun.failureCount > 0 { + add(XCTAttachment(screenshot: app.screenshot())) + } + + if let app { + deleteAccountIfPossible(in: app) + cleanupAccountOnLaunchIfNeeded(after: app) + } + + app?.terminate() + app = nil + cleanupLaunchConfiguration = nil + } + + func testEmailCodeSignUpThenPasswordSignIn() throws { + let publishableKey = try requiredPublishableKey(named: Self.defaultPublishableKeyName) + let email = Self.makeUniqueTestEmail() + let keychainService = "com.clerk.E2EHost.\(UUID().uuidString)" + + app = launchApp( + authMode: "signUp", + publishableKey: publishableKey, + publishableKeyName: Self.defaultPublishableKeyName, + keychainService: keychainService + ) + guard let signUpApp = app else { return } + + openAuth(in: signUpApp) + completeEmailCodeSignUp(email: email, in: signUpApp) + waitForSignedIn(in: signUpApp) + dismissSavePasswordPromptIfPresent(in: signUpApp) + + tap(E2EIdentifier.signOut, in: signUpApp) + waitForSignedOut(in: signUpApp) + signUpApp.terminate() + + app = launchApp( + authMode: "signIn", + publishableKey: publishableKey, + publishableKeyName: Self.defaultPublishableKeyName, + keychainService: keychainService + ) + guard let signInApp = app else { return } + + openAuth(in: signInApp) + enterText(email, into: E2EIdentifier.authStartIdentifier, in: signInApp) + tap(E2EIdentifier.authStartContinue, in: signInApp) + enterText(testPassword, into: E2EIdentifier.signInPassword, in: signInApp) + tap(E2EIdentifier.signInContinue, in: signInApp) + waitForSignedIn(in: signInApp) + dismissSavePasswordPromptIfPresent(in: signInApp) + + tap(E2EIdentifier.deleteAccount, in: signInApp) + waitForSignedOut(in: signInApp) + } + + func testSignUpCompletesSetupMfaTask() throws { + let publishableKey = try requiredPublishableKey(named: Self.setupMfaPublishableKeyName) + let email = Self.makeUniqueTestEmail() + let keychainService = "com.clerk.E2EHost.\(UUID().uuidString)" + + app = launchApp( + authMode: "signUp", + publishableKey: publishableKey, + publishableKeyName: Self.setupMfaPublishableKeyName, + keychainService: keychainService + ) + guard let signUpApp = app else { return } + + openAuth(in: signUpApp) + completeEmailCodeSignUp(email: email, in: signUpApp) + waitForSignedIn(in: signUpApp) + waitForSessionPending(in: signUpApp) + assertPendingTasksContain("setup-mfa", in: signUpApp) + dismissSavePasswordPromptIfPresent(in: signUpApp) + + try completeAuthenticatorAppSetup(in: signUpApp) + waitForSessionActive(in: signUpApp) + + tap(E2EIdentifier.deleteAccount, in: signUpApp) + waitForSignedOut(in: signUpApp) + } + + func testCleanupOnLaunchDeletesRestoredPendingUser() throws { + let publishableKey = try requiredPublishableKey(named: Self.setupMfaPublishableKeyName) + let email = Self.makeUniqueTestEmail() + let keychainService = "com.clerk.E2EHost.\(UUID().uuidString)" + let configuration = E2ELaunchConfiguration( + authMode: "signUp", + publishableKey: publishableKey, + publishableKeyName: Self.setupMfaPublishableKeyName, + keychainService: keychainService + ) + + cleanupLaunchConfiguration = configuration + app = launchApp(configuration: configuration) + guard let signUpApp = app else { return } + waitForSignedOut(in: signUpApp) + + openAuth(in: signUpApp) + completeEmailCodeSignUp(email: email, in: signUpApp) + waitForSignedIn(in: signUpApp) + waitForSessionPending(in: signUpApp) + dismissSavePasswordPromptIfPresent(in: signUpApp) + signUpApp.terminate() + + let cleanupApp = launchApp(configuration: configuration, cleanupOnLaunch: true) + app = cleanupApp + waitForCleanupComplete(in: cleanupApp) + waitForSignedOut(in: cleanupApp) + } +} + +extension E2EHostE2ETests { + fileprivate enum E2EIdentifier { + static let authStartIdentifier = "clerk.auth.start.identifier" + static let authStartContinue = "clerk.auth.start.continue" + static let signUpCode = "clerk.auth.signUp.code" + static let signUpPassword = "clerk.auth.signUp.password" + static let signUpContinue = "clerk.auth.signUp.continue" + static let signInPassword = "clerk.auth.signIn.password" + static let signInContinue = "clerk.auth.signIn.continue" + static let signedIn = "e2e.auth.signedIn" + static let signedOut = "e2e.auth.signedOut" + static let sessionActive = "e2e.auth.sessionActive" + static let sessionPending = "e2e.auth.sessionPending" + static let sessionStatus = "e2e.auth.sessionStatus" + static let pendingTasks = "e2e.auth.pendingTasks" + static let cleanupComplete = "e2e.auth.cleanupComplete" + static let signOut = "e2e.auth.signOut" + static let deleteAccount = "e2e.auth.deleteAccount" + static let setupMfaAuthenticatorApp = "clerk.auth.sessionTask.setupMfa.authenticatorApp" + static let totpSecret = "clerk.auth.sessionTask.totp.secret" + static let totpContinue = "clerk.auth.sessionTask.totp.continue" + static let totpCode = "clerk.auth.sessionTask.totp.code" + static let backupCodesContinue = "clerk.auth.sessionTask.backupCodes.continue" + } + + fileprivate enum TOTPError: Error { + case invalidSecret + } + + fileprivate static func makeUniqueTestEmail() -> String { + let suffix = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + return "clerk_ios_e2e+clerk_test_\(suffix)@example.com" + } + + private func requiredPublishableKey(named keyName: String) throws -> String { + let environment = ProcessInfo.processInfo.environment + let selectedKeyName = Self.publishableKeyName(environment: environment) + + if selectedKeyName == keyName { + if let publishableKey = Self.normalized(environment["CLERK_PUBLISHABLE_KEY"]) + ?? Self.normalized(environment["CLERK_E2E_PUBLISHABLE_KEY"]) + ?? Self.publishableKeyFromGeneratedFile(matching: keyName) + { + return publishableKey + } + } + + if let publishableKey = Self.publishableKeyFromKeysFile(keyName: keyName) { + return publishableKey + } + + throw XCTSkip("Configure '\(keyName).pk' in .keys.json.") + } + + private static func publishableKeyName(environment: [String: String]) -> String { + normalized(environment["CLERK_E2E_KEY_NAME"]) + ?? publishableKeyNameFromGeneratedFile() + ?? defaultPublishableKeyName + } + + private static func publishableKeyNameFromGeneratedFile(sourceFilePath: String = #filePath) -> String? { + let keyURL = repositoryRoot(sourceFilePath: sourceFilePath) + .appendingPathComponent("build/reports/E2EHostPublishableKeyName.txt") + + guard + let contents = try? String(contentsOf: keyURL, encoding: .utf8), + let keyName = normalized(contents) + else { + return nil + } + + return keyName + } + + private static func publishableKeyFromGeneratedFile( + matching keyName: String, + sourceFilePath: String = #filePath + ) -> String? { + guard publishableKeyNameFromGeneratedFile(sourceFilePath: sourceFilePath) == keyName else { + return nil + } + + let keyURL = repositoryRoot(sourceFilePath: sourceFilePath) + .appendingPathComponent("build/reports/E2EHostPublishableKey.txt") + + guard + let contents = try? String(contentsOf: keyURL, encoding: .utf8), + let publishableKey = normalized(contents) + else { + return nil + } + + return publishableKey + } + + private static func publishableKeyFromKeysFile( + keyName: String, + sourceFilePath: String = #filePath + ) -> String? { + let keysURL = repositoryRoot(sourceFilePath: sourceFilePath) + .appendingPathComponent(".keys.json") + + guard + let data = try? Data(contentsOf: keysURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let instance = json[keyName] as? [String: Any], + let publishableKey = normalized(instance["pk"] as? String) + else { + return nil + } + + return publishableKey + } + + private static func repositoryRoot(sourceFilePath: String) -> URL { + var directory = URL(fileURLWithPath: sourceFilePath).deletingLastPathComponent() + let fileManager = FileManager.default + + while directory.path != "/" { + if fileManager.fileExists(atPath: directory.appendingPathComponent("Clerk.xcworkspace").path) { + return directory + } + + directory.deleteLastPathComponent() + } + + return URL(fileURLWithPath: fileManager.currentDirectoryPath) + } + + private static func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + return value + } + + private func launchApp( + authMode: String, + publishableKey: String, + publishableKeyName: String, + keychainService: String + ) -> XCUIApplication { + let configuration = E2ELaunchConfiguration( + authMode: authMode, + publishableKey: publishableKey, + publishableKeyName: publishableKeyName, + keychainService: keychainService + ) + cleanupLaunchConfiguration = configuration + + let app = launchApp(configuration: configuration) + waitForSignedOut(in: app) + return app + } + + private func launchApp( + configuration: E2ELaunchConfiguration, + cleanupOnLaunch: Bool = false + ) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-AppleLanguages", "(en)", + "-AppleLocale", "en_US", + ] + var launchEnvironment = [ + "CLERK_PUBLISHABLE_KEY": configuration.publishableKey, + "CLERK_E2E_MODE": "1", + "CLERK_E2E_KEY_NAME": configuration.publishableKeyName, + "CLERK_E2E_AUTH_MODE": configuration.authMode, + "CLERK_E2E_KEYCHAIN_SERVICE": configuration.keychainService, + ] + + if cleanupOnLaunch { + launchEnvironment["CLERK_E2E_CLEANUP_ON_LAUNCH"] = "1" + } + + app.launchEnvironment = launchEnvironment + app.launch() + return app + } + + private func openAuth(in app: XCUIApplication) { + let signInButton = app.buttons["Sign in"].firstMatch + XCTAssertTrue(signInButton.waitForExistence(timeout: 20), "Expected the E2EHost sign-in button.") + signInButton.tap() + } + + private func completeEmailCodeSignUp(email: String, in app: XCUIApplication) { + enterText(email, into: E2EIdentifier.authStartIdentifier, in: app) + tap(E2EIdentifier.authStartContinue, in: app) + waitForSignUpCodePrepared(in: app) + enterText(verificationCode, into: E2EIdentifier.signUpCode, in: app) + completePasswordCollectionIfNeeded(in: app) + } + + private func completePasswordCollectionIfNeeded(in app: XCUIApplication) { + let passwordField = app.descendants(matching: .any)[E2EIdentifier.signUpPassword] + if passwordField.waitForExistence(timeout: 10) { + enterText(testPassword, into: E2EIdentifier.signUpPassword, in: app) + tap(E2EIdentifier.signUpContinue, in: app) + return + } + + waitForSignedIn(in: app) + } + + private func waitForSignUpCodePrepared( + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + let resendCooldown = app.buttons.matching( + NSPredicate(format: "label CONTAINS %@", "Resend (") + ).firstMatch + + XCTAssertTrue( + resendCooldown.waitForExistence(timeout: 30), + "Expected sign-up email code preparation to finish before entering the verification code.", + file: file, + line: line + ) + } + + private func enterText( + _ text: String, + into identifier: String, + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + let element = inputElement(withIdentifier: identifier, in: app) + XCTAssertTrue(element.waitForExistence(timeout: 30), "Expected input '\(identifier)'.", file: file, line: line) + element.tap() + app.typeText(text) + } + + private func inputElement(withIdentifier identifier: String, in app: XCUIApplication) -> XCUIElement { + let textField = app.textFields[identifier].firstMatch + if textField.waitForExistence(timeout: 1) { + return textField + } + + let secureTextField = app.secureTextFields[identifier].firstMatch + if secureTextField.waitForExistence(timeout: 1) { + return secureTextField + } + + return app.descendants(matching: .any).matching(identifier: identifier).firstMatch + } + + private func tap( + _ identifier: String, + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + let element = app.descendants(matching: .any)[identifier] + XCTAssertTrue(element.waitForExistence(timeout: 30), "Expected tappable element '\(identifier)'.", file: file, line: line) + element.tap() + } + + private func waitForSignedIn( + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertTrue( + app.staticTexts[E2EIdentifier.signedIn].waitForExistence(timeout: 45), + "Expected the E2EHost signed-in state.", + file: file, + line: line + ) + } + + private func waitForSignedOut( + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertTrue( + app.staticTexts[E2EIdentifier.signedOut].waitForExistence(timeout: 30), + "Expected the E2EHost signed-out state.", + file: file, + line: line + ) + } + + private func waitForSessionPending( + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertTrue( + app.staticTexts[E2EIdentifier.sessionPending].waitForExistence(timeout: 45), + "Expected the E2EHost pending-session state.", + file: file, + line: line + ) + } + + private func waitForSessionActive( + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertTrue( + app.staticTexts[E2EIdentifier.sessionActive].waitForExistence(timeout: 45), + "Expected the E2EHost active-session state.", + file: file, + line: line + ) + } + + private func waitForCleanupComplete( + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertTrue( + app.staticTexts[E2EIdentifier.cleanupComplete].waitForExistence(timeout: 45), + "Expected E2EHost cleanup-on-launch to finish.", + file: file, + line: line + ) + } + + private func assertPendingTasksContain( + _ task: String, + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + let pendingTasks = app.staticTexts[E2EIdentifier.pendingTasks] + XCTAssertTrue( + pendingTasks.waitForExistence(timeout: 30), + "Expected pending session tasks.", + file: file, + line: line + ) + XCTAssertTrue( + pendingTasks.label.contains(task), + "Expected pending tasks to contain '\(task)', got '\(pendingTasks.label)'.", + file: file, + line: line + ) + } + + private func dismissSavePasswordPromptIfPresent(in app: XCUIApplication, timeout: TimeInterval = 5) { + let notNowButton = app.buttons["Not Now"].firstMatch + guard notNowButton.waitForExistence(timeout: timeout) else { return } + + notNowButton.tap() + _ = notNowButton.waitForNonExistence(timeout: 5) + } + + private func completeAuthenticatorAppSetup( + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + tap(E2EIdentifier.setupMfaAuthenticatorApp, in: app, file: file, line: line) + + let secretElement = app.descendants(matching: .any)[E2EIdentifier.totpSecret] + XCTAssertTrue( + secretElement.waitForExistence(timeout: 30), + "Expected the TOTP setup secret.", + file: file, + line: line + ) + let secret = secretElement.label + + tap(E2EIdentifier.totpContinue, in: app, file: file, line: line) + waitForStableTOTPWindow() + let code = try Self.currentTOTPCode(secret: secret) + enterText(code, into: E2EIdentifier.totpCode, in: app, file: file, line: line) + + let backupCodesContinue = app.descendants(matching: .any)[E2EIdentifier.backupCodesContinue] + if backupCodesContinue.waitForExistence(timeout: 10) { + backupCodesContinue.tap() + } + } + + private func waitForStableTOTPWindow() { + let period: TimeInterval = 30 + let elapsed = Date().timeIntervalSince1970.truncatingRemainder(dividingBy: period) + guard elapsed > 24 else { return } + + Thread.sleep(forTimeInterval: period - elapsed + 1) + } + + private static func currentTOTPCode(secret: String, date: Date = Date()) throws -> String { + let keyData = try base32DecodedData(secret) + let key = SymmetricKey(data: keyData) + let counter = UInt64(date.timeIntervalSince1970 / 30) + let counterData = withUnsafeBytes(of: counter.bigEndian) { Data($0) } + let hash = Array(HMAC.authenticationCode(for: counterData, using: key)) + let offset = Int(hash[hash.count - 1] & 0x0F) + + let truncatedHash = UInt32(hash[offset] & 0x7F) << 24 + | UInt32(hash[offset + 1] & 0xFF) << 16 + | UInt32(hash[offset + 2] & 0xFF) << 8 + | UInt32(hash[offset + 3] & 0xFF) + let code = truncatedHash % 1_000_000 + + return String(format: "%06d", code) + } + + private static func base32DecodedData(_ secret: String) throws -> Data { + let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") + let lookup = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($0.element, UInt8($0.offset)) }) + let characters = secret + .uppercased() + .filter { lookup[$0] != nil } + + guard !characters.isEmpty else { + throw TOTPError.invalidSecret + } + + var buffer = 0 + var bitsLeft = 0 + var bytes = [UInt8]() + + for character in characters { + guard let value = lookup[character] else { + throw TOTPError.invalidSecret + } + + buffer = (buffer << 5) | Int(value) + bitsLeft += 5 + + if bitsLeft >= 8 { + bitsLeft -= 8 + bytes.append(UInt8((buffer >> bitsLeft) & 0xFF)) + } + } + + guard !bytes.isEmpty else { + throw TOTPError.invalidSecret + } + + return Data(bytes) + } + + private func deleteAccountIfPossible(in app: XCUIApplication) { + if app.staticTexts[E2EIdentifier.signedOut].exists { + return + } + + dismissSavePasswordPromptIfPresent(in: app, timeout: 1) + + if app.staticTexts[E2EIdentifier.signedOut].exists { + return + } + + let deleteAccountButton = app.descendants(matching: .any)[E2EIdentifier.deleteAccount] + guard deleteAccountButton.waitForExistence(timeout: 1) else { return } + + deleteAccountButton.tap() + _ = app.staticTexts[E2EIdentifier.signedOut].waitForExistence(timeout: 15) + } + + private func cleanupAccountOnLaunchIfNeeded(after app: XCUIApplication) { + guard !app.staticTexts[E2EIdentifier.signedOut].exists, + let cleanupLaunchConfiguration + else { + return + } + + app.terminate() + + let cleanupApp = launchApp( + configuration: cleanupLaunchConfiguration, + cleanupOnLaunch: true + ) + self.app = cleanupApp + + _ = cleanupApp.staticTexts[E2EIdentifier.cleanupComplete].waitForExistence(timeout: 45) + _ = cleanupApp.staticTexts[E2EIdentifier.signedOut].waitForExistence(timeout: 15) + } +} diff --git a/Examples/Quickstart/Quickstart.xcodeproj/project.pbxproj b/Examples/Quickstart/Quickstart.xcodeproj/project.pbxproj index 242c3422d..1cfff7925 100644 --- a/Examples/Quickstart/Quickstart.xcodeproj/project.pbxproj +++ b/Examples/Quickstart/Quickstart.xcodeproj/project.pbxproj @@ -10,10 +10,24 @@ 8F692CC02E3D008D00E6E7ED /* ClerkKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8F692CBF2E3D008D00E6E7ED /* ClerkKit */; }; 8F9023522E3A648E00831B13 /* ClerkKitUI in Frameworks */ = {isa = PBXBuildFile; productRef = 8F9023512E3A648E00831B13 /* ClerkKitUI */; }; 8FATLANTIS001E3A648E00831B13 /* Atlantis in Frameworks */ = {isa = PBXBuildFile; productRef = 8FATLANTIS002E3A648E00831B13 /* Atlantis */; }; + 8FE2E2F12E90000000000011 /* ClerkKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8F692CBF2E3D008D00E6E7ED /* ClerkKit */; }; + 8FE2E2F22E90000000000012 /* ClerkKitUI in Frameworks */ = {isa = PBXBuildFile; productRef = 8F9023512E3A648E00831B13 /* ClerkKitUI */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 8FE2E2EC2E9000000000000C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8FC2FE002E3A5C32001E5953 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8FE2E2F72E90000000000017; + remoteInfo = E2EHost; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 8FE2E2F02E90000000000010 /* E2EHost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = E2EHost.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8FC2FE082E3A5C32001E5953 /* Quickstart.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Quickstart.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8FE2E2E22E90000000000002 /* E2EHostE2ETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = E2EHostE2ETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -35,6 +49,18 @@ path = Quickstart; sourceTree = ""; }; + 8FE2E2F32E90000000000013 /* E2EHost */ = { + isa = PBXFileSystemSynchronizedRootGroup; + name = E2EHost; + path = ../E2EHost/E2EHost; + sourceTree = ""; + }; + 8FE2E2E32E90000000000003 /* E2EHostE2ETests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + name = E2EHostE2ETests; + path = ../E2EHost/E2EHostE2ETests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -48,6 +74,22 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8FE2E2F52E90000000000015 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8FE2E2F12E90000000000011 /* ClerkKit in Frameworks */, + 8FE2E2F22E90000000000012 /* ClerkKitUI in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8FE2E2E52E90000000000005 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -55,6 +97,8 @@ isa = PBXGroup; children = ( 8FC2FE0A2E3A5C32001E5953 /* Quickstart */, + 8FE2E2F32E90000000000013 /* E2EHost */, + 8FE2E2E32E90000000000003 /* E2EHostE2ETests */, 8FC2FE092E3A5C32001E5953 /* Products */, ); sourceTree = ""; @@ -63,6 +107,8 @@ isa = PBXGroup; children = ( 8FC2FE082E3A5C32001E5953 /* Quickstart.app */, + 8FE2E2F02E90000000000010 /* E2EHost.app */, + 8FE2E2E22E90000000000002 /* E2EHostE2ETests.xctest */, ); name = Products; sourceTree = ""; @@ -95,6 +141,53 @@ productReference = 8FC2FE082E3A5C32001E5953 /* Quickstart.app */; productType = "com.apple.product-type.application"; }; + 8FE2E2F72E90000000000017 /* E2EHost */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8FE2E2FA2E9000000000001A /* Build configuration list for PBXNativeTarget "E2EHost" */; + buildPhases = ( + 8FE2E2F42E90000000000014 /* Sources */, + 8FE2E2F52E90000000000015 /* Frameworks */, + 8FE2E2F62E90000000000016 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 8FE2E2F32E90000000000013 /* E2EHost */, + ); + name = E2EHost; + packageProductDependencies = ( + 8F9023512E3A648E00831B13 /* ClerkKitUI */, + 8F692CBF2E3D008D00E6E7ED /* ClerkKit */, + ); + productName = E2EHost; + productReference = 8FE2E2F02E90000000000010 /* E2EHost.app */; + productType = "com.apple.product-type.application"; + }; + 8FE2E2E72E90000000000007 /* E2EHostE2ETests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8FE2E2EA2E9000000000000A /* Build configuration list for PBXNativeTarget "E2EHostE2ETests" */; + buildPhases = ( + 8FE2E2E42E90000000000004 /* Sources */, + 8FE2E2E52E90000000000005 /* Frameworks */, + 8FE2E2E62E90000000000006 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 8FE2E2EB2E9000000000000B /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 8FE2E2E32E90000000000003 /* E2EHostE2ETests */, + ); + name = E2EHostE2ETests; + packageProductDependencies = ( + ); + productName = E2EHostE2ETests; + productReference = 8FE2E2E22E90000000000002 /* E2EHostE2ETests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -108,6 +201,13 @@ 8FC2FE072E3A5C32001E5953 = { CreatedOnToolsVersion = 26.0; }; + 8FE2E2F72E90000000000017 = { + CreatedOnToolsVersion = 26.0; + }; + 8FE2E2E72E90000000000007 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = 8FE2E2F72E90000000000017; + }; }; }; buildConfigurationList = 8FC2FE032E3A5C32001E5953 /* Build configuration list for PBXProject "Quickstart" */; @@ -128,6 +228,8 @@ projectRoot = ""; targets = ( 8FC2FE072E3A5C32001E5953 /* Quickstart */, + 8FE2E2F72E90000000000017 /* E2EHost */, + 8FE2E2E72E90000000000007 /* E2EHostE2ETests */, ); }; /* End PBXProject section */ @@ -140,6 +242,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8FE2E2F62E90000000000016 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8FE2E2E62E90000000000006 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -150,8 +266,30 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8FE2E2F42E90000000000014 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8FE2E2E42E90000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 8FE2E2EB2E9000000000000B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8FE2E2F72E90000000000017 /* E2EHost */; + targetProxy = 8FE2E2EC2E9000000000000C /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 8FC2FE112E3A5C33001E5953 /* Debug */ = { isa = XCBuildConfiguration; @@ -353,6 +491,136 @@ }; name = Release; }; + 8FE2E2F82E90000000000018 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8SD6SB282; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.clerk.E2EHost; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 8FE2E2F92E90000000000019 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8SD6SB282; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.clerk.E2EHost; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 8FE2E2E82E90000000000008 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8SD6SB282; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.clerk.E2EHostE2ETests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = E2EHost; + }; + name = Debug; + }; + 8FE2E2E92E90000000000009 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8SD6SB282; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.clerk.E2EHostE2ETests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = E2EHost; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -374,6 +642,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 8FE2E2FA2E9000000000001A /* Build configuration list for PBXNativeTarget "E2EHost" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8FE2E2F82E90000000000018 /* Debug */, + 8FE2E2F92E90000000000019 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8FE2E2EA2E9000000000000A /* Build configuration list for PBXNativeTarget "E2EHostE2ETests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8FE2E2E82E90000000000008 /* Debug */, + 8FE2E2E92E90000000000009 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/Examples/Quickstart/Quickstart.xcodeproj/xcshareddata/xcschemes/E2EHost.xcscheme b/Examples/Quickstart/Quickstart.xcodeproj/xcshareddata/xcschemes/E2EHost.xcscheme new file mode 100644 index 000000000..0dc0b9b50 --- /dev/null +++ b/Examples/Quickstart/Quickstart.xcodeproj/xcshareddata/xcschemes/E2EHost.xcscheme @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Makefile b/Makefile index 5ddb26faf..5d8a63950 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ -.PHONY: all clean setup format format-check lint lint-fix check install-tools install-hooks install-xcode-template-macros create-example-local-secrets-plists set-example-pk test test-ui test-integration help create-env install-1password-cli fetch-test-keys update-swiftformat update-swiftlint +.PHONY: all clean setup format format-check lint lint-fix check install-tools install-hooks install-xcode-template-macros create-example-local-secrets-plists set-example-pk test test-ui test-e2e test-integration help create-env install-1password-cli fetch-test-keys sync-test-keys-to-github update-swiftformat update-swiftlint SWIFTFORMAT := $(CURDIR)/.tools/bin/swiftformat SWIFTLINT := $(CURDIR)/.tools/bin/swiftlint IOS_SIMULATOR_DESTINATION ?= +CLERK_E2E_KEY_NAME ?= with-email-codes # Default target @@ -12,6 +13,7 @@ help: @echo "Available commands:" @echo " make setup - Install tools/hooks and configure Xcode file headers" @echo " make fetch-test-keys - Fetch integration test keys from 1Password (optional, for Clerk employees; auto-installs CLI if needed)" + @echo " make sync-test-keys-to-github - Fetch test keys and sync them to the CLERK_TEST_KEYS_JSON GitHub Actions secret" @echo " make format - Format all Swift files using SwiftFormat" @echo " make format-check - Check formatting without modifying files (for CI)" @echo " make lint - Run SwiftLint to check code quality" @@ -19,6 +21,8 @@ help: @echo " make check - Run both format-check and lint (for CI)" @echo " make test - Run ClerkKitTests on macOS" @echo " make test-ui - Run ClerkKitUI tests on iOS Simulator" + @echo " make test-e2e - Run E2EHost tests on iOS Simulator" + @echo " CLERK_E2E_KEY_NAME=with-session-tasks-setup-mfa make test-e2e" @echo " make test-integration - Run only integration tests" @echo " make install-tools - Install pinned SwiftFormat and SwiftLint" @echo " make update-swiftformat - Update pinned SwiftFormat to the latest release" @@ -113,6 +117,10 @@ install-1password-cli: fetch-test-keys: install-1password-cli create-env @./scripts/fetch-1password-secrets.sh +# Sync the 1Password-backed .keys.json snapshot into GitHub Actions. +sync-test-keys-to-github: fetch-test-keys + @./scripts/sync-test-keys-to-github.sh + # Format all Swift files format: @echo "Formatting Swift files..." @@ -185,6 +193,47 @@ test-ui: xcodebuild test -workspace .swiftpm/xcode/package.xcworkspace -scheme Clerk-Package -destination "$$destination" -only-testing:ClerkKitUITests @echo "✅ ClerkKitUI tests completed!" +# Run E2EHost tests on iOS Simulator +test-e2e: + @echo "Running E2EHost tests on iOS Simulator..." + @mkdir -p build/reports + @key_name="$(CLERK_E2E_KEY_NAME)"; \ + if [ -z "$$key_name" ]; then \ + key_name="with-email-codes"; \ + fi; \ + publishable_key="$${CLERK_E2E_PUBLISHABLE_KEY:-}"; \ + if [ -z "$$publishable_key" ] && [ -f .keys.json ]; then \ + publishable_key="$$(/usr/bin/plutil -extract "$$key_name.pk" raw -o - .keys.json 2>/dev/null || true)"; \ + fi; \ + if [ -z "$$publishable_key" ]; then \ + echo "❌ Unable to find a publishable key for E2EHost tests."; \ + echo " Set CLERK_E2E_PUBLISHABLE_KEY or configure '$$key_name.pk' in .keys.json."; \ + exit 1; \ + fi; \ + echo "Using E2E test key: $$key_name"; \ + destination="$(IOS_SIMULATOR_DESTINATION)"; \ + if [ -z "$$destination" ]; then \ + available_devices="$$(xcrun simctl list devices available)"; \ + simulator_id="$$(printf '%s\n' "$$available_devices" | sed -nE 's/^ (iPhone[^()]*) \(([0-9A-F-]{36})\) \(.*$$/\2/p' | head -n1)"; \ + if [ -n "$$simulator_id" ]; then \ + destination="platform=iOS Simulator,id=$$simulator_id"; \ + fi; \ + fi; \ + if [ -z "$$destination" ]; then \ + echo "❌ Unable to find an available iPhone simulator for E2EHostE2ETests."; \ + echo " Set IOS_SIMULATOR_DESTINATION explicitly and rerun make test-e2e."; \ + exit 1; \ + fi; \ + echo "Using simulator destination: $$destination"; \ + rm -rf build/reports/E2EHost.xcresult; \ + printf '%s' "$$publishable_key" > build/reports/E2EHostPublishableKey.txt; \ + printf '%s' "$$key_name" > build/reports/E2EHostPublishableKeyName.txt; \ + chmod 600 build/reports/E2EHostPublishableKey.txt; \ + chmod 600 build/reports/E2EHostPublishableKeyName.txt; \ + trap 'rm -f build/reports/E2EHostPublishableKey.txt build/reports/E2EHostPublishableKeyName.txt' EXIT; \ + CLERK_E2E_KEY_NAME="$$key_name" CLERK_E2E_PUBLISHABLE_KEY="$$publishable_key" CLERK_PUBLISHABLE_KEY="$$publishable_key" xcodebuild test -workspace Clerk.xcworkspace -scheme E2EHost -destination "$$destination" -only-testing:E2EHostE2ETests -resultBundlePath build/reports/E2EHost.xcresult + @echo "✅ E2EHost tests completed!" + # Run only integration tests # Tests decide which key to use from .keys.json (each test can specify its own key) # In CI, .keys.json is created from CLERK_TEST_KEYS_JSON GitHub Actions secret diff --git a/Sources/ClerkKitUI/Common/ClerkE2EEnvironment.swift b/Sources/ClerkKitUI/Common/ClerkE2EEnvironment.swift new file mode 100644 index 000000000..773a9c640 --- /dev/null +++ b/Sources/ClerkKitUI/Common/ClerkE2EEnvironment.swift @@ -0,0 +1,16 @@ +// +// ClerkE2EEnvironment.swift +// Clerk +// + +#if os(iOS) + +import Foundation + +enum ClerkE2EEnvironment { + static var isEnabled: Bool { + ProcessInfo.processInfo.environment["CLERK_E2E_MODE"] == "1" + } +} + +#endif diff --git a/Sources/ClerkKitUI/Common/ClerkTextField.swift b/Sources/ClerkKitUI/Common/ClerkTextField.swift index 290525554..698b8cebb 100644 --- a/Sources/ClerkKitUI/Common/ClerkTextField.swift +++ b/Sources/ClerkKitUI/Common/ClerkTextField.swift @@ -25,17 +25,20 @@ struct ClerkTextField: View { @Binding var text: String let isSecure: Bool let fieldState: FieldState + let accessibilityIdentifier: String init( _ titleKey: LocalizedStringKey, text: Binding, isSecure: Bool = false, - fieldState: FieldState = .default + fieldState: FieldState = .default, + accessibilityIdentifier: String = "" ) { self.titleKey = titleKey _text = text self.isSecure = isSecure self.fieldState = fieldState + self.accessibilityIdentifier = accessibilityIdentifier } var isFocusedOrFilled: Bool { @@ -132,6 +135,7 @@ struct ClerkTextField: View { .padding(.vertical, 6) .frame(minHeight: 56) .contentShape(.rect) + .accessibilityIdentifier(accessibilityIdentifier) .onTapGesture { if isSecure { focused = revealText ? .regular : .secure diff --git a/Sources/ClerkKitUI/Common/CopyableTextView.swift b/Sources/ClerkKitUI/Common/CopyableTextView.swift index 6c35f140f..2961d3b63 100644 --- a/Sources/ClerkKitUI/Common/CopyableTextView.swift +++ b/Sources/ClerkKitUI/Common/CopyableTextView.swift @@ -10,8 +10,14 @@ struct CopyableTextView: View { @Environment(\.clerkTheme) private var theme let text: String + var accessibilityIdentifier: String = "" var body: some View { + textView + .accessibilityIdentifier(accessibilityIdentifier) + } + + private var textView: some View { Text(verbatim: text) .font(theme.fonts.subheadline) .foregroundStyle(theme.colors.foreground) diff --git a/Sources/ClerkKitUI/Common/OTPField.swift b/Sources/ClerkKitUI/Common/OTPField.swift index 429636abf..6f28ad371 100644 --- a/Sources/ClerkKitUI/Common/OTPField.swift +++ b/Sources/ClerkKitUI/Common/OTPField.swift @@ -14,6 +14,7 @@ struct OTPField: View { var numberOfInputs: Int = 6 @Binding var fieldState: FieldState @FocusState.Binding var isFocused: Bool + var accessibilityIdentifier: String = "" var onCodeEntry: (String) async -> Void enum FieldState { @@ -47,6 +48,7 @@ struct OTPField: View { .focused($isFocused) .textContentType(.oneTimeCode) .keyboardType(.numberPad) + .accessibilityIdentifier(accessibilityIdentifier) .foregroundStyle(.clear) .tint(.clear) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -56,13 +58,18 @@ struct OTPField: View { code = String(newValue.prefix(numberOfInputs)) if previousCode == code { return } - if code.count == numberOfInputs { - fieldState = .default - Task { await onCodeEntry(code) } - } else if code.isEmpty { + if code.isEmpty { fieldState = .default } } + .task(id: code) { + guard code.count == numberOfInputs else { + return + } + + fieldState = .default + await onCodeEntry(code) + } .onChange( of: fieldState, { _, newValue in diff --git a/Sources/ClerkKitUI/Components/Auth/AuthStartView.swift b/Sources/ClerkKitUI/Components/Auth/AuthStartView.swift index ddec3f7cf..a195e5d61 100644 --- a/Sources/ClerkKitUI/Components/Auth/AuthStartView.swift +++ b/Sources/ClerkKitUI/Components/Auth/AuthStartView.swift @@ -224,7 +224,8 @@ extension AuthStartView { ClerkTextField( emailOrUsernamePlaceholder, text: $authState.authStartIdentifier, - fieldState: fieldError != nil ? .error : .default + fieldState: fieldError != nil ? .error : .default, + accessibilityIdentifier: ClerkAccessibilityIdentifiers.Auth.Start.identifier ) .textContentType(.username) .keyboardType(.emailAddress) @@ -255,6 +256,7 @@ extension AuthStartView { } .buttonStyle(.primary()) .disabled(continueIsDisabled) + .accessibilityIdentifier(ClerkAccessibilityIdentifiers.Auth.Start.continueButton) .simultaneousGesture(TapGesture()) } diff --git a/Sources/ClerkKitUI/Components/Auth/ClerkAccessibilityIdentifiers.swift b/Sources/ClerkKitUI/Components/Auth/ClerkAccessibilityIdentifiers.swift new file mode 100644 index 000000000..f1e76654d --- /dev/null +++ b/Sources/ClerkKitUI/Components/Auth/ClerkAccessibilityIdentifiers.swift @@ -0,0 +1,45 @@ +// +// ClerkAccessibilityIdentifiers.swift +// Clerk +// + +#if os(iOS) + +enum ClerkAccessibilityIdentifiers { + enum Auth { + enum Start { + static let identifier = "clerk.auth.start.identifier" + static let continueButton = "clerk.auth.start.continue" + } + + enum SignIn { + static let password = "clerk.auth.signIn.password" + static let continueButton = "clerk.auth.signIn.continue" + } + + enum SignUp { + static let code = "clerk.auth.signUp.code" + static let password = "clerk.auth.signUp.password" + static let continueButton = "clerk.auth.signUp.continue" + } + + enum SessionTask { + enum SetupMfa { + static let smsCode = "clerk.auth.sessionTask.setupMfa.smsCode" + static let authenticatorApp = "clerk.auth.sessionTask.setupMfa.authenticatorApp" + } + + enum Totp { + static let secret = "clerk.auth.sessionTask.totp.secret" + static let continueButton = "clerk.auth.sessionTask.totp.continue" + static let code = "clerk.auth.sessionTask.totp.code" + } + + enum BackupCodes { + static let continueButton = "clerk.auth.sessionTask.backupCodes.continue" + } + } + } +} + +#endif diff --git a/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskBackupCodesView.swift b/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskBackupCodesView.swift index fc31d62a9..f51d90714 100644 --- a/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskBackupCodesView.swift +++ b/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskBackupCodesView.swift @@ -119,6 +119,7 @@ struct SessionTaskBackupCodesView: View { } .frame(maxWidth: .infinity) } + .accessibilityIdentifier(ClerkAccessibilityIdentifiers.Auth.SessionTask.BackupCodes.continueButton) .buttonStyle(.primary()) .padding(.bottom, 32) diff --git a/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskMfaSetupView.swift b/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskMfaSetupView.swift index 06a23ffe8..9939776fb 100644 --- a/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskMfaSetupView.swift +++ b/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskMfaSetupView.swift @@ -64,6 +64,7 @@ struct SessionTaskMfaSetupView: View { } label: { StrategyOptionButton(iconName: "icon-phone", text: "SMS code") } + .accessibilityIdentifier(ClerkAccessibilityIdentifiers.Auth.SessionTask.SetupMfa.smsCode) .buttonStyle(.secondary()) } @@ -74,6 +75,7 @@ struct SessionTaskMfaSetupView: View { StrategyOptionButton(iconName: "icon-key", text: "Authenticator application") .overlayProgressView(isActive: isRunning) } + .accessibilityIdentifier(ClerkAccessibilityIdentifiers.Auth.SessionTask.SetupMfa.authenticatorApp) .buttonStyle(.secondary()) } } diff --git a/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskMfaTotpView.swift b/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskMfaTotpView.swift index edb69f9d3..bd5dc138d 100644 --- a/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskMfaTotpView.swift +++ b/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskMfaTotpView.swift @@ -38,7 +38,10 @@ struct SessionTaskMfaTotpView: View { .fixedSize(horizontal: false, vertical: true) } - CopyableTextView(text: secret) + CopyableTextView( + text: secret, + accessibilityIdentifier: ClerkAccessibilityIdentifiers.Auth.SessionTask.Totp.secret + ) Button { UIPasteboard.general.string = secret @@ -60,6 +63,7 @@ struct SessionTaskMfaTotpView: View { } label: { ContinueButtonLabelView() } + .accessibilityIdentifier(ClerkAccessibilityIdentifiers.Auth.SessionTask.Totp.continueButton) .buttonStyle(.primary()) .padding(.bottom, 32) diff --git a/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskMfaVerifyTotpView.swift b/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskMfaVerifyTotpView.swift index a322ac8b0..5d3e23ab2 100644 --- a/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskMfaVerifyTotpView.swift +++ b/Sources/ClerkKitUI/Components/Auth/SessionTask/SessionTaskMfaVerifyTotpView.swift @@ -32,7 +32,8 @@ struct SessionTaskMfaVerifyTotpView: View { OTPField( code: $code, fieldState: $otpFieldState, - isFocused: $otpFieldIsFocused + isFocused: $otpFieldIsFocused, + accessibilityIdentifier: ClerkAccessibilityIdentifiers.Auth.SessionTask.Totp.code ) { _ in await attempt() } diff --git a/Sources/ClerkKitUI/Components/Auth/SignIn/SignInFactorOnePasswordView.swift b/Sources/ClerkKitUI/Components/Auth/SignIn/SignInFactorOnePasswordView.swift index 33ce282e5..f32993808 100644 --- a/Sources/ClerkKitUI/Components/Auth/SignIn/SignInFactorOnePasswordView.swift +++ b/Sources/ClerkKitUI/Components/Auth/SignIn/SignInFactorOnePasswordView.swift @@ -50,9 +50,10 @@ struct SignInFactorOnePasswordView: View { "Enter your password", text: $authState.signInPassword, isSecure: true, - fieldState: fieldError != nil ? .error : .default + fieldState: fieldError != nil ? .error : .default, + accessibilityIdentifier: ClerkAccessibilityIdentifiers.Auth.SignIn.password ) - .textContentType(.password) + .textContentType(ClerkE2EEnvironment.isEnabled ? nil : .password) .textInputAutocapitalization(.never) .focused($isFocused) .onFirstAppear { @@ -74,6 +75,7 @@ struct SignInFactorOnePasswordView: View { } .buttonStyle(.primary()) .disabled(authState.signInPassword.isEmpty) + .accessibilityIdentifier(ClerkAccessibilityIdentifiers.Auth.SignIn.continueButton) .simultaneousGesture(TapGesture()) } .padding(.bottom, 16) diff --git a/Sources/ClerkKitUI/Components/Auth/SignUp/SignUpCodeView.swift b/Sources/ClerkKitUI/Components/Auth/SignUp/SignUpCodeView.swift index 2a13e5012..2d1ef4383 100644 --- a/Sources/ClerkKitUI/Components/Auth/SignUp/SignUpCodeView.swift +++ b/Sources/ClerkKitUI/Components/Auth/SignUp/SignUpCodeView.swift @@ -87,7 +87,12 @@ struct SignUpCodeView: View { } VStack(spacing: 24) { - OTPField(code: $code, fieldState: $otpFieldState, isFocused: $otpFieldIsFocused) { _ in + OTPField( + code: $code, + fieldState: $otpFieldState, + isFocused: $otpFieldIsFocused, + accessibilityIdentifier: ClerkAccessibilityIdentifiers.Auth.SignUp.code + ) { _ in await attempt() } .onAppear { diff --git a/Sources/ClerkKitUI/Components/Auth/SignUp/SignUpCollectFieldView.swift b/Sources/ClerkKitUI/Components/Auth/SignUp/SignUpCollectFieldView.swift index c7382512a..c4c544b84 100644 --- a/Sources/ClerkKitUI/Components/Auth/SignUp/SignUpCollectFieldView.swift +++ b/Sources/ClerkKitUI/Components/Auth/SignUp/SignUpCollectFieldView.swift @@ -79,9 +79,10 @@ struct SignUpCollectFieldView: View { ClerkTextField( "Choose your password", text: $authState.signUpPassword, - isSecure: true + isSecure: true, + accessibilityIdentifier: ClerkAccessibilityIdentifiers.Auth.SignUp.password ) - .textContentType(.newPassword) + .textContentType(ClerkE2EEnvironment.isEnabled ? nil : .newPassword) .hiddenTextField( text: $usernameForPasswordKeeper, textContentType: .username @@ -135,6 +136,7 @@ struct SignUpCollectFieldView: View { } .buttonStyle(.primary()) .disabled(continueIsDisabled) + .accessibilityIdentifier(ClerkAccessibilityIdentifiers.Auth.SignUp.continueButton) .simultaneousGesture(TapGesture()) } diff --git a/Sources/ClerkKitUI/Extensions/View+HiddenTextField.swift b/Sources/ClerkKitUI/Extensions/View+HiddenTextField.swift index 08f9ed3ef..a58863810 100644 --- a/Sources/ClerkKitUI/Extensions/View+HiddenTextField.swift +++ b/Sources/ClerkKitUI/Extensions/View+HiddenTextField.swift @@ -18,16 +18,20 @@ struct HiddenTextFieldModifier: ViewModifier { let isSecure: Bool func body(content: Content) -> some View { - content - .background { - field - .textContentType(textContentType) - .opacity(0.00001) - .offset(y: -100) - .disabled(true) - .accessibilityHidden(true) - .allowsHitTesting(false) - } + if ClerkE2EEnvironment.isEnabled { + content + } else { + content + .background { + field + .textContentType(textContentType) + .opacity(0.00001) + .offset(y: -100) + .disabled(true) + .accessibilityHidden(true) + .allowsHitTesting(false) + } + } } @ViewBuilder diff --git a/scripts/sync-test-keys-to-github.sh b/scripts/sync-test-keys-to-github.sh new file mode 100755 index 000000000..089184513 --- /dev/null +++ b/scripts/sync-test-keys-to-github.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +KEYS_FILE="$REPO_ROOT/.keys.json" +GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-clerk/clerk-ios}" +SECRET_NAME="CLERK_TEST_KEYS_JSON" + +if ! command -v jq > /dev/null 2>&1; then + echo "❌ Error: jq is required to validate .keys.json." + echo " Install jq via Homebrew: brew install jq" + exit 1 +fi + +if ! command -v gh > /dev/null 2>&1; then + echo "❌ Error: GitHub CLI is required to update $SECRET_NAME." + echo " Install gh via Homebrew: brew install gh" + exit 1 +fi + +if ! gh auth status -h github.com > /dev/null 2>&1; then + echo "❌ Error: GitHub CLI is not authenticated for github.com." + echo " Run: gh auth login -h github.com" + exit 1 +fi + +if [ ! -f "$KEYS_FILE" ]; then + echo "❌ Error: .keys.json was not found." + echo " Run: make fetch-test-keys" + exit 1 +fi + +if ! jq -e 'type == "object" and all(.[]; (.pk | type == "string" and length > 0))' "$KEYS_FILE" > /dev/null; then + echo "❌ Error: .keys.json must be an object of entries shaped like { \"pk\": \"pk_test_...\" }." + exit 1 +fi + +echo "Syncing test keys to GitHub Actions secret $SECRET_NAME for $GITHUB_REPOSITORY..." +echo "Key names:" +jq -r 'keys | sort | .[]' "$KEYS_FILE" | sed 's/^/ - /' + +jq -c . "$KEYS_FILE" | gh secret set "$SECRET_NAME" --repo "$GITHUB_REPOSITORY" +echo "✅ Synced $SECRET_NAME from .keys.json"