Files
orca/mobile/scripts/hosted-ios-document-upload.mjs
T
Jinwoo-H 5735189af7 test(mobile): fix hosted iOS e2e harness and root test triage
Hosted iOS security e2e (three branch-only harness bugs):
- `simctl privacy grant` terminates the app; the relaunch cold-resumes
  straight onto the session route, so skip workspace-row activation when
  the hybrid handoff already landed on /session/. Activation failures now
  name the document href.
- Uploaded-path regex fused two abutting orca-paste-*.png paths; use a
  lazy segment quantifier.
- Photos orders by capture date, which addmedia takes from file birth
  time; stage a freshly written fixture copy (copyFile clones birth time
  on APFS) so "last Photo" is the fixture.

Root tests:
- Allowlist walker skips -test-fakes / -test-fixture(s) / .test-support
  sources, which are not mobile call sites.
- Drop the height-only OSC-8 test: main's #17759 clears restored ranges
  on any dimension change and pins it.
- Drop serve-update-handoff-startup-order.test.ts: main pins the same
  order against main-process-preflight.ts.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-01 21:17:38 -04:00

289 lines
10 KiB
JavaScript

import { createHash } from 'node:crypto'
import { execFile } from 'node:child_process'
import { copyFile, mkdir, readFile, rm, stat, utimes } from 'node:fs/promises'
import path from 'node:path'
import { promisify } from 'node:util'
import {
tapHostedIosAccessibilityControl,
tapHostedIosAccessibilityControlByLabelPrefix,
tapHostedIosAccessibilityControlByLabelPrefixAtPosition,
waitForHostedIosAccessibilityLabelToDisappear,
waitForHostedIosAccessibilityControl
} from './hosted-ios-emulator-accessibility.mjs'
import { longPressHostedIosPoint } from './hosted-ios-emulator-long-press.mjs'
import { readHostedRuntimeTerminalSnapshot } from './hosted-ios-photo-permission-denial.mjs'
import { readHostedWebViewControlPoint } from './hosted-webview-control-point.mjs'
import { dispatchHostedWebViewLongPress } from './hosted-webview-long-press.mjs'
import {
readHostedWebViewState,
waitForVisibleHostedWebView
} from './hosted-webview-cdp-session.mjs'
const execFileAsync = promisify(execFile)
export const HOSTED_IOS_DOCUMENT_FIXTURE_NAME = 'orca-document-upload-fixture.png'
const LOCAL_FILE_PROVIDER_GROUP = 'group.com.apple.FileProvider.LocalStorage'
const PRIVILEGED_PAGE_MARKERS = ['orca-paste-', 'data:image/']
// Why lazy: two pasted paths can abut in the joined tail; greedy segments would fuse them into one.
const UPLOADED_PATH_PATTERN =
/\/(?:private\/)?var\/folders\/(?:[^/\s]+\/)+?T\/orca-paste-[0-9]+-[0-9a-f-]+\.png/gi
export async function seedHostedIosDocumentFixture({ deviceUdid, fixturePath }, operations = {}) {
const runCommand = operations.runCommand ?? execFileAsync
const copy = operations.copy ?? copyFile
const createDirectory = operations.createDirectory ?? mkdir
const readFixture = operations.readFixture ?? readFile
const touch = operations.touch ?? utimes
const { stdout } = await runCommand(
'xcrun',
['simctl', 'get_app_container', deviceUdid, 'com.apple.DocumentsApp', 'groups'],
{ encoding: 'utf8' }
)
const groupPath = parseLocalFileProviderGroupPath(String(stdout))
const storagePath = path.join(groupPath, 'File Provider Storage')
const destinationPath = path.join(storagePath, HOSTED_IOS_DOCUMENT_FIXTURE_NAME)
await createDirectory(storagePath, { recursive: true })
await copy(fixturePath, destinationPath)
const now = new Date()
await touch(destinationPath, now, now)
const fixture = await readFixture(fixturePath)
return {
destinationPath,
fixtureName: HOSTED_IOS_DOCUMENT_FIXTURE_NAME,
sha256: sha256(fixture),
size: fixture.byteLength
}
}
export async function removeHostedIosDocumentFixture(destinationPath, remove = rm) {
await remove(destinationPath, { force: true })
}
export function parseLocalFileProviderGroupPath(output) {
const line = output
.split(/\r?\n/)
.find((candidate) => candidate.startsWith(`${LOCAL_FILE_PROVIDER_GROUP}\t`))
const groupPath = line?.slice(LOCAL_FILE_PROVIDER_GROUP.length + 1).trim()
if (!groupPath || !path.isAbsolute(groupPath)) {
throw new Error('Simulator Local File Provider container was not found')
}
return groupPath
}
export async function selectHostedIosDocumentFixture(
emulator,
fixtureName,
timeoutMs,
operations = {}
) {
const tapControl = operations.tapControl ?? tapHostedIosAccessibilityControl
const tapByPrefix = operations.tapByPrefix ?? tapHostedIosAccessibilityControlByLabelPrefix
const tapFixture =
operations.tapFixture ?? tapHostedIosAccessibilityControlByLabelPrefixAtPosition
const waitForPickerDismissal =
operations.waitForPickerDismissal ?? waitForHostedIosAccessibilityLabelToDisappear
const fixtureLabelPrefix = path.parse(fixtureName).name
const selectVisibleFixture = async (selectionTimeoutMs) => {
const point = await tapFixture(
emulator,
fixtureLabelPrefix,
{ x: 0.5, y: 0.25 },
selectionTimeoutMs
)
await waitForPickerDismissal(emulator, 'Browse', Math.min(selectionTimeoutMs, 3_000))
return point
}
try {
return await selectVisibleFixture(Math.min(timeoutMs, 5_000))
} catch {
await tapControl(emulator, 'Browse', Math.min(timeoutMs, 5_000))
}
try {
return await selectVisibleFixture(Math.min(timeoutMs, 5_000))
} catch {
await tapByPrefix(emulator, 'On My iPhone', Math.min(timeoutMs, 5_000))
}
return selectVisibleFixture(timeoutMs)
}
export async function verifyHostedIosDocumentUpload(
{
discoveryUrl,
documentFixture,
emulator,
orcaCli,
pairingRuntimeUserDataPath,
sessionDocument,
terminalHandle,
timeoutMs,
worktree
},
operations = {}
) {
const readControlPoint = operations.readControlPoint ?? readHostedWebViewControlPoint
const readAccessibilityPoint =
operations.readAccessibilityPoint ?? waitForHostedIosAccessibilityControl
const openPicker = operations.openPicker ?? openHostedIosDocumentPicker
const selectFixture = operations.selectFixture ?? selectHostedIosDocumentFixture
const readTerminal = operations.readTerminal ?? readHostedRuntimeTerminalSnapshot
const readState = operations.readState ?? readHostedWebViewState
const waitForDocument = operations.waitForDocument ?? waitForVisibleHostedWebView
const readUploadedFile = operations.readUploadedFile ?? readFile
const fileStat = operations.fileStat ?? stat
const terminalArgs = {
orcaCli,
pairingRuntimeUserDataPath,
terminalHandle,
worktree
}
const beforeTerminal = await readTerminal(terminalArgs)
const attachControlPoint = await readControlPoint(sessionDocument, 'Attach a photo')
const attachAccessibilityPoint = await readAccessibilityPoint(
emulator,
'Attach a photo',
timeoutMs
)
const pickerActivation = await openPicker({
document: sessionDocument,
emulator,
point: attachControlPoint
})
let selectedFilePoint
try {
selectedFilePoint = await selectFixture(emulator, documentFixture.fixtureName, timeoutMs)
} catch (error) {
throw new Error(
`Document picker did not open (DOM ${formatPoint(attachControlPoint)}, accessibility ${formatPoint(attachAccessibilityPoint)}): ${error instanceof Error ? error.message : String(error)}`
)
}
const upload = await waitForNewHostedIosTerminalImageUpload({
beforeTerminal,
description: 'Selected document',
failureMessages: ['Attach failed', 'Image too large to attach'],
readState,
readTerminal,
sessionDocument,
terminalArgs,
timeoutMs
})
const uploadedBytes = await readUploadedFile(upload.path)
const uploadedStat = await fileStat(upload.path)
const uploadedSha256 = sha256(uploadedBytes)
if (
uploadedBytes.byteLength !== documentFixture.size ||
uploadedStat.size !== documentFixture.size ||
uploadedSha256 !== documentFixture.sha256
) {
throw new Error('Selected document upload did not preserve the fixture bytes')
}
const pageState = await readState(sessionDocument)
assertNoPrivilegedDocumentState(pageState, documentFixture, uploadedBytes)
const activeSessionDocument = await waitForDocument({
discoveryUrl,
expectedText: 'Mobile Emulator',
expectedHrefIncludes: '/session/',
requireInteractiveControls: false,
timeoutMs
})
return {
evidence: {
attachControlPoint,
attachAccessibilityPoint,
fixtureName: documentFixture.fixtureName,
pickerActivation,
privilegedPageMarkers: 'absent',
route: activeSessionDocument.href,
selectedFilePoint,
sha256: uploadedSha256,
size: uploadedBytes.byteLength,
terminalPathInjected: true
},
sessionDocument: activeSessionDocument
}
}
export function uploadedPathsFromTerminalSnapshot(snapshot) {
return [...String(snapshot.join('')).matchAll(UPLOADED_PATH_PATTERN)].map((match) => match[0])
}
export async function waitForNewHostedIosTerminalImageUpload({
beforeTerminal,
description,
failureMessages,
readState,
readTerminal,
sessionDocument,
terminalArgs,
timeoutMs
}) {
const beforePaths = new Set(uploadedPathsFromTerminalSnapshot(beforeTerminal))
const deadline = Date.now() + timeoutMs
let lastTerminal = beforeTerminal
let lastPageText = ''
while (Date.now() < deadline) {
lastTerminal = await readTerminal(terminalArgs)
const pathValue = uploadedPathsFromTerminalSnapshot(lastTerminal).find(
(candidate) => !beforePaths.has(candidate)
)
if (pathValue) {
return { path: pathValue, terminal: lastTerminal }
}
lastPageText = (await readState(sessionDocument)).bodyText
const failure = failureMessages.find((message) => lastPageText.includes(message))
if (failure) {
throw new Error(`${description} upload failed in the native shell: ${failure}`)
}
await delay(150)
}
throw new Error(
`${description} path did not reach Desktop terminal: ${lastTerminal.slice(-8).join(' | ')}; page: ${lastPageText.slice(-160)}`
)
}
function assertNoPrivilegedDocumentState(state, fixture, uploadedBytes) {
const markers = [
...PRIVILEGED_PAGE_MARKERS,
fixture.fixtureName,
fixture.sha256,
uploadedBytes.toString('base64').slice(0, 16)
]
const marker = markers.find((candidate) => state.bodyText.includes(candidate))
if (marker) {
throw new Error(`Selected document exposed privileged page marker: ${marker}`)
}
}
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function formatPoint(point) {
return `${point.x.toFixed(4)},${point.y.toFixed(4)}`
}
export async function openHostedIosDocumentPicker(args, operations = {}) {
const longPressPoint = operations.longPressPoint ?? longPressHostedIosPoint
const waitForPicker = operations.waitForPicker ?? waitForHostedIosAccessibilityControl
const dispatchWebLongPress = operations.dispatchWebLongPress ?? dispatchHostedWebViewLongPress
for (let attempt = 0; attempt < 2; attempt++) {
await longPressPoint(args.emulator, args.point)
try {
await waitForPicker(args.emulator, 'Browse', 1_000)
return 'native-long-press'
} catch {}
await dispatchWebLongPress(args.document, 'Attach a photo')
try {
await waitForPicker(args.emulator, 'Browse', 5_000)
return 'native-touch-plus-web-responder'
} catch (error) {
if (attempt === 1) {
throw error
}
}
}
throw new Error('Document picker activation exhausted')
}