test(mobile): add iOS files preview parity

This commit is contained in:
OrcaWin
2026-08-09 18:34:57 -04:00
committed by Jinwoo-H
parent abaa3e59bf
commit 8cec2462d8
16 changed files with 826 additions and 44 deletions
@@ -2458,4 +2458,7 @@ copy.
| 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 | Complete | The deterministic iOS fixture now opens the real `Casks/orca.rb` from the unchanged Files UI into the unchanged Preview UI. Files passes at 0.030% changed pixels / 0.128 mean channel difference and Preview at 0.061% / 0.274 against 3% / 4 budgets. Equivalent hosted route values retain one logical preview load, and RNW uses the existing native iOS font fallback. |
| 2026-07-28 | Complete | Both the cached-app and fresh Xcode build/install complete journeys pass Files/Preview parity plus Tasks, Session, Agent History portrait/landscape, Desktop restart/E2EE recovery, native-touch resume, Source Control, third diff tab, standalone Review, and private-origin isolation. |
| 2026-07-28 | Complete | RNW package `9e5e807523e8b917fef68f221cc1fd2e1a16dbe07d7077e717238eed17003b52` verifies with 49 assets, 9,330,604 raw bytes, and 2,697,919 gzip bytes. The mobile suite passes 553 files / 3,297 tests with 2 expected skips; all typechecks and repository quality gates pass. This slice's root suite hit an unrelated timeout that passed in isolation, so the prior complete root-suite checkpoint remains authoritative. |
| 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. |
@@ -486,6 +486,14 @@ 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 iOS fixture now also navigates the unchanged Files tree into the real
`Casks/orca.rb` Preview in native and hosted modes. Files measures 0.030%
changed pixels / 0.128 mean channel difference and Preview measures 0.061% /
0.274, within the 3% / 4 budgets. Both the cached-app and fresh Xcode
build/install complete journeys pass. Hosted Preview source identity is stable
across equivalent route rerenders, while web typography follows the existing
native iOS fallback rather than introducing a replacement presentation.
The interrupted-transcript versus hook-status mismatch and a real structured
prompt response pass Host 37 Simulator replay. Current package `4b7df7d4…`
also carries the network-denied local Mermaid engine and its WebKit-compatible
@@ -1851,6 +1851,15 @@ failure, or fatal process error. The final post-rebase run uses independently
verified build
`f852d8525d2b0e20d79262d74ce3ef74bfa73c3e55b95176bfb1b467beafae61`.
The deterministic iOS fixture now covers the full Files-to-Preview transition
with the real `Casks/orca.rb` content. Native and hosted use the same
`MobileFilePreviewScreen`; equivalent route values retain one logical preview
load, and RNW follows the native iOS font fallback. Files and Preview pass
strict screenshot parity in both cached-app and fresh Xcode build/install
complete journeys. The independently verified package containing this work is
`9e5e807523e8b917fef68f221cc1fd2e1a16dbe07d7077e717238eed17003b52`:
49 assets, 9,330,604 raw bytes, and 2,697,919 gzip bytes.
The host-only router now imports the existing
`mobile/app/h/[hostId]/session/[worktreeId].tsx` route directly; a
source-identity test prevents that wrapper from growing copied presentation.
@@ -57,14 +57,27 @@ 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 same exact-app iOS gate now covers the unchanged Files route and a real
Preview navigation through `Casks/orca.rb`. Files passes at 0.030% changed
pixels and 0.128 mean channel difference; Preview passes at 0.061% and 0.274.
Both cached-app and fresh Xcode build/install journeys pass the complete route,
recovery, review, and isolation matrix. Equivalent hosted route values no
longer restart Preview loads, and RNW preserves the native iOS font fallback.
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, 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.
`9e5e807523e8b917fef68f221cc1fd2e1a16dbe07d7077e717238eed17003b52`:
49 assets, 9,330,604 raw bytes, and 2,697,919 gzip bytes. The current mobile
suite passes 553 files / 3,297 tests with 2 expected skips; all project
typechecks and repository-wide quality gates pass. A fresh root-suite attempt
hit an unrelated 30-second timeout in
`project-view-wrapper-source-context-boundary.test.ts` and was interrupted
under concurrent test load; that exact file passes alone, so the prior complete
root-suite checkpoint above remains authoritative.
## 1. Finish Hosted Feature Parity
@@ -130,8 +130,7 @@ export async function captureHostedCoreRouteParity({
screenshotName: 'hosted-session-portrait.png',
title: expectedWorkspace
})
const backPoint = await readHostedWebViewControlPoint(sessionDocument, 'Back to worktrees')
await tapHostedIosPoint(emulator, backPoint)
await tapHostedSessionBack(emulator, sessionDocument, timeoutMs)
activeWorkspaceDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
@@ -214,6 +213,19 @@ function tasksBackPoint(titlePoint) {
return { x: Math.max(0.04, titlePoint.x - 0.12), y: titlePoint.y }
}
async function tapHostedSessionBack(emulator, document, timeoutMs) {
try {
await tapHostedIosAccessibilityControl(
emulator,
'Back to worktrees',
Math.min(timeoutMs, 5_000)
)
} catch {
const backPoint = await readHostedWebViewControlPoint(document, 'Back to worktrees')
await tapHostedIosPoint(emulator, backPoint)
}
}
async function captureSimulatorScreenshot(deviceUdid, outputPath) {
await execFileAsync('xcrun', ['simctl', 'io', deviceUdid, 'screenshot', outputPath])
}
@@ -0,0 +1,308 @@
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
} from './hosted-ios-emulator-accessibility.mjs'
import { assertHostedIosScreenshotParity } from './hosted-ios-screenshot-parity.mjs'
import {
activateHostedWebViewControl,
readHostedWebViewState,
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 FILES_STABLE_LABEL = 'Open folder Casks'
const PREVIEW_FILE_LABEL = 'Preview file orca.rb'
const PREVIEW_STABLE_LABEL = 'File preview'
const PREVIEW_STABLE_TEXT = 'cask "orca" do'
export async function captureNativeFilesPreviewBaselines({
deviceUdid,
emulator,
expectedWorkspace,
runtimeDirectory,
timeoutMs
}) {
await dismissEmulatorDeveloperMenuIfPresent(emulator)
await tapHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs)
await waitForHostedIosAccessibilityControlByLabelPrefix(emulator, 'Mobile Emulator', timeoutMs)
await tapHostedIosAccessibilityControl(emulator, 'Open file explorer', timeoutMs)
await waitForHostedIosAccessibilityControl(emulator, FILES_STABLE_LABEL, timeoutMs)
const files = await captureNativeRoute({
deviceUdid,
emulator,
runtimeDirectory,
screenshotName: 'native-files-portrait.png',
title: 'Files',
timeoutMs
})
await tapHostedIosAccessibilityControl(emulator, FILES_STABLE_LABEL, timeoutMs)
await tapHostedIosAccessibilityControl(emulator, PREVIEW_FILE_LABEL, timeoutMs)
await waitForHostedIosAccessibilityControl(emulator, PREVIEW_STABLE_LABEL, timeoutMs)
const preview = await captureNativeRoute({
deviceUdid,
emulator,
runtimeDirectory,
screenshotName: 'native-file-preview-portrait.png',
title: 'orca.rb',
timeoutMs
})
await returnNativeToWorkspaces({ emulator, expectedWorkspace, timeoutMs })
return { files, preview }
}
export async function captureHostedFilesPreviewParity({
deviceUdid,
discoveryUrl,
emulator,
expectedWorkspace,
nativeBaselines,
runtimeDirectory,
timeoutMs,
workspaceDocument
}) {
await activateHostedWorkspaceRow(
workspaceDocument,
expectedWorkspace,
activateHostedWebViewControl,
timeoutMs,
() =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
timeoutMs
})
)
const sessionDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Mobile Emulator',
expectedHrefIncludes: '/session/',
timeoutMs
})
const filesDocument = await tapForHostedTransition({
document: sessionDocument,
emulator,
label: 'Open file explorer',
timeoutMs,
resolve: (transitionTimeoutMs) =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Files',
expectedHrefIncludes: '/files/',
timeoutMs: transitionTimeoutMs
})
})
await waitForHostedLabel(filesDocument, FILES_STABLE_LABEL, timeoutMs)
const files = await captureHostedRoute({
deviceUdid,
document: filesDocument,
nativeBaseline: nativeBaselines.files,
runtimeDirectory,
screenshotName: 'hosted-files-portrait.png',
title: 'Files',
timeoutMs
})
await tapForHostedTransition({
document: filesDocument,
emulator,
label: FILES_STABLE_LABEL,
timeoutMs,
resolve: (transitionTimeoutMs) =>
waitForHostedLabel(filesDocument, PREVIEW_FILE_LABEL, transitionTimeoutMs)
})
await tapForHostedTransition({
document: filesDocument,
emulator,
label: PREVIEW_FILE_LABEL,
timeoutMs,
resolve: (transitionTimeoutMs) =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'orca.rb',
expectedHrefIncludes: '/files/preview/',
timeoutMs: transitionTimeoutMs
})
})
const previewDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: PREVIEW_STABLE_TEXT,
expectedHrefIncludes: '/files/preview/',
timeoutMs
})
const preview = await captureHostedRoute({
deviceUdid,
document: previewDocument,
nativeBaseline: nativeBaselines.preview,
runtimeDirectory,
screenshotName: 'hosted-file-preview-portrait.png',
title: 'orca.rb',
timeoutMs
})
const returnedWorkspaceDocument = await returnHostedToWorkspaces({
discoveryUrl,
emulator,
expectedWorkspace,
filesDocument,
previewDocument,
timeoutMs
})
return {
evidence: {
files: filesPreviewParityEvidence(nativeBaselines.files, files),
preview: filesPreviewParityEvidence(nativeBaselines.preview, preview)
},
workspaceDocument: returnedWorkspaceDocument
}
}
export function filesPreviewParityEvidence(nativeCapture, hostedCapture) {
return {
nativeScreenshot: path.basename(nativeCapture.screenshot),
hostedScreenshot: path.basename(hostedCapture.screenshot),
nativeScreenTitlePoint: nativeCapture.screenTitlePoint,
hostedScreenTitlePoint: hostedCapture.screenTitlePoint,
screenshotParity: hostedCapture.screenshotParity
}
}
async function captureNativeRoute(options) {
const screenTitlePoint = await waitForHostedIosAccessibilityControl(
options.emulator,
options.title,
options.timeoutMs
)
await delay(500)
const screenshot = path.join(options.runtimeDirectory, options.screenshotName)
await captureSimulatorScreenshot(options.deviceUdid, screenshot)
return { screenTitlePoint, screenshot }
}
async function captureHostedRoute(options) {
const screenTitlePoint = await readHostedWebViewTextPoint(options.document, options.title)
const screenshot = path.join(options.runtimeDirectory, options.screenshotName)
const deadline = Date.now() + options.timeoutMs
let lastError = new Error(`${options.title} did not reach screenshot parity`)
while (Date.now() < deadline) {
await delay(500)
await captureSimulatorScreenshot(options.deviceUdid, screenshot)
try {
const screenshotParity = await assertHostedIosScreenshotParity({
hostedLandmark: screenTitlePoint,
hostedScreenshot: screenshot,
nativeLandmark: options.nativeBaseline.screenTitlePoint,
nativeScreenshot: options.nativeBaseline.screenshot
})
return { screenTitlePoint, screenshot, screenshotParity }
} catch (error) {
lastError = error
}
}
throw lastError
}
async function returnNativeToWorkspaces({ emulator, expectedWorkspace, timeoutMs }) {
await tapHostedIosAccessibilityControl(emulator, 'Back to files', timeoutMs)
await waitForHostedIosAccessibilityControl(emulator, FILES_STABLE_LABEL, timeoutMs)
await tapHostedIosAccessibilityControl(emulator, 'Back to session', timeoutMs)
await waitForHostedIosAccessibilityControlByLabelPrefix(emulator, 'Mobile Emulator', timeoutMs)
await tapHostedIosAccessibilityControl(emulator, 'Back to worktrees', timeoutMs)
await waitForHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs)
}
async function returnHostedToWorkspaces(options) {
const filesDocument = await tapForHostedTransition({
document: options.previewDocument,
emulator: options.emulator,
label: 'Back to files',
timeoutMs: options.timeoutMs,
resolve: (transitionTimeoutMs) =>
waitForVisibleHostedWebView({
discoveryUrl: options.discoveryUrl,
expectedText: 'Files',
expectedHrefIncludes: '/files/',
timeoutMs: transitionTimeoutMs
})
})
const sessionDocument = await tapForHostedTransition({
document: filesDocument,
emulator: options.emulator,
label: 'Back to session',
timeoutMs: options.timeoutMs,
resolve: (transitionTimeoutMs) =>
waitForVisibleHostedWebView({
discoveryUrl: options.discoveryUrl,
expectedText: 'Mobile Emulator',
expectedHrefIncludes: '/session/',
timeoutMs: transitionTimeoutMs
})
})
return tapForHostedTransition({
document: sessionDocument,
emulator: options.emulator,
label: 'Back to worktrees',
timeoutMs: options.timeoutMs,
resolve: (transitionTimeoutMs) =>
waitForVisibleHostedWebView({
discoveryUrl: options.discoveryUrl,
expectedText: 'Orca Desktop',
timeoutMs: transitionTimeoutMs
})
})
}
async function tapForHostedTransition({ document, emulator, label, resolve, timeoutMs }) {
const deadline = Date.now() + timeoutMs
let lastError = new Error(`${label} did not transition`)
for (let attempt = 0; attempt < 3 && Date.now() < deadline; attempt += 1) {
try {
await tapHostedControl(emulator, document, label, timeoutMs, attempt)
return await resolve(Math.min(3_000, Math.max(1_000, deadline - Date.now())))
} catch (error) {
lastError = error
}
}
throw lastError
}
async function tapHostedControl(emulator, document, label, timeoutMs, attempt) {
if (attempt === 0) {
try {
await tapHostedIosAccessibilityControl(emulator, label, Math.min(timeoutMs, 5_000))
return
} catch {
// WebKit can omit a descendant while refreshing its accessibility tree.
}
}
const point = await readHostedWebViewControlPoint(document, label)
await tapHostedIosPoint(emulator, point)
}
async function waitForHostedLabel(document, label, timeoutMs) {
const deadline = Date.now() + timeoutMs
let state
while (Date.now() < deadline) {
state = await readHostedWebViewState(document)
if (state.labels.includes(label)) {
return state
}
await delay(250)
}
throw new Error(`${label} was not present. Last labels: ${(state?.labels ?? []).join(', ')}`)
}
async function captureSimulatorScreenshot(deviceUdid, outputPath) {
await execFileAsync('xcrun', ['simctl', 'io', deviceUdid, 'screenshot', outputPath])
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -1,11 +1,12 @@
import process from 'node:process'
const usage =
'Usage: node scripts/run-hosted-webview-simulator-e2e.mjs [--device <name|udid>] [--timeout-ms <ms>] [--security-only] [--native-settings-only] [--source-control-only] [--skip-native-build]'
'Usage: node scripts/run-hosted-webview-simulator-e2e.mjs [--device <name|udid>] [--timeout-ms <ms>] [--security-only] [--files-preview-only] [--native-settings-only] [--source-control-only] [--skip-native-build]'
export function parseHostedWebViewSimulatorE2eOptions(args) {
const parsed = {
device: 'iPhone 17 Pro',
filesPreviewOnly: false,
nativeSettingsOnly: false,
securityOnly: false,
skipNativeBuild: false,
@@ -21,6 +22,8 @@ export function parseHostedWebViewSimulatorE2eOptions(args) {
parsed.timeoutMs = Number(args[++index])
} else if (args[index] === '--security-only') {
parsed.securityOnly = true
} else if (args[index] === '--files-preview-only') {
parsed.filesPreviewOnly = true
} else if (args[index] === '--native-settings-only') {
parsed.nativeSettingsOnly = true
} else if (args[index] === '--source-control-only') {
@@ -38,8 +41,12 @@ export function parseHostedWebViewSimulatorE2eOptions(args) {
throw new Error('--timeout-ms must be an integer of at least 10000')
}
if (
[parsed.securityOnly, parsed.nativeSettingsOnly, parsed.sourceControlOnly].filter(Boolean)
.length > 1
[
parsed.securityOnly,
parsed.filesPreviewOnly,
parsed.nativeSettingsOnly,
parsed.sourceControlOnly
].filter(Boolean).length > 1
) {
throw new Error('Focused journey options are mutually exclusive')
}
@@ -21,6 +21,10 @@ import {
captureHostedCoreRouteParity,
captureNativeCoreRouteBaselines
} from './hosted-ios-core-route-parity.mjs'
import {
captureHostedFilesPreviewParity,
captureNativeFilesPreviewBaselines
} from './hosted-ios-files-preview-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'
@@ -100,7 +104,10 @@ async function main() {
completeHostedIosNativeOnboarding(emulator, expectedWorkspace, options.timeoutMs)
)
const nativeCoreRoutes =
options.securityOnly || options.nativeSettingsOnly || options.sourceControlOnly
options.securityOnly ||
options.filesPreviewOnly ||
options.nativeSettingsOnly ||
options.sourceControlOnly
? null
: await evidenceStep('native Tasks and Session baselines', () =>
captureNativeCoreRouteBaselines({
@@ -111,8 +118,23 @@ async function main() {
timeoutMs: options.timeoutMs
})
)
const nativeAgentHistory =
const nativeFilesPreview =
options.securityOnly || options.nativeSettingsOnly || options.sourceControlOnly
? null
: await evidenceStep('native Files and Preview baselines', () =>
captureNativeFilesPreviewBaselines({
deviceUdid,
emulator,
expectedWorkspace,
runtimeDirectory,
timeoutMs: options.timeoutMs
})
)
const nativeAgentHistory =
options.securityOnly ||
options.filesPreviewOnly ||
options.nativeSettingsOnly ||
options.sourceControlOnly
? null
: await evidenceStep('native Agent History baseline', () =>
captureNativeAgentHistoryBaseline({
@@ -134,7 +156,10 @@ async function main() {
timeoutMs: options.timeoutMs
})
const hostedCoreRoutes =
options.securityOnly || options.nativeSettingsOnly || options.sourceControlOnly
options.securityOnly ||
options.filesPreviewOnly ||
options.nativeSettingsOnly ||
options.sourceControlOnly
? null
: await evidenceStep('hosted Tasks and Session parity', () =>
captureHostedCoreRouteParity({
@@ -149,15 +174,31 @@ async function main() {
})
)
const activeWorkspaceDocument = hostedCoreRoutes?.workspaceDocument ?? workspaceDocument
const hostedFilesPreview =
options.securityOnly || options.nativeSettingsOnly || options.sourceControlOnly
? null
: await evidenceStep('hosted Files and Preview parity', () =>
captureHostedFilesPreviewParity({
deviceUdid,
discoveryUrl: `http://127.0.0.1:${inspectorPort}`,
emulator,
expectedWorkspace,
nativeBaselines: nativeFilesPreview,
runtimeDirectory,
timeoutMs: options.timeoutMs,
workspaceDocument: activeWorkspaceDocument
})
)
const parityWorkspaceDocument = hostedFilesPreview?.workspaceDocument ?? activeWorkspaceDocument
const historyEvidence =
options.securityOnly || options.sourceControlOnly
options.securityOnly || options.filesPreviewOnly || options.sourceControlOnly
? null
: options.nativeSettingsOnly
? await evidenceStep('native Terminal Settings journey', () =>
verifyNativeSettingsJourney({
discoveryUrl: `http://127.0.0.1:${inspectorPort}`,
emulator,
workspaceDocument: activeWorkspaceDocument,
workspaceDocument: parityWorkspaceDocument,
expectedWorkspace,
timeoutMs: options.timeoutMs
})
@@ -169,13 +210,13 @@ async function main() {
emulator,
nativeAgentHistory,
runtimeDirectory,
workspaceDocument: activeWorkspaceDocument,
workspaceDocument: parityWorkspaceDocument,
expectedWorkspace,
timeoutMs: options.timeoutMs
})
)
const sourceControlReview =
options.securityOnly || options.nativeSettingsOnly
options.securityOnly || options.filesPreviewOnly || options.nativeSettingsOnly
? null
: await evidenceStep('Source Control and Review journey', async () => {
if (options.sourceControlOnly) {
@@ -207,14 +248,15 @@ async function main() {
timeoutMs: options.timeoutMs
})
})
const securityDocument = options.securityOnly
? workspaceDocument
: await waitForVisibleHostedWebView({
discoveryUrl: `http://127.0.0.1:${inspectorPort}`,
expectedText: options.nativeSettingsOnly ? 'Mobile Emulator' : 'reviewed',
expectedHrefIncludes: options.nativeSettingsOnly ? '/session/' : '/review/',
timeoutMs: options.timeoutMs
})
const securityDocument =
options.securityOnly || options.filesPreviewOnly
? parityWorkspaceDocument
: await waitForVisibleHostedWebView({
discoveryUrl: `http://127.0.0.1:${inspectorPort}`,
expectedText: options.nativeSettingsOnly ? 'Mobile Emulator' : 'reviewed',
expectedHrefIncludes: options.nativeSettingsOnly ? '/session/' : '/review/',
timeoutMs: options.timeoutMs
})
const networkIsolation = await evidenceStep('network isolation probe', () =>
verifyHostedWebViewNetworkIsolation({
document: securityDocument,
@@ -248,6 +290,7 @@ async function main() {
nativeOnboarding,
agentHistory: historyEvidence,
coreRouteParity: hostedCoreRoutes?.evidence ?? null,
filesPreviewParity: hostedFilesPreview?.evidence ?? null,
sourceControlReview
},
null,
@@ -1,4 +1,9 @@
import { describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HostFilePreviewOperations } from './host-file-preview-operations'
import type { MobileFilePreviewRouteState } from './mobile-file-preview-route'
import { MobileFilePreviewScreen } from './MobileFilePreviewScreen'
import { sourceKeyForPreview } from './mobile-file-preview-source'
import {
hasUnsavedMobileTerminalArtifactDraft,
@@ -6,7 +11,71 @@ import {
shouldKeepDirtyDraftOnPreviewLoadResult
} from './mobile-file-preview-editability'
vi.mock('react-native', () => ({
Alert: { alert: vi.fn() },
BackHandler: { addEventListener: () => ({ remove: vi.fn() }) },
Pressable: 'Pressable',
Text: 'Text',
View: 'View',
useWindowDimensions: () => ({ width: 390, height: 844 })
}))
vi.mock('react-native-safe-area-context', () => ({
SafeAreaView: 'SafeAreaView'
}))
vi.mock('expo-router', () => ({
useRouter: () => ({ back: vi.fn() })
}))
vi.mock('lucide-react-native', () => ({
ChevronLeft: 'ChevronLeft',
Save: 'Save'
}))
vi.mock('../transport/client-context', () => ({
useForceReconnect: () => vi.fn(),
useHostClient: () => ({ client: null, state: 'disconnected' })
}))
vi.mock('../theme/mobile-theme', () => ({
colors: { textPrimary: '#fff', textSecondary: '#999' },
spacing: { md: 16 }
}))
vi.mock('./mobile-file-preview-styles', () => ({
filePreviewStyles: {}
}))
vi.mock('./MobileFilePreviewBody', () => ({
MobileFilePreviewBody: 'MobileFilePreviewBody'
}))
vi.mock('./default-host-file-preview-operations', () => ({
defaultHostFilePreviewOperations: vi.fn()
}))
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
describe('MobileFilePreviewScreen', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
renderer?.unmount()
renderer = null
vi.restoreAllMocks()
})
it('treats empty terminal artifact text previews as editable', () => {
expect(isEditableMobileTerminalArtifactPreview({ status: 'empty', kind: 'text' })).toBe(true)
expect(
@@ -81,4 +150,55 @@ describe('MobileFilePreviewScreen', () => {
})
).toBe(false)
})
it('does not reload when an equivalent route object is rendered', async () => {
const route: MobileFilePreviewRouteState = {
ok: true,
params: {
hostId: 'host-1',
worktreeId: 'worktree-1',
relativePath: 'Casks/orca.rb',
source: 'worktree'
}
}
const operations: HostFilePreviewOperations = {
load: vi.fn(async () => ({
status: 'ready',
kind: 'text',
content: 'cask "orca" do',
truncated: false,
byteLength: 14
})),
saveTerminalArtifact: vi.fn(),
reconnect: vi.fn(),
openExternalUrl: vi.fn()
}
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileFilePreviewScreen, {
route,
operations,
connectionState: 'connected',
nativeHostBinding: false
})
)
})
await act(async () => {
renderer!.update(
createElement(MobileFilePreviewScreen, {
route: { ok: true, params: { ...route.params } },
operations,
connectionState: 'connected',
nativeHostBinding: false
})
)
})
} finally {
restoreConsoleError()
}
expect(operations.load).toHaveBeenCalledTimes(1)
})
})
+25 -15
View File
@@ -17,7 +17,11 @@ import {
displayNameFromPreviewPath,
type MobileFilePreviewRouteState
} from './mobile-file-preview-route'
import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source'
import {
previewSourceFromRoute,
sourceKeyForPreview,
sourceRevisionForPreview
} from './mobile-file-preview-source'
import { normalizeMobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
import {
hasUnsavedMobileTerminalArtifactDraft,
@@ -43,17 +47,16 @@ export function MobileFilePreviewScreen({
}: Props) {
const router = useRouter()
const previewParams = route.ok ? route.params : null
const nativeHost = useHostClient(nativeHostBinding ? previewParams?.hostId : undefined)
const previewHostId = previewParams?.hostId
const nativeHost = useHostClient(nativeHostBinding ? previewHostId : undefined)
const forceReconnect = useForceReconnect()
const operations = useMemo(
() =>
operationsProp ??
(nativeHost.client && previewParams
? defaultHostFilePreviewOperations(nativeHost.client, () =>
forceReconnect(previewParams.hostId)
)
(nativeHost.client && previewHostId
? defaultHostFilePreviewOperations(nativeHost.client, () => forceReconnect(previewHostId))
: null),
[forceReconnect, nativeHost.client, operationsProp, previewParams]
[forceReconnect, nativeHost.client, operationsProp, previewHostId]
)
const connState = connectionState ?? nativeHost.state
const handleOpenExternalUrl = useCallback(
@@ -73,10 +76,15 @@ export function MobileFilePreviewScreen({
const savedContentRef = useRef(savedContent)
const draftSourceKeyRef = useRef<string | null>(null)
const { width, height } = useWindowDimensions()
const routePreviewSource = useMemo(
() => (previewParams ? previewSourceFromRoute(previewParams) : null),
[previewParams]
)
const routePreviewSourceCandidate = previewParams ? previewSourceFromRoute(previewParams) : null
const routePreviewSourceRevision = sourceRevisionForPreview(routePreviewSourceCandidate)
const routePreviewSourceRef = useRef(routePreviewSourceCandidate)
const routePreviewSourceRevisionRef = useRef(routePreviewSourceRevision)
if (routePreviewSourceRevisionRef.current !== routePreviewSourceRevision) {
routePreviewSourceRef.current = routePreviewSourceCandidate
routePreviewSourceRevisionRef.current = routePreviewSourceRevision
}
const routePreviewSource = routePreviewSourceRef.current
const [previewSource, setPreviewSource] = useState<MobileFilePreviewSource | null>(
routePreviewSource
)
@@ -85,6 +93,8 @@ export function MobileFilePreviewScreen({
() => sourceKeyForPreview(routePreviewSource),
[routePreviewSource]
)
const hasPreviewParams = previewParams !== null
const routeErrorMessage = route.ok ? null : route.message
const previewSourceKeyRef = useRef(previewSourceKey)
const lineColumn = useMemo(
() =>
@@ -113,8 +123,8 @@ export function MobileFilePreviewScreen({
const loadPreview = useCallback(async () => {
const loadSourceKey = previewSourceKey
if (!previewParams || !previewSource || loadSourceKey !== routePreviewSourceKey) {
setPreview(previewError(route.ok ? 'Unable to load preview' : route.message))
if (!hasPreviewParams || !previewSource || loadSourceKey !== routePreviewSourceKey) {
setPreview(previewError(routeErrorMessage ?? 'Unable to load preview'))
return
}
const preserveDirtyDraft =
@@ -167,11 +177,11 @@ export function MobileFilePreviewScreen({
}
}, [
connState,
hasPreviewParams,
operations,
previewParams,
previewSource,
previewSourceKey,
route,
routeErrorMessage,
routePreviewSourceKey
])
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { sourceKeyForPreview } from './mobile-file-preview-source'
import { sourceKeyForPreview, sourceRevisionForPreview } from './mobile-file-preview-source'
describe('mobile-file-preview-source', () => {
it('uses structured preview keys so colons in paths cannot collide', () => {
@@ -25,4 +25,17 @@ describe('mobile-file-preview-source', () => {
sourceKeyForPreview({ source: 'worktree', worktreeId: 'wt:1', relativePath: 'a:b.ts' })
).toBe(JSON.stringify(['worktree', 'wt:1', 'a:b.ts']))
})
it('tracks refreshed terminal capabilities separately from file identity', () => {
const original = {
source: 'terminalArtifact' as const,
worktreeId: 'wt:1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1'
}
const refreshed = { ...original, grantId: 'grant-2' }
expect(sourceKeyForPreview(original)).toBe(sourceKeyForPreview(refreshed))
expect(sourceRevisionForPreview(original)).not.toBe(sourceRevisionForPreview(refreshed))
})
})
@@ -37,3 +37,20 @@ export function sourceKeyForPreview(source: MobileFilePreviewSource | null): str
])
: JSON.stringify(['worktree', source.worktreeId, source.relativePath])
}
export function sourceRevisionForPreview(source: MobileFilePreviewSource | null): string | null {
if (!source) {
return null
}
return source.source === 'terminalArtifact'
? JSON.stringify([
'terminal',
source.worktreeId,
source.absolutePath,
source.grantId,
source.terminalHandle ?? '',
source.pathText ?? '',
source.cwd ?? ''
])
: JSON.stringify(['worktree', source.worktreeId, source.relativePath])
}
@@ -1,4 +1,4 @@
import { StyleSheet } from 'react-native'
import { Platform, StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const filePreviewStyles = StyleSheet.create({
@@ -94,7 +94,10 @@ export const filePreviewStyles = StyleSheet.create({
},
textPreview: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
...Platform.select({
default: { fontFamily: typography.monoFamily },
web: {}
}),
fontSize: 13,
lineHeight: 19
},
@@ -161,7 +164,10 @@ export const filePreviewStyles = StyleSheet.create({
editInput: {
flex: 1,
color: colors.textPrimary,
fontFamily: typography.monoFamily,
...Platform.select({
default: { fontFamily: typography.monoFamily },
web: {}
}),
fontSize: 13,
lineHeight: 19,
padding: 0
@@ -143,7 +143,39 @@ describe('hosted iOS core-route parity', () => {
expect.any(Function)
)
expect(mocks.readTextPoint).toHaveBeenCalledWith(expect.anything(), 'Filter')
expect(mocks.readControlPoint).toHaveBeenCalledWith(expect.anything(), 'Back to worktrees')
expect(mocks.tapControl).toHaveBeenCalledWith(
{ deviceUdid: 'simulator' },
'Back to worktrees',
5_000
)
expect(mocks.compareScreenshots).toHaveBeenCalledTimes(2)
})
it('uses a measured point when the hosted session back control is missing from AX', async () => {
mocks.tapControl.mockRejectedValueOnce(new Error('missing accessibility descendant'))
await captureHostedCoreRouteParity({
deviceUdid: 'simulator',
discoveryUrl: 'http://127.0.0.1:9222',
emulator: { deviceUdid: 'simulator' },
expectedWorkspace: 'mobile-rearch',
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'
}
},
runtimeDirectory: '/tmp/parity',
timeoutMs: 30_000,
workspaceDocument: { href: 'orca-mobile-web://build/h/host' }
})
expect(mocks.readControlPoint).toHaveBeenCalledWith(expect.anything(), 'Back to worktrees')
expect(mocks.tapPoint).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, { x: 0.8, y: 0.1 })
})
})
@@ -0,0 +1,180 @@
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(),
readState: vi.fn(),
readTextPoint: vi.fn(),
tapControl: vi.fn(),
tapControlByPrefix: vi.fn(),
tapPoint: vi.fn(),
waitForControl: vi.fn(),
waitForControlByPrefix: 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
}))
vi.mock('../../scripts/hosted-ios-screenshot-parity.mjs', () => ({
assertHostedIosScreenshotParity: mocks.compareScreenshots
}))
vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({
activateHostedWebViewControl: mocks.activateControl,
readHostedWebViewState: mocks.readState,
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 {
captureHostedFilesPreviewParity,
captureNativeFilesPreviewBaselines
} from '../../scripts/hosted-ios-files-preview-parity.mjs'
describe('hosted iOS Files and Preview 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.readState.mockResolvedValue({
href: 'orca-mobile-web://build/h/host/files/worktree',
bodyText: 'Files Casks orca.rb',
labels: ['Open folder Casks', 'Preview file orca.rb', 'File preview'],
placeholders: []
})
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.waitForDocument.mockImplementation(({ expectedHrefIncludes }) => ({
href: `orca-mobile-web://build${expectedHrefIncludes ?? '/h/host'}`,
targetId: expectedHrefIncludes ?? 'workspace'
}))
})
it('captures native Files and Preview and returns to worktrees', async () => {
const baselines = await captureNativeFilesPreviewBaselines({
deviceUdid: 'simulator',
emulator: { deviceUdid: 'simulator' },
expectedWorkspace: 'mobile-rearch',
runtimeDirectory: '/tmp/parity',
timeoutMs: 30_000
})
expect(mocks.tapControl).toHaveBeenCalledWith(
{ deviceUdid: 'simulator' },
'Open file explorer',
30_000
)
expect(mocks.tapControl).toHaveBeenCalledWith(
{ deviceUdid: 'simulator' },
'Preview file orca.rb',
30_000
)
expect(mocks.tapControl).toHaveBeenCalledWith(
{ deviceUdid: 'simulator' },
'Back to worktrees',
30_000
)
expect(baselines).toEqual({
files: {
screenTitlePoint: { x: 0.2, y: 0.1 },
screenshot: '/tmp/parity/native-files-portrait.png'
},
preview: {
screenTitlePoint: { x: 0.2, y: 0.1 },
screenshot: '/tmp/parity/native-file-preview-portrait.png'
}
})
})
it('compares hosted Files and Preview and returns to the workspace route', async () => {
const result = await captureHostedFilesPreviewParity({
deviceUdid: 'simulator',
discoveryUrl: 'http://127.0.0.1:9222',
emulator: { deviceUdid: 'simulator' },
expectedWorkspace: 'mobile-rearch',
nativeBaselines: {
files: {
screenTitlePoint: { x: 0.2, y: 0.1 },
screenshot: '/tmp/parity/native-files-portrait.png'
},
preview: {
screenTitlePoint: { x: 0.2, y: 0.1 },
screenshot: '/tmp/parity/native-file-preview-portrait.png'
}
},
runtimeDirectory: '/tmp/parity',
timeoutMs: 30_000,
workspaceDocument: { href: 'orca-mobile-web://build/h/host' }
})
expect(result.evidence.files.screenshotParity).toEqual({ changedPixelRatio: 0.01 })
expect(result.evidence.preview.screenshotParity).toEqual({ changedPixelRatio: 0.01 })
expect(result.workspaceDocument.href).toContain('/h/host')
expect(mocks.activateWorkspace).toHaveBeenCalledOnce()
expect(mocks.tapControl).toHaveBeenCalledWith(
{ deviceUdid: 'simulator' },
'Open file explorer',
5_000
)
expect(mocks.tapControl).toHaveBeenCalledWith(
{ deviceUdid: 'simulator' },
'Preview file orca.rb',
5_000
)
expect(mocks.compareScreenshots).toHaveBeenCalledTimes(2)
})
it('uses a measured WebView point when a control is missing from native accessibility', async () => {
mocks.tapControl.mockRejectedValueOnce(new Error('missing accessibility descendant'))
await captureHostedFilesPreviewParity({
deviceUdid: 'simulator',
discoveryUrl: 'http://127.0.0.1:9222',
emulator: { deviceUdid: 'simulator' },
expectedWorkspace: 'mobile-rearch',
nativeBaselines: {
files: {
screenTitlePoint: { x: 0.2, y: 0.1 },
screenshot: '/tmp/parity/native-files-portrait.png'
},
preview: {
screenTitlePoint: { x: 0.2, y: 0.1 },
screenshot: '/tmp/parity/native-file-preview-portrait.png'
}
},
runtimeDirectory: '/tmp/parity',
timeoutMs: 30_000,
workspaceDocument: { href: 'orca-mobile-web://build/h/host' }
})
expect(mocks.readControlPoint).toHaveBeenCalledWith(expect.anything(), 'Open file explorer')
expect(mocks.tapPoint).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, { x: 0.8, y: 0.1 })
})
})
@@ -5,6 +5,7 @@ describe('hosted WebView simulator E2E options', () => {
it('retains bounded defaults', () => {
expect(parseHostedWebViewSimulatorE2eOptions([])).toEqual({
device: 'iPhone 17 Pro',
filesPreviewOnly: false,
nativeSettingsOnly: false,
securityOnly: false,
skipNativeBuild: false,
@@ -34,7 +35,7 @@ describe('hosted WebView simulator E2E options', () => {
it('rejects mutually exclusive focused journeys', () => {
expect(() =>
parseHostedWebViewSimulatorE2eOptions(['--security-only', '--source-control-only'])
parseHostedWebViewSimulatorE2eOptions(['--files-preview-only', '--source-control-only'])
).toThrow('mutually exclusive')
})