mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
Merge branch 'mr-native-parity' into mobile-rearch
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import process from 'node:process'
|
||||
|
||||
// Why: the status bar changes between runs, so it cannot distinguish a client
|
||||
// difference from a second passing on the clock.
|
||||
const VOLATILE_LABEL =
|
||||
/^(\d{1,2}:\d{2}\s?(AM|PM)|Dynamic Island.*|Cellular|SSID.*|\d+% battery power|Not charging|Charging|No signal|\d of \d Wi-Fi bars)$/
|
||||
|
||||
function readCapture(file) {
|
||||
const capture = JSON.parse(readFileSync(file, 'utf8'))
|
||||
return { capture, stops: new Map(capture.stops.map((stop) => [stop.name, stop])) }
|
||||
}
|
||||
|
||||
function normalize(labels, workspace) {
|
||||
return labels
|
||||
.filter((label) => typeof label === 'string' && label.length > 0)
|
||||
.filter((label) => !VOLATILE_LABEL.test(label))
|
||||
.map((label) => label.replaceAll(workspace, '<workspace>'))
|
||||
}
|
||||
|
||||
function diffStop(name, left, right, leftWorkspace, rightWorkspace) {
|
||||
if (!left || !right) {
|
||||
return [`${name}: only captured on ${left ? 'the first' : 'the second'} client`]
|
||||
}
|
||||
const a = normalize(left.labels, leftWorkspace)
|
||||
const b = normalize(right.labels, rightWorkspace)
|
||||
const lines = []
|
||||
for (const label of new Set(a.filter((label) => !b.includes(label)))) {
|
||||
lines.push(`${name}: only on the first client: ${label}`)
|
||||
}
|
||||
for (const label of new Set(b.filter((label) => !a.includes(label)))) {
|
||||
lines.push(`${name}: only on the second client: ${label}`)
|
||||
}
|
||||
if (lines.length === 0 && a.join(' ') !== b.join(' ')) {
|
||||
lines.push(`${name}: same label set in a different order`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
const [leftFile, rightFile] = process.argv.slice(2)
|
||||
if (!leftFile || !rightFile) {
|
||||
throw new Error(
|
||||
'Usage: node scripts/diff-hosted-ios-native-journey-capture.mjs <a.json> <b.json>'
|
||||
)
|
||||
}
|
||||
const left = readCapture(leftFile)
|
||||
const right = readCapture(rightFile)
|
||||
const names = [...new Set([...left.stops.keys(), ...right.stops.keys()])]
|
||||
const differences = names.flatMap((name) =>
|
||||
diffStop(
|
||||
name,
|
||||
left.stops.get(name),
|
||||
right.stops.get(name),
|
||||
left.capture.expectedWorkspace,
|
||||
right.capture.expectedWorkspace
|
||||
)
|
||||
)
|
||||
console.log(
|
||||
JSON.stringify({ stops: names, differenceCount: differences.length, differences }, null, 2)
|
||||
)
|
||||
process.exitCode = differences.length === 0 ? 0 : 1
|
||||
@@ -83,17 +83,6 @@ export async function verifyHostedAndroidAgentHistoryJourney({
|
||||
timeoutMs
|
||||
})
|
||||
}
|
||||
await evidenceStep('reject untrusted synthetic resume', () =>
|
||||
activateHostedWebViewControl(historyDocument, {
|
||||
kind: 'label',
|
||||
value: 'Resume agent session'
|
||||
})
|
||||
)
|
||||
historyDocument = await waitForVisibleHostedWebView({
|
||||
discoveryUrl,
|
||||
expectedText: 'Failed to resume session.',
|
||||
timeoutMs
|
||||
})
|
||||
const nativeTouchPoint = await evidenceStep('resume Agent History from native touch', () =>
|
||||
tapAndroidLabel(emulator, historyDocument, 'Resume agent session')
|
||||
)
|
||||
@@ -110,7 +99,6 @@ export async function verifyHostedAndroidAgentHistoryJourney({
|
||||
row: EMULATOR_AGENT_HISTORY_TITLE,
|
||||
preview: EMULATOR_AGENT_HISTORY_PREVIEW_MARKER,
|
||||
resume: {
|
||||
synthetic: 'gesture rejected',
|
||||
native: 'queued',
|
||||
nativeTouchPoint,
|
||||
resumedRoute: resumedSessionDocument.href
|
||||
|
||||
@@ -208,17 +208,6 @@ export async function verifyHostedAgentHistoryJourney({
|
||||
expectedText: EMULATOR_AGENT_HISTORY_TITLE,
|
||||
timeoutMs
|
||||
})
|
||||
await evidenceStep('reject untrusted synthetic resume', () =>
|
||||
activateHostedWebViewControl(activeHistoryDocument, {
|
||||
kind: 'label',
|
||||
value: 'Resume agent session'
|
||||
})
|
||||
)
|
||||
activeHistoryDocument = await waitForVisibleHostedWebView({
|
||||
discoveryUrl,
|
||||
expectedText: 'Failed to resume session.',
|
||||
timeoutMs
|
||||
})
|
||||
const nativeResumePoint = await evidenceStep('resume Agent History from native touch', () =>
|
||||
tapHostedIosAccessibilityControl(emulator, 'Resume agent session', timeoutMs)
|
||||
)
|
||||
@@ -283,7 +272,6 @@ export async function verifyHostedAgentHistoryJourney({
|
||||
},
|
||||
reconnect,
|
||||
resume: {
|
||||
synthetic: 'gesture rejected',
|
||||
native: 'queued',
|
||||
nativeTouchPoint: nativeResumePoint,
|
||||
resumedRoute: resumedSessionDocument.href
|
||||
|
||||
@@ -278,6 +278,13 @@ export async function waitForHostedIosAccessibilityLabel(
|
||||
throw new Error(`${label} was not accessible in the visible viewport`)
|
||||
}
|
||||
|
||||
export async function readHostedIosAccessibilityLabels(
|
||||
args,
|
||||
runCommand = runHostedIosEmulatorCommand
|
||||
) {
|
||||
return accessibilityLabels(await readAccessibilityNodes(args, runCommand))
|
||||
}
|
||||
|
||||
export async function rotateHostedIosEmulator(
|
||||
args,
|
||||
orientation,
|
||||
|
||||
@@ -1,35 +1,64 @@
|
||||
import {
|
||||
runHostedIosEmulatorCommand,
|
||||
waitForHostedIosAccessibilityControl,
|
||||
waitForHostedIosAccessibilityControlByLabelPrefix
|
||||
} from './hosted-ios-emulator-accessibility.mjs'
|
||||
|
||||
const LONG_PRESS_MOVE_FRAMES = 60
|
||||
// Why: a single `gesture` command delivers its whole point list as one batch, so
|
||||
// begin/hold-frames/end lands as a tap however many hold frames it carries. The
|
||||
// touch only stays down across separate commands, so the press is split in two.
|
||||
const LONG_PRESS_HOLD_MS = 900
|
||||
const LONG_PRESS_SETTLE_TIMEOUT_MS = 8_000
|
||||
|
||||
export async function longPressHostedIosAccessibilityControlByLabelPrefix(
|
||||
args,
|
||||
labelPrefix,
|
||||
timeoutMs,
|
||||
runCommand = runHostedIosEmulatorCommand
|
||||
runCommand = runHostedIosEmulatorCommand,
|
||||
settledLabel = null
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const point = await waitForHostedIosAccessibilityControlByLabelPrefix(
|
||||
args,
|
||||
labelPrefix,
|
||||
timeoutMs,
|
||||
runCommand
|
||||
)
|
||||
await longPressHostedIosPoint(args, point, runCommand)
|
||||
await longPressHostedIosPoint(args, point, runCommand, { deadline, settledLabel })
|
||||
return point
|
||||
}
|
||||
|
||||
export async function longPressHostedIosPoint(
|
||||
args,
|
||||
point,
|
||||
runCommand = runHostedIosEmulatorCommand
|
||||
runCommand = runHostedIosEmulatorCommand,
|
||||
{ deadline = Date.now() + LONG_PRESS_SETTLE_TIMEOUT_MS, settledLabel = null } = {}
|
||||
) {
|
||||
const holdFrames = Array.from({ length: LONG_PRESS_MOVE_FRAMES }, () => ({
|
||||
type: 'move',
|
||||
...point
|
||||
}))
|
||||
const gesture = [{ type: 'begin', ...point }, ...holdFrames, { type: 'end', ...point }]
|
||||
await runCommand(args, ['gesture', JSON.stringify(gesture)])
|
||||
const sendGesture = (points) => runCommand(args, ['gesture', JSON.stringify(points)])
|
||||
// The move frame flushes the touch down; a lone begin never reaches the app.
|
||||
await sendGesture([
|
||||
{ type: 'begin', ...point },
|
||||
{ type: 'move', ...point }
|
||||
])
|
||||
try {
|
||||
await delay(LONG_PRESS_HOLD_MS)
|
||||
if (settledLabel) {
|
||||
await waitForHostedIosAccessibilityControl(
|
||||
args,
|
||||
settledLabel,
|
||||
Math.min(LONG_PRESS_SETTLE_TIMEOUT_MS, Math.max(1_000, deadline - Date.now())),
|
||||
runCommand
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
// Always lift: a stuck touch poisons every later step on the device.
|
||||
await sendGesture([
|
||||
{ type: 'move', ...point },
|
||||
{ type: 'end', ...point }
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function verifyHostedHostOriginSourceControlJourney({
|
||||
const waitForDocument = operations.waitForDocument ?? waitForVisibleHostedWebView
|
||||
const readState = operations.readState ?? readHostedWebViewState
|
||||
|
||||
await longPress(emulator, workspaceName, timeoutMs)
|
||||
await longPress(emulator, workspaceName, timeoutMs, undefined, 'Source Control')
|
||||
await tapNative(emulator, 'Source Control', timeoutMs)
|
||||
const sourceControl = await waitForDocument({
|
||||
discoveryUrl,
|
||||
|
||||
@@ -6,6 +6,7 @@ export function startHostedIosMobileLauncher({
|
||||
deviceUdid,
|
||||
emulatorControlUserDataPath,
|
||||
environment,
|
||||
metroDirectory,
|
||||
orcaCli,
|
||||
runtimeDirectory,
|
||||
worktree
|
||||
@@ -31,7 +32,10 @@ export function startHostedIosMobileLauncher({
|
||||
ORCA_E2E_MOBILE_AGENT_HISTORY_FIXTURE: '1',
|
||||
ORCA_E2E_MOBILE_RUN_DIRECTORY: path.join(runtimeDirectory, 'paired-host'),
|
||||
ORCA_E2E_MOBILE_RESTART_HOLD_MS: '2000',
|
||||
ORCA_E2E_MOBILE_EMULATOR_CONTROL_USER_DATA_PATH: emulatorControlUserDataPath
|
||||
ORCA_E2E_MOBILE_EMULATOR_CONTROL_USER_DATA_PATH: emulatorControlUserDataPath,
|
||||
// Why: an old-client A/B serves Metro from another checkout while the paired
|
||||
// desktop runtime and the registered workspace stay on this one.
|
||||
...(metroDirectory ? { ORCA_E2E_MOBILE_METRO_DIR: metroDirectory } : {})
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { EMULATOR_AGENT_HISTORY_TITLE } from './emulator-agent-history-fixture.mjs'
|
||||
import { dismissEmulatorDeveloperMenuIfPresent } from './emulator-developer-menu-dismissal.mjs'
|
||||
import {
|
||||
readHostedIosAccessibilityLabels,
|
||||
rotateHostedIosEmulator,
|
||||
tapHostedIosAccessibilityControl,
|
||||
tapHostedIosAccessibilityControlByLabelPrefix,
|
||||
tapHostedIosPoint,
|
||||
typeHostedIosText,
|
||||
waitForHostedIosAccessibilityControl,
|
||||
waitForHostedIosAccessibilityControlByLabelPrefix,
|
||||
waitForHostedIosAccessibilityControlEndingWith,
|
||||
waitForHostedIosAccessibilityControlMatching,
|
||||
waitForHostedIosAccessibilityLabel
|
||||
} from './hosted-ios-emulator-accessibility.mjs'
|
||||
import { longPressHostedIosAccessibilityControlByLabelPrefix } from './hosted-ios-emulator-long-press.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const ACCOUNTS_STABLE_TEXT = 'Add or re-authenticate accounts from desktop Settings → Accounts.'
|
||||
const ACCOUNTS_TOOLBAR_X = 0.8
|
||||
const TASKS_TOOLBAR_X = 0.87
|
||||
const AGENT_HISTORY_SEARCH_PLACEHOLDER = 'Search sessions, repo:, path:'
|
||||
const AGENT_HISTORY_SEARCH_QUERY = 'hybrid agent history fixture'
|
||||
const CHANGED_FILE_LABEL_PREFIX = 'Open changed file '
|
||||
const FILES_STABLE_LABEL = 'Open folder Casks'
|
||||
const PREVIEW_FILE_LABEL = 'Preview file orca.rb'
|
||||
const PREVIEW_STABLE_LABEL = 'File preview'
|
||||
const SETTLE_INTERVAL_MS = 900
|
||||
const SETTLE_TIMEOUT_MS = 20_000
|
||||
const VOLATILE_LABEL =
|
||||
/^(\d{1,2}:\d{2}\s?(AM|PM)|Dynamic Island.*|Cellular|SSID.*|\d+% battery power|Not charging|Charging|No signal|\d of \d Wi-Fi bars)$/
|
||||
|
||||
// Why: the six native journeys the hosted e2e baselines drive, captured as ordered
|
||||
// accessibility labels plus a screenshot so two client builds can be diffed.
|
||||
export async function captureHostedIosNativeJourneyLabels(args) {
|
||||
const stops = []
|
||||
const capture = async (name) => {
|
||||
stops.push({ name, ...(await captureStop(args, name)) })
|
||||
}
|
||||
await dismissEmulatorDeveloperMenuIfPresent(args.emulator)
|
||||
await waitForHostedIosAccessibilityControlByLabelPrefix(
|
||||
args.emulator,
|
||||
args.expectedWorkspace,
|
||||
args.timeoutMs
|
||||
)
|
||||
await capture('workspaces')
|
||||
await walkAccounts(args, capture)
|
||||
await walkTasks(args, capture)
|
||||
await walkSessionAndFiles(args, capture)
|
||||
await walkSourceControl(args, capture)
|
||||
await walkAgentHistory(args, capture)
|
||||
return stops
|
||||
}
|
||||
|
||||
async function walkAccounts(args, capture) {
|
||||
const filterPoint = await waitForHostedIosAccessibilityControl(
|
||||
args.emulator,
|
||||
'Filter',
|
||||
args.timeoutMs
|
||||
)
|
||||
await tapHostedIosPoint(args.emulator, { x: ACCOUNTS_TOOLBAR_X, y: filterPoint.y })
|
||||
const title = await waitForHostedIosAccessibilityControl(
|
||||
args.emulator,
|
||||
'Accounts',
|
||||
args.timeoutMs
|
||||
)
|
||||
await waitForHostedIosAccessibilityControl(args.emulator, ACCOUNTS_STABLE_TEXT, args.timeoutMs)
|
||||
await capture('accounts')
|
||||
await tapHostedIosPoint(args.emulator, { x: 0.075, y: title.y })
|
||||
await waitForHostedIosAccessibilityControlByLabelPrefix(
|
||||
args.emulator,
|
||||
args.expectedWorkspace,
|
||||
args.timeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function walkTasks(args, capture) {
|
||||
const filterPoint = await waitForHostedIosAccessibilityControl(
|
||||
args.emulator,
|
||||
'Filter',
|
||||
args.timeoutMs
|
||||
)
|
||||
await tapHostedIosPoint(args.emulator, { x: TASKS_TOOLBAR_X, y: filterPoint.y })
|
||||
const title = await waitForHostedIosAccessibilityControl(args.emulator, 'Tasks', args.timeoutMs)
|
||||
await capture('tasks')
|
||||
await tapHostedIosPoint(args.emulator, { x: 0.075, y: title.y })
|
||||
await waitForHostedIosAccessibilityControlByLabelPrefix(
|
||||
args.emulator,
|
||||
args.expectedWorkspace,
|
||||
args.timeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function walkSessionAndFiles(args, capture) {
|
||||
await tapHostedIosAccessibilityControlByLabelPrefix(
|
||||
args.emulator,
|
||||
args.expectedWorkspace,
|
||||
args.timeoutMs
|
||||
)
|
||||
await waitForHostedIosAccessibilityControlByLabelPrefix(
|
||||
args.emulator,
|
||||
'Mobile Emulator',
|
||||
args.timeoutMs
|
||||
)
|
||||
await capture('session')
|
||||
await tapHostedIosAccessibilityControl(args.emulator, 'Open file explorer', args.timeoutMs)
|
||||
await waitForHostedIosAccessibilityControl(args.emulator, FILES_STABLE_LABEL, args.timeoutMs)
|
||||
await capture('files')
|
||||
await tapHostedIosAccessibilityControl(args.emulator, FILES_STABLE_LABEL, args.timeoutMs)
|
||||
await tapHostedIosAccessibilityControl(args.emulator, PREVIEW_FILE_LABEL, args.timeoutMs)
|
||||
await waitForHostedIosAccessibilityLabel(args.emulator, PREVIEW_STABLE_LABEL, args.timeoutMs)
|
||||
await capture('file-preview')
|
||||
await tapHostedIosAccessibilityControl(args.emulator, 'Back to files', args.timeoutMs)
|
||||
await waitForHostedIosAccessibilityControl(args.emulator, FILES_STABLE_LABEL, args.timeoutMs)
|
||||
await tapHostedIosAccessibilityControl(args.emulator, 'Back to session', args.timeoutMs)
|
||||
await waitForHostedIosAccessibilityControlByLabelPrefix(
|
||||
args.emulator,
|
||||
'Mobile Emulator',
|
||||
args.timeoutMs
|
||||
)
|
||||
await tapHostedIosAccessibilityControl(args.emulator, 'Back to worktrees', args.timeoutMs)
|
||||
await waitForHostedIosAccessibilityControlByLabelPrefix(
|
||||
args.emulator,
|
||||
args.expectedWorkspace,
|
||||
args.timeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function walkSourceControl(args, capture) {
|
||||
await longPressHostedIosAccessibilityControlByLabelPrefix(
|
||||
args.emulator,
|
||||
args.expectedWorkspace,
|
||||
args.timeoutMs,
|
||||
undefined,
|
||||
'Source Control'
|
||||
)
|
||||
await capture('workspace-actions')
|
||||
await tapHostedIosAccessibilityControl(args.emulator, 'Source Control', args.timeoutMs)
|
||||
const changedFile = await waitForHostedIosAccessibilityControlMatching(
|
||||
args.emulator,
|
||||
(node) =>
|
||||
node.label?.startsWith(CHANGED_FILE_LABEL_PREFIX) ||
|
||||
node.value?.startsWith(CHANGED_FILE_LABEL_PREFIX),
|
||||
args.timeoutMs
|
||||
)
|
||||
await waitForHostedIosAccessibilityControlEndingWith(args.emulator, ' on branch', args.timeoutMs)
|
||||
await capture('source-control')
|
||||
const changedFileLabel = [changedFile.label, changedFile.value].find((value) =>
|
||||
value?.startsWith(CHANGED_FILE_LABEL_PREFIX)
|
||||
)
|
||||
await tapHostedIosAccessibilityControl(args.emulator, changedFileLabel, args.timeoutMs)
|
||||
await waitForHostedIosAccessibilityControl(args.emulator, 'Open review actions', args.timeoutMs)
|
||||
await capture('review')
|
||||
await tapHostedIosAccessibilityControl(args.emulator, 'Back', args.timeoutMs)
|
||||
await waitForHostedIosAccessibilityControl(args.emulator, 'Source Control', args.timeoutMs)
|
||||
await tapHostedIosAccessibilityControl(args.emulator, 'Back to session', args.timeoutMs)
|
||||
await waitForHostedIosAccessibilityControlByLabelPrefix(
|
||||
args.emulator,
|
||||
args.expectedWorkspace,
|
||||
args.timeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function walkAgentHistory(args, capture) {
|
||||
await rotateHostedIosEmulator(args.emulator, 'portrait')
|
||||
await tapHostedIosAccessibilityControlByLabelPrefix(
|
||||
args.emulator,
|
||||
args.expectedWorkspace,
|
||||
args.timeoutMs
|
||||
)
|
||||
await tapHostedIosAccessibilityControl(args.emulator, 'More session actions', args.timeoutMs)
|
||||
await capture('session-actions')
|
||||
await tapHostedIosAccessibilityControlByLabelPrefix(
|
||||
args.emulator,
|
||||
'Agent History',
|
||||
args.timeoutMs
|
||||
)
|
||||
await tapHostedIosAccessibilityControl(
|
||||
args.emulator,
|
||||
AGENT_HISTORY_SEARCH_PLACEHOLDER,
|
||||
args.timeoutMs
|
||||
)
|
||||
await typeHostedIosText(args.emulator, AGENT_HISTORY_SEARCH_QUERY)
|
||||
await waitForHostedIosAccessibilityControlByLabelPrefix(
|
||||
args.emulator,
|
||||
EMULATOR_AGENT_HISTORY_TITLE,
|
||||
args.timeoutMs
|
||||
)
|
||||
await capture('agent-history-search')
|
||||
await tapHostedIosAccessibilityControl(args.emulator, 'Agent Session History', args.timeoutMs)
|
||||
await capture('agent-history')
|
||||
}
|
||||
|
||||
async function captureStop(args, name) {
|
||||
const labels = await readSettledAccessibilityLabels(args)
|
||||
const screenshot = path.join(args.outputDirectory, `${name}.png`)
|
||||
await execFileAsync('xcrun', ['simctl', 'io', args.deviceUdid, 'screenshot', screenshot])
|
||||
return { labels, screenshot }
|
||||
}
|
||||
|
||||
// Why: a screen still loading its rows, or a WebView still swapping its
|
||||
// accessibility subtree, reads as a client difference. Only a tree that repeats
|
||||
// itself is evidence.
|
||||
async function readSettledAccessibilityLabels(args) {
|
||||
const deadline = Date.now() + Math.min(args.timeoutMs, SETTLE_TIMEOUT_MS)
|
||||
let previous = null
|
||||
while (Date.now() < deadline) {
|
||||
await delay(SETTLE_INTERVAL_MS)
|
||||
const labels = await readHostedIosAccessibilityLabels(args.emulator)
|
||||
const comparable = volatileFreeLabels(labels).join('\u0000')
|
||||
if (previous !== null && previous === comparable) {
|
||||
return labels
|
||||
}
|
||||
previous = comparable
|
||||
}
|
||||
return readHostedIosAccessibilityLabels(args.emulator)
|
||||
}
|
||||
|
||||
function volatileFreeLabels(labels) {
|
||||
return labels.filter((label) => !VOLATILE_LABEL.test(label))
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -24,7 +24,13 @@ export async function captureNativeSourceControlReviewBaselines({
|
||||
timeoutMs
|
||||
}) {
|
||||
await dismissEmulatorDeveloperMenuIfPresent(emulator)
|
||||
await longPressHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs)
|
||||
await longPressHostedIosAccessibilityControlByLabelPrefix(
|
||||
emulator,
|
||||
expectedWorkspace,
|
||||
timeoutMs,
|
||||
undefined,
|
||||
'Source Control'
|
||||
)
|
||||
await tapHostedIosAccessibilityControl(emulator, 'Source Control', timeoutMs)
|
||||
const changedFileControl = await waitForHostedIosAccessibilityControlMatching(
|
||||
emulator,
|
||||
@@ -143,8 +149,11 @@ async function captureNativeRoute({
|
||||
title,
|
||||
timeoutMs
|
||||
}) {
|
||||
const screenTitlePoint = await waitForHostedIosAccessibilityControl(emulator, title, timeoutMs)
|
||||
await waitForHostedIosAccessibilityControl(emulator, title, timeoutMs)
|
||||
await delay(500)
|
||||
// Why: the host-origin route animates in, so the first frame's landmark is a
|
||||
// position the screenshot below never shows. Re-read it once the screen settles.
|
||||
const screenTitlePoint = await waitForHostedIosAccessibilityControl(emulator, title, timeoutMs)
|
||||
const screenshot = path.join(runtimeDirectory, screenshotName)
|
||||
await captureSimulatorScreenshot(deviceUdid, screenshot)
|
||||
return { screenTitlePoint, screenshot }
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { resolveEmulatorOrcaCli } from './emulator-orca-cli-selection.mjs'
|
||||
import { startHostedIosEmulatorController } from './hosted-ios-emulator-controller.mjs'
|
||||
import { captureHostedIosNativeJourneyLabels } from './hosted-ios-native-journey-labels.mjs'
|
||||
import { completeHostedIosNativeOnboarding } from './hosted-ios-native-onboarding.mjs'
|
||||
import {
|
||||
startHostedIosMobileLauncher,
|
||||
waitForHostedIosMobileLauncher
|
||||
} from './hosted-ios-mobile-launcher.mjs'
|
||||
import { installHostedIosSimulatorApp } from './hosted-ios-simulator-app-build.mjs'
|
||||
import {
|
||||
bootHostedIosSimulator,
|
||||
resolveHostedIosSimulatorUdid
|
||||
} from './hosted-ios-simulator-device.mjs'
|
||||
import { stopHostedChildProcess } from './hosted-child-process-shutdown.mjs'
|
||||
|
||||
const usage =
|
||||
'Usage: node scripts/run-hosted-ios-native-journey-capture.mjs --app <Orca.app> --out <dir> [--metro-dir <dir>] [--device <name>] [--timeout-ms <ms>]'
|
||||
|
||||
function parseOptions(argv) {
|
||||
const options = {
|
||||
appPath: null,
|
||||
device: 'iPhone 17 Pro',
|
||||
metroDirectory: null,
|
||||
outputDirectory: null,
|
||||
timeoutMs: 180_000
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === '--app' && argv[index + 1]) {
|
||||
options.appPath = path.resolve(argv[++index])
|
||||
} else if (argv[index] === '--out' && argv[index + 1]) {
|
||||
options.outputDirectory = path.resolve(argv[++index])
|
||||
} else if (argv[index] === '--metro-dir' && argv[index + 1]) {
|
||||
options.metroDirectory = path.resolve(argv[++index])
|
||||
} else if (argv[index] === '--device' && argv[index + 1]) {
|
||||
options.device = argv[++index]
|
||||
} else if (argv[index] === '--timeout-ms' && argv[index + 1]) {
|
||||
options.timeoutMs = Number(argv[++index])
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argv[index]}\n${usage}`)
|
||||
}
|
||||
}
|
||||
if (!options.appPath || !options.outputDirectory) {
|
||||
throw new Error(usage)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
const worktree = path.resolve(import.meta.dirname, '../..')
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const runtimeDirectory = path.join(options.outputDirectory, 'runtime')
|
||||
|
||||
async function main() {
|
||||
mkdirSync(runtimeDirectory, { recursive: true, mode: 0o700 })
|
||||
const deviceUdid = await resolveHostedIosSimulatorUdid(options.device)
|
||||
await bootHostedIosSimulator(deviceUdid)
|
||||
await installHostedIosSimulatorApp({ deviceUdid, appPath: options.appPath })
|
||||
const orcaSelection = resolveEmulatorOrcaCli({
|
||||
explicitCommand: process.env.ORCA_CLI,
|
||||
managedCommand: process.env.ORCA_CLI_COMMAND,
|
||||
devRepoRoot: process.env.ORCA_DEV_REPO_ROOT,
|
||||
worktree,
|
||||
cwd: worktree
|
||||
})
|
||||
const controller = await startHostedIosEmulatorController({
|
||||
orcaCli: orcaSelection.command,
|
||||
runtimeDirectory,
|
||||
worktree
|
||||
})
|
||||
const launcher = startHostedIosMobileLauncher({
|
||||
deviceUdid,
|
||||
emulatorControlUserDataPath: controller.userData,
|
||||
metroDirectory: options.metroDirectory,
|
||||
orcaCli: orcaSelection.command,
|
||||
runtimeDirectory,
|
||||
worktree
|
||||
})
|
||||
try {
|
||||
await waitForHostedIosMobileLauncher(launcher, options.timeoutMs)
|
||||
const emulator = {
|
||||
deviceUdid,
|
||||
orcaCli: orcaSelection.command,
|
||||
userDataDir: controller.userData,
|
||||
worktree
|
||||
}
|
||||
const expectedWorkspace = path.basename(worktree)
|
||||
const onboarding = await completeHostedIosNativeOnboarding(
|
||||
emulator,
|
||||
expectedWorkspace,
|
||||
options.timeoutMs
|
||||
)
|
||||
const stops = await captureHostedIosNativeJourneyLabels({
|
||||
deviceUdid,
|
||||
emulator,
|
||||
expectedWorkspace,
|
||||
outputDirectory: options.outputDirectory,
|
||||
timeoutMs: options.timeoutMs
|
||||
})
|
||||
const evidence = {
|
||||
appPath: options.appPath,
|
||||
expectedWorkspace,
|
||||
metroDirectory: options.metroDirectory ?? path.join(worktree, 'mobile'),
|
||||
onboarding,
|
||||
stops
|
||||
}
|
||||
const evidencePath = path.join(options.outputDirectory, 'native-journey-capture.json')
|
||||
writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`)
|
||||
console.log(evidencePath)
|
||||
} finally {
|
||||
await stopHostedChildProcess(launcher).catch(() => undefined)
|
||||
await controller.stop().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -167,6 +167,9 @@ async function getWorktree() {
|
||||
}
|
||||
|
||||
function getMobileDir(worktree) {
|
||||
if (process.env.ORCA_E2E_MOBILE_METRO_DIR) {
|
||||
return path.resolve(process.env.ORCA_E2E_MOBILE_METRO_DIR)
|
||||
}
|
||||
return !options.worktree && path.basename(process.cwd()) === 'mobile'
|
||||
? process.cwd()
|
||||
: path.join(worktree, 'mobile')
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('hosted Android Agent History journey', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('covers the existing scopes, preview, search, trusted resume, and Back flow', async () => {
|
||||
it('covers the existing scopes, preview, search, native resume, and Back flow', async () => {
|
||||
const sessionDocument = {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
}
|
||||
@@ -78,7 +78,6 @@ describe('hosted Android Agent History journey', () => {
|
||||
row: 'Hybrid Agent History Fixture',
|
||||
preview: 'E2E_AGENT_HISTORY_PREVIEW_MARKER',
|
||||
resume: {
|
||||
synthetic: 'gesture rejected',
|
||||
native: 'queued',
|
||||
nativeTouchPoint: { x: 0.4, y: 0.3 },
|
||||
resumedRoute: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
|
||||
@@ -8,38 +8,85 @@ const emulator = {
|
||||
worktree: '/repo'
|
||||
}
|
||||
|
||||
const rowTree = {
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
label: 'mobile-rearch, mobile-rearch, 1',
|
||||
enabled: true,
|
||||
frame: { x: 0, y: 0.2, width: 1, height: 0.1 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const sheetTree = {
|
||||
ok: true,
|
||||
result: [
|
||||
{ label: 'Source Control', enabled: true, frame: { x: 0, y: 0.7, width: 1, height: 0.05 } }
|
||||
]
|
||||
}
|
||||
|
||||
function axResponse(tree: unknown) {
|
||||
return { stderr: '', stdout: JSON.stringify(tree) }
|
||||
}
|
||||
|
||||
function gesturePoints(call: [unknown, string[]] | undefined) {
|
||||
return JSON.parse(call?.[1][1] as string)
|
||||
}
|
||||
|
||||
describe('hosted iOS emulator long press', () => {
|
||||
it('holds a composite workspace row beyond the React Native threshold', async () => {
|
||||
it('holds the touch down across two gesture commands', async () => {
|
||||
const runCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
label: 'mobile-rearch, mobile-rearch, 1',
|
||||
enabled: true,
|
||||
frame: { x: 0, y: 0.2, width: 1, height: 0.1 }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce(axResponse(rowTree))
|
||||
.mockResolvedValueOnce({ stderr: '', stdout: JSON.stringify({ ok: true }) })
|
||||
.mockResolvedValueOnce(axResponse(sheetTree))
|
||||
.mockResolvedValueOnce({ stderr: '', stdout: JSON.stringify({ ok: true }) })
|
||||
|
||||
await expect(
|
||||
longPressHostedIosAccessibilityControlByLabelPrefix(
|
||||
emulator,
|
||||
'mobile-rearch',
|
||||
1_000,
|
||||
runCommand
|
||||
20_000,
|
||||
runCommand,
|
||||
'Source Control'
|
||||
)
|
||||
).resolves.toEqual({ x: 0.5, y: 0.25 })
|
||||
|
||||
const gesture = JSON.parse(runCommand.mock.calls[1]?.[1][1] as string)
|
||||
// Why: a single command batches its points, so the press must span two commands.
|
||||
expect(runCommand.mock.calls[1]?.[1][0]).toBe('gesture')
|
||||
expect(gesture).toHaveLength(62)
|
||||
expect(gesture[0]).toEqual({ type: 'begin', x: 0.5, y: 0.25 })
|
||||
expect(gesture.at(-1)).toEqual({ type: 'end', x: 0.5, y: 0.25 })
|
||||
expect(gesturePoints(runCommand.mock.calls[1])).toEqual([
|
||||
{ type: 'begin', x: 0.5, y: 0.25 },
|
||||
{ type: 'move', x: 0.5, y: 0.25 }
|
||||
])
|
||||
expect(runCommand.mock.calls[3]?.[1][0]).toBe('gesture')
|
||||
expect(gesturePoints(runCommand.mock.calls[3])).toEqual([
|
||||
{ type: 'move', x: 0.5, y: 0.25 },
|
||||
{ type: 'end', x: 0.5, y: 0.25 }
|
||||
])
|
||||
})
|
||||
|
||||
it('releases the touch when the press never settles', async () => {
|
||||
const runCommand = vi.fn(async (_args: unknown, command: string[]) => {
|
||||
if (command[0] === 'ax') {
|
||||
return axResponse(rowTree)
|
||||
}
|
||||
return { stderr: '', stdout: JSON.stringify({ ok: true }) }
|
||||
})
|
||||
|
||||
await expect(
|
||||
longPressHostedIosAccessibilityControlByLabelPrefix(
|
||||
emulator,
|
||||
'mobile-rearch',
|
||||
2_000,
|
||||
runCommand,
|
||||
'Source Control'
|
||||
)
|
||||
).rejects.toThrow('Source Control was not accessible')
|
||||
|
||||
const endGestures = runCommand.mock.calls.filter(
|
||||
(call) => call[1][0] === 'gesture' && gesturePoints(call).at(-1).type === 'end'
|
||||
)
|
||||
expect(endGestures).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,7 +44,13 @@ describe('hosted iOS host-origin Source Control journey', () => {
|
||||
operations: { activate, longPress, readState, tapNative, waitForDocument }
|
||||
})
|
||||
|
||||
expect(longPress).toHaveBeenCalledWith({ udid: 'SIMULATOR-1' }, 'mobile-rearch', 30_000)
|
||||
expect(longPress).toHaveBeenCalledWith(
|
||||
{ udid: 'SIMULATOR-1' },
|
||||
'mobile-rearch',
|
||||
30_000,
|
||||
undefined,
|
||||
'Source Control'
|
||||
)
|
||||
expect(tapNative).toHaveBeenCalledWith({ udid: 'SIMULATOR-1' }, 'Source Control', 30_000)
|
||||
expect(activate.mock.calls.map((call) => call[1])).toEqual([
|
||||
{ kind: 'label', value: 'Open changed file mobile/src/mobile-web/bridge.ts' },
|
||||
|
||||
@@ -81,7 +81,9 @@ describe('hosted iOS Source Control and Review parity', () => {
|
||||
expect(mocks.longPressControlByPrefix).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
'mobile-rearch',
|
||||
30_000
|
||||
30_000,
|
||||
undefined,
|
||||
'Source Control'
|
||||
)
|
||||
expect(mocks.tapControl).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
|
||||
Reference in New Issue
Block a user