diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md index 773aa14b1eb..fa92c8d7793 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md @@ -2924,4 +2924,7 @@ passes 3,854 files / 40,508 tests with 71 expected skips. | 2026-07-29 | Complete | The exact Pixel 9 Pro API 36 arm64 Debug app passes the complete Android hostile route journey: Tasks provider title/body, a bounded hostile provider error, Session, live OSC-8 terminal links, Source Control/Review, hostile filename/diff/Markdown/HTML/SVG/PNG, and normal/malicious/invalid Mermaid. The private-origin network/navigation/executable, bridge-log, DOM/storage/cookie, and `ApplicationExitInfo` audits record zero executed marker or sentinel observation. | | 2026-07-29 | Complete | Post-repair validation passes 596 mobile files / 3,552 tests with 2 expected skips, mobile formatting, root typecheck/lint/code-quality/reliability/localization/max-lines gates, and diff hygiene. Android reports 41 JVM tests plus exact Debug packaging. Independently verified RNW build `240b80e7da8601d062daf17e2dc88e0ca32c984f7580d4060bd5b877e0022c3d` contains 51 assets / 9,151,939 raw / 2,649,167 gzip bytes. The root suite passes 40,515 tests but hit one unrelated load-sensitive transcript watcher assertion; that 28-test file passes standalone. | | 2026-07-29 | Finding | A stability rerun exposed a harness-only missed workspace activation: the automation call returned before proving the `/session/` transition and then consumed the full route timeout on the workspace list. The Android journey now bounds each transition wait, rereads the active document, and retries a missed activation up to three times. | +| 2026-07-29 | Complete | Rebased all 62 migration commits onto `origin/main` at `6c3b2cfb39`; the branch is zero behind. The conflict resolution preserves upstream orchestration and legacy-terminal SSH recovery together with bounded mobile recovery generations. The focused merged path passes 940 tests with 1 expected skip. | +| 2026-07-29 | Complete | The Android exact-app harness waits for the current process to mount React before pairing, selects the existing Tasks control through a nonvisual accessibility label instead of a fixed coordinate, proves each route transition before retrying, and scopes its final bridge audit to the Orca app PID. The final hostile route journey returns `ok: true` with zero executed marker, sentinel observation, bridge-log finding, or new failure exit record. | +| 2026-07-29 | Complete | Final post-rebase validation passes 597 mobile files / 3,562 tests with 2 expected skips, mobile typecheck/lint/format, and diff hygiene. Independently verified RNW build `121fe8682fc221fd7e6f2955fe1f246017d164db3122d71526bc3f66b19578c5` contains 51 assets / 9,151,993 raw / 2,649,166 gzip bytes. Android retains 41 passing JVM tests and successful Debug APK packaging; root typecheck/lint/code-quality, 56 reliability gates, localization, and max-lines passed after the rebase. | | 2026-07-29 | Next | Continue broader live cross-host/workspace/topology races, sustained allocation and performance testing, physical-device/accessibility validation, independent security review, store-signed release drills, and App Store review. Feature implementation and the Android emulator content corpus are complete. | diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md index f2b24e9756b..bf34684f85d 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md @@ -150,7 +150,7 @@ prototype: inventory entry, and fixtures are removed. The production `/hybrid` route, production bridge clients, native fallback, and Experimental Settings entry remain intentionally until the external cutover gates pass. -- The 61-commit branch is rebased onto `origin/main` at `4543bb6826` and remains +- The 62-commit branch is rebased onto `origin/main` at `6c3b2cfb39` and remains zero behind. Upstream native-chat launch-draft, transcript identity, loading, reconnect, and orchestration behavior is retained in both native and hosted adapters. @@ -1146,15 +1146,21 @@ and nested syntax text keeps the effective native font behavior. On iPhone 17 Pro Simulator, Source Control passes at 0.736% changed pixels / 0.910 mean channel difference and Review at 2.134% / 1.947, within the 3% / 4 budgets. -Current validation passes 596 mobile files / 3,552 tests with 2 expected skips. +Current validation passes 597 mobile files / 3,562 tests with 2 expected skips. The root suite passes 40,515 tests but hit one unrelated load-sensitive transcript watcher assertion; its 28-test file passes standalone. All project typechecks, root/mobile/mobile-web lint and code-quality audits, formatting, localization, max-lines and diff hygiene, and the current 56 reliability gates pass. React Doctor reports zero blocking migration error without suppressions. The independently verified production package -`240b80e7da8601d062daf17e2dc88e0ca32c984f7580d4060bd5b877e0022c3d` -contains 51 assets and verifies at 9,151,939 raw bytes / 2,649,167 gzip bytes. +`121fe8682fc221fd7e6f2955fe1f246017d164db3122d71526bc3f66b19578c5` +contains 51 assets and verifies at 9,151,993 raw bytes / 2,649,166 gzip bytes. +The post-rebase exact Android journey passes Tasks, Session, terminal links, +Source Control/Review, hostile provider/file/diff/Markdown/HTML/SVG/PNG/Mermaid +content, all isolation probes, the app-scoped bridge audit, and the privacy and +exit-info audit with zero executed marker or sentinel observation. The harness +waits for React before pairing and locates the existing Tasks control through +its nonvisual accessibility label rather than a fixed screen coordinate. The preceding exact package `3c0f364f…` passes the unpacked macOS arm64 → Docker SSH → actual iOS WKWebView journey from a clean app reinstall in 1.9 minutes. Authenticated RPC returned the packaged build with no checkout-output fallback; the unchanged diff --git a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md index e9f30297999..f22d0680db9 100644 --- a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md +++ b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md @@ -102,11 +102,12 @@ longer restart Preview loads, and RNW preserves the native iOS font fallback. The unchanged Accounts screen now also passes deterministic iOS native-versus-hosted parity at 0.050% changed pixels, 0.099 mean channel difference, and 0.000544 vertical-title delta, within the 3% / 4 / 0.005 -budgets. Its existing non-embedded toolbar icon has no native accessibility -label, so the fixture uses the unchanged icon position and leaves the semantic -gap for the broader VoiceOver review. The complete cached-app journey passes -with Accounts inserted before Tasks, Session, Files/Preview, Agent History, -Desktop restart/recovery, Source Control, Review, and both isolation probes. +budgets. Its existing non-embedded toolbar icon now exposes a nonvisual `Tasks` +accessibility label without changing layout or styling. The exact Android +fixture locates that control semantically and retries only after proving the +route did not open. The complete cached-app journey passes with Accounts +inserted before Tasks, Session, Files/Preview, Agent History, Desktop +restart/recovery, Source Control, Review, and both isolation probes. The base workspace screen now has the same deterministic proof. Native and hosted mount the unchanged `HostScreen` and pass at 0.879% changed pixels, @@ -124,17 +125,22 @@ pixels and 0.910 mean channel difference; Review passes at 2.134% and 1.947, within the 3% / 4 budgets. The packaged document opts into native safe-area insets, and nested syntax text retains the native effective font behavior. -The migration is based on `origin/main` at `4543bb6826`; the final rebase is -complete and the branch is 61 commits ahead and zero behind. Post-rebase -validation plus the hostile-content slice now passes 593 mobile files / 3,530 -tests with 2 expected skips. The latest full root run passes 3,854 files / -40,508 tests with 71 expected skips. All project typechecks, +The migration is based on `origin/main` at `6c3b2cfb39`; the final rebase is +complete and the branch is 62 commits ahead and zero behind. Post-rebase +validation plus the hostile-content slice now passes 597 mobile files / 3,562 +tests with 2 expected skips. The focused merged SSH recovery path passes 940 +tests with 1 expected skip. The latest full root run passes 40,515 tests apart +from one unrelated load-sensitive transcript watcher assertion whose 28-test +file passes standalone. All project typechecks, root/mobile/mobile-web lint and code-quality audits, 56 reliability gates, changed-file and full-mobile formatting, localization, the max-lines ratchet, and diff hygiene pass. React Doctor reports zero new migration findings. The independently verified React Native Web package is -`3c0f364f9cb6f1785d1d08fdeb81ca5367b091706c24d124374a88578839e745`: -50 assets, 9,135,273 raw bytes, and 2,644,558 gzip bytes. +`121fe8682fc221fd7e6f2955fe1f246017d164db3122d71526bc3f66b19578c5`: +51 assets, 9,151,993 raw bytes, and 2,649,166 gzip bytes. The final exact +Android journey passes the complete hostile route and isolation corpus with +zero executed marker, sentinel observation, bridge-log finding, or new failure +exit record. The immediately preceding `7c7c673d…` package passed the unpacked macOS arm64 → Docker SSH → actual iOS WKWebView journey from a clean app reinstall in 1.9 diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 7a03599fd26..680bc22beec 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1056,6 +1056,8 @@ export function HostScreen({ style={styles.searchToggle} onPress={() => navigateFromHostList(`/h/${hostId}/tasks`)} disabled={connState !== 'connected'} + accessibilityRole="button" + accessibilityLabel="Tasks" > { const mermaidFrames = Array.from( @@ -48,33 +49,70 @@ const PROVIDER_DOM_EXPRESSION = `JSON.stringify((() => { mermaidFrames }; })())` -const TASKS_TOOLBAR_X = 0.87 - export async function verifyHostedAdversarialTasks({ activatePoint, discoveryUrl, document, timeoutMs }) { - const filterPoint = await readHostedWebViewTextPoint(document, 'Filter') - await activatePoint({ x: TASKS_TOOLBAR_X, y: filterPoint.y }) - const tasks = await waitForVisibleHostedWebView({ - discoveryUrl, - expectedText: HOSTED_ADVERSARIAL_PROVIDER_TITLE_MARKER, - expectedHrefIncludes: '/tasks', - timeoutMs - }) + let activeDocument = document + let tasks + let lastError + for (let attempt = 0; attempt < 3 && !tasks; attempt += 1) { + if (!activeDocument.href.includes('/tasks')) { + await activatePoint(await readHostedWebViewControlPoint(activeDocument, 'Tasks')) + } + try { + tasks = await waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: HOSTED_ADVERSARIAL_PROVIDER_TITLE_MARKER, + expectedHrefIncludes: '/tasks', + timeoutMs: Math.min(timeoutMs, 15_000) + }) + } catch (error) { + lastError = error + activeDocument = await waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: '', + requireInteractiveControls: false, + timeoutMs + }) + } + } + if (!tasks) { + throw lastError ?? new Error('Hosted adversarial Tasks route did not open') + } const evidence = await waitForProviderEvidence(tasks, timeoutMs, { errorMarker: true, titleMarker: true }) - const titlePoint = await readHostedWebViewTextPoint(tasks, 'Tasks') - await activatePoint({ x: Math.max(0.04, titlePoint.x - 0.12), y: titlePoint.y }) - const workspaceDocument = await waitForVisibleHostedWebView({ - discoveryUrl, - expectedText: 'Orca Desktop', - timeoutMs - }) + activeDocument = tasks + let workspaceDocument + lastError = undefined + for (let attempt = 0; attempt < 3 && !workspaceDocument; attempt += 1) { + if (activeDocument.href.includes('/tasks')) { + const titlePoint = await readHostedWebViewTextPoint(activeDocument, 'Tasks') + await activatePoint({ x: Math.max(0.04, titlePoint.x - 0.12), y: titlePoint.y }) + } + try { + workspaceDocument = await waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: 'Orca Desktop', + timeoutMs: Math.min(timeoutMs, 15_000) + }) + } catch (error) { + lastError = error + activeDocument = await waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: '', + requireInteractiveControls: false, + timeoutMs + }) + } + } + if (!workspaceDocument) { + throw lastError ?? new Error('Hosted adversarial Tasks route did not close') + } return { evidence, workspaceDocument } } diff --git a/mobile/scripts/hosted-android-emulator-session.mjs b/mobile/scripts/hosted-android-emulator-session.mjs index 7d8dc541b87..2de23b5bd69 100644 --- a/mobile/scripts/hosted-android-emulator-session.mjs +++ b/mobile/scripts/hosted-android-emulator-session.mjs @@ -123,6 +123,28 @@ export async function launchHostedAndroidDevClient(adb, metroPort, probe) { ) } +export async function waitForHostedAndroidReactReady(adb, timeoutMs, runAdb = runAndroidAdb) { + const deadline = Date.now() + timeoutMs + let lastError = 'Android app process is unavailable' + while (Date.now() < deadline) { + try { + const pid = await runAdb(adb, ['shell', 'pidof', packageName]) + if (!/^\d+$/u.test(pid)) { + throw new Error('Android app process is unavailable') + } + const logcat = await runAdb(adb, ['logcat', '--pid', pid, '-d', '-v', 'brief']) + if (/Running "main"/u.test(logcat)) { + return pid + } + lastError = 'React main has not mounted' + } catch (error) { + lastError = error instanceof Error ? error.message : String(error) + } + await delay(250) + } + throw new Error(`Android React runtime was unavailable: ${lastError}`) +} + export function openHostedAndroidUrl(adb, url) { return runAndroidAdb( adb, @@ -188,11 +210,12 @@ export function findHostedAndroidBridgeLogFailures(logcat, appPid) { }) } -export async function assertHostedAndroidBridgeLogClean(adb) { - const [logcat, appPid] = await Promise.all([ - runAndroidAdb(adb, ['logcat', '-d', '-v', 'brief']), - runAndroidAdb(adb, ['shell', 'pidof', packageName]) - ]) +export async function assertHostedAndroidBridgeLogClean(adb, runAdb = runAndroidAdb) { + const appPid = await runAdb(adb, ['shell', 'pidof', packageName]) + if (!/^\d+$/u.test(appPid)) { + throw new Error('Android app process is unavailable') + } + const logcat = await runAdb(adb, ['logcat', '--pid', appPid, '-d', '-v', 'brief']) const failures = findHostedAndroidBridgeLogFailures(logcat, appPid) if (failures.length > 0) { throw new Error(`Android bridge emitted errors:\n${failures.slice(0, 16).join('\n')}`) diff --git a/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs b/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs index 078b3cf3d40..58768ea2521 100644 --- a/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs +++ b/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs @@ -35,7 +35,8 @@ import { openHostedAndroidUrl, resolveHostedAndroidAdb, startHostedAndroidMetro, - stopHostedAndroidApp + stopHostedAndroidApp, + waitForHostedAndroidReactReady } from './hosted-android-emulator-session.mjs' import { runAndroidAdb } from './hosted-android-mobile-web-cache.mjs' import { HOSTED_MOBILE_APP_ROUTE_URL } from './hosted-mobile-e2e-launch.mjs' @@ -164,6 +165,7 @@ async function main() { await stage('development client launch', () => launchHostedAndroidDevClient(adb, metro.port, probe) ) + await stage('React runtime', () => waitForHostedAndroidReactReady(adb, options.timeoutMs)) const emulator = { adb } await stage('native pairing', () => pairAndroidApp(emulator, runtime.pairingUrl, options.timeoutMs) diff --git a/mobile/src/mobile-web/hosted-android-emulator-session.test.ts b/mobile/src/mobile-web/hosted-android-emulator-session.test.ts index 3d890734704..71b7b5a23fc 100644 --- a/mobile/src/mobile-web/hosted-android-emulator-session.test.ts +++ b/mobile/src/mobile-web/hosted-android-emulator-session.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { findHostedAndroidBridgeLogFailures } from '../../scripts/hosted-android-emulator-session.mjs' +import { + assertHostedAndroidBridgeLogClean, + findHostedAndroidBridgeLogFailures, + waitForHostedAndroidReactReady +} from '../../scripts/hosted-android-emulator-session.mjs' describe('hosted Android emulator session', () => { it('finds native bridge rejection, conversion, cast, and process failures', () => { @@ -33,4 +37,30 @@ describe('hosted Android emulator session', () => { ' E/AndroidRuntime(20200): FATAL EXCEPTION: main' ]) }) + + it('waits for the current Android app process to mount React main', async () => { + const responses = [ + '7301', + 'I/ReactNativeJS: Loading bundle', + '7301', + 'I/ReactNativeJS: Running "main"' + ] + const runAdb = async () => responses.shift() ?? '' + + await expect(waitForHostedAndroidReactReady('adb', 2_000, runAdb)).resolves.toBe('7301') + }) + + it('scopes the bridge audit to the current Android app process', async () => { + const calls: string[][] = [] + const runAdb = async (_adb: string, args: string[]) => { + calls.push(args) + return calls.length === 1 ? '7301' : 'I/chromium: hosted route' + } + + await expect(assertHostedAndroidBridgeLogClean('adb', runAdb)).resolves.toBeUndefined() + expect(calls).toEqual([ + ['shell', 'pidof', 'com.stably.orca.mobile'], + ['logcat', '--pid', '7301', '-d', '-v', 'brief'] + ]) + }) })