mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
test(mobile): validate hosted adversarial content
This commit is contained in:
@@ -1279,8 +1279,13 @@ copy.
|
||||
operations resolve the current execution owner inside Desktop; real SSH
|
||||
and Relay compositions pass, while the broader release topology matrix
|
||||
remains a validation gate.
|
||||
- [ ] Test repository-controlled filenames, Markdown, SVG/images, and diff
|
||||
content against script/bridge injection.
|
||||
- [~] Test repository-controlled filenames, Markdown, SVG/images, and diff
|
||||
content against script/bridge injection. A disposable repository now
|
||||
drives a hostile `<img onerror>` filename and changed diff line through
|
||||
Source Control, Session diff, and Review in the exact iOS Simulator and
|
||||
Android Debug emulator apps. Both render the payloads literally without
|
||||
creating an image or setting either execution sentinel. Markdown,
|
||||
SVG/image, physical-device, and release-runtime coverage remains.
|
||||
- [ ] Test large file counts, large diffs, binary files, conflicts, detached
|
||||
state, missing upstream, and provider failures.
|
||||
- [ ] Complete physical-device diff/file performance validation.
|
||||
@@ -1575,9 +1580,15 @@ copy.
|
||||
unchanged raw-source fallback. Focused document and package-budget boundaries
|
||||
pass. A deterministic corpus now proves bounded filenames, diff lines,
|
||||
task/provider fields, and errors remain inert React Native text, and existing
|
||||
terminal-link tests reject unsupported targets. The exact Orca app route,
|
||||
native nested component, Android, physical-device, Release, broader live
|
||||
adversarial interaction, and independent review remain open.
|
||||
terminal-link tests reject unsupported targets. A disposable hostile
|
||||
repository now passes its `<img onerror>` filename and changed diff line
|
||||
through the exact Source Control, Session diff, and Review routes on iPhone
|
||||
17 Pro / iOS 26.5 Simulator and Pixel 9 Pro API 36 arm64 Debug. Both render
|
||||
the payloads literally, create no matching image, leave both execution
|
||||
sentinels unset, and retain network/navigation/executable and privacy
|
||||
isolation. Exact-app HTML, SVG/image, Markdown, Mermaid, terminal-link,
|
||||
task/provider, bounded-error, physical-device, Release, broader live
|
||||
interaction, and independent review remain open.
|
||||
- [~] Fuzz manifest, chunks, asset paths, MIME types, CSP, and cache metadata.
|
||||
A mirrored TypeScript, Swift, and Kotlin scalar-type corpus rejects quoted and
|
||||
Boolean schema, bridge, total-byte, and asset-byte integers before staging. It
|
||||
@@ -1959,11 +1970,8 @@ gates, max-lines, localization, formatting, diff hygiene, and production
|
||||
package verification pass. The current privacy-hardening package is
|
||||
`3c0f364f9cb6f1785d1d08fdeb81ca5367b091706c24d124374a88578839e745`:
|
||||
50 assets, 9,135,273 raw bytes, and 2,644,558 gzip bytes. The full mobile suite
|
||||
passes 588 files / 3,499 tests with 2 expected skips. The latest full root run
|
||||
from before the final upstream rebases passes 3,839 files / 40,326 tests with 71
|
||||
expected skips except for one unrelated load-sensitive remote-runtime socket
|
||||
setup failure; its complete one-test file passes in a 194-millisecond isolated
|
||||
rerun.
|
||||
passes 589 files / 3,511 tests with 2 expected skips. The latest full root run
|
||||
passes 3,854 files / 40,508 tests with 71 expected skips.
|
||||
|
||||
| Date | Workstream | Evidence | Result |
|
||||
| ---------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -2359,6 +2367,10 @@ rerun.
|
||||
| 2026-07-27 | RNW package | Exact-source production build and independent verifier after the Source Control/Review repair and lint-safe bridge extraction | Passed; build `4b7df7d47a9b949b788c9f88bac5f68e8b18eb1ea7c615e16ebc4be126c8d07d`, 49 assets, 9,328,523 raw bytes, and 2,697,136 gzip bytes |
|
||||
| 2026-07-27 | iOS Source Control | Exact hosted app opened Source Control from Session, opened a changed file as a second Session diff tab, then exercised standalone Review | Passed on iPhone 17 Pro Simulator; existing segments and review controls retained; network and navigation isolation probes passed |
|
||||
| 2026-07-27 | Final validation pass | Full mobile/root suites; mobile, RNW, Node, CLI, and renderer typechecks; full mobile/RNW lint; root reliability/max-lines/skill/localization-catalog phases; focused bridge/provider coverage | Passed; mobile 544 files / 3,248 tests with 2 skips; root 3,551 files / 37,420 tests with 60 skips; 56 focused tests; root localization coverage retains the recorded unrelated `Ghostty` finding |
|
||||
| 2026-07-29 | Adversarial content | Disposable hostile filename and diff through exact Source Control, active Session diff, and standalone Review on iOS 26.5 Simulator and Android API 36 emulator | Passed; both payloads render literally, zero injected images, both execution sentinels unset, and network/navigation/executable/privacy audits green |
|
||||
| 2026-07-29 | Focused validation | Adversarial fixture/inspection, route hooks, workspace activation, CDP reveal, Android privacy/log filtering, and focused-option coverage | Passed; 7 files / 78 tests |
|
||||
| 2026-07-29 | Full validation | Mobile/root suites, all project typechecks, root/mobile/mobile-web lint and code-quality audits, reliability, localization, max-lines, formatting, package verification, and diff hygiene | Passed; mobile 589 files / 3,511 tests with 2 skips; root 3,854 files / 40,508 tests with 71 skips; build `3c0f364f…` remains 50 assets / 9,135,273 raw / 2,644,558 gzip bytes |
|
||||
| 2026-07-29 | Rebase | Rebased all 58 migration commits onto `origin/main` at `4543bb6826`; branch divergence `0/58`; rebuilt and independently verified the hosted package | Passed without conflicts; exact build remains `3c0f364f…`, 50 assets, 9,135,273 raw bytes, and 2,644,558 gzip bytes |
|
||||
|
||||
## Status and Decision Log
|
||||
|
||||
@@ -2883,3 +2895,8 @@ rerun.
|
||||
| 2026-07-29 | Complete | The exact Pixel 9 Pro API 36 arm64 Debug app passes private-origin DOM/History, page-storage/cookie, network/navigation/executable isolation, fresh `logcat`, and `ApplicationExitInfo` review. The 11,459-byte document and 975,464-byte log slice contain zero privileged marker or unexpected WebSocket URL; the sentinel records zero escaped request and Android records no new crash/native-crash/ANR/initialization/resource failure. Two focused files / 28 tests cover shared iOS/Android markers, Android exit/debug-URL mutations, and live-gate integration. |
|
||||
| 2026-07-29 | Complete | Post-audit validation passes 588 mobile files / 3,499 tests with 2 expected skips; all project typechecks; root/mobile/mobile-web lint and code-quality audits; 56 reliability gates; localization; max-lines; full-mobile formatting; changed-code quality; React Doctor with zero new migration findings; exact `3c0f364f…` package verification; and diff hygiene. |
|
||||
| 2026-07-29 | Complete | Rebased all 56 migration commits without conflict onto `origin/main` at `fa449bc0e`; documentation reconciliation leaves the branch 57 commits ahead and zero behind. The exact `3c0f364f…` package rebuild remains 50 assets / 9,135,273 raw / 2,644,558 gzip bytes. Post-rebase validation passes 588 mobile files / 3,499 tests with 2 expected skips, all project typechecks, root/mobile/mobile-web lint and code-quality audits, 56 reliability gates, formatting, package verification, and diff hygiene. |
|
||||
| 2026-07-29 | Complete | A disposable Git fixture now places `000-<img src=x onerror=…>.tsx` and a hostile changed line into the unchanged hosted Source Control, Session diff, and Review presentation. The broker supplies the opaque page workspace authority; raw Desktop workspace identity never enters the hosted URL. |
|
||||
| 2026-07-29 | Complete | Exact iPhone 17 Pro / iOS 26.5 Simulator and Pixel 9 Pro API 36 arm64 Debug runs render both hostile payloads literally, create zero injected images, leave both execution markers unset, and pass network/navigation/executable isolation. iOS reports empty Local Storage, Session Storage, and cookies; Android reports zero privacy markers, sentinel observations, and new failure exit records. |
|
||||
| 2026-07-29 | Finding | The first post-rebase root run shared the machine with package, lint, and typecheck jobs and hit the known 30-second `ProjectViewWrapper` dynamic-import timeout. Its isolated file passes 2/2 in 5.5 seconds, and the subsequent standalone full root run passes completely. |
|
||||
| 2026-07-29 | Complete | Rebased all 58 migration commits without conflict onto `origin/main` at `4543bb6826`; the branch is zero behind. Post-rebase validation passes 589 mobile files / 3,511 tests with 2 expected skips and 3,854 root files / 40,508 tests with 71 expected skips. All project typechecks, root/mobile/mobile-web lint and code-quality audits, 56 reliability gates, localization, max-lines, full-mobile and plan formatting, exact `3c0f364f…` package verification, and diff hygiene pass. |
|
||||
| 2026-07-29 | Next | Finish the live HTML, SVG/image, Markdown, Mermaid, terminal-link, task/provider, and bounded-error corpus, then continue cross-host/workspace lifecycle races, sustained allocation fuzzing, independent review, and the external device/release gates. |
|
||||
|
||||
@@ -150,7 +150,7 @@ prototype:
|
||||
inventory entry, and fixtures are removed. The production `/hybrid` route,
|
||||
production bridge clients, native fallback, and Experimental Settings entry
|
||||
remain intentionally until the external cutover gates pass.
|
||||
- The 57-commit branch is rebased onto `origin/main` at `fa449bc0e` and remains
|
||||
- The 58-commit branch is rebased onto `origin/main` at `4543bb6826` and remains
|
||||
zero behind. Upstream
|
||||
native-chat launch-draft, transcript identity, loading, reconnect, and
|
||||
orchestration behavior is retained in both native and hosted adapters.
|
||||
@@ -207,6 +207,13 @@ prototype:
|
||||
- Native package generations and hosted privileged-state namespaces use the
|
||||
paired Desktop public key. Mutable profile IDs remain limited to
|
||||
non-privileged navigation, diagnostics, and host selection.
|
||||
- A disposable hostile repository now exercises the real Source Control,
|
||||
Session diff, and Review routes. The exact iPhone 17 Pro / iOS 26.5 Simulator
|
||||
app and Pixel 9 Pro API 36 arm64 Debug app render an `<img onerror>` filename
|
||||
and changed diff line literally, create no matching image, leave both
|
||||
execution sentinels unset, and retain network/navigation/executable and
|
||||
privacy isolation. The hosted URL carries only the broker-issued opaque
|
||||
workspace authority.
|
||||
- Gesture-mediated native operations share one shell authority that consumes
|
||||
each native-observed touch once, rejects expired or future timestamps, clears
|
||||
pending authority whenever native `AppState` leaves foreground, and refuses a
|
||||
@@ -1134,15 +1141,14 @@ and nested syntax text keeps the effective native font behavior. On iPhone 17
|
||||
Pro Simulator, Source Control passes at 0.736% changed pixels / 0.910 mean
|
||||
channel difference and Review at 2.134% / 1.947, within the 3% / 4 budgets.
|
||||
|
||||
Current validation passes 570 mobile files / 3,418 tests with 2 expected skips
|
||||
and 3,818 root files / 39,968 tests with 62 expected skips. The earlier
|
||||
load-sensitive root timeouts do not recur in the latest complete run. All
|
||||
project typechecks, root/mobile/mobile-web lint and code-quality audits,
|
||||
changed-file formatting, localization, max-lines and diff hygiene, and 55
|
||||
Current validation passes 589 mobile files / 3,511 tests with 2 expected skips
|
||||
and 3,854 root files / 40,508 tests with 71 expected skips. All project
|
||||
typechecks, root/mobile/mobile-web lint and code-quality audits, changed-file
|
||||
formatting, localization, max-lines and diff hygiene, and the current 56
|
||||
reliability gates pass. React Doctor reports zero blocking errors across the
|
||||
migration without suppressions. The independently verified production package
|
||||
`7c7c673deb74e158cdfb99b1ca536fd88cd3ab5dac4eb8db78c43ca12f6ce31d`
|
||||
contains 50 assets and verifies at 9,290,968 raw bytes / 2,688,499 gzip bytes.
|
||||
`3c0f364f9cb6f1785d1d08fdeb81ca5367b091706c24d124374a88578839e745`
|
||||
contains 50 assets and verifies at 9,135,273 raw bytes / 2,644,558 gzip bytes.
|
||||
That exact package passes the unpacked macOS arm64 → Docker SSH → actual iOS
|
||||
WKWebView journey from a clean app reinstall in 1.9 minutes. Authenticated RPC
|
||||
returned the packaged build with no checkout-output fallback; the unchanged
|
||||
@@ -2817,6 +2823,17 @@ Both runs also pass network/navigation isolation; Android records zero sentinel
|
||||
observations and no native bridge error. This does not replace physical-device,
|
||||
store-signed release, fuzz, or independent adversarial evidence.
|
||||
|
||||
The same exact emulator apps now pass the first live repository-content
|
||||
injection slice. A disposable Git repository presents a hostile
|
||||
`<img onerror>` filename and changed line through Source Control, the active
|
||||
Session diff tab, and standalone Review. Both platforms render both payloads as
|
||||
literal text, create no matching image node, leave the filename and content
|
||||
execution sentinels unset, and pass executable, network, navigation, DOM,
|
||||
storage, cookie, log, and process-exit inspection applicable to the platform.
|
||||
This closes live filename/diff evidence only; the remaining HTML, SVG/image,
|
||||
Markdown, Mermaid, terminal-link, task/provider, bounded-error, physical-device,
|
||||
release-runtime, and independent-review corpus remains required.
|
||||
|
||||
A mirrored TypeScript, Swift, and Kotlin manifest corpus also rejects quoted and
|
||||
Boolean numeric schema, bridge, total-byte, and asset-byte fields before native
|
||||
staging. The corpus found Android `JSONObject.optInt` string coercion and iOS
|
||||
|
||||
@@ -56,6 +56,17 @@ search/no-match/clear flow, rejection of synthetic privileged activation, and
|
||||
native-touch resume into a second Session tab before continuing through Source
|
||||
Control and Review.
|
||||
|
||||
A disposable repository now drives a hostile filename and changed diff line
|
||||
through the real Source Control, Session diff, and Review routes. The exact
|
||||
iPhone 17 Pro / iOS 26.5 Simulator app and Pixel 9 Pro API 36 arm64 Debug app
|
||||
both render the `<img onerror>` payloads literally, create no matching image,
|
||||
leave both execution sentinels unset, and pass the existing
|
||||
network/navigation/executable and privacy audits. The fixture uses only the
|
||||
broker-issued opaque page workspace authority; raw Desktop workspace identity
|
||||
never enters the hosted URL. HTML, SVG/image, Markdown, Mermaid, terminal-link,
|
||||
task/provider, bounded-error, physical-device, release-runtime, and independent
|
||||
review coverage remain open.
|
||||
|
||||
The iPhone 17 Pro / iOS 26.5 Simulator now captures the unchanged native and
|
||||
hosted Tasks and Session screens from one disposable Desktop runtime. Tasks
|
||||
passes at 0.022% changed pixels, 0.084 mean channel difference, and 0.000016
|
||||
@@ -102,13 +113,11 @@ pixels and 0.910 mean channel difference; Review passes at 2.134% and 1.947,
|
||||
within the 3% / 4 budgets. The packaged document opts into native safe-area
|
||||
insets, and nested syntax text retains the native effective font behavior.
|
||||
|
||||
The migration is based on `origin/main` at `fa449bc0e`; the final rebase is
|
||||
complete and the branch is 57 commits ahead and zero behind. Post-rebase
|
||||
validation now passes 588 mobile files / 3,499 tests with 2 expected skips. The
|
||||
latest full root run from before the final upstream rebases passes
|
||||
3,839 files / 40,326 tests with 71 expected skips except for one unrelated
|
||||
load-sensitive remote-runtime socket setup failure; its complete one-test file
|
||||
passes in a 194-millisecond isolated rerun. All project typechecks,
|
||||
The migration is based on `origin/main` at `4543bb6826`; the final rebase is
|
||||
complete and the branch is 58 commits ahead and zero behind. Post-rebase
|
||||
validation plus the hostile-content slice now passes 589 mobile files / 3,511
|
||||
tests with 2 expected skips. The latest full root run passes 3,854 files /
|
||||
40,508 tests with 71 expected skips. All project typechecks,
|
||||
root/mobile/mobile-web lint and code-quality audits, 56 reliability gates,
|
||||
changed-file and full-mobile formatting, localization, the max-lines ratchet,
|
||||
and diff hygiene pass. React Doctor reports zero new migration findings. The
|
||||
@@ -315,13 +324,11 @@ requested workspace. Existing Source Control, provider-review, file, and
|
||||
Markdown correlation remains in force.
|
||||
|
||||
The production package now verifies as `3c0f364f…`: 50 assets, 9,135,273 raw
|
||||
bytes, and 2,644,558 gzip bytes. The full mobile suite passes 588 files / 3,499
|
||||
bytes, and 2,644,558 gzip bytes. The full mobile suite passes 589 files / 3,511
|
||||
tests with 2 expected skips. All project typechecks; root, mobile, and
|
||||
mobile-web lint; all 56 reliability gates; localization; max-lines; formatting;
|
||||
and package verification pass. The latest full root run from before the final
|
||||
upstream rebases passes 3,839 files / 40,326 tests with 71 expected skips except
|
||||
for one unrelated load-sensitive remote-runtime socket setup failure; its
|
||||
complete one-test file passes in a 194-millisecond isolated rerun.
|
||||
and package verification pass. The latest full root run passes 3,854 files /
|
||||
40,508 tests with 71 expected skips.
|
||||
|
||||
The mirrored native package-store suites now pass 120 concurrent cache flows per
|
||||
platform. The expanded same-host matrix covers competing distinct generations,
|
||||
@@ -396,7 +403,13 @@ remain open.
|
||||
- [ ] Run the deterministic filename, diff, terminal-link, provider/task,
|
||||
bounded-error, HTML, SVG, Markdown, and Mermaid corpus through the exact
|
||||
release app on both platforms and complete independent live interaction
|
||||
testing.
|
||||
testing. Disposable hostile filename and diff fixtures now pass through
|
||||
Source Control, Session diff, and Review in the exact cached iOS
|
||||
Simulator app and Android Debug emulator app. Both platforms render the
|
||||
payloads literally, create no injected image, leave execution sentinels
|
||||
unset, and retain network/navigation/executable and privacy isolation.
|
||||
The remaining sinks, physical devices, release builds, and independent
|
||||
interaction review remain open.
|
||||
- [ ] Fuzz manifests, chunks, paths, MIME types, CSP, cache metadata, bridge
|
||||
envelopes, limits, ordering, cancellation, and subscriptions. The
|
||||
ten-case TypeScript/Swift/Kotlin quoted/Boolean numeric manifest corpus
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const fixturePrefix = 'orca-mobile-adversarial.'
|
||||
export const HOSTED_ADVERSARIAL_FILENAME_MARKER = 'ORCA_ADVERSARIAL_FILENAME'
|
||||
export const HOSTED_ADVERSARIAL_CONTENT_MARKER = 'ORCA_ADVERSARIAL_CONTENT'
|
||||
export const HOSTED_ADVERSARIAL_WORKSPACE_ROW = 'orca-adversarial-row'
|
||||
export const HOSTED_ADVERSARIAL_FILENAME = `000-<img src=x onerror=globalThis.${HOSTED_ADVERSARIAL_FILENAME_MARKER}=1>.tsx`
|
||||
export const HOSTED_ADVERSARIAL_CONTENT = `<img src=x onerror="globalThis.${HOSTED_ADVERSARIAL_CONTENT_MARKER}=1">`
|
||||
|
||||
export async function createHostedAdversarialRepositoryFixture() {
|
||||
const root = await realpath(await mkdtemp(path.join(os.tmpdir(), fixturePrefix)))
|
||||
try {
|
||||
await git(root, ['init', '-q'])
|
||||
await git(root, ['config', 'user.name', 'Orca Mobile Test'])
|
||||
await git(root, ['config', 'user.email', 'mobile-test@orca.invalid'])
|
||||
await writeFile(path.join(root, 'README.md'), 'Adversarial mobile fixture\n')
|
||||
const blobSource = path.join(root, '.orca-adversarial-content')
|
||||
await writeFile(blobSource, `${HOSTED_ADVERSARIAL_CONTENT}\n`)
|
||||
const blob = await git(root, ['hash-object', '-w', blobSource])
|
||||
await rm(blobSource)
|
||||
await git(root, ['add', 'README.md'])
|
||||
await git(root, [
|
||||
'update-index',
|
||||
'--add',
|
||||
'--cacheinfo',
|
||||
'100644',
|
||||
blob,
|
||||
HOSTED_ADVERSARIAL_FILENAME
|
||||
])
|
||||
await git(root, ['commit', '-q', '-m', 'Initial fixture'])
|
||||
await git(root, ['branch', '-m', HOSTED_ADVERSARIAL_WORKSPACE_ROW])
|
||||
return {
|
||||
root,
|
||||
workspaceName: path.basename(root),
|
||||
workspaceRowName: HOSTED_ADVERSARIAL_WORKSPACE_ROW,
|
||||
filename: HOSTED_ADVERSARIAL_FILENAME,
|
||||
content: HOSTED_ADVERSARIAL_CONTENT
|
||||
}
|
||||
} catch (error) {
|
||||
await removeHostedAdversarialRepositoryFixture({ root })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeHostedAdversarialRepositoryFixture(fixture) {
|
||||
if (!fixture?.root || path.basename(fixture.root).startsWith(fixturePrefix) === false) {
|
||||
throw new Error('Refusing to remove an invalid adversarial repository fixture')
|
||||
}
|
||||
await rm(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
export async function readHostedAdversarialRepositoryContent(fixture) {
|
||||
return `${await git(fixture.root, ['show', `HEAD:${fixture.filename}`])}\n`
|
||||
}
|
||||
|
||||
async function git(cwd, args) {
|
||||
const { stdout } = await execFileAsync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 })
|
||||
return stdout.trim()
|
||||
}
|
||||
@@ -176,20 +176,24 @@ export async function stopHostedAndroidApp(adb) {
|
||||
await runAndroidAdb(adb, ['shell', 'am', 'force-stop', packageName]).catch(() => {})
|
||||
}
|
||||
|
||||
export function findHostedAndroidBridgeLogFailures(logcat) {
|
||||
return logcat
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) =>
|
||||
/FATAL EXCEPTION|Call to function 'ExpoMobileWebShell\.[^']+' has been rejected|mobile_web_shell_view_unavailable|Cannot convert .* to a Kotlin type|ClassCastException.*MobileWebShellView/iu.test(
|
||||
line
|
||||
)
|
||||
export function findHostedAndroidBridgeLogFailures(logcat, appPid) {
|
||||
return logcat.split(/\r?\n/u).filter((line) => {
|
||||
const fatal = /FATAL EXCEPTION/iu.test(line)
|
||||
if (fatal && appPid && !line.includes(`(${appPid})`)) {
|
||||
return false
|
||||
}
|
||||
return /FATAL EXCEPTION|Call to function 'ExpoMobileWebShell\.[^']+' has been rejected|mobile_web_shell_view_unavailable|Cannot convert .* to a Kotlin type|ClassCastException.*MobileWebShellView/iu.test(
|
||||
line
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function assertHostedAndroidBridgeLogClean(adb) {
|
||||
const failures = findHostedAndroidBridgeLogFailures(
|
||||
await runAndroidAdb(adb, ['logcat', '-d', '-v', 'brief'])
|
||||
)
|
||||
const [logcat, appPid] = await Promise.all([
|
||||
runAndroidAdb(adb, ['logcat', '-d', '-v', 'brief']),
|
||||
runAndroidAdb(adb, ['shell', 'pidof', packageName])
|
||||
])
|
||||
const failures = findHostedAndroidBridgeLogFailures(logcat, appPid)
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Android bridge emitted errors:\n${failures.slice(0, 16).join('\n')}`)
|
||||
}
|
||||
|
||||
@@ -96,10 +96,11 @@ function isExpectedDebugWebSocketUrl(value, expected) {
|
||||
return true
|
||||
}
|
||||
const keys = [...url.searchParams.keys()].sort()
|
||||
const debugPorts = new Set([String(expected.devServerPort), '8081'])
|
||||
return (
|
||||
url.protocol === 'ws:' &&
|
||||
['10.0.2.2', '127.0.0.1'].includes(url.hostname) &&
|
||||
url.port === String(expected.devServerPort) &&
|
||||
debugPorts.has(url.port) &&
|
||||
url.pathname === '/message' &&
|
||||
!url.hash &&
|
||||
keys.join(',') === 'app,clientid,device' &&
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import process from 'node:process'
|
||||
import { promisify } from 'node:util'
|
||||
import { HOSTED_ADVERSARIAL_CONTENT_MARKER } from './hosted-adversarial-repository-fixture.mjs'
|
||||
import {
|
||||
captureHostedWebViewAdversarialObservation,
|
||||
hostedWebViewAdversarialContentObservations
|
||||
} from './hosted-webview-adversarial-content.mjs'
|
||||
import { readHostedWebViewTextPoint } from './hosted-webview-cdp-session.mjs'
|
||||
import { tapHostedIosPoint } from './hosted-ios-emulator-accessibility.mjs'
|
||||
import { registerWorktreeForPairingRuntime } from './start-emulator-pairing-runtime.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export async function registerHostedIosAdversarialRepository(
|
||||
{ fixture, orcaCli, pairingRuntimeUserDataPath },
|
||||
runCli = execFileAsync
|
||||
) {
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_DEV_USER_DATA_PATH: pairingRuntimeUserDataPath,
|
||||
ORCA_USER_DATA_PATH: pairingRuntimeUserDataPath
|
||||
}
|
||||
await registerWorktreeForPairingRuntime({ env }, fixture.root, {
|
||||
logStep: () => {},
|
||||
logSuccess: () => {},
|
||||
orca: async (args, options) => {
|
||||
const result = await runCli(orcaCli, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
env: options.env,
|
||||
timeout: options.timeout
|
||||
})
|
||||
return {
|
||||
stdout: String(result.stdout).trim(),
|
||||
stderr: String(result.stderr).trim()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createHostedIosAdversarialContentInspector({ emulator, fixture, timeoutMs }) {
|
||||
const observations = []
|
||||
return {
|
||||
async inspect({ document, phase }) {
|
||||
if (phase === 'sessionDiff') {
|
||||
const point = await readHostedWebViewTextPoint(document, fixture.filename)
|
||||
await tapHostedIosPoint(emulator, point)
|
||||
await delay(250)
|
||||
}
|
||||
observations.push(
|
||||
await captureHostedWebViewAdversarialObservation({
|
||||
document,
|
||||
expectedMarker: phase === 'sessionDiff' ? HOSTED_ADVERSARIAL_CONTENT_MARKER : undefined,
|
||||
timeoutMs: Math.min(timeoutMs, 15_000)
|
||||
})
|
||||
)
|
||||
},
|
||||
evidence() {
|
||||
return hostedWebViewAdversarialContentObservations(observations)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
export function startHostedIosMobileLauncher({
|
||||
deviceUdid,
|
||||
emulatorControlUserDataPath,
|
||||
orcaCli,
|
||||
runtimeDirectory,
|
||||
worktree
|
||||
}) {
|
||||
return spawn(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(worktree, 'mobile', 'scripts', 'start-emulator.mjs'),
|
||||
'--worktree',
|
||||
worktree,
|
||||
'--device',
|
||||
deviceUdid,
|
||||
'--wait-for-ready'
|
||||
],
|
||||
{
|
||||
cwd: worktree,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_CLI: orcaCli,
|
||||
ORCA_E2E_MOBILE_AUTO_SELECT_PAIRED_HOST: '1',
|
||||
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
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function waitForHostedIosMobileLauncher(child, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let outputTail = ''
|
||||
let settled = false
|
||||
const timer = setTimeout(() => {
|
||||
finish(new Error(`Mobile launcher timed out.\n${outputTail}`))
|
||||
}, timeoutMs)
|
||||
const consume = (chunk, target) => {
|
||||
const text = String(chunk)
|
||||
target.write(text)
|
||||
outputTail = (outputTail + text).slice(-32 * 1024)
|
||||
if (outputTail.includes('Setup complete!')) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
const finish = (error) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
child.off('exit', handleExit)
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
const handleExit = (code) => {
|
||||
finish(new Error(`Mobile launcher exited with code ${code}.\n${outputTail}`))
|
||||
}
|
||||
child.stdout.on('data', (chunk) => consume(chunk, process.stdout))
|
||||
child.stderr.on('data', (chunk) => consume(chunk, process.stderr))
|
||||
child.once('error', finish)
|
||||
child.once('exit', handleExit)
|
||||
})
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export async function verifyHostedSourceControlReviewJourney({
|
||||
sessionDocument,
|
||||
timeoutMs,
|
||||
expectedSessionDiffText = '2 tabs',
|
||||
inspectChangedContent,
|
||||
tapPoint = tapHostedJourneyPoint
|
||||
}) {
|
||||
const sourceControl = await journeyStep('wait for Source Control route', () =>
|
||||
@@ -48,6 +49,11 @@ export async function verifyHostedSourceControlReviewJourney({
|
||||
if (!changedFileLabel) {
|
||||
throw new Error('Source Control has no changed file available for Review.')
|
||||
}
|
||||
if (inspectChangedContent) {
|
||||
await journeyStep('inspect Source Control content', () =>
|
||||
inspectChangedContent({ phase: 'sourceControl', document: sourceControl })
|
||||
)
|
||||
}
|
||||
if (nativeBaselines) {
|
||||
sourceState = await journeyStep('wait for stable Source Control parity state', () =>
|
||||
waitForSourceControlParityState(sourceControl, timeoutMs, sourceState)
|
||||
@@ -77,6 +83,11 @@ export async function verifyHostedSourceControlReviewJourney({
|
||||
timeoutMs
|
||||
})
|
||||
)
|
||||
if (inspectChangedContent) {
|
||||
await journeyStep('inspect Session diff content', () =>
|
||||
inspectChangedContent({ phase: 'sessionDiff', document: sessionDiff })
|
||||
)
|
||||
}
|
||||
await journeyStep('open standalone Review route', () =>
|
||||
navigateHostedWebViewRoute(sessionDiff, standaloneReviewRoute(sourceState.href))
|
||||
)
|
||||
@@ -94,6 +105,11 @@ export async function verifyHostedSourceControlReviewJourney({
|
||||
throw new Error(`Review is missing ${label}.`)
|
||||
}
|
||||
}
|
||||
if (inspectChangedContent) {
|
||||
await journeyStep('inspect Review content', () =>
|
||||
inspectChangedContent({ phase: 'review', document: review })
|
||||
)
|
||||
}
|
||||
const hostedReview = nativeBaselines
|
||||
? await journeyStep('capture Review parity', () =>
|
||||
captureHostedSourceControlReviewScreen({
|
||||
@@ -150,7 +166,7 @@ async function openSourceControlRoute({
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const point = await readHostedWebViewControlPoint(sessionDocument, 'Open source control')
|
||||
await tapPoint(emulator, point, 'Open source control', attempt)
|
||||
await tapPoint(emulator, point, 'Open source control', attempt, sessionDocument)
|
||||
return await waitForVisibleHostedWebView({
|
||||
discoveryUrl,
|
||||
expectedText: 'Source Control',
|
||||
@@ -177,7 +193,7 @@ async function openSessionDiffRoute({
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const point = await readHostedWebViewControlPoint(sourceControl, label)
|
||||
await tapPoint(emulator, point, label, attempt)
|
||||
await tapPoint(emulator, point, label, attempt, sourceControl)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { WebSocket } from 'ws'
|
||||
import {
|
||||
HOSTED_ADVERSARIAL_CONTENT,
|
||||
HOSTED_ADVERSARIAL_CONTENT_MARKER,
|
||||
HOSTED_ADVERSARIAL_FILENAME,
|
||||
HOSTED_ADVERSARIAL_FILENAME_MARKER
|
||||
} from './hosted-adversarial-repository-fixture.mjs'
|
||||
import {
|
||||
evaluateHostedDocumentWithRetry,
|
||||
readHostedWebViewState
|
||||
} from './hosted-webview-cdp-session.mjs'
|
||||
|
||||
const executionExpression = `JSON.stringify({
|
||||
filenameExecuted: Boolean(globalThis.${HOSTED_ADVERSARIAL_FILENAME_MARKER}),
|
||||
contentExecuted: Boolean(globalThis.${HOSTED_ADVERSARIAL_CONTENT_MARKER}),
|
||||
injectedImageCount: [...document.images].filter((image) => image.getAttribute('src') === 'x').length
|
||||
})`
|
||||
|
||||
export async function verifyHostedWebViewAdversarialContent({
|
||||
document,
|
||||
documents,
|
||||
WebSocketCtor = WebSocket
|
||||
}) {
|
||||
const targets = documents ?? (document ? [document] : [])
|
||||
if (targets.length === 0) {
|
||||
throw new Error('Hosted adversarial content requires a document')
|
||||
}
|
||||
const observations = await Promise.all(
|
||||
targets.map((target) => readHostedWebViewAdversarialContent(target, WebSocketCtor))
|
||||
)
|
||||
return hostedWebViewAdversarialContentObservations(observations)
|
||||
}
|
||||
|
||||
export async function readHostedWebViewAdversarialContent(document, WebSocketCtor = WebSocket) {
|
||||
const [state, executionValue] = await Promise.all([
|
||||
readHostedWebViewState(document, WebSocketCtor),
|
||||
evaluateHostedDocumentWithRetry(document, executionExpression, WebSocketCtor)
|
||||
])
|
||||
return { state, execution: JSON.parse(executionValue) }
|
||||
}
|
||||
|
||||
export async function captureHostedWebViewAdversarialObservation({
|
||||
document,
|
||||
expectedMarker,
|
||||
timeoutMs,
|
||||
WebSocketCtor = WebSocket
|
||||
}) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let observation
|
||||
do {
|
||||
observation = await readHostedWebViewAdversarialContent(document, WebSocketCtor)
|
||||
const text = `${observation.state.bodyText}\n${observation.state.labels.join('\n')}`
|
||||
if (!expectedMarker || text.includes(expectedMarker)) {
|
||||
return observation
|
||||
}
|
||||
await delay(250)
|
||||
} while (Date.now() < deadline)
|
||||
throw new Error(`Hosted adversarial marker was not rendered: ${expectedMarker}`)
|
||||
}
|
||||
|
||||
export function hostedWebViewAdversarialContentObservations(observations) {
|
||||
for (const { execution } of observations) {
|
||||
if (
|
||||
execution?.filenameExecuted !== false ||
|
||||
execution?.contentExecuted !== false ||
|
||||
execution?.injectedImageCount !== 0
|
||||
) {
|
||||
throw new Error(`Hosted adversarial content executed: ${JSON.stringify(execution)}`)
|
||||
}
|
||||
}
|
||||
return hostedWebViewAdversarialContentEvidence({
|
||||
text: observations
|
||||
.map(({ state }) => `${state.bodyText}\n${state.labels.join('\n')}`)
|
||||
.join('\n'),
|
||||
execution: {
|
||||
filenameExecuted: false,
|
||||
contentExecuted: false,
|
||||
injectedImageCount: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function hostedWebViewAdversarialContentEvidence({ text, execution }) {
|
||||
const filenameRendered = text.includes(HOSTED_ADVERSARIAL_FILENAME)
|
||||
const diffRendered = text.includes(HOSTED_ADVERSARIAL_CONTENT)
|
||||
if (!filenameRendered || !diffRendered) {
|
||||
throw new Error(
|
||||
`Hosted adversarial content was not rendered: filename=${filenameRendered} diff=${diffRendered}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
execution?.filenameExecuted !== false ||
|
||||
execution?.contentExecuted !== false ||
|
||||
execution?.injectedImageCount !== 0
|
||||
) {
|
||||
throw new Error(`Hosted adversarial content executed: ${JSON.stringify(execution)}`)
|
||||
}
|
||||
return {
|
||||
filenameRenderedAsText: true,
|
||||
diffRenderedAsText: true,
|
||||
injectedImageCount: 0,
|
||||
scriptMarkersExecuted: false
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -236,7 +236,7 @@ export async function readHostedWebViewTextPoint(
|
||||
rect.left < innerWidth;
|
||||
};
|
||||
const matches = Array.from(document.querySelectorAll('body *')).filter((candidate) =>
|
||||
candidate.children.length === 0 && isVisible(candidate) && (
|
||||
candidate.children.length === 0 && (${options.reveal === true} || isVisible(candidate)) && (
|
||||
${
|
||||
options.ignoreCase === true
|
||||
? "String(candidate.textContent ?? '').trim().toLocaleLowerCase()"
|
||||
@@ -246,6 +246,9 @@ export async function readHostedWebViewTextPoint(
|
||||
);
|
||||
const element = matches[${options.occurrence ?? 0}];
|
||||
if (!element) return '';
|
||||
if (${options.reveal === true}) {
|
||||
element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const screenWidth = Number(screen.width);
|
||||
const screenHeight = Number(screen.height);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import process from 'node:process'
|
||||
|
||||
const usage =
|
||||
'Usage: node scripts/run-hosted-webview-simulator-e2e.mjs [--device <name|udid>] [--timeout-ms <ms>] [--expected-build <sha256>] [--accounts-only] [--security-only] [--isolation-only] [--clipboard-image-only] [--photos-revocation-only] [--files-preview-only] [--native-settings-only] [--source-control-only] [--skip-native-build] [--reuse-native-install]'
|
||||
'Usage: node scripts/run-hosted-webview-simulator-e2e.mjs [--device <name|udid>] [--timeout-ms <ms>] [--expected-build <sha256>] [--accounts-only] [--security-only] [--isolation-only] [--clipboard-image-only] [--photos-revocation-only] [--files-preview-only] [--native-settings-only] [--source-control-only] [--adversarial-content] [--skip-native-build] [--reuse-native-install]'
|
||||
|
||||
export function parseHostedWebViewSimulatorE2eOptions(args) {
|
||||
const parsed = {
|
||||
accountsOnly: false,
|
||||
adversarialContent: false,
|
||||
clipboardImageOnly: false,
|
||||
device: 'iPhone 17 Pro',
|
||||
expectedBuild: undefined,
|
||||
@@ -44,6 +45,8 @@ export function parseHostedWebViewSimulatorE2eOptions(args) {
|
||||
parsed.nativeSettingsOnly = true
|
||||
} else if (args[index] === '--source-control-only') {
|
||||
parsed.sourceControlOnly = true
|
||||
} else if (args[index] === '--adversarial-content') {
|
||||
parsed.adversarialContent = true
|
||||
} else if (args[index] === '--skip-native-build') {
|
||||
parsed.skipNativeBuild = true
|
||||
} else if (args[index] === '--reuse-native-install') {
|
||||
@@ -67,6 +70,7 @@ export function parseHostedWebViewSimulatorE2eOptions(args) {
|
||||
if (
|
||||
[
|
||||
parsed.accountsOnly,
|
||||
parsed.adversarialContent,
|
||||
parsed.clipboardImageOnly,
|
||||
parsed.isolationOnly,
|
||||
parsed.securityOnly,
|
||||
@@ -80,5 +84,6 @@ export function parseHostedWebViewSimulatorE2eOptions(args) {
|
||||
}
|
||||
parsed.securityOnly ||=
|
||||
parsed.clipboardImageOnly || parsed.isolationOnly || parsed.photosRevocationOnly
|
||||
parsed.sourceControlOnly ||= parsed.adversarialContent
|
||||
return parsed
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ export async function activateHostedWorkspaceRow(
|
||||
activeDocument = await resolveDocument()
|
||||
continue
|
||||
}
|
||||
if (isMissingControl(error) && resolveDocument) {
|
||||
const resolvedDocument = await resolveDocument()
|
||||
if (resolvedDocument.href !== activeDocument.href) {
|
||||
return
|
||||
}
|
||||
activeDocument = resolvedDocument
|
||||
}
|
||||
if (!isMissingControl(error)) {
|
||||
throw error
|
||||
}
|
||||
@@ -33,7 +40,8 @@ async function activateHostedWorkspaceRowOnce(document, workspaceName, activateC
|
||||
try {
|
||||
await activateControl(document, {
|
||||
kind: 'label',
|
||||
value: `Open ${workspaceName}`
|
||||
value: `Open ${workspaceName}`,
|
||||
reveal: true
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
@@ -46,7 +54,8 @@ async function activateHostedWorkspaceRowOnce(document, workspaceName, activateC
|
||||
kind: 'text',
|
||||
value: workspaceName,
|
||||
ignoreCase: true,
|
||||
occurrence: 1
|
||||
occurrence: 1,
|
||||
reveal: true
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isMissingControl(error)) {
|
||||
@@ -55,7 +64,8 @@ async function activateHostedWorkspaceRowOnce(document, workspaceName, activateC
|
||||
await activateControl(document, {
|
||||
kind: 'text',
|
||||
value: workspaceName,
|
||||
ignoreCase: true
|
||||
ignoreCase: true,
|
||||
reveal: true
|
||||
})
|
||||
await delay(250)
|
||||
try {
|
||||
@@ -63,7 +73,8 @@ async function activateHostedWorkspaceRowOnce(document, workspaceName, activateC
|
||||
kind: 'text',
|
||||
value: workspaceName,
|
||||
ignoreCase: true,
|
||||
occurrence: 1
|
||||
occurrence: 1,
|
||||
reveal: true
|
||||
})
|
||||
} catch (fallbackError) {
|
||||
if (isStaleDocument(fallbackError)) {
|
||||
|
||||
@@ -6,6 +6,11 @@ import process from 'node:process'
|
||||
import { promisify } from 'node:util'
|
||||
import { resolveEmulatorOrcaCli } from './emulator-orca-cli-selection.mjs'
|
||||
import { verifyHostedAndroidAgentHistoryJourney } from './hosted-android-agent-history-journey.mjs'
|
||||
import {
|
||||
createHostedAdversarialRepositoryFixture,
|
||||
HOSTED_ADVERSARIAL_CONTENT_MARKER,
|
||||
removeHostedAdversarialRepositoryFixture
|
||||
} from './hosted-adversarial-repository-fixture.mjs'
|
||||
import {
|
||||
readHostedAndroidExitInfo,
|
||||
verifyHostedAndroidPrivacyAudit
|
||||
@@ -38,6 +43,10 @@ import {
|
||||
} from './hosted-webview-cdp-session.mjs'
|
||||
import { verifyHostedWebViewExecutableIsolation } from './hosted-webview-executable-isolation.mjs'
|
||||
import { verifyHostedWebViewPrivacyIsolation } from './hosted-webview-privacy-isolation.mjs'
|
||||
import {
|
||||
captureHostedWebViewAdversarialObservation,
|
||||
hostedWebViewAdversarialContentObservations
|
||||
} from './hosted-webview-adversarial-content.mjs'
|
||||
import { resolveHostedWebViewRuntimeDirectory } from './hosted-webview-runtime-directory.mjs'
|
||||
import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs'
|
||||
import { verifyHostedSourceControlReviewJourney } from './hosted-ios-source-control-review-journey.mjs'
|
||||
@@ -72,6 +81,7 @@ async function main() {
|
||||
let probe
|
||||
let inspector
|
||||
let exitInfoBaseline
|
||||
let adversarialFixture
|
||||
const reversePorts = new Set()
|
||||
try {
|
||||
await stage('Android emulator', () => runAndroidAdb(adb, ['get-state']))
|
||||
@@ -82,6 +92,12 @@ async function main() {
|
||||
if (!options.skipNativeBuild) {
|
||||
await stage('Android debug app build', () => buildHostedAndroidDebugApp({ adb, androidDir }))
|
||||
}
|
||||
if (options.adversarialContent) {
|
||||
adversarialFixture = await stage(
|
||||
'adversarial repository fixture',
|
||||
createHostedAdversarialRepositoryFixture
|
||||
)
|
||||
}
|
||||
probe = await stage('network isolation sentinel', startHostedWebViewSecurityProbe)
|
||||
runtime = await stage('temporary paired desktop runtime', () =>
|
||||
startHeadlessPairingRuntime({
|
||||
@@ -94,9 +110,12 @@ async function main() {
|
||||
logSuccess: () => {}
|
||||
})
|
||||
)
|
||||
runtime.env.ORCA_E2E_MOBILE_AGENT_HISTORY_FIXTURE = '1'
|
||||
if (!options.adversarialContent) {
|
||||
runtime.env.ORCA_E2E_MOBILE_AGENT_HISTORY_FIXTURE = '1'
|
||||
}
|
||||
const testWorkspace = adversarialFixture?.root ?? worktree
|
||||
await stage('test workspace registration', () =>
|
||||
registerWorktreeForPairingRuntime(runtime, worktree, {
|
||||
registerWorktreeForPairingRuntime(runtime, testWorkspace, {
|
||||
orca: runOrca,
|
||||
logStep: () => {},
|
||||
logSuccess: () => {}
|
||||
@@ -134,7 +153,8 @@ async function main() {
|
||||
timeoutMs: options.timeoutMs
|
||||
})
|
||||
)
|
||||
const expectedWorkspace = path.basename(worktree)
|
||||
const expectedWorkspace = path.basename(testWorkspace)
|
||||
const workspaceRowName = adversarialFixture?.workspaceRowName ?? expectedWorkspace
|
||||
const workspaceDocument = await stage('hosted workspace data', () =>
|
||||
waitForVisibleHostedWebView({
|
||||
discoveryUrl,
|
||||
@@ -142,23 +162,20 @@ async function main() {
|
||||
timeoutMs: options.timeoutMs
|
||||
})
|
||||
)
|
||||
let privacyIsolation = null
|
||||
if (options.securityOnly) {
|
||||
privacyIsolation = await stage('workspace privacy isolation probe', () =>
|
||||
verifyHostedWebViewPrivacyIsolation({ document: workspaceDocument })
|
||||
)
|
||||
}
|
||||
const privacyIsolation = await stage('workspace privacy isolation probe', () =>
|
||||
verifyHostedWebViewPrivacyIsolation({ document: workspaceDocument })
|
||||
)
|
||||
await stage('workspace activation', async () => {
|
||||
try {
|
||||
await activateHostedWorkspaceRow(
|
||||
workspaceDocument,
|
||||
expectedWorkspace,
|
||||
workspaceRowName,
|
||||
(document, target) => activateAndroidWorkspaceControl(emulator, document, target),
|
||||
Math.min(options.timeoutMs, 15_000),
|
||||
() =>
|
||||
waitForVisibleHostedWebView({
|
||||
discoveryUrl,
|
||||
expectedText: 'Orca Desktop',
|
||||
expectedText: workspaceRowName,
|
||||
timeoutMs: options.timeoutMs
|
||||
})
|
||||
)
|
||||
@@ -194,29 +211,69 @@ async function main() {
|
||||
})
|
||||
)
|
||||
let agentHistory = null
|
||||
let adversarialContent = null
|
||||
const adversarialObservations = []
|
||||
let sourceControlReview = null
|
||||
let isolationDocument = sessionDocument
|
||||
if (!options.securityOnly) {
|
||||
const agentHistoryResult = await stage('Agent History journey', () =>
|
||||
verifyHostedAndroidAgentHistoryJourney({
|
||||
discoveryUrl,
|
||||
emulator,
|
||||
sessionDocument,
|
||||
timeoutMs: options.timeoutMs
|
||||
})
|
||||
)
|
||||
const { returnedSessionDocument, ...evidence } = agentHistoryResult
|
||||
agentHistory = evidence
|
||||
let sourceSessionDocument = sessionDocument
|
||||
if (!options.adversarialContent) {
|
||||
const agentHistoryResult = await stage('Agent History journey', () =>
|
||||
verifyHostedAndroidAgentHistoryJourney({
|
||||
discoveryUrl,
|
||||
emulator,
|
||||
sessionDocument,
|
||||
timeoutMs: options.timeoutMs
|
||||
})
|
||||
)
|
||||
const { returnedSessionDocument, ...evidence } = agentHistoryResult
|
||||
agentHistory = evidence
|
||||
sourceSessionDocument = returnedSessionDocument
|
||||
}
|
||||
sourceControlReview = await stage('Source Control and Review journey', () =>
|
||||
verifyHostedSourceControlReviewJourney({
|
||||
discoveryUrl,
|
||||
emulator,
|
||||
sessionDocument: returnedSessionDocument,
|
||||
expectedSessionDiffText: '3 tabs',
|
||||
sessionDocument: sourceSessionDocument,
|
||||
expectedSessionDiffText: options.adversarialContent ? '2 tabs' : '3 tabs',
|
||||
inspectChangedContent: options.adversarialContent
|
||||
? async ({ document, phase }) => {
|
||||
if (phase === 'sessionDiff') {
|
||||
await activateAndroidAdversarialDiffTab(
|
||||
emulator,
|
||||
document,
|
||||
adversarialFixture.filename
|
||||
)
|
||||
}
|
||||
adversarialObservations.push(
|
||||
await captureHostedWebViewAdversarialObservation({
|
||||
document,
|
||||
expectedMarker:
|
||||
phase === 'sessionDiff' ? HOSTED_ADVERSARIAL_CONTENT_MARKER : undefined,
|
||||
timeoutMs: Math.min(options.timeoutMs, 15_000)
|
||||
})
|
||||
)
|
||||
}
|
||||
: undefined,
|
||||
timeoutMs: options.timeoutMs,
|
||||
tapPoint: tapHostedAndroidJourneyControl
|
||||
})
|
||||
)
|
||||
if (options.adversarialContent) {
|
||||
adversarialContent = await stage('adversarial filename and diff presentation', () => {
|
||||
try {
|
||||
return hostedWebViewAdversarialContentObservations(adversarialObservations)
|
||||
} catch (error) {
|
||||
const states = adversarialObservations.map(({ state }) => ({
|
||||
bodyText: state.bodyText.slice(0, 1024),
|
||||
labels: state.labels.slice(0, 16)
|
||||
}))
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}. States ${JSON.stringify(states)}`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
isolationDocument = await waitForVisibleHostedWebView({
|
||||
discoveryUrl,
|
||||
expectedText: 'reviewed',
|
||||
@@ -243,9 +300,6 @@ async function main() {
|
||||
probeId: probe.token
|
||||
})
|
||||
)
|
||||
privacyIsolation ??= await stage('privacy isolation probe', () =>
|
||||
verifyHostedWebViewPrivacyIsolation({ document: isolationDocument })
|
||||
)
|
||||
await delay(500)
|
||||
if (probe.observations.length > 0) {
|
||||
throw new Error(
|
||||
@@ -269,6 +323,7 @@ async function main() {
|
||||
pid: inspector.pid,
|
||||
workspace: expectedWorkspace,
|
||||
agentHistory,
|
||||
adversarialContent,
|
||||
sourceControlReview,
|
||||
networkIsolation,
|
||||
navigationIsolation,
|
||||
@@ -292,6 +347,9 @@ async function main() {
|
||||
await metro?.stop()
|
||||
await runtime?.stop({ shutdownDaemon: true })
|
||||
await probe?.stop()
|
||||
if (adversarialFixture) {
|
||||
await removeHostedAdversarialRepositoryFixture(adversarialFixture)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +393,14 @@ async function activateAndroidWorkspaceControl(emulator, document, target) {
|
||||
if (target.kind !== 'text') {
|
||||
return activateHostedWebViewControl(document, target)
|
||||
}
|
||||
if (target.reveal) {
|
||||
await readHostedWebViewTextPoint(document, target.value, undefined, {
|
||||
ignoreCase: target.ignoreCase,
|
||||
occurrence: target.occurrence,
|
||||
reveal: true
|
||||
})
|
||||
await delay(250)
|
||||
}
|
||||
const point = await readHostedWebViewTextPoint(document, target.value, undefined, {
|
||||
ignoreCase: target.ignoreCase,
|
||||
occurrence: target.occurrence
|
||||
@@ -342,17 +408,26 @@ async function activateAndroidWorkspaceControl(emulator, document, target) {
|
||||
await tapHostedAndroidPoint(emulator, point)
|
||||
}
|
||||
|
||||
async function tapHostedAndroidJourneyControl(emulator, point, label) {
|
||||
if (label) {
|
||||
async function tapHostedAndroidJourneyControl(emulator, point, label, attempt = 0, document) {
|
||||
if (label && attempt === 0) {
|
||||
try {
|
||||
return await tapHostedAndroidAccessibilityControl(emulator, label, 5_000)
|
||||
} catch {
|
||||
// Chromium may omit a WebView descendant during an accessibility-tree refresh.
|
||||
}
|
||||
}
|
||||
if (label && attempt > 0 && document) {
|
||||
return activateHostedWebViewControl(document, { kind: 'label', value: label, reveal: true })
|
||||
}
|
||||
return tapHostedAndroidPoint(emulator, point)
|
||||
}
|
||||
|
||||
async function activateAndroidAdversarialDiffTab(emulator, document, filename) {
|
||||
const point = await readHostedWebViewTextPoint(document, filename)
|
||||
await tapHostedAndroidPoint(emulator, point)
|
||||
await delay(250)
|
||||
}
|
||||
|
||||
async function proveSentinelReachability(command, probe) {
|
||||
await runAndroidAdb(command, ['shell', 'nc', '-z', '-w', '5', '127.0.0.1', String(probe.port)])
|
||||
if (!probe.observations.includes('tcp:connection')) {
|
||||
@@ -388,6 +463,7 @@ function parseOptions(args) {
|
||||
const result = {
|
||||
adb: null,
|
||||
apk: defaultApk,
|
||||
adversarialContent: false,
|
||||
securityOnly: false,
|
||||
skipNativeBuild: false,
|
||||
timeoutMs: 90_000
|
||||
@@ -400,6 +476,8 @@ function parseOptions(args) {
|
||||
result.adb = requireValue(args, ++index, option)
|
||||
} else if (option === '--apk') {
|
||||
result.apk = path.resolve(requireValue(args, ++index, option))
|
||||
} else if (option === '--adversarial-content') {
|
||||
result.adversarialContent = true
|
||||
} else if (option === '--skip-native-build') {
|
||||
result.skipNativeBuild = true
|
||||
} else if (option === '--security-only') {
|
||||
@@ -413,6 +491,9 @@ function parseOptions(args) {
|
||||
if (!Number.isInteger(result.timeoutMs) || result.timeoutMs < 1_000) {
|
||||
throw new Error('--timeout-ms must be an integer of at least 1000')
|
||||
}
|
||||
if (result.adversarialContent && result.securityOnly) {
|
||||
throw new Error('--adversarial-content and --security-only are mutually exclusive')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import path from 'node:path'
|
||||
@@ -8,6 +8,10 @@ import process from 'node:process'
|
||||
import { promisify } from 'node:util'
|
||||
import { startCdpServer } from 'inspect-webkit'
|
||||
import { resolveEmulatorOrcaCli } from './emulator-orca-cli-selection.mjs'
|
||||
import {
|
||||
createHostedAdversarialRepositoryFixture,
|
||||
removeHostedAdversarialRepositoryFixture
|
||||
} from './hosted-adversarial-repository-fixture.mjs'
|
||||
import { stopHostedChildProcess } from './hosted-child-process-shutdown.mjs'
|
||||
import { parseHostedWebViewSimulatorE2eOptions } from './hosted-webview-simulator-e2e-options.mjs'
|
||||
import {
|
||||
@@ -24,6 +28,10 @@ import {
|
||||
import { verifyHostedWebViewExecutableIsolation } from './hosted-webview-executable-isolation.mjs'
|
||||
import { verifyHostedWebViewPrivacyIsolation } from './hosted-webview-privacy-isolation.mjs'
|
||||
import { captureNativeAgentHistoryBaseline } from './hosted-ios-agent-history-parity.mjs'
|
||||
import {
|
||||
createHostedIosAdversarialContentInspector,
|
||||
registerHostedIosAdversarialRepository
|
||||
} from './hosted-ios-adversarial-content.mjs'
|
||||
import {
|
||||
captureHostedCoreRouteParity,
|
||||
captureNativeCoreRouteBaselines
|
||||
@@ -38,6 +46,10 @@ import {
|
||||
} 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 {
|
||||
startHostedIosMobileLauncher,
|
||||
waitForHostedIosMobileLauncher
|
||||
} from './hosted-ios-mobile-launcher.mjs'
|
||||
import { verifyHostedNativeTerminalSettingsHandoff } from './hosted-ios-native-settings-handoff.mjs'
|
||||
import { verifyHostedSourceControlReviewJourney } from './hosted-ios-source-control-review-journey.mjs'
|
||||
import { captureNativeSourceControlReviewBaselines } from './hosted-ios-source-control-review-parity.mjs'
|
||||
@@ -56,7 +68,6 @@ import { hostedIosSimulatorAppPreparation } from './hosted-ios-simulator-app-pre
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const worktree = path.resolve(import.meta.dirname, '../..')
|
||||
const launcherPath = path.join(worktree, 'mobile', 'scripts', 'start-emulator.mjs')
|
||||
const options = parseHostedWebViewSimulatorE2eOptions(process.argv.slice(2))
|
||||
const runtimeDirectory = resolveHostedWebViewRuntimeDirectory({
|
||||
worktree,
|
||||
@@ -81,7 +92,12 @@ async function main() {
|
||||
let networkProbe = null
|
||||
let emulatorController = null
|
||||
let nativeAppPath = null
|
||||
let adversarialFixture = null
|
||||
let adversarialInspector = null
|
||||
try {
|
||||
if (options.adversarialContent) {
|
||||
adversarialFixture = await createHostedAdversarialRepositoryFixture()
|
||||
}
|
||||
networkProbe = await startHostedIosWebViewSecurityProbe()
|
||||
await bootSimulator(deviceUdid)
|
||||
emulatorController = await startHostedIosEmulatorController({
|
||||
@@ -97,8 +113,14 @@ async function main() {
|
||||
resetHostedIosPhotosPermission(deviceUdid)
|
||||
)
|
||||
}
|
||||
launcher = startMobileLauncher(deviceUdid, emulatorController.userData)
|
||||
await waitForLauncher(launcher, options.timeoutMs)
|
||||
launcher = startHostedIosMobileLauncher({
|
||||
deviceUdid,
|
||||
emulatorControlUserDataPath: emulatorController.userData,
|
||||
orcaCli: orcaSelection.command,
|
||||
runtimeDirectory,
|
||||
worktree
|
||||
})
|
||||
await waitForHostedIosMobileLauncher(launcher, options.timeoutMs)
|
||||
const emulator = {
|
||||
deviceUdid,
|
||||
orcaCli: orcaSelection.command,
|
||||
@@ -109,6 +131,20 @@ async function main() {
|
||||
const nativeOnboarding = await evidenceStep('native onboarding', () =>
|
||||
completeHostedIosNativeOnboarding(emulator, expectedWorkspace, options.timeoutMs)
|
||||
)
|
||||
if (adversarialFixture) {
|
||||
await evidenceStep('adversarial repository registration', () =>
|
||||
registerHostedIosAdversarialRepository({
|
||||
fixture: adversarialFixture,
|
||||
orcaCli: orcaSelection.command,
|
||||
pairingRuntimeUserDataPath: path.join(runtimeDirectory, 'paired-host', 'userData')
|
||||
})
|
||||
)
|
||||
adversarialInspector = createHostedIosAdversarialContentInspector({
|
||||
emulator,
|
||||
fixture: adversarialFixture,
|
||||
timeoutMs: options.timeoutMs
|
||||
})
|
||||
}
|
||||
const nativeWorkspace =
|
||||
options.securityOnly ||
|
||||
options.filesPreviewOnly ||
|
||||
@@ -140,6 +176,7 @@ async function main() {
|
||||
)
|
||||
const nativeCoreRoutes =
|
||||
options.accountsOnly ||
|
||||
options.adversarialContent ||
|
||||
options.securityOnly ||
|
||||
options.filesPreviewOnly ||
|
||||
options.nativeSettingsOnly ||
|
||||
@@ -171,6 +208,7 @@ async function main() {
|
||||
)
|
||||
const nativeSourceControlReview =
|
||||
options.accountsOnly ||
|
||||
options.adversarialContent ||
|
||||
options.securityOnly ||
|
||||
options.filesPreviewOnly ||
|
||||
options.nativeSettingsOnly
|
||||
@@ -211,6 +249,11 @@ async function main() {
|
||||
expectedText: 'Orca Desktop',
|
||||
timeoutMs: options.timeoutMs
|
||||
})
|
||||
const workspacePrivacyIsolation = options.adversarialContent
|
||||
? await evidenceStep('workspace privacy isolation probe', () =>
|
||||
verifyHostedWebViewPrivacyIsolation({ document: workspaceDocument })
|
||||
)
|
||||
: null
|
||||
const securityJourney = {
|
||||
deviceUdid,
|
||||
discoveryUrl,
|
||||
@@ -339,13 +382,13 @@ async function main() {
|
||||
if (options.sourceControlOnly) {
|
||||
await activateHostedWorkspaceRow(
|
||||
workspaceDocument,
|
||||
expectedWorkspace,
|
||||
adversarialFixture?.workspaceRowName ?? expectedWorkspace,
|
||||
activateHostedWebViewControl,
|
||||
options.timeoutMs,
|
||||
() =>
|
||||
waitForVisibleHostedWebView({
|
||||
discoveryUrl: `http://127.0.0.1:${inspectorPort}`,
|
||||
expectedText: 'Orca Desktop',
|
||||
expectedText: adversarialFixture?.workspaceRowName ?? 'Orca Desktop',
|
||||
timeoutMs: options.timeoutMs
|
||||
})
|
||||
)
|
||||
@@ -363,11 +406,17 @@ async function main() {
|
||||
emulator,
|
||||
expectedSessionDiffText: options.sourceControlOnly ? '2 tabs' : '3 tabs',
|
||||
nativeBaselines: nativeSourceControlReview,
|
||||
inspectChangedContent: adversarialInspector?.inspect,
|
||||
runtimeDirectory,
|
||||
sessionDocument,
|
||||
timeoutMs: options.timeoutMs
|
||||
})
|
||||
})
|
||||
const adversarialContent = adversarialInspector
|
||||
? await evidenceStep('adversarial filename and diff presentation', () =>
|
||||
adversarialInspector.evidence()
|
||||
)
|
||||
: null
|
||||
const securityDocument =
|
||||
options.securityOnly && terminalDeviceInput
|
||||
? (terminalDeviceInput.terminalClipboardImagePaste?.sessionDocument ??
|
||||
@@ -399,9 +448,11 @@ async function main() {
|
||||
probeId: networkProbe.token
|
||||
})
|
||||
)
|
||||
const privacyIsolation = await evidenceStep('privacy isolation probe', () =>
|
||||
verifyHostedWebViewPrivacyIsolation({ document: securityDocument })
|
||||
)
|
||||
const privacyIsolation =
|
||||
workspacePrivacyIsolation ??
|
||||
(await evidenceStep('privacy isolation probe', () =>
|
||||
verifyHostedWebViewPrivacyIsolation({ document: securityDocument })
|
||||
))
|
||||
await delay(500)
|
||||
if (networkProbe.observations.length > 0) {
|
||||
throw new Error(
|
||||
@@ -436,7 +487,8 @@ async function main() {
|
||||
agentHistory: historyEvidence,
|
||||
coreRouteParity: hostedCoreRoutes?.evidence ?? null,
|
||||
filesPreviewParity: hostedFilesPreview?.evidence ?? null,
|
||||
sourceControlReview
|
||||
sourceControlReview,
|
||||
adversarialContent
|
||||
},
|
||||
null,
|
||||
2
|
||||
@@ -445,6 +497,9 @@ async function main() {
|
||||
} finally {
|
||||
inspector?.stop()
|
||||
await stopHostedChildProcess(launcher)
|
||||
if (adversarialFixture) {
|
||||
await removeHostedAdversarialRepositoryFixture(adversarialFixture)
|
||||
}
|
||||
await emulatorController?.stop()
|
||||
await clearHostedIosWebViewSecurityProbe(deviceUdid)
|
||||
await networkProbe?.stop()
|
||||
@@ -516,64 +571,6 @@ async function bootSimulator(deviceUdid) {
|
||||
await execFileAsync('xcrun', ['simctl', 'bootstatus', deviceUdid, '-b'])
|
||||
}
|
||||
|
||||
function startMobileLauncher(deviceUdid, emulatorControlUserDataPath) {
|
||||
return spawn(
|
||||
process.execPath,
|
||||
[launcherPath, '--worktree', worktree, '--device', deviceUdid, '--wait-for-ready'],
|
||||
{
|
||||
cwd: worktree,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_CLI: orcaSelection.command,
|
||||
ORCA_E2E_MOBILE_AUTO_SELECT_PAIRED_HOST: '1',
|
||||
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
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function waitForLauncher(child, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let outputTail = ''
|
||||
let settled = false
|
||||
const timer = setTimeout(() => {
|
||||
finish(new Error(`Mobile launcher timed out.\n${outputTail}`))
|
||||
}, timeoutMs)
|
||||
const consume = (chunk, target) => {
|
||||
const text = String(chunk)
|
||||
target.write(text)
|
||||
outputTail = (outputTail + text).slice(-32 * 1024)
|
||||
if (outputTail.includes('Setup complete!')) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
const finish = (error) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
child.off('exit', handleExit)
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
const handleExit = (code) => {
|
||||
finish(new Error(`Mobile launcher exited with code ${code}.\n${outputTail}`))
|
||||
}
|
||||
child.stdout.on('data', (chunk) => consume(chunk, process.stdout))
|
||||
child.stderr.on('data', (chunk) => consume(chunk, process.stderr))
|
||||
child.once('error', finish)
|
||||
child.once('exit', handleExit)
|
||||
})
|
||||
}
|
||||
|
||||
function findAvailableLoopbackPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
|
||||
@@ -22,4 +22,15 @@ describe('hosted Android emulator session', () => {
|
||||
`)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores automation-process fatals while retaining app-process fatals', () => {
|
||||
const logcat = `
|
||||
E/AndroidRuntime(20196): FATAL EXCEPTION: UiAutomation
|
||||
E/AndroidRuntime(20200): FATAL EXCEPTION: main
|
||||
`
|
||||
|
||||
expect(findHostedAndroidBridgeLogFailures(logcat, '20200')).toEqual([
|
||||
' E/AndroidRuntime(20200): FATAL EXCEPTION: main'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,18 +50,20 @@ describe('hosted Android privacy audit', () => {
|
||||
[
|
||||
'ws://10.0.2.2:8081/message?device=Pixel%209%20-%2016%20-%20API%2036&app=com.stably.orca.mobile&clientid=BridgelessDevSupportManager',
|
||||
'ws://10.0.2.2:8081/message?device=Pixel%209%20-%2016%20-%20API%2036&app=com.stably.orca.mobile&clientid=DevLauncherBridgelessDevSupportManager',
|
||||
'ws://10.0.2.2:57999/message?device=Pixel%209&app=com.stably.orca.mobile&clientid=BridgelessDevSupportManager',
|
||||
'ws://127.0.0.1:53250/socket-probe'
|
||||
].join('\n'),
|
||||
{ devServerPort: 8081, probePort: 53250 }
|
||||
{ devServerPort: 57999, probePort: 53250 }
|
||||
)
|
||||
|
||||
expect(evidence.expectedDebugWebSocketUrls).toBe(3)
|
||||
expect(evidence.expectedDebugWebSocketUrls).toBe(4)
|
||||
expect(evidence.counts.webSocketUrl).toBe(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'ws://10.0.2.2:8081/message?device=Pixel&app=com.stably.orca.mobile&clientid=BridgelessDevSupportManager&token=secret',
|
||||
'ws://10.0.2.2:8081/message?device=Pixel&app=other&clientid=BridgelessDevSupportManager',
|
||||
'ws://10.0.2.2:8082/message?device=Pixel&app=com.stably.orca.mobile&clientid=BridgelessDevSupportManager',
|
||||
'wss://127.0.0.1:53250/socket-probe',
|
||||
'ws://127.0.0.1:53250/socket-probe?token=secret'
|
||||
])('rejects mutated debug WebSocket %s', (url) => {
|
||||
|
||||
@@ -122,6 +122,7 @@ describe('hosted iOS Source Control and Review journey', () => {
|
||||
|
||||
it('accepts a platform-specific native tap implementation', async () => {
|
||||
const tapPoint = vi.fn().mockResolvedValue(undefined)
|
||||
const inspectChangedContent = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await verifyHostedSourceControlReviewJourney({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
@@ -130,6 +131,7 @@ describe('hosted iOS Source Control and Review journey', () => {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
},
|
||||
expectedSessionDiffText: '3 tabs',
|
||||
inspectChangedContent,
|
||||
timeoutMs: 30_000,
|
||||
tapPoint
|
||||
})
|
||||
@@ -139,6 +141,11 @@ describe('hosted iOS Source Control and Review journey', () => {
|
||||
'Open source control',
|
||||
'Open changed file mobile/app/index.tsx'
|
||||
])
|
||||
expect(inspectChangedContent.mock.calls.map((call) => call[0].phase)).toEqual([
|
||||
'sourceControl',
|
||||
'sessionDiff',
|
||||
'review'
|
||||
])
|
||||
expect(mocks.waitForDocument).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ expectedText: '3 tabs' })
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createHostedAdversarialRepositoryFixture,
|
||||
HOSTED_ADVERSARIAL_CONTENT,
|
||||
HOSTED_ADVERSARIAL_CONTENT_MARKER,
|
||||
HOSTED_ADVERSARIAL_FILENAME,
|
||||
HOSTED_ADVERSARIAL_FILENAME_MARKER,
|
||||
HOSTED_ADVERSARIAL_WORKSPACE_ROW,
|
||||
readHostedAdversarialRepositoryContent,
|
||||
removeHostedAdversarialRepositoryFixture
|
||||
} from '../../scripts/hosted-adversarial-repository-fixture.mjs'
|
||||
import {
|
||||
hostedWebViewAdversarialContentEvidence,
|
||||
hostedWebViewAdversarialContentObservations,
|
||||
verifyHostedWebViewAdversarialContent
|
||||
} from '../../scripts/hosted-webview-adversarial-content.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const fixtures: Array<Awaited<ReturnType<typeof createHostedAdversarialRepositoryFixture>>> = []
|
||||
const androidHarnessSource = readFileSync(
|
||||
new URL('../../scripts/run-hosted-android-source-control-review-e2e.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const androidHarnessPath = fileURLToPath(
|
||||
new URL('../../scripts/run-hosted-android-source-control-review-e2e.mjs', import.meta.url)
|
||||
)
|
||||
const iosHarnessSource = readFileSync(
|
||||
new URL('../../scripts/run-hosted-webview-simulator-e2e.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(fixtures.splice(0).map(removeHostedAdversarialRepositoryFixture))
|
||||
})
|
||||
|
||||
describe('hosted WebView adversarial content', () => {
|
||||
it('creates one disposable hostile filename and diff', async () => {
|
||||
const fixture = await createHostedAdversarialRepositoryFixture()
|
||||
fixtures.push(fixture)
|
||||
|
||||
const status = await execFileAsync('git', ['status', '--short'], {
|
||||
cwd: fixture.root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
expect(status.stdout).toContain(HOSTED_ADVERSARIAL_FILENAME_MARKER)
|
||||
const diff = await execFileAsync('git', ['diff', '--'], {
|
||||
cwd: fixture.root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
expect(diff.stdout).toContain(HOSTED_ADVERSARIAL_CONTENT_MARKER)
|
||||
expect(await readHostedAdversarialRepositoryContent(fixture)).toBe(
|
||||
`${HOSTED_ADVERSARIAL_CONTENT}\n`
|
||||
)
|
||||
const branch = await execFileAsync('git', ['symbolic-ref', '--short', 'HEAD'], {
|
||||
cwd: fixture.root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
expect(branch.stdout.trim()).toBe(HOSTED_ADVERSARIAL_WORKSPACE_ROW)
|
||||
expect(fixture.workspaceRowName).toBe(HOSTED_ADVERSARIAL_WORKSPACE_ROW)
|
||||
})
|
||||
|
||||
it('accepts literal markers without created elements or execution', () => {
|
||||
expect(
|
||||
hostedWebViewAdversarialContentEvidence({
|
||||
text: `${HOSTED_ADVERSARIAL_FILENAME}\n${HOSTED_ADVERSARIAL_CONTENT}`,
|
||||
execution: {
|
||||
filenameExecuted: false,
|
||||
contentExecuted: false,
|
||||
injectedImageCount: 0
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
filenameRenderedAsText: true,
|
||||
diffRenderedAsText: true,
|
||||
injectedImageCount: 0,
|
||||
scriptMarkersExecuted: false
|
||||
})
|
||||
})
|
||||
|
||||
it('aggregates literal markers across route documents', async () => {
|
||||
const values = [
|
||||
JSON.stringify({
|
||||
href: 'https://orca-mobile-web.invalid/source-control',
|
||||
bodyText: HOSTED_ADVERSARIAL_FILENAME,
|
||||
labels: [],
|
||||
placeholders: []
|
||||
}),
|
||||
JSON.stringify({
|
||||
filenameExecuted: false,
|
||||
contentExecuted: false,
|
||||
injectedImageCount: 0
|
||||
}),
|
||||
JSON.stringify({
|
||||
href: 'https://orca-mobile-web.invalid/session',
|
||||
bodyText: HOSTED_ADVERSARIAL_CONTENT,
|
||||
labels: [],
|
||||
placeholders: []
|
||||
}),
|
||||
JSON.stringify({
|
||||
filenameExecuted: false,
|
||||
contentExecuted: false,
|
||||
injectedImageCount: 0
|
||||
})
|
||||
]
|
||||
class FakeWebSocket {
|
||||
private message: ((data: Buffer) => void) | undefined
|
||||
|
||||
once(event: string, listener: () => void) {
|
||||
if (event === 'open') {
|
||||
queueMicrotask(listener)
|
||||
}
|
||||
}
|
||||
|
||||
on(event: string, listener: (data: Buffer) => void) {
|
||||
if (event === 'message') {
|
||||
this.message = listener
|
||||
}
|
||||
}
|
||||
|
||||
send(payload: string) {
|
||||
const id = JSON.parse(payload).id
|
||||
const value = values.shift()
|
||||
queueMicrotask(() => {
|
||||
this.message?.(
|
||||
Buffer.from(JSON.stringify({ id, result: { result: { value: value ?? '' } } }))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
close() {}
|
||||
}
|
||||
|
||||
await expect(
|
||||
verifyHostedWebViewAdversarialContent({
|
||||
documents: [
|
||||
{ webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/source' },
|
||||
{ webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/session' }
|
||||
],
|
||||
WebSocketCtor: FakeWebSocket as never
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
filenameRenderedAsText: true,
|
||||
diffRenderedAsText: true,
|
||||
scriptMarkersExecuted: false
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects missing literal markers and executed content', () => {
|
||||
expect(() =>
|
||||
hostedWebViewAdversarialContentEvidence({
|
||||
text: HOSTED_ADVERSARIAL_FILENAME,
|
||||
execution: {
|
||||
filenameExecuted: false,
|
||||
contentExecuted: false,
|
||||
injectedImageCount: 0
|
||||
}
|
||||
})
|
||||
).toThrow('was not rendered')
|
||||
expect(() =>
|
||||
hostedWebViewAdversarialContentEvidence({
|
||||
text: `${HOSTED_ADVERSARIAL_FILENAME}\n${HOSTED_ADVERSARIAL_CONTENT}`,
|
||||
execution: {
|
||||
filenameExecuted: false,
|
||||
contentExecuted: true,
|
||||
injectedImageCount: 1
|
||||
}
|
||||
})
|
||||
).toThrow('content executed')
|
||||
})
|
||||
|
||||
it('rejects malformed execution observations without coercion', () => {
|
||||
expect(() =>
|
||||
hostedWebViewAdversarialContentObservations([
|
||||
{
|
||||
state: {
|
||||
bodyText: `${HOSTED_ADVERSARIAL_FILENAME}\n${HOSTED_ADVERSARIAL_CONTENT}`,
|
||||
labels: []
|
||||
},
|
||||
execution: {
|
||||
filenameExecuted: 'false',
|
||||
contentExecuted: false,
|
||||
injectedImageCount: 0
|
||||
}
|
||||
}
|
||||
])
|
||||
).toThrow('content executed')
|
||||
})
|
||||
|
||||
it('keeps the disposable corpus wired into the exact Android route gate', () => {
|
||||
expect(androidHarnessSource).toContain('createHostedAdversarialRepositoryFixture')
|
||||
expect(androidHarnessSource).toContain('captureHostedWebViewAdversarialObservation')
|
||||
expect(androidHarnessSource).toContain('hostedWebViewAdversarialContentObservations')
|
||||
expect(androidHarnessSource).toContain('removeHostedAdversarialRepositoryFixture')
|
||||
})
|
||||
|
||||
it('rejects conflicting Android security journeys before device access', async () => {
|
||||
await expect(
|
||||
execFileAsync(process.execPath, [
|
||||
androidHarnessPath,
|
||||
'--adversarial-content',
|
||||
'--security-only'
|
||||
])
|
||||
).rejects.toMatchObject({
|
||||
stderr: expect.stringContaining('mutually exclusive')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the same disposable corpus wired into the exact iOS route gate', () => {
|
||||
expect(iosHarnessSource).toContain('registerHostedIosAdversarialRepository')
|
||||
expect(iosHarnessSource).toContain('createHostedIosAdversarialContentInspector')
|
||||
expect(iosHarnessSource).toContain('removeHostedAdversarialRepositoryFixture')
|
||||
})
|
||||
})
|
||||
@@ -296,13 +296,16 @@ describe('hosted WebView CDP target selection', () => {
|
||||
{ webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current' },
|
||||
'Agent Session History',
|
||||
fakeCdpConstructor(socket),
|
||||
{ ignoreCase: true, occurrence: 1 }
|
||||
{ ignoreCase: true, occurrence: 1, reveal: true }
|
||||
)
|
||||
).resolves.toEqual({ x: 0.25, y: 0.125 })
|
||||
expect(socket.evaluations[0]?.params.expression).toContain('getBoundingClientRect')
|
||||
expect(socket.evaluations[0]?.params.expression).toContain("style.visibility !== 'hidden'")
|
||||
expect(socket.evaluations[0]?.params.expression).toContain('toLocaleLowerCase')
|
||||
expect(socket.evaluations[0]?.params.expression).toContain('matches[1]')
|
||||
expect(socket.evaluations[0]?.params.expression).toContain(
|
||||
"scrollIntoView({ block: 'nearest', inline: 'nearest' })"
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects incomplete adversarial navigation evidence', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ describe('hosted WebView simulator E2E options', () => {
|
||||
it('retains bounded defaults', () => {
|
||||
expect(parseHostedWebViewSimulatorE2eOptions([])).toEqual({
|
||||
accountsOnly: false,
|
||||
adversarialContent: false,
|
||||
clipboardImageOnly: false,
|
||||
device: 'iPhone 17 Pro',
|
||||
expectedBuild: undefined,
|
||||
@@ -80,6 +81,13 @@ describe('hosted WebView simulator E2E options', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('maps adversarial content onto the focused Source Control journey', () => {
|
||||
expect(parseHostedWebViewSimulatorE2eOptions(['--adversarial-content'])).toMatchObject({
|
||||
adversarialContent: true,
|
||||
sourceControlOnly: true
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects mutually exclusive focused journeys', () => {
|
||||
expect(() =>
|
||||
parseHostedWebViewSimulatorE2eOptions(['--accounts-only', '--source-control-only'])
|
||||
|
||||
@@ -11,11 +11,12 @@ describe('hosted WebView workspace activation', () => {
|
||||
expect(activate).toHaveBeenCalledOnce()
|
||||
expect(activate).toHaveBeenCalledWith(document, {
|
||||
kind: 'label',
|
||||
value: 'Open mobile-rearch'
|
||||
value: 'Open mobile-rearch',
|
||||
reveal: true
|
||||
})
|
||||
})
|
||||
|
||||
it('retains grouped-list text expansion for older row markup', async () => {
|
||||
it('reveals a grouped text row when accessible row labels are absent', async () => {
|
||||
const missing = new Error('Hosted WebView control was not found: mobile-rearch')
|
||||
const activate = vi
|
||||
.fn()
|
||||
@@ -33,7 +34,8 @@ describe('hosted WebView workspace activation', () => {
|
||||
kind: 'text',
|
||||
value: 'mobile-rearch',
|
||||
ignoreCase: true,
|
||||
occurrence: 1
|
||||
occurrence: 1,
|
||||
reveal: true
|
||||
}
|
||||
)
|
||||
expect(activate).toHaveBeenNthCalledWith(
|
||||
@@ -42,7 +44,8 @@ describe('hosted WebView workspace activation', () => {
|
||||
{
|
||||
kind: 'text',
|
||||
value: 'mobile-rearch',
|
||||
ignoreCase: true
|
||||
ignoreCase: true,
|
||||
reveal: true
|
||||
}
|
||||
)
|
||||
expect(activate).toHaveBeenNthCalledWith(
|
||||
@@ -52,7 +55,8 @@ describe('hosted WebView workspace activation', () => {
|
||||
kind: 'text',
|
||||
value: 'mobile-rearch',
|
||||
ignoreCase: true,
|
||||
occurrence: 1
|
||||
occurrence: 1,
|
||||
reveal: true
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -74,7 +78,8 @@ describe('hosted WebView workspace activation', () => {
|
||||
expect(resolveDocument).toHaveBeenCalledOnce()
|
||||
expect(activate).toHaveBeenLastCalledWith(replacement, {
|
||||
kind: 'label',
|
||||
value: 'Open mobile-rearch'
|
||||
value: 'Open mobile-rearch',
|
||||
reveal: true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,7 +100,8 @@ describe('hosted WebView workspace activation', () => {
|
||||
expect(resolveDocument).toHaveBeenCalledOnce()
|
||||
expect(activate).toHaveBeenLastCalledWith(replacement, {
|
||||
kind: 'label',
|
||||
value: 'Open mobile-rearch'
|
||||
value: 'Open mobile-rearch',
|
||||
reveal: true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -113,10 +119,10 @@ describe('hosted WebView workspace activation', () => {
|
||||
await activateHostedWorkspaceRow({}, 'mobile-rearch', activate, 1_000)
|
||||
|
||||
expect(activate).toHaveBeenCalledTimes(6)
|
||||
expect(activate).toHaveBeenLastCalledWith({}, expect.objectContaining({ occurrence: 1 }))
|
||||
expect(activate).toHaveBeenLastCalledWith({}, expect.objectContaining({ reveal: true }))
|
||||
})
|
||||
|
||||
it('accepts navigation after activating the only text row', async () => {
|
||||
it('accepts activation of the only matching text row', async () => {
|
||||
const missing = new Error('Hosted WebView control was not found: mobile-rearch')
|
||||
const stale = new Error('WebKit CDP connection closed')
|
||||
const activate = vi
|
||||
@@ -130,4 +136,27 @@ describe('hosted WebView workspace activation', () => {
|
||||
|
||||
expect(activate).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('accepts an SPA route transition after the activated row disappears', async () => {
|
||||
const missing = new Error('Hosted WebView control was not found: mobile-rearch')
|
||||
const activate = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(missing)
|
||||
.mockRejectedValueOnce(missing)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(missing)
|
||||
const resolveDocument = vi.fn().mockResolvedValue({
|
||||
href: 'https://orca-mobile-web.invalid/h/host/session/workspace'
|
||||
})
|
||||
|
||||
await activateHostedWorkspaceRow(
|
||||
{ href: 'https://orca-mobile-web.invalid/h/host' },
|
||||
'mobile-rearch',
|
||||
activate,
|
||||
1_000,
|
||||
resolveDocument
|
||||
)
|
||||
|
||||
expect(resolveDocument).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user