mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 08:02:32 +00:00
* fix(mobile): the update-mobile wall opens the exact release When the in-app checker knows the newest release, the update-mobile wall offers "Get Orca <version>" and opens that release's page instead of the generic releases list or a hand-built App Store link. Mounting the wall runs the single-flight, bounded checker once. Dismissal is ignored here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the update checker out of the page closure The wall now takes the offered release as a prop and opens it through the external-link seam. A shell-only hook runs the checker once per update-mobile wall and supplies the release; MobileWebShellScreen, which no page route reaches, wires it. HostProtocolGate is in every page closure, so it stays unwired pending a ruling. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): platform-split the wall's release offer and throttle its check The hook is the one module whose behaviour differs by platform: the native file runs the checker, the .web.ts sibling offers nothing, so both HostProtocolGate and MobileWebShellScreen wire it the same way and the page closure never carries the checker. A remount checks only when no release is known and the last check is absent or over the retry interval old, since each check is an unauthenticated GitHub call. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the checker decide whether the wall's check is due The hook's own throttle read lastCheckedAt, which moves only on success, so after a failed lookup every wall remount looked up again, and before preferences loaded a cold-start wall looked up despite a fresh stored result. The checker already owns the cadence: checkIfDue waits for the stored state and checks only past the retry or daily interval. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the wall reads the known release and triggers nothing The started checker's own timer, cold-start and foreground paths already run every due check, so a check requested by the wall could never be due. The hook now only returns the known release; the checker is back to main. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): the wall reads its release through useWallAppUpdate The .web.ts sibling alone keeps the page closure clean, so the screen calls the hook itself and the optional prop, the gate wiring and the shell wiring go. HostProtocolGate and MobileWebShellScreen are back to their base content. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
234 lines
8.8 KiB
TypeScript
234 lines
8.8 KiB
TypeScript
import { createElement } from 'react'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { KnownAppUpdate } from '../storage/app-update-preferences'
|
|
import type { BlockedVerdict } from './ProtocolBlockScreen'
|
|
import { ProtocolBlockScreen } from './ProtocolBlockScreen'
|
|
|
|
const nativeTestState = vi.hoisted(() => {
|
|
// Declared wide so a test can switch stores; an assertion here would only widen the same literal.
|
|
const platform: { OS: 'ios' | 'android' } = { OS: 'ios' }
|
|
return { openUrl: vi.fn(), platform }
|
|
})
|
|
|
|
const wallUpdate = vi.hoisted(() => {
|
|
const state: { current: KnownAppUpdate | null } = { current: null }
|
|
return state
|
|
})
|
|
|
|
// The release the installed app's update check found; the real hook imports the checker module.
|
|
vi.mock('../app-update/use-wall-app-update', () => ({ useWallAppUpdate: () => wallUpdate.current }))
|
|
|
|
vi.mock('react-native', () => ({
|
|
Linking: { openURL: nativeTestState.openUrl },
|
|
Platform: nativeTestState.platform,
|
|
Pressable: 'Pressable',
|
|
StyleSheet: { create: <T>(styles: T) => styles },
|
|
Text: 'Text',
|
|
View: 'View'
|
|
}))
|
|
|
|
vi.mock('expo-router', () => ({
|
|
router: { replace: vi.fn() },
|
|
// `ProtocolBlockScreen` reaches the router through the navigation handoff now, and the handoff's
|
|
// native form is this hook. Its web form is what posts the target to the shell.
|
|
useRouter: () => ({ replace: vi.fn(), push: vi.fn(), back: vi.fn(), dismissTo: vi.fn() })
|
|
}))
|
|
|
|
const RELEASES_URL = 'https://github.com/stablyai/orca/releases'
|
|
|
|
let renderer: ReactTestRenderer | null = null
|
|
|
|
function render(verdict: BlockedVerdict, mobileUpdate: KnownAppUpdate | null = null): string {
|
|
wallUpdate.current = mobileUpdate
|
|
act(() => {
|
|
renderer = create(createElement(ProtocolBlockScreen, { verdict }))
|
|
})
|
|
return JSON.stringify(renderer?.toJSON())
|
|
}
|
|
|
|
/** The mocked host components are plain strings, which `ElementType` does not admit. */
|
|
function isMockedHostElement(type: unknown, name: string): boolean {
|
|
return type === name
|
|
}
|
|
|
|
function pressableCount(): number {
|
|
return renderer?.root.findAll((node) => isMockedHostElement(node.type, 'Pressable')).length ?? 0
|
|
}
|
|
|
|
function primaryActionUrl(): unknown {
|
|
const pressable = renderer?.root.findAll((node) => isMockedHostElement(node.type, 'Pressable'))[0]
|
|
act(() => pressable?.props.onPress())
|
|
return nativeTestState.openUrl.mock.calls[0]?.[0]
|
|
}
|
|
|
|
describe('ProtocolBlockScreen', () => {
|
|
beforeEach(() => {
|
|
nativeTestState.openUrl.mockClear()
|
|
nativeTestState.platform.OS = 'ios'
|
|
})
|
|
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
renderer = null
|
|
})
|
|
|
|
// Why: the protocol wall shipped before the bundle one; its copy is what users already see.
|
|
it('keeps the existing protocol wall rendering unchanged', () => {
|
|
const mobile = render({
|
|
kind: 'blocked',
|
|
reason: 'mobile-too-old',
|
|
desktopVersion: 5,
|
|
requiredMobileVersion: 99
|
|
})
|
|
expect(mobile).toContain('Update Orca Mobile')
|
|
expect(mobile).toContain(
|
|
'This desktop needs a newer Orca Mobile app. Update Orca Mobile from the App Store, then try this host again.'
|
|
)
|
|
expect(mobile).toContain('Open App Store')
|
|
act(() => renderer?.unmount())
|
|
|
|
const desktop = render({
|
|
kind: 'blocked',
|
|
reason: 'desktop-too-old',
|
|
desktopVersion: 0,
|
|
requiredDesktopVersion: 2
|
|
})
|
|
expect(desktop).toContain('Update Orca on your computer')
|
|
expect(desktop).toContain(
|
|
'This paired desktop app is too old for your current Orca Mobile app. Update Orca on your computer, then try this host again.'
|
|
)
|
|
expect(desktop).toContain('Open GitHub Releases')
|
|
})
|
|
|
|
it('sends a host without a bundle to the desktop update', () => {
|
|
const output = render({ kind: 'blocked', reason: 'bundle-unavailable' })
|
|
expect(output).toContain('Update Orca on your computer')
|
|
expect(output).toContain(
|
|
'This paired desktop app does not include the mobile workspace yet. Update Orca on your computer, then try this host again.'
|
|
)
|
|
expect(primaryActionUrl()).toBe(RELEASES_URL)
|
|
})
|
|
|
|
it('sends an unknown manifest schema to the mobile update', () => {
|
|
const output = render({
|
|
kind: 'blocked',
|
|
reason: 'bundle-shell-too-old',
|
|
schemaVersion: 2
|
|
})
|
|
expect(output).toContain('Update Orca Mobile')
|
|
expect(output).toContain(
|
|
"This desktop's mobile workspace needs a newer Orca Mobile app. Update Orca Mobile from the App Store, then try this host again."
|
|
)
|
|
expect(primaryActionUrl()).toBe('itms-apps://apps.apple.com/app/orca-ide/id6766130217')
|
|
})
|
|
|
|
it('offers no download for a cached bundle the host outgrew, because none would clear it', () => {
|
|
const output = render({
|
|
kind: 'blocked',
|
|
reason: 'bundle-incompatible',
|
|
side: 'mobile',
|
|
bundleRuntimeProtocolVersion: 3,
|
|
requiredBundleRuntimeProtocolVersion: 4
|
|
})
|
|
|
|
expect(output).toContain('Refresh the mobile workspace')
|
|
expect(output).toContain(
|
|
'The workspace cached for this host is older than the desktop expects. Reconnect to this host to download the current one.'
|
|
)
|
|
// A store update cannot replace a stale cache, so neither store link is offered.
|
|
expect(output).not.toContain('Open App Store')
|
|
expect(output).not.toContain('Open GitHub Releases')
|
|
expect(output).not.toContain('Update Orca')
|
|
// Back to hosts is the only button left, and it is not a download.
|
|
expect(pressableCount()).toBe(1)
|
|
expect(output).toContain('Back to hosts')
|
|
// Nothing was "already updated" here; the note keeps only the pairing fallback.
|
|
expect(output).not.toContain('Already updated?')
|
|
expect(output).toContain('If this message stays, remove this host and pair it again.')
|
|
})
|
|
|
|
it('sends a host older than its own bundle to the desktop update', () => {
|
|
const output = render({
|
|
kind: 'blocked',
|
|
reason: 'bundle-incompatible',
|
|
side: 'desktop',
|
|
hostProtocolVersion: 1,
|
|
requiredHostProtocolVersion: 2
|
|
})
|
|
expect(output).toContain('Update Orca on your computer')
|
|
expect(output).toContain('This paired desktop app is too old for your current Orca Mobile app')
|
|
expect(primaryActionUrl()).toBe(RELEASES_URL)
|
|
})
|
|
|
|
it('sends a desktop whose page is older than this shell to the desktop update', () => {
|
|
const output = render({
|
|
kind: 'blocked',
|
|
reason: 'bundle-incompatible',
|
|
side: 'desktop',
|
|
pageVersion: 0,
|
|
requiredPageVersion: 1
|
|
})
|
|
expect(output).toContain('Update Orca on your computer')
|
|
expect(output).toContain('This paired desktop app is too old for your current Orca Mobile app')
|
|
expect(primaryActionUrl()).toBe(RELEASES_URL)
|
|
})
|
|
|
|
it('routes an Android bundle wall to GitHub Releases, not a store that has no listing', () => {
|
|
nativeTestState.platform.OS = 'android'
|
|
const output = render({
|
|
kind: 'blocked',
|
|
reason: 'bundle-shell-too-old',
|
|
schemaVersion: 2
|
|
})
|
|
expect(output).toContain('Update Orca Mobile from GitHub Releases')
|
|
expect(primaryActionUrl()).toBe(RELEASES_URL)
|
|
})
|
|
|
|
it('keeps the update walls on two buttons and the full recovery note', () => {
|
|
const output = render({ kind: 'blocked', reason: 'bundle-unavailable' })
|
|
expect(output).toContain('Already updated? Go back to Hosts and refresh the connection.')
|
|
// The presence precondition for the absence asserted on the refresh wall above.
|
|
expect(pressableCount()).toBe(2)
|
|
})
|
|
|
|
describe('with the newest release known', () => {
|
|
const release = {
|
|
version: '0.0.52',
|
|
url: 'https://github.com/stablyai/orca/releases/tag/mobile-v0.0.52'
|
|
}
|
|
|
|
it.each(['ios', 'android'] as const)('opens that exact release on %s', (os) => {
|
|
nativeTestState.platform.OS = os
|
|
const output = render(
|
|
{ kind: 'blocked', reason: 'bundle-shell-too-old', schemaVersion: 2 },
|
|
release
|
|
)
|
|
expect(output).toContain('Get Orca 0.0.52')
|
|
expect(output).not.toContain('Open App Store')
|
|
expect(output).not.toContain('Open GitHub Releases')
|
|
expect(primaryActionUrl()).toBe(release.url)
|
|
})
|
|
|
|
it('leaves the desktop wall on the desktop releases', () => {
|
|
const output = render({ kind: 'blocked', reason: 'bundle-unavailable' }, release)
|
|
expect(output).not.toContain('Get Orca')
|
|
expect(primaryActionUrl()).toBe(RELEASES_URL)
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
['ios', 'Open App Store', 'itms-apps://apps.apple.com/app/orca-ide/id6766130217'],
|
|
['android', 'Open GitHub Releases', RELEASES_URL]
|
|
] as const)('keeps the %s store link when no release is known', (os, label, url) => {
|
|
nativeTestState.platform.OS = os
|
|
const output = render(
|
|
{ kind: 'blocked', reason: 'mobile-too-old', desktopVersion: 5, requiredMobileVersion: 9 },
|
|
null
|
|
)
|
|
expect(output).toContain(label)
|
|
expect(output).not.toContain('Get Orca')
|
|
expect(primaryActionUrl()).toBe(url)
|
|
})
|
|
})
|