test(mobile): add iOS core route parity

This commit is contained in:
OrcaWin
2026-08-09 18:34:57 -04:00
committed by Jinwoo-H
parent 3feb8bf332
commit abaa3e59bf
9 changed files with 639 additions and 35 deletions
@@ -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. |
@@ -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
@@ -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
@@ -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.
@@ -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))
}
@@ -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
@@ -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,
@@ -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)
})
})
@@ -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' })
)
})
})