Fix PR OTA deployments reverting after apply (#11338)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -94,9 +94,9 @@ export function useIntentHandler() {
|
||||
: null
|
||||
if (!channel) {
|
||||
Alert.alert('Error', 'No channel provided to look for.')
|
||||
} else {
|
||||
tryApplyUpdate(channel, appVersion)
|
||||
return
|
||||
}
|
||||
tryApplyUpdate(channel, appVersion)
|
||||
return
|
||||
}
|
||||
default: {
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
fetchUpdateAsync,
|
||||
reloadAsync,
|
||||
setExtraParamAsync,
|
||||
UpdateCheckResultNotAvailableReason,
|
||||
useUpdates,
|
||||
} from 'expo-updates'
|
||||
import {act, renderHook, waitFor} from '@testing-library/react-native'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {APP_VERSION} from '#/env'
|
||||
import {device} from '#/storage'
|
||||
import {
|
||||
useApplyPullRequestOTAUpdate,
|
||||
@@ -22,6 +24,7 @@ jest.mock('expo-updates', () => ({
|
||||
reloadAsync: jest.fn(),
|
||||
setExtraParamAsync: jest.fn(),
|
||||
UpdateCheckResultNotAvailableReason: {
|
||||
NO_UPDATE_AVAILABLE_ON_SERVER: 'noUpdateAvailableOnServer',
|
||||
UPDATE_PREVIOUSLY_FAILED: 'updatePreviouslyFailed',
|
||||
},
|
||||
useUpdates: jest.fn(),
|
||||
@@ -42,25 +45,156 @@ jest.mock('#/storage', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const currentUpdate = {
|
||||
channel: 'testflight',
|
||||
emergencyLaunchReason: null,
|
||||
isEmbeddedLaunch: false,
|
||||
isEmergencyLaunch: false,
|
||||
updateId: 'current-update',
|
||||
/**
|
||||
* `channel` here is the build-time constant baked into the native build, not the
|
||||
* channel of the running bundle. `channel` is passed as the manifest metadata
|
||||
* channel our update server stamps into every published update - omit it to
|
||||
* simulate an embedded launch, which has no server manifest.
|
||||
*/
|
||||
function mockCurrentlyRunning({
|
||||
buildChannel = 'testflight',
|
||||
channel,
|
||||
updateId = 'current-update',
|
||||
}: {
|
||||
buildChannel?: string
|
||||
channel?: string
|
||||
updateId?: string
|
||||
} = {}) {
|
||||
const currentlyRunning = {
|
||||
channel: buildChannel,
|
||||
emergencyLaunchReason: null,
|
||||
isEmbeddedLaunch: !channel,
|
||||
isEmergencyLaunch: false,
|
||||
updateId,
|
||||
manifest: channel ? {id: updateId, metadata: {channel}} : undefined,
|
||||
}
|
||||
jest.mocked(useUpdates).mockReturnValue({
|
||||
currentlyRunning,
|
||||
} as ReturnType<typeof useUpdates>)
|
||||
return currentlyRunning
|
||||
}
|
||||
|
||||
const currentUpdate = {updateId: 'current-update'}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.mocked(useUpdates).mockReturnValue({
|
||||
currentlyRunning: currentUpdate,
|
||||
} as ReturnType<typeof useUpdates>)
|
||||
mockCurrentlyRunning()
|
||||
jest.mocked(setExtraParamAsync).mockResolvedValue(undefined)
|
||||
jest.mocked(reloadAsync).mockResolvedValue(undefined)
|
||||
jest.spyOn(Alert, 'alert').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
describe('useApplyPullRequestOTAUpdate', () => {
|
||||
it('detects a running PR deployment from the manifest metadata', () => {
|
||||
mockCurrentlyRunning({
|
||||
buildChannel: 'testflight',
|
||||
channel: 'pull-request-123',
|
||||
})
|
||||
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
expect(result.current.currentChannel).toBe('pull-request-123')
|
||||
expect(result.current.isCurrentlyRunningPullRequestDeployment).toBe(true)
|
||||
expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a standard downloaded update as a standard channel', () => {
|
||||
mockCurrentlyRunning({buildChannel: 'testflight', channel: 'testflight'})
|
||||
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
expect(result.current.currentChannel).toBe('testflight')
|
||||
expect(result.current.isCurrentlyRunningPullRequestDeployment).toBe(false)
|
||||
expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the build channel for an embedded launch', () => {
|
||||
mockCurrentlyRunning({buildChannel: 'testflight'})
|
||||
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
expect(result.current.currentChannel).toBe('testflight')
|
||||
expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false)
|
||||
})
|
||||
|
||||
it('reports no channel when updates are disabled', () => {
|
||||
mockCurrentlyRunning({buildChannel: ''})
|
||||
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
expect(result.current.currentChannel).toBeUndefined()
|
||||
expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false)
|
||||
})
|
||||
|
||||
it('stays quiet when already running the latest of the requested channel', async () => {
|
||||
mockCurrentlyRunning({
|
||||
buildChannel: 'testflight',
|
||||
channel: 'pull-request-123',
|
||||
})
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: false,
|
||||
reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
|
||||
expect(Alert.alert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when no deployment is available for a different channel', async () => {
|
||||
mockCurrentlyRunning({
|
||||
buildChannel: 'testflight',
|
||||
channel: 'pull-request-123',
|
||||
})
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: false,
|
||||
reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-456'))
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'No Deployment Available',
|
||||
expect.stringContaining('pull-request-456'),
|
||||
)
|
||||
})
|
||||
|
||||
it('stays silent on a re-fired intent even when the app version differs', async () => {
|
||||
mockCurrentlyRunning({
|
||||
buildChannel: 'testflight',
|
||||
channel: 'pull-request-123',
|
||||
})
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: false,
|
||||
reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
|
||||
|
||||
expect(Alert.alert).not.toHaveBeenCalled()
|
||||
expect(fetchUpdateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prompts to apply an available update when the app version matches', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() =>
|
||||
result.current.tryApplyUpdate('pull-request-123', APP_VERSION),
|
||||
)
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Apply update from PR #123?',
|
||||
expect.stringContaining('relaunch'),
|
||||
expect.arrayContaining([expect.objectContaining({text: 'Apply'})]),
|
||||
)
|
||||
})
|
||||
|
||||
it('warns before applying an OTA built for a different app version', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
@@ -74,7 +208,6 @@ describe('useApplyPullRequestOTAUpdate', () => {
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
|
||||
|
||||
expect(checkForUpdateAsync).not.toHaveBeenCalled()
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'App Version Mismatch',
|
||||
expect.stringContaining('Applying it anyway may cause'),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {Alert, AppState, type AppStateStatus} from 'react-native'
|
||||
import {nativeBuildVersion} from 'expo-application'
|
||||
import {
|
||||
checkForUpdateAsync,
|
||||
type CurrentlyRunningInfo,
|
||||
fetchUpdateAsync,
|
||||
isEnabled,
|
||||
reloadAsync,
|
||||
@@ -36,6 +37,32 @@ function getDeploymentName(channel: string) {
|
||||
return pullRequestNumber ? `PR #${pullRequestNumber}` : channel
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel of the update bundle that is actually running. The
|
||||
* `currentlyRunning.channel` constant only reflects the channel baked into the
|
||||
* native build config, so a manually applied deployment (e.g. a pull request
|
||||
* channel) must be detected from the manifest metadata our update server stamps
|
||||
* into every published update. Embedded launches have no server manifest and
|
||||
* fall back to the build constant.
|
||||
*/
|
||||
function getRunningChannel(
|
||||
currentlyRunning: CurrentlyRunningInfo | undefined,
|
||||
): string | undefined {
|
||||
/*
|
||||
* `metadata` is typed as a bare `object` by expo-manifests, and is absent
|
||||
* entirely from embedded manifests, so narrow it ourselves.
|
||||
*/
|
||||
const manifest = currentlyRunning?.manifest as
|
||||
| {metadata?: {channel?: unknown}}
|
||||
| undefined
|
||||
const channel = manifest?.metadata?.channel
|
||||
if (typeof channel === 'string' && channel) {
|
||||
return channel
|
||||
}
|
||||
// The build constant is an empty string rather than null when unconfigured.
|
||||
return currentlyRunning?.channel || undefined
|
||||
}
|
||||
|
||||
async function setExtraParams() {
|
||||
await setExtraParamAsync(
|
||||
IS_IOS ? 'ios-build-number' : 'android-build-number',
|
||||
@@ -85,12 +112,12 @@ async function updateTestflight() {
|
||||
export function useApplyPullRequestOTAUpdate() {
|
||||
const {currentlyRunning} = useUpdates()
|
||||
const [pending, setPending] = useState(false)
|
||||
const currentChannel = currentlyRunning?.channel
|
||||
const currentChannel = getRunningChannel(currentlyRunning)
|
||||
const isCurrentlyRunningPullRequestDeployment =
|
||||
currentChannel?.startsWith('pull-request')
|
||||
/*
|
||||
* Covers pull request deployments as well as any other channel we manually
|
||||
* applied an update from. Note that `channel` is null when updates are
|
||||
* applied an update from. Note that the channel is undefined when updates are
|
||||
* disabled (e.g. in dev), in which case there's nothing to restore.
|
||||
*/
|
||||
const isCurrentlyRunningNonStandardChannel = Boolean(
|
||||
@@ -141,6 +168,12 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
updateId: fetchedUpdate.manifest.id,
|
||||
})
|
||||
try {
|
||||
/*
|
||||
* TODO: once expo-linking is upgraded to >= 57, enable this so the
|
||||
* re-delivered initial URL doesn't trigger a redundant silent check
|
||||
* after the reload.
|
||||
*/
|
||||
// Linking.clearInitialURL()
|
||||
await reloadAsync()
|
||||
} catch (e) {
|
||||
device.remove(['pendingOTAUpdate'])
|
||||
@@ -159,29 +192,35 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
})()
|
||||
}
|
||||
|
||||
if (declaredAppVersion && declaredAppVersion !== APP_VERSION) {
|
||||
Alert.alert(
|
||||
'App Version Mismatch',
|
||||
`This OTA update was built for a different version of the app.\n\nCurrent app version: ${APP_VERSION}\nOTA app version: ${declaredAppVersion}\n\nApplying it anyway may cause the app to stop working and require a reinstall.`,
|
||||
[
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Apply Anyway',
|
||||
style: 'destructive',
|
||||
onPress: applyUpdate,
|
||||
},
|
||||
],
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
* Check before prompting about anything, so that re-running this while
|
||||
* already on the newest update of `channel` stays silent. Reloading into an
|
||||
* update re-delivers the deep link that triggered it, and the same link may
|
||||
* also just be tapped again.
|
||||
*/
|
||||
setPending(true)
|
||||
try {
|
||||
if (!(await checkForDeployment())) return
|
||||
|
||||
if (declaredAppVersion && declaredAppVersion !== APP_VERSION) {
|
||||
Alert.alert(
|
||||
'App Version Mismatch',
|
||||
`This OTA update was built for a different version of the app.\n\nCurrent app version: ${APP_VERSION}\nOTA app version: ${declaredAppVersion}\n\nApplying it anyway may cause the app to stop working and require a reinstall.`,
|
||||
[
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Apply Anyway',
|
||||
style: 'destructive',
|
||||
onPress: applyUpdate,
|
||||
},
|
||||
],
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
`Apply update from ${deploymentName}?`,
|
||||
'The app will relaunch after the update is applied.',
|
||||
@@ -301,7 +340,7 @@ export function useOTAUpdates() {
|
||||
const ranInitialCheck = useRef(false)
|
||||
const timeout = useRef<NodeJS.Timeout>(undefined)
|
||||
const {currentlyRunning, isUpdatePending} = useUpdates()
|
||||
const currentChannel = currentlyRunning?.channel
|
||||
const currentChannel = getRunningChannel(currentlyRunning)
|
||||
|
||||
const setCheckTimeout = useCallback(() => {
|
||||
timeout.current = setTimeout(async () => {
|
||||
|
||||
Reference in New Issue
Block a user