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 39e496694b0..9570b3cfddc 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 @@ -2463,4 +2463,6 @@ copy. | 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 | Complete | The unchanged Accounts screen passes iPhone 17 Pro native-versus-hosted parity at 0.050% changed pixels / 0.099 mean channel difference / 0.000544 vertical-title delta against 3% / 4 / 0.005 budgets. The cached-app journey then passes Tasks, Session, Files/Preview, Agent History, Desktop restart/recovery, Source Control, Review, and both isolation probes. The unlabeled toolbar icon remains an open VoiceOver finding. | | 2026-07-28 | Complete | Post-Accounts validation passes 554 mobile files / 3,299 tests with 2 expected skips, mobile and RNW typechecks, mobile lint, full mobile formatting, max-lines, and diff hygiene. The RNW package remains unchanged because this slice adds only the native-versus-hosted driver and evidence. | +| 2026-07-28 | Complete | The unchanged base workspace `HostScreen` passes iPhone 17 Pro native-versus-hosted parity at 0.879% changed pixels / 1.876 mean channel difference / 0.000395 vertical landmark delta against 3% / 4 / 0.005 budgets. The complete cached-app journey captures Workspace before Accounts, then passes every existing downstream parity, recovery, review, and isolation checkpoint. | +| 2026-07-28 | Complete | Post-Workspace validation passes 555 mobile files / 3,301 tests with 2 expected skips, mobile and RNW typechecks, mobile lint, full mobile formatting, max-lines, and diff hygiene. The RNW package remains unchanged because this slice adds only deterministic parity infrastructure and evidence. | | 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 6840c706c72..7deb431be61 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 @@ -62,7 +62,7 @@ but is a component, not a route. It migrates with the session UI. | `mobile/app/about.tsx` | Native application identity/version | Native shell | Keep; add shell and active web build versions where useful | | `mobile/app/h/_layout.tsx` | Host protocol gate and host route stack | Native shell | Keep as host security/recovery boundary; mount one production hybrid workspace route | | `mobile/app/h/[hostId]/edit.tsx` | Paired host display name, endpoint, reconnect | Native shell | Keep because it changes paired connectivity rather than workspace content | -| `mobile/app/h/[hostId]/index.tsx` | Worktree list, creation, actions, host workspace entry | Mobile web app | Reuse `HostScreen` presentation unchanged through a web route/transport adapter | +| `mobile/app/h/[hostId]/index.tsx` | Worktree list, creation, actions, host workspace entry | Mobile web app | Complete on iOS Simulator: native and hosted mount the same `HostScreen` and pass strict screenshot parity | | `mobile/app/h/[hostId]/accounts.tsx` | Host agent-account usage and selection | Mobile web app | Complete on iOS Simulator: the same screen uses typed native/web host-account adapters and passes strict screenshot parity | | `mobile/app/h/[hostId]/tasks.tsx` | Host task providers, task details, mutations, workspace creation | Mobile web app | Complete on iOS Simulator: same route/presentation with strict native/web operations | | `mobile/app/h/[hostId]/session/[worktreeId].tsx` | Sessions, tabs, terminal, browser, native chat, files, attachments, dictation | Mobile web app | Reuse current session presentation; native-chat/agent-state slice is adapter-complete and awaiting live parity evidence | @@ -502,6 +502,12 @@ existing unlabeled non-embedded toolbar icon position; its missing accessibility label remains part of the open VoiceOver review rather than a presentation change in this migration. +The fixture now starts by capturing the unchanged base workspace screen in +native and hosted modes. Workspace measures 0.879% changed pixels / 1.876 mean +channel difference / 0.000395 vertical landmark delta, within the 3% / 4 / +0.005 budgets. This proves the shared `HostScreen` itself before the same +fixture navigates into Accounts and the downstream route matrix. + 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 4f5cb00268a..f47232e8b97 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 @@ -1866,6 +1866,13 @@ changed pixels, 0.099 mean channel difference, and 0.000544 vertical-title delta against the 3% / 4 / 0.005 budgets. This extends source-sharing evidence; it does not replace or restyle the account presentation. +The base host-workspace route now has equivalent evidence. The exact-app gate +captures the unchanged shared `HostScreen` before any feature navigation and +passes at 0.879% changed pixels, 1.876 mean channel difference, and 0.000395 +vertical landmark delta. The route remains the existing worktree list, +creation, action, and host-entry presentation rendered through its native/web +operations boundary. + 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 3fb5dd88930..6c14df79813 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 @@ -73,6 +73,12 @@ gap for the broader VoiceOver review. The complete cached-app journey passes with Accounts inserted before Tasks, Session, Files/Preview, Agent History, Desktop restart/recovery, Source Control, Review, and both isolation probes. +The base workspace screen now has the same deterministic proof. Native and +hosted mount the unchanged `HostScreen` and pass at 0.879% changed pixels, +1.876 mean channel difference, and 0.000395 vertical landmark delta against the +3% / 4 / 0.005 budgets. The complete journey captures this screen before +Accounts and the rest of the route matrix. + 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, @@ -81,7 +87,7 @@ formatting, localization, and the max-lines ratchet pass. The independently verified React Native Web package is `9e5e807523e8b917fef68f221cc1fd2e1a16dbe07d7077e717238eed17003b52`: 49 assets, 9,330,604 raw bytes, and 2,697,919 gzip bytes. The current mobile -suite passes 554 files / 3,299 tests with 2 expected skips; all project +suite passes 555 files / 3,301 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 diff --git a/mobile/scripts/hosted-ios-workspace-parity.mjs b/mobile/scripts/hosted-ios-workspace-parity.mjs new file mode 100644 index 00000000000..304fd1434a7 --- /dev/null +++ b/mobile/scripts/hosted-ios-workspace-parity.mjs @@ -0,0 +1,74 @@ +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 { waitForHostedIosAccessibilityControl } from './hosted-ios-emulator-accessibility.mjs' +import { assertHostedIosScreenshotParity } from './hosted-ios-screenshot-parity.mjs' +import { readHostedWebViewTextPoint } from './hosted-webview-cdp-session.mjs' + +const execFileAsync = promisify(execFile) + +export async function captureNativeWorkspaceBaseline({ + deviceUdid, + emulator, + runtimeDirectory, + timeoutMs +}) { + await dismissEmulatorDeveloperMenuIfPresent(emulator) + const filterPoint = await waitForHostedIosAccessibilityControl(emulator, 'Filter', timeoutMs) + await delay(500) + const screenshot = path.join(runtimeDirectory, 'native-workspace-portrait.png') + await captureSimulatorScreenshot(deviceUdid, screenshot) + return { filterPoint, screenshot } +} + +export async function captureHostedWorkspaceParity({ + deviceUdid, + document, + nativeBaseline, + runtimeDirectory, + timeoutMs +}) { + const filterPoint = await readHostedWebViewTextPoint(document, 'Filter') + const screenshot = path.join(runtimeDirectory, 'hosted-workspace-portrait.png') + const deadline = Date.now() + timeoutMs + let lastError = new Error('Workspace did not reach screenshot parity') + while (Date.now() < deadline) { + await delay(500) + await captureSimulatorScreenshot(deviceUdid, screenshot) + try { + const screenshotParity = await assertHostedIosScreenshotParity({ + hostedLandmark: filterPoint, + hostedScreenshot: screenshot, + nativeLandmark: nativeBaseline.filterPoint, + nativeScreenshot: nativeBaseline.screenshot + }) + return workspaceParityEvidence(nativeBaseline, { + filterPoint, + screenshot, + screenshotParity + }) + } catch (error) { + lastError = error + } + } + throw lastError +} + +export function workspaceParityEvidence(nativeCapture, hostedCapture) { + return { + nativeScreenshot: path.basename(nativeCapture.screenshot), + hostedScreenshot: path.basename(hostedCapture.screenshot), + nativeFilterPoint: nativeCapture.filterPoint, + hostedFilterPoint: hostedCapture.filterPoint, + screenshotParity: hostedCapture.screenshotParity + } +} + +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/run-hosted-webview-simulator-e2e.mjs b/mobile/scripts/run-hosted-webview-simulator-e2e.mjs index d80c442c634..00d4d4cefa5 100644 --- a/mobile/scripts/run-hosted-webview-simulator-e2e.mjs +++ b/mobile/scripts/run-hosted-webview-simulator-e2e.mjs @@ -29,6 +29,10 @@ import { captureHostedFilesPreviewParity, captureNativeFilesPreviewBaselines } from './hosted-ios-files-preview-parity.mjs' +import { + captureHostedWorkspaceParity, + captureNativeWorkspaceBaseline +} from './hosted-ios-workspace-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' @@ -107,6 +111,20 @@ async function main() { const nativeOnboarding = await evidenceStep('native onboarding', () => completeHostedIosNativeOnboarding(emulator, expectedWorkspace, options.timeoutMs) ) + const nativeWorkspace = + options.securityOnly || + options.filesPreviewOnly || + options.nativeSettingsOnly || + options.sourceControlOnly + ? null + : await evidenceStep('native workspace baseline', () => + captureNativeWorkspaceBaseline({ + deviceUdid, + emulator, + runtimeDirectory, + timeoutMs: options.timeoutMs + }) + ) const nativeAccounts = options.securityOnly || options.filesPreviewOnly || @@ -179,6 +197,21 @@ async function main() { expectedText: 'Orca Desktop', timeoutMs: options.timeoutMs }) + const hostedWorkspace = + options.securityOnly || + options.filesPreviewOnly || + options.nativeSettingsOnly || + options.sourceControlOnly + ? null + : await evidenceStep('hosted workspace parity', () => + captureHostedWorkspaceParity({ + deviceUdid, + document: workspaceDocument, + nativeBaseline: nativeWorkspace, + runtimeDirectory, + timeoutMs: options.timeoutMs + }) + ) const hostedAccounts = options.securityOnly || options.filesPreviewOnly || @@ -340,6 +373,7 @@ async function main() { networkIsolation, navigationIsolation, nativeOnboarding, + workspaceParity: hostedWorkspace, accountsParity: hostedAccounts?.evidence ?? null, agentHistory: historyEvidence, coreRouteParity: hostedCoreRoutes?.evidence ?? null, diff --git a/mobile/src/mobile-web/hosted-ios-workspace-parity.test.ts b/mobile/src/mobile-web/hosted-ios-workspace-parity.test.ts new file mode 100644 index 00000000000..2f5efed23dd --- /dev/null +++ b/mobile/src/mobile-web/hosted-ios-workspace-parity.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + captureScreenshot: vi.fn(), + compareScreenshots: vi.fn(), + dismissDeveloperMenu: vi.fn(), + readTextPoint: vi.fn(), + waitForControl: 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', () => ({ + waitForHostedIosAccessibilityControl: mocks.waitForControl +})) +vi.mock('../../scripts/hosted-ios-screenshot-parity.mjs', () => ({ + assertHostedIosScreenshotParity: mocks.compareScreenshots +})) +vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({ + readHostedWebViewTextPoint: mocks.readTextPoint +})) + +import { + captureHostedWorkspaceParity, + captureNativeWorkspaceBaseline +} from '../../scripts/hosted-ios-workspace-parity.mjs' + +describe('hosted iOS workspace parity', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.captureScreenshot.mockImplementation((_command, _args, callback) => + callback(null, '', '') + ) + mocks.compareScreenshots.mockResolvedValue({ changedPixelRatio: 0.01 }) + mocks.readTextPoint.mockResolvedValue({ x: 0.2, y: 0.1 }) + mocks.waitForControl.mockResolvedValue({ x: 0.2, y: 0.1 }) + }) + + it('captures the unchanged native workspace screen', async () => { + const baseline = await captureNativeWorkspaceBaseline({ + deviceUdid: 'simulator', + emulator: { deviceUdid: 'simulator' }, + runtimeDirectory: '/tmp/parity', + timeoutMs: 30_000 + }) + + expect(mocks.waitForControl).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, 'Filter', 30_000) + expect(baseline).toEqual({ + filterPoint: { x: 0.2, y: 0.1 }, + screenshot: '/tmp/parity/native-workspace-portrait.png' + }) + }) + + it('compares the unchanged hosted workspace screen', async () => { + const evidence = await captureHostedWorkspaceParity({ + deviceUdid: 'simulator', + document: { href: 'orca-mobile-web://build/h/host' }, + nativeBaseline: { + filterPoint: { x: 0.2, y: 0.1 }, + screenshot: '/tmp/parity/native-workspace-portrait.png' + }, + runtimeDirectory: '/tmp/parity', + timeoutMs: 30_000 + }) + + expect(mocks.readTextPoint).toHaveBeenCalledWith(expect.anything(), 'Filter') + expect(mocks.compareScreenshots).toHaveBeenCalledOnce() + expect(evidence).toEqual({ + nativeScreenshot: 'native-workspace-portrait.png', + hostedScreenshot: 'hosted-workspace-portrait.png', + nativeFilterPoint: { x: 0.2, y: 0.1 }, + hostedFilterPoint: { x: 0.2, y: 0.1 }, + screenshotParity: { changedPixelRatio: 0.01 } + }) + }) +})