From abaa3e59bf5bfa20a475747ac3a401ae8a351d39 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:59:20 -0700 Subject: [PATCH] test(mobile): add iOS core route parity --- ...hybrid-webview-implementation-checklist.md | 32 ++- ...-mobile-hybrid-webview-parity-inventory.md | 7 +- ...bile-hybrid-webview-single-pr-migration.md | 18 +- ...27-mobile-hybrid-webview-remaining-work.md | 21 +- .../scripts/hosted-ios-core-route-parity.mjs | 223 ++++++++++++++++++ ...sted-ios-source-control-review-journey.mjs | 75 +++++- .../run-hosted-webview-simulator-e2e.mjs | 38 ++- .../hosted-ios-core-route-parity.test.ts | 149 ++++++++++++ ...-ios-source-control-review-journey.test.ts | 111 ++++++++- 9 files changed, 639 insertions(+), 35 deletions(-) create mode 100644 mobile/scripts/hosted-ios-core-route-parity.mjs create mode 100644 mobile/src/mobile-web/hosted-ios-core-route-parity.test.ts 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 7b50caafa4e..c288d7b6ff6 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 @@ -11,9 +11,9 @@ the production hosted broker/client contract, and reconnect pass a real Docker transport journey; hosted workspace, session, and native-chat reads and production package delivery also cross the real mobile Relay client and - E2EE stack through a protocol-compatible local cell; the first automated - native-versus-hosted populated-screen gate now passes Agent History on the - iPhone 17 Pro Simulator; the hosted Session now hands Terminal Settings to the + E2EE stack through a protocol-compatible local cell; automated + native-versus-hosted populated-screen gates now pass Tasks, Session, and + Agent History on the iPhone 17 Pro Simulator; the hosted Session now hands Terminal Settings to the existing native route and returns to the identical hosted session; native-chat deadlines and gesture-gated image attachments preserve the unchanged composer through opaque shell-owned references; the Android API 36 emulator now stages @@ -107,10 +107,10 @@ to the existing RPC behavior. Named page schemas and broker grants now cover repository/settings/trust/provider/SSH/agent/hook/capability reads with opaque repository and SSH authority; source search, base resolution, create operations, native revalidation, and the complete web adapter now pass focused -round-trip tests. Populated Agent History now passes deterministic native-versus- -hosted screenshot and interaction parity in portrait and landscape; the -remaining routes, shell states, tablet/device classes, and accessibility -matrix remain open. +round-trip tests. Populated Tasks and Session now pass deterministic +native-versus-hosted portrait screenshot and interaction parity, and Agent +History passes in portrait and landscape; the remaining routes, shell states, +tablet/device classes, and accessibility matrix remain open. The unchanged session route now receives snapshot, subscription, blank-terminal creation, activation, and close behavior through `HostSessionTabOperations`; native retains the existing RPC mapping while the hosted route uses only the @@ -407,6 +407,14 @@ Source Control and Review journey. Session-origin changed files open a second Session diff tab, standalone Review retains its existing controls, and the same run passes the private-origin network and navigation isolation probes. +The same simulator now captures native and hosted Tasks and Session from one +disposable Desktop fixture. Tasks passes at 0.022% changed pixels, 0.084 mean +channel difference, and 0.000016 vertical-title delta. Session passes at +0.800%, 1.693, and 0.000366 respectively, within the 3%, 4, and 0.005 budgets. +The complete cached-app run also passes Agent History portrait/landscape, +Desktop restart/recovery, synthetic-resume rejection, native-touch resume, a +third Session diff tab, standalone Review, and both isolation probes. + The exact Pixel 9 Pro API 36 arm64 Debug APK now passes the same journey after a fresh native build and install. Android accessibility opens the unchanged Source Control hub and changed-file action, Session retains the second diff tab, @@ -416,8 +424,9 @@ escaped traffic and a clean native bridge log audit. **Next action:** Complete the remaining parity inventory and cutover cleanup before expanding the external validation matrix. -Extend the deterministic native-versus-hosted screenshot fixture beyond the -passing populated Agent History portrait case. The authoritative shared RNW +Extend the deterministic native-versus-hosted screenshot fixture across the +remaining populated routes, shell states, tablet/device classes, and +accessibility matrix. The authoritative shared RNW route is now governed by a separate reviewed ceiling of 10 MiB total, 3 MiB gzip, 9.5 MiB of scripts, 256 KiB of styles, and 64 assets. The earlier 8 MiB / 2 MiB / 7.5 MiB ceilings held before the existing Mermaid presentation was @@ -2444,4 +2453,9 @@ copy. | 2026-07-28 | Complete | The Pixel 9 Pro API 36 route gate now seeds a disposable Agent History fixture and passes Workspace/Project/All scopes, lazy preview, search/no-match/clear, synthetic privileged-resume rejection, and native-touch resume into a second Session tab. The same app session then opens Source Control, a third changed-file Session tab, standalone Review, both isolation probes, and a clean bridge log audit. | | 2026-07-28 | Complete | Rebased both migration commits without conflict onto current `origin/main` at `0404f27b3`. The branch is two commits ahead and zero behind. | | 2026-07-28 | Complete | Final validation passes 551 mobile files / 3,286 tests with 2 expected skips and 3,770 root files / 39,212 tests with 62 expected skips. Root/mobile/RNW typechecks, lint, formatting, reliability gates, localization, max-lines, the verified 49-asset RNW package, and the rebuilt Android journey all pass. | +| 2026-07-28 | Complete | A cached exact-app iPhone 17 Pro / iOS 26.5 Simulator run captured native and hosted Tasks and Session from the same fixture. Tasks passed at 0.022% changed pixels / 0.084 mean channel difference / 0.000016 vertical-title delta; Session passed at 0.800% / 1.693 / 0.000366 against 3% / 4 / 0.005 budgets. | +| 2026-07-28 | Complete | The same iOS run passed Agent History portrait/landscape parity, forced Desktop restart and E2EE recovery, synthetic-resume rejection, native-touch resume, Source Control, a third changed-file Session tab, standalone Review, and private-origin network/navigation isolation. | +| 2026-07-28 | Finding | The existing non-embedded Tasks toolbar icon has no native accessibility label. The parity driver uses the accessible Filter row to locate the unchanged icon; full VoiceOver review remains open. Source Control native taps now prefer platform accessibility and retry a measured point after a silent WebKit miss, and the full journey asserts the correct third diff tab. | +| 2026-07-28 | Complete | A fresh iOS native build/install passed the complete exact-app journey with the final parity and route-settling driver: Tasks, Session, Agent History portrait/landscape, Desktop restart/recovery, native-touch resume, third diff tab, standalone Review, and both private-origin isolation probes. | +| 2026-07-28 | Complete | Post-slice validation passes 552 mobile files / 3,291 tests with 2 expected skips and 3,770 root files / 39,212 tests with 62 expected skips. All three typechecks, root/mobile/RNW lint, reliability, localization, max-lines, changed-file and full-mobile formatting, and diff hygiene pass; fetched `origin/main` remains `0404f27b3`. | | 2026-07-28 | Next | Complete the remaining parity inventory and cutover cleanup, then execute the physical-device, topology, security, performance, packaged-release, and App Store gates. | diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md index 7faf78e1b06..ab389314513 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md @@ -478,7 +478,12 @@ iPhone 17 Pro Simulator verifies Session-origin changed-file handoff, standalone Review, and both isolation probes. The Pixel 9 Pro API 36 emulator now verifies those paths in the same fresh app session as Agent History scopes, preview, filtering, synthetic-resume rejection, and native-touch resume. -Provider-neutral History and PR compatibility redirects are present. Close the +The iPhone fixture also captures native and hosted Tasks and Session from the +same disposable runtime: Tasks passes at 0.022% changed pixels / 0.084 mean +channel difference / 0.000016 vertical-title delta, and Session passes at +0.800% / 1.693 / 0.000366 within the 3% / 4 / 0.005 budgets. Provider-neutral +History and PR compatibility redirects are present. The non-embedded Tasks +toolbar icon's missing native accessibility label remains open. Close the remaining native-versus-hosted interaction matrix. The interrupted-transcript versus hook-status mismatch and a real structured 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 d746e6e2e28..e7816f731ca 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 @@ -2092,6 +2092,15 @@ accessibility finding remains open. This is not completion of the phone/tablet, shell-state, route, screen-reader, or physical-device matrix. +The same deterministic fixture now captures the unchanged native and hosted +Tasks and Session presentations from one disposable Desktop runtime. Tasks +passes at 0.022% changed pixels, 0.084 mean channel difference, and 0.000016 +vertical-title delta. Session passes at 0.800%, 1.693, and 0.000366 +respectively, within the 3%, 4, and 0.005 budgets. The existing non-embedded +Tasks toolbar icon has no native accessibility label, so the fixture locates +its row from the accessible Filter control and preserves the current UI. That +missing label remains part of the full accessibility gate. + The Pixel 9 Pro API 36 emulator now exercises that same hosted Agent History presentation in a freshly installed paired app. Workspace/Project/All scopes, lazy preview, search/no-match/clear, synthetic privileged-resume rejection, and @@ -2322,8 +2331,9 @@ feature UI. overwrite those native records. - Add deterministic native-versus-web screenshot and interaction fixtures for phone, tablet, portrait, landscape, loading, offline, error, and populated - states. Populated Agent History portrait now passes the first calibrated - pixel and vertical safe-area fixture; the remainder of the matrix stays open. + states. Populated Tasks and Session portrait plus Agent History portrait and + landscape now pass calibrated pixel and vertical safe-area fixtures; the + remainder of the matrix stays open. - After every rebase, reconcile upstream mobile presentation changes through the shared source and rerun the parity fixtures; do not preserve an older forked snapshot for the web runtime. @@ -2385,6 +2395,10 @@ independently renders standalone Review with its existing controls. The same run passes private-origin network and navigation isolation. The exact Pixel 9 Pro API 36 arm64 Debug APK passes the matching route journey and isolation corpus after a fresh build/install, with a clean native bridge log audit. +After Agent History resumes a second Session tab, the full iOS journey now +asserts that the changed file creates the third tab. Native iOS route taps +prefer the accessibility control and retry the measured WebView point after a +silent WebKit miss. ### 10. Adapt remaining host features and native capabilities 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 4f6345db25e..1c195ca240e 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 @@ -43,10 +43,25 @@ search/no-match/clear flow, rejection of synthetic privileged activation, and native-touch resume into a second Session tab before continuing through Source Control and Review. -The migration is rebased onto `origin/main` at `0404f27b3`. Post-rebase -validation passes 551 mobile files / 3,286 tests with 2 expected skips and +The iPhone 17 Pro / iOS 26.5 Simulator now captures the unchanged native and +hosted Tasks and Session screens from one disposable Desktop runtime. Tasks +passes at 0.022% changed pixels, 0.084 mean channel difference, and 0.000016 +vertical-title delta. Session passes at 0.800%, 1.693, and 0.000366 +respectively, within the 3%, 4, and 0.005 budgets. The same fresh exact-app run +passes Agent History portrait/landscape parity, Desktop restart and recovery, +native-touch resume, a third Session diff tab, standalone Review, and both +isolation probes. + +The existing non-embedded Tasks toolbar icon has no native accessibility label. +The fixture locates its unchanged row from the accessible Filter control and +uses the existing icon position. This is recorded as an accessibility finding, +not treated as a reason to change product UI inside the migration. + +The migration is rebased onto `origin/main` at `0404f27b3`. Current post-rebase +validation passes 552 mobile files / 3,291 tests with 2 expected skips and 3,770 root files / 39,212 tests with 62 expected skips. All project typechecks, -root/mobile/mobile-web lint, reliability gates, and the max-lines ratchet pass. +root/mobile/mobile-web lint, reliability gates, changed-file and full-mobile +formatting, localization, and the max-lines ratchet pass. The independently verified React Native Web package is `f852d8525d2b0e20d79262d74ce3ef74bfa73c3e55b95176bfb1b467beafae61`: 49 assets, 9,330,210 raw bytes, and 2,697,838 gzip bytes. diff --git a/mobile/scripts/hosted-ios-core-route-parity.mjs b/mobile/scripts/hosted-ios-core-route-parity.mjs new file mode 100644 index 00000000000..50d63c8a76a --- /dev/null +++ b/mobile/scripts/hosted-ios-core-route-parity.mjs @@ -0,0 +1,223 @@ +import { execFile } from 'node:child_process' +import path from 'node:path' +import { promisify } from 'node:util' +import { dismissEmulatorDeveloperMenuIfPresent } from './emulator-developer-menu-dismissal.mjs' +import { + tapHostedIosAccessibilityControl, + tapHostedIosAccessibilityControlByLabelPrefix, + tapHostedIosPoint, + waitForHostedIosAccessibilityControl, + waitForHostedIosAccessibilityControlByLabelPrefix, + waitForHostedIosAccessibilityControlMatch +} from './hosted-ios-emulator-accessibility.mjs' +import { assertHostedIosScreenshotParity } from './hosted-ios-screenshot-parity.mjs' +import { + activateHostedWebViewControl, + readHostedWebViewTextPoint, + waitForVisibleHostedWebView +} from './hosted-webview-cdp-session.mjs' +import { readHostedWebViewControlPoint } from './hosted-webview-control-point.mjs' +import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs' + +const execFileAsync = promisify(execFile) +const TASKS_TOOLBAR_X = 0.87 +const TASK_STABLE_TEXTS = [ + 'Connect your Linear account', + 'No Linear tasks', + 'No GitHub tasks', + 'No GitLab tasks', + 'No matching tasks', + 'Choose a GitHub project', + 'Update Orca desktop' +] + +export async function captureNativeCoreRouteBaselines({ + deviceUdid, + emulator, + expectedWorkspace, + runtimeDirectory, + timeoutMs +}) { + await dismissEmulatorDeveloperMenuIfPresent(emulator) + const filterPoint = await waitForHostedIosAccessibilityControl(emulator, 'Filter', timeoutMs) + // The existing non-embedded Tasks icon has no native accessibility label. + await tapHostedIosPoint(emulator, { x: TASKS_TOOLBAR_X, y: filterPoint.y }) + const taskStableState = await waitForHostedIosAccessibilityControlMatch( + emulator, + TASK_STABLE_TEXTS, + timeoutMs + ) + const tasks = await captureNativeRoute({ + deviceUdid, + emulator, + runtimeDirectory, + screenshotName: 'native-tasks-portrait.png', + title: 'Tasks', + timeoutMs + }) + tasks.stableText = taskStableState.label + await tapHostedIosPoint(emulator, tasksBackPoint(tasks.screenTitlePoint)) + await waitForHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs) + await tapHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs) + await waitForHostedIosAccessibilityControlByLabelPrefix(emulator, 'Mobile Emulator', timeoutMs) + const session = await captureNativeRoute({ + deviceUdid, + emulator, + runtimeDirectory, + screenshotName: 'native-session-portrait.png', + title: expectedWorkspace, + timeoutMs + }) + await tapHostedIosAccessibilityControl(emulator, 'Back to worktrees', timeoutMs) + await waitForHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs) + return { session, tasks } +} + +export async function captureHostedCoreRouteParity({ + deviceUdid, + discoveryUrl, + emulator, + expectedWorkspace, + nativeBaselines, + runtimeDirectory, + timeoutMs, + workspaceDocument +}) { + const tasksDocument = await openHostedTasks({ + discoveryUrl, + emulator, + stableText: nativeBaselines.tasks.stableText, + timeoutMs, + workspaceDocument + }) + const tasks = await captureHostedRoute({ + deviceUdid, + document: tasksDocument, + nativeBaseline: nativeBaselines.tasks, + runtimeDirectory, + screenshotName: 'hosted-tasks-portrait.png', + title: 'Tasks' + }) + await tapHostedIosPoint(emulator, tasksBackPoint(tasks.screenTitlePoint)) + let activeWorkspaceDocument = await waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: 'Orca Desktop', + timeoutMs + }) + await activateHostedWorkspaceRow( + activeWorkspaceDocument, + expectedWorkspace, + activateHostedWebViewControl, + timeoutMs, + () => + waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: 'Orca Desktop', + timeoutMs + }) + ) + const sessionDocument = await waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: 'Mobile Emulator', + expectedHrefIncludes: '/session/', + timeoutMs + }) + const session = await captureHostedRoute({ + deviceUdid, + document: sessionDocument, + nativeBaseline: nativeBaselines.session, + runtimeDirectory, + screenshotName: 'hosted-session-portrait.png', + title: expectedWorkspace + }) + const backPoint = await readHostedWebViewControlPoint(sessionDocument, 'Back to worktrees') + await tapHostedIosPoint(emulator, backPoint) + activeWorkspaceDocument = await waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: 'Orca Desktop', + timeoutMs + }) + return { + evidence: { + session: coreRouteParityEvidence(nativeBaselines.session, session), + tasks: coreRouteParityEvidence(nativeBaselines.tasks, tasks) + }, + workspaceDocument: activeWorkspaceDocument + } +} + +export function coreRouteParityEvidence(nativeCapture, hostedCapture) { + return { + nativeScreenshot: path.basename(nativeCapture.screenshot), + hostedScreenshot: path.basename(hostedCapture.screenshot), + nativeScreenTitlePoint: nativeCapture.screenTitlePoint, + hostedScreenTitlePoint: hostedCapture.screenTitlePoint, + screenshotParity: hostedCapture.screenshotParity + } +} + +async function openHostedTasks({ + discoveryUrl, + emulator, + stableText, + timeoutMs, + workspaceDocument +}) { + const filterPoint = await readHostedWebViewTextPoint(workspaceDocument, 'Filter') + await tapHostedIosPoint(emulator, { x: TASKS_TOOLBAR_X, y: filterPoint.y }) + return waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: stableText, + expectedHrefIncludes: '/tasks', + timeoutMs + }) +} + +async function captureNativeRoute({ + deviceUdid, + emulator, + runtimeDirectory, + screenshotName, + title, + timeoutMs +}) { + const screenTitlePoint = await waitForHostedIosAccessibilityControl(emulator, title, timeoutMs) + await delay(500) + const screenshot = path.join(runtimeDirectory, screenshotName) + await captureSimulatorScreenshot(deviceUdid, screenshot) + return { screenTitlePoint, screenshot } +} + +async function captureHostedRoute({ + deviceUdid, + document, + nativeBaseline, + runtimeDirectory, + screenshotName, + title +}) { + const screenTitlePoint = await readHostedWebViewTextPoint(document, title) + await delay(500) + const screenshot = path.join(runtimeDirectory, screenshotName) + await captureSimulatorScreenshot(deviceUdid, screenshot) + const capture = { screenTitlePoint, screenshot } + capture.screenshotParity = await assertHostedIosScreenshotParity({ + hostedLandmark: capture.screenTitlePoint, + hostedScreenshot: capture.screenshot, + nativeLandmark: nativeBaseline.screenTitlePoint, + nativeScreenshot: nativeBaseline.screenshot + }) + return capture +} + +function tasksBackPoint(titlePoint) { + return { x: Math.max(0.04, titlePoint.x - 0.12), y: titlePoint.y } +} + +async function captureSimulatorScreenshot(deviceUdid, outputPath) { + await execFileAsync('xcrun', ['simctl', 'io', deviceUdid, 'screenshot', outputPath]) +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/mobile/scripts/hosted-ios-source-control-review-journey.mjs b/mobile/scripts/hosted-ios-source-control-review-journey.mjs index c1334e27db1..8330e9d9b13 100644 --- a/mobile/scripts/hosted-ios-source-control-review-journey.mjs +++ b/mobile/scripts/hosted-ios-source-control-review-journey.mjs @@ -3,7 +3,10 @@ import { waitForVisibleHostedWebView } from './hosted-webview-cdp-session.mjs' import { readHostedWebViewControlPoint } from './hosted-webview-control-point.mjs' -import { tapHostedIosPoint } from './hosted-ios-emulator-accessibility.mjs' +import { + tapHostedIosAccessibilityControl, + tapHostedIosPoint +} from './hosted-ios-emulator-accessibility.mjs' import { navigateHostedWebViewRoute } from './hosted-webview-route-navigation.mjs' export async function verifyHostedSourceControlReviewJourney({ @@ -12,7 +15,7 @@ export async function verifyHostedSourceControlReviewJourney({ sessionDocument, timeoutMs, expectedSessionDiffText = '2 tabs', - tapPoint = tapHostedIosPoint + tapPoint = tapHostedJourneyPoint }) { const sourceControl = await journeyStep('wait for Source Control route', () => openSourceControlRoute({ @@ -38,18 +41,14 @@ export async function verifyHostedSourceControlReviewJourney({ if (!changedFileLabel) { throw new Error('Source Control has no changed file available for Review.') } - const changedFilePoint = await journeyStep(`measure ${changedFileLabel}`, () => - readHostedWebViewControlPoint(sourceControl, changedFileLabel) - ) - await journeyStep(`tap ${changedFileLabel}`, () => - tapPoint(emulator, changedFilePoint, changedFileLabel) - ) const sessionDiff = await journeyStep('wait for Session diff route', () => - waitForVisibleHostedWebView({ + openSessionDiffRoute({ discoveryUrl, + emulator, expectedText: expectedSessionDiffText, - expectedHrefIncludes: '/session/', - requireInteractiveControls: false, + label: changedFileLabel, + sourceControl, + tapPoint, timeoutMs }) ) @@ -80,6 +79,17 @@ export async function verifyHostedSourceControlReviewJourney({ } } +async function tapHostedJourneyPoint(emulator, point, label, attempt = 0) { + if (label && attempt === 0) { + try { + return await tapHostedIosAccessibilityControl(emulator, label, 5_000) + } catch { + // WebKit can omit a descendant while refreshing its accessibility tree. + } + } + return tapHostedIosPoint(emulator, point) +} + async function openSourceControlRoute({ discoveryUrl, emulator, @@ -91,7 +101,7 @@ async function openSourceControlRoute({ for (let attempt = 0; attempt < 3; attempt += 1) { try { const point = await readHostedWebViewControlPoint(sessionDocument, 'Open source control') - await tapPoint(emulator, point, 'Open source control') + await tapPoint(emulator, point, 'Open source control', attempt) return await waitForVisibleHostedWebView({ discoveryUrl, expectedText: 'Source Control', @@ -105,6 +115,47 @@ async function openSourceControlRoute({ throw lastError } +async function openSessionDiffRoute({ + discoveryUrl, + emulator, + expectedText, + label, + sourceControl, + tapPoint, + timeoutMs +}) { + let lastError = new Error('Session diff route did not open') + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const point = await readHostedWebViewControlPoint(sourceControl, label) + await tapPoint(emulator, point, label, attempt) + } catch (error) { + lastError = error + continue + } + try { + return await waitForSessionDiff(discoveryUrl, expectedText, Math.min(timeoutMs, 3_000)) + } catch (error) { + lastError = error + } + const transitioned = await waitForSessionDiff(discoveryUrl, '', 1_000).catch(() => null) + if (transitioned) { + return waitForSessionDiff(discoveryUrl, expectedText, timeoutMs) + } + } + throw lastError +} + +function waitForSessionDiff(discoveryUrl, expectedText, timeoutMs) { + return waitForVisibleHostedWebView({ + discoveryUrl, + expectedText, + expectedHrefIncludes: '/session/', + requireInteractiveControls: false, + timeoutMs + }) +} + async function waitForChangedFileState(document, timeoutMs) { const deadline = Date.now() + timeoutMs let state diff --git a/mobile/scripts/run-hosted-webview-simulator-e2e.mjs b/mobile/scripts/run-hosted-webview-simulator-e2e.mjs index f6ff27f781d..2312655b4cc 100644 --- a/mobile/scripts/run-hosted-webview-simulator-e2e.mjs +++ b/mobile/scripts/run-hosted-webview-simulator-e2e.mjs @@ -17,6 +17,10 @@ import { waitForVisibleHostedWebView } from './hosted-webview-cdp-session.mjs' import { captureNativeAgentHistoryBaseline } from './hosted-ios-agent-history-parity.mjs' +import { + captureHostedCoreRouteParity, + captureNativeCoreRouteBaselines +} from './hosted-ios-core-route-parity.mjs' import { verifyHostedAgentHistoryJourney } from './hosted-ios-agent-history-journey.mjs' import { openHostedIosHybridRoute } from './hosted-ios-hybrid-route-handoff.mjs' import { verifyHostedNativeTerminalSettingsHandoff } from './hosted-ios-native-settings-handoff.mjs' @@ -95,6 +99,18 @@ async function main() { const nativeOnboarding = await evidenceStep('native onboarding', () => completeHostedIosNativeOnboarding(emulator, expectedWorkspace, options.timeoutMs) ) + const nativeCoreRoutes = + options.securityOnly || options.nativeSettingsOnly || options.sourceControlOnly + ? null + : await evidenceStep('native Tasks and Session baselines', () => + captureNativeCoreRouteBaselines({ + deviceUdid, + emulator, + expectedWorkspace, + runtimeDirectory, + timeoutMs: options.timeoutMs + }) + ) const nativeAgentHistory = options.securityOnly || options.nativeSettingsOnly || options.sourceControlOnly ? null @@ -117,6 +133,22 @@ async function main() { expectedText: 'Orca Desktop', timeoutMs: options.timeoutMs }) + const hostedCoreRoutes = + options.securityOnly || options.nativeSettingsOnly || options.sourceControlOnly + ? null + : await evidenceStep('hosted Tasks and Session parity', () => + captureHostedCoreRouteParity({ + deviceUdid, + discoveryUrl: `http://127.0.0.1:${inspectorPort}`, + emulator, + expectedWorkspace, + nativeBaselines: nativeCoreRoutes, + runtimeDirectory, + timeoutMs: options.timeoutMs, + workspaceDocument + }) + ) + const activeWorkspaceDocument = hostedCoreRoutes?.workspaceDocument ?? workspaceDocument const historyEvidence = options.securityOnly || options.sourceControlOnly ? null @@ -125,7 +157,7 @@ async function main() { verifyNativeSettingsJourney({ discoveryUrl: `http://127.0.0.1:${inspectorPort}`, emulator, - workspaceDocument, + workspaceDocument: activeWorkspaceDocument, expectedWorkspace, timeoutMs: options.timeoutMs }) @@ -137,7 +169,7 @@ async function main() { emulator, nativeAgentHistory, runtimeDirectory, - workspaceDocument, + workspaceDocument: activeWorkspaceDocument, expectedWorkspace, timeoutMs: options.timeoutMs }) @@ -170,6 +202,7 @@ async function main() { return verifyHostedSourceControlReviewJourney({ discoveryUrl: `http://127.0.0.1:${inspectorPort}`, emulator, + expectedSessionDiffText: options.sourceControlOnly ? '2 tabs' : '3 tabs', sessionDocument, timeoutMs: options.timeoutMs }) @@ -214,6 +247,7 @@ async function main() { navigationIsolation, nativeOnboarding, agentHistory: historyEvidence, + coreRouteParity: hostedCoreRoutes?.evidence ?? null, sourceControlReview }, null, diff --git a/mobile/src/mobile-web/hosted-ios-core-route-parity.test.ts b/mobile/src/mobile-web/hosted-ios-core-route-parity.test.ts new file mode 100644 index 00000000000..7cf0a76b870 --- /dev/null +++ b/mobile/src/mobile-web/hosted-ios-core-route-parity.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + activateControl: vi.fn(), + activateWorkspace: vi.fn(), + captureScreenshot: vi.fn(), + compareScreenshots: vi.fn(), + dismissDeveloperMenu: vi.fn(), + readControlPoint: vi.fn(), + readTextPoint: vi.fn(), + tapControl: vi.fn(), + tapControlByPrefix: vi.fn(), + tapPoint: vi.fn(), + waitForControl: vi.fn(), + waitForControlByPrefix: vi.fn(), + waitForControlMatch: vi.fn(), + waitForDocument: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: mocks.captureScreenshot +})) +vi.mock('../../scripts/emulator-developer-menu-dismissal.mjs', () => ({ + dismissEmulatorDeveloperMenuIfPresent: mocks.dismissDeveloperMenu +})) +vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({ + tapHostedIosAccessibilityControl: mocks.tapControl, + tapHostedIosAccessibilityControlByLabelPrefix: mocks.tapControlByPrefix, + tapHostedIosPoint: mocks.tapPoint, + waitForHostedIosAccessibilityControl: mocks.waitForControl, + waitForHostedIosAccessibilityControlByLabelPrefix: mocks.waitForControlByPrefix, + waitForHostedIosAccessibilityControlMatch: mocks.waitForControlMatch +})) +vi.mock('../../scripts/hosted-ios-screenshot-parity.mjs', () => ({ + assertHostedIosScreenshotParity: mocks.compareScreenshots +})) +vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({ + activateHostedWebViewControl: mocks.activateControl, + readHostedWebViewTextPoint: mocks.readTextPoint, + waitForVisibleHostedWebView: mocks.waitForDocument +})) +vi.mock('../../scripts/hosted-webview-control-point.mjs', () => ({ + readHostedWebViewControlPoint: mocks.readControlPoint +})) +vi.mock('../../scripts/hosted-webview-workspace-activation.mjs', () => ({ + activateHostedWorkspaceRow: mocks.activateWorkspace +})) + +import { + captureHostedCoreRouteParity, + captureNativeCoreRouteBaselines +} from '../../scripts/hosted-ios-core-route-parity.mjs' + +describe('hosted iOS core-route parity', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.captureScreenshot.mockImplementation((_command, _args, callback) => + callback(null, '', '') + ) + mocks.compareScreenshots.mockResolvedValue({ changedPixelRatio: 0.01 }) + mocks.readControlPoint.mockResolvedValue({ x: 0.8, y: 0.1 }) + mocks.readTextPoint.mockResolvedValue({ x: 0.2, y: 0.1 }) + mocks.tapControl.mockResolvedValue(undefined) + mocks.tapControlByPrefix.mockResolvedValue(undefined) + mocks.tapPoint.mockResolvedValue(undefined) + mocks.waitForControl.mockResolvedValue({ x: 0.2, y: 0.1 }) + mocks.waitForControlByPrefix.mockResolvedValue({ x: 0.2, y: 0.1 }) + mocks.waitForControlMatch.mockResolvedValue({ + label: 'Connect your Linear account', + x: 0.5, + y: 0.5 + }) + mocks.waitForDocument.mockImplementation(({ expectedHrefIncludes }) => ({ + href: `orca-mobile-web://build${expectedHrefIncludes ?? '/h/host'}` + })) + }) + + it('captures native Tasks and Session from the same presentation source', async () => { + const baselines = await captureNativeCoreRouteBaselines({ + deviceUdid: 'simulator', + emulator: { deviceUdid: 'simulator' }, + expectedWorkspace: 'mobile-rearch', + runtimeDirectory: '/tmp/parity', + timeoutMs: 30_000 + }) + + expect(baselines.tasks.stableText).toBe('Connect your Linear account') + expect(mocks.tapPoint).toHaveBeenNthCalledWith( + 1, + { deviceUdid: 'simulator' }, + { x: 0.87, y: 0.1 } + ) + expect(mocks.tapPoint).toHaveBeenNthCalledWith( + 2, + { deviceUdid: 'simulator' }, + expect.objectContaining({ y: 0.1 }) + ) + expect(mocks.tapPoint.mock.calls[1][1].x).toBeCloseTo(0.08) + expect(mocks.tapControl).toHaveBeenCalledWith( + { deviceUdid: 'simulator' }, + 'Back to worktrees', + 30_000 + ) + expect(baselines.session.screenshot).toBe('/tmp/parity/native-session-portrait.png') + }) + + it('compares hosted Tasks and Session and returns to the workspace route', async () => { + const nativeBaselines = { + tasks: { + screenTitlePoint: { x: 0.2, y: 0.1 }, + screenshot: '/tmp/parity/native-tasks-portrait.png', + stableText: 'Connect your Linear account' + }, + session: { + screenTitlePoint: { x: 0.2, y: 0.1 }, + screenshot: '/tmp/parity/native-session-portrait.png' + } + } + + const result = await captureHostedCoreRouteParity({ + deviceUdid: 'simulator', + discoveryUrl: 'http://127.0.0.1:9222', + emulator: { deviceUdid: 'simulator' }, + expectedWorkspace: 'mobile-rearch', + nativeBaselines, + runtimeDirectory: '/tmp/parity', + timeoutMs: 30_000, + workspaceDocument: { href: 'orca-mobile-web://build/h/host' } + }) + + expect(result.evidence.tasks.screenshotParity).toEqual({ + changedPixelRatio: 0.01 + }) + expect(result.evidence.session.screenshotParity).toEqual({ + changedPixelRatio: 0.01 + }) + expect(result.workspaceDocument.href).toContain('/h/host') + expect(mocks.activateWorkspace).toHaveBeenCalledWith( + expect.anything(), + 'mobile-rearch', + mocks.activateControl, + 30_000, + expect.any(Function) + ) + expect(mocks.readTextPoint).toHaveBeenCalledWith(expect.anything(), 'Filter') + expect(mocks.readControlPoint).toHaveBeenCalledWith(expect.anything(), 'Back to worktrees') + expect(mocks.compareScreenshots).toHaveBeenCalledTimes(2) + }) +}) diff --git a/mobile/src/mobile-web/hosted-ios-source-control-review-journey.test.ts b/mobile/src/mobile-web/hosted-ios-source-control-review-journey.test.ts index 94713947bfc..8f65fc22c7f 100644 --- a/mobile/src/mobile-web/hosted-ios-source-control-review-journey.test.ts +++ b/mobile/src/mobile-web/hosted-ios-source-control-review-journey.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ navigateRoute: vi.fn(), readControlPoint: vi.fn(), readState: vi.fn(), + tapAccessibilityControl: vi.fn(), tapPoint: vi.fn(), waitForDocument: vi.fn() })) @@ -18,6 +19,7 @@ vi.mock('../../scripts/hosted-webview-control-point.mjs', () => ({ })) vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({ + tapHostedIosAccessibilityControl: mocks.tapAccessibilityControl, tapHostedIosPoint: mocks.tapPoint })) @@ -34,6 +36,7 @@ describe('hosted iOS Source Control and Review journey', () => { .mockResolvedValueOnce({ x: 0.4, y: 0.2 }) .mockResolvedValueOnce({ x: 0.5, y: 0.4 }) mocks.navigateRoute.mockResolvedValue(undefined) + mocks.tapAccessibilityControl.mockResolvedValue(undefined) mocks.tapPoint.mockResolvedValue(undefined) mocks.waitForDocument .mockResolvedValueOnce({ @@ -85,18 +88,19 @@ describe('hosted iOS Source Control and Review journey', () => { sessionDocument, 'Open source control' ) - expect(mocks.tapPoint).toHaveBeenNthCalledWith( + expect(mocks.tapAccessibilityControl).toHaveBeenNthCalledWith( 1, emulator, - { x: 0.4, y: 0.2 }, - 'Open source control' + 'Open source control', + 5_000 ) - expect(mocks.tapPoint).toHaveBeenNthCalledWith( + expect(mocks.tapAccessibilityControl).toHaveBeenNthCalledWith( 2, emulator, - { x: 0.5, y: 0.4 }, - 'Open changed file mobile/app/index.tsx' + 'Open changed file mobile/app/index.tsx', + 5_000 ) + expect(mocks.tapPoint).not.toHaveBeenCalled() expect(mocks.navigateRoute).toHaveBeenCalledWith( { href: 'orca-mobile-web://build/h/host/session/workspace?name=repo' }, '/h/host/review/workspace?scope=all&name=repo' @@ -127,4 +131,99 @@ describe('hosted iOS Source Control and Review journey', () => { expect.objectContaining({ expectedText: '3 tabs' }) ) }) + + it('falls back to measured points when WebKit omits accessibility descendants', async () => { + mocks.tapAccessibilityControl.mockRejectedValue(new Error('missing descendant')) + + await verifyHostedSourceControlReviewJourney({ + discoveryUrl: 'http://127.0.0.1:9222', + emulator: { deviceUdid: 'simulator' }, + sessionDocument: { + href: 'orca-mobile-web://build/h/host/session/workspace' + }, + timeoutMs: 30_000 + }) + + expect(mocks.tapPoint).toHaveBeenNthCalledWith(1, expect.anything(), { + x: 0.4, + y: 0.2 + }) + expect(mocks.tapPoint).toHaveBeenNthCalledWith(2, expect.anything(), { + x: 0.5, + y: 0.4 + }) + }) + + it('falls back to a measured point when an accessibility tap silently misses', async () => { + mocks.readControlPoint + .mockReset() + .mockResolvedValueOnce({ x: 0.4, y: 0.2 }) + .mockResolvedValueOnce({ x: 0.5, y: 0.4 }) + .mockResolvedValueOnce({ x: 0.5, y: 0.4 }) + mocks.waitForDocument + .mockReset() + .mockResolvedValueOnce({ + href: 'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session' + }) + .mockRejectedValueOnce(new Error('route unchanged')) + .mockRejectedValueOnce(new Error('route unchanged')) + .mockResolvedValueOnce({ + href: 'orca-mobile-web://build/h/host/session/workspace?name=repo' + }) + .mockResolvedValueOnce({ + href: 'orca-mobile-web://build/h/host/review/workspace' + }) + + await verifyHostedSourceControlReviewJourney({ + discoveryUrl: 'http://127.0.0.1:9222', + emulator: { deviceUdid: 'simulator' }, + sessionDocument: { + href: 'orca-mobile-web://build/h/host/session/workspace' + }, + timeoutMs: 30_000 + }) + + expect(mocks.tapAccessibilityControl).toHaveBeenCalledTimes(2) + expect(mocks.tapPoint).toHaveBeenCalledWith(expect.anything(), { + x: 0.5, + y: 0.4 + }) + }) + + it('waits for tab state after the route changes', async () => { + const sessionDocument = { + href: 'orca-mobile-web://build/h/host/session/workspace?name=repo' + } + mocks.waitForDocument + .mockReset() + .mockResolvedValueOnce({ + href: 'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session' + }) + .mockRejectedValueOnce(new Error('tab count pending')) + .mockResolvedValueOnce(sessionDocument) + .mockResolvedValueOnce(sessionDocument) + .mockResolvedValueOnce({ + href: 'orca-mobile-web://build/h/host/review/workspace' + }) + + await verifyHostedSourceControlReviewJourney({ + discoveryUrl: 'http://127.0.0.1:9222', + emulator: { deviceUdid: 'simulator' }, + sessionDocument: { + href: 'orca-mobile-web://build/h/host/session/workspace' + }, + timeoutMs: 30_000 + }) + + expect(mocks.tapAccessibilityControl).toHaveBeenCalledTimes(2) + expect(mocks.tapPoint).not.toHaveBeenCalled() + expect(mocks.waitForDocument).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ expectedText: '' }) + ) + expect(mocks.waitForDocument).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ expectedText: '2 tabs' }) + ) + }) })