From 8cec2462d8dee18a758e2d9b0aafcd8e67ae8041 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:20:03 -0700 Subject: [PATCH] test(mobile): add iOS files preview parity --- ...hybrid-webview-implementation-checklist.md | 3 + ...-mobile-hybrid-webview-parity-inventory.md | 8 + ...bile-hybrid-webview-single-pr-migration.md | 9 + ...27-mobile-hybrid-webview-remaining-work.md | 17 +- .../scripts/hosted-ios-core-route-parity.mjs | 16 +- .../hosted-ios-files-preview-parity.mjs | 308 ++++++++++++++++++ .../hosted-webview-simulator-e2e-options.mjs | 13 +- .../run-hosted-webview-simulator-e2e.mjs | 73 ++++- .../src/files/MobileFilePreviewScreen.test.ts | 122 ++++++- mobile/src/files/MobileFilePreviewScreen.tsx | 40 ++- .../files/mobile-file-preview-source.test.ts | 15 +- .../src/files/mobile-file-preview-source.ts | 17 + .../src/files/mobile-file-preview-styles.ts | 12 +- .../hosted-ios-core-route-parity.test.ts | 34 +- .../hosted-ios-files-preview-parity.test.ts | 180 ++++++++++ ...sted-webview-simulator-e2e-options.test.ts | 3 +- 16 files changed, 826 insertions(+), 44 deletions(-) create mode 100644 mobile/scripts/hosted-ios-files-preview-parity.mjs create mode 100644 mobile/src/mobile-web/hosted-ios-files-preview-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 c288d7b6ff6..4e24faa9bae 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 @@ -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. | 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 ab389314513..3f5e95eb7ae 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 @@ -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 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 e7816f731ca..d706596a61b 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 @@ -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. 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 1c195ca240e..efb8acc8395 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 @@ -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 diff --git a/mobile/scripts/hosted-ios-core-route-parity.mjs b/mobile/scripts/hosted-ios-core-route-parity.mjs index 50d63c8a76a..6defa00a11b 100644 --- a/mobile/scripts/hosted-ios-core-route-parity.mjs +++ b/mobile/scripts/hosted-ios-core-route-parity.mjs @@ -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]) } diff --git a/mobile/scripts/hosted-ios-files-preview-parity.mjs b/mobile/scripts/hosted-ios-files-preview-parity.mjs new file mode 100644 index 00000000000..8aaad1b180b --- /dev/null +++ b/mobile/scripts/hosted-ios-files-preview-parity.mjs @@ -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)) +} diff --git a/mobile/scripts/hosted-webview-simulator-e2e-options.mjs b/mobile/scripts/hosted-webview-simulator-e2e-options.mjs index f4edbb186b5..df81a66641a 100644 --- a/mobile/scripts/hosted-webview-simulator-e2e-options.mjs +++ b/mobile/scripts/hosted-webview-simulator-e2e-options.mjs @@ -1,11 +1,12 @@ import process from 'node:process' const usage = - 'Usage: node scripts/run-hosted-webview-simulator-e2e.mjs [--device ] [--timeout-ms ] [--security-only] [--native-settings-only] [--source-control-only] [--skip-native-build]' + 'Usage: node scripts/run-hosted-webview-simulator-e2e.mjs [--device ] [--timeout-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') } diff --git a/mobile/scripts/run-hosted-webview-simulator-e2e.mjs b/mobile/scripts/run-hosted-webview-simulator-e2e.mjs index 2312655b4cc..3b6dff735a2 100644 --- a/mobile/scripts/run-hosted-webview-simulator-e2e.mjs +++ b/mobile/scripts/run-hosted-webview-simulator-e2e.mjs @@ -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, diff --git a/mobile/src/files/MobileFilePreviewScreen.test.ts b/mobile/src/files/MobileFilePreviewScreen.test.ts index 1595454a12a..4d25d222176 100644 --- a/mobile/src/files/MobileFilePreviewScreen.test.ts +++ b/mobile/src/files/MobileFilePreviewScreen.test.ts @@ -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) + }) }) diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx index 69cb3b09cbe..dfd6db6bac4 100644 --- a/mobile/src/files/MobileFilePreviewScreen.tsx +++ b/mobile/src/files/MobileFilePreviewScreen.tsx @@ -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(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( 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 ]) diff --git a/mobile/src/files/mobile-file-preview-source.test.ts b/mobile/src/files/mobile-file-preview-source.test.ts index b5fec782cfc..ece29a7f615 100644 --- a/mobile/src/files/mobile-file-preview-source.test.ts +++ b/mobile/src/files/mobile-file-preview-source.test.ts @@ -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)) + }) }) diff --git a/mobile/src/files/mobile-file-preview-source.ts b/mobile/src/files/mobile-file-preview-source.ts index b1b23757a6e..a66a288705f 100644 --- a/mobile/src/files/mobile-file-preview-source.ts +++ b/mobile/src/files/mobile-file-preview-source.ts @@ -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]) +} diff --git a/mobile/src/files/mobile-file-preview-styles.ts b/mobile/src/files/mobile-file-preview-styles.ts index 5b0df61caa5..dc57bbb689a 100644 --- a/mobile/src/files/mobile-file-preview-styles.ts +++ b/mobile/src/files/mobile-file-preview-styles.ts @@ -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 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 index 7cf0a76b870..4d2052e0303 100644 --- a/mobile/src/mobile-web/hosted-ios-core-route-parity.test.ts +++ b/mobile/src/mobile-web/hosted-ios-core-route-parity.test.ts @@ -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 }) + }) }) diff --git a/mobile/src/mobile-web/hosted-ios-files-preview-parity.test.ts b/mobile/src/mobile-web/hosted-ios-files-preview-parity.test.ts new file mode 100644 index 00000000000..1d5eb12d7c1 --- /dev/null +++ b/mobile/src/mobile-web/hosted-ios-files-preview-parity.test.ts @@ -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 }) + }) +}) diff --git a/mobile/src/mobile-web/hosted-webview-simulator-e2e-options.test.ts b/mobile/src/mobile-web/hosted-webview-simulator-e2e-options.test.ts index baf7e43cff6..8bf5ddba4c8 100644 --- a/mobile/src/mobile-web/hosted-webview-simulator-e2e-options.test.ts +++ b/mobile/src/mobile-web/hosted-webview-simulator-e2e-options.test.ts @@ -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') })