diff --git a/.changeset/expo-native-update-required-spec.md b/.changeset/expo-native-update-required-spec.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/expo-native-update-required-spec.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/verify-clerk-expo/SKILL.md b/.claude/skills/verify-clerk-expo/SKILL.md index 277edd5db23..94144225bf3 100644 --- a/.claude/skills/verify-clerk-expo/SKILL.md +++ b/.claude/skills/verify-clerk-expo/SKILL.md @@ -139,7 +139,7 @@ test('the home UserButton opens the profile of the signed-in user', async ({ hos ``` - `host.seedUser({ phone?, password? })` creates a `+clerk_test` user in the worktree's application. With `password: true` the user also has a password that the run generated for that user, and `user.password` holds it. Pass it to `host.fill`, as `host.fill(field, user.password!)`, and to nothing else. It prints as ``, `run` redacts its value from the logs it writes, and `attach` refuses a run whose files hold it. For a sign-up through the form, `host.newEmail()` returns a new address of this run, and `host.newPhone()` returns a 555-01xx number that no user holds. `host.newEmail()` claims a first name for the run on this machine, as `maya.7c1e+clerk_test@example.com`, and calls nothing. After the tests, `run` finds the user by the run tag, the last four characters of the run id, in the address. -- `host.launch({ signedInAs?, authMode?, keepStorage?, landsOn? })` relaunches the test app and waits up to 60 seconds for its home. Every launch opens on the home, and a spec taps from there to the screen it needs, as a user does. `authMode` is the mode of every AuthView the home opens: `signIn`, `signUp`, or `signInOrUp`. A launch starts with fresh storage unless `keepStorage` is true, so a launch with no `signedInAs` is signed out and lands on the home's `Sign in` button. `host.launch()` with no argument is that launch. A launch with `signedInAs` lands on the home, which must show that user's email, user ID, and a session ID. When the home should show anything else, pass its locator as `landsOn`. A launch that keeps storage and has no `signedInAs` needs it. `host.launch` fails at once when the test app shows its error screen, and the error has the message on that screen. +- `host.launch({ signedInAs?, authMode?, keepStorage?, landsOn? })` relaunches the test app and waits up to 60 seconds for its home. Every launch opens on the home, unless a minimum app version blocks it, and a spec taps from there to the screen it needs, as a user does. `authMode` is the mode of every AuthView the home opens: `signIn`, `signUp`, or `signInOrUp`. A launch starts with fresh storage unless `keepStorage` is true, so a launch with no `signedInAs` and no `keepStorage` is signed out and lands on the home's `Sign in` button, and one with `keepStorage: true` keeps the session of the launch before it. `host.launch()` with no argument is that launch. A launch with `signedInAs` lands on the home, which must show that user's email, user ID, and a session ID. When the home should show anything else, pass its locator as `landsOn`. A launch that keeps storage and has no `signedInAs` needs it, and so does one that a minimum app version blocks, which shows the update screen over the home. `host.launch` fails at once when the test app shows its error screen, and the error has the message on that screen. - The home lists a link to each of the test app's other screens. Signed out, it has `Sign in` (`host.app.signIn`, AuthView in a modal), `Sign in with a logo` (the same modal with a React Native logo), `Sign in full screen` (`host.app.signInFullScreen`, AuthView with no close button), `Sign in full screen with a close button`, `Custom sign-in`, `Custom sign-up`, and `Native modules`. Signed in, it has `Embedded profile` (UserProfileView inline). `Token cache` shows in both states. `homeLinks(screen)` in `specs/native.ts` has the locator of each link that `host.app` does not hold. A new screen goes in `screens/destinations.ts` and `verify/VerifyHost.tsx` of the test app and in `homeLinks`. - `host.expectSignedInAs(user, timeoutMs?)` waits for the home to show `Signed in as `, the user's ID, and a session ID. Pass the email alone for a user that the sign-up form created. `host.expectSignedOut(timeoutMs?)` waits for the home to show `Signed out` with no user ID and no session ID. Use them for the outcome of a flow that returns to the home. `host.expectVisible(locator, what, timeoutMs?)` is the same wait for an element of an SDK view, and its message names the element with `what`. On iOS, use it for the first element a spec waits for after it submits a password, because iOS may cover the app with its `Save Password?` sheet then. When one of these waits or the wait of `host.launch` runs out, its message says what the home showed at the last reading, how many passes and screen reads the wait made, how long the slowest read took, and the error of a read that failed. - `host.app` has the locators of the home: `signIn`, `signInFullScreen`, `signedOut`, `signedIn`, `userId`, `sessionId`, `signOut`, and `error` for the error screen. `host.runId` is the id of the run. For a sign-up password, fill the field with `runPassword(host.runId)` from `@clerk/internal-mobile-e2e/specs/support/clerk`, two words and four digits such as `Otter-Lamp-3917`, the same in every test of the run. For an organization name that a spec types into a form, use `host.newOrganizationName()`, a word and the run tag that no other test of the run gets, as `Maple 7c1e`. @@ -158,6 +158,8 @@ On iOS, locate nodes of the native views with the SDK's identifiers, such as `sc Every test starts on the standard settings. A test that needs other settings calls `host.useSettings({ config, environment? })`, and the CLI puts every leaf it changed back when the test ends. [Test instances](references/instances.md) has the call, the restore, and the leaves that tests often change. To see which settings are on or off, run `integration/expo-native/bin/control-clerk-expo settings `, such as `settings auth_multi_factor`. +A test of the update screen calls `host.useMinimumAppVersion({ minimumVersion, updateUrl })`, which registers the app under test with the application and sets its minimum. The CLI removes the minimum when the test ends, pass or fail. [Test instances](references/instances.md) has the details. + ### Check your work The specs under `specs/features//` are committed and cover the feature map in `features/`, which starts at `features/README.md`. Prove your own change, on both platforms when the change is not specific to one. CI runs every committed spec only on the Version packages pull request (`changeset-release/main`), before each release, and never on your pull request (`.github/workflows/verify-e2e.yml`). When your change is to behavior a committed spec already covers, that spec is your proof: run it. A spec covers a behavior only when it asserts on it. A spec that passes through the screen you changed without checking what you changed is not your proof. Run another feature's specs too when you changed code that feature shares. For new work: @@ -184,7 +186,7 @@ Every `run` writes `.verify/runs//` and prints its path. | File | What it holds | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `run.json` | The record: `results` per spec, `platform`, `backend`, `gitHead`, `dirty`, `build`, `device`, `identities`, `settings` (one entry per `host.useSettings` call), and `tainted` | +| `run.json` | The record: `results` per spec, `platform`, `backend`, `gitHead`, `dirty`, `build`, `device`, `identities`, `settings` (one entry per `host.useSettings` or `host.useMinimumAppVersion` call), and `tainted` | | `video.mp4` | The run, from the first `host.launch` to the end, recorded by `simctl io recordVideo` on iOS or `adb shell screenrecord` on Android. Just before that launch, `run` closes the app and starts the recorder, so the video opens on the device's home screen for a moment and then on the app being launched. A run in which no test calls `host.launch` has no video, and `run` prints a `video` line that says so | | `evidence-marks.jsonl` | When each `host.evidence` block started and ended, and when and where each `host.tap` and `host.fill` tapped, in a run that records evidence. `run.json` keeps them as `evidenceSpans` and `evidenceTaps`, in seconds of the video | | `video-cut.mp4` | What `attach --video` last posted for this run: the marked part of `video.mp4`. `video.mp4` stays as recorded | diff --git a/.claude/skills/verify-clerk-expo/features/README.md b/.claude/skills/verify-clerk-expo/features/README.md index 67c1678aa8c..2195204508b 100644 --- a/.claude/skills/verify-clerk-expo/features/README.md +++ b/.claude/skills/verify-clerk-expo/features/README.md @@ -6,7 +6,7 @@ This directory is the maintained source for verifying the user-facing behavior o `SKILL.md` under Launch has the setup and the commands. These facts hold for every recipe here, on both platforms. -- The standard settings have every auth method and signed-in feature that these recipes use. The `settings` check of `doctor` compares the live instance with the standard file once the worktree holds an application. +- The standard settings have every auth method and signed-in feature that these recipes use, except a minimum app version, which a spec sets with `host.useMinimumAppVersion`. The `settings` check of `doctor` compares the live instance with the standard file once the worktree holds an application. - Every launch gets a new `verifyStorageScope`, so no spec inherits a session from another spec. A launch with `keepStorage: true` reuses the scope of the launch before it in the same test. - `down` deletes this worktree's application, and with it every user and organization a run created, including users created through the sign-up form. `down --platform

` keeps the application while the other platform still holds a lease in this worktree. It deletes the application when the other platform holds no lease. @@ -47,7 +47,7 @@ The iOS identifiers come from `Sources/ClerkKitUI/Components/Auth/ClerkAccessibi ## Feature entry contract -Each feature file starts with an H1 title and one paragraph describing the user-visible behavior, then exactly four H2 sections in this order: `Sub-features`, `How to get to it (user POV)`, `Driving it with verify`, and `Gotchas`. `Driving it with verify` starts with `Preconditions:` and names the specs that prove each sub-feature. A feature file lists only entry points that a spec drives. +Each feature file starts with an H1 title and one paragraph describing the user-visible behavior, then these four required H2 sections in this order: `Sub-features`, `How to get to it (user POV)`, `Driving it with verify`, and `Gotchas`. `Driving it with verify` starts with `Preconditions:` and names the specs that prove each sub-feature. A feature file lists only entry points that a spec drives. A feature with states that no spec can reach adds an optional fifth H2, `States a spec cannot reach`, before `Gotchas`, which names each state, where the app decides it, and what it takes. ## Features @@ -58,7 +58,8 @@ Each feature file starts with an H1 title and one paragraph describing the user- - [Token cache persistence](./token-cache-persistence.md) covers restoring the same session after a relaunch. - [Native and JS session sync](./native-js-sync.md) covers a native sign-out and a native password sign-in reaching `useAuth`, `useUser`, and `useSession`, that sign-in surviving a relaunch, a second native sign-in in one process, a ticket sign-in reaching the native profile, and a JS sign-out clearing the hooks. - [Native modules](./native-modules.md) covers `useSignInWithGoogle` opening the native Google sign-in, a cancel of it on iOS, and on Android when back closes Google's page, and `useBiometricCredentials` reporting what its native module found on the device. +- [Update required](./update-required.md) covers the update screen for a signed-in and a signed-out user on a build below the minimum, the same session returning once the minimum is lowered, and a build at the minimum, on both platforms, and it lists the states no spec reaches. -Not mapped yet: SSO through `useSSO` (no real OAuth on simulators), sign-in and sign-up in Account Portal through `useHostedAuth` from `@clerk/expo/hosted-auth`, which opens a browser that no spec drives and the test app has no button for (`packages/expo/src/hooks/useHostedAuth.ts`), a Google or Apple native sign-in with a real account, passkeys, enrolling or using a biometric (no associated domains or Secure Enclave on the simulator), session tasks in the native views, and the update screen that `` shows over the app on iOS and Android when the instance requires a newer version of it (`packages/expo/src/update-required/`, or the native clerk-ios or clerk-android screen when the app does not install `expo-application`; it needs a minimum version for `com.clerk.exponativebuildfixture` in the instance's native app settings, which the standard settings do not set and a settings file cannot declare). +Not mapped yet: SSO through `useSSO` (no real OAuth on simulators), sign-in and sign-up in Account Portal through `useHostedAuth` from `@clerk/expo/hosted-auth`, which opens a browser that no spec drives and the test app has no button for (`packages/expo/src/hooks/useHostedAuth.ts`), a Google or Apple native sign-in with a real account, passkeys, enrolling or using a biometric (no associated domains or Secure Enclave on the simulator), session tasks in the native views. Organizations have no page, because a user of the test app has no way to reach organization UI. This holds for the native components only. `@clerk/expo/web` exports the clerk-js OrganizationProfile, OrganizationSwitcher, OrganizationList, and CreateOrganization for Expo web, but the test app runs native only. `@clerk/expo-native-components` exports AuthView, UserButton, and UserProfileView and no OrganizationSwitcher or OrganizationProfileView. The profile that the UserButton opens lists `Manage account`, `Security`, `Add account`, and `Sign out`, with `Switch account` once a second session exists, and no organization row, in the pinned clerk-ios and clerk-android releases, though the standard settings turn organizations on. The one organization screen that a native view shows is a session task in AuthView, choose or create an organization, which needs `force_organization_selection` on. The harness seeds users, sign-in tickets, and, through `host.seedOrganization`, organizations whose members hold one of the harness roles. It cannot seed invitations, so no spec can reach a choose-organization task that lists an invitation. Today such a state can be shown only with a local patch that is not committed, and the description of the pull request that shows it says so. diff --git a/.claude/skills/verify-clerk-expo/features/update-required.md b/.claude/skills/verify-clerk-expo/features/update-required.md new file mode 100644 index 00000000000..d8bd3601948 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/features/update-required.md @@ -0,0 +1,44 @@ +# Update required + +An instance sets a minimum version for an app. Clerk rejects most requests from a build below it, and `` covers the app with an update screen that shows the build's version and links to the App Store or Google Play. Users stay signed in, so they continue where they left off once the build is allowed again. + +## Sub-features + +- `signed-in` covers a signed-in user's app with the update screen on a build below the minimum, showing `Your version` and the store button, plus `Required` on iOS. Lowering the minimum returns the same user and session. +- `signed-out` covers a signed-out user's app with the update screen too. +- `at-the-minimum` leaves a build at the minimum to open as usual. + +## How to get to it (user POV) + +- A developer sets a minimum on the Clerk Dashboard's `Native applications` page, then a user opens a build below it. + +## Driving it with verify + +Preconditions: + +- The native app settings are off the standard settings, so each test calls `host.useMinimumAppVersion({ minimumVersion, updateUrl })`. It registers `com.clerk.exponativebuildfixture` with the application, sets the minimum, and waits for the public environment to show it. When the test ends, the CLI removes the minimum and leaves the registration. +- Clerk's `native_app_force_update` flag must be on for the instance. It's on globally, and with it off the call fails. +- The test app installs `expo-application` (`integration/templates/expo-native/package.sdk-58.json`), so `@clerk/expo` can read its version. Its `version` is `1.0.0` in `app.json`, and its Android version code is Expo's default of `1`. A minimum is a version on iOS and a whole version code on Android, so the spec blocks it with `2.0` and `2`. + +- **Signed in.** Run `integration/expo-native/bin/control-clerk-expo run update-required --platform ios --grep "signed-in user"` (or `android`). The spec signs in with a ticket, sets the minimum above the build, and relaunches with `keepStorage: true`. It expects `1.0.0`, `Required` on iOS, and the store button (screenshot `update-required`). It then lowers the minimum to the build's version, relaunches, and expects the same user and session ID on the home (screenshot `signed-in-after-lowering`). +- **Signed out.** Run `integration/expo-native/bin/control-clerk-expo run update-required --platform ios --grep "signed-out user"` (or `android`). The spec sets the minimum before the first launch, then expects the update screen and `1.0.0` (screenshot `update-required-signed-out`). +- **At the minimum.** Run `integration/expo-native/bin/control-clerk-expo run update-required --platform ios --grep "at the minimum"` (or `android`). The spec sets the minimum to the build's version, signs in with a ticket, and expects the signed-in home with no update screen (screenshot `at-the-minimum`). +- **Proof.** `specs/features/update-required/update-required.e2e.ts` passes on each platform. The video shows the update screen, then the home with the same user and session ID. + +## States a spec cannot reach + +| State | Where the app decides | What it takes | +| -------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `Can't update? Get help` | `UpdateRequiredView.tsx`, from `getUpdateRequiredDisplay` | A support email on the instance. The Platform API config has no leaf for it, so a spec cannot set one | +| The screen with no store button | `UpdateRequiredView.tsx`, when `status.updateUrl` is empty | A minimum with no update URL. Clerk requires one (`validateNativeAppMinimum` in clerk_go), so only a local patch reaches it | +| An app's own screen instead of Clerk's | `ClerkProvider.tsx`, `updateRequiredViewEnabled` | That prop set to `false` in the test app, and a screen that reads `useAppVersionSupport()` | +| The native clerk-ios or clerk-android screen | `@clerk/expo` falls back to it when the app does not install `expo-application` | A build of the test app without `expo-application`, which this spec no longer uses | + +## Gotchas + +- The version box differs by platform. iOS shows `Your version` and `Required`, and groups each label and value into one element, so `updateRequiredScreen` in `specs/native.ts` uses `getByLabel('Your version, 1.0.0')`. Android shows only `Your version`, as two text nodes, so it uses `getByText('1.0.0')`. +- The store button opens an outside app, so the spec checks that it shows and does not tap it. On Android, an update URL on another host shows `Update app` instead (`storeButtonText`). +- The screen has no test IDs, so the spec finds it by its text. A change to that wording changes the spec. +- A minimum applies to the next request or launch. The spec relaunches instead of waiting for a running app to foreground. +- The subtitle names the application, which here is the `verify-throwaway-...` name. Don't assert on it. +- A launch that the update screen covers needs `landsOn`, because the home isn't on screen. diff --git a/.claude/skills/verify-clerk-expo/references/instances.md b/.claude/skills/verify-clerk-expo/references/instances.md index 037f5a35e2f..0390a107f16 100644 --- a/.claude/skills/verify-clerk-expo/references/instances.md +++ b/.claude/skills/verify-clerk-expo/references/instances.md @@ -40,9 +40,19 @@ Any leaf of the instance config works, not only the leaves in `base.json`. Clerk To find the name of an `environment` leaf, read `environment` in `base.json`. A leaf that is not there has the dotted path of its value in the JSON that the instance's public `/v1/environment` returns. The Platform API cannot set reverification, the development-mode banner, test mode, or PII protection off. +## Require a minimum app version + +A minimum app version isn't a leaf that `host.useSettings` can name, because the bundle ID or package name in its path holds dots and the app must be registered first. A test calls this instead: + +```ts +await host.useMinimumAppVersion({ minimumVersion: '2.0', updateUrl: 'https://apps.apple.com/app/id000000000' }); +``` + +`minimumVersion` is a version on iOS and a whole version code on Android. The CLI registers the app under test with the application, which a later call finds already done, and sets the minimum through the Platform API config key `native_app_minimum_versions`. It then waits until `native_app_settings.minimum_supported_version` in the public `/v1/environment` shows it. Call it again in the same test to change the minimum. Clerk's `native_app_force_update` flag must be on for the instance. + ## Restore -When a test ends, pass or fail, the CLI puts back every leaf that the test changed, to the value it read before the first change. A leaf changed twice goes back to its value before the first change. The next test starts on the standard settings again. +When a test ends, pass or fail, the CLI puts back every leaf that the test changed, to the value it read before the first change. A leaf changed twice goes back to its value before the first change. It deletes any minimum app version the test set and leaves the app registered. The next test starts on the standard settings again. The restore runs in three places, so a crash does not leave the application changed: @@ -50,7 +60,7 @@ The restore runs in three places, so a crash does not leave the application chan - `run` restores after e2e exits, in case e2e was stopped before the fixture could. - The next `run` or `up` restores what a crashed CLI left. The CLI writes the old values to the worktree's state file before it changes anything, so it can always put them back. -`run.json` has one `settings` entry per call: `at` is the spec line that called `host.useSettings`, `changed` lists each leaf with `from` and `to`, `shown` lists the environment leaves that showed the change, and `restored` says the leaves went back. `attach` refuses a run with an entry that was not restored. +`run.json` has one `settings` entry per call: `at` is the spec line that called `host.useSettings` or `host.useMinimumAppVersion`, `changed` lists each leaf with `from` and `to`, `shown` lists the environment leaves that showed the change, and `restored` says the leaves went back. `attach` refuses a run with an entry that was not restored. ## See the settings diff --git a/.claude/skills/verify-clerk-expo/references/output.md b/.claude/skills/verify-clerk-expo/references/output.md index d1895abc7f1..3fb4fbb4e18 100644 --- a/.claude/skills/verify-clerk-expo/references/output.md +++ b/.claude/skills/verify-clerk-expo/references/output.md @@ -40,7 +40,7 @@ On an error the object is `{ "ok": false, "error": { "code", "message", "fix", " | `LEASE_LOST` | The leased device or the remote session is gone | | `BUILD_FAILED` | A step of the test app's build failed, such as `turbo build`, `expo prebuild`, `xcodebuild`, or `gradlew`. The message names the step and has the last 15 lines of its output. With the remote backend, also that GitHub does not have HEAD, that the session could not build the commit, or that the uncommitted changes to the app sources do not apply to it, change a file outside the app sources, or are larger than 16 MiB. A session on `main` refuses uncommitted changes | | `KEYS_MISSING` | No Platform API credential works on this machine, or `AI_GATEWAY_API_KEY_FILE` names a file that is missing, empty, or readable by other users | -| `INSTANCE_MISCONFIGURED` | The Clerk instance does not show the settings a spec needs | +| `INSTANCE_MISCONFIGURED` | The Clerk instance does not show the settings a spec needs, or Clerk refused the change that a `host.useSettings` or `host.useMinimumAppVersion` call asked for | | `NOT_TEST_IDENTITY` | Reserved. No command raises it. A run whose `app.log` names a user that the run did not create fails `attach` with `EVIDENCE_UNSAFE` | | `NO_SPECS` | The selection matched no spec, or no selected spec ran | | `E2E_CRASHED` | e2e exited without a readable report. Read `e2e.log` in the run directory | diff --git a/integration/expo-native/README.md b/integration/expo-native/README.md index 34cf1bdbbeb..01067744d94 100644 --- a/integration/expo-native/README.md +++ b/integration/expo-native/README.md @@ -40,7 +40,7 @@ To run against a dev client instead, `integration/expo-native/bin/control-clerk- ## The instance -The tests expect the settings in `src/core/instances/base.json` of `@clerk/internal-mobile-e2e`, which the CLI puts on the application it creates. On an instance with those settings, every test that does not call `host.useSettings` passes by hand. +The tests expect the settings in `src/core/instances/base.json` of `@clerk/internal-mobile-e2e`, which the CLI puts on the application it creates. On an instance with those settings, every test that calls neither `host.useSettings` nor `host.useMinimumAppVersion` passes by hand. An instance left at Clerk's defaults passes only part of the suite. The tests that sign in with a ticket, or sign up with an email address and a password, pass. The tests that need a phone number, an email code at sign-in, organizations, or a second account on the device fail, because a new instance has none of those turned on. @@ -48,11 +48,12 @@ Every run creates users with addresses like `maya.7c1e+clerk_test@example.com` a ## Test files that need the CLI -This test file calls `host.useSettings` to change the instance's settings, and passes only under the CLI: +These test files call `host.useSettings` or `host.useMinimumAppVersion` to change the instance, and pass only under the CLI: - `specs/features/native-modules/biometric-availability.e2e.ts` +- `specs/features/update-required/update-required.e2e.ts` -The CLI holds the Platform API key that changes the settings, and it puts them back when the test ends. In a run by hand, `host.useSettings` fails, because e2e alone has no such key. Run it through the CLI, for example `integration/expo-native/bin/control-clerk-expo run native-modules` from the repo root. +The CLI holds the Platform API key that changes the settings, and it puts them back when the test ends. In a run by hand, both fail, because e2e alone has no such key. Run them through the CLI, for example `integration/expo-native/bin/control-clerk-expo run native-modules` from the repo root. ## What a run by hand does not do diff --git a/integration/expo-native/package-lock.json b/integration/expo-native/package-lock.json index c50c9f356e2..ee27fe6a6c1 100644 --- a/integration/expo-native/package-lock.json +++ b/integration/expo-native/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "verify-clerk-expo", "devDependencies": { - "@clerk/internal-mobile-e2e": "github:clerk/internal-mobile-e2e#0889a2d95f23f0bb43a615a931d4dca742f9acba", + "@clerk/internal-mobile-e2e": "github:clerk/internal-mobile-e2e#95259b24be280f8c9aaf293c18391e9786c1b77f", "@types/node": "24.19.1", "typescript": "7.0.2" }, @@ -122,8 +122,8 @@ } }, "node_modules/@clerk/internal-mobile-e2e": { - "resolved": "git+ssh://git@github.com/clerk/internal-mobile-e2e.git#0889a2d95f23f0bb43a615a931d4dca742f9acba", - "integrity": "sha512-3XTTA8cOTRZMVCwZMMWxzHVxMG1V0Lv22e5ZZmfZ7mOajWd8CMWdmt1qFMNdxyU8bfnsPhIELr3zte0wLa2v2A==", + "resolved": "git+ssh://git@github.com/clerk/internal-mobile-e2e.git#95259b24be280f8c9aaf293c18391e9786c1b77f", + "integrity": "sha512-f/XzZrRW1OXx++rkgF5nEcfXju4wGH/r7lzrOowe70Jo+jL6CZtAaGNrODaFmhUxgf+fAJhAENP1ZoK6dACpUQ==", "dev": true, "dependencies": { "@e2e-dev/github": "0.4.0", diff --git a/integration/expo-native/package.json b/integration/expo-native/package.json index 5a848746349..dae28b2149b 100644 --- a/integration/expo-native/package.json +++ b/integration/expo-native/package.json @@ -7,7 +7,7 @@ "typecheck": "tsc -p ." }, "devDependencies": { - "@clerk/internal-mobile-e2e": "github:clerk/internal-mobile-e2e#0889a2d95f23f0bb43a615a931d4dca742f9acba", + "@clerk/internal-mobile-e2e": "github:clerk/internal-mobile-e2e#95259b24be280f8c9aaf293c18391e9786c1b77f", "@types/node": "24.19.1", "typescript": "7.0.2" }, diff --git a/integration/expo-native/specs/features/update-required/update-required.e2e.ts b/integration/expo-native/specs/features/update-required/update-required.e2e.ts new file mode 100644 index 00000000000..ce94fbd2b37 --- /dev/null +++ b/integration/expo-native/specs/features/update-required/update-required.e2e.ts @@ -0,0 +1,66 @@ +import { test, expect } from '../../fixtures.ts'; +import { updateRequiredScreen } from '../../native.ts'; + +const TEST_APP_VERSION = '1.0.0'; +const IOS = { + atTheBuild: TEST_APP_VERSION, + aboveTheBuild: '2.0', + updateUrl: 'https://apps.apple.com/app/id000000000', +}; +const ANDROID = { + atTheBuild: '1', + aboveTheBuild: '2', + updateUrl: 'https://play.google.com/store/apps/details?id=com.clerk.exponativebuildfixture', +}; +const minimumsFor = (platform: string) => (platform === 'ios' ? IOS : ANDROID); + +test('a signed-in user on a build below the minimum sees the update screen, and is still signed in once the minimum is lowered', async ({ + host, + screen, + platform, +}) => { + const { atTheBuild, aboveTheBuild, updateUrl } = minimumsFor(platform); + const updateRequired = updateRequiredScreen(screen, platform); + const user = await host.seedUser(); + await host.launch({ signedInAs: user }); + const sessionId = (await host.app.sessionId.textContent()) ?? ''; + expect(sessionId).not.toBe(''); + await host.evidence( + 'the update screen covers the app, and the same session returns once the minimum is lowered', + async () => { + await host.useMinimumAppVersion({ minimumVersion: aboveTheBuild, updateUrl }); + await host.launch({ keepStorage: true, landsOn: updateRequired.title }); + await expect(updateRequired.yourVersion(TEST_APP_VERSION)).toBeVisible(); + if (platform === 'ios') { + await expect(updateRequired.required(aboveTheBuild)).toBeVisible(); + } + await expect(updateRequired.storeButton).toBeVisible(); + await host.screenshot('update-required'); + + await host.useMinimumAppVersion({ minimumVersion: atTheBuild, updateUrl }); + await host.launch({ keepStorage: true, landsOn: host.app.signedIn }); + await host.expectSignedInAs(user); + await expect(host.app.sessionId).toHaveText(sessionId); + await expect(updateRequired.title).toHaveCount(0); + await host.screenshot('signed-in-after-lowering'); + }, + ); +}); + +test('a signed-out user on a build below the minimum sees the update screen', async ({ host, screen, platform }) => { + const { aboveTheBuild, updateUrl } = minimumsFor(platform); + const updateRequired = updateRequiredScreen(screen, platform); + await host.useMinimumAppVersion({ minimumVersion: aboveTheBuild, updateUrl }); + await host.launch({ landsOn: updateRequired.title }); + await expect(updateRequired.yourVersion(TEST_APP_VERSION)).toBeVisible(); + await host.screenshot('update-required-signed-out'); +}); + +test('a build at the minimum opens as usual', async ({ host, screen, platform }) => { + const { atTheBuild, updateUrl } = minimumsFor(platform); + await host.useMinimumAppVersion({ minimumVersion: atTheBuild, updateUrl }); + const user = await host.seedUser(); + await host.launch({ signedInAs: user }); + await expect(updateRequiredScreen(screen, platform).title).toHaveCount(0); + await host.screenshot('at-the-minimum'); +}); diff --git a/integration/expo-native/specs/native.ts b/integration/expo-native/specs/native.ts index 96b48fab5d8..bc8de03c2b6 100644 --- a/integration/expo-native/specs/native.ts +++ b/integration/expo-native/specs/native.ts @@ -107,6 +107,17 @@ export function homeLinks(screen: Screen) { }; } +export function updateRequiredScreen(screen: Screen, platform: string) { + const ios = platform === 'ios'; + return { + title: screen.getByText('Update required'), + yourVersion: (version: string): Locator => + ios ? screen.getByLabel(`Your version, ${version}`) : screen.getByText(version), + required: (minimum: string): Locator => screen.getByLabel(`Required, ${minimum}+`), + storeButton: screen.getByText(ios ? 'Go to App Store' : 'Go to Google Play'), + }; +} + export function nativeUserButton(screen: Screen, platform: string): Locator { return platform === 'ios' ? screen.getByTestId('clerk.userButton.profile') : screen.getByLabel('Open user profile'); } diff --git a/integration/templates/expo-native/package.sdk-55.json b/integration/templates/expo-native/package.sdk-55.json index 86ee715c2c8..c9b1f9277f8 100644 --- a/integration/templates/expo-native/package.sdk-55.json +++ b/integration/templates/expo-native/package.sdk-55.json @@ -11,6 +11,7 @@ }, "dependencies": { "expo": "~55.0.26", + "expo-application": "~55.0.15", "react": "19.2.0", "react-native": "0.83.6" }, diff --git a/integration/templates/expo-native/package.sdk-58.json b/integration/templates/expo-native/package.sdk-58.json index 05f819ff186..076279c3b49 100644 --- a/integration/templates/expo-native/package.sdk-58.json +++ b/integration/templates/expo-native/package.sdk-58.json @@ -11,6 +11,7 @@ }, "dependencies": { "expo": "~58.0.12", + "expo-application": "~58.1.1", "react": "19.3.0", "react-dom": "19.3.0", "react-native": "0.88.1" diff --git a/integration/templates/expo-native/verify/VerifyHost.tsx b/integration/templates/expo-native/verify/VerifyHost.tsx index fba05df76d3..ca0e8aab1a6 100644 --- a/integration/templates/expo-native/verify/VerifyHost.tsx +++ b/integration/templates/expo-native/verify/VerifyHost.tsx @@ -1,6 +1,7 @@ import { ClerkProvider, isClerkAPIResponseError, + useAppVersionSupport, useAuth, useClerk, useSession, @@ -98,6 +99,7 @@ function useVerifyLog(state: VerifyState): void { function RoutedHost({ launch, clientTokenKeptAtLaunch }: RoutedHostProps): ReactElement { const status = useClerkStatus(); + const { isSupported: isAppVersionSupported } = useAppVersionSupport(); const { isLoaded, isSignedIn, orgId } = useAuth(); const { user } = useUser(); const { session } = useSession(); @@ -109,14 +111,22 @@ function RoutedHost({ launch, clientTokenKeptAtLaunch }: RoutedHostProps): React const [ticket, setTicket] = useState(launch.signInTicket ? 'pending' : 'none'); const [failure, setFailure] = useState(null); const ticketStarted = useRef(false); + const loadFailedWhileBlocked = useRef(false); useEffect(() => { if (status !== 'error') { + loadFailedWhileBlocked.current = false; + return; + } + if (!isAppVersionSupported) { + loadFailedWhileBlocked.current = true; + } + if (loadFailedWhileBlocked.current) { return; } setFailure({ code: 'environment_load_failed', message: 'Clerk failed to load the environment.' }); setTicket(current => (current === 'pending' ? 'failed' : current)); - }, [status]); + }, [status, isAppVersionSupported]); useEffect(() => { const signInTicket = launch.signInTicket;