mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
fix(mobile): make the Android hosted origin a canonical host label
The Android private origin's host label is the session id's prefix, but the ids were base64url. `https` is a special scheme, so Chromium ASCII-lowercases the host of every URL it loads and reports back, while `Uri.parse` keeps the mixed case the shell derived: `serveRequest`'s origin check rejected the main document and answered 403, which Android renders as `net::ERR_HTTP_RESPONSE_CODE_FAILURE`. `java.net.URI.getHost()` is also null for a label holding `_`, so the same ids dropped every bridge message. Session ids now come from a lowercase base32 alphabet, `mobileWebOriginForSession` rejects anything a URL host cannot carry, and host comparisons are case-insensitive. A failed main-frame document no longer stops at Chromium's error page: the shell hides the WebView and reports `failed` with a reason the React Native shell shows. iOS uses a custom scheme, whose opaque host preserves case and `_`, which is why only the Android lane saw this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -152,6 +152,17 @@ external-link authority.
|
||||
- Top-level navigation is restricted to the active document. External
|
||||
navigation, popups, downloads, workers, and arbitrary bridge origins fail
|
||||
closed.
|
||||
- The Android session id *is* the private origin's host label, so it is drawn
|
||||
from a canonical hostname alphabet (lowercase base32). `https` is a special
|
||||
scheme: Chromium ASCII-lowercases the host of every URL it loads and reports
|
||||
back, and `java.net.URI.getHost()` is null for a label holding `_`. A
|
||||
base64url id therefore lost every asset request to a 403 and dropped every
|
||||
bridge message (`MobileWebOrigin.kt`, `MobileWebSessionIdentifier.kt`). iOS
|
||||
uses a custom scheme, whose opaque host preserves both, which is why the iOS
|
||||
lane never saw it.
|
||||
- A failed main-frame document does not stop at Chromium's error page: the
|
||||
Android shell hides the WebView and reports `onLoadState` `failed` with a
|
||||
reason code the React Native shell shows instead.
|
||||
|
||||
### Capability bridge
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { MobileWebHealthDeadline } from '../src/mobile-web/mobile-web-health-dea
|
||||
import { useMobileWebAlertSafePackageSession } from '../src/mobile-web/use-mobile-web-alert-safe-package-session'
|
||||
import { createMobileWebNativeCapabilityAuthority } from '../src/mobile-web/mobile-web-native-capability-authority'
|
||||
import { MobileWebHybridShellPresentation } from '../src/mobile-web/MobileWebHybridShellPresentation'
|
||||
import { mobileWebShellLoadFailureWarning } from '../src/mobile-web/mobile-web-shell-load-failure-warning'
|
||||
import { useMobileWebNavigationIntentHandoff } from '../src/mobile-web/use-mobile-web-navigation-intent-handoff'
|
||||
import { useMobileWebColdResumeRoute } from '../src/mobile-web/use-mobile-web-cold-resume-route'
|
||||
import { mobileWebBridgeConnectionState } from '../src/mobile-web/mobile-web-bridge-connection-state'
|
||||
@@ -384,6 +385,10 @@ export default function HybridScreen() {
|
||||
hardwareBackHandoff.resetPage()
|
||||
void postInit()
|
||||
}}
|
||||
onLoadFailed={(reason) => {
|
||||
healthDeadlineRef.current.clear()
|
||||
showWarning(mobileWebShellLoadFailureWarning(reason))
|
||||
}}
|
||||
onNavigationBlocked={() => showWarning('Navigation outside Orca was blocked.')}
|
||||
onProcessTerminated={(sessionId) => {
|
||||
hardwareBackHandoff.resetPage()
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ internal fun isAllowedMobileWebBridgeDocumentUrl(value: String, sessionId: Strin
|
||||
val url = URI(value)
|
||||
value.length <= MOBILE_WEB_DOCUMENT_URL_LIMIT &&
|
||||
url.scheme == MOBILE_WEB_ORIGIN_SCHEME &&
|
||||
url.host == mobileWebOriginHostForSession(sessionId) &&
|
||||
isMobileWebOriginHostForSession(url.host, sessionId) &&
|
||||
url.port == -1 &&
|
||||
url.userInfo == null &&
|
||||
url.fragment == sessionId
|
||||
|
||||
+21
-4
@@ -3,13 +3,26 @@ package expo.modules.mobilewebshell
|
||||
import android.net.Uri
|
||||
|
||||
private const val MOBILE_WEB_ORIGIN_SUFFIX = ".orca-mobile-web.invalid"
|
||||
private const val MOBILE_WEB_ORIGIN_LABEL_LIMIT = 32
|
||||
|
||||
/** Derives a per-session origin so WebView cookies/storage cannot cross hosts. */
|
||||
/**
|
||||
* Derives a per-session origin so WebView cookies/storage cannot cross hosts.
|
||||
*
|
||||
* The id's prefix *is* the host label, so it must already be a canonical hostname label: `https` is
|
||||
* a special scheme, so Chromium ASCII-lowercases the host of every URL it loads and reports back,
|
||||
* and `java.net.URI.getHost()` returns null for a label holding anything outside `[a-z0-9-]`.
|
||||
* Rejecting a non-canonical id here fails the session open loudly instead of leaving every asset
|
||||
* request to 403 behind Chromium's own error page.
|
||||
*/
|
||||
internal fun mobileWebOriginForSession(sessionId: String): String {
|
||||
require(sessionId.isNotEmpty() && sessionId.length <= 128 && sessionId.all { it.isLetterOrDigit() || it == '-' || it == '_' }) {
|
||||
require(
|
||||
sessionId.isNotEmpty() &&
|
||||
sessionId.length <= 128 &&
|
||||
sessionId.all { it in 'a'..'z' || it in '0'..'9' }
|
||||
) {
|
||||
"mobile_web_session_id_invalid"
|
||||
}
|
||||
return "$MOBILE_WEB_ORIGIN_SCHEME://${sessionId.take(32)}$MOBILE_WEB_ORIGIN_SUFFIX"
|
||||
return "$MOBILE_WEB_ORIGIN_SCHEME://${sessionId.take(MOBILE_WEB_ORIGIN_LABEL_LIMIT)}$MOBILE_WEB_ORIGIN_SUFFIX"
|
||||
}
|
||||
|
||||
internal fun mobileWebOriginUriForSession(sessionId: String): Uri = Uri.parse(mobileWebOriginForSession(sessionId))
|
||||
@@ -18,8 +31,12 @@ internal fun mobileWebOriginUriForSession(sessionId: String): Uri = Uri.parse(mo
|
||||
internal fun mobileWebOriginHostForSession(sessionId: String): String =
|
||||
mobileWebOriginForSession(sessionId).substringAfter("://")
|
||||
|
||||
/** Hosts are case-insensitive, so a non-canonical parser must still bind to the active session. */
|
||||
internal fun isMobileWebOriginHostForSession(host: String?, sessionId: String): Boolean =
|
||||
host != null && host.equals(mobileWebOriginHostForSession(sessionId), ignoreCase = true)
|
||||
|
||||
internal fun isMobileWebOriginForSession(url: Uri, sessionId: String): Boolean =
|
||||
url.scheme == MOBILE_WEB_ORIGIN_SCHEME &&
|
||||
url.host == mobileWebOriginUriForSession(sessionId).host &&
|
||||
isMobileWebOriginHostForSession(url.host, sessionId) &&
|
||||
url.port == -1 &&
|
||||
url.userInfo == null
|
||||
|
||||
+1
-9
@@ -9,7 +9,6 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
|
||||
private const val CHUNK_BYTE_LIMIT = 48 * 1024
|
||||
private const val CHUNK_BASE64_CHARACTER_LIMIT = ((CHUNK_BYTE_LIMIT + 2) / 3) * 4
|
||||
@@ -676,14 +675,7 @@ internal class MobileWebPackageStore internal constructor(
|
||||
"url" to "${mobileWebOriginForSession(sessionId)}/#$sessionId"
|
||||
)
|
||||
|
||||
private fun randomIdentifier(): String {
|
||||
val bytes = ByteArray(32)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return encodeBase64(bytes)
|
||||
.replace('+', '-')
|
||||
.replace('/', '_')
|
||||
.trimEnd('=')
|
||||
}
|
||||
private fun randomIdentifier(): String = mobileWebRandomIdentifier()
|
||||
|
||||
private fun requireStage(stageId: String): MobileWebStageRecord =
|
||||
stages[stageId] ?: throw IllegalArgumentException("mobile_web_stage_unknown")
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package expo.modules.mobilewebshell
|
||||
|
||||
import java.security.SecureRandom
|
||||
|
||||
private const val MOBILE_WEB_IDENTIFIER_BYTE_LENGTH = 32
|
||||
private const val MOBILE_WEB_IDENTIFIER_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
|
||||
|
||||
/**
|
||||
* Session ids become the WebView origin's host label, so they are drawn from a canonical hostname
|
||||
* alphabet: base64url's `_` makes `URI.getHost()` null and its uppercase letters do not survive
|
||||
* Chromium's host canonicalization.
|
||||
*/
|
||||
internal fun mobileWebRandomIdentifier(random: SecureRandom = SecureRandom()): String {
|
||||
val bytes = ByteArray(MOBILE_WEB_IDENTIFIER_BYTE_LENGTH)
|
||||
random.nextBytes(bytes)
|
||||
return encodeMobileWebIdentifierAlphabet(bytes)
|
||||
}
|
||||
|
||||
/** RFC 4648 base32 over a lowercase alphabet, without padding. */
|
||||
private fun encodeMobileWebIdentifierAlphabet(bytes: ByteArray): String {
|
||||
val encoded = StringBuilder()
|
||||
var buffer = 0
|
||||
var bufferedBits = 0
|
||||
for (byte in bytes) {
|
||||
buffer = (buffer shl 8) or (byte.toInt() and 0xff)
|
||||
bufferedBits += 8
|
||||
while (bufferedBits >= 5) {
|
||||
bufferedBits -= 5
|
||||
encoded.append(MOBILE_WEB_IDENTIFIER_ALPHABET[(buffer shr bufferedBits) and 0x1f])
|
||||
}
|
||||
}
|
||||
if (bufferedBits > 0) {
|
||||
encoded.append(MOBILE_WEB_IDENTIFIER_ALPHABET[(buffer shl (5 - bufferedBits)) and 0x1f])
|
||||
}
|
||||
return encoded.toString()
|
||||
}
|
||||
+29
-3
@@ -263,7 +263,14 @@ internal class MobileWebShellView(
|
||||
|
||||
private inner class LockedWebViewClient : WebViewClient() {
|
||||
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse {
|
||||
return runCatching { serveRequest(request) }.getOrElse { blockedResponse() }
|
||||
return runCatching { serveRequest(request) }.getOrElse { error ->
|
||||
if (request.isForMainFrame) {
|
||||
// Chromium turns a non-2xx main-frame response into its own error page, so the shell has
|
||||
// to name the reason itself before that page replaces the document.
|
||||
reportDocumentFailure(error.message ?: "mobile_web_document_unavailable")
|
||||
}
|
||||
blockedResponse()
|
||||
}
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||
@@ -280,6 +287,7 @@ internal class MobileWebShellView(
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
if (isAllowedDocumentUrl(Uri.parse(url))) {
|
||||
documentLoaded = true
|
||||
view.visibility = View.VISIBLE
|
||||
view.clearHistory()
|
||||
onLoadState(mapOf("state" to "loaded"))
|
||||
}
|
||||
@@ -291,8 +299,17 @@ internal class MobileWebShellView(
|
||||
error: android.webkit.WebResourceError
|
||||
) {
|
||||
if (request.isForMainFrame && isAllowedDocumentRequestUrl(request.url)) {
|
||||
documentLoaded = false
|
||||
onLoadState(mapOf("state" to "failed"))
|
||||
reportDocumentFailure("mobile_web_document_load_error_${error.errorCode}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedHttpError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
errorResponse: WebResourceResponse
|
||||
) {
|
||||
if (request.isForMainFrame && isAllowedDocumentRequestUrl(request.url)) {
|
||||
reportDocumentFailure("mobile_web_document_http_${errorResponse.statusCode}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +340,15 @@ internal class MobileWebShellView(
|
||||
}
|
||||
}
|
||||
|
||||
/** Hides the Chromium error page and hands the RN shell a reason it can show instead. */
|
||||
private fun reportDocumentFailure(reason: String) {
|
||||
post {
|
||||
documentLoaded = false
|
||||
webView.visibility = View.INVISIBLE
|
||||
onLoadState(mapOf("state" to "failed", "reason" to reason.take(128)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun serveRequest(request: WebResourceRequest): WebResourceResponse {
|
||||
val url = request.url
|
||||
val sessionId = activeSessionId
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MobileWebBridgeDocumentUrlTest {
|
||||
private val sessionId = "S".repeat(43)
|
||||
private val sessionId = "s5i5ifxeticyxgmyvxhtvzktwwbo5o7h5uzaapc0w"
|
||||
private val origin = mobileWebOriginForSession(sessionId)
|
||||
|
||||
@Test
|
||||
@@ -39,7 +39,7 @@ class MobileWebBridgeDocumentUrlTest {
|
||||
"$origin:443/#$sessionId",
|
||||
"https://orca-mobile-web.invalid.evil.test/#$sessionId",
|
||||
"$origin/",
|
||||
"${mobileWebOriginForSession("T".repeat(43))}/#$sessionId",
|
||||
"${mobileWebOriginForSession("t".repeat(43))}/#$sessionId",
|
||||
"$origin/${"a".repeat(8 * 1024)}#$sessionId",
|
||||
"not a url"
|
||||
)
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package expo.modules.mobilewebshell
|
||||
|
||||
import java.net.URI
|
||||
import java.security.SecureRandom
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The session id's prefix is the private origin's host label. Chromium ASCII-lowercases the host of
|
||||
* every `https` URL it loads and reports back, and `java.net.URI.getHost()` is null for a label
|
||||
* holding `_`, so a base64url id silently loses the document (403) and the bridge (dropped
|
||||
* messages).
|
||||
*/
|
||||
class MobileWebOriginTest {
|
||||
@Test
|
||||
fun `generated session ids survive host parsing unchanged`() {
|
||||
val random = SecureRandom.getInstance("SHA1PRNG").apply { setSeed(ByteArray(16)) }
|
||||
|
||||
repeat(64) {
|
||||
val sessionId = mobileWebRandomIdentifier(random)
|
||||
val host = mobileWebOriginHostForSession(sessionId)
|
||||
|
||||
assertEquals(host, host.lowercase())
|
||||
assertEquals(host, URI(mobileWebOriginForSession(sessionId)).host)
|
||||
assertTrue(sessionId, isMobileWebOriginHostForSession(URI("https://$host/").host, sessionId))
|
||||
assertTrue(
|
||||
sessionId,
|
||||
isAllowedMobileWebBridgeDocumentUrl(
|
||||
"${mobileWebOriginForSession(sessionId)}/#$sessionId",
|
||||
sessionId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects session ids a URL host cannot carry`() {
|
||||
val rejected = listOf(
|
||||
"47Im-Tb2rq2Y5jz235dEGMcvyQSk5p17H5UZaAPc",
|
||||
"47im-tb2_rq2y5jz235degmcvyqsk5p17h5uzaapc",
|
||||
"47im-tb2-rq2y5jz235degmcvyqsk5p17h5uzaapc",
|
||||
""
|
||||
)
|
||||
|
||||
for (sessionId in rejected) {
|
||||
assertEquals(
|
||||
sessionId,
|
||||
"mobile_web_session_id_invalid",
|
||||
runCatching { mobileWebOriginForSession(sessionId) }.exceptionOrNull()?.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps distinct sessions on distinct origins`() {
|
||||
val sessionId = mobileWebRandomIdentifier()
|
||||
val other = mobileWebRandomIdentifier()
|
||||
|
||||
assertFalse(mobileWebOriginForSession(sessionId) == mobileWebOriginForSession(other))
|
||||
assertFalse(isMobileWebOriginHostForSession(mobileWebOriginHostForSession(other), sessionId))
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -37,7 +37,7 @@ class MobileWebPackageStoreGeneratedMutationTest {
|
||||
}
|
||||
|
||||
val stageId = store.beginStage("generated-host", fixture.manifest, fixture.canonical)
|
||||
assertTrue(stageId.matches(Regex("^[A-Za-z0-9_-]{43}$")))
|
||||
assertTrue(stageId.matches(Regex("^[a-z2-7]{52}$")))
|
||||
val encoded = Base64.getEncoder().encodeToString(fixture.bytes)
|
||||
repeat(256) { iteration ->
|
||||
val characters = encoded.toCharArray()
|
||||
|
||||
+3
-2
@@ -31,8 +31,9 @@ class MobileWebPackageStoreTest {
|
||||
val asset = store.readAsset(sessionId, "index.html")
|
||||
|
||||
assertEquals(fixture.buildId, session["buildId"])
|
||||
assertEquals(43, sessionId.length)
|
||||
assertEquals(true, Regex("[A-Za-z0-9_-]{43}").matches(sessionId))
|
||||
assertEquals(52, sessionId.length)
|
||||
assertEquals(true, Regex("[a-z2-7]{52}").matches(sessionId))
|
||||
assertEquals("${mobileWebOriginForSession(sessionId)}/#$sessionId", session["url"])
|
||||
assertEquals("text/html; charset=utf-8", asset.contentType)
|
||||
assertArrayEquals(fixture.bytes, asset.bytes)
|
||||
val error = assertThrows(IllegalArgumentException::class.java) {
|
||||
|
||||
@@ -24,7 +24,7 @@ export type MobileWebShellViewProps = ViewProps & {
|
||||
onBridgeMessage?: MobileWebShellEvent<{ data: string }>
|
||||
onNavigationBlocked?: MobileWebShellEvent<{ url: string }>
|
||||
onProcessTerminated?: MobileWebShellEvent<{ sessionId: string }>
|
||||
onLoadState?: MobileWebShellEvent<{ state: 'loading' | 'loaded' | 'failed' }>
|
||||
onLoadState?: MobileWebShellEvent<{ state: 'loading' | 'loaded' | 'failed'; reason?: string }>
|
||||
}
|
||||
|
||||
const NativeMobileWebShellView: ComponentType<
|
||||
|
||||
@@ -35,6 +35,7 @@ type MobileWebHybridShellPresentationProps = {
|
||||
onRecoveryFailure: () => void
|
||||
onBridgeMessage: (message: string) => void
|
||||
onPageLoaded: () => void
|
||||
onLoadFailed: (reason: string | undefined) => void
|
||||
onNavigationBlocked: () => void
|
||||
onProcessTerminated: (sessionId: string) => void
|
||||
}
|
||||
@@ -56,6 +57,7 @@ export function MobileWebHybridShellPresentation({
|
||||
onRecoveryFailure,
|
||||
onBridgeMessage,
|
||||
onPageLoaded,
|
||||
onLoadFailed,
|
||||
onNavigationBlocked,
|
||||
onProcessTerminated
|
||||
}: MobileWebHybridShellPresentationProps) {
|
||||
@@ -127,6 +129,10 @@ export function MobileWebHybridShellPresentation({
|
||||
onLoadState={(event) => {
|
||||
if (event.nativeEvent.state === 'loaded') {
|
||||
onPageLoaded()
|
||||
return
|
||||
}
|
||||
if (event.nativeEvent.state === 'failed') {
|
||||
onLoadFailed(event.nativeEvent.reason)
|
||||
}
|
||||
}}
|
||||
onNavigationBlocked={onNavigationBlocked}
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('mobile web native bridge transport', () => {
|
||||
'!isAllowedMobileWebBridgeDocumentUrl(documentUrl.toString(), sessionId)'
|
||||
)
|
||||
expect(androidBridgeUrlSource).toContain('url.scheme == MOBILE_WEB_ORIGIN_SCHEME')
|
||||
expect(androidBridgeUrlSource).toContain('url.host == mobileWebOriginHostForSession(sessionId)')
|
||||
expect(androidBridgeUrlSource).toContain('isMobileWebOriginHostForSession(url.host, sessionId)')
|
||||
expect(androidBridgeUrlSource).toContain('url.fragment == sessionId')
|
||||
expect(androidBridgeUrlSource).toContain('url.userInfo == null')
|
||||
expect(androidSource).toContain('request.isForMainFrame && isAllowedDocumentUrl(url)')
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Turns a native shell document-load failure code into text the hosted shell can show. */
|
||||
export function mobileWebShellLoadFailureWarning(reason: string | undefined): string {
|
||||
if (reason === 'mobile_web_generation_invalid') {
|
||||
return 'This desktop’s cached workspace interface failed verification and was not displayed.'
|
||||
}
|
||||
if (reason && reason !== 'mobile_web_document_unavailable') {
|
||||
return `The workspace interface could not be displayed (${reason}).`
|
||||
}
|
||||
return 'The workspace interface could not be displayed.'
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileWebHybridShellPresentation } from './MobileWebHybridShellPresentation'
|
||||
import { mobileWebShellLoadFailureWarning } from './mobile-web-shell-load-failure-warning'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: (styles: Record<string, unknown>) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ChevronLeft: 'ChevronLeft',
|
||||
MonitorSmartphone: 'MonitorSmartphone'
|
||||
}))
|
||||
|
||||
vi.mock('@orca/expo-mobile-web-shell', () => ({
|
||||
MobileWebShellView: 'MobileWebShellView'
|
||||
}))
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
describe('hosted shell document load failures', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('hands the native failure reason to the shell instead of leaving the error page unexplained', () => {
|
||||
const failures: (string | undefined)[] = []
|
||||
const loaded = vi.fn()
|
||||
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileWebHybridShellPresentation, {
|
||||
viewRef: { current: null },
|
||||
selectedHost: { id: 'host-1', name: 'Desk', publicKeyB64: 'k' } as never,
|
||||
session: { sessionId: 'abc', buildId: 'build-1' } as never,
|
||||
viewEpoch: 0,
|
||||
packageLoading: false,
|
||||
packageProgress: undefined,
|
||||
packageWarning: undefined,
|
||||
hostedViewActive: true,
|
||||
onBack: noop,
|
||||
onShowHosts: noop,
|
||||
onRetryRecovery: noop,
|
||||
onUsePrevious: noop,
|
||||
onClearCache: noop,
|
||||
onRecoveryFailure: noop,
|
||||
onBridgeMessage: noop,
|
||||
onPageLoaded: loaded,
|
||||
onLoadFailed: (reason) => failures.push(reason),
|
||||
onNavigationBlocked: noop,
|
||||
onProcessTerminated: noop
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const shell = renderer!.root.findByType('MobileWebShellView' as never)
|
||||
act(() => {
|
||||
shell.props.onLoadState({
|
||||
nativeEvent: { state: 'failed', reason: 'mobile_web_document_http_403' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(failures).toEqual(['mobile_web_document_http_403'])
|
||||
expect(loaded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('names the failure in copy the hosted shell can show', () => {
|
||||
expect(mobileWebShellLoadFailureWarning('mobile_web_document_http_403')).toContain(
|
||||
'mobile_web_document_http_403'
|
||||
)
|
||||
expect(mobileWebShellLoadFailureWarning('mobile_web_generation_invalid')).toContain(
|
||||
'failed verification'
|
||||
)
|
||||
expect(mobileWebShellLoadFailureWarning(undefined)).toBe(
|
||||
'The workspace interface could not be displayed.'
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user