test(mobile): add iOS workspace parity

This commit is contained in:
OrcaWin
2026-08-09 18:34:57 -04:00
committed by Jinwoo-H
parent ab97f84485
commit d2fbda1d2f
7 changed files with 211 additions and 2 deletions
@@ -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. |
@@ -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
@@ -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.
@@ -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
@@ -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))
}
@@ -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,
@@ -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 }
})
})
})