mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge branch 'ota-w-testprune' of /tmp/orca-fix/testprune into ota-wave-integrate
# Conflicts: # mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts
This commit is contained in:
@@ -141,7 +141,6 @@ inspectable APK again.
|
||||
## Gates
|
||||
|
||||
```bash
|
||||
pnpm test mobile/src/mobile-web/hosted-webview-cdp-session.test.ts
|
||||
pnpm test mobile/src/mobile-web/hosted-android-inspectable-release.test.ts
|
||||
cd mobile && npx tsc --noEmit -p .
|
||||
pnpm run check:code-quality:changed
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hostedAdversarialGitHubResponse } from '../../scripts/hosted-adversarial-github-cli.mjs'
|
||||
import {
|
||||
createHostedAdversarialProviderFixture,
|
||||
HOSTED_ADVERSARIAL_MERMAID_MARKER,
|
||||
HOSTED_ADVERSARIAL_PROVIDER_ERROR_MARKER,
|
||||
HOSTED_ADVERSARIAL_PROVIDER_MARKER,
|
||||
HOSTED_ADVERSARIAL_PROVIDER_TITLE_MARKER,
|
||||
removeHostedAdversarialProviderFixture
|
||||
} from '../../scripts/hosted-adversarial-provider-fixture.mjs'
|
||||
import {
|
||||
createHostedAdversarialRepositoryFixture,
|
||||
removeHostedAdversarialRepositoryFixture
|
||||
} from '../../scripts/hosted-adversarial-repository-fixture.mjs'
|
||||
|
||||
const config = {
|
||||
baseOid: 'a'.repeat(40),
|
||||
branch: 'orca-adversarial-row',
|
||||
body: 'provider body',
|
||||
comment: `${HOSTED_ADVERSARIAL_MERMAID_MARKER}\n\n\`\`\`mermaid\ngraph TD\nStart --> Done\n\`\`\``,
|
||||
error: `${HOSTED_ADVERSARIAL_PROVIDER_ERROR_MARKER}: hostile`,
|
||||
headOid: 'b'.repeat(40),
|
||||
title: `${HOSTED_ADVERSARIAL_PROVIDER_TITLE_MARKER}: hostile`,
|
||||
updatedAt: '2026-07-29T00:00:00Z'
|
||||
}
|
||||
const androidHarnessSource = readFileSync(
|
||||
new URL('../../scripts/run-hosted-android-source-control-review-e2e.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
describe('hosted adversarial provider fixture', () => {
|
||||
it('returns one hostile task while another repository error stays bounded', async () => {
|
||||
const tasks = await hostedAdversarialGitHubResponse(
|
||||
['api', 'search/issues?q=repo%3Aorca-e2e%2Fadversarial'],
|
||||
config
|
||||
)
|
||||
const issues = await hostedAdversarialGitHubResponse(
|
||||
['api', 'search/issues?q=repo%3Astablyai%2Forca'],
|
||||
config
|
||||
)
|
||||
|
||||
expect(tasks.code).toBe(0)
|
||||
expect(JSON.parse(tasks.stdout)).toMatchObject([
|
||||
{
|
||||
number: 17,
|
||||
title: expect.stringContaining(HOSTED_ADVERSARIAL_PROVIDER_TITLE_MARKER)
|
||||
}
|
||||
])
|
||||
expect(issues).toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining(HOSTED_ADVERSARIAL_PROVIDER_ERROR_MARKER)
|
||||
})
|
||||
expect(issues.stderr.length).toBeLessThanOrEqual(1_000)
|
||||
})
|
||||
|
||||
it('registers success and error repositories in the Android runtime', () => {
|
||||
expect(androidHarnessSource).toContain("stage('provider error workspace registration'")
|
||||
expect(androidHarnessSource).toContain('registerWorktreeForPairingRuntime(runtime, worktree, {')
|
||||
expect(androidHarnessSource).toContain(
|
||||
'registerWorktreeForPairingRuntime(runtime, testWorkspace, {'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a provider comment with the Mermaid corpus', async () => {
|
||||
const response = await hostedAdversarialGitHubResponse(
|
||||
[
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
'query=query { repository { pullRequest { reviewThreads(first: 100) { nodes { id } } } } }'
|
||||
],
|
||||
config
|
||||
)
|
||||
const parsed = JSON.parse(response.stdout)
|
||||
|
||||
expect(parsed.data.repository.pullRequest.comments.nodes[0].body).toContain(
|
||||
HOSTED_ADVERSARIAL_MERMAID_MARKER
|
||||
)
|
||||
expect(parsed.data.repository.pullRequest.comments.nodes[0].body).toContain('```mermaid')
|
||||
})
|
||||
|
||||
it('installs an isolated cross-platform gh shim and repository remote', async () => {
|
||||
const repository = await createHostedAdversarialRepositoryFixture()
|
||||
let fixture
|
||||
try {
|
||||
fixture = await createHostedAdversarialProviderFixture({
|
||||
probePort: 43210,
|
||||
repositoryRoot: repository.root
|
||||
})
|
||||
const fixtureConfig = JSON.parse(await readFile(fixture.configPath, 'utf8'))
|
||||
|
||||
expect(fixture.environment.PATH.split(path.delimiter)[0]).toContain(fixture.root)
|
||||
expect(fixtureConfig.body).toContain(`\`${HOSTED_ADVERSARIAL_PROVIDER_MARKER}\``)
|
||||
expect(fixtureConfig.comment).toContain(`\`${HOSTED_ADVERSARIAL_MERMAID_MARKER}\``)
|
||||
expect(fixtureConfig.comment).toContain('http://127.0.0.1:43210/')
|
||||
expect(fixtureConfig.comment).toContain(
|
||||
'Start --> Parse --> Sanitize --> Render --> Verify --> Done'
|
||||
)
|
||||
expect(await readFile(path.join(fixture.root, 'bin', 'gh.cmd'), 'utf8')).toContain(
|
||||
'hosted-adversarial-github-cli.mjs'
|
||||
)
|
||||
} finally {
|
||||
if (fixture) {
|
||||
await removeHostedAdversarialProviderFixture(fixture, repository.root)
|
||||
}
|
||||
await removeHostedAdversarialRepositoryFixture(repository)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,299 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
HOSTED_TERMINAL_HTTP_LINK_LABEL,
|
||||
HOSTED_TERMINAL_JAVASCRIPT_LINK_LABEL,
|
||||
HOSTED_TERMINAL_FILE_LINK_LABEL,
|
||||
hostedAdversarialTerminalLinkCommand,
|
||||
hostedAdversarialTerminalLinkSafetyEvidence,
|
||||
stageHostedAdversarialTerminalLinksWithInput,
|
||||
verifyHostedAdversarialTerminalLinks
|
||||
} from '../../scripts/hosted-adversarial-terminal-links.mjs'
|
||||
|
||||
const androidHarnessSource = readFileSync(
|
||||
new URL('../../scripts/run-hosted-android-source-control-review-e2e.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const androidTerminalHarnessSource = readFileSync(
|
||||
new URL('../../scripts/hosted-android-adversarial-terminal-links.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const iosHarnessSource = readFileSync(
|
||||
new URL('../../scripts/run-hosted-webview-simulator-e2e.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
describe('hosted adversarial terminal links', () => {
|
||||
it('emits bounded OSC-8 HTTP and javascript rows through a portable Node command', () => {
|
||||
const command = hostedAdversarialTerminalLinkCommand(43210, 'PROBE-TOKEN', '001-adversarial.md')
|
||||
const encoded = command.match(/Buffer\.from\('([^']+)'/)?.[1]
|
||||
|
||||
expect(command).not.toContain('javascript:')
|
||||
expect(encoded).toBeTruthy()
|
||||
const output = Buffer.from(encoded ?? '', 'base64').toString()
|
||||
expect(output).toContain('\u001B]8;;http://127.0.0.1:43210/terminal-link/PROBE-TOKEN\u001B\\')
|
||||
expect(output).toContain(
|
||||
"\u001B]8;;javascript:globalThis.__ORCA_HOSTED_TERMINAL_LINK_EXECUTED__='executed'\u001B\\"
|
||||
)
|
||||
expect(output).toContain('\u001B]8;;001-adversarial.md:1\u001B\\')
|
||||
expect(output.match(new RegExp(HOSTED_TERMINAL_HTTP_LINK_LABEL, 'gu'))).toHaveLength(2)
|
||||
expect(output.match(new RegExp(HOSTED_TERMINAL_JAVASCRIPT_LINK_LABEL, 'gu'))).toHaveLength(2)
|
||||
expect(output.match(new RegExp(HOSTED_TERMINAL_FILE_LINK_LABEL, 'gu'))).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('proves the allowed file tap before rejecting a native javascript tap', async () => {
|
||||
const initialDocument = {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
}
|
||||
const resumedDocument = {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace',
|
||||
targetId: 'resumed'
|
||||
}
|
||||
const filePoint = { x: 0.2, y: 0.7 }
|
||||
const httpPoint = { x: 0.2, y: 0.6 }
|
||||
const javascriptPoint = { x: 0.2, y: 0.8 }
|
||||
const probe = {
|
||||
observations: [],
|
||||
port: 43210,
|
||||
reset() {
|
||||
this.observations.splice(0)
|
||||
},
|
||||
token: 'PROBE-TOKEN'
|
||||
}
|
||||
const tapPoint = vi.fn().mockResolvedValue(undefined)
|
||||
const waitForDocument = vi.fn().mockResolvedValue(resumedDocument)
|
||||
const writeLinks = vi.fn().mockResolvedValue('terminal-handle')
|
||||
|
||||
await expect(
|
||||
verifyHostedAdversarialTerminalLinks(
|
||||
{
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
document: initialDocument,
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
orcaCli: './config/scripts/orca-dev.mjs',
|
||||
pairingRuntimeUserDataPath: '/tmp/pairing/userData',
|
||||
positiveFilePath: '001-adversarial.md',
|
||||
probe,
|
||||
tapPoint,
|
||||
terminalHandle: 'terminal-handle',
|
||||
timeoutMs: 30_000,
|
||||
worktree: '/repo/mobile-rearch'
|
||||
},
|
||||
{
|
||||
activateTerminal: vi.fn().mockResolvedValue(undefined),
|
||||
enableDiagnostics: vi.fn().mockResolvedValue(undefined),
|
||||
readPoints: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
file: filePoint,
|
||||
http: httpPoint,
|
||||
javascript: javascriptPoint
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
file: filePoint,
|
||||
http: httpPoint,
|
||||
javascript: javascriptPoint
|
||||
}),
|
||||
readState: vi.fn().mockResolvedValue({
|
||||
href: resumedDocument.href,
|
||||
javascriptMarkerPresent: false,
|
||||
javascriptMarkerValue: null
|
||||
}),
|
||||
settle: vi.fn().mockResolvedValue(undefined),
|
||||
waitForDocument,
|
||||
writeLinks
|
||||
}
|
||||
)
|
||||
).resolves.toEqual({
|
||||
evidence: {
|
||||
fileLinkOpenedAfterNativeTap: true,
|
||||
httpLinkRequestedWithoutTap: false,
|
||||
javascriptLinkEscaped: false,
|
||||
javascriptLinkExecuted: false,
|
||||
javascriptTapPoint: javascriptPoint,
|
||||
routeRetained: true,
|
||||
terminalHandle: 'terminal-handle'
|
||||
},
|
||||
sessionDocument: resumedDocument
|
||||
})
|
||||
|
||||
expect(tapPoint).toHaveBeenNthCalledWith(1, { deviceUdid: 'simulator' }, filePoint)
|
||||
expect(tapPoint).toHaveBeenNthCalledWith(2, { deviceUdid: 'simulator' }, javascriptPoint)
|
||||
expect(writeLinks).toHaveBeenCalledWith({
|
||||
orcaCli: './config/scripts/orca-dev.mjs',
|
||||
pairingRuntimeUserDataPath: '/tmp/pairing/userData',
|
||||
positiveFilePath: '001-adversarial.md',
|
||||
probePort: 43210,
|
||||
probeToken: 'PROBE-TOKEN',
|
||||
terminalHandle: 'terminal-handle',
|
||||
timeoutMs: 30_000,
|
||||
worktree: '/repo/mobile-rearch'
|
||||
})
|
||||
expect(waitForDocument).toHaveBeenCalledWith({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
expectedHrefIncludes: '/files/preview/',
|
||||
expectedText: '001-adversarial.md',
|
||||
requireInteractiveControls: false,
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
expect(probe.observations).toEqual([])
|
||||
})
|
||||
|
||||
it('fails closed on execution, navigation, or escaped network traffic', () => {
|
||||
const safeState = {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace',
|
||||
javascriptMarkerPresent: false,
|
||||
javascriptMarkerValue: null
|
||||
}
|
||||
expect(
|
||||
hostedAdversarialTerminalLinkSafetyEvidence({
|
||||
observations: [],
|
||||
state: safeState
|
||||
})
|
||||
).toEqual({
|
||||
javascriptLinkEscaped: false,
|
||||
javascriptLinkExecuted: false,
|
||||
routeRetained: true
|
||||
})
|
||||
expect(() =>
|
||||
hostedAdversarialTerminalLinkSafetyEvidence({
|
||||
observations: [],
|
||||
state: {
|
||||
...safeState,
|
||||
javascriptMarkerPresent: true,
|
||||
javascriptMarkerValue: 'executed'
|
||||
}
|
||||
})
|
||||
).toThrow('javascript link executed')
|
||||
expect(() =>
|
||||
hostedAdversarialTerminalLinkSafetyEvidence({
|
||||
observations: ['http:/terminal-link/PROBE-TOKEN'],
|
||||
state: safeState
|
||||
})
|
||||
).toThrow('javascript link escaped')
|
||||
})
|
||||
|
||||
it('retries a missed native file-link activation before testing javascript', async () => {
|
||||
const document = { href: 'orca-mobile-web://build/h/host/session/workspace' }
|
||||
const tapPoint = vi.fn().mockResolvedValue(undefined)
|
||||
const waitForDocument = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('missed native touch'))
|
||||
.mockResolvedValue(document)
|
||||
const readPoints = vi.fn().mockResolvedValue({
|
||||
file: { x: 0.2, y: 0.7 },
|
||||
http: { x: 0.2, y: 0.6 },
|
||||
javascript: { x: 0.2, y: 0.8 }
|
||||
})
|
||||
|
||||
await verifyHostedAdversarialTerminalLinks(
|
||||
{
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
document,
|
||||
emulator: {},
|
||||
positiveFilePath: '001-adversarial.md',
|
||||
probe: { observations: [], port: 43210, token: 'PROBE-TOKEN' },
|
||||
tapPoint,
|
||||
timeoutMs: 30_000
|
||||
},
|
||||
{
|
||||
activateTerminal: vi.fn().mockResolvedValue(undefined),
|
||||
enableDiagnostics: vi.fn().mockResolvedValue(undefined),
|
||||
readPoints,
|
||||
readState: vi.fn().mockResolvedValue({
|
||||
href: document.href,
|
||||
javascriptMarkerPresent: false,
|
||||
javascriptMarkerValue: null
|
||||
}),
|
||||
settle: vi.fn().mockResolvedValue(undefined),
|
||||
waitForDocument,
|
||||
writeLinks: vi.fn().mockResolvedValue('terminal-handle')
|
||||
}
|
||||
)
|
||||
|
||||
expect(waitForDocument).toHaveBeenCalledTimes(3)
|
||||
expect(readPoints).toHaveBeenCalledTimes(3)
|
||||
expect(tapPoint).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('taps the duplicate file-link row after a missed native activation', async () => {
|
||||
const document = { href: 'orca-mobile-web://build/h/host/session/workspace' }
|
||||
const file = { x: 0.2, y: 0.7 }
|
||||
const fileAlternate = { x: 0.2, y: 0.72 }
|
||||
const tapPoint = vi.fn().mockResolvedValue(undefined)
|
||||
const prepareFileTap = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await verifyHostedAdversarialTerminalLinks(
|
||||
{
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
document,
|
||||
emulator: {},
|
||||
positiveFilePath: '001-adversarial.md',
|
||||
probe: { observations: [], port: 43210, token: 'PROBE-TOKEN' },
|
||||
tapPoint,
|
||||
timeoutMs: 30_000
|
||||
},
|
||||
{
|
||||
activateTerminal: vi.fn().mockResolvedValue(undefined),
|
||||
enableDiagnostics: vi.fn().mockResolvedValue(undefined),
|
||||
prepareFileTap,
|
||||
readPoints: vi.fn().mockResolvedValue({
|
||||
file,
|
||||
fileAlternate,
|
||||
http: { x: 0.2, y: 0.6 },
|
||||
javascript: { x: 0.2, y: 0.8 }
|
||||
}),
|
||||
readState: vi.fn().mockResolvedValue({
|
||||
href: document.href,
|
||||
javascriptMarkerPresent: false,
|
||||
javascriptMarkerValue: null
|
||||
}),
|
||||
settle: vi.fn().mockResolvedValue(undefined),
|
||||
waitForDocument: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('missed'))
|
||||
.mockResolvedValue(document),
|
||||
writeLinks: vi.fn().mockResolvedValue('terminal-handle')
|
||||
}
|
||||
)
|
||||
|
||||
expect(tapPoint).toHaveBeenNthCalledWith(1, {}, file)
|
||||
expect(tapPoint).toHaveBeenNthCalledWith(2, {}, fileAlternate)
|
||||
expect(prepareFileTap).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries missed live terminal activations without changing the staged command', async () => {
|
||||
const inputCommand = vi.fn().mockResolvedValue(undefined)
|
||||
const waitForStage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('missed'))
|
||||
.mockRejectedValueOnce(new Error('missed'))
|
||||
.mockResolvedValue(undefined)
|
||||
const prepared = {
|
||||
command: 'node .git/stage.cjs',
|
||||
terminalHandle: 'terminal-handle'
|
||||
}
|
||||
const prepare = vi.fn().mockResolvedValue(prepared)
|
||||
|
||||
await expect(
|
||||
stageHostedAdversarialTerminalLinksWithInput({ timeoutMs: 30_000 }, inputCommand, {
|
||||
prepare,
|
||||
waitForStage
|
||||
})
|
||||
).resolves.toBe('terminal-handle')
|
||||
|
||||
expect(inputCommand).toHaveBeenCalledTimes(3)
|
||||
expect(inputCommand).toHaveBeenCalledWith(prepared.command)
|
||||
expect(prepare).toHaveBeenCalledWith({ timeoutMs: 30_000 })
|
||||
expect(waitForStage).toHaveBeenCalledTimes(3)
|
||||
expect(waitForStage).toHaveBeenCalledWith(prepared, 5_000)
|
||||
})
|
||||
|
||||
it('keeps the exact iOS and Android adversarial gates wired to the same corpus', () => {
|
||||
expect(iosHarnessSource).toContain('verifyHostedIosAdversarialTerminalLinks')
|
||||
expect(androidHarnessSource).toContain('verifyHostedAndroidAdversarialTerminalLinks')
|
||||
expect(androidTerminalHarnessSource).toContain('verifyHostedAdversarialTerminalLinks')
|
||||
expect(iosHarnessSource).toContain('adversarial terminal links')
|
||||
expect(androidHarnessSource).toContain('adversarial terminal links')
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
dismissHostedAndroidKeyboardIfShown,
|
||||
hostedAndroidAdversarialTerminalInputText,
|
||||
verifyHostedAndroidAdversarialTerminalLinks
|
||||
} from '../../scripts/hosted-android-adversarial-terminal-links.mjs'
|
||||
|
||||
describe('hosted Android adversarial terminal links', () => {
|
||||
it('stages links through the focused live terminal input', async () => {
|
||||
const activateTerminal = vi.fn().mockResolvedValue(undefined)
|
||||
const dismissKeyboard = vi.fn().mockResolvedValue(false)
|
||||
const runAdb = vi.fn().mockResolvedValue('')
|
||||
const stageWithInput = vi.fn(async (_args, inputCommand) => {
|
||||
await inputCommand('node ".git/orca-mobile-terminal-links.cjs"')
|
||||
return 'terminal-handle'
|
||||
})
|
||||
const tapControl = vi.fn().mockResolvedValue(undefined)
|
||||
const verifyLinks = vi.fn(async (_args, operations) => {
|
||||
return operations.writeLinks({ timeoutMs: 30_000 })
|
||||
})
|
||||
const args = {
|
||||
document: { targetId: 'android-webview' },
|
||||
emulator: { adb: '/sdk/platform-tools/adb' },
|
||||
timeoutMs: 30_000
|
||||
}
|
||||
|
||||
await expect(
|
||||
verifyHostedAndroidAdversarialTerminalLinks(args, {
|
||||
activateTerminal,
|
||||
dismissKeyboard,
|
||||
runAdb,
|
||||
stageWithInput,
|
||||
tapControl,
|
||||
verifyLinks
|
||||
})
|
||||
).resolves.toBe('terminal-handle')
|
||||
|
||||
expect(activateTerminal).toHaveBeenCalledWith(args.document, {
|
||||
kind: 'text',
|
||||
value: 'Mobile Emulator'
|
||||
})
|
||||
expect(tapControl).toHaveBeenCalledWith(
|
||||
args.emulator,
|
||||
'Show keyboard for live terminal input',
|
||||
30_000
|
||||
)
|
||||
expect(runAdb).toHaveBeenNthCalledWith(1, args.emulator.adb, [
|
||||
'shell',
|
||||
'input',
|
||||
'text',
|
||||
'node%s.git/orca-mobile-terminal-links.cjs'
|
||||
])
|
||||
expect(runAdb).toHaveBeenNthCalledWith(2, args.emulator.adb, [
|
||||
'shell',
|
||||
'input',
|
||||
'keyevent',
|
||||
'KEYCODE_ENTER'
|
||||
])
|
||||
expect(runAdb).toHaveBeenCalledTimes(2)
|
||||
expect(dismissKeyboard).toHaveBeenCalledWith(args.emulator.adb, runAdb)
|
||||
})
|
||||
|
||||
it('dismisses only an observed Android input method', async () => {
|
||||
const visibleRunAdb = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
'InsetsSource id=20b40001 type=navigationBars frame=[0,2712][1280,2856] visible=true\nmImeShowing=true'
|
||||
)
|
||||
.mockResolvedValueOnce('')
|
||||
.mockResolvedValueOnce('mImeShowing=false')
|
||||
await expect(
|
||||
dismissHostedAndroidKeyboardIfShown('/sdk/platform-tools/adb', visibleRunAdb)
|
||||
).resolves.toBe(true)
|
||||
expect(visibleRunAdb).toHaveBeenNthCalledWith(2, '/sdk/platform-tools/adb', [
|
||||
'shell',
|
||||
'input',
|
||||
'tap',
|
||||
'128',
|
||||
'2784'
|
||||
])
|
||||
|
||||
const hiddenRunAdb = vi.fn().mockResolvedValue('mImeShowing=false')
|
||||
await expect(
|
||||
dismissHostedAndroidKeyboardIfShown('/sdk/platform-tools/adb', hiddenRunAdb)
|
||||
).resolves.toBe(false)
|
||||
expect(hiddenRunAdb).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('accepts only the generated disposable script command', () => {
|
||||
expect(
|
||||
hostedAndroidAdversarialTerminalInputText('node ".git/orca-mobile-terminal-links.cjs"')
|
||||
).toBe('node%s.git/orca-mobile-terminal-links.cjs')
|
||||
expect(() => hostedAndroidAdversarialTerminalInputText('node other.cjs')).toThrow(
|
||||
'staging command is invalid'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
activateControl: vi.fn(),
|
||||
readControlPoint: vi.fn(),
|
||||
readState: vi.fn(),
|
||||
readStablePoint: vi.fn(),
|
||||
readTextPoint: vi.fn(),
|
||||
setInput: vi.fn(),
|
||||
tapPoint: vi.fn(),
|
||||
waitForDocument: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({
|
||||
activateHostedWebViewControl: mocks.activateControl,
|
||||
readHostedWebViewState: mocks.readState,
|
||||
readHostedWebViewTextPoint: mocks.readTextPoint,
|
||||
setHostedWebViewInput: mocks.setInput,
|
||||
waitForVisibleHostedWebView: mocks.waitForDocument
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-android-emulator-accessibility.mjs', () => ({
|
||||
tapHostedAndroidPoint: mocks.tapPoint
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-android-webview-touch-point.mjs', () => ({
|
||||
readStableHostedAndroidWebViewPoint: mocks.readStablePoint
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-webview-control-point.mjs', () => ({
|
||||
readHostedWebViewControlPoint: mocks.readControlPoint
|
||||
}))
|
||||
|
||||
import { verifyHostedAndroidAgentHistoryJourney } from '../../scripts/hosted-android-agent-history-journey.mjs'
|
||||
|
||||
describe('hosted Android Agent History journey', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.activateControl.mockResolvedValue(undefined)
|
||||
mocks.readControlPoint.mockResolvedValue({ x: 0.4, y: 0.3 })
|
||||
mocks.readStablePoint.mockImplementation((readPoint) => readPoint())
|
||||
mocks.readTextPoint.mockResolvedValue({ x: 0.5, y: 0.4 })
|
||||
mocks.setInput.mockResolvedValue(undefined)
|
||||
mocks.tapPoint.mockImplementation((_emulator, point) => point)
|
||||
mocks.readState.mockResolvedValue({
|
||||
href: 'orca-mobile-web://build/h/host/agent-history/workspace',
|
||||
bodyText: 'Workspace Project All',
|
||||
labels: ['Back', 'Refresh agent sessions', 'Resume agent session'],
|
||||
placeholders: ['Search sessions, repo:, path:']
|
||||
})
|
||||
mocks.waitForDocument.mockImplementation(({ expectedHrefIncludes, expectedText }) => ({
|
||||
href: `orca-mobile-web://build/h/host${
|
||||
expectedHrefIncludes ??
|
||||
(expectedText === '2 tabs' || expectedText === 'Agent History'
|
||||
? '/session/'
|
||||
: '/agent-history/')
|
||||
}workspace`
|
||||
}))
|
||||
})
|
||||
|
||||
it('covers the existing scopes, preview, search, native resume, and Back flow', async () => {
|
||||
const sessionDocument = {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
}
|
||||
|
||||
const result = await verifyHostedAndroidAgentHistoryJourney({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { adb: '/sdk/adb' },
|
||||
sessionDocument,
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
const { returnedSessionDocument, ...evidence } = result
|
||||
expect(evidence).toEqual({
|
||||
route: 'orca-mobile-web://build/h/host/agent-history/workspace',
|
||||
scopes: ['Workspace', 'Project', 'All'],
|
||||
search: 'present',
|
||||
row: 'Hybrid Agent History Fixture',
|
||||
preview: 'E2E_AGENT_HISTORY_PREVIEW_MARKER',
|
||||
resume: {
|
||||
native: 'queued',
|
||||
nativeTouchPoint: { x: 0.4, y: 0.3 },
|
||||
resumedRoute: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
},
|
||||
headerControls: ['Back', 'Refresh agent sessions']
|
||||
})
|
||||
expect(returnedSessionDocument.href).toContain('/session/')
|
||||
expect(mocks.setInput).toHaveBeenNthCalledWith(1, expect.anything(), {
|
||||
placeholder: 'Search sessions, repo:, path:',
|
||||
value: 'NO_MATCHING_AGENT_HISTORY_SESSION'
|
||||
})
|
||||
expect(mocks.setInput).toHaveBeenNthCalledWith(2, expect.anything(), {
|
||||
placeholder: 'Search sessions, repo:, path:',
|
||||
value: ''
|
||||
})
|
||||
expect(mocks.readControlPoint.mock.calls.map((call) => call[1])).toEqual([
|
||||
'More session actions',
|
||||
'Resume agent session'
|
||||
])
|
||||
expect(mocks.readTextPoint.mock.calls.map((call) => call[1])).toEqual(['Agent History'])
|
||||
expect(mocks.tapPoint).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
parseHostedAndroidAccessibilityControls,
|
||||
parseHostedAndroidDisplaySize,
|
||||
tapHostedAndroidAccessibilityControl,
|
||||
tapHostedAndroidPoint
|
||||
} from '../../scripts/hosted-android-emulator-accessibility.mjs'
|
||||
|
||||
const emulator = { adb: '/sdk/platform-tools/adb' }
|
||||
|
||||
describe('hosted Android emulator accessibility controls', () => {
|
||||
it('parses enabled labels, descriptions, entities, and bounds', () => {
|
||||
const controls = parseHostedAndroidAccessibilityControls(`
|
||||
<hierarchy>
|
||||
<node text="Pair & continue" content-desc="" enabled="true"
|
||||
bounds="[10,20][110,220]" />
|
||||
<node text="" content-desc="Open source control" enabled="true"
|
||||
bounds="[20,30][220,330]" />
|
||||
<node text="Disabled" content-desc="" enabled="false"
|
||||
bounds="[0,0][10,10]" />
|
||||
</hierarchy>
|
||||
`)
|
||||
|
||||
expect(controls).toEqual([
|
||||
{
|
||||
label: 'Pair & continue',
|
||||
enabled: true,
|
||||
bounds: { left: 10, top: 20, right: 110, bottom: 220 }
|
||||
},
|
||||
{
|
||||
label: 'Open source control',
|
||||
enabled: true,
|
||||
bounds: { left: 20, top: 30, right: 220, bottom: 330 }
|
||||
},
|
||||
{
|
||||
label: 'Disabled',
|
||||
enabled: false,
|
||||
bounds: { left: 0, top: 0, right: 10, bottom: 10 }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('prefers the override display size', () => {
|
||||
expect(
|
||||
parseHostedAndroidDisplaySize('Physical size: 1280x2856\nOverride size: 1000x2200')
|
||||
).toEqual({ width: 1000, height: 2200 })
|
||||
})
|
||||
|
||||
it('taps the center of an exact accessible label', async () => {
|
||||
const runAdb = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
'<hierarchy><node text="Pair" enabled="true" bounds="[100,200][300,400]" /></hierarchy>'
|
||||
)
|
||||
.mockResolvedValueOnce('')
|
||||
|
||||
await expect(
|
||||
tapHostedAndroidAccessibilityControl(emulator, 'Pair', 1_000, runAdb)
|
||||
).resolves.toEqual({ x: 200, y: 300 })
|
||||
expect(runAdb).toHaveBeenLastCalledWith(emulator.adb, ['shell', 'input', 'tap', '200', '300'])
|
||||
})
|
||||
|
||||
it('maps a normalized WebView point to physical display pixels', async () => {
|
||||
const runAdb = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce('Physical size: 1280x2856')
|
||||
.mockResolvedValueOnce('')
|
||||
|
||||
await expect(tapHostedAndroidPoint(emulator, { x: 0.25, y: 0.5 }, runAdb)).resolves.toEqual({
|
||||
x: 320,
|
||||
y: 1428
|
||||
})
|
||||
expect(runAdb).toHaveBeenLastCalledWith(emulator.adb, ['shell', 'input', 'tap', '320', '1428'])
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertHostedAndroidBridgeLogClean,
|
||||
findHostedAndroidBridgeLogFailures,
|
||||
installAndResetHostedAndroidApp,
|
||||
waitForHostedAndroidReactReady
|
||||
} from '../../scripts/hosted-android-emulator-session.mjs'
|
||||
|
||||
describe('hosted Android emulator session', () => {
|
||||
it('finds native bridge rejection, conversion, cast, and process failures', () => {
|
||||
const failures = findHostedAndroidBridgeLogFailures(`
|
||||
E/ReactNativeJS: Call to function 'ExpoMobileWebShell.postViewMessage' has been rejected.
|
||||
E/ReactNativeJS: java.lang.IllegalArgumentException: mobile_web_shell_view_unavailable
|
||||
E/ExpoModules: Cannot convert '{}' to a Kotlin type
|
||||
E/AndroidRuntime: java.lang.ClassCastException: View cannot be cast to MobileWebShellView
|
||||
E/AndroidRuntime: FATAL EXCEPTION: main
|
||||
`)
|
||||
|
||||
expect(failures).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('ignores normal Android runtime and WebView output', () => {
|
||||
expect(
|
||||
findHostedAndroidBridgeLogFailures(`
|
||||
D/AndroidRuntime: Calling main entry com.android.commands.uiautomator.Launcher
|
||||
I/chromium: source: https://orca-mobile-web.invalid/
|
||||
`)
|
||||
).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'
|
||||
])
|
||||
})
|
||||
|
||||
it('waits for the current Android app process to mount React main', async () => {
|
||||
const responses = [
|
||||
'7301',
|
||||
'I/ReactNativeJS: Loading bundle',
|
||||
'7301',
|
||||
'I/ReactNativeJS: Running "main"'
|
||||
]
|
||||
const runAdb = async () => responses.shift() ?? ''
|
||||
|
||||
await expect(waitForHostedAndroidReactReady('adb', 2_000, runAdb)).resolves.toBe('7301')
|
||||
})
|
||||
|
||||
it('allows an exact debug APK to replace a higher-version beta install', async () => {
|
||||
const calls: { args: string[]; timeout?: number }[] = []
|
||||
const runAdb = async (_adb: string, args: string[], timeout?: number) => {
|
||||
calls.push({ args, timeout })
|
||||
return ''
|
||||
}
|
||||
|
||||
await installAndResetHostedAndroidApp('adb', '/tmp/app-debug.apk', runAdb)
|
||||
|
||||
expect(calls[0]).toEqual({
|
||||
args: ['install', '-r', '-d', '-t', '/tmp/app-debug.apk'],
|
||||
timeout: 120_000
|
||||
})
|
||||
expect(calls[1]?.args).toEqual(['shell', 'pm', 'clear', 'com.stably.orca.mobile'])
|
||||
})
|
||||
|
||||
it('scopes the bridge audit to the current Android app process', async () => {
|
||||
const calls: string[][] = []
|
||||
const runAdb = async (_adb: string, args: string[]) => {
|
||||
calls.push(args)
|
||||
return calls.length === 1 ? '7301' : 'I/chromium: hosted route'
|
||||
}
|
||||
|
||||
await expect(assertHostedAndroidBridgeLogClean('adb', runAdb)).resolves.toBeUndefined()
|
||||
expect(calls).toEqual([
|
||||
['shell', 'pidof', 'com.stably.orca.mobile'],
|
||||
['logcat', '--pid', '7301', '-d', '-v', 'brief']
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
openHostedAndroidHardwareBackNestedRoute,
|
||||
sendHostedAndroidHardwareBack,
|
||||
waitForHostedAndroidWorkspaceRoot,
|
||||
verifyHostedAndroidHardwareBackJourney
|
||||
} from '../../scripts/hosted-android-hardware-back-journey.mjs'
|
||||
|
||||
describe('hosted Android hardware Back journey', () => {
|
||||
it('uses the real Android Back key event', async () => {
|
||||
const runAdb = vi.fn(async () => '')
|
||||
|
||||
await sendHostedAndroidHardwareBack('/sdk/adb', runAdb)
|
||||
|
||||
expect(runAdb).toHaveBeenCalledWith('/sdk/adb', ['shell', 'input', 'keyevent', 'KEYCODE_BACK'])
|
||||
})
|
||||
|
||||
it('opens the nested route through the hosted UI navigation stack', async () => {
|
||||
const sessionDocument = { href: 'orca-mobile-web://build/h/host/session/workspace' }
|
||||
const actionsDocument = { href: sessionDocument.href }
|
||||
const historyDocument = {
|
||||
href: 'orca-mobile-web://build/h/host/agent-history/workspace'
|
||||
}
|
||||
const activate = vi.fn().mockResolvedValue(undefined)
|
||||
const settle = vi.fn().mockResolvedValue(undefined)
|
||||
const waitForDocument = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(actionsDocument)
|
||||
.mockResolvedValueOnce(historyDocument)
|
||||
|
||||
await expect(
|
||||
openHostedAndroidHardwareBackNestedRoute(
|
||||
{
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
sessionDocument,
|
||||
timeoutMs: 30_000
|
||||
},
|
||||
{ activate, settle, waitForDocument }
|
||||
)
|
||||
).resolves.toBe(historyDocument)
|
||||
|
||||
expect(activate.mock.calls.map((call) => call[1])).toEqual([
|
||||
{ kind: 'label', value: 'More session actions' },
|
||||
{ kind: 'text', value: 'Agent History' }
|
||||
])
|
||||
expect(waitForDocument).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ expectedHrefIncludes: '/agent-history/' })
|
||||
)
|
||||
expect(settle).toHaveBeenCalledWith(500)
|
||||
})
|
||||
|
||||
it('waits for the Session route to leave before accepting the workspace root', async () => {
|
||||
const waitForDocument = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ href: '/session/workspace' })
|
||||
.mockResolvedValueOnce({ href: '/session/workspace' })
|
||||
.mockResolvedValueOnce({ href: '/' })
|
||||
const settle = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await expect(
|
||||
waitForHostedAndroidWorkspaceRoot({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
settle,
|
||||
timeoutMs: 30_000,
|
||||
waitForDocument,
|
||||
workspaceMarker: 'MOBILE-REARCH'
|
||||
})
|
||||
).resolves.toEqual({ href: '/' })
|
||||
|
||||
expect(settle).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns through Session and workspace root before dismissing to native home', async () => {
|
||||
const events: string[] = []
|
||||
const pressBack = vi.fn(async () => events.push('back'))
|
||||
const openAgentHistory = vi.fn(async () => {
|
||||
events.push('agent-history')
|
||||
return { href: 'orca-mobile-web://build/h/host/agent-history/workspace' }
|
||||
})
|
||||
const waitForDocument = vi.fn(async ({ expectedHrefIncludes }) => {
|
||||
const route = expectedHrefIncludes ? 'session' : 'workspace'
|
||||
events.push(route)
|
||||
return { href: `orca-mobile-web://build/h/host/${route}` }
|
||||
})
|
||||
const waitForNativeControl = vi.fn(async () => {
|
||||
events.push('native-shell')
|
||||
return { label: 'Open settings' }
|
||||
})
|
||||
|
||||
const evidence = await verifyHostedAndroidHardwareBackJourney(
|
||||
{
|
||||
adb: '/sdk/adb',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { adb: '/sdk/adb' },
|
||||
sessionDocument: { href: 'orca-mobile-web://build/h/host/session/workspace' },
|
||||
timeoutMs: 30_000,
|
||||
workspaceMarker: 'MOBILE-REARCH'
|
||||
},
|
||||
{ openAgentHistory, pressBack, waitForDocument, waitForNativeControl }
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
'agent-history',
|
||||
'back',
|
||||
'session',
|
||||
'back',
|
||||
'workspace',
|
||||
'back',
|
||||
'native-shell'
|
||||
])
|
||||
expect(evidence).toEqual({
|
||||
nestedRoute: 'orca-mobile-web://build/h/host/agent-history/workspace',
|
||||
returnedSessionRoute: 'orca-mobile-web://build/h/host/session',
|
||||
workspaceRootRoute: 'orca-mobile-web://build/h/host/workspace',
|
||||
nativeShellControl: 'Open settings',
|
||||
hardwareBackPresses: 3
|
||||
})
|
||||
expect(waitForDocument).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ expectedHrefIncludes: '/session/', expectedText: '1 tab' })
|
||||
)
|
||||
expect(waitForNativeControl).toHaveBeenCalledWith(
|
||||
{ adb: '/sdk/adb' },
|
||||
[
|
||||
'Open settings',
|
||||
'Open sessions in Chat UI',
|
||||
'Open sessions in the terminal',
|
||||
'Show paired hosts'
|
||||
],
|
||||
30_000
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a nested route that remains visible at the workspace step', async () => {
|
||||
await expect(
|
||||
verifyHostedAndroidHardwareBackJourney(
|
||||
{
|
||||
adb: '/sdk/adb',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { adb: '/sdk/adb' },
|
||||
sessionDocument: { href: 'orca-mobile-web://build/h/host/session/workspace' },
|
||||
timeoutMs: 30_000,
|
||||
workspaceMarker: 'MOBILE-REARCH'
|
||||
},
|
||||
{
|
||||
openAgentHistory: async () => ({ href: '/agent-history/' }),
|
||||
pressBack: async () => {},
|
||||
waitForWorkspaceRoot: async () => ({ href: '/agent-history/' }),
|
||||
waitForDocument: async ({ expectedHrefIncludes }) => ({
|
||||
href: expectedHrefIncludes ? '/session/' : '/agent-history/'
|
||||
}),
|
||||
waitForNativeControl: vi.fn()
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('did not reach the workspace root')
|
||||
})
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
parseAndroidMobileWebActivation,
|
||||
readAndroidRollbackActivation,
|
||||
readSingleAndroidActivation
|
||||
} from '../../scripts/hosted-android-mobile-web-cache.mjs'
|
||||
|
||||
const active = 'a'.repeat(64)
|
||||
const previous = 'b'.repeat(64)
|
||||
const harnessSource = readFileSync(
|
||||
new URL('../../scripts/run-hosted-android-corrupt-cache.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
describe('hosted Android mobile web cache evidence', () => {
|
||||
it('normalizes an omitted previous generation and rejects invalid identities', () => {
|
||||
expect(parseAndroidMobileWebActivation(JSON.stringify({ active }))).toEqual({
|
||||
active,
|
||||
previous: null
|
||||
})
|
||||
expect(() => parseAndroidMobileWebActivation(JSON.stringify({ active: '../active' }))).toThrow(
|
||||
'Android cache returned an invalid activation record'
|
||||
)
|
||||
})
|
||||
|
||||
it('selects the only rollback candidate', async () => {
|
||||
const runAdb = activationExecutor([
|
||||
['cache/one/activation.json', { active }],
|
||||
['cache/two/activation.json', { active, previous }]
|
||||
])
|
||||
|
||||
await expect(readAndroidRollbackActivation('adb', runAdb)).resolves.toEqual({
|
||||
path: 'cache/two/activation.json',
|
||||
active,
|
||||
previous
|
||||
})
|
||||
})
|
||||
|
||||
it('requires exactly one activation record for corruption drills', async () => {
|
||||
const runAdb = activationExecutor([['cache/one/activation.json', { active }]])
|
||||
|
||||
await expect(readSingleAndroidActivation('adb', runAdb)).resolves.toEqual({
|
||||
path: 'cache/one/activation.json',
|
||||
active,
|
||||
previous: null
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the offline corruption drill bounded and reversible', () => {
|
||||
expect(harnessSource).toContain("process.kill(options.desktopPid, 'SIGSTOP')")
|
||||
expect(harnessSource).toContain("process.kill(options.desktopPid, 'SIGCONT')")
|
||||
expect(harnessSource).toContain('repairFixture(')
|
||||
expect(harnessSource).toContain('requireGenerationRemoved(')
|
||||
expect(harnessSource).toContain('expectedText: options.expectedText')
|
||||
})
|
||||
})
|
||||
|
||||
function activationExecutor(records: [string, { active: string; previous?: string }][]) {
|
||||
return vi.fn(async (_command: string, args: string[]) => {
|
||||
if (args.includes('find')) {
|
||||
return records.map(([path]) => path).join('\n')
|
||||
}
|
||||
const path = args.at(-1)
|
||||
const record = records.find(([candidate]) => candidate === path)?.[1]
|
||||
if (!record) {
|
||||
throw new Error('unexpected Android activation path')
|
||||
}
|
||||
return JSON.stringify(record)
|
||||
})
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hostedAndroidExitInfoEvidence,
|
||||
hostedAndroidPrivacyLogEvidence
|
||||
} from '../../scripts/hosted-android-privacy-audit.mjs'
|
||||
|
||||
const priorExit = exitInfo(0, 100, 10, 'USER REQUESTED')
|
||||
const routeHarnessSource = readFileSync(
|
||||
new URL('../../scripts/run-hosted-android-source-control-review-e2e.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
describe('hosted Android privacy audit', () => {
|
||||
it('keeps DOM, log, and process-exit audits in the live route gate', () => {
|
||||
expect(routeHarnessSource).toContain('verifyHostedWebViewPrivacyIsolation')
|
||||
expect(routeHarnessSource).toContain('readHostedAndroidExitInfo')
|
||||
expect(routeHarnessSource).toContain('verifyHostedAndroidPrivacyAudit')
|
||||
})
|
||||
|
||||
it('accepts fixed-category logs', () => {
|
||||
expect(hostedAndroidPrivacyLogEvidence('I/Orca: state=connected retries=1')).toMatchObject({
|
||||
logBytes: 33,
|
||||
counts: {
|
||||
privilegedField: 0,
|
||||
tokenStorage: 0,
|
||||
nativeAuthority: 0,
|
||||
privateOriginUrl: 0,
|
||||
webSocketUrl: 0,
|
||||
fixtureMarker: 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'deviceToken',
|
||||
'orca.host-token.host-a',
|
||||
'openHostLogicalClient',
|
||||
'orca-mobile-web://abcdefghijklmnopqrstuvwxyz/',
|
||||
'wss://paired-host.invalid/socket',
|
||||
'ORCA_E2E_MOBILE_WEB_HOST_PUBLIC_KEY'
|
||||
])('rejects privileged log marker %s', (marker) => {
|
||||
expect(() => hostedAndroidPrivacyLogEvidence(marker)).toThrow(
|
||||
'Hosted Android privacy log audit failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('allows only exact debug-server and deliberate probe WebSockets', () => {
|
||||
const evidence = hostedAndroidPrivacyLogEvidence(
|
||||
[
|
||||
'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: 57999, probePort: 53250 }
|
||||
)
|
||||
|
||||
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) => {
|
||||
expect(() =>
|
||||
hostedAndroidPrivacyLogEvidence(url, { devServerPort: 8081, probePort: 53250 })
|
||||
).toThrow('Hosted Android privacy log audit failed')
|
||||
})
|
||||
|
||||
it('ignores renumbered baseline exits and accepts a new force-stop', () => {
|
||||
const evidence = hostedAndroidExitInfoEvidence(
|
||||
priorExit,
|
||||
`${exitInfo(0, 200, 10, 'USER REQUESTED')}\n${exitInfo(1, 100, 10, 'USER REQUESTED')}`
|
||||
)
|
||||
|
||||
expect(evidence).toMatchObject({
|
||||
currentRecords: 2,
|
||||
newRecords: 1,
|
||||
failureRecords: 0
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[4, 'CRASH'],
|
||||
[5, 'NATIVE CRASH'],
|
||||
[6, 'ANR'],
|
||||
[7, 'INITIALIZATION FAILURE'],
|
||||
[9, 'EXCESSIVE RESOURCE USAGE']
|
||||
])('rejects new process failure reason %s', (reason, label) => {
|
||||
expect(() =>
|
||||
hostedAndroidExitInfoEvidence(priorExit, `${exitInfo(0, 200, reason, label)}\n${priorExit}`)
|
||||
).toThrow('recorded a process failure')
|
||||
})
|
||||
|
||||
it('rejects privileged markers in new exit records', () => {
|
||||
expect(() =>
|
||||
hostedAndroidExitInfoEvidence(
|
||||
priorExit,
|
||||
`${exitInfo(0, 200, 10, 'USER REQUESTED', 'publicKeyB64')}\n${priorExit}`
|
||||
)
|
||||
).toThrow('Hosted Android exit-info privacy log audit failed')
|
||||
})
|
||||
})
|
||||
|
||||
function exitInfo(index, timestamp, reason, label, description = 'test') {
|
||||
return `ApplicationExitInfo #${index}:
|
||||
timestamp=${timestamp} pid=${timestamp}
|
||||
process=com.stably.orca.mobile reason=${reason} (${label}) status=0
|
||||
importance=100 description=${description} trace=null`
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
activateControl: vi.fn(),
|
||||
readTextPoint: vi.fn(),
|
||||
tapPoint: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({
|
||||
activateHostedWebViewControl: mocks.activateControl,
|
||||
readHostedWebViewTextPoint: mocks.readTextPoint
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-android-emulator-accessibility.mjs', () => ({
|
||||
tapHostedAndroidPoint: mocks.tapPoint
|
||||
}))
|
||||
|
||||
import {
|
||||
activateHostedAndroidWorkspaceControl,
|
||||
prepareHostedAndroidWorkspaceInput
|
||||
} from '../../scripts/hosted-android-workspace-activation.mjs'
|
||||
|
||||
describe('hosted Android workspace activation', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
mocks.activateControl.mockResolvedValue(undefined)
|
||||
mocks.readTextPoint.mockResolvedValue({ x: 0.2, y: 0.3 })
|
||||
mocks.tapPoint.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('primes WebView focus before workspace input', async () => {
|
||||
const emulator = { adb: '/sdk/adb' }
|
||||
const result = prepareHostedAndroidWorkspaceInput(emulator)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
await result
|
||||
|
||||
expect(mocks.tapPoint).toHaveBeenCalledWith(emulator, { x: 0.5, y: 0.75 })
|
||||
})
|
||||
|
||||
it('uses the grouped text control instead of a non-hittable ARIA node', async () => {
|
||||
const document = { href: 'https://orca-mobile-web.invalid/' }
|
||||
|
||||
await expect(
|
||||
activateHostedAndroidWorkspaceControl({}, document, {
|
||||
kind: 'label',
|
||||
value: 'Open mobile-rearch',
|
||||
reveal: true
|
||||
})
|
||||
).rejects.toThrow('Hosted WebView control was not found')
|
||||
|
||||
await activateHostedAndroidWorkspaceControl({}, document, {
|
||||
kind: 'text',
|
||||
value: 'mobile-rearch',
|
||||
reveal: true
|
||||
})
|
||||
|
||||
expect(mocks.activateControl).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('waits for visible row coordinates to stabilize before tapping', async () => {
|
||||
const emulator = { adb: '/sdk/adb' }
|
||||
const result = activateHostedAndroidWorkspaceControl(
|
||||
emulator,
|
||||
{},
|
||||
{
|
||||
kind: 'text',
|
||||
value: 'mobile-rearch',
|
||||
occurrence: 1,
|
||||
reveal: true
|
||||
}
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
await result
|
||||
|
||||
expect(mocks.readTextPoint).toHaveBeenCalledWith(
|
||||
{},
|
||||
'mobile-rearch',
|
||||
undefined,
|
||||
expect.objectContaining({ occurrence: 1 })
|
||||
)
|
||||
expect(mocks.tapPoint).toHaveBeenCalledWith(emulator, { x: 0.2, y: 0.3 })
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
captureScreenshot: vi.fn(),
|
||||
compareScreenshots: vi.fn(),
|
||||
dismissDeveloperMenu: vi.fn(),
|
||||
readTextPoint: vi.fn(),
|
||||
tapPoint: vi.fn(),
|
||||
waitForControl: vi.fn(),
|
||||
waitForControlByPrefix: vi.fn(),
|
||||
waitForDocument: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: mocks.captureScreenshot
|
||||
}))
|
||||
vi.mock('../../scripts/emulator-developer-menu-dismissal.mjs', () => ({
|
||||
dismissEmulatorDeveloperMenuIfPresent: mocks.dismissDeveloperMenu
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({
|
||||
tapHostedIosPoint: mocks.tapPoint,
|
||||
waitForHostedIosAccessibilityControl: mocks.waitForControl,
|
||||
waitForHostedIosAccessibilityControlByLabelPrefix: mocks.waitForControlByPrefix
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-screenshot-parity.mjs', () => ({
|
||||
assertHostedIosScreenshotParity: mocks.compareScreenshots
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({
|
||||
readHostedWebViewTextPoint: mocks.readTextPoint,
|
||||
waitForVisibleHostedWebView: mocks.waitForDocument
|
||||
}))
|
||||
|
||||
import {
|
||||
captureHostedAccountsParity,
|
||||
captureNativeAccountsBaseline
|
||||
} from '../../scripts/hosted-ios-accounts-parity.mjs'
|
||||
|
||||
describe('hosted iOS Accounts parity', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.captureScreenshot.mockImplementation((_command, _args, callback) =>
|
||||
callback(null, '', '')
|
||||
)
|
||||
mocks.compareScreenshots.mockResolvedValue({ changedPixelRatio: 0.01 })
|
||||
mocks.readTextPoint.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
mocks.tapPoint.mockResolvedValue(undefined)
|
||||
mocks.waitForControl.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
mocks.waitForControlByPrefix.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
mocks.waitForDocument.mockImplementation(({ expectedHrefIncludes }) => ({
|
||||
href: `orca-mobile-web://build${expectedHrefIncludes ?? '/h/host'}`,
|
||||
targetId: expectedHrefIncludes ?? 'workspace'
|
||||
}))
|
||||
})
|
||||
|
||||
it('captures native Accounts and returns to worktrees', async () => {
|
||||
const baseline = await captureNativeAccountsBaseline({
|
||||
deviceUdid: 'simulator',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(mocks.tapPoint).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ deviceUdid: 'simulator' },
|
||||
{ x: 0.8, y: 0.1 }
|
||||
)
|
||||
expect(mocks.waitForControl).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
'Add or re-authenticate accounts from desktop Settings → Accounts.',
|
||||
30_000
|
||||
)
|
||||
expect(baseline).toEqual({
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-accounts-portrait.png'
|
||||
})
|
||||
})
|
||||
|
||||
it('compares hosted Accounts and returns to the workspace route', async () => {
|
||||
const result = await captureHostedAccountsParity({
|
||||
deviceUdid: 'simulator',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
nativeBaseline: {
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-accounts-portrait.png'
|
||||
},
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
timeoutMs: 30_000,
|
||||
workspaceDocument: { href: 'orca-mobile-web://build/h/host' }
|
||||
})
|
||||
|
||||
expect(mocks.readTextPoint).toHaveBeenCalledWith(expect.anything(), 'Filter')
|
||||
expect(mocks.tapPoint).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, { x: 0.8, y: 0.1 })
|
||||
expect(mocks.compareScreenshots).toHaveBeenCalledOnce()
|
||||
expect(result.evidence.screenshotParity).toEqual({ changedPixelRatio: 0.01 })
|
||||
expect(result.workspaceDocument.href).toContain('/h/host')
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { verifyHostedIosAdversarialTerminalLinks } from '../../scripts/hosted-ios-adversarial-terminal-links.mjs'
|
||||
|
||||
describe('hosted iOS adversarial terminal staging', () => {
|
||||
it('reuses the registration-stage corpus without a second terminal send', async () => {
|
||||
const stageLinks = vi.fn()
|
||||
const verifyLinks = vi.fn(async (args, operations) => ({
|
||||
evidence: {
|
||||
terminalHandle: await operations.writeLinks({
|
||||
terminalHandle: args.terminalHandle
|
||||
})
|
||||
},
|
||||
sessionDocument: { href: 'orca-mobile-web://build/session' }
|
||||
}))
|
||||
|
||||
await expect(
|
||||
verifyHostedIosAdversarialTerminalLinks(
|
||||
{
|
||||
stagedTerminalHandle: 'pre-staged-terminal'
|
||||
},
|
||||
{ stageLinks, verifyLinks }
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
evidence: { terminalHandle: 'pre-staged-terminal' },
|
||||
yOffset: 0
|
||||
})
|
||||
|
||||
expect(verifyLinks).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ terminalHandle: 'pre-staged-terminal' }),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(stageLinks).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
dismissDeveloperMenu,
|
||||
rotateEmulator,
|
||||
tapControl,
|
||||
tapControlByPrefix,
|
||||
typeText,
|
||||
waitByPrefix
|
||||
} = vi.hoisted(() => ({
|
||||
dismissDeveloperMenu: vi.fn(),
|
||||
rotateEmulator: vi.fn(),
|
||||
tapControl: vi.fn(),
|
||||
tapControlByPrefix: vi.fn(),
|
||||
typeText: vi.fn(),
|
||||
waitByPrefix: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/emulator-developer-menu-dismissal.mjs', () => ({
|
||||
dismissEmulatorDeveloperMenuIfPresent: dismissDeveloperMenu
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({
|
||||
rotateHostedIosEmulator: rotateEmulator,
|
||||
tapHostedIosAccessibilityControl: tapControl,
|
||||
tapHostedIosAccessibilityControlByLabelPrefix: tapControlByPrefix,
|
||||
typeHostedIosText: typeText,
|
||||
waitForHostedIosAccessibilityControl: vi.fn(),
|
||||
waitForHostedIosAccessibilityControlByLabelPrefix: waitByPrefix
|
||||
}))
|
||||
|
||||
import { openNativeAgentHistoryBaseline } from '../../scripts/hosted-ios-agent-history-parity.mjs'
|
||||
|
||||
describe('native Agent History baseline', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('dismisses a reopened Expo developer overlay before navigating', async () => {
|
||||
const emulator = { deviceUdid: 'simulator' }
|
||||
const opened = openNativeAgentHistoryBaseline({
|
||||
emulator,
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
await opened
|
||||
|
||||
expect(dismissDeveloperMenu).toHaveBeenCalledWith(emulator)
|
||||
expect(rotateEmulator).toHaveBeenCalledWith(emulator, 'portrait')
|
||||
expect(tapControlByPrefix).toHaveBeenNthCalledWith(1, emulator, 'mobile-rearch', 30_000)
|
||||
expect(tapControl).toHaveBeenCalledWith(emulator, 'More session actions', 30_000)
|
||||
expect(tapControlByPrefix).toHaveBeenNthCalledWith(2, emulator, 'Agent History', 30_000)
|
||||
expect(tapControl).toHaveBeenCalledWith(emulator, 'Search sessions, repo:, path:', 30_000)
|
||||
expect(typeText).toHaveBeenCalledWith(emulator, 'hybrid agent history fixture')
|
||||
expect(waitByPrefix).toHaveBeenCalledWith(emulator, 'Hybrid Agent History Fixture', 30_000)
|
||||
expect(tapControl).toHaveBeenCalledWith(emulator, 'Agent Session History', 30_000)
|
||||
expect(rotateEmulator.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
dismissDeveloperMenu.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(dismissDeveloperMenu.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tapControlByPrefix.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,115 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { waitForHostedIosBuildActivation } from '../../scripts/hosted-ios-build-activation.mjs'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('hosted iOS build activation wait', () => {
|
||||
it('waits against the installed app data container', async () => {
|
||||
const appDataPath = await mkdtemp(path.join(os.tmpdir(), 'orca-ios-activation-'))
|
||||
const runtimeDirectory = await mkdtemp(path.join(os.tmpdir(), 'orca-ios-runtime-'))
|
||||
temporaryDirectories.push(appDataPath)
|
||||
temporaryDirectories.push(runtimeDirectory)
|
||||
const publicKeyB64 = 'paired-public-key'
|
||||
const hostIdentity = createHash('sha256').update(publicKeyB64).digest('hex')
|
||||
const buildId = 'a'.repeat(64)
|
||||
const hostRoot = path.join(
|
||||
appDataPath,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'OrcaMobileWeb',
|
||||
hostIdentity
|
||||
)
|
||||
await mkdir(hostRoot, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(hostRoot, 'activation.json'),
|
||||
JSON.stringify({ active: buildId, previous: null })
|
||||
)
|
||||
const keypairRoot = path.join(runtimeDirectory, 'paired-host', 'userData')
|
||||
await mkdir(keypairRoot, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(keypairRoot, 'orca-e2ee-keypair.json'),
|
||||
JSON.stringify({ publicKeyB64 })
|
||||
)
|
||||
const runCommand = vi.fn().mockResolvedValue({ stdout: `${appDataPath}\n` })
|
||||
|
||||
await expect(
|
||||
waitForHostedIosBuildActivation(
|
||||
'simulator',
|
||||
{ expectedBuild: buildId, timeoutMs: 1_000 },
|
||||
runtimeDirectory,
|
||||
runCommand
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
expect(runCommand).toHaveBeenCalledWith('xcrun', [
|
||||
'simctl',
|
||||
'get_app_container',
|
||||
'simulator',
|
||||
'com.stably.orca.mobile',
|
||||
'data'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not inspect the simulator without an expected build', async () => {
|
||||
const runCommand = vi.fn()
|
||||
|
||||
await waitForHostedIosBuildActivation(
|
||||
'simulator',
|
||||
{ expectedBuild: undefined, timeoutMs: 1_000 },
|
||||
'/runtime',
|
||||
runCommand
|
||||
)
|
||||
expect(runCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores the same active build under an unrelated host identity', async () => {
|
||||
const appDataPath = await mkdtemp(path.join(os.tmpdir(), 'orca-ios-activation-'))
|
||||
const runtimeDirectory = await mkdtemp(path.join(os.tmpdir(), 'orca-ios-runtime-'))
|
||||
temporaryDirectories.push(appDataPath)
|
||||
temporaryDirectories.push(runtimeDirectory)
|
||||
const publicKeyB64 = 'expected-paired-public-key'
|
||||
const expectedIdentity = createHash('sha256').update(publicKeyB64).digest('hex')
|
||||
const unrelatedIdentity = 'f'.repeat(64)
|
||||
const buildId = 'b'.repeat(64)
|
||||
const cacheRoot = path.join(appDataPath, 'Library', 'Application Support', 'OrcaMobileWeb')
|
||||
const keypairRoot = path.join(runtimeDirectory, 'paired-host', 'userData')
|
||||
await mkdir(path.join(cacheRoot, unrelatedIdentity), { recursive: true })
|
||||
await mkdir(keypairRoot, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(cacheRoot, unrelatedIdentity, 'activation.json'),
|
||||
JSON.stringify({ active: buildId })
|
||||
)
|
||||
await writeFile(
|
||||
path.join(keypairRoot, 'orca-e2ee-keypair.json'),
|
||||
JSON.stringify({ publicKeyB64 })
|
||||
)
|
||||
const expectedHostRoot = path.join(cacheRoot, expectedIdentity)
|
||||
const activateExpectedHost = setTimeout(async () => {
|
||||
await mkdir(expectedHostRoot, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(expectedHostRoot, 'activation.json'),
|
||||
JSON.stringify({ active: buildId })
|
||||
)
|
||||
}, 20)
|
||||
const runCommand = vi.fn().mockResolvedValue({ stdout: appDataPath })
|
||||
|
||||
await expect(
|
||||
waitForHostedIosBuildActivation(
|
||||
'simulator',
|
||||
{ expectedBuild: buildId, timeoutMs: 1_000 },
|
||||
runtimeDirectory,
|
||||
runCommand
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
clearTimeout(activateExpectedHost)
|
||||
})
|
||||
})
|
||||
@@ -1,213 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
activateControl: vi.fn(),
|
||||
activateWorkspace: vi.fn(),
|
||||
captureScreenshot: vi.fn(),
|
||||
compareScreenshots: vi.fn(),
|
||||
dismissDeveloperMenu: vi.fn(),
|
||||
readControlPoint: vi.fn(),
|
||||
readTextPoint: vi.fn(),
|
||||
tapControl: vi.fn(),
|
||||
tapControlByPrefix: vi.fn(),
|
||||
tapPoint: vi.fn(),
|
||||
waitForControl: vi.fn(),
|
||||
waitForControlByPrefix: vi.fn(),
|
||||
waitForControlMatch: vi.fn(),
|
||||
waitForControlMatching: vi.fn(),
|
||||
waitForDocument: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: mocks.captureScreenshot
|
||||
}))
|
||||
vi.mock('../../scripts/emulator-developer-menu-dismissal.mjs', () => ({
|
||||
dismissEmulatorDeveloperMenuIfPresent: mocks.dismissDeveloperMenu
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({
|
||||
tapHostedIosAccessibilityControl: mocks.tapControl,
|
||||
tapHostedIosAccessibilityControlByLabelPrefix: mocks.tapControlByPrefix,
|
||||
tapHostedIosPoint: mocks.tapPoint,
|
||||
waitForHostedIosAccessibilityControl: mocks.waitForControl,
|
||||
waitForHostedIosAccessibilityControlByLabelPrefix: mocks.waitForControlByPrefix,
|
||||
waitForHostedIosAccessibilityControlMatch: mocks.waitForControlMatch,
|
||||
waitForHostedIosAccessibilityControlMatching: mocks.waitForControlMatching
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-screenshot-parity.mjs', () => ({
|
||||
assertHostedIosScreenshotParity: mocks.compareScreenshots
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({
|
||||
activateHostedWebViewControl: mocks.activateControl,
|
||||
readHostedWebViewTextPoint: mocks.readTextPoint,
|
||||
waitForVisibleHostedWebView: mocks.waitForDocument
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-webview-control-point.mjs', () => ({
|
||||
readHostedWebViewControlPoint: mocks.readControlPoint
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-webview-workspace-activation.mjs', () => ({
|
||||
activateHostedWorkspaceRow: mocks.activateWorkspace
|
||||
}))
|
||||
|
||||
import {
|
||||
captureHostedCoreRouteParity,
|
||||
captureNativeCoreRouteBaselines
|
||||
} from '../../scripts/hosted-ios-core-route-parity.mjs'
|
||||
|
||||
describe('hosted iOS core-route parity', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.captureScreenshot.mockImplementation((_command, _args, callback) =>
|
||||
callback(null, '', '')
|
||||
)
|
||||
mocks.compareScreenshots.mockResolvedValue({ changedPixelRatio: 0.01 })
|
||||
mocks.readControlPoint.mockResolvedValue({ x: 0.8, y: 0.1 })
|
||||
mocks.readTextPoint.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
mocks.tapControl.mockResolvedValue(undefined)
|
||||
mocks.tapControlByPrefix.mockResolvedValue(undefined)
|
||||
mocks.tapPoint.mockResolvedValue(undefined)
|
||||
mocks.waitForControl.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
mocks.waitForControlByPrefix.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
mocks.waitForControlMatch.mockResolvedValue({
|
||||
label: 'Connect your Linear account',
|
||||
x: 0.5,
|
||||
y: 0.5
|
||||
})
|
||||
mocks.waitForControlMatching.mockResolvedValue({
|
||||
label: 'Stable task title, 3m, Issue · orca #1, Open',
|
||||
value: '',
|
||||
x: 0.5,
|
||||
y: 0.5
|
||||
})
|
||||
mocks.waitForDocument.mockImplementation(({ expectedHrefIncludes }) => ({
|
||||
href: `orca-mobile-web://build${expectedHrefIncludes ?? '/h/host'}`
|
||||
}))
|
||||
})
|
||||
|
||||
it('captures native Tasks and Session from the same presentation source', async () => {
|
||||
const baselines = await captureNativeCoreRouteBaselines({
|
||||
deviceUdid: 'simulator',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(baselines.tasks.stableText).toBe('Connect your Linear account')
|
||||
expect(mocks.tapPoint).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ deviceUdid: 'simulator' },
|
||||
{ x: 0.87, y: 0.1 }
|
||||
)
|
||||
expect(mocks.tapPoint).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ deviceUdid: 'simulator' },
|
||||
expect.objectContaining({ y: 0.1 })
|
||||
)
|
||||
expect(mocks.tapPoint.mock.calls[1][1].x).toBeCloseTo(0.08)
|
||||
expect(mocks.tapControl).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
'Back to worktrees',
|
||||
30_000
|
||||
)
|
||||
expect(baselines.session.screenshot).toBe('/tmp/parity/native-session-portrait.png')
|
||||
})
|
||||
|
||||
it('captures a populated GitHub Tasks list after pagination appears', async () => {
|
||||
mocks.waitForControlMatch.mockRejectedValueOnce(new Error('no empty state'))
|
||||
|
||||
const baselines = await captureNativeCoreRouteBaselines({
|
||||
deviceUdid: 'simulator',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(mocks.waitForControlMatch).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
expect.any(Array),
|
||||
5_000
|
||||
)
|
||||
expect(mocks.waitForControlMatching).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
expect.any(Function),
|
||||
25_000
|
||||
)
|
||||
expect(baselines.tasks.stableText).toBe('Stable task title')
|
||||
})
|
||||
|
||||
it('compares hosted Tasks and Session and returns to the workspace route', async () => {
|
||||
const nativeBaselines = {
|
||||
tasks: {
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-tasks-portrait.png',
|
||||
stableText: 'Connect your Linear account'
|
||||
},
|
||||
session: {
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-session-portrait.png'
|
||||
}
|
||||
}
|
||||
|
||||
const result = await captureHostedCoreRouteParity({
|
||||
deviceUdid: 'simulator',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
nativeBaselines,
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
timeoutMs: 30_000,
|
||||
workspaceDocument: { href: 'orca-mobile-web://build/h/host' }
|
||||
})
|
||||
|
||||
expect(result.evidence.tasks.screenshotParity).toEqual({
|
||||
changedPixelRatio: 0.01
|
||||
})
|
||||
expect(result.evidence.session.screenshotParity).toEqual({
|
||||
changedPixelRatio: 0.01
|
||||
})
|
||||
expect(result.workspaceDocument.href).toContain('/h/host')
|
||||
expect(mocks.activateWorkspace).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'mobile-rearch',
|
||||
mocks.activateControl,
|
||||
30_000,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mocks.readTextPoint).toHaveBeenCalledWith(expect.anything(), 'Filter')
|
||||
expect(mocks.tapControl).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
'Back to worktrees',
|
||||
5_000
|
||||
)
|
||||
expect(mocks.compareScreenshots).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('uses a measured point when the hosted session back control is missing from AX', async () => {
|
||||
mocks.tapControl.mockRejectedValueOnce(new Error('missing accessibility descendant'))
|
||||
|
||||
await captureHostedCoreRouteParity({
|
||||
deviceUdid: 'simulator',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
nativeBaselines: {
|
||||
tasks: {
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-tasks-portrait.png',
|
||||
stableText: 'Connect your Linear account'
|
||||
},
|
||||
session: {
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-session-portrait.png'
|
||||
}
|
||||
},
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
timeoutMs: 30_000,
|
||||
workspaceDocument: { href: 'orca-mobile-web://build/h/host' }
|
||||
})
|
||||
|
||||
expect(mocks.readControlPoint).toHaveBeenCalledWith(expect.anything(), 'Back to worktrees')
|
||||
expect(mocks.tapPoint).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, { x: 0.8, y: 0.1 })
|
||||
})
|
||||
})
|
||||
@@ -1,277 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
HOSTED_IOS_DOCUMENT_FIXTURE_NAME,
|
||||
openHostedIosDocumentPicker,
|
||||
parseLocalFileProviderGroupPath,
|
||||
seedHostedIosDocumentFixture,
|
||||
selectHostedIosDocumentFixture,
|
||||
uploadedPathsFromTerminalSnapshot,
|
||||
verifyHostedIosDocumentUpload
|
||||
} from '../../scripts/hosted-ios-document-upload.mjs'
|
||||
|
||||
const fixture = {
|
||||
destinationPath: '/simulator/File Provider Storage/orca-document-upload-fixture.png',
|
||||
fixtureName: HOSTED_IOS_DOCUMENT_FIXTURE_NAME,
|
||||
sha256: 'dec4a91731905b9e8ed450a6c46931258528fc034fcfc64d95b0b23264f8e9d4',
|
||||
size: 123
|
||||
}
|
||||
const uploadedPath =
|
||||
'/var/folders/x/y/T/orca-paste-1780000000000-00000000-0000-4000-8000-000000000000.png'
|
||||
const args = {
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
documentFixture: fixture,
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
orcaCli: './config/scripts/orca-dev.mjs',
|
||||
pairingRuntimeUserDataPath: '/tmp/pairing/userData',
|
||||
sessionDocument: { href: 'orca-mobile-web://build/h/host/session/workspace' },
|
||||
terminalHandle: 'terminal-handle',
|
||||
timeoutMs: 30_000,
|
||||
worktree: '/repo/mobile-rearch'
|
||||
}
|
||||
const fixtureBytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7Q', 'base64')
|
||||
|
||||
describe('hosted iOS document upload', () => {
|
||||
it('resolves and seeds the simulator Local File Provider container', async () => {
|
||||
const runCommand = vi.fn().mockResolvedValue({
|
||||
stdout:
|
||||
'group.com.apple.DocumentManager\t/simulator/document-manager\n' +
|
||||
'group.com.apple.FileProvider.LocalStorage\t/simulator/local-files\n'
|
||||
})
|
||||
const copy = vi.fn().mockResolvedValue(undefined)
|
||||
const createDirectory = vi.fn().mockResolvedValue(undefined)
|
||||
const readFixture = vi.fn().mockResolvedValue(Buffer.alloc(123))
|
||||
const touch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await seedHostedIosDocumentFixture(
|
||||
{
|
||||
deviceUdid: 'simulator',
|
||||
fixturePath: '/repo/mobile/assets/favicon.png'
|
||||
},
|
||||
{ copy, createDirectory, readFixture, runCommand, touch }
|
||||
)
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'get_app_container', 'simulator', 'com.apple.DocumentsApp', 'groups'],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
expect(createDirectory).toHaveBeenCalledWith('/simulator/local-files/File Provider Storage', {
|
||||
recursive: true
|
||||
})
|
||||
expect(copy).toHaveBeenCalledWith(
|
||||
'/repo/mobile/assets/favicon.png',
|
||||
'/simulator/local-files/File Provider Storage/orca-document-upload-fixture.png'
|
||||
)
|
||||
expect(touch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a missing Local File Provider group', () => {
|
||||
expect(() =>
|
||||
parseLocalFileProviderGroupPath(
|
||||
'group.com.apple.DocumentManager\t/simulator/document-manager'
|
||||
)
|
||||
).toThrow('Local File Provider container was not found')
|
||||
})
|
||||
|
||||
it('falls back through Browse and On My iPhone to select the seeded file', async () => {
|
||||
const tapByPrefix = vi.fn().mockResolvedValueOnce({ x: 0.5, y: 0.4 })
|
||||
const tapFixture = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('not in Recents'))
|
||||
.mockRejectedValueOnce(new Error('not in Browse root'))
|
||||
.mockResolvedValueOnce({ x: 0.5, y: 0.3 })
|
||||
const tapControl = vi.fn().mockResolvedValue({ x: 0.2, y: 0.9 })
|
||||
const waitForPickerDismissal = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await expect(
|
||||
selectHostedIosDocumentFixture(
|
||||
args.emulator,
|
||||
HOSTED_IOS_DOCUMENT_FIXTURE_NAME,
|
||||
args.timeoutMs,
|
||||
{ tapByPrefix, tapControl, tapFixture, waitForPickerDismissal }
|
||||
)
|
||||
).resolves.toEqual({ x: 0.5, y: 0.3 })
|
||||
|
||||
expect(tapControl).toHaveBeenCalledWith(args.emulator, 'Browse', 5_000)
|
||||
expect(tapByPrefix).toHaveBeenCalledWith(args.emulator, 'On My iPhone', 5_000)
|
||||
expect(tapFixture).toHaveBeenLastCalledWith(
|
||||
args.emulator,
|
||||
'orca-document-upload-fixture',
|
||||
{ x: 0.5, y: 0.25 },
|
||||
args.timeoutMs
|
||||
)
|
||||
expect(waitForPickerDismissal).toHaveBeenCalledWith(args.emulator, 'Browse', 3_000)
|
||||
})
|
||||
|
||||
it('splits abutting uploaded paths instead of fusing them', () => {
|
||||
const first =
|
||||
'/var/folders/x/y/T/orca-paste-1780000000000-00000000-0000-4000-8000-000000000000.png'
|
||||
const second =
|
||||
'/var/folders/x/y/T/orca-paste-1780000000001-00000000-0000-4000-8000-000000000001.png'
|
||||
expect(uploadedPathsFromTerminalSnapshot([first + second])).toEqual([first, second])
|
||||
})
|
||||
|
||||
it('reconstructs wrapped uploaded paths from terminal rows', () => {
|
||||
expect(
|
||||
uploadedPathsFromTerminalSnapshot([
|
||||
'/orca/ORCA_HOSTED_CLIPBOARD_TEXT_PASTE/var/folders/x/y/T/orca-paste-1780000',
|
||||
'000000-00000000-0000-4000-8000-000000000000.png'
|
||||
])
|
||||
).toEqual([
|
||||
'/var/folders/x/y/T/orca-paste-1780000000000-00000000-0000-4000-8000-000000000000.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the physical long press when it opens the native picker', async () => {
|
||||
const longPressPoint = vi.fn().mockResolvedValue(undefined)
|
||||
const waitForPicker = vi.fn().mockResolvedValue({ x: 0.2, y: 0.9 })
|
||||
const dispatchWebLongPress = vi.fn()
|
||||
|
||||
await expect(
|
||||
openHostedIosDocumentPicker(
|
||||
{
|
||||
document: args.sessionDocument,
|
||||
emulator: args.emulator,
|
||||
point: { x: 0.75, y: 0.9 }
|
||||
},
|
||||
{ dispatchWebLongPress, longPressPoint, waitForPicker }
|
||||
)
|
||||
).resolves.toBe('native-long-press')
|
||||
|
||||
expect(dispatchWebLongPress).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains native gesture authority while completing the RNW long press', async () => {
|
||||
const longPressPoint = vi.fn().mockResolvedValue(undefined)
|
||||
const waitForPicker = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('picker not visible'))
|
||||
.mockResolvedValueOnce({ x: 0.2, y: 0.9 })
|
||||
const dispatchWebLongPress = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await expect(
|
||||
openHostedIosDocumentPicker(
|
||||
{
|
||||
document: args.sessionDocument,
|
||||
emulator: args.emulator,
|
||||
point: { x: 0.75, y: 0.9 }
|
||||
},
|
||||
{ dispatchWebLongPress, longPressPoint, waitForPicker }
|
||||
)
|
||||
).resolves.toBe('native-touch-plus-web-responder')
|
||||
|
||||
expect(dispatchWebLongPress).toHaveBeenCalledWith(args.sessionDocument, 'Attach a photo')
|
||||
expect(waitForPicker).toHaveBeenLastCalledWith(args.emulator, 'Browse', 5_000)
|
||||
})
|
||||
|
||||
it('repeats the combined gesture when WebKit drops the first responder hold', async () => {
|
||||
const longPressPoint = vi.fn().mockResolvedValue(undefined)
|
||||
const waitForPicker = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('physical hold did not open picker'))
|
||||
.mockRejectedValueOnce(new Error('first responder hold did not open picker'))
|
||||
.mockRejectedValueOnce(new Error('second physical hold did not open picker'))
|
||||
.mockResolvedValueOnce({ x: 0.2, y: 0.9 })
|
||||
const dispatchWebLongPress = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await expect(
|
||||
openHostedIosDocumentPicker(
|
||||
{
|
||||
document: args.sessionDocument,
|
||||
emulator: args.emulator,
|
||||
point: { x: 0.75, y: 0.9 }
|
||||
},
|
||||
{ dispatchWebLongPress, longPressPoint, waitForPicker }
|
||||
)
|
||||
).resolves.toBe('native-touch-plus-web-responder')
|
||||
|
||||
expect(longPressPoint).toHaveBeenCalledTimes(2)
|
||||
expect(dispatchWebLongPress).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('selects through unchanged Attach and verifies terminal-only byte identity', async () => {
|
||||
const before = ['ORCA_HOSTED_CLIPBOARD_TEXT_PASTE']
|
||||
const after = [...before, uploadedPath]
|
||||
const operations = {
|
||||
fileStat: vi.fn().mockResolvedValue({ size: fixtureBytes.byteLength }),
|
||||
openPicker: vi.fn().mockResolvedValue('native-touch-plus-web-responder'),
|
||||
readAccessibilityPoint: vi.fn().mockResolvedValue({ x: 0.74, y: 0.89 }),
|
||||
readControlPoint: vi.fn().mockResolvedValue({ x: 0.75, y: 0.9 }),
|
||||
readState: vi.fn().mockResolvedValue({ bodyText: 'Mobile Emulator' }),
|
||||
readTerminal: vi.fn().mockResolvedValueOnce(before).mockResolvedValueOnce(after),
|
||||
readUploadedFile: vi.fn().mockResolvedValue(fixtureBytes),
|
||||
selectFixture: vi.fn().mockResolvedValue({ x: 0.5, y: 0.5 }),
|
||||
waitForDocument: vi.fn().mockResolvedValue(args.sessionDocument)
|
||||
}
|
||||
const documentFixture = {
|
||||
...fixture,
|
||||
sha256: '1b0a38dc09fb7fe56599abeab066965c1b7791815e7dd08483938630b0d15226',
|
||||
size: fixtureBytes.byteLength
|
||||
}
|
||||
|
||||
await expect(
|
||||
verifyHostedIosDocumentUpload({ ...args, documentFixture }, operations)
|
||||
).resolves.toEqual({
|
||||
evidence: {
|
||||
attachAccessibilityPoint: { x: 0.74, y: 0.89 },
|
||||
attachControlPoint: { x: 0.75, y: 0.9 },
|
||||
fixtureName: HOSTED_IOS_DOCUMENT_FIXTURE_NAME,
|
||||
privilegedPageMarkers: 'absent',
|
||||
pickerActivation: 'native-touch-plus-web-responder',
|
||||
route: args.sessionDocument.href,
|
||||
selectedFilePoint: { x: 0.5, y: 0.5 },
|
||||
sha256: documentFixture.sha256,
|
||||
size: fixtureBytes.byteLength,
|
||||
terminalPathInjected: true
|
||||
},
|
||||
sessionDocument: args.sessionDocument
|
||||
})
|
||||
expect(operations.readControlPoint).toHaveBeenCalledWith(args.sessionDocument, 'Attach a photo')
|
||||
expect(operations.openPicker).toHaveBeenCalledWith({
|
||||
document: args.sessionDocument,
|
||||
emulator: args.emulator,
|
||||
point: { x: 0.75, y: 0.9 }
|
||||
})
|
||||
expect(operations.readUploadedFile).toHaveBeenCalledWith(uploadedPath)
|
||||
})
|
||||
|
||||
it('rejects selected bytes exposed in hosted page state', async () => {
|
||||
const documentFixture = {
|
||||
...fixture,
|
||||
sha256: '1b0a38dc09fb7fe56599abeab066965c1b7791815e7dd08483938630b0d15226',
|
||||
size: fixtureBytes.byteLength
|
||||
}
|
||||
const operations = {
|
||||
fileStat: vi.fn().mockResolvedValue({ size: fixtureBytes.byteLength }),
|
||||
openPicker: vi.fn().mockResolvedValue('native-touch-plus-web-responder'),
|
||||
readAccessibilityPoint: vi.fn().mockResolvedValue({ x: 0.74, y: 0.89 }),
|
||||
readControlPoint: vi.fn().mockResolvedValue({ x: 0.75, y: 0.9 }),
|
||||
readState: vi.fn().mockResolvedValue({
|
||||
bodyText: `Mobile Emulator ${fixtureBytes.toString('base64').slice(0, 16)}`
|
||||
}),
|
||||
readTerminal: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([uploadedPath]),
|
||||
readUploadedFile: vi.fn().mockResolvedValue(fixtureBytes),
|
||||
selectFixture: vi.fn().mockResolvedValue({ x: 0.5, y: 0.5 }),
|
||||
waitForDocument: vi.fn().mockResolvedValue(args.sessionDocument)
|
||||
}
|
||||
|
||||
await expect(
|
||||
verifyHostedIosDocumentUpload({ ...args, documentFixture }, operations)
|
||||
).rejects.toThrow('exposed privileged page marker')
|
||||
})
|
||||
|
||||
it('reports the native shell attachment error before the terminal timeout', async () => {
|
||||
const operations = {
|
||||
openPicker: vi.fn().mockResolvedValue('native-touch-plus-web-responder'),
|
||||
readAccessibilityPoint: vi.fn().mockResolvedValue({ x: 0.74, y: 0.89 }),
|
||||
readControlPoint: vi.fn().mockResolvedValue({ x: 0.75, y: 0.9 }),
|
||||
readState: vi.fn().mockResolvedValue({ bodyText: 'Mobile Emulator Attach failed' }),
|
||||
readTerminal: vi.fn().mockResolvedValue([]),
|
||||
selectFixture: vi.fn().mockResolvedValue({ x: 0.5, y: 0.3 })
|
||||
}
|
||||
|
||||
await expect(
|
||||
verifyHostedIosDocumentUpload({ ...args, timeoutMs: 100 }, operations)
|
||||
).rejects.toThrow('Selected document upload failed in the native shell: Attach failed')
|
||||
})
|
||||
})
|
||||
@@ -1,426 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
rotateHostedIosEmulator,
|
||||
tapHostedIosAccessibilityControl,
|
||||
tapHostedIosAccessibilityControlAtOccurrence,
|
||||
tapHostedIosAccessibilityControlAtLastOccurrence,
|
||||
tapHostedIosAccessibilityControlByLabelPrefix,
|
||||
tapHostedIosAccessibilityControlByLabelPrefixAtPosition,
|
||||
tapHostedIosAccessibilityControlStartingWith,
|
||||
tapHostedIosPoint,
|
||||
typeHostedIosText,
|
||||
waitForHostedIosAccessibilityControl,
|
||||
waitForHostedIosAccessibilityControlByLabelPrefix,
|
||||
waitForHostedIosAccessibilityControlEndingWith,
|
||||
waitForHostedIosAccessibilityControlMatching,
|
||||
waitForHostedIosAccessibilityLabel,
|
||||
waitForHostedIosAccessibilityLabelToDisappear
|
||||
} from '../../scripts/hosted-ios-emulator-accessibility.mjs'
|
||||
|
||||
const emulator = {
|
||||
deviceUdid: 'simulator-a',
|
||||
orcaCli: '/repo/config/scripts/orca-dev.mjs',
|
||||
userDataDir: '/tmp/orca-mobile/userData',
|
||||
worktree: '/repo'
|
||||
}
|
||||
|
||||
describe('hosted iOS emulator accessibility controls', () => {
|
||||
it('rotates through the isolated Orca emulator controller', async () => {
|
||||
const runCommand = vi.fn().mockResolvedValue({ stderr: '', stdout: '{}' })
|
||||
|
||||
await rotateHostedIosEmulator(emulator, 'landscape_left', runCommand)
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith(emulator, ['rotate', 'landscape_left'])
|
||||
})
|
||||
|
||||
it('finds a nested enabled control and taps its normalized center', async () => {
|
||||
const runCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
label: 'Orca',
|
||||
children: [
|
||||
{
|
||||
label: 'Resume agent session',
|
||||
enabled: true,
|
||||
frame: { x: 0.6, y: 0.7, width: 0.2, height: 0.1 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stderr: '', stdout: JSON.stringify({ ok: true }) })
|
||||
|
||||
await expect(
|
||||
tapHostedIosAccessibilityControl(emulator, 'Resume agent session', 1_000, runCommand)
|
||||
).resolves.toEqual({ x: 0.7, y: 0.75 })
|
||||
expect(runCommand).toHaveBeenLastCalledWith(emulator, ['tap', '0.7', '0.75'])
|
||||
})
|
||||
|
||||
it('selects a specific visible occurrence without changing the label contract', async () => {
|
||||
const runCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
label: 'mobile-rearch',
|
||||
enabled: true,
|
||||
frame: { x: 0.1, y: 0.1, width: 0.3, height: 0.1 }
|
||||
},
|
||||
{
|
||||
label: 'mobile-rearch',
|
||||
enabled: true,
|
||||
frame: { x: 0.1, y: 0.3, width: 0.3, height: 0.1 }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stderr: '', stdout: JSON.stringify({ ok: true }) })
|
||||
|
||||
await expect(
|
||||
tapHostedIosAccessibilityControlAtOccurrence(emulator, 'mobile-rearch', 1, 1_000, runCommand)
|
||||
).resolves.toEqual({ x: 0.25, y: 0.35 })
|
||||
expect(runCommand).toHaveBeenLastCalledWith(emulator, ['tap', '0.25', '0.35'])
|
||||
})
|
||||
|
||||
it('targets the last visible occurrence for native photo grids', async () => {
|
||||
const runCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
label: 'Photo',
|
||||
enabled: true,
|
||||
frame: { x: 0, y: 0.2, width: 0.3, height: 0.1 }
|
||||
},
|
||||
{
|
||||
label: 'Photo',
|
||||
enabled: true,
|
||||
frame: { x: 0.3, y: 0.4, width: 0.3, height: 0.1 }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stderr: '', stdout: JSON.stringify({ ok: true }) })
|
||||
|
||||
await expect(
|
||||
tapHostedIosAccessibilityControlAtLastOccurrence(emulator, 'Photo', 1_000, runCommand)
|
||||
).resolves.toEqual({ x: 0.44999999999999996, y: 0.45 })
|
||||
expect(runCommand).toHaveBeenLastCalledWith(emulator, ['tap', '0.44999999999999996', '0.45'])
|
||||
})
|
||||
|
||||
it('targets a composite native row by its leading visible label', 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({ stderr: '', stdout: JSON.stringify({ ok: true }) })
|
||||
|
||||
await expect(
|
||||
tapHostedIosAccessibilityControlByLabelPrefix(emulator, 'mobile-rearch', 1_000, runCommand)
|
||||
).resolves.toEqual({ x: 0.5, y: 0.25 })
|
||||
expect(runCommand).toHaveBeenLastCalledWith(emulator, ['tap', '0.5', '0.25'])
|
||||
})
|
||||
|
||||
it('taps a relative position within a composite native control', async () => {
|
||||
const runCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
label: 'orca-document-upload-fixture, Image',
|
||||
enabled: true,
|
||||
frame: { x: 0.1, y: 0.2, width: 0.2, height: 0.4 }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stderr: '', stdout: JSON.stringify({ ok: true }) })
|
||||
|
||||
await expect(
|
||||
tapHostedIosAccessibilityControlByLabelPrefixAtPosition(
|
||||
emulator,
|
||||
'orca-document-upload-fixture',
|
||||
{ x: 0.5, y: 0.25 },
|
||||
1_000,
|
||||
runCommand
|
||||
)
|
||||
).resolves.toEqual({ x: 0.2, y: 0.30000000000000004 })
|
||||
expect(runCommand).toHaveBeenLastCalledWith(emulator, ['tap', '0.2', '0.30000000000000004'])
|
||||
})
|
||||
|
||||
it('targets a dynamic control whose label starts with a stable action prefix', async () => {
|
||||
const runCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
label: 'Open changed file mobile/app/index.tsx',
|
||||
enabled: true,
|
||||
frame: { x: 0.1, y: 0.3, width: 0.8, height: 0.1 }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stderr: '', stdout: JSON.stringify({ ok: true }) })
|
||||
|
||||
await expect(
|
||||
tapHostedIosAccessibilityControlStartingWith(
|
||||
emulator,
|
||||
'Open changed file ',
|
||||
1_000,
|
||||
runCommand
|
||||
)
|
||||
).resolves.toEqual({ x: 0.5, y: 0.35 })
|
||||
expect(runCommand).toHaveBeenLastCalledWith(emulator, ['tap', '0.5', '0.35'])
|
||||
})
|
||||
|
||||
it.each(['label', 'value'])(
|
||||
'reads a composite row from its %s without tapping it',
|
||||
async (field) => {
|
||||
const runCommand = vi.fn().mockResolvedValueOnce({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
[field]: 'Hybrid Agent History Fixture, 2h, Preview',
|
||||
enabled: true,
|
||||
frame: { x: 0.1, y: 0.2, width: 0.8, height: 0.1 }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
waitForHostedIosAccessibilityControlByLabelPrefix(
|
||||
emulator,
|
||||
'Hybrid Agent History Fixture',
|
||||
1_000,
|
||||
runCommand
|
||||
)
|
||||
).resolves.toEqual({ x: 0.5, y: 0.25 })
|
||||
expect(runCommand).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('waits for a dynamic count by its stable suffix', async () => {
|
||||
const runCommand = vi.fn().mockResolvedValueOnce({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
label: '128 on branch',
|
||||
enabled: true,
|
||||
frame: { x: 0.6, y: 0.3, width: 0.3, height: 0.1 }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
waitForHostedIosAccessibilityControlEndingWith(emulator, ' on branch', 1_000, runCommand)
|
||||
).resolves.toEqual({ x: 0.75, y: 0.35 })
|
||||
})
|
||||
|
||||
it('returns metadata for a visible control matched by shape', async () => {
|
||||
const runCommand = vi.fn().mockResolvedValueOnce({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
label: 'Stable task title, 3m, Issue · orca #1, Open',
|
||||
value: '',
|
||||
enabled: true,
|
||||
frame: { x: 0.1, y: 0.3, width: 0.8, height: 0.1 }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
waitForHostedIosAccessibilityControlMatching(
|
||||
emulator,
|
||||
(node) => node.label?.endsWith(', Open'),
|
||||
1_000,
|
||||
runCommand
|
||||
)
|
||||
).resolves.toEqual({
|
||||
label: 'Stable task title, 3m, Issue · orca #1, Open',
|
||||
value: '',
|
||||
x: 0.5,
|
||||
y: 0.35
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a tall visible label whose center is below the viewport', async () => {
|
||||
const runCommand = vi.fn().mockResolvedValueOnce({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
label: 'File preview',
|
||||
value: '',
|
||||
enabled: true,
|
||||
frame: { x: 0.03, y: 0.28, width: 0.94, height: 1.52 }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
waitForHostedIosAccessibilityLabel(emulator, 'File preview', 1_000, runCommand)
|
||||
).resolves.toEqual({
|
||||
frame: { x: 0.03, y: 0.28, width: 0.94, height: 1.52 },
|
||||
label: 'File preview',
|
||||
value: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an invalid accessibility response', async () => {
|
||||
await expect(
|
||||
waitForHostedIosAccessibilityControl(emulator, 'Resume agent session', 1_000, async () => ({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({ ok: true, result: null })
|
||||
}))
|
||||
).rejects.toThrow('invalid accessibility response')
|
||||
})
|
||||
|
||||
it('restarts a wedged emulator controller before retrying accessibility', async () => {
|
||||
const runCommand = vi.fn(async (_args, command) => {
|
||||
if (command[0] === 'ax' && runCommand.mock.calls.length === 1) {
|
||||
throw new Error('emulator_helper_failed: request timed out')
|
||||
}
|
||||
if (command[0] === 'ax') {
|
||||
return {
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
label: 'Resume agent session',
|
||||
enabled: true,
|
||||
frame: { x: 0.6, y: 0.7, width: 0.2, height: 0.1 }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
return { stderr: '', stdout: JSON.stringify({ ok: true }) }
|
||||
})
|
||||
|
||||
await expect(
|
||||
waitForHostedIosAccessibilityControl(emulator, 'Resume agent session', 1_000, runCommand)
|
||||
).resolves.toEqual({ x: 0.7, y: 0.75 })
|
||||
expect(runCommand.mock.calls.map(([, command]) => command[0])).toEqual([
|
||||
'ax',
|
||||
'kill',
|
||||
'attach',
|
||||
'ax'
|
||||
])
|
||||
expect(runCommand).toHaveBeenNthCalledWith(3, emulator, ['attach', emulator.deviceUdid])
|
||||
})
|
||||
|
||||
it('reattaches an explicit device before retrying a point tap', async () => {
|
||||
const runCommand = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('emulator_no_active'))
|
||||
.mockResolvedValue({ stderr: '', stdout: JSON.stringify({ ok: true }) })
|
||||
const point = { x: 0.25, y: 0.75 }
|
||||
|
||||
await expect(tapHostedIosPoint(emulator, point, runCommand)).resolves.toEqual(point)
|
||||
expect(runCommand.mock.calls.map(([, command]) => command[0])).toEqual([
|
||||
'tap',
|
||||
'kill',
|
||||
'attach',
|
||||
'tap'
|
||||
])
|
||||
expect(runCommand).toHaveBeenNthCalledWith(3, emulator, ['attach', emulator.deviceUdid])
|
||||
})
|
||||
|
||||
it('types through the isolated emulator controller', async () => {
|
||||
const runCommand = vi.fn().mockResolvedValue({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({ ok: true })
|
||||
})
|
||||
|
||||
await typeHostedIosText(emulator, 'Hybrid Agent History Fixture', runCommand)
|
||||
|
||||
expect(runCommand.mock.calls.map(([, command]) => command)).toEqual(
|
||||
Array.from('Hybrid Agent History Fixture', (character) => ['type', character])
|
||||
)
|
||||
})
|
||||
|
||||
it('retries transient helper failures while reattaching a point tap', async () => {
|
||||
let attachAttempts = 0
|
||||
const runCommand = vi.fn(async (_args, command) => {
|
||||
if (command[0] === 'tap' && runCommand.mock.calls.length === 1) {
|
||||
throw new Error('emulator_no_active')
|
||||
}
|
||||
if (command[0] === 'attach' && attachAttempts++ === 0) {
|
||||
throw new Error('emulator_helper_failed: stream endpoints unavailable')
|
||||
}
|
||||
return { stderr: '', stdout: JSON.stringify({ ok: true }) }
|
||||
})
|
||||
|
||||
await expect(tapHostedIosPoint(emulator, { x: 0.25, y: 0.75 }, runCommand)).resolves.toEqual({
|
||||
x: 0.25,
|
||||
y: 0.75
|
||||
})
|
||||
expect(runCommand.mock.calls.map(([, command]) => command[0])).toEqual([
|
||||
'tap',
|
||||
'kill',
|
||||
'attach',
|
||||
'attach',
|
||||
'tap'
|
||||
])
|
||||
})
|
||||
|
||||
it('waits for a reconnect label to leave the native accessibility tree', async () => {
|
||||
const runCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({ ok: true, result: [{ label: 'Reconnecting' }] })
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stderr: '',
|
||||
stdout: JSON.stringify({ ok: true, result: [{ label: '1 tab' }] })
|
||||
})
|
||||
|
||||
await expect(
|
||||
waitForHostedIosAccessibilityLabelToDisappear(emulator, 'Reconnecting', 1_000, runCommand)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { startHostedIosEmulatorController } from '../../scripts/hosted-ios-emulator-controller.mjs'
|
||||
|
||||
const args = {
|
||||
orcaCli: '/repo/config/scripts/orca-dev.mjs',
|
||||
runtimeDirectory: '/tmp/mobile-e2e',
|
||||
worktree: '/repo'
|
||||
}
|
||||
|
||||
describe('hosted iOS emulator controller', () => {
|
||||
it('registers the worktree in an isolated stable runtime', async () => {
|
||||
const runtime = { env: { ORCA_USER_DATA_PATH: '/controller' }, stop: vi.fn() }
|
||||
const startRuntime = vi.fn(async () => runtime)
|
||||
const runCli = vi.fn(async () => ({ stdout: '{}', stderr: '' }))
|
||||
|
||||
await expect(startHostedIosEmulatorController(args, startRuntime, runCli)).resolves.toBe(
|
||||
runtime
|
||||
)
|
||||
expect(startRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
orcaCli: args.orcaCli,
|
||||
cwd: args.worktree,
|
||||
runDirectory: path.join(args.runtimeDirectory, 'emulator-control')
|
||||
})
|
||||
)
|
||||
expect(runCli).toHaveBeenCalledWith(
|
||||
args.orcaCli,
|
||||
['repo', 'add', '--path', args.worktree, '--json'],
|
||||
expect.objectContaining({ cwd: args.worktree, env: runtime.env })
|
||||
)
|
||||
})
|
||||
|
||||
it('stops the controller when worktree registration fails', async () => {
|
||||
const runtime = { env: {}, stop: vi.fn(async () => {}) }
|
||||
const error = new Error('registration failed')
|
||||
|
||||
await expect(
|
||||
startHostedIosEmulatorController(
|
||||
args,
|
||||
async () => runtime,
|
||||
async () => {
|
||||
throw error
|
||||
}
|
||||
)
|
||||
).rejects.toBe(error)
|
||||
expect(runtime.stop).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { longPressHostedIosAccessibilityControlByLabelPrefix } from '../../scripts/hosted-ios-emulator-long-press.mjs'
|
||||
|
||||
const emulator = {
|
||||
deviceUdid: 'simulator-a',
|
||||
orcaCli: '/repo/config/scripts/orca-dev.mjs',
|
||||
userDataDir: '/tmp/orca-mobile/userData',
|
||||
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 the touch down across two gesture commands', async () => {
|
||||
const runCommand = vi
|
||||
.fn()
|
||||
.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',
|
||||
20_000,
|
||||
runCommand,
|
||||
'Source Control'
|
||||
)
|
||||
).resolves.toEqual({ x: 0.5, y: 0.25 })
|
||||
|
||||
// Why: a single command batches its points, so the press must span two commands.
|
||||
expect(runCommand.mock.calls[1]?.[1][0]).toBe('gesture')
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,187 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
activateControl: vi.fn(),
|
||||
activateWorkspace: vi.fn(),
|
||||
captureScreenshot: vi.fn(),
|
||||
compareScreenshots: vi.fn(),
|
||||
dismissDeveloperMenu: vi.fn(),
|
||||
readControlPoint: vi.fn(),
|
||||
readState: vi.fn(),
|
||||
readTextPoint: vi.fn(),
|
||||
tapControl: vi.fn(),
|
||||
tapControlByPrefix: vi.fn(),
|
||||
tapPoint: vi.fn(),
|
||||
waitForControl: vi.fn(),
|
||||
waitForControlByPrefix: vi.fn(),
|
||||
waitForLabel: vi.fn(),
|
||||
waitForDocument: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: mocks.captureScreenshot
|
||||
}))
|
||||
vi.mock('../../scripts/emulator-developer-menu-dismissal.mjs', () => ({
|
||||
dismissEmulatorDeveloperMenuIfPresent: mocks.dismissDeveloperMenu
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({
|
||||
tapHostedIosAccessibilityControl: mocks.tapControl,
|
||||
tapHostedIosAccessibilityControlByLabelPrefix: mocks.tapControlByPrefix,
|
||||
tapHostedIosPoint: mocks.tapPoint,
|
||||
waitForHostedIosAccessibilityControl: mocks.waitForControl,
|
||||
waitForHostedIosAccessibilityControlByLabelPrefix: mocks.waitForControlByPrefix,
|
||||
waitForHostedIosAccessibilityLabel: mocks.waitForLabel
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-screenshot-parity.mjs', () => ({
|
||||
assertHostedIosScreenshotParity: mocks.compareScreenshots
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({
|
||||
activateHostedWebViewControl: mocks.activateControl,
|
||||
readHostedWebViewState: mocks.readState,
|
||||
readHostedWebViewTextPoint: mocks.readTextPoint,
|
||||
waitForVisibleHostedWebView: mocks.waitForDocument
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-webview-control-point.mjs', () => ({
|
||||
readHostedWebViewControlPoint: mocks.readControlPoint
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-webview-workspace-activation.mjs', () => ({
|
||||
activateHostedWorkspaceRow: mocks.activateWorkspace
|
||||
}))
|
||||
|
||||
import {
|
||||
captureHostedFilesPreviewParity,
|
||||
captureNativeFilesPreviewBaselines
|
||||
} from '../../scripts/hosted-ios-files-preview-parity.mjs'
|
||||
|
||||
describe('hosted iOS Files and Preview parity', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.captureScreenshot.mockImplementation((_command, _args, callback) =>
|
||||
callback(null, '', '')
|
||||
)
|
||||
mocks.compareScreenshots.mockResolvedValue({ changedPixelRatio: 0.01 })
|
||||
mocks.readControlPoint.mockResolvedValue({ x: 0.8, y: 0.1 })
|
||||
mocks.readState.mockResolvedValue({
|
||||
href: 'orca-mobile-web://build/h/host/files/worktree',
|
||||
bodyText: 'Files Casks orca.rb',
|
||||
labels: ['Open folder Casks', 'Preview file orca.rb', 'File preview'],
|
||||
placeholders: []
|
||||
})
|
||||
mocks.readTextPoint.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
mocks.tapControl.mockResolvedValue(undefined)
|
||||
mocks.tapControlByPrefix.mockResolvedValue(undefined)
|
||||
mocks.tapPoint.mockResolvedValue(undefined)
|
||||
mocks.waitForControl.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
mocks.waitForControlByPrefix.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
mocks.waitForLabel.mockResolvedValue({
|
||||
frame: { x: 0, y: 0.3, width: 1, height: 1.5 },
|
||||
label: 'File preview',
|
||||
value: ''
|
||||
})
|
||||
mocks.waitForDocument.mockImplementation(({ expectedHrefIncludes }) => ({
|
||||
href: `orca-mobile-web://build${expectedHrefIncludes ?? '/h/host'}`,
|
||||
targetId: expectedHrefIncludes ?? 'workspace'
|
||||
}))
|
||||
})
|
||||
|
||||
it('captures native Files and Preview and returns to worktrees', async () => {
|
||||
const baselines = await captureNativeFilesPreviewBaselines({
|
||||
deviceUdid: 'simulator',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(mocks.tapControl).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
'Open file explorer',
|
||||
30_000
|
||||
)
|
||||
expect(mocks.tapControl).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
'Preview file orca.rb',
|
||||
30_000
|
||||
)
|
||||
expect(mocks.tapControl).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
'Back to worktrees',
|
||||
30_000
|
||||
)
|
||||
expect(baselines).toEqual({
|
||||
files: {
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-files-portrait.png'
|
||||
},
|
||||
preview: {
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-file-preview-portrait.png'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('compares hosted Files and Preview and returns to the workspace route', async () => {
|
||||
const result = await captureHostedFilesPreviewParity({
|
||||
deviceUdid: 'simulator',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
nativeBaselines: {
|
||||
files: {
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-files-portrait.png'
|
||||
},
|
||||
preview: {
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-file-preview-portrait.png'
|
||||
}
|
||||
},
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
timeoutMs: 30_000,
|
||||
workspaceDocument: { href: 'orca-mobile-web://build/h/host' }
|
||||
})
|
||||
|
||||
expect(result.evidence.files.screenshotParity).toEqual({ changedPixelRatio: 0.01 })
|
||||
expect(result.evidence.preview.screenshotParity).toEqual({ changedPixelRatio: 0.01 })
|
||||
expect(result.workspaceDocument.href).toContain('/h/host')
|
||||
expect(mocks.activateWorkspace).toHaveBeenCalledOnce()
|
||||
expect(mocks.tapControl).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
'Open file explorer',
|
||||
5_000
|
||||
)
|
||||
expect(mocks.tapControl).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
'Preview file orca.rb',
|
||||
5_000
|
||||
)
|
||||
expect(mocks.compareScreenshots).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('uses a measured WebView point when a control is missing from native accessibility', async () => {
|
||||
mocks.tapControl.mockRejectedValueOnce(new Error('missing accessibility descendant'))
|
||||
|
||||
await captureHostedFilesPreviewParity({
|
||||
deviceUdid: 'simulator',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
nativeBaselines: {
|
||||
files: {
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-files-portrait.png'
|
||||
},
|
||||
preview: {
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-file-preview-portrait.png'
|
||||
}
|
||||
},
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
timeoutMs: 30_000,
|
||||
workspaceDocument: { href: 'orca-mobile-web://build/h/host' }
|
||||
})
|
||||
|
||||
expect(mocks.readControlPoint).toHaveBeenCalledWith(expect.anything(), 'Open file explorer')
|
||||
expect(mocks.tapPoint).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, { x: 0.8, y: 0.1 })
|
||||
})
|
||||
})
|
||||
@@ -1,159 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
runHostedHostOriginSourceControlStep,
|
||||
verifyHostedHostOriginSourceControlJourney
|
||||
} from '../../scripts/hosted-ios-host-origin-source-control-journey.mjs'
|
||||
|
||||
const tapByLabelPrefix = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
tapHostedIosAccessibilityControlByLabelPrefix: tapByLabelPrefix
|
||||
}))
|
||||
|
||||
const CHANGED_FILE = 'mobile/src/mobile-web/bridge.ts'
|
||||
const CHANGED_FILE_LABEL = `Open changed file ${CHANGED_FILE}`
|
||||
|
||||
function journeyOperations(labels: string[]) {
|
||||
const sourceControl = {
|
||||
href: 'orca-mobile-web://build/h/host/source-control/workspace'
|
||||
}
|
||||
const review = { href: 'orca-mobile-web://build/h/host/review/workspace' }
|
||||
const returnedWorkspace = { href: 'orca-mobile-web://build/' }
|
||||
const activate = vi.fn().mockResolvedValue(undefined)
|
||||
const longPress = vi.fn().mockResolvedValue(undefined)
|
||||
const tapNative = vi.fn().mockResolvedValue(undefined)
|
||||
const waitForDocument = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(sourceControl)
|
||||
.mockResolvedValueOnce(review)
|
||||
.mockResolvedValueOnce(sourceControl)
|
||||
.mockResolvedValueOnce(sourceControl)
|
||||
.mockResolvedValueOnce(returnedWorkspace)
|
||||
const readState = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ href: sourceControl.href, labels })
|
||||
.mockResolvedValueOnce({ href: review.href, labels: ['Show all review files'] })
|
||||
.mockResolvedValueOnce({ href: review.href, labels: ['Back', 'Open review actions'] })
|
||||
return {
|
||||
documents: { returnedWorkspace, review, sourceControl },
|
||||
operations: { activate, longPress, readState, tapNative, waitForDocument }
|
||||
}
|
||||
}
|
||||
|
||||
describe('hosted iOS host-origin Source Control journey', () => {
|
||||
it.each([
|
||||
['native baseline', { changedFileLabel: CHANGED_FILE_LABEL }, undefined],
|
||||
['fixture changed file', null, CHANGED_FILE]
|
||||
])('opens mobile Review from the %s oracle', async (_name, nativeBaseline, changedFilePath) => {
|
||||
const { documents, operations } = journeyOperations([
|
||||
'Refresh source control',
|
||||
CHANGED_FILE_LABEL
|
||||
])
|
||||
|
||||
const result = await verifyHostedHostOriginSourceControlJourney({
|
||||
changedFilePath,
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { udid: 'SIMULATOR-1' },
|
||||
nativeBaseline,
|
||||
timeoutMs: 30_000,
|
||||
workspaceName: 'mobile-rearch',
|
||||
operations
|
||||
})
|
||||
|
||||
expect(operations.longPress).toHaveBeenCalledWith(
|
||||
{ udid: 'SIMULATOR-1' },
|
||||
'mobile-rearch',
|
||||
30_000,
|
||||
undefined,
|
||||
'Source Control'
|
||||
)
|
||||
expect(operations.tapNative).toHaveBeenCalledWith(
|
||||
{ udid: 'SIMULATOR-1' },
|
||||
'Source Control',
|
||||
30_000
|
||||
)
|
||||
expect(operations.activate.mock.calls.map((call) => call[1])).toEqual([
|
||||
{ kind: 'label', value: CHANGED_FILE_LABEL },
|
||||
{ kind: 'label', value: 'Back' },
|
||||
{ kind: 'label', value: 'Back to session' }
|
||||
])
|
||||
expect(operations.waitForDocument).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ expectedHrefIncludes: '/review/' })
|
||||
)
|
||||
expect(operations.waitForDocument).toHaveBeenCalledTimes(5)
|
||||
expect(result).toMatchObject({
|
||||
changedFileLabel: CHANGED_FILE_LABEL,
|
||||
reviewRoute: documents.review.href,
|
||||
sourceControlRoute: documents.sourceControl.href,
|
||||
workspaceDocument: documents.returnedWorkspace
|
||||
})
|
||||
})
|
||||
|
||||
it('fails when the fixture changed file is absent from Source Control', async () => {
|
||||
const { operations } = journeyOperations([
|
||||
'Refresh source control',
|
||||
'Open changed file mobile/app/index.tsx'
|
||||
])
|
||||
|
||||
await expect(
|
||||
verifyHostedHostOriginSourceControlJourney({
|
||||
changedFilePath: CHANGED_FILE,
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { udid: 'SIMULATOR-1' },
|
||||
nativeBaseline: null,
|
||||
timeoutMs: 30_000,
|
||||
workspaceName: 'mobile-rearch',
|
||||
operations
|
||||
})
|
||||
).rejects.toThrow(`Host-origin Source Control is missing ${CHANGED_FILE_LABEL}`)
|
||||
expect(operations.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to run without an independent expected changed file', async () => {
|
||||
const { operations } = journeyOperations(['Refresh source control', CHANGED_FILE_LABEL])
|
||||
|
||||
await expect(
|
||||
verifyHostedHostOriginSourceControlJourney({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { udid: 'SIMULATOR-1' },
|
||||
nativeBaseline: null,
|
||||
timeoutMs: 30_000,
|
||||
workspaceName: 'mobile-rearch',
|
||||
operations
|
||||
})
|
||||
).rejects.toThrow('needs a native baseline or a fixture changed file path')
|
||||
expect(operations.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'the adversarial fixture row',
|
||||
{ workspaceRowName: 'orca-adversarial-row' },
|
||||
'orca-adversarial-row'
|
||||
],
|
||||
['the worktree basename', undefined, 'mobile-rearch']
|
||||
])('lands back on %s after the host-origin step', async (_name, adversarialFixture, expected) => {
|
||||
const hostOrigin = { workspaceDocument: { href: 'orca-mobile-web://build/' } }
|
||||
// The journey itself is covered above; the step only owns the workspace row it returns to.
|
||||
const evidenceStep = vi.fn().mockResolvedValue(hostOrigin)
|
||||
|
||||
await expect(
|
||||
runHostedHostOriginSourceControlStep({
|
||||
adversarialFixture,
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { udid: 'SIMULATOR-1' },
|
||||
evidenceStep,
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
nativeBaseline: null,
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
).resolves.toBe(hostOrigin)
|
||||
|
||||
expect(evidenceStep).toHaveBeenCalledWith(
|
||||
'host-origin Source Control journey',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(tapByLabelPrefix).toHaveBeenLastCalledWith({ udid: 'SIMULATOR-1' }, expected, 30_000)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openHostedIosHybridRoute } from '../../scripts/hosted-ios-hybrid-route-handoff.mjs'
|
||||
|
||||
describe('hosted iOS hybrid route handoff', () => {
|
||||
it('opens the production route without an Experimental Settings entry', async () => {
|
||||
const openUrl = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await openHostedIosHybridRoute({ deviceUdid: 'simulator' }, 10_000, openUrl)
|
||||
|
||||
expect(openUrl).toHaveBeenCalledWith('simulator', 'orca:///hybrid')
|
||||
})
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
readIosActivation,
|
||||
readIosRollbackActivation
|
||||
} from '../../scripts/hosted-ios-mobile-web-cache.mjs'
|
||||
import { selectHostedIosWebContentPid } from '../../scripts/hosted-ios-webcontent-process.mjs'
|
||||
|
||||
const active = 'a'.repeat(64)
|
||||
const previous = 'b'.repeat(64)
|
||||
const temporaryRoots: string[] = []
|
||||
const harnessSource = readFileSync(
|
||||
new URL('../../scripts/run-hosted-ios-webview-crash-loop.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true })))
|
||||
})
|
||||
|
||||
describe('hosted iOS mobile web cache evidence', () => {
|
||||
it('selects the only host with an active and previous generation', async () => {
|
||||
const root = await createAppData()
|
||||
await writeActivation(root, '1'.repeat(64), { active })
|
||||
const candidate = await writeActivation(root, '2'.repeat(64), { active, previous })
|
||||
|
||||
await expect(readIosRollbackActivation(root)).resolves.toEqual({
|
||||
path: candidate,
|
||||
active,
|
||||
previous
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes an omitted previous generation and rejects invalid identities', async () => {
|
||||
const root = await createAppData()
|
||||
const valid = await writeActivation(root, '1'.repeat(64), { active })
|
||||
const invalid = await writeActivation(root, '2'.repeat(64), { active: '../active' })
|
||||
|
||||
await expect(readIosActivation(valid)).resolves.toEqual({ active, previous: null })
|
||||
await expect(readIosActivation(invalid)).rejects.toThrow(
|
||||
'iOS cache returned an invalid activation record'
|
||||
)
|
||||
})
|
||||
|
||||
it('selects only the simulator WebContent child', () => {
|
||||
const processes = [
|
||||
' 100 1 /System/Library/com.apple.WebKit.WebContent',
|
||||
' 200 67323 /Runtime/WebContentExtension.appex/com.apple.WebKit.WebContent -LaunchArguments',
|
||||
' 300 90000 /Runtime/WebContentExtension.appex/com.apple.WebKit.WebContent -LaunchArguments'
|
||||
].join('\n')
|
||||
|
||||
expect(selectHostedIosWebContentPid(processes, 67323)).toBe(200)
|
||||
expect(() => selectHostedIosWebContentPid(`${processes}\n${processes}`, 67323)).toThrow(
|
||||
'Expected one iOS WebContent process, found 2'
|
||||
)
|
||||
})
|
||||
|
||||
it('kills three WebContent processes and requires native activation rollback', () => {
|
||||
expect(harnessSource).toContain('const failureCount = 3')
|
||||
expect(harnessSource).toContain('terminateHostedIosWebContent(deviceUdid)')
|
||||
expect(harnessSource).toContain('initial.previous')
|
||||
expect(harnessSource).toContain('waitForIosActivation(')
|
||||
expect(harnessSource).toContain('>= 60_000')
|
||||
expect(harnessSource).toContain('documents.at(-1)?.href === documents[0]?.href')
|
||||
})
|
||||
})
|
||||
|
||||
async function createAppData() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-ios-cache-evidence-'))
|
||||
temporaryRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function writeActivation(
|
||||
root: string,
|
||||
hostIdentity: string,
|
||||
value: { active: string; previous?: string }
|
||||
) {
|
||||
const hostRoot = path.join(root, 'Library', 'Application Support', 'OrcaMobileWeb', hostIdentity)
|
||||
await mkdir(hostRoot, { recursive: true })
|
||||
const activationPath = path.join(hostRoot, 'activation.json')
|
||||
await writeFile(activationPath, JSON.stringify(value))
|
||||
return activationPath
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { verifyHostedIosNativeAlertJourney } from '../../scripts/hosted-ios-native-alert-journey.mjs'
|
||||
|
||||
describe('hosted iOS native Alert journey', () => {
|
||||
it('presents an OS alert, correlates its action, and returns to workspaces', async () => {
|
||||
const workspaceDocument = { href: 'orca-mobile-web://build/h/host' }
|
||||
const sessionDocument = { href: 'orca-mobile-web://build/h/host/session/workspace' }
|
||||
const returnedDocument = { href: 'orca-mobile-web://build/h/host' }
|
||||
const evaluate = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(JSON.stringify({ started: true }))
|
||||
.mockResolvedValueOnce(JSON.stringify({ posted: true }))
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({ status: 'success', payload: { kind: 'button', buttonIndex: 0 } })
|
||||
)
|
||||
const waitForDocument = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(sessionDocument)
|
||||
.mockResolvedValueOnce(returnedDocument)
|
||||
const activateWorkspace = vi.fn().mockResolvedValue(undefined)
|
||||
const activateControl = vi.fn().mockResolvedValue(undefined)
|
||||
const waitForLabel = vi.fn().mockResolvedValue({ frame: { x: 0, y: 0, width: 1, height: 1 } })
|
||||
const tapControl = vi.fn().mockResolvedValue({ x: 0.5, y: 0.5 })
|
||||
const waitForLabelToDisappear = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await verifyHostedIosNativeAlertJourney(
|
||||
{
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
timeoutMs: 30_000,
|
||||
workspaceDocument
|
||||
},
|
||||
{
|
||||
activateControl,
|
||||
activateWorkspace,
|
||||
evaluate,
|
||||
tapControl,
|
||||
waitForDocument,
|
||||
waitForLabel,
|
||||
waitForLabelToDisappear
|
||||
}
|
||||
)
|
||||
|
||||
expect(tapControl).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, 'Keep editing', 30_000)
|
||||
expect(activateControl).toHaveBeenCalledWith(sessionDocument, {
|
||||
kind: 'label',
|
||||
value: 'Back to worktrees'
|
||||
})
|
||||
expect(waitForDocument).toHaveBeenLastCalledWith({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
expectedText: 'mobile-rearch',
|
||||
expectedPathname: '/h/host',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(result).toEqual({
|
||||
evidence: {
|
||||
button: { x: 0.5, y: 0.5 },
|
||||
buttonIndex: 0,
|
||||
presentation: 'native',
|
||||
title: { x: 0, y: 0, width: 1, height: 1 }
|
||||
},
|
||||
workspaceDocument: returnedDocument
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
tap: vi.fn(),
|
||||
wait: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({
|
||||
tapHostedIosAccessibilityControl: mocks.tap,
|
||||
waitForHostedIosAccessibilityControlByLabelPrefix: mocks.wait
|
||||
}))
|
||||
|
||||
import { completeHostedIosNativeOnboarding } from '../../scripts/hosted-ios-native-onboarding.mjs'
|
||||
|
||||
describe('hosted iOS native onboarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.tap.mockResolvedValue({ x: 0.5, y: 0.5 })
|
||||
mocks.wait.mockResolvedValue({ x: 0.5, y: 0.5 })
|
||||
})
|
||||
|
||||
it('completes the existing native decisions before entering the hosted workspace', async () => {
|
||||
mocks.wait.mockRejectedValueOnce(new Error('workspace is not visible'))
|
||||
const emulator = { deviceUdid: 'simulator' }
|
||||
|
||||
await expect(
|
||||
completeHostedIosNativeOnboarding(emulator, 'mobile-rearch', 30_000)
|
||||
).resolves.toEqual({
|
||||
sessionView: 'terminal',
|
||||
notifications: 'skipped'
|
||||
})
|
||||
expect(mocks.tap).toHaveBeenNthCalledWith(1, emulator, 'Open sessions in the terminal', 30_000)
|
||||
expect(mocks.tap).toHaveBeenNthCalledWith(2, emulator, 'Skip notifications for now', 30_000)
|
||||
expect(mocks.wait).toHaveBeenNthCalledWith(1, emulator, 'mobile-rearch', 1_000)
|
||||
expect(mocks.wait).toHaveBeenNthCalledWith(2, emulator, 'mobile-rearch', 5_000)
|
||||
})
|
||||
|
||||
it('returns from an automatically opened session before selecting the workspace', async () => {
|
||||
mocks.wait
|
||||
.mockRejectedValueOnce(new Error('workspace is not visible'))
|
||||
.mockRejectedValueOnce(new Error('workspace is not visible'))
|
||||
.mockResolvedValueOnce({ x: 0.5, y: 0.5 })
|
||||
const emulator = { deviceUdid: 'simulator' }
|
||||
|
||||
await completeHostedIosNativeOnboarding(emulator, 'mobile-rearch', 30_000)
|
||||
|
||||
expect(mocks.tap).toHaveBeenNthCalledWith(3, emulator, 'Back to worktrees', 5_000)
|
||||
expect(mocks.wait).toHaveBeenNthCalledWith(3, emulator, 'mobile-rearch', 30_000)
|
||||
})
|
||||
|
||||
it('preserves an already completed native route', async () => {
|
||||
const emulator = { deviceUdid: 'simulator' }
|
||||
|
||||
await expect(
|
||||
completeHostedIosNativeOnboarding(emulator, 'mobile-rearch', 30_000)
|
||||
).resolves.toEqual({
|
||||
sessionView: 'retained',
|
||||
notifications: 'retained'
|
||||
})
|
||||
expect(mocks.wait).toHaveBeenCalledWith(emulator, 'mobile-rearch', 1_000)
|
||||
expect(mocks.tap).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
activate: vi.fn(),
|
||||
restartController: vi.fn(),
|
||||
waitForDocument: vi.fn(),
|
||||
tapAccessibility: vi.fn(),
|
||||
waitForAccessibility: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({
|
||||
activateHostedWebViewControl: mocks.activate,
|
||||
waitForVisibleHostedWebView: mocks.waitForDocument
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({
|
||||
restartHostedIosEmulatorController: mocks.restartController,
|
||||
tapHostedIosAccessibilityControl: mocks.tapAccessibility,
|
||||
waitForHostedIosAccessibilityControl: mocks.waitForAccessibility
|
||||
}))
|
||||
|
||||
import { verifyHostedNativeTerminalSettingsHandoff } from '../../scripts/hosted-ios-native-settings-handoff.mjs'
|
||||
|
||||
describe('hosted iOS native settings handoff', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.activate.mockResolvedValue(undefined)
|
||||
mocks.restartController.mockResolvedValue(undefined)
|
||||
mocks.waitForDocument
|
||||
.mockResolvedValueOnce({ href: 'orca-mobile-web://build/session/workspace' })
|
||||
.mockResolvedValueOnce({ href: 'orca-mobile-web://build/session/workspace' })
|
||||
mocks.tapAccessibility.mockResolvedValue({ x: 0.5, y: 0.7 })
|
||||
mocks.waitForAccessibility.mockResolvedValue({ x: 0.5, y: 0.2 })
|
||||
})
|
||||
|
||||
it('uses a native touch and returns to the same hosted session', async () => {
|
||||
const emulator = { deviceUdid: 'simulator' }
|
||||
|
||||
await expect(
|
||||
verifyHostedNativeTerminalSettingsHandoff({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator,
|
||||
sessionDocument: { href: 'orca-mobile-web://build/session/workspace' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
).resolves.toEqual({
|
||||
route: 'orca-mobile-web://build/session/workspace',
|
||||
nativeTapPoint: { x: 0.5, y: 0.7 }
|
||||
})
|
||||
|
||||
expect(mocks.activate).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: 'label',
|
||||
value: 'Add custom shortcut',
|
||||
reveal: true
|
||||
})
|
||||
expect(mocks.restartController).toHaveBeenCalledWith(emulator)
|
||||
expect(mocks.tapAccessibility).toHaveBeenCalledWith(emulator, 'Manage Shortcuts', 30_000)
|
||||
expect(mocks.waitForAccessibility).toHaveBeenCalledWith(
|
||||
emulator,
|
||||
'WHEN YOU LEAVE THE APP',
|
||||
30_000
|
||||
)
|
||||
expect(mocks.tapAccessibility).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
emulator,
|
||||
'Back to hosted session',
|
||||
30_000
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a native return that changes the hosted session route', async () => {
|
||||
mocks.waitForDocument
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({ href: 'orca-mobile-web://build/session/one' })
|
||||
.mockResolvedValueOnce({ href: 'orca-mobile-web://build/session/two' })
|
||||
|
||||
await expect(
|
||||
verifyHostedNativeTerminalSettingsHandoff({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: {},
|
||||
sessionDocument: {},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
).rejects.toThrow('same hosted session route')
|
||||
})
|
||||
})
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
resetHostedIosPhotosPermission,
|
||||
verifyHostedIosPhotoPermissionDenial
|
||||
} from '../../scripts/hosted-ios-photo-permission-denial.mjs'
|
||||
|
||||
const args = {
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
orcaCli: './config/scripts/orca-dev.mjs',
|
||||
pairingRuntimeUserDataPath: '/tmp/pairing/userData',
|
||||
sessionDocument: {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
},
|
||||
terminalHandle: 'terminal-handle',
|
||||
timeoutMs: 30_000,
|
||||
worktree: '/repo/mobile-rearch'
|
||||
}
|
||||
|
||||
function createOperations() {
|
||||
return {
|
||||
readState: vi.fn().mockResolvedValue({
|
||||
bodyText: 'Mobile Emulator\nPhoto permission denied'
|
||||
}),
|
||||
readTerminal: vi.fn().mockResolvedValue(['ORCA_HOSTED_CLIPBOARD_TEXT_PASTE']),
|
||||
tapControl: vi.fn().mockResolvedValue({ x: 0.75, y: 0.9 }),
|
||||
tapPoint: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
waitForDocument: vi.fn().mockResolvedValue(args.sessionDocument),
|
||||
waitForPrompt: vi.fn().mockResolvedValue({
|
||||
label: 'Don’t Allow',
|
||||
x: 0.5,
|
||||
y: 0.6
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('hosted iOS Photos permission denial', () => {
|
||||
it('resets only the app Photos permission before launch', async () => {
|
||||
const runCommand = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await resetHostedIosPhotosPermission('simulator', runCommand)
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith('xcrun', [
|
||||
'simctl',
|
||||
'privacy',
|
||||
'simulator',
|
||||
'reset',
|
||||
'photos',
|
||||
'com.stably.orca.mobile'
|
||||
])
|
||||
})
|
||||
|
||||
it('denies through the unchanged Attach flow without changing terminal output', async () => {
|
||||
const operations = createOperations()
|
||||
|
||||
await expect(verifyHostedIosPhotoPermissionDenial(args, operations)).resolves.toEqual({
|
||||
evidence: {
|
||||
activationAttempts: 1,
|
||||
attachControlPoint: { x: 0.75, y: 0.9 },
|
||||
denialControl: { label: 'Don’t Allow', x: 0.5, y: 0.6 },
|
||||
privilegedPageMarkers: 'absent',
|
||||
route: args.sessionDocument.href,
|
||||
terminalOutput: 'unchanged',
|
||||
toast: 'Photo permission denied'
|
||||
},
|
||||
sessionDocument: args.sessionDocument
|
||||
})
|
||||
|
||||
expect(operations.tapControl).toHaveBeenCalledWith(
|
||||
args.emulator,
|
||||
'Attach a photo',
|
||||
args.timeoutMs
|
||||
)
|
||||
expect(operations.waitForPrompt).toHaveBeenCalledWith(
|
||||
args.emulator,
|
||||
['Don’t Allow', "Don't Allow"],
|
||||
5_000
|
||||
)
|
||||
expect(operations.tapPoint).toHaveBeenCalledWith(args.emulator, {
|
||||
label: 'Don’t Allow',
|
||||
x: 0.5,
|
||||
y: 0.6
|
||||
})
|
||||
expect(operations.readTerminal).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries a silent Attach activation before denying the prompt', async () => {
|
||||
const operations = createOperations()
|
||||
operations.waitForPrompt
|
||||
.mockRejectedValueOnce(new Error('prompt not shown'))
|
||||
.mockResolvedValueOnce({ label: "Don't Allow", x: 0.5, y: 0.6 })
|
||||
|
||||
const result = await verifyHostedIosPhotoPermissionDenial(args, operations)
|
||||
|
||||
expect(result.evidence).toMatchObject({
|
||||
activationAttempts: 2,
|
||||
denialControl: { label: "Don't Allow" }
|
||||
})
|
||||
expect(operations.tapControl).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects a privileged image marker in hosted page state', async () => {
|
||||
const operations = createOperations()
|
||||
operations.readState.mockResolvedValue({
|
||||
bodyText: 'Photo permission denied\ndata:image/png;base64,AAAA'
|
||||
})
|
||||
|
||||
await expect(verifyHostedIosPhotoPermissionDenial(args, operations)).rejects.toThrow(
|
||||
'exposed privileged page marker: data:image/'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects any terminal output change after denial', async () => {
|
||||
const operations = createOperations()
|
||||
operations.readTerminal
|
||||
.mockResolvedValueOnce(['before'])
|
||||
.mockResolvedValueOnce(['before', '/tmp/orca-paste-private.png'])
|
||||
|
||||
await expect(verifyHostedIosPhotoPermissionDenial(args, operations)).rejects.toThrow(
|
||||
'changed the Desktop terminal'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts terminal row reflow when the character stream is unchanged', async () => {
|
||||
const operations = createOperations()
|
||||
operations.readTerminal
|
||||
.mockResolvedValueOnce([
|
||||
'jinwoo ~/orca/',
|
||||
'ORCA_HOSTED_CLIPBOARD_TEXT_PASTE',
|
||||
'/tmp/orca-paste.png'
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
'jinwoo ~/orca/workspaces/orca/mobile-rearch [mobile-rearch] $ ',
|
||||
'ORCA_HOSTED_CLIPBOARD_',
|
||||
'TEXT_PASTE/tmp/orca-paste.png'
|
||||
])
|
||||
|
||||
await expect(verifyHostedIosPhotoPermissionDenial(args, operations)).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -1,331 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
backgroundHostedIosMobileApp,
|
||||
grantHostedIosPhotosPermission,
|
||||
launchHostedIosMobileApp,
|
||||
revokeHostedIosPhotosPermission,
|
||||
verifyHostedIosPhotoPermissionRevocation
|
||||
} from '../../scripts/hosted-ios-photo-permission-revocation.mjs'
|
||||
|
||||
const args = {
|
||||
deviceUdid: 'simulator',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
orcaCli: './config/scripts/orca-dev.mjs',
|
||||
pairingRuntimeUserDataPath: '/tmp/pairing/userData',
|
||||
sessionDocument: {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
},
|
||||
terminalHandle: 'terminal-handle',
|
||||
timeoutMs: 30_000,
|
||||
worktree: '/repo/mobile-rearch'
|
||||
}
|
||||
const grantedDocument = {
|
||||
href: args.sessionDocument.href
|
||||
.replace('://build/', '://grant-restart/')
|
||||
.replace('/workspace', '/grant-workspace')
|
||||
}
|
||||
const revokedDocument = {
|
||||
href: args.sessionDocument.href
|
||||
.replace('://build/', '://revocation-restart/')
|
||||
.replace('/workspace', '/revoked-workspace')
|
||||
}
|
||||
|
||||
function createOperations() {
|
||||
return {
|
||||
activateWorkspace: vi.fn().mockResolvedValue(undefined),
|
||||
backgroundApp: vi.fn().mockResolvedValue(undefined),
|
||||
dismissDeveloperMenu: vi.fn().mockResolvedValue(false),
|
||||
grantPermission: vi.fn().mockResolvedValue(undefined),
|
||||
launchApp: vi.fn().mockResolvedValue(undefined),
|
||||
openHybridRoute: vi.fn().mockResolvedValue(undefined),
|
||||
readState: vi.fn().mockResolvedValue({
|
||||
bodyText: 'Mobile Emulator\nPhoto permission denied'
|
||||
}),
|
||||
readTerminal: vi.fn().mockResolvedValue(['ORCA_HOSTED_CLIPBOARD_TEXT_PASTE']),
|
||||
revokePermission: vi.fn().mockResolvedValue(undefined),
|
||||
tapControl: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ x: 0.75, y: 0.9 })
|
||||
.mockResolvedValueOnce({ x: 0.75, y: 0.9 }),
|
||||
tapPoint: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
waitForControl: vi.fn().mockResolvedValue({ label: 'Cancel', x: 0.9, y: 0.1 }),
|
||||
waitForPickerDismissal: vi.fn().mockResolvedValue(undefined),
|
||||
waitForDocument: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(grantedDocument)
|
||||
.mockResolvedValueOnce(grantedDocument)
|
||||
.mockResolvedValueOnce(revokedDocument)
|
||||
}
|
||||
}
|
||||
|
||||
describe('hosted iOS Photos permission revocation', () => {
|
||||
it.each([
|
||||
['grant', grantHostedIosPhotosPermission],
|
||||
['revoke', revokeHostedIosPhotosPermission]
|
||||
])('%ss only the app Photos permission', async (action, changePermission) => {
|
||||
const runCommand = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await changePermission('simulator', runCommand)
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith('xcrun', [
|
||||
'simctl',
|
||||
'privacy',
|
||||
'simulator',
|
||||
action,
|
||||
'photos',
|
||||
'com.stably.orca.mobile'
|
||||
])
|
||||
})
|
||||
|
||||
it('launches only the exact mobile app', async () => {
|
||||
const runCommand = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await launchHostedIosMobileApp('simulator', runCommand)
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith('xcrun', [
|
||||
'simctl',
|
||||
'launch',
|
||||
'simulator',
|
||||
'com.stably.orca.mobile'
|
||||
])
|
||||
})
|
||||
|
||||
it('backgrounds through the emulator Home button', async () => {
|
||||
const runCommand = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await backgroundHostedIosMobileApp(args.emulator, runCommand)
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith(args.emulator, ['button', 'home'])
|
||||
})
|
||||
|
||||
it('opens under a grant, revokes, and denies without changing terminal output', async () => {
|
||||
const operations = createOperations()
|
||||
|
||||
await expect(verifyHostedIosPhotoPermissionRevocation(args, operations)).resolves.toEqual({
|
||||
evidence: {
|
||||
grantedAttachControlPoint: { x: 0.75, y: 0.9 },
|
||||
grantPrivateOriginRotated: true,
|
||||
grantPermissionPrompt: 'not-shown',
|
||||
grantSessionRecovery: 'session-retained',
|
||||
grantWorkspaceAuthorityRotated: true,
|
||||
interruptionPrivateOrigin: 'retained',
|
||||
interruptionSessionRecovery: 'session-retained',
|
||||
interruptionWorkspaceAuthority: 'retained',
|
||||
permissionState: 'revoked-after-grant',
|
||||
pickerInterruption: 'resumed-then-cancelled',
|
||||
pickerCancelControl: { label: 'Cancel', x: 0.9, y: 0.1 },
|
||||
privilegedPageMarkers: 'absent',
|
||||
revokedAttachControlPoint: { x: 0.75, y: 0.9 },
|
||||
revocationPrivateOriginRotated: true,
|
||||
revocationSessionRecovery: 'session-retained',
|
||||
revocationWorkspaceAuthorityRotated: true,
|
||||
route: revokedDocument.href,
|
||||
routeRestored: true,
|
||||
resumedPickerControl: { label: 'Cancel', x: 0.9, y: 0.1 },
|
||||
terminalOutput: 'unchanged',
|
||||
toast: 'Photo permission denied'
|
||||
},
|
||||
sessionDocument: revokedDocument
|
||||
})
|
||||
expect(operations.grantPermission).toHaveBeenCalledWith('simulator')
|
||||
expect(operations.revokePermission).toHaveBeenCalledWith('simulator')
|
||||
expect(operations.backgroundApp).toHaveBeenCalledOnce()
|
||||
expect(operations.backgroundApp).toHaveBeenCalledWith(args.emulator)
|
||||
expect(operations.waitForPickerDismissal).toHaveBeenCalledWith(args.emulator, 'Cancel', 5_000)
|
||||
expect(operations.launchApp).toHaveBeenCalledTimes(3)
|
||||
expect(operations.dismissDeveloperMenu).toHaveBeenCalledTimes(3)
|
||||
expect(operations.tapControl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
args.emulator,
|
||||
'Attach a photo',
|
||||
args.timeoutMs
|
||||
)
|
||||
expect(operations.tapControl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
args.emulator,
|
||||
'Attach a photo',
|
||||
args.timeoutMs
|
||||
)
|
||||
expect(operations.waitForControl).toHaveBeenCalledWith(
|
||||
args.emulator,
|
||||
['Cancel', 'Allow Full Access'],
|
||||
10_000
|
||||
)
|
||||
expect(operations.waitForControl).toHaveBeenCalledWith(args.emulator, ['Cancel'], 5_000)
|
||||
expect(operations.readTerminal).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects privileged page markers after revocation', async () => {
|
||||
const operations = createOperations()
|
||||
operations.readState.mockResolvedValue({
|
||||
bodyText: 'Photo permission denied\norca-paste-secret.png'
|
||||
})
|
||||
|
||||
await expect(verifyHostedIosPhotoPermissionRevocation(args, operations)).rejects.toThrow(
|
||||
'exposed privileged page marker: orca-paste-'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts the real full-access prompt before cancelling the granted picker', async () => {
|
||||
const operations = createOperations()
|
||||
operations.waitForControl
|
||||
.mockResolvedValueOnce({ label: 'Allow Full Access', x: 0.5, y: 0.7 })
|
||||
.mockResolvedValueOnce({ label: 'Cancel', x: 0.9, y: 0.1 })
|
||||
|
||||
const result = await verifyHostedIosPhotoPermissionRevocation(args, operations)
|
||||
|
||||
expect(result.evidence.grantPermissionPrompt).toBe('allowed-full-access')
|
||||
expect(operations.tapPoint).toHaveBeenNthCalledWith(1, args.emulator, {
|
||||
label: 'Allow Full Access',
|
||||
x: 0.5,
|
||||
y: 0.7
|
||||
})
|
||||
expect(operations.tapPoint).toHaveBeenNthCalledWith(2, args.emulator, {
|
||||
label: 'Cancel',
|
||||
x: 0.9,
|
||||
y: 0.1
|
||||
})
|
||||
expect(operations.backgroundApp).toHaveBeenCalledWith(args.emulator)
|
||||
})
|
||||
|
||||
it('rejects a private origin reused across the grant restart', async () => {
|
||||
const operations = createOperations()
|
||||
operations.waitForDocument.mockReset().mockResolvedValue(args.sessionDocument)
|
||||
|
||||
await expect(verifyHostedIosPhotoPermissionRevocation(args, operations)).rejects.toThrow(
|
||||
'grant reused the private WebView origin'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a private origin reused across the revocation restart', async () => {
|
||||
const operations = createOperations()
|
||||
operations.waitForDocument
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(grantedDocument)
|
||||
.mockResolvedValueOnce(grantedDocument)
|
||||
.mockResolvedValueOnce(grantedDocument)
|
||||
|
||||
await expect(verifyHostedIosPhotoPermissionRevocation(args, operations)).rejects.toThrow(
|
||||
'revocation reused the private WebView origin'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects private origin replacement across picker interruption', async () => {
|
||||
const operations = createOperations()
|
||||
const replacedOrigin = {
|
||||
href: grantedDocument.href.replace('://grant-restart/', '://interruption-restart/')
|
||||
}
|
||||
operations.waitForDocument
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(grantedDocument)
|
||||
.mockResolvedValueOnce(replacedOrigin)
|
||||
|
||||
await expect(verifyHostedIosPhotoPermissionRevocation(args, operations)).rejects.toThrow(
|
||||
'picker interruption authority did not match session-retained recovery'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects opaque authority replacement across picker interruption', async () => {
|
||||
const operations = createOperations()
|
||||
const replacedAuthority = {
|
||||
href: grantedDocument.href.replace('/grant-workspace', '/interrupted-workspace')
|
||||
}
|
||||
operations.waitForDocument
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(grantedDocument)
|
||||
.mockResolvedValueOnce(replacedAuthority)
|
||||
|
||||
await expect(verifyHostedIosPhotoPermissionRevocation(args, operations)).rejects.toThrow(
|
||||
'picker interruption authority did not match session-retained recovery'
|
||||
)
|
||||
})
|
||||
|
||||
it('requires rotated authority when picker interruption needs route handoff', async () => {
|
||||
const operations = createOperations()
|
||||
const workspaceDocument = { href: 'orca-mobile-web://workspace/h/host' }
|
||||
const interruptedDocument = {
|
||||
href: grantedDocument.href
|
||||
.replace('://grant-restart/', '://interruption-restart/')
|
||||
.replace('/grant-workspace', '/interrupted-workspace')
|
||||
}
|
||||
operations.waitForDocument
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(grantedDocument)
|
||||
.mockRejectedValueOnce(new Error('session not retained'))
|
||||
.mockResolvedValueOnce(workspaceDocument)
|
||||
.mockResolvedValueOnce(interruptedDocument)
|
||||
.mockResolvedValueOnce(revokedDocument)
|
||||
|
||||
const result = await verifyHostedIosPhotoPermissionRevocation(args, operations)
|
||||
|
||||
expect(result.evidence).toMatchObject({
|
||||
interruptionPrivateOrigin: 'rotated',
|
||||
interruptionSessionRecovery: 'hybrid-route-handoff',
|
||||
interruptionWorkspaceAuthority: 'rotated'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips row activation when the hybrid route restores the session itself', async () => {
|
||||
const operations = createOperations()
|
||||
operations.waitForDocument
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new Error('session not retained'))
|
||||
.mockResolvedValueOnce(grantedDocument)
|
||||
.mockResolvedValueOnce(grantedDocument)
|
||||
.mockResolvedValueOnce(grantedDocument)
|
||||
.mockResolvedValueOnce(revokedDocument)
|
||||
|
||||
const result = await verifyHostedIosPhotoPermissionRevocation(args, operations)
|
||||
|
||||
expect(result.evidence).toMatchObject({ grantSessionRecovery: 'hybrid-route-handoff' })
|
||||
expect(operations.openHybridRoute).toHaveBeenCalledOnce()
|
||||
expect(operations.activateWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reopens the production hybrid route after permission-triggered termination', async () => {
|
||||
const operations = createOperations()
|
||||
const workspaceDocument = { href: 'orca-mobile-web://workspace/h/host' }
|
||||
operations.waitForDocument
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new Error('session not retained'))
|
||||
.mockResolvedValueOnce(workspaceDocument)
|
||||
.mockResolvedValueOnce(grantedDocument)
|
||||
.mockResolvedValueOnce(grantedDocument)
|
||||
.mockRejectedValueOnce(new Error('session not retained'))
|
||||
.mockResolvedValueOnce(workspaceDocument)
|
||||
.mockResolvedValueOnce(revokedDocument)
|
||||
|
||||
const result = await verifyHostedIosPhotoPermissionRevocation(args, operations)
|
||||
|
||||
expect(result.evidence).toMatchObject({
|
||||
grantSessionRecovery: 'hybrid-route-handoff',
|
||||
interruptionSessionRecovery: 'session-retained',
|
||||
revocationSessionRecovery: 'hybrid-route-handoff',
|
||||
routeRestored: true
|
||||
})
|
||||
expect(operations.openHybridRoute).toHaveBeenCalledTimes(2)
|
||||
expect(operations.openHybridRoute).toHaveBeenCalledWith(args.emulator, args.timeoutMs)
|
||||
expect(operations.activateWorkspace).toHaveBeenCalledTimes(2)
|
||||
expect(operations.activateWorkspace).toHaveBeenCalledWith(
|
||||
workspaceDocument,
|
||||
args.expectedWorkspace,
|
||||
expect.any(Function),
|
||||
args.timeoutMs,
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects terminal mutations after revocation', async () => {
|
||||
const operations = createOperations()
|
||||
operations.readTerminal
|
||||
.mockResolvedValueOnce(['ORCA_HOSTED_CLIPBOARD_TEXT_PASTE'])
|
||||
.mockResolvedValueOnce(['ORCA_HOSTED_CLIPBOARD_TEXT_PASTE', '/tmp/orca-paste-private.png'])
|
||||
|
||||
await expect(verifyHostedIosPhotoPermissionRevocation(args, operations)).rejects.toThrow(
|
||||
'changed the Desktop terminal'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hostedIosLogStartTime,
|
||||
hostedIosPrivacyLogEvidence
|
||||
} from '../../scripts/hosted-ios-privacy-log-audit.mjs'
|
||||
|
||||
describe('hosted iOS privacy log audit', () => {
|
||||
it('formats simulator log start times without ISO separators', () => {
|
||||
const value = hostedIosLogStartTime(new Date(2026, 6, 29, 12, 34, 56).getTime())
|
||||
|
||||
expect(value).toBe('2026-07-29 12:34:56')
|
||||
})
|
||||
|
||||
it('accepts fixed-category connection and recovery logs', () => {
|
||||
expect(
|
||||
hostedIosPrivacyLogEvidence(
|
||||
'[net] state {"endpoint":"websocket","from":"connecting","to":"connected"}'
|
||||
)
|
||||
).toEqual({
|
||||
logBytes: 73,
|
||||
counts: {
|
||||
privilegedField: 0,
|
||||
tokenStorage: 0,
|
||||
nativeAuthority: 0,
|
||||
privateOriginUrl: 0,
|
||||
webSocketUrl: 0,
|
||||
fixtureMarker: 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'deviceToken',
|
||||
'orca.host-token.host-a',
|
||||
'openHostLogicalClient',
|
||||
'orca-mobile-web://abcdefghijklmnopqrstuvwxyz/',
|
||||
'wss://paired-host.invalid/socket',
|
||||
'ORCA_E2E_MOBILE_WEB_HOST_PUBLIC_KEY'
|
||||
])('rejects privileged log marker %s', (marker) => {
|
||||
expect(() => hostedIosPrivacyLogEvidence(marker)).toThrow('privacy log audit failed')
|
||||
})
|
||||
})
|
||||
@@ -1,131 +0,0 @@
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { PNG } from 'pngjs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertHostedIosScreenshotParity } from '../../scripts/hosted-ios-screenshot-parity.mjs'
|
||||
|
||||
describe('hosted iOS screenshot parity', () => {
|
||||
it('masks status-bar changes while enforcing the shared content surface', async () => {
|
||||
const native = createScreenshot(100, 100)
|
||||
const hosted = createScreenshot(100, 100)
|
||||
paintRows(hosted, 0, 6, 255)
|
||||
const paths = await writeScreenshots(native, hosted)
|
||||
|
||||
const result = await assertHostedIosScreenshotParity({
|
||||
...paths,
|
||||
nativeLandmark: { x: 0.5, y: 0.1 },
|
||||
hostedLandmark: { x: 0.7, y: 0.104 }
|
||||
})
|
||||
expect(result.changedPixelRatio).toBe(0)
|
||||
expect(result.landmarkDelta.x).toBeCloseTo(0.2)
|
||||
expect(result.landmarkDelta.y).toBeCloseTo(0.004)
|
||||
})
|
||||
|
||||
it('rejects a material change below the status bar', async () => {
|
||||
const native = createScreenshot(100, 100)
|
||||
const hosted = createScreenshot(100, 100)
|
||||
paintRows(hosted, 20, 40, 255)
|
||||
const paths = await writeScreenshots(native, hosted)
|
||||
|
||||
await expect(
|
||||
assertHostedIosScreenshotParity({
|
||||
...paths,
|
||||
nativeLandmark: { x: 0.5, y: 0.1 },
|
||||
hostedLandmark: { x: 0.5, y: 0.1 }
|
||||
})
|
||||
).rejects.toThrow('exceeded parity budgets')
|
||||
})
|
||||
|
||||
it('tolerates local text-rasterization shifts without hiding the raw difference', async () => {
|
||||
const native = createScreenshot(200, 200)
|
||||
const hosted = createScreenshot(200, 200)
|
||||
paintVerticalStripes(native, 20, 0)
|
||||
paintVerticalStripes(hosted, 20, 4)
|
||||
const paths = await writeScreenshots(native, hosted)
|
||||
|
||||
const result = await assertHostedIosScreenshotParity({
|
||||
...paths,
|
||||
nativeLandmark: { x: 0.5, y: 0.1 },
|
||||
hostedLandmark: { x: 0.5, y: 0.1 }
|
||||
})
|
||||
expect(result.rawChangedPixelRatio).toBeGreaterThan(0.1)
|
||||
expect(result.changedPixelRatio).toBe(0)
|
||||
expect(result.neighborhoodRadiusPixels).toBe(16)
|
||||
})
|
||||
|
||||
it('rejects a safe-area landmark shift even when the pixels match', async () => {
|
||||
const screenshot = createScreenshot(100, 100)
|
||||
const paths = await writeScreenshots(screenshot, screenshot)
|
||||
|
||||
await expect(
|
||||
assertHostedIosScreenshotParity({
|
||||
...paths,
|
||||
nativeLandmark: { x: 0.5, y: 0.1 },
|
||||
hostedLandmark: { x: 0.5, y: 0.11 }
|
||||
})
|
||||
).rejects.toThrow('exceeded parity budgets')
|
||||
})
|
||||
|
||||
it('supports pixel-only evidence when rotated WebView accessibility is unavailable', async () => {
|
||||
const screenshot = createScreenshot(100, 100)
|
||||
const paths = await writeScreenshots(screenshot, screenshot)
|
||||
|
||||
await expect(
|
||||
assertHostedIosScreenshotParity({
|
||||
...paths,
|
||||
nativeLandmark: null,
|
||||
hostedLandmark: null
|
||||
})
|
||||
).resolves.toMatchObject({ changedPixelRatio: 0, landmarkDelta: null })
|
||||
})
|
||||
})
|
||||
|
||||
function createScreenshot(width: number, height: number) {
|
||||
const png = new PNG({ width, height })
|
||||
png.data.fill(0)
|
||||
return png
|
||||
}
|
||||
|
||||
function paintRows(png: PNG, firstRow: number, lastRow: number, value: number) {
|
||||
for (let y = firstRow; y <= lastRow; y++) {
|
||||
for (let x = 0; x < png.width; x++) {
|
||||
const offset = (y * png.width + x) * 4
|
||||
png.data[offset] = value
|
||||
png.data[offset + 1] = value
|
||||
png.data[offset + 2] = value
|
||||
png.data[offset + 3] = 255
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function paintVerticalStripes(png: PNG, firstRow: number, xOffset: number) {
|
||||
for (let x = xOffset; x < png.width; x += 12) {
|
||||
for (let y = firstRow; y < png.height; y++) {
|
||||
paintPixel(png, x, y, 255)
|
||||
paintPixel(png, x + 1, y, 128)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function paintPixel(png: PNG, x: number, y: number, value: number) {
|
||||
if (x >= png.width) {
|
||||
return
|
||||
}
|
||||
const offset = (y * png.width + x) * 4
|
||||
png.data[offset] = value
|
||||
png.data[offset + 1] = value
|
||||
png.data[offset + 2] = value
|
||||
png.data[offset + 3] = 255
|
||||
}
|
||||
|
||||
async function writeScreenshots(native: PNG, hosted: PNG) {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'orca-hosted-ios-parity-'))
|
||||
const nativeScreenshot = path.join(directory, 'native.png')
|
||||
const hostedScreenshot = path.join(directory, 'hosted.png')
|
||||
await Promise.all([
|
||||
writeFile(nativeScreenshot, PNG.sync.write(native)),
|
||||
writeFile(hostedScreenshot, PNG.sync.write(hosted))
|
||||
])
|
||||
return { nativeScreenshot, hostedScreenshot }
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildAndInstallHostedIosSimulatorApp } from '../../scripts/hosted-ios-simulator-app-build.mjs'
|
||||
|
||||
describe('hosted iOS simulator app build', () => {
|
||||
it('removes persisted app state before installing the exact build', async () => {
|
||||
const runCommand = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
const appPath = await buildAndInstallHostedIosSimulatorApp({
|
||||
deviceUdid: 'simulator-1',
|
||||
worktree: '/worktree',
|
||||
runCommand
|
||||
})
|
||||
|
||||
expect(runCommand).toHaveBeenNthCalledWith(2, 'xcrun', [
|
||||
'simctl',
|
||||
'uninstall',
|
||||
'simulator-1',
|
||||
'com.stably.orca.mobile'
|
||||
])
|
||||
expect(runCommand).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'xcrun',
|
||||
['simctl', 'install', 'simulator-1', appPath],
|
||||
{ maxBuffer: 4 * 1024 * 1024 }
|
||||
)
|
||||
expect(runCommand).toHaveBeenNthCalledWith(4, 'xcrun', [
|
||||
'simctl',
|
||||
'spawn',
|
||||
'simulator-1',
|
||||
'defaults',
|
||||
'write',
|
||||
'com.stably.orca.mobile',
|
||||
'EXDevMenuShowFloatingActionButton',
|
||||
'-bool',
|
||||
'false'
|
||||
])
|
||||
})
|
||||
|
||||
it('continues when no prior app is installed', async () => {
|
||||
const runCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
.mockRejectedValueOnce(new Error('not installed'))
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
|
||||
await expect(
|
||||
buildAndInstallHostedIosSimulatorApp({
|
||||
deviceUdid: 'simulator-1',
|
||||
worktree: '/worktree',
|
||||
runCommand
|
||||
})
|
||||
).resolves.toContain('Orca.app')
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildAndInstall: vi.fn(),
|
||||
install: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-ios-simulator-app-build.mjs', () => ({
|
||||
buildAndInstallHostedIosSimulatorApp: mocks.buildAndInstall,
|
||||
hostedIosSimulatorAppPath: (worktree: string) => `${worktree}/Orca.app`,
|
||||
installHostedIosSimulatorApp: mocks.install
|
||||
}))
|
||||
|
||||
import { hostedIosSimulatorAppPreparation } from '../../scripts/hosted-ios-simulator-app-preparation.mjs'
|
||||
|
||||
describe('hosted iOS simulator app preparation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.buildAndInstall.mockResolvedValue('/worktree/Orca.app')
|
||||
mocks.install.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('reuses the installed app without reinstalling it', async () => {
|
||||
const runCommand = vi.fn().mockResolvedValue({ stdout: '/installed/Orca.app' })
|
||||
const preparation = hostedIosSimulatorAppPreparation({
|
||||
deviceUdid: 'simulator',
|
||||
reuseNativeInstall: true,
|
||||
skipNativeBuild: false,
|
||||
worktree: '/worktree',
|
||||
runCommand
|
||||
})
|
||||
|
||||
await expect(preparation.run()).resolves.toBe('/installed/Orca.app')
|
||||
expect(preparation.label).toBe('existing native simulator app')
|
||||
expect(runCommand).toHaveBeenCalledWith('xcrun', [
|
||||
'simctl',
|
||||
'get_app_container',
|
||||
'simulator',
|
||||
'com.stably.orca.mobile',
|
||||
'app'
|
||||
])
|
||||
expect(mocks.install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains the cached reinstall and build paths', async () => {
|
||||
const cached = hostedIosSimulatorAppPreparation({
|
||||
deviceUdid: 'simulator',
|
||||
reuseNativeInstall: false,
|
||||
skipNativeBuild: true,
|
||||
worktree: '/worktree'
|
||||
})
|
||||
const build = hostedIosSimulatorAppPreparation({
|
||||
deviceUdid: 'simulator',
|
||||
reuseNativeInstall: false,
|
||||
skipNativeBuild: false,
|
||||
worktree: '/worktree'
|
||||
})
|
||||
|
||||
await expect(cached.run()).resolves.toBe('/worktree/Orca.app')
|
||||
await expect(build.run()).resolves.toBe('/worktree/Orca.app')
|
||||
expect(mocks.install).toHaveBeenCalledWith({
|
||||
deviceUdid: 'simulator',
|
||||
appPath: '/worktree/Orca.app',
|
||||
runCommand: expect.any(Function)
|
||||
})
|
||||
expect(mocks.buildAndInstall).toHaveBeenCalledWith({
|
||||
deviceUdid: 'simulator',
|
||||
worktree: '/worktree',
|
||||
runCommand: expect.any(Function)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,531 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
captureParityScreen: vi.fn(),
|
||||
readBridgeErrors: vi.fn(),
|
||||
navigateRoute: vi.fn(),
|
||||
parityEvidence: vi.fn(),
|
||||
readControlPoint: vi.fn(),
|
||||
readState: vi.fn(),
|
||||
tapAccessibilityControl: vi.fn(),
|
||||
tapPoint: vi.fn(),
|
||||
startBridgeErrorObservation: vi.fn(),
|
||||
waitForDocument: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({
|
||||
readHostedWebViewState: mocks.readState,
|
||||
waitForVisibleHostedWebView: mocks.waitForDocument
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-webview-control-point.mjs', () => ({
|
||||
readHostedWebViewControlPoint: mocks.readControlPoint
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({
|
||||
tapHostedIosAccessibilityControl: mocks.tapAccessibilityControl,
|
||||
tapHostedIosPoint: mocks.tapPoint
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-webview-route-navigation.mjs', () => ({
|
||||
navigateHostedWebViewRoute: mocks.navigateRoute
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-webview-bridge-error-observation.mjs', () => ({
|
||||
readHostedWebViewBridgeErrors: mocks.readBridgeErrors,
|
||||
startHostedWebViewBridgeErrorObservation: mocks.startBridgeErrorObservation
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-ios-source-control-review-parity.mjs', () => ({
|
||||
captureHostedSourceControlReviewScreen: mocks.captureParityScreen,
|
||||
HEADLESS_REVIEW_OPEN_ERROR: 'renderer_unavailable',
|
||||
sourceControlReviewParityEvidence: mocks.parityEvidence
|
||||
}))
|
||||
|
||||
import { verifyHostedSourceControlReviewJourney } from '../../scripts/hosted-ios-source-control-review-journey.mjs'
|
||||
|
||||
describe('hosted iOS Source Control and Review journey', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.readControlPoint
|
||||
.mockResolvedValueOnce({ x: 0.4, y: 0.2 })
|
||||
.mockResolvedValueOnce({ x: 0.5, y: 0.4 })
|
||||
mocks.navigateRoute.mockResolvedValue(undefined)
|
||||
mocks.captureParityScreen
|
||||
.mockResolvedValueOnce({ screenshot: '/tmp/hosted-source-control.png' })
|
||||
.mockResolvedValueOnce({ screenshot: '/tmp/hosted-review.png' })
|
||||
mocks.parityEvidence
|
||||
.mockReturnValueOnce({ screen: 'source-control' })
|
||||
.mockReturnValueOnce({ screen: 'review' })
|
||||
mocks.tapAccessibilityControl.mockResolvedValue(undefined)
|
||||
mocks.tapPoint.mockResolvedValue(undefined)
|
||||
mocks.startBridgeErrorObservation.mockResolvedValue(undefined)
|
||||
mocks.readBridgeErrors.mockResolvedValue([])
|
||||
mocks.waitForDocument
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/source-control/workspace'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/review/workspace'
|
||||
})
|
||||
mocks.readState
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/source-control/workspace',
|
||||
bodyText: 'Source Control Changes Pull Request Commits Create pull request 128 on branch',
|
||||
labels: ['Refresh source control', 'Open changed file mobile/app/index.tsx']
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/review/workspace',
|
||||
bodyText: 'reviewed',
|
||||
labels: ['Back', 'Open review actions']
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the unchanged Session diff flow and standalone Review route', async () => {
|
||||
const emulator = { deviceUdid: 'simulator' }
|
||||
const sessionDocument = {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
}
|
||||
|
||||
await expect(
|
||||
verifyHostedSourceControlReviewJourney({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator,
|
||||
sessionDocument,
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
).resolves.toEqual({
|
||||
sourceControlRoute: 'orca-mobile-web://build/h/host/source-control/workspace',
|
||||
sourceControlSegments: ['Changes', 'Pull Request', 'Commits'],
|
||||
sessionDiffRoute: 'orca-mobile-web://build/h/host/session/workspace',
|
||||
reviewOpen: { headless: false, native: null },
|
||||
reviewRoute: 'orca-mobile-web://build/h/host/review/workspace',
|
||||
reviewControls: ['Back', 'Open review actions']
|
||||
})
|
||||
|
||||
expect(mocks.readControlPoint).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
sessionDocument,
|
||||
'Open source control'
|
||||
)
|
||||
expect(mocks.tapAccessibilityControl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
emulator,
|
||||
'Open source control',
|
||||
5_000
|
||||
)
|
||||
expect(mocks.tapAccessibilityControl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
emulator,
|
||||
'Open changed file mobile/app/index.tsx',
|
||||
5_000
|
||||
)
|
||||
expect(mocks.tapPoint).not.toHaveBeenCalled()
|
||||
expect(mocks.navigateRoute).toHaveBeenCalledWith(
|
||||
{ href: 'orca-mobile-web://build/h/host/session/workspace' },
|
||||
'/h/host/review/workspace?scope=all'
|
||||
)
|
||||
})
|
||||
|
||||
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',
|
||||
emulator: { adb: '/sdk/adb' },
|
||||
sessionDocument: {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
},
|
||||
expectedSessionDiffText: '3 tabs',
|
||||
inspectChangedContent,
|
||||
timeoutMs: 30_000,
|
||||
tapPoint
|
||||
})
|
||||
|
||||
expect(tapPoint).toHaveBeenCalledTimes(2)
|
||||
expect(tapPoint.mock.calls.map((call) => call[2])).toEqual([
|
||||
'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' })
|
||||
)
|
||||
})
|
||||
|
||||
it('captures strict Source Control and Review parity when native baselines are provided', async () => {
|
||||
const result = await verifyHostedSourceControlReviewJourney({
|
||||
deviceUdid: 'simulator',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
nativeBaselines: {
|
||||
sourceControl: {
|
||||
changedFileLabel: 'Open changed file mobile/app/index.tsx',
|
||||
pullRequestState: {
|
||||
kind: 'create',
|
||||
label: 'Create pull request'
|
||||
},
|
||||
screenshot: '/tmp/native-source-control.png'
|
||||
},
|
||||
review: { screenshot: '/tmp/native-review.png' }
|
||||
},
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
sessionDocument: {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(mocks.captureParityScreen).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
deviceUdid: 'simulator',
|
||||
screenshotName: 'hosted-source-control-portrait.png',
|
||||
title: 'Source Control'
|
||||
})
|
||||
)
|
||||
expect(mocks.captureParityScreen).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
deviceUdid: 'simulator',
|
||||
screenshotName: 'hosted-review-portrait.png',
|
||||
title: 'Changes'
|
||||
})
|
||||
)
|
||||
expect(result.parityFixture).toEqual({
|
||||
sourceControl: { screen: 'source-control' },
|
||||
review: { screen: 'review' }
|
||||
})
|
||||
})
|
||||
|
||||
it('settles parity against the existing pull request captured by native', async () => {
|
||||
const pullRequestLabel = 'Pull request #13386, Draft, No checks. Open pull request.'
|
||||
mocks.readState
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/source-control/workspace',
|
||||
bodyText: 'Source Control Changes Pull Request Commits #13386 Draft 128 on branch',
|
||||
labels: [
|
||||
'Refresh source control',
|
||||
pullRequestLabel,
|
||||
'Open changed file mobile/app/index.tsx'
|
||||
]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/review/workspace',
|
||||
bodyText: 'reviewed',
|
||||
labels: ['Back', 'Open review actions']
|
||||
})
|
||||
|
||||
await expect(
|
||||
verifyHostedSourceControlReviewJourney({
|
||||
deviceUdid: 'simulator',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
nativeBaselines: {
|
||||
sourceControl: {
|
||||
changedFileLabel: 'Open changed file mobile/app/index.tsx',
|
||||
pullRequestState: { kind: 'ready', label: pullRequestLabel, number: '13386' },
|
||||
screenshot: '/tmp/native-source-control.png'
|
||||
},
|
||||
review: { screenshot: '/tmp/native-review.png' }
|
||||
},
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
sessionDocument: {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
).resolves.toMatchObject({ sourceControlSegments: ['Changes', 'Pull Request', 'Commits'] })
|
||||
})
|
||||
|
||||
it('opens the exact changed file captured by the native baseline', async () => {
|
||||
const nativeChangedFileLabel = 'Open changed file mobile/src/mobile-web/bridge.ts'
|
||||
mocks.readState
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/source-control/workspace',
|
||||
bodyText: 'Source Control Changes Pull Request Commits Create pull request 128 on branch',
|
||||
labels: [
|
||||
'Refresh source control',
|
||||
'Open changed file mobile/app/index.tsx',
|
||||
nativeChangedFileLabel
|
||||
]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/review/workspace',
|
||||
bodyText: 'reviewed',
|
||||
labels: ['Back', 'Open review actions']
|
||||
})
|
||||
|
||||
await verifyHostedSourceControlReviewJourney({
|
||||
deviceUdid: 'simulator',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
nativeBaselines: {
|
||||
sourceControl: {
|
||||
changedFileLabel: nativeChangedFileLabel,
|
||||
pullRequestState: { kind: 'create', label: 'Create pull request' },
|
||||
screenshot: '/tmp/native-source-control.png'
|
||||
},
|
||||
review: { screenshot: '/tmp/native-review.png' }
|
||||
},
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
sessionDocument: { href: 'orca-mobile-web://build/h/host/session/workspace' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(mocks.tapAccessibilityControl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ deviceUdid: 'simulator' },
|
||||
nativeChangedFileLabel,
|
||||
5_000
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to measured points when WebKit omits accessibility descendants', async () => {
|
||||
mocks.tapAccessibilityControl.mockRejectedValue(new Error('missing descendant'))
|
||||
|
||||
await verifyHostedSourceControlReviewJourney({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
sessionDocument: {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(mocks.tapPoint).toHaveBeenNthCalledWith(1, expect.anything(), {
|
||||
x: 0.4,
|
||||
y: 0.2
|
||||
})
|
||||
expect(mocks.tapPoint).toHaveBeenNthCalledWith(2, expect.anything(), {
|
||||
x: 0.5,
|
||||
y: 0.4
|
||||
})
|
||||
})
|
||||
|
||||
it('uses measured native taps for duplicated Source Control segment labels', async () => {
|
||||
const sourceState = {
|
||||
href: 'orca-mobile-web://build/h/host/source-control/workspace',
|
||||
bodyText: 'Source Control Changes Pull Request Commits Create pull request 128 on branch',
|
||||
labels: ['Refresh source control', 'Open changed file mobile/app/index.tsx']
|
||||
}
|
||||
mocks.readControlPoint
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({ x: 0.4, y: 0.2 })
|
||||
.mockResolvedValueOnce({ x: 0.6, y: 0.3 })
|
||||
.mockResolvedValueOnce({ x: 0.3, y: 0.3 })
|
||||
.mockResolvedValueOnce({ x: 0.5, y: 0.4 })
|
||||
mocks.readState
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(sourceState)
|
||||
.mockResolvedValueOnce(sourceState)
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/review/workspace',
|
||||
bodyText: 'reviewed',
|
||||
labels: ['Back', 'Open review actions']
|
||||
})
|
||||
|
||||
await verifyHostedSourceControlReviewJourney({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
inspectProviderContent: vi.fn().mockResolvedValue(undefined),
|
||||
sessionDocument: {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(mocks.tapPoint).toHaveBeenNthCalledWith(1, expect.anything(), {
|
||||
x: 0.6,
|
||||
y: 0.3
|
||||
})
|
||||
expect(mocks.tapPoint).toHaveBeenNthCalledWith(2, expect.anything(), {
|
||||
x: 0.3,
|
||||
y: 0.3
|
||||
})
|
||||
})
|
||||
|
||||
it('transforms only the measured fallback while retaining semantic activation', async () => {
|
||||
const transformPoint = vi.fn((point) => ({ ...point, y: point.y + 0.1 }))
|
||||
mocks.tapAccessibilityControl.mockRejectedValue(new Error('missing descendant'))
|
||||
|
||||
await verifyHostedSourceControlReviewJourney({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
sessionDocument: {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
},
|
||||
timeoutMs: 30_000,
|
||||
transformPoint
|
||||
})
|
||||
|
||||
expect(mocks.tapAccessibilityControl).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.tapPoint.mock.calls[0][1]).toMatchObject({ x: 0.4 })
|
||||
expect(mocks.tapPoint.mock.calls[0][1].y).toBeCloseTo(0.3)
|
||||
expect(mocks.tapPoint.mock.calls[1][1]).toEqual({ x: 0.5, y: 0.5 })
|
||||
})
|
||||
|
||||
it('falls back to a measured point when an accessibility tap silently misses', async () => {
|
||||
mocks.readControlPoint
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({ x: 0.4, y: 0.2 })
|
||||
.mockResolvedValueOnce({ x: 0.5, y: 0.4 })
|
||||
.mockResolvedValueOnce({ x: 0.5, y: 0.4 })
|
||||
mocks.waitForDocument
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/source-control/workspace'
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('route unchanged'))
|
||||
.mockRejectedValueOnce(new Error('route unchanged'))
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/review/workspace'
|
||||
})
|
||||
|
||||
await verifyHostedSourceControlReviewJourney({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
sessionDocument: {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(mocks.tapAccessibilityControl).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.tapPoint).toHaveBeenCalledWith(expect.anything(), {
|
||||
x: 0.5,
|
||||
y: 0.4
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for tab state after the route changes', async () => {
|
||||
const sessionDocument = {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
}
|
||||
mocks.waitForDocument
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/source-control/workspace'
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('tab count pending'))
|
||||
.mockResolvedValueOnce(sessionDocument)
|
||||
.mockResolvedValueOnce(sessionDocument)
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/review/workspace'
|
||||
})
|
||||
|
||||
await verifyHostedSourceControlReviewJourney({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
sessionDocument: {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(mocks.tapAccessibilityControl).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.tapPoint).not.toHaveBeenCalled()
|
||||
expect(mocks.waitForDocument).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({ expectedText: '' })
|
||||
)
|
||||
expect(mocks.waitForDocument).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({ expectedText: '2 tabs' })
|
||||
)
|
||||
})
|
||||
it('records the headless host outcome when the diff route never opens', async () => {
|
||||
// Why: `orca serve --mobile-pairing` registers no renderer, so files.openDiff
|
||||
// answers renderer_unavailable. The claim is parity with native on this host.
|
||||
mocks.waitForDocument.mockReset()
|
||||
mocks.waitForDocument.mockImplementation(({ expectedHrefIncludes }) => {
|
||||
if (expectedHrefIncludes === '/session/') {
|
||||
return Promise.reject(new Error('Session diff route did not open'))
|
||||
}
|
||||
return Promise.resolve({
|
||||
href: `orca-mobile-web://build/h/host${expectedHrefIncludes ?? '/review/'}workspace`
|
||||
})
|
||||
})
|
||||
mocks.readBridgeErrors.mockResolvedValue([
|
||||
{ capability: 'sourceControl', operation: 'reviewOpen', code: 'host_error', retryable: true }
|
||||
])
|
||||
mocks.readState.mockReset()
|
||||
mocks.readState
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/source-control/workspace',
|
||||
bodyText: 'Source Control Changes Pull Request Commits Create pull request 128 on branch',
|
||||
labels: ['Refresh source control', 'Open changed file mobile/app/index.tsx']
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/source-control/workspace',
|
||||
bodyText: 'Source Control\nSource control action failed\nStage All'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
href: 'orca-mobile-web://build/h/host/review/workspace',
|
||||
bodyText: 'reviewed',
|
||||
labels: ['Back', 'Open review actions']
|
||||
})
|
||||
|
||||
const result = await verifyHostedSourceControlReviewJourney({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
nativeReviewOpen: { headless: 'renderer_unavailable' },
|
||||
sessionDocument: { href: 'orca-mobile-web://build/h/host/session/workspace' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(result.sessionDiffRoute).toBeNull()
|
||||
expect(result.reviewOpen).toEqual({
|
||||
headless: 'renderer_unavailable',
|
||||
native: 'renderer_unavailable',
|
||||
bridgeError: {
|
||||
capability: 'sourceControl',
|
||||
operation: 'reviewOpen',
|
||||
code: 'host_error',
|
||||
retryable: true
|
||||
},
|
||||
visibleMessage: 'Source control action failed'
|
||||
})
|
||||
// The standalone Review route still opens, from the Source Control document.
|
||||
expect(result.reviewRoute).toBe('orca-mobile-web://build/h/host/review/workspace')
|
||||
})
|
||||
|
||||
it('fails when the hybrid outcome diverges from what native did on the same host', async () => {
|
||||
mocks.waitForDocument.mockReset()
|
||||
mocks.waitForDocument.mockImplementation(({ expectedHrefIncludes }) => {
|
||||
if (expectedHrefIncludes === '/session/') {
|
||||
return Promise.reject(new Error('Session diff route did not open'))
|
||||
}
|
||||
return Promise.resolve({
|
||||
href: `orca-mobile-web://build/h/host${expectedHrefIncludes ?? '/review/'}workspace`
|
||||
})
|
||||
})
|
||||
mocks.readBridgeErrors.mockResolvedValue([
|
||||
{ capability: 'sourceControl', operation: 'reviewOpen', code: 'host_error', retryable: true }
|
||||
])
|
||||
|
||||
await expect(
|
||||
verifyHostedSourceControlReviewJourney({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
nativeReviewOpen: { headless: false },
|
||||
sessionDocument: { href: 'orca-mobile-web://build/h/host/session/workspace' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
).rejects.toThrow('Session-origin reviewOpen diverged')
|
||||
})
|
||||
})
|
||||
@@ -1,203 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
captureScreenshot: vi.fn(),
|
||||
compareScreenshots: vi.fn(),
|
||||
dismissDeveloperMenu: vi.fn(),
|
||||
longPressControlByPrefix: vi.fn(),
|
||||
readLabels: vi.fn(),
|
||||
readTextPoint: vi.fn(),
|
||||
tapControl: vi.fn(),
|
||||
tapControlByPrefix: vi.fn(),
|
||||
waitForControl: vi.fn(),
|
||||
waitForControlByPrefix: vi.fn(),
|
||||
waitForControlEndingWith: vi.fn(),
|
||||
waitForControlMatching: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: mocks.captureScreenshot
|
||||
}))
|
||||
vi.mock('../../scripts/emulator-developer-menu-dismissal.mjs', () => ({
|
||||
dismissEmulatorDeveloperMenuIfPresent: mocks.dismissDeveloperMenu
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({
|
||||
readHostedIosAccessibilityLabels: mocks.readLabels,
|
||||
tapHostedIosAccessibilityControl: mocks.tapControl,
|
||||
tapHostedIosAccessibilityControlByLabelPrefix: mocks.tapControlByPrefix,
|
||||
waitForHostedIosAccessibilityControl: mocks.waitForControl,
|
||||
waitForHostedIosAccessibilityControlByLabelPrefix: mocks.waitForControlByPrefix,
|
||||
waitForHostedIosAccessibilityControlEndingWith: mocks.waitForControlEndingWith,
|
||||
waitForHostedIosAccessibilityControlMatching: mocks.waitForControlMatching
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-emulator-long-press.mjs', () => ({
|
||||
longPressHostedIosAccessibilityControlByLabelPrefix: mocks.longPressControlByPrefix
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-screenshot-parity.mjs', () => ({
|
||||
assertHostedIosScreenshotParity: mocks.compareScreenshots
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({
|
||||
readHostedWebViewTextPoint: mocks.readTextPoint
|
||||
}))
|
||||
|
||||
import {
|
||||
captureHostedSourceControlReviewScreen,
|
||||
captureNativeSourceControlReviewBaselines,
|
||||
HEADLESS_REVIEW_OPEN_ERROR
|
||||
} from '../../scripts/hosted-ios-source-control-review-parity.mjs'
|
||||
|
||||
describe('hosted iOS Source Control and Review parity', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.captureScreenshot.mockImplementation((_command, _args, callback) =>
|
||||
callback(null, '', '')
|
||||
)
|
||||
mocks.compareScreenshots.mockResolvedValue({ changedPixelRatio: 0.01 })
|
||||
mocks.longPressControlByPrefix.mockResolvedValue({ x: 0.5, y: 0.4 })
|
||||
mocks.readLabels.mockResolvedValue([
|
||||
'Open source control',
|
||||
'Source Control',
|
||||
'Back to session',
|
||||
HEADLESS_REVIEW_OPEN_ERROR
|
||||
])
|
||||
mocks.readTextPoint.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
mocks.tapControl.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
mocks.tapControlByPrefix.mockResolvedValue({ x: 0.5, y: 0.4 })
|
||||
mocks.waitForControl.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
mocks.waitForControlByPrefix.mockResolvedValue({ x: 0.5, y: 0.4 })
|
||||
mocks.waitForControlEndingWith.mockResolvedValue({ x: 0.5, y: 0.4 })
|
||||
mocks.waitForControlMatching
|
||||
.mockResolvedValueOnce({
|
||||
label: 'Open changed file mobile/app/index.tsx',
|
||||
value: '',
|
||||
x: 0.5,
|
||||
y: 0.5
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
label: 'Pull request #13386, Draft, No checks. Open pull request.',
|
||||
value: '',
|
||||
x: 0.5,
|
||||
y: 0.3
|
||||
})
|
||||
.mockResolvedValue({
|
||||
label: 'Open changed file mobile/app/index.tsx',
|
||||
value: '',
|
||||
x: 0.5,
|
||||
y: 0.5
|
||||
})
|
||||
})
|
||||
|
||||
it('captures the real host-origin Source Control and standalone Review path', async () => {
|
||||
const baselines = await captureNativeSourceControlReviewBaselines({
|
||||
deviceUdid: 'simulator',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(mocks.longPressControlByPrefix).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
'mobile-rearch',
|
||||
30_000,
|
||||
undefined,
|
||||
'Source Control'
|
||||
)
|
||||
expect(mocks.tapControl).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
'Source Control',
|
||||
30_000
|
||||
)
|
||||
expect(mocks.tapControl).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
'Open changed file mobile/app/index.tsx',
|
||||
30_000
|
||||
)
|
||||
expect(baselines).toEqual({
|
||||
review: {
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-review-portrait.png'
|
||||
},
|
||||
// Why: the session-origin probe records what the paired host answers, so the
|
||||
// hosted journey can assert parity instead of asserting a failure.
|
||||
sessionOriginReviewOpen: {
|
||||
changedFileLabel: 'Open changed file mobile/app/index.tsx',
|
||||
headless: HEADLESS_REVIEW_OPEN_ERROR
|
||||
},
|
||||
sourceControl: {
|
||||
changedFileLabel: 'Open changed file mobile/app/index.tsx',
|
||||
pullRequestState: {
|
||||
kind: 'ready',
|
||||
label: 'Pull request #13386, Draft, No checks. Open pull request.',
|
||||
number: '13386'
|
||||
},
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-source-control-portrait.png'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('uses value-backed accessibility text for the changed file and pull request', async () => {
|
||||
mocks.waitForControlMatching
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
label: 'Changed file',
|
||||
value: 'Open changed file mobile/src/mobile-web/bridge.ts',
|
||||
x: 0.5,
|
||||
y: 0.5
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
label: 'Changed file',
|
||||
value: 'Open changed file mobile/src/mobile-web/bridge.ts',
|
||||
x: 0.5,
|
||||
y: 0.5
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
label: '',
|
||||
value: 'Pull request #13386, Draft, No checks. Open pull request.',
|
||||
x: 0.5,
|
||||
y: 0.3
|
||||
})
|
||||
|
||||
const baselines = await captureNativeSourceControlReviewBaselines({
|
||||
deviceUdid: 'simulator',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(baselines.sourceControl).toMatchObject({
|
||||
changedFileLabel: 'Open changed file mobile/src/mobile-web/bridge.ts',
|
||||
pullRequestState: { kind: 'ready', number: '13386' }
|
||||
})
|
||||
expect(mocks.tapControl).toHaveBeenCalledWith(
|
||||
{ deviceUdid: 'simulator' },
|
||||
'Open changed file mobile/src/mobile-web/bridge.ts',
|
||||
30_000
|
||||
)
|
||||
})
|
||||
|
||||
it('compares a hosted route against its native screenshot and title landmark', async () => {
|
||||
const capture = await captureHostedSourceControlReviewScreen({
|
||||
deviceUdid: 'simulator',
|
||||
document: { href: 'orca-mobile-web://build/h/host/review/worktree' },
|
||||
nativeBaseline: {
|
||||
screenTitlePoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-review-portrait.png'
|
||||
},
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
screenshotName: 'hosted-review-portrait.png',
|
||||
title: 'Changes',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(capture.screenshotParity).toEqual({ changedPixelRatio: 0.01 })
|
||||
expect(mocks.compareScreenshots).toHaveBeenCalledWith({
|
||||
hostedLandmark: { x: 0.2, y: 0.1 },
|
||||
hostedScreenshot: '/tmp/parity/hosted-review-portrait.png',
|
||||
nativeLandmark: { x: 0.2, y: 0.1 },
|
||||
nativeScreenshot: '/tmp/parity/native-review-portrait.png'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,247 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PNG } from 'pngjs'
|
||||
import {
|
||||
copyHostedIosPhotoFixtureToClipboard,
|
||||
stageFreshHostedIosPhotoFixture,
|
||||
pngPixelIdentity,
|
||||
verifyHostedIosTerminalClipboardImagePaste
|
||||
} from '../../scripts/hosted-ios-terminal-clipboard-image-paste.mjs'
|
||||
|
||||
const uploadedPath =
|
||||
'/var/folders/x/y/T/orca-paste-1780000000000-00000000-0000-4000-8000-000000000000.png'
|
||||
const sessionDocument = {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
}
|
||||
const args = {
|
||||
deviceUdid: 'simulator',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
orcaCli: './config/scripts/orca-dev.mjs',
|
||||
pairingRuntimeUserDataPath: '/tmp/pairing/userData',
|
||||
sessionDocument,
|
||||
terminalHandle: 'terminal-handle',
|
||||
timeoutMs: 30_000,
|
||||
worktree: '/repo/mobile-rearch'
|
||||
}
|
||||
const fixtureBytes = createPng([18, 22, 41, 255])
|
||||
const fixturePixelIdentity = pngPixelIdentity(fixtureBytes)
|
||||
|
||||
describe('hosted iOS terminal clipboard image paste', () => {
|
||||
it('normalizes Photos to its library and copies the latest visible photo', async () => {
|
||||
const runCommand = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
const tapControl = vi.fn().mockResolvedValue({ x: 0.5, y: 0.5 })
|
||||
const tapLastControl = vi.fn().mockResolvedValue({ x: 0.16, y: 0.52 })
|
||||
const tapPoint = vi.fn()
|
||||
const waitForControl = vi.fn().mockResolvedValue({ x: 0.5, y: 0.5 })
|
||||
const waitForMatch = vi.fn().mockResolvedValue({ label: 'Share', x: 0.1, y: 0.9 })
|
||||
|
||||
await expect(
|
||||
copyHostedIosPhotoFixtureToClipboard(
|
||||
{
|
||||
deviceUdid: 'simulator',
|
||||
emulator: args.emulator,
|
||||
fixturePath: '/repo/mobile/assets/favicon.png',
|
||||
timeoutMs: args.timeoutMs
|
||||
},
|
||||
{
|
||||
readFixture: vi.fn().mockResolvedValue(fixtureBytes),
|
||||
runCommand,
|
||||
stageFixture: vi.fn().mockResolvedValue('/repo/mobile/assets/favicon.png'),
|
||||
tapControl,
|
||||
tapLastControl,
|
||||
tapPoint,
|
||||
waitForControl,
|
||||
waitForMatch
|
||||
}
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
fixtureName: 'favicon.png',
|
||||
fixturePixelIdentity,
|
||||
photoPoint: { x: 0.16, y: 0.52 }
|
||||
})
|
||||
|
||||
expect(runCommand).toHaveBeenNthCalledWith(1, 'xcrun', [
|
||||
'simctl',
|
||||
'addmedia',
|
||||
'simulator',
|
||||
'/repo/mobile/assets/favicon.png'
|
||||
])
|
||||
expect(runCommand).toHaveBeenNthCalledWith(2, 'xcrun', [
|
||||
'simctl',
|
||||
'launch',
|
||||
'simulator',
|
||||
'com.apple.mobileslideshow'
|
||||
])
|
||||
expect(tapControl.mock.calls.map(([, label]) => label)).toEqual([
|
||||
'Back',
|
||||
'Share',
|
||||
'Copy Photo',
|
||||
'Return to Orca'
|
||||
])
|
||||
expect(tapLastControl).toHaveBeenCalledWith(args.emulator, 'Photo', args.timeoutMs)
|
||||
expect(tapPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stages a freshly written copy so the fixture is the newest library photo', async () => {
|
||||
const writeFixture = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await expect(
|
||||
stageFreshHostedIosPhotoFixture('/repo/mobile/assets/favicon.png', {
|
||||
createDirectory: vi.fn().mockResolvedValue('/tmp/staged'),
|
||||
readFixture: vi.fn().mockResolvedValue(fixtureBytes),
|
||||
writeFixture
|
||||
})
|
||||
).resolves.toBe('/tmp/staged/favicon.png')
|
||||
expect(writeFixture).toHaveBeenCalledWith('/tmp/staged/favicon.png', fixtureBytes)
|
||||
})
|
||||
|
||||
it('dismisses first-launch Photos surfaces before copying', async () => {
|
||||
const waitForMatch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ label: 'Continue', x: 0.5, y: 0.9 })
|
||||
.mockResolvedValueOnce({ label: 'Don’t Allow', x: 0.3, y: 0.6 })
|
||||
.mockResolvedValueOnce({ label: 'Continue', x: 0.5, y: 0.9 })
|
||||
.mockResolvedValueOnce({ label: 'Select', x: 0.8, y: 0.1 })
|
||||
const tapPoint = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await copyHostedIosPhotoFixtureToClipboard(
|
||||
{
|
||||
deviceUdid: 'simulator',
|
||||
emulator: args.emulator,
|
||||
fixturePath: '/repo/mobile/assets/favicon.png',
|
||||
timeoutMs: args.timeoutMs
|
||||
},
|
||||
{
|
||||
readFixture: vi.fn().mockResolvedValue(fixtureBytes),
|
||||
runCommand: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
stageFixture: vi.fn().mockResolvedValue('/repo/mobile/assets/favicon.png'),
|
||||
tapControl: vi.fn().mockResolvedValue({ x: 0.5, y: 0.5 }),
|
||||
tapLastControl: vi.fn().mockResolvedValue({ x: 0.16, y: 0.52 }),
|
||||
tapPoint,
|
||||
waitForControl: vi.fn().mockResolvedValue({ x: 0.5, y: 0.5 }),
|
||||
waitForMatch
|
||||
}
|
||||
)
|
||||
|
||||
expect(tapPoint).toHaveBeenNthCalledWith(1, args.emulator, {
|
||||
label: 'Continue',
|
||||
x: 0.5,
|
||||
y: 0.9
|
||||
})
|
||||
expect(tapPoint).toHaveBeenNthCalledWith(2, args.emulator, {
|
||||
label: 'Don’t Allow',
|
||||
x: 0.3,
|
||||
y: 0.6
|
||||
})
|
||||
expect(tapPoint).toHaveBeenNthCalledWith(3, args.emulator, {
|
||||
label: 'Continue',
|
||||
x: 0.5,
|
||||
y: 0.9
|
||||
})
|
||||
})
|
||||
|
||||
it('retries when Photos onboarding appears after the first photo tap', async () => {
|
||||
const waitForMatch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ label: 'Select', x: 0.8, y: 0.1 })
|
||||
.mockResolvedValueOnce({ label: 'Continue', x: 0.5, y: 0.9 })
|
||||
.mockResolvedValueOnce({ label: 'Select', x: 0.8, y: 0.1 })
|
||||
const tapLastControl = vi.fn().mockResolvedValue({ x: 0.16, y: 0.52 })
|
||||
const tapPoint = vi.fn().mockResolvedValue(undefined)
|
||||
const waitForControl = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('lazy onboarding'))
|
||||
.mockResolvedValue({ x: 0.5, y: 0.5 })
|
||||
|
||||
await copyHostedIosPhotoFixtureToClipboard(
|
||||
{
|
||||
deviceUdid: 'simulator',
|
||||
emulator: args.emulator,
|
||||
fixturePath: '/repo/mobile/assets/favicon.png',
|
||||
timeoutMs: args.timeoutMs
|
||||
},
|
||||
{
|
||||
readFixture: vi.fn().mockResolvedValue(fixtureBytes),
|
||||
runCommand: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
stageFixture: vi.fn().mockResolvedValue('/repo/mobile/assets/favicon.png'),
|
||||
tapControl: vi.fn().mockResolvedValue({ x: 0.5, y: 0.5 }),
|
||||
tapLastControl,
|
||||
tapPoint,
|
||||
waitForControl,
|
||||
waitForMatch
|
||||
}
|
||||
)
|
||||
|
||||
expect(tapLastControl).toHaveBeenCalledTimes(2)
|
||||
expect(tapPoint).toHaveBeenCalledWith(args.emulator, {
|
||||
label: 'Continue',
|
||||
x: 0.5,
|
||||
y: 0.9
|
||||
})
|
||||
})
|
||||
|
||||
it('requires pixel-identical shell upload and terminal-only path exposure', async () => {
|
||||
const beforeTerminal = ['ORCA_HOSTED_CLIPBOARD_TEXT_PASTE']
|
||||
const afterTerminal = [...beforeTerminal, uploadedPath]
|
||||
const operations = {
|
||||
allowPaste: vi.fn().mockResolvedValue('allowed'),
|
||||
copyFixture: vi.fn().mockResolvedValue({
|
||||
copyPoint: { x: 0.15, y: 0.66 },
|
||||
fixtureName: 'favicon.png',
|
||||
fixturePixelIdentity
|
||||
}),
|
||||
readState: vi.fn().mockResolvedValue({ bodyText: 'Mobile Emulator' }),
|
||||
readTerminal: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(beforeTerminal)
|
||||
.mockResolvedValueOnce(afterTerminal),
|
||||
readUploadedFile: vi.fn().mockResolvedValue(fixtureBytes),
|
||||
tapControl: vi.fn().mockResolvedValue({ x: 0.3, y: 0.9 }),
|
||||
waitForDocument: vi.fn().mockResolvedValue(sessionDocument)
|
||||
}
|
||||
|
||||
await expect(verifyHostedIosTerminalClipboardImagePaste(args, operations)).resolves.toEqual({
|
||||
evidence: {
|
||||
copyPoint: { x: 0.15, y: 0.66 },
|
||||
fixtureName: 'favicon.png',
|
||||
height: 1,
|
||||
pasteControlPoint: { x: 0.3, y: 0.9 },
|
||||
pastePermissionPrompt: 'allowed',
|
||||
pixelSha256: fixturePixelIdentity.sha256,
|
||||
privilegedPageMarkers: 'absent',
|
||||
route: sessionDocument.href,
|
||||
size: fixtureBytes.byteLength,
|
||||
terminalPathInjected: true,
|
||||
width: 1
|
||||
},
|
||||
sessionDocument
|
||||
})
|
||||
expect(operations.readUploadedFile).toHaveBeenCalledWith(uploadedPath)
|
||||
})
|
||||
|
||||
it('rejects a clipboard upload with different decoded pixels', async () => {
|
||||
const operations = {
|
||||
allowPaste: vi.fn().mockResolvedValue('not-shown'),
|
||||
copyFixture: vi.fn().mockResolvedValue({
|
||||
copyPoint: { x: 0.15, y: 0.66 },
|
||||
fixtureName: 'favicon.png',
|
||||
fixturePixelIdentity
|
||||
}),
|
||||
readState: vi.fn().mockResolvedValue({ bodyText: 'Mobile Emulator' }),
|
||||
readTerminal: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([uploadedPath]),
|
||||
readUploadedFile: vi.fn().mockResolvedValue(createPng([255, 0, 0, 255])),
|
||||
tapControl: vi.fn().mockResolvedValue({ x: 0.3, y: 0.9 }),
|
||||
waitForDocument: vi.fn().mockResolvedValue(sessionDocument)
|
||||
}
|
||||
|
||||
await expect(verifyHostedIosTerminalClipboardImagePaste(args, operations)).rejects.toThrow(
|
||||
'did not preserve the fixture pixels'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function createPng(rgba: [number, number, number, number]): Buffer {
|
||||
const png = new PNG({ height: 1, width: 1 })
|
||||
png.data.set(rgba)
|
||||
return PNG.sync.write(png)
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
activateControl: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({
|
||||
activateHostedWebViewControl: mocks.activateControl,
|
||||
waitForVisibleHostedWebView: vi.fn()
|
||||
}))
|
||||
|
||||
import {
|
||||
verifyHostedIosTerminalClipboardPaste,
|
||||
writeHostedIosSimulatorPasteboard
|
||||
} from '../../scripts/hosted-ios-terminal-clipboard-paste.mjs'
|
||||
|
||||
describe('hosted iOS terminal clipboard paste', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses the unchanged Paste control and requires the marker in the Desktop terminal', async () => {
|
||||
const workspaceDocument = { href: 'orca-mobile-web://build/h/host' }
|
||||
const sessionDocument = {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
}
|
||||
const writePasteboard = vi.fn().mockResolvedValue(undefined)
|
||||
const activateWorkspace = vi
|
||||
.fn()
|
||||
.mockImplementation(async (_document, _workspace, _activate, _timeout, waitForWorkspace) => {
|
||||
await waitForWorkspace()
|
||||
})
|
||||
const waitForDocument = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(workspaceDocument)
|
||||
.mockResolvedValueOnce(sessionDocument)
|
||||
const tapControl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ x: 0.75, y: 0.9 })
|
||||
.mockResolvedValueOnce({ x: 0.5, y: 0.6 })
|
||||
const readTerminal = vi.fn().mockResolvedValue('terminal-handle')
|
||||
|
||||
await expect(
|
||||
verifyHostedIosTerminalClipboardPaste(
|
||||
{
|
||||
deviceUdid: 'simulator',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
orcaCli: './config/scripts/orca-dev.mjs',
|
||||
pairingRuntimeUserDataPath: '/tmp/pairing/userData',
|
||||
timeoutMs: 30_000,
|
||||
workspaceDocument,
|
||||
worktree: '/repo/mobile-rearch'
|
||||
},
|
||||
{
|
||||
activateWorkspace,
|
||||
readTerminal,
|
||||
tapControl,
|
||||
waitForDocument,
|
||||
writePasteboard
|
||||
}
|
||||
)
|
||||
).resolves.toEqual({
|
||||
evidence: {
|
||||
activationAttempts: 1,
|
||||
marker: 'ORCA_HOSTED_CLIPBOARD_TEXT_PASTE',
|
||||
pasteControlPoint: { x: 0.75, y: 0.9 },
|
||||
pastePermissionPrompt: 'allowed',
|
||||
route: sessionDocument.href,
|
||||
terminalHandle: 'terminal-handle'
|
||||
},
|
||||
sessionDocument
|
||||
})
|
||||
|
||||
expect(writePasteboard).toHaveBeenCalledWith('simulator', 'ORCA_HOSTED_CLIPBOARD_TEXT_PASTE')
|
||||
expect(activateWorkspace).toHaveBeenCalledWith(
|
||||
workspaceDocument,
|
||||
'mobile-rearch',
|
||||
mocks.activateControl,
|
||||
30_000,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(tapControl).toHaveBeenNthCalledWith(1, { deviceUdid: 'simulator' }, 'Paste', 30_000)
|
||||
expect(tapControl).toHaveBeenNthCalledWith(2, { deviceUdid: 'simulator' }, 'Allow Paste', 3_000)
|
||||
expect(readTerminal).toHaveBeenCalledWith({
|
||||
marker: 'ORCA_HOSTED_CLIPBOARD_TEXT_PASTE',
|
||||
orcaCli: './config/scripts/orca-dev.mjs',
|
||||
pairingRuntimeUserDataPath: '/tmp/pairing/userData',
|
||||
timeoutMs: 10_000,
|
||||
worktree: '/repo/mobile-rearch'
|
||||
})
|
||||
})
|
||||
|
||||
it('writes text to the selected simulator pasteboard', async () => {
|
||||
const stdin = { end: vi.fn() }
|
||||
const child = {
|
||||
once: vi.fn((event, listener) => {
|
||||
if (event === 'exit') {
|
||||
listener(0)
|
||||
}
|
||||
}),
|
||||
stderr: { on: vi.fn(), setEncoding: vi.fn() },
|
||||
stdin
|
||||
}
|
||||
const spawnProcess = vi.fn().mockReturnValue(child)
|
||||
|
||||
await expect(
|
||||
writeHostedIosSimulatorPasteboard('simulator', 'marker', spawnProcess)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(spawnProcess).toHaveBeenCalledWith('xcrun', ['simctl', 'pbcopy', 'simulator'], {
|
||||
stdio: ['pipe', 'ignore', 'pipe']
|
||||
})
|
||||
expect(stdin.end).toHaveBeenCalledWith('marker')
|
||||
})
|
||||
|
||||
it('retries a silent native tap before accepting terminal evidence', async () => {
|
||||
const sessionDocument = {
|
||||
href: 'orca-mobile-web://build/h/host/session/workspace'
|
||||
}
|
||||
const tapControl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ x: 0.25, y: 0.9 })
|
||||
.mockRejectedValueOnce(new Error('prompt not shown'))
|
||||
.mockResolvedValueOnce({ x: 0.25, y: 0.9 })
|
||||
.mockRejectedValueOnce(new Error('prompt not shown'))
|
||||
const readTerminal = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('marker missing'))
|
||||
.mockResolvedValueOnce('terminal-handle')
|
||||
|
||||
const result = await verifyHostedIosTerminalClipboardPaste(
|
||||
{
|
||||
deviceUdid: 'simulator',
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
expectedWorkspace: 'mobile-rearch',
|
||||
orcaCli: './config/scripts/orca-dev.mjs',
|
||||
pairingRuntimeUserDataPath: '/tmp/pairing/userData',
|
||||
timeoutMs: 30_000,
|
||||
workspaceDocument: { href: 'orca-mobile-web://build/h/host' },
|
||||
worktree: '/repo/mobile-rearch'
|
||||
},
|
||||
{
|
||||
activateWorkspace: vi.fn().mockResolvedValue(undefined),
|
||||
readTerminal,
|
||||
tapControl,
|
||||
waitForDocument: vi.fn().mockResolvedValue(sessionDocument),
|
||||
writePasteboard: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.evidence).toMatchObject({
|
||||
activationAttempts: 2,
|
||||
pastePermissionPrompt: 'not-shown',
|
||||
terminalHandle: 'terminal-handle'
|
||||
})
|
||||
expect(readTerminal).toHaveBeenCalledTimes(2)
|
||||
expect(tapControl).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
||||
@@ -1,183 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
verifyClipboardImagePaste: vi.fn(),
|
||||
verifyDocumentUpload: vi.fn(),
|
||||
verifyClipboardPaste: vi.fn(),
|
||||
verifyPhotoDenial: vi.fn(),
|
||||
verifyPhotoRevocation: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../scripts/hosted-ios-document-upload.mjs', () => ({
|
||||
verifyHostedIosDocumentUpload: mocks.verifyDocumentUpload
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-terminal-clipboard-paste.mjs', () => ({
|
||||
verifyHostedIosTerminalClipboardPaste: mocks.verifyClipboardPaste
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-terminal-clipboard-image-paste.mjs', () => ({
|
||||
verifyHostedIosTerminalClipboardImagePaste: mocks.verifyClipboardImagePaste
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-photo-permission-denial.mjs', () => ({
|
||||
verifyHostedIosPhotoPermissionDenial: mocks.verifyPhotoDenial
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-photo-permission-revocation.mjs', () => ({
|
||||
verifyHostedIosPhotoPermissionRevocation: mocks.verifyPhotoRevocation
|
||||
}))
|
||||
|
||||
import {
|
||||
verifyHostedIosTerminalDeviceInputJourney,
|
||||
verifyHostedIosTerminalInputJourney
|
||||
} from '../../scripts/hosted-ios-terminal-device-input-journey.mjs'
|
||||
|
||||
describe('hosted iOS terminal device-input journey', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('carries the exact session and terminal through every device-input operation', async () => {
|
||||
const args = {
|
||||
deviceUdid: 'simulator',
|
||||
emulator: {},
|
||||
worktree: '/repo/mobile-rearch',
|
||||
workspaceDocument: { href: 'workspace' }
|
||||
}
|
||||
const documentFixture = {
|
||||
destinationPath: '/simulator/fixture.png',
|
||||
fixtureName: 'fixture.png'
|
||||
}
|
||||
const seedFixture = vi.fn().mockResolvedValue(documentFixture)
|
||||
const removeFixture = vi.fn().mockResolvedValue(undefined)
|
||||
const terminalClipboardPaste = {
|
||||
evidence: { terminalHandle: 'terminal-handle' },
|
||||
sessionDocument: { href: 'session' }
|
||||
}
|
||||
const photoPermissionDenial = {
|
||||
evidence: { toast: 'Photo permission denied' },
|
||||
sessionDocument: { href: 'session-after-photo-denial' }
|
||||
}
|
||||
const photoPermissionRevocation = {
|
||||
evidence: { permissionState: 'revoked-after-grant' },
|
||||
sessionDocument: { href: 'session-after-photo-revocation' }
|
||||
}
|
||||
const documentUpload = {
|
||||
evidence: { terminalPathInjected: true },
|
||||
sessionDocument: { href: 'session-after-document-upload' }
|
||||
}
|
||||
const terminalClipboardImagePaste = {
|
||||
evidence: { terminalPathInjected: true },
|
||||
sessionDocument: { href: 'session-after-clipboard-image-paste' }
|
||||
}
|
||||
mocks.verifyClipboardPaste.mockResolvedValue(terminalClipboardPaste)
|
||||
mocks.verifyClipboardImagePaste.mockResolvedValue(terminalClipboardImagePaste)
|
||||
mocks.verifyPhotoDenial.mockResolvedValue(photoPermissionDenial)
|
||||
mocks.verifyPhotoRevocation.mockResolvedValue(photoPermissionRevocation)
|
||||
mocks.verifyDocumentUpload.mockResolvedValue(documentUpload)
|
||||
|
||||
await expect(
|
||||
verifyHostedIosTerminalDeviceInputJourney(args, {
|
||||
removeFixture,
|
||||
seedFixture
|
||||
})
|
||||
).resolves.toEqual({
|
||||
documentUpload,
|
||||
photoPermissionDenial,
|
||||
photoPermissionRevocation,
|
||||
terminalClipboardImagePaste,
|
||||
terminalClipboardPaste
|
||||
})
|
||||
expect(seedFixture).toHaveBeenCalledWith({
|
||||
deviceUdid: 'simulator',
|
||||
fixturePath: '/repo/mobile-rearch/mobile/assets/favicon.png'
|
||||
})
|
||||
expect(mocks.verifyPhotoDenial).toHaveBeenCalledWith({
|
||||
...args,
|
||||
sessionDocument: documentUpload.sessionDocument,
|
||||
terminalHandle: 'terminal-handle'
|
||||
})
|
||||
expect(mocks.verifyDocumentUpload).toHaveBeenCalledWith({
|
||||
...args,
|
||||
documentFixture,
|
||||
sessionDocument: terminalClipboardPaste.sessionDocument,
|
||||
terminalHandle: 'terminal-handle'
|
||||
})
|
||||
expect(mocks.verifyPhotoRevocation).toHaveBeenCalledWith({
|
||||
...args,
|
||||
sessionDocument: photoPermissionDenial.sessionDocument,
|
||||
terminalHandle: 'terminal-handle'
|
||||
})
|
||||
expect(mocks.verifyClipboardImagePaste).toHaveBeenCalledWith({
|
||||
...args,
|
||||
sessionDocument: photoPermissionRevocation.sessionDocument,
|
||||
terminalHandle: 'terminal-handle'
|
||||
})
|
||||
expect(removeFixture).toHaveBeenCalledWith(documentFixture.destinationPath)
|
||||
})
|
||||
|
||||
it('runs clipboard-image verification without unrelated picker stages', async () => {
|
||||
const args = {
|
||||
deviceUdid: 'simulator',
|
||||
emulator: {},
|
||||
worktree: '/repo/mobile-rearch',
|
||||
workspaceDocument: { href: 'workspace' }
|
||||
}
|
||||
const terminalClipboardPaste = {
|
||||
evidence: { terminalHandle: 'terminal-handle' },
|
||||
sessionDocument: { href: 'session' }
|
||||
}
|
||||
const terminalClipboardImagePaste = {
|
||||
evidence: { terminalPathInjected: true },
|
||||
sessionDocument: { href: 'session-after-image-paste' }
|
||||
}
|
||||
mocks.verifyClipboardPaste.mockResolvedValue(terminalClipboardPaste)
|
||||
mocks.verifyClipboardImagePaste.mockResolvedValue(terminalClipboardImagePaste)
|
||||
|
||||
await expect(
|
||||
verifyHostedIosTerminalInputJourney(args, { clipboardImageOnly: true })
|
||||
).resolves.toEqual({
|
||||
terminalClipboardImagePaste,
|
||||
terminalClipboardPaste
|
||||
})
|
||||
expect(mocks.verifyClipboardImagePaste).toHaveBeenCalledWith({
|
||||
...args,
|
||||
sessionDocument: terminalClipboardPaste.sessionDocument,
|
||||
terminalHandle: 'terminal-handle'
|
||||
})
|
||||
expect(mocks.verifyDocumentUpload).not.toHaveBeenCalled()
|
||||
expect(mocks.verifyPhotoDenial).not.toHaveBeenCalled()
|
||||
expect(mocks.verifyPhotoRevocation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs Photos revocation without unrelated picker stages', async () => {
|
||||
const args = {
|
||||
deviceUdid: 'simulator',
|
||||
emulator: {},
|
||||
worktree: '/repo/mobile-rearch',
|
||||
workspaceDocument: { href: 'workspace' }
|
||||
}
|
||||
const terminalClipboardPaste = {
|
||||
evidence: { terminalHandle: 'terminal-handle' },
|
||||
sessionDocument: { href: 'session' }
|
||||
}
|
||||
const photoPermissionRevocation = {
|
||||
evidence: { permissionState: 'revoked-after-grant' },
|
||||
sessionDocument: { href: 'session-after-photo-revocation' }
|
||||
}
|
||||
mocks.verifyClipboardPaste.mockResolvedValue(terminalClipboardPaste)
|
||||
mocks.verifyPhotoRevocation.mockResolvedValue(photoPermissionRevocation)
|
||||
|
||||
await expect(
|
||||
verifyHostedIosTerminalInputJourney(args, { photosRevocationOnly: true })
|
||||
).resolves.toEqual({
|
||||
photoPermissionRevocation,
|
||||
terminalClipboardPaste
|
||||
})
|
||||
expect(mocks.verifyPhotoRevocation).toHaveBeenCalledWith({
|
||||
...args,
|
||||
sessionDocument: terminalClipboardPaste.sessionDocument,
|
||||
terminalHandle: 'terminal-handle'
|
||||
})
|
||||
expect(mocks.verifyDocumentUpload).not.toHaveBeenCalled()
|
||||
expect(mocks.verifyPhotoDenial).not.toHaveBeenCalled()
|
||||
expect(mocks.verifyClipboardImagePaste).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
alignHostedIosSessionPoint,
|
||||
alignHostedIosTerminalLinkPoints
|
||||
} from '../../scripts/hosted-ios-adversarial-terminal-links.mjs'
|
||||
|
||||
describe('hosted iOS terminal link point alignment', () => {
|
||||
it('removes the transient viewport displacement before a native tap', () => {
|
||||
expect(
|
||||
alignHostedIosTerminalLinkPoints(
|
||||
{
|
||||
file: { x: 0.07, y: 0.72 },
|
||||
http: { x: 0.07, y: 0.79 },
|
||||
javascript: { x: 0.07, y: 0.65 }
|
||||
},
|
||||
{ x: 0.4, y: 0.5 },
|
||||
{ x: 0.4, y: 0.14 }
|
||||
)
|
||||
).toEqual({
|
||||
points: {
|
||||
file: { x: 0.07, y: 0.36 },
|
||||
http: { x: 0.07, y: 0.43000000000000005 },
|
||||
javascript: { x: 0.07, y: 0.29000000000000004 }
|
||||
},
|
||||
yOffset: -0.36
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an alignment that leaves the native screen', () => {
|
||||
expect(() =>
|
||||
alignHostedIosTerminalLinkPoints(
|
||||
{ file: { x: 0.2, y: 0.1 } },
|
||||
{ x: 0.2, y: 0.8 },
|
||||
{ x: 0.2, y: 0.1 }
|
||||
)
|
||||
).toThrow('alignment is invalid')
|
||||
})
|
||||
|
||||
it('applies the measured offset only to Session-origin taps', () => {
|
||||
const point = { x: 0.8, y: 0.7 }
|
||||
expect(
|
||||
alignHostedIosSessionPoint(point, -0.36, { href: 'orca://host/session/opaque' })
|
||||
).toEqual({ x: 0.8, y: 0.33999999999999997 })
|
||||
expect(
|
||||
alignHostedIosSessionPoint(point, -0.36, { href: 'orca://host/source-control/opaque' })
|
||||
).toBe(point)
|
||||
})
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
captureScreenshot: vi.fn(),
|
||||
compareScreenshots: vi.fn(),
|
||||
dismissDeveloperMenu: vi.fn(),
|
||||
readTextPoint: vi.fn(),
|
||||
waitForControl: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: mocks.captureScreenshot
|
||||
}))
|
||||
vi.mock('../../scripts/emulator-developer-menu-dismissal.mjs', () => ({
|
||||
dismissEmulatorDeveloperMenuIfPresent: mocks.dismissDeveloperMenu
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({
|
||||
waitForHostedIosAccessibilityControl: mocks.waitForControl
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-ios-screenshot-parity.mjs', () => ({
|
||||
assertHostedIosScreenshotParity: mocks.compareScreenshots
|
||||
}))
|
||||
vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({
|
||||
readHostedWebViewTextPoint: mocks.readTextPoint
|
||||
}))
|
||||
|
||||
import {
|
||||
captureHostedWorkspaceParity,
|
||||
captureNativeWorkspaceBaseline
|
||||
} from '../../scripts/hosted-ios-workspace-parity.mjs'
|
||||
|
||||
describe('hosted iOS workspace parity', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.captureScreenshot.mockImplementation((_command, _args, callback) =>
|
||||
callback(null, '', '')
|
||||
)
|
||||
mocks.compareScreenshots.mockResolvedValue({ changedPixelRatio: 0.01 })
|
||||
mocks.readTextPoint.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
mocks.waitForControl.mockResolvedValue({ x: 0.2, y: 0.1 })
|
||||
})
|
||||
|
||||
it('captures the unchanged native workspace screen', async () => {
|
||||
const baseline = await captureNativeWorkspaceBaseline({
|
||||
deviceUdid: 'simulator',
|
||||
emulator: { deviceUdid: 'simulator' },
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(mocks.waitForControl).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, 'Filter', 30_000)
|
||||
expect(baseline).toEqual({
|
||||
filterPoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-workspace-portrait.png'
|
||||
})
|
||||
})
|
||||
|
||||
it('compares the unchanged hosted workspace screen', async () => {
|
||||
const evidence = await captureHostedWorkspaceParity({
|
||||
deviceUdid: 'simulator',
|
||||
document: { href: 'orca-mobile-web://build/h/host' },
|
||||
nativeBaseline: {
|
||||
filterPoint: { x: 0.2, y: 0.1 },
|
||||
screenshot: '/tmp/parity/native-workspace-portrait.png'
|
||||
},
|
||||
runtimeDirectory: '/tmp/parity',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
|
||||
expect(mocks.readTextPoint).toHaveBeenCalledWith(expect.anything(), 'Filter')
|
||||
expect(mocks.compareScreenshots).toHaveBeenCalledOnce()
|
||||
expect(evidence).toEqual({
|
||||
nativeScreenshot: 'native-workspace-portrait.png',
|
||||
hostedScreenshot: 'hosted-workspace-portrait.png',
|
||||
nativeFilterPoint: { x: 0.2, y: 0.1 },
|
||||
hostedFilterPoint: { x: 0.2, y: 0.1 },
|
||||
screenshotParity: { changedPixelRatio: 0.01 }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
HOSTED_MOBILE_APP_ROUTE_URL,
|
||||
hostedMobileMetroArguments
|
||||
} from '../../scripts/hosted-mobile-e2e-launch.mjs'
|
||||
|
||||
describe('hosted mobile E2E launch', () => {
|
||||
it('uses an Expo Router path URL', () => {
|
||||
expect(HOSTED_MOBILE_APP_ROUTE_URL).toBe('orca:///hybrid')
|
||||
})
|
||||
|
||||
it('invalidates Metro transforms when the public host identity changes', () => {
|
||||
expect(hostedMobileMetroArguments(8081, true)).toEqual([
|
||||
'start',
|
||||
'--host',
|
||||
'lan',
|
||||
'--port',
|
||||
'8081',
|
||||
'--clear'
|
||||
])
|
||||
expect(hostedMobileMetroArguments(8081, false)).not.toContain('--clear')
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hostedModuleGraph, hostedRoot, mobileRoot } from './hosted-module-graph.test-support'
|
||||
|
||||
const HOSTED_SESSION_ROUTE = join(hostedRoot, 'h', '[hostId]', 'session', '[worktreeId].tsx')
|
||||
|
||||
// Native-only entry points: on the hosted page these either throw or silently do nothing.
|
||||
const BANNED_IN_HOSTED_BUNDLE = ['Clipboard.setStringAsync']
|
||||
const BANNED_IN_SESSION_ROUTE = [
|
||||
...BANNED_IN_HOSTED_BUNDLE,
|
||||
'Linking.openURL',
|
||||
"/transport/host-store'"
|
||||
]
|
||||
|
||||
describe('hosted route native API census', () => {
|
||||
it('keeps native-only APIs out of every module the hosted session route reaches', () => {
|
||||
const graph = hostedModuleGraph([HOSTED_SESSION_ROUTE])
|
||||
|
||||
// Presence precondition: the walk must actually reach the session screen's own modules.
|
||||
expect(graph.length).toBeGreaterThan(500)
|
||||
expect(graph.map((path) => relative(mobileRoot, path))).toEqual(
|
||||
expect.arrayContaining([
|
||||
join('app', 'h', '[hostId]', 'session', '[worktreeId].tsx'),
|
||||
join('src', 'session', 'MobileTerminalInputActions.tsx')
|
||||
])
|
||||
)
|
||||
expect(offenders(graph, BANNED_IN_SESSION_ROUTE)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps native-only APIs out of every module any hosted route reaches', () => {
|
||||
const graph = hostedModuleGraph()
|
||||
|
||||
expect(graph.length).toBeGreaterThan(500)
|
||||
expect(graph).toContain(join(hostedRoot, 'terminal-settings.tsx'))
|
||||
// Terminal settings now has a real hosted route; it is no longer a native-only destination.
|
||||
// AsyncStorage is deliberately not censused: it is reachable today through the shared
|
||||
// storage and transport modules, and on web it resolves to localStorage.
|
||||
expect(offenders(graph, BANNED_IN_HOSTED_BUNDLE)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function offenders(graph: string[], banned: string[]): string[] {
|
||||
const found: string[] = []
|
||||
for (const modulePath of graph) {
|
||||
readFileSync(modulePath, 'utf8')
|
||||
.split('\n')
|
||||
.forEach((line, index) => {
|
||||
if (banned.some((pattern) => line.includes(pattern))) {
|
||||
found.push(`${relative(mobileRoot, modulePath)}:${index + 1}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
return found
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
describeHostedTerminalLinkPoint,
|
||||
hostedTerminalLinkPointsFromGeometry,
|
||||
readHostedTerminalLinkPoints
|
||||
} from '../../scripts/hosted-terminal-link-locator.mjs'
|
||||
|
||||
const geometry = {
|
||||
cellHeight: 20,
|
||||
cellWidth: 10,
|
||||
cursorTop: 460,
|
||||
innerHeight: 740,
|
||||
screenRect: {
|
||||
height: 600,
|
||||
width: 400
|
||||
},
|
||||
terminalRect: {
|
||||
bottom: 700,
|
||||
height: 610,
|
||||
left: 0,
|
||||
top: 90,
|
||||
width: 400
|
||||
},
|
||||
screenHeight: 800,
|
||||
screenWidth: 400
|
||||
}
|
||||
|
||||
describe('hosted terminal link locator', () => {
|
||||
it('maps fixed cursor-relative rows through the resized xterm grid', () => {
|
||||
expect(hostedTerminalLinkPointsFromGeometry(geometry)).toEqual({
|
||||
javascript: { x: 0.0875, y: 0.530625 },
|
||||
file: { x: 0.0875, y: 0.606875 },
|
||||
fileAlternate: { x: 0.0875, y: 0.6322916666666667 },
|
||||
http: { x: 0.0875, y: 0.683125 }
|
||||
})
|
||||
})
|
||||
|
||||
it('uses rendered link rows when the cursor moved after staging', () => {
|
||||
expect(
|
||||
hostedTerminalLinkPointsFromGeometry({
|
||||
...geometry,
|
||||
cursorTop: 20,
|
||||
linkRows: { javascript: 4, file: 7, fileAlternate: 8, http: 10 }
|
||||
})
|
||||
).toEqual({
|
||||
javascript: { x: 0.0875, y: 0.301875 },
|
||||
file: { x: 0.0875, y: 0.378125 },
|
||||
fileAlternate: { x: 0.0875, y: 0.40354166666666663 },
|
||||
http: { x: 0.0875, y: 0.454375 }
|
||||
})
|
||||
})
|
||||
|
||||
it('anchors the staged corpus to the viewport bottom when xterm is blurred', () => {
|
||||
expect(hostedTerminalLinkPointsFromGeometry({ ...geometry, cursorTop: 0 })).toEqual({
|
||||
javascript: { x: 0.0875, y: 0.683125 },
|
||||
file: { x: 0.0875, y: 0.759375 },
|
||||
fileAlternate: { x: 0.0875, y: 0.7847916666666666 },
|
||||
http: { x: 0.0875, y: 0.835625 }
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an empty discovered xterm instead of tapping a guessed row', () => {
|
||||
expect(() =>
|
||||
hostedTerminalLinkPointsFromGeometry({
|
||||
...geometry,
|
||||
cursorTop: 0,
|
||||
linkRows: { javascript: null, file: null, fileAlternate: null, http: null },
|
||||
xtermFound: true
|
||||
})
|
||||
).toThrow('corpus is not present')
|
||||
})
|
||||
|
||||
it('fails when the rendered cell geometry cannot describe the screen', () => {
|
||||
expect(() =>
|
||||
hostedTerminalLinkPointsFromGeometry({
|
||||
...geometry,
|
||||
cellHeight: 70
|
||||
})
|
||||
).toThrow('grid is invalid')
|
||||
})
|
||||
|
||||
it('reports the DOM target under a normalized emulator point', async () => {
|
||||
const evaluate = async (_document: unknown, expression: string) => {
|
||||
expect(expression).toContain('"x":0.25')
|
||||
expect(expression).toContain('document.elementFromPoint')
|
||||
return JSON.stringify({ clientX: 100, clientY: 200, terminalHit: true })
|
||||
}
|
||||
|
||||
await expect(
|
||||
describeHostedTerminalLinkPoint({}, { x: 0.25, y: 0.5 }, { evaluate })
|
||||
).resolves.toEqual({
|
||||
clientX: 100,
|
||||
clientY: 200,
|
||||
terminalHit: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reads rendered link rows from the visible xterm buffer', async () => {
|
||||
const evaluate = async (_document: unknown, expression: string) => {
|
||||
expect(expression).toContain('xterm.buffer.active')
|
||||
return JSON.stringify({
|
||||
...geometry,
|
||||
cursorTop: 0,
|
||||
linkRows: { javascript: 4, file: 7, fileAlternate: 8, http: 10 }
|
||||
})
|
||||
}
|
||||
|
||||
await expect(readHostedTerminalLinkPoints({}, { evaluate })).resolves.toEqual(
|
||||
hostedTerminalLinkPointsFromGeometry({
|
||||
...geometry,
|
||||
cursorTop: 0,
|
||||
linkRows: { javascript: 4, file: 7, fileAlternate: 8, http: 10 }
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,266 +0,0 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
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_HTML_FILENAME,
|
||||
HOSTED_ADVERSARIAL_HTML_MARKER,
|
||||
HOSTED_ADVERSARIAL_IMAGE_FILENAME,
|
||||
HOSTED_ADVERSARIAL_IMAGE_MARKER,
|
||||
HOSTED_ADVERSARIAL_MARKDOWN_FILENAME,
|
||||
HOSTED_ADVERSARIAL_MARKDOWN_MARKER,
|
||||
HOSTED_ADVERSARIAL_SVG_FILENAME,
|
||||
HOSTED_ADVERSARIAL_SVG_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'
|
||||
import { hostedAdversarialFileExecutionEvidence } from '../../scripts/hosted-webview-adversarial-files.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const fixtures: 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({
|
||||
probePort: 54321
|
||||
})
|
||||
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)
|
||||
expect(fixture.repositoryFiles.map(({ filename }) => filename)).toEqual([
|
||||
HOSTED_ADVERSARIAL_MARKDOWN_FILENAME,
|
||||
HOSTED_ADVERSARIAL_HTML_FILENAME,
|
||||
HOSTED_ADVERSARIAL_SVG_FILENAME,
|
||||
HOSTED_ADVERSARIAL_IMAGE_FILENAME
|
||||
])
|
||||
expect(fixture.repositoryFiles.map(({ marker }) => marker)).toEqual([
|
||||
HOSTED_ADVERSARIAL_MARKDOWN_MARKER,
|
||||
HOSTED_ADVERSARIAL_HTML_MARKER,
|
||||
HOSTED_ADVERSARIAL_SVG_MARKER,
|
||||
HOSTED_ADVERSARIAL_IMAGE_MARKER
|
||||
])
|
||||
for (const file of fixture.repositoryFiles) {
|
||||
const content = await readFile(path.join(fixture.root, file.filename))
|
||||
expect(content).toEqual(Buffer.from(file.content))
|
||||
expect(content.includes(file.marker)).toBe(true)
|
||||
expect(content.includes('http://127.0.0.1:54321/')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
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('rejects malformed repository-file execution evidence', () => {
|
||||
expect(
|
||||
hostedAdversarialFileExecutionEvidence({
|
||||
markers: [false, false, false, false],
|
||||
injectedElementCount: 0
|
||||
})
|
||||
).toEqual({
|
||||
injectedElementCount: 0,
|
||||
repositoryFileScriptMarkersExecuted: false
|
||||
})
|
||||
expect(() =>
|
||||
hostedAdversarialFileExecutionEvidence({
|
||||
markers: [false, 'false', false, false],
|
||||
injectedElementCount: 0
|
||||
})
|
||||
).toThrow('repository file executed')
|
||||
})
|
||||
|
||||
it('keeps the disposable corpus wired into the exact Android route gate', () => {
|
||||
expect(androidHarnessSource).toContain('createHostedAdversarialRuntimeFixture')
|
||||
expect(androidHarnessSource).toContain('captureHostedWebViewAdversarialObservation')
|
||||
expect(androidHarnessSource).toContain('hostedWebViewAdversarialContentObservations')
|
||||
expect(androidHarnessSource).toContain('removeHostedAdversarialRuntimeFixture')
|
||||
})
|
||||
|
||||
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('removeHostedAdversarialRuntimeFixture')
|
||||
})
|
||||
})
|
||||
@@ -1,804 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
activateHostedWebViewControl,
|
||||
assertNoHostedMobileWebCdpTarget,
|
||||
isHostedMobileWebUrl,
|
||||
readHostedWebViewState,
|
||||
readHostedWebViewTextPoint,
|
||||
selectVisibleHostedWebView,
|
||||
setHostedWebViewInput,
|
||||
startHostedWebViewConnectionObservation,
|
||||
terminateHostedWebViewProcess,
|
||||
waitForHostedWebViewConnectionSequence,
|
||||
verifyHostedWebViewNavigationIsolation,
|
||||
verifyHostedWebViewNetworkIsolation
|
||||
} from '../../scripts/hosted-webview-cdp-session.mjs'
|
||||
import { createHostedIosNativeBaselineStep } from '../../scripts/hosted-ios-native-baseline-step.mjs'
|
||||
import { verifyHostedWebViewExecutableIsolation } from '../../scripts/hosted-webview-executable-isolation.mjs'
|
||||
import {
|
||||
FakeCdpSocket,
|
||||
FakeProcessTerminationSocket,
|
||||
fakeCdpConstructor
|
||||
} from './hosted-webview-cdp-test-fakes'
|
||||
|
||||
const iosShellSource = readFileSync(
|
||||
new URL('../../packages/expo-mobile-web-shell/ios/MobileWebShellView.swift', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const androidShellSource = readFileSync(
|
||||
new URL(
|
||||
'../../packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt',
|
||||
import.meta.url
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
const androidProbeSource = readFileSync(
|
||||
new URL(
|
||||
'../../packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt',
|
||||
import.meta.url
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
// Read patches through pnpm-workspace.yaml so a dependency bump can't leave a patch undeclared.
|
||||
const workspaceManifest = readFileSync(
|
||||
new URL('../../pnpm-workspace.yaml', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
function readPatchedDependencySource(packageName: string): string {
|
||||
const entry = workspaceManifest.match(
|
||||
new RegExp(`^\\s+'?${packageName.replace(/[/@.]/g, '\\$&')}@[^':]+'?:\\s*(\\S+)$`, 'm')
|
||||
)
|
||||
if (!entry) {
|
||||
throw new Error(`no patchedDependencies entry for ${packageName}`)
|
||||
}
|
||||
return readFileSync(new URL(`../../${entry[1]}`, import.meta.url), 'utf8')
|
||||
}
|
||||
const expoLogBoxPatch = readPatchedDependencySource('@expo/log-box')
|
||||
const expoDomWebViewPatch = readPatchedDependencySource('@expo/dom-webview')
|
||||
const reactNativeWebViewPatch = readPatchedDependencySource('react-native-webview')
|
||||
const simulatorHarnessSource = readFileSync(
|
||||
new URL('../../scripts/run-hosted-webview-simulator-e2e.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const simulatorSecurityEvidenceSource = readFileSync(
|
||||
new URL('../../scripts/hosted-webview-security-evidence.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const simulatorReportSource = readFileSync(
|
||||
new URL('../../scripts/hosted-webview-e2e-report.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const simulatorAppBuildSource = readFileSync(
|
||||
new URL('../../scripts/hosted-ios-simulator-app-build.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const simulatorAppPreparationSource = readFileSync(
|
||||
new URL('../../scripts/hosted-ios-simulator-app-preparation.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const simulatorLauncherSource = readFileSync(
|
||||
new URL('../../scripts/hosted-ios-mobile-launcher.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const androidSecurityHarnessSource = readFileSync(
|
||||
new URL('../../scripts/run-hosted-android-webview-security-e2e.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const androidReleaseHarnessSource = readFileSync(
|
||||
new URL('../../scripts/verify-hosted-android-release-webview.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const androidCrashLoopHarnessSource = readFileSync(
|
||||
new URL('../../scripts/run-hosted-android-webview-crash-loop.mjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function probe(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
targetId: 'target-a',
|
||||
href: 'orca-mobile-web://session-a/',
|
||||
visibility: 'visible',
|
||||
focused: true,
|
||||
bridgeListening: true,
|
||||
bodyText: 'mobile-rearch',
|
||||
buttonCount: 3,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('hosted WebView CDP target selection', () => {
|
||||
it('waits for the workspace route when the departing session has the same text', () => {
|
||||
const session = probe({ href: 'orca-mobile-web://session-a/h/host/session/workspace' })
|
||||
const workspace = probe({ href: 'orca-mobile-web://session-a/h/host', focused: false })
|
||||
expect(
|
||||
selectVisibleHostedWebView([session], 'mobile-rearch', undefined, true, '/h/host')
|
||||
).toBeNull()
|
||||
expect(
|
||||
selectVisibleHostedWebView([session, workspace], 'mobile-rearch', undefined, true, '/h/host')
|
||||
).toBe(workspace)
|
||||
})
|
||||
|
||||
it('recognizes only the platform private asset origins', () => {
|
||||
expect(isHostedMobileWebUrl('orca-mobile-web://session-a/')).toBe(true)
|
||||
expect(isHostedMobileWebUrl('https://session-a.orca-mobile-web.invalid/#session-a')).toBe(true)
|
||||
expect(isHostedMobileWebUrl('https://orca-mobile-web.invalid/#session-a')).toBe(true)
|
||||
expect(isHostedMobileWebUrl('https://orca-mobile-web.invalid.evil.test/')).toBe(false)
|
||||
expect(isHostedMobileWebUrl('https://session-a.orca-mobile-web.invalid.evil.test/')).toBe(false)
|
||||
expect(isHostedMobileWebUrl('http://orca-mobile-web.invalid/')).toBe(false)
|
||||
expect(isHostedMobileWebUrl('https://session-a.orca-mobile-web.invalid.evil/')).toBe(false)
|
||||
})
|
||||
|
||||
it('proves native baselines have no hosted private-origin CDP target', async () => {
|
||||
const target = (url: string) => ({
|
||||
id: 'target',
|
||||
type: 'page',
|
||||
url,
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/target'
|
||||
})
|
||||
const fetchTargets = (urls: string[]) => async () =>
|
||||
new Response(JSON.stringify(urls.map(target)))
|
||||
|
||||
await expect(
|
||||
assertNoHostedMobileWebCdpTarget({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
fetchImpl: fetchTargets(['about:blank', 'file:///native-terminal.html'])
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
await expect(
|
||||
assertNoHostedMobileWebCdpTarget({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
fetchImpl: fetchTargets(Array.from({ length: 17 }, () => 'about:blank'))
|
||||
})
|
||||
).rejects.toThrow('Native baseline CDP target count exceeded its inspection limit')
|
||||
for (const url of [
|
||||
'orca-mobile-web://session-a/',
|
||||
'https://orca-mobile-web.invalid/#session-a'
|
||||
]) {
|
||||
await expect(
|
||||
assertNoHostedMobileWebCdpTarget({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
fetchImpl: fetchTargets([url])
|
||||
})
|
||||
).rejects.toThrow('Native baseline has a hosted mobile WebView CDP target')
|
||||
}
|
||||
await expect(
|
||||
assertNoHostedMobileWebCdpTarget({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
fetchImpl: async () =>
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'uninspectable-hosted-target',
|
||||
type: 'page',
|
||||
url: 'orca-mobile-web://session-a/'
|
||||
}
|
||||
])
|
||||
)
|
||||
})
|
||||
).rejects.toThrow('Native baseline has a hosted mobile WebView CDP target')
|
||||
const staleDiscoverySocket = new FakeCdpSocket([JSON.stringify(probe())])
|
||||
await expect(
|
||||
assertNoHostedMobileWebCdpTarget({
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
fetchImpl: fetchTargets(['about:blank']),
|
||||
WebSocketCtor: fakeCdpConstructor(staleDiscoverySocket)
|
||||
})
|
||||
).rejects.toThrow('Native baseline has a hosted mobile WebView CDP target')
|
||||
})
|
||||
|
||||
it('asserts target exclusion around every native baseline capture', async () => {
|
||||
const events: string[] = []
|
||||
const nativeBaselineStep = createHostedIosNativeBaselineStep({
|
||||
assertNoHostedMobileWebCdpTarget: async () => {
|
||||
events.push('assert')
|
||||
},
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
evidenceStep: async (label: string, run: () => Promise<string>) => {
|
||||
events.push(label)
|
||||
return run()
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
nativeBaselineStep('native workspace baseline', async () => {
|
||||
events.push('capture')
|
||||
return 'captured'
|
||||
})
|
||||
).resolves.toBe('captured')
|
||||
expect(events).toEqual([
|
||||
'native workspace baseline hosted target exclusion',
|
||||
'assert',
|
||||
'native workspace baseline',
|
||||
'capture',
|
||||
'native workspace baseline hosted target exclusion after capture',
|
||||
'assert'
|
||||
])
|
||||
})
|
||||
|
||||
it('selects only a visible interactive hosted document with expected UI text', () => {
|
||||
expect(
|
||||
selectVisibleHostedWebView(
|
||||
[
|
||||
probe({ href: 'https://untrusted.example/' }),
|
||||
probe({ visibility: 'hidden' }),
|
||||
probe({ bridgeListening: false }),
|
||||
probe({ bodyText: 'another workspace' }),
|
||||
probe()
|
||||
],
|
||||
'mobile-rearch'
|
||||
)
|
||||
).toMatchObject({ targetId: 'target-a' })
|
||||
})
|
||||
|
||||
it('prefers the focused generation when WebKit briefly reports two visible documents', () => {
|
||||
expect(
|
||||
selectVisibleHostedWebView(
|
||||
[probe({ targetId: 'old', focused: false }), probe({ targetId: 'current', focused: true })],
|
||||
'mobile-rearch'
|
||||
)
|
||||
).toMatchObject({ targetId: 'current' })
|
||||
})
|
||||
|
||||
it('rejects a document without user-observable controls', () => {
|
||||
expect(selectVisibleHostedWebView([probe({ buttonCount: 0 })], 'mobile-rearch')).toBeNull()
|
||||
})
|
||||
|
||||
it('can select a ready terminal document without raw DOM buttons', () => {
|
||||
expect(
|
||||
selectVisibleHostedWebView(
|
||||
[probe({ buttonCount: 0, href: 'orca-mobile-web://session-a/h/host/session/worktree' })],
|
||||
'mobile-rearch',
|
||||
'/session/',
|
||||
false
|
||||
)
|
||||
).toMatchObject({ targetId: 'target-a' })
|
||||
})
|
||||
|
||||
it('can require the visible document to be on the expected route', () => {
|
||||
expect(
|
||||
selectVisibleHostedWebView(
|
||||
[
|
||||
probe({
|
||||
href: 'orca-mobile-web://session-a/h/host/agent-history/worktree'
|
||||
}),
|
||||
probe({
|
||||
href: 'orca-mobile-web://session-a/h/host/session/worktree'
|
||||
})
|
||||
],
|
||||
'mobile-rearch',
|
||||
'/session/'
|
||||
)
|
||||
).toMatchObject({
|
||||
href: 'orca-mobile-web://session-a/h/host/session/worktree'
|
||||
})
|
||||
})
|
||||
|
||||
it('starts the document probe and requires its completion marker', async () => {
|
||||
const socket = new FakeCdpSocket(['probe-a', 'probe-a'])
|
||||
const result = await verifyHostedWebViewNetworkIsolation({
|
||||
document: {
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current'
|
||||
},
|
||||
probeId: 'probe-a',
|
||||
settleDelayMs: 0,
|
||||
WebSocketCtor: class {
|
||||
constructor() {
|
||||
return socket
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
fetch: 'attempted',
|
||||
xhr: 'attempted',
|
||||
webSocket: 'attempted',
|
||||
image: 'attempted'
|
||||
})
|
||||
expect(socket.evaluations).toHaveLength(2)
|
||||
const expressions = socket.evaluations
|
||||
.map((evaluation) => evaluation.params.expression)
|
||||
.join('\n')
|
||||
expect(expressions).toContain('__orcaRunSecurityProbe')
|
||||
expect(expressions).toContain('__orcaDebugNetworkProbeCompletion')
|
||||
})
|
||||
|
||||
it('requires an exact probe token', async () => {
|
||||
await expect(
|
||||
verifyHostedWebViewNetworkIsolation({
|
||||
document: {
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current'
|
||||
},
|
||||
probeId: ''
|
||||
})
|
||||
).rejects.toThrow('token is unavailable')
|
||||
})
|
||||
|
||||
it('requires all adversarial navigation attempts to fail closed', async () => {
|
||||
const socket = new FakeCdpSocket([
|
||||
JSON.stringify({
|
||||
token: 'probe-a',
|
||||
documentRetained: true,
|
||||
popupBlocked: true,
|
||||
serviceWorkerBlocked: true,
|
||||
redirectFrameAttempted: true,
|
||||
downloadAttempted: true,
|
||||
externalSchemeAttempted: true
|
||||
})
|
||||
])
|
||||
const result = await verifyHostedWebViewNavigationIsolation({
|
||||
document: {
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current'
|
||||
},
|
||||
probeId: 'probe-a',
|
||||
settleDelayMs: 0,
|
||||
WebSocketCtor: class {
|
||||
constructor() {
|
||||
return socket
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
documentRetained: true,
|
||||
popupBlocked: true,
|
||||
serviceWorkerBlocked: true,
|
||||
redirectFrameAttempted: true,
|
||||
downloadAttempted: true,
|
||||
externalSchemeAttempted: true
|
||||
})
|
||||
expect(socket.evaluations[0]?.params.expression).toContain(
|
||||
'__orcaDebugNavigationProbeCompletion'
|
||||
)
|
||||
})
|
||||
|
||||
it('requires the active script and blocks an undeclared executable asset', async () => {
|
||||
const socket = new FakeCdpSocket([
|
||||
JSON.stringify({
|
||||
token: 'probe-a',
|
||||
activeDeclaredScriptLoaded: true,
|
||||
undeclaredScriptBlocked: true,
|
||||
documentRetained: true,
|
||||
bridgeListening: true,
|
||||
scriptPaths: ['/h/host/review/assets/bundle.js']
|
||||
})
|
||||
])
|
||||
|
||||
await expect(
|
||||
verifyHostedWebViewExecutableIsolation({
|
||||
document: {
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current'
|
||||
},
|
||||
probeId: 'probe-a',
|
||||
settleDelayMs: 0,
|
||||
WebSocketCtor: class {
|
||||
constructor() {
|
||||
return socket
|
||||
}
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({
|
||||
activeDeclaredScriptLoaded: true,
|
||||
undeclaredScriptBlocked: true,
|
||||
documentRetained: true
|
||||
})
|
||||
expect(socket.evaluations[0]?.params.expression).toContain(
|
||||
'__orcaDebugExecutableProbeCompletion'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects incomplete executable isolation evidence', async () => {
|
||||
const socket = new FakeCdpSocket([
|
||||
JSON.stringify({
|
||||
token: 'probe-a',
|
||||
activeDeclaredScriptLoaded: true,
|
||||
undeclaredScriptBlocked: false,
|
||||
documentRetained: true
|
||||
})
|
||||
])
|
||||
|
||||
await expect(
|
||||
verifyHostedWebViewExecutableIsolation({
|
||||
document: {
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current'
|
||||
},
|
||||
probeId: 'probe-a',
|
||||
settleDelayMs: 0,
|
||||
WebSocketCtor: class {
|
||||
constructor() {
|
||||
return socket
|
||||
}
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('executable isolation failed')
|
||||
})
|
||||
|
||||
it('reads a normalized hosted text landmark', async () => {
|
||||
const socket = new FakeCdpSocket([JSON.stringify({ x: 0.25, y: 0.125 })])
|
||||
|
||||
await expect(
|
||||
readHostedWebViewTextPoint(
|
||||
{ webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current' },
|
||||
'Agent Session History',
|
||||
fakeCdpConstructor(socket),
|
||||
{ horizontalPosition: 0.15, 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('rect.width * 0.15')
|
||||
expect(socket.evaluations[0]?.params.expression).toContain(
|
||||
"scrollIntoView({ block: 'nearest', inline: 'nearest' })"
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects incomplete adversarial navigation evidence', async () => {
|
||||
const socket = new FakeCdpSocket([
|
||||
JSON.stringify({
|
||||
token: 'probe-a',
|
||||
documentRetained: true,
|
||||
popupBlocked: false
|
||||
})
|
||||
])
|
||||
await expect(
|
||||
verifyHostedWebViewNavigationIsolation({
|
||||
document: {
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current'
|
||||
},
|
||||
probeId: 'probe-a',
|
||||
settleDelayMs: 0,
|
||||
WebSocketCtor: class {
|
||||
constructor() {
|
||||
return socket
|
||||
}
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('navigation isolation failed')
|
||||
})
|
||||
|
||||
it('accepts an Android popup proxy only without a second page target', async () => {
|
||||
const socket = new FakeCdpSocket([
|
||||
JSON.stringify({
|
||||
token: 'probe-a',
|
||||
documentRetained: true,
|
||||
popupBlocked: false,
|
||||
serviceWorkerBlocked: true,
|
||||
redirectFrameAttempted: true,
|
||||
downloadAttempted: true,
|
||||
externalSchemeAttempted: true
|
||||
})
|
||||
])
|
||||
const fetchImpl = async () =>
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'current',
|
||||
type: 'page',
|
||||
url: 'https://orca-mobile-web.invalid/#session',
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await expect(
|
||||
verifyHostedWebViewNavigationIsolation({
|
||||
document: {
|
||||
targetId: 'current',
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current'
|
||||
},
|
||||
discoveryUrl: 'http://127.0.0.1:9222',
|
||||
probeId: 'probe-a',
|
||||
settleDelayMs: 0,
|
||||
WebSocketCtor: class {
|
||||
constructor() {
|
||||
return socket
|
||||
}
|
||||
},
|
||||
fetchImpl
|
||||
})
|
||||
).resolves.toMatchObject({ popupBlocked: true })
|
||||
})
|
||||
|
||||
it('reads bounded UI facts and activates controls without page-owned selectors', async () => {
|
||||
const socket = new FakeCdpSocket([
|
||||
JSON.stringify({
|
||||
href: 'orca-mobile-web://session-a/',
|
||||
bodyText: 'Agent Session History',
|
||||
labels: ['Back', 'Refresh agent sessions'],
|
||||
placeholders: ['Search sessions, repo:, path:']
|
||||
}),
|
||||
JSON.stringify({ found: true }),
|
||||
JSON.stringify({ activated: true }),
|
||||
JSON.stringify({ updated: true }),
|
||||
JSON.stringify({ found: true }),
|
||||
JSON.stringify({ activated: true })
|
||||
])
|
||||
const document = {
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current'
|
||||
}
|
||||
|
||||
await expect(
|
||||
readHostedWebViewState(document, fakeCdpConstructor(socket))
|
||||
).resolves.toMatchObject({
|
||||
bodyText: 'Agent Session History',
|
||||
labels: ['Back', 'Refresh agent sessions']
|
||||
})
|
||||
await expect(
|
||||
activateHostedWebViewControl(
|
||||
document,
|
||||
{ kind: 'label', value: 'More session actions', reveal: true },
|
||||
fakeCdpConstructor(socket)
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
await expect(
|
||||
setHostedWebViewInput(
|
||||
document,
|
||||
{ placeholder: 'Search sessions, repo:, path:', value: 'fixture' },
|
||||
fakeCdpConstructor(socket)
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
await expect(
|
||||
activateHostedWebViewControl(
|
||||
document,
|
||||
{ kind: 'text', value: 'mobile-rearch' },
|
||||
fakeCdpConstructor(socket)
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
const expressions = socket.evaluations
|
||||
.map((evaluation) => evaluation.params.expression)
|
||||
.join('\n')
|
||||
expect(expressions).toContain('input[placeholder],textarea[placeholder]')
|
||||
expect(expressions).toContain('element.getBoundingClientRect()')
|
||||
expect(expressions).toContain('rect.top < innerHeight')
|
||||
expect(expressions).toContain('More session actions')
|
||||
expect(expressions).toContain('closest(\'button,[role="button"],a,[tabindex]\')')
|
||||
expect(expressions).toContain('getBoundingClientRect()')
|
||||
expect(expressions).toContain("scrollIntoView({ block: 'nearest', inline: 'nearest' })")
|
||||
expect(expressions).toContain('data-orca-cdp-activation')
|
||||
expect(expressions).toContain('__orcaCdpActivationLedger')
|
||||
expect(expressions).toContain("Object.getOwnPropertyDescriptor(prototype, 'value')")
|
||||
expect(expressions).toContain("new InputEvent('input'")
|
||||
expect(expressions).toContain('element.click()')
|
||||
})
|
||||
|
||||
it('requires renderer-loss evidence after requesting a debug process crash', async () => {
|
||||
const socket = new FakeProcessTerminationSocket()
|
||||
await expect(
|
||||
terminateHostedWebViewProcess(
|
||||
{ webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current' },
|
||||
fakeCdpConstructor(socket)
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
expect(socket.command).toEqual({ id: 1, method: 'Page.crash' })
|
||||
})
|
||||
|
||||
it('observes an ordered reconnect while recording retained route evidence', async () => {
|
||||
const entries = [
|
||||
{
|
||||
state: 'recovering',
|
||||
href: 'orca-mobile-web://session-a/h/host/agent-history/worktree',
|
||||
retainedExpectedText: true,
|
||||
retainedExpectedRoute: true
|
||||
},
|
||||
{
|
||||
state: 'connected',
|
||||
href: 'orca-mobile-web://session-a/h/host/agent-history/worktree',
|
||||
retainedExpectedText: true,
|
||||
retainedExpectedRoute: true
|
||||
}
|
||||
]
|
||||
const socket = new FakeCdpSocket([JSON.stringify({ started: true }), JSON.stringify(entries)])
|
||||
const document = {
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/current'
|
||||
}
|
||||
const WebSocketCtor = fakeCdpConstructor(socket)
|
||||
|
||||
await startHostedWebViewConnectionObservation(
|
||||
document,
|
||||
{
|
||||
expectedText: 'Hybrid Agent History Fixture',
|
||||
expectedHrefIncludes: '/agent-history/'
|
||||
},
|
||||
WebSocketCtor
|
||||
)
|
||||
await expect(
|
||||
waitForHostedWebViewConnectionSequence(
|
||||
document,
|
||||
['recovering', 'connected'],
|
||||
1_000,
|
||||
WebSocketCtor
|
||||
)
|
||||
).resolves.toEqual(entries)
|
||||
|
||||
const expressions = socket.evaluations
|
||||
.map((evaluation) => evaluation.params.expression)
|
||||
.join('\n')
|
||||
expect(expressions).toContain("addEventListener('message'")
|
||||
expect(expressions).toContain("message?.type !== 'connection'")
|
||||
expect(expressions).toContain('requestAnimationFrame')
|
||||
expect(expressions).toContain('retainedExpectedText')
|
||||
expect(expressions).toContain('retainedExpectedRoute')
|
||||
})
|
||||
|
||||
it('keeps the native probe DEBUG-only, loopback-only, and completion-marked', () => {
|
||||
const probeStart = iosShellSource.indexOf(
|
||||
'private func mobileWebNetworkIsolationProbeUserScript()'
|
||||
)
|
||||
const debugStart = iosShellSource.lastIndexOf('#if DEBUG', probeStart)
|
||||
const debugEnd = iosShellSource.indexOf('#endif', probeStart)
|
||||
const probeSource = iosShellSource.slice(debugStart, debugEnd)
|
||||
|
||||
expect(debugStart).toBeGreaterThanOrEqual(0)
|
||||
expect(debugEnd).toBeGreaterThan(probeStart)
|
||||
expect(probeSource).toContain('ORCA_E2E_MOBILE_WEB_NETWORK_PROBE_PORT')
|
||||
expect(probeSource).toContain('ORCA_E2E_MOBILE_WEB_NETWORK_PROBE_TOKEN')
|
||||
expect(probeSource).toContain('http://127.0.0.1:')
|
||||
expect(probeSource).toContain('ws://127.0.0.1:')
|
||||
expect(probeSource).toContain('completed===4')
|
||||
expect(probeSource).toContain('__orcaDebugNavigationProbeCompletion')
|
||||
expect(probeSource).toContain('__orcaDebugExecutableProbeCompletion')
|
||||
expect(probeSource).toContain('activeDeclaredScriptLoaded')
|
||||
expect(probeSource).toContain('undeclaredScriptBlocked')
|
||||
expect(probeSource).toContain('[a-f0-9]{64}\\\\.js$')
|
||||
expect(probeSource).toContain("script.getAttribute('src')")
|
||||
expect(probeSource).toContain('new URL(declaredScriptPath(activeScript),location.origin)')
|
||||
expect(probeSource).toContain("window.open(probeBase+'/popup-probe','_blank')")
|
||||
expect(probeSource).toContain("frame.src=probeBase+'/redirect-probe'")
|
||||
expect(probeSource).toContain("download.href=probeBase+'/download-probe'")
|
||||
expect(probeSource).toContain("navigator.serviceWorker.register(probeBase+'/worker-probe')")
|
||||
expect(probeSource).toContain("location.assign('orca-security-probe://blocked')")
|
||||
expect(probeSource).toContain('forMainFrameOnly: true')
|
||||
expect(iosShellSource).toContain(
|
||||
'#if DEBUG\n if let networkProbe = mobileWebNetworkIsolationProbeUserScript()'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the Android probe debuggable-only and installs it at document start', () => {
|
||||
expect(androidProbeSource).toContain('BuildConfig.DEBUG')
|
||||
expect(androidProbeSource).toContain('ApplicationInfo.FLAG_DEBUGGABLE')
|
||||
expect(androidProbeSource).toContain(
|
||||
'WebView.setWebContentsDebuggingEnabled(isMobileWebInspectionEnabled(applicationFlags))'
|
||||
)
|
||||
expect(androidProbeSource).toContain('if (!BuildConfig.DEBUG || !isDebuggable) return null')
|
||||
expect(androidProbeSource).toContain('ORCA_E2E_MOBILE_WEB_NETWORK_PROBE_PORT')
|
||||
expect(androidProbeSource).toContain('ORCA_E2E_MOBILE_WEB_NETWORK_PROBE_TOKEN')
|
||||
expect(androidProbeSource).toContain('http://127.0.0.1:')
|
||||
expect(androidProbeSource).toContain('ws://127.0.0.1:')
|
||||
expect(androidProbeSource).toContain('globalThis.__orcaRunSecurityProbe=function()')
|
||||
expect(androidProbeSource).toContain('globalThis.__orcaMobileWebShellListening!==true')
|
||||
expect(androidProbeSource).toContain('completed===4')
|
||||
expect(androidProbeSource).toContain('__orcaDebugNavigationProbeCompletion')
|
||||
expect(androidProbeSource).toContain('__orcaDebugExecutableProbeCompletion')
|
||||
expect(androidProbeSource).toContain('activeDeclaredScriptLoaded')
|
||||
expect(androidProbeSource).toContain('undeclaredScriptBlocked')
|
||||
expect(androidProbeSource).toContain('[a-f0-9]{64}\\.js')
|
||||
expect(androidProbeSource).toContain("script.getAttribute('src')")
|
||||
expect(androidProbeSource).toContain(
|
||||
'new URL(declaredScriptPath(activeScript),location.origin)'
|
||||
)
|
||||
expect(androidProbeSource).toContain("window.open(probeBase+'/popup-probe','_blank')")
|
||||
expect(androidProbeSource).toContain("frame.src=probeBase+'/redirect-probe'")
|
||||
expect(androidProbeSource).toContain("download.href=probeBase+'/download-probe'")
|
||||
expect(androidProbeSource).toContain(
|
||||
"navigator.serviceWorker.register(probeBase+'/worker-probe')"
|
||||
)
|
||||
expect(androidProbeSource).toContain("location.assign('orca-security-probe://blocked')")
|
||||
expect(androidProbeSource).toContain('WebViewFeature.DOCUMENT_START_SCRIPT')
|
||||
expect(androidProbeSource).toContain('WebViewCompat.addDocumentStartJavaScript')
|
||||
expect(androidProbeSource).toContain('allowedOrigin: String')
|
||||
expect(androidProbeSource).toContain('setOf(allowedOrigin)')
|
||||
expect(androidProbeSource).not.toContain('setOf(MOBILE_WEB_ORIGIN)')
|
||||
expect(androidShellSource).toContain(
|
||||
'installMobileWebDebugIsolationProbe(\n webView,\n appContext,\n mobileWebOriginForSession(sessionId)'
|
||||
)
|
||||
expect(androidShellSource).toContain('override fun onAttachedToWindow()')
|
||||
expect(androidShellSource).toContain('addBridgeMessageListener(sessionId)')
|
||||
expect(androidShellSource).toContain(
|
||||
'!documentLoaded || currentUrl == null || !isAllowedDocumentUrl(currentUrl)'
|
||||
)
|
||||
expect(expoLogBoxPatch).toContain(
|
||||
'context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0'
|
||||
)
|
||||
expect(expoLogBoxPatch).toContain('- setWebContentsDebuggingEnabled(true)')
|
||||
expect(expoLogBoxPatch).toContain('+ setWebContentsDebuggingEnabled(isDebuggable)')
|
||||
expect(expoDomWebViewPatch).toContain(
|
||||
'+ WebView.setWebContentsDebuggingEnabled(webviewDebuggingEnabled && isDebuggable)'
|
||||
)
|
||||
expect(reactNativeWebViewPatch).toContain(
|
||||
'+ RNCWebView.setWebContentsDebuggingEnabled(enabled && isDebuggable)'
|
||||
)
|
||||
expect(reactNativeWebViewPatch).toContain(
|
||||
'+ if (ReactBuildConfig.DEBUG && isDebuggable) {'
|
||||
)
|
||||
})
|
||||
|
||||
it('requires a production Android image for the Release inspector gate', () => {
|
||||
expect(androidReleaseHarnessSource).toContain("buildType !== 'user'")
|
||||
expect(androidReleaseHarnessSource).toContain("debuggable !== '0'")
|
||||
expect(androidReleaseHarnessSource).toContain("!fingerprint.includes(':user/')")
|
||||
expect(androidReleaseHarnessSource).toContain("packageFlags.includes('DEBUGGABLE')")
|
||||
expect(androidReleaseHarnessSource).toContain("'am', 'force-stop'")
|
||||
expect(androidReleaseHarnessSource).toContain("'uiautomator', 'dump', '/dev/tty'")
|
||||
expect(androidReleaseHarnessSource).toContain('assertNoInspectorSocket(options, pid)')
|
||||
expect(androidReleaseHarnessSource).toContain(
|
||||
'Android Release DevTools discovery endpoint is accessible'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds and installs the exact native shell before the isolated live gate', () => {
|
||||
expect(simulatorHarnessSource).toContain('hostedIosSimulatorAppPreparation')
|
||||
expect(simulatorHarnessSource).toContain(
|
||||
'const appPreparation = hostedIosSimulatorAppPreparation'
|
||||
)
|
||||
expect(simulatorHarnessSource).toContain(
|
||||
'nativeAppPath = await evidenceStep(appPreparation.label, appPreparation.run)'
|
||||
)
|
||||
expect(simulatorAppPreparationSource).toContain("label: 'existing native simulator app'")
|
||||
expect(simulatorAppPreparationSource).toContain("label: 'cached native simulator app install'")
|
||||
expect(simulatorAppPreparationSource).toContain("label: 'native simulator app build'")
|
||||
expect(simulatorHarnessSource).toContain('options.securityOnly')
|
||||
expect(simulatorHarnessSource).toContain(
|
||||
"expectedText: options.sourceControlOnly ? '1 tab' : '2 tabs'"
|
||||
)
|
||||
expect(simulatorHarnessSource).toContain(
|
||||
"await evidenceStep('hosted terminal device input journey'"
|
||||
)
|
||||
expect(simulatorHarnessSource).toContain(
|
||||
'terminalDeviceInput.photoPermissionRevocation?.sessionDocument'
|
||||
)
|
||||
expect(simulatorReportSource).toContain(
|
||||
'terminalDeviceInput?.terminalClipboardImagePaste?.evidence'
|
||||
)
|
||||
expect(simulatorHarnessSource).toContain('captureHostedWebViewSecurityEvidence')
|
||||
expect(simulatorSecurityEvidenceSource).toContain('verifyHostedWebViewExecutableIsolation')
|
||||
expect(simulatorSecurityEvidenceSource).toContain('verifyHostedWebViewPrivacyIsolation')
|
||||
expect(simulatorHarnessSource).toContain("await evidenceStep('Photos permission reset'")
|
||||
expect(simulatorHarnessSource).toContain('await clearHostedIosWebViewSecurityProbe(deviceUdid)')
|
||||
expect(simulatorAppBuildSource).toContain("'xcodebuild'")
|
||||
expect(simulatorAppBuildSource).toContain("'simctl', 'install', deviceUdid, appPath")
|
||||
expect(simulatorAppBuildSource).not.toContain('CODE_SIGNING_ALLOWED=NO')
|
||||
expect(simulatorLauncherSource).toContain("EXPO_PUBLIC_ORCA_E2E_MOBILE_NATIVE_BASELINE: '1'")
|
||||
const inspectorStart = simulatorHarnessSource.indexOf('inspector = await startCdpServer')
|
||||
const nativeCapture = simulatorHarnessSource.indexOf("'native workspace baseline'")
|
||||
const exclusion = simulatorHarnessSource.indexOf(
|
||||
'assertNoHostedMobileWebCdpTarget',
|
||||
inspectorStart
|
||||
)
|
||||
const handoff = simulatorHarnessSource.indexOf("'native hybrid route handoff'")
|
||||
const hostedWait = simulatorHarnessSource.indexOf('let workspaceDocument = await')
|
||||
expect(inspectorStart).toBeGreaterThanOrEqual(0)
|
||||
expect(exclusion).toBeGreaterThan(inspectorStart)
|
||||
expect(nativeCapture).toBeGreaterThan(exclusion)
|
||||
expect(handoff).toBeGreaterThan(nativeCapture)
|
||||
expect(hostedWait).toBeGreaterThan(handoff)
|
||||
})
|
||||
|
||||
it('installs and launches the exact Android shell with a proven sentinel', () => {
|
||||
expect(androidSecurityHarnessSource).toContain("['install', '-r', '-t', options.apk]")
|
||||
expect(androidSecurityHarnessSource).toContain(
|
||||
"['reverse', `tcp:${probe.port}`, `tcp:${probe.port}`]"
|
||||
)
|
||||
expect(androidSecurityHarnessSource).toContain("'nc', '-z', '-w', '5', '127.0.0.1'")
|
||||
expect(androidSecurityHarnessSource).toContain("includes('tcp:connection')")
|
||||
expect(androidSecurityHarnessSource).toContain('probe.reset()')
|
||||
expect(androidSecurityHarnessSource).toContain("'ORCA_E2E_MOBILE_WEB_NETWORK_PROBE_PORT'")
|
||||
expect(androidSecurityHarnessSource).toContain("'ORCA_E2E_MOBILE_WEB_NETWORK_PROBE_TOKEN'")
|
||||
expect(androidSecurityHarnessSource).toContain('waitForVisibleHostedWebView')
|
||||
expect(androidSecurityHarnessSource).toContain('verifyHostedWebViewNetworkIsolation')
|
||||
expect(androidSecurityHarnessSource).toContain('verifyHostedWebViewNavigationIsolation')
|
||||
expect(androidSecurityHarnessSource).toContain('verifyHostedWebViewExecutableIsolation')
|
||||
expect(androidSecurityHarnessSource).toContain('verifyHostedWebViewPrivacyIsolation')
|
||||
expect(androidSecurityHarnessSource).toContain('probe.observations.length > 0')
|
||||
})
|
||||
|
||||
it('crashes three Android renderers and requires native activation rollback', () => {
|
||||
expect(androidCrashLoopHarnessSource).toContain('const failureCount = 3')
|
||||
expect(androidCrashLoopHarnessSource).toContain('terminateHostedWebViewProcess(document)')
|
||||
expect(androidCrashLoopHarnessSource).toContain('initial.previous')
|
||||
expect(androidCrashLoopHarnessSource).toContain('waitForAndroidActivation(')
|
||||
expect(androidCrashLoopHarnessSource).toContain('>= 60_000')
|
||||
expect(androidCrashLoopHarnessSource).toContain('documents.at(-1)?.href === documents[0]?.href')
|
||||
})
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { waitForHostedWebViewConnectionSequence } from '../../scripts/hosted-webview-cdp-session.mjs'
|
||||
|
||||
describe('hosted WebView CDP target replacement', () => {
|
||||
it('reacquires the document while observing reconnect', async () => {
|
||||
const recovering = connectionEntry('recovering')
|
||||
const connected = connectionEntry('connected')
|
||||
const stale = new TargetSocket([
|
||||
JSON.stringify([recovering]),
|
||||
new Error('target replaced'),
|
||||
new Error('target replaced'),
|
||||
new Error('target replaced')
|
||||
])
|
||||
const replacement = new TargetSocket([JSON.stringify([connected])])
|
||||
const WebSocketCtor = class {
|
||||
constructor(endpoint: string) {
|
||||
return endpoint.endsWith('/stale') ? stale : replacement
|
||||
}
|
||||
}
|
||||
const reacquireDocument = vi.fn().mockResolvedValue({
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/replacement'
|
||||
})
|
||||
|
||||
await expect(
|
||||
waitForHostedWebViewConnectionSequence(
|
||||
{ webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/stale' },
|
||||
['recovering', 'connected'],
|
||||
5_000,
|
||||
{ WebSocketCtor, reacquireDocument }
|
||||
)
|
||||
).resolves.toEqual([recovering, connected])
|
||||
expect(reacquireDocument).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
function connectionEntry(state: string) {
|
||||
return {
|
||||
state,
|
||||
href: 'orca-mobile-web://session-a/h/host/agent-history/worktree',
|
||||
retainedExpectedText: true,
|
||||
retainedExpectedRoute: true
|
||||
}
|
||||
}
|
||||
|
||||
class TargetSocket {
|
||||
private readonly values: (string | Error)[]
|
||||
private errorListener: ((error: Error) => void) | undefined
|
||||
private messageListener: ((data: Buffer) => void) | undefined
|
||||
|
||||
constructor(values: (string | Error)[]) {
|
||||
this.values = [...values]
|
||||
}
|
||||
|
||||
once(event: string, listener: (value?: unknown) => void): void {
|
||||
if (event === 'open') {
|
||||
queueMicrotask(listener)
|
||||
} else if (event === 'error') {
|
||||
this.errorListener = listener as (error: Error) => void
|
||||
}
|
||||
}
|
||||
|
||||
on(event: string, listener: (data: Buffer) => void): void {
|
||||
if (event === 'message') {
|
||||
this.messageListener = listener
|
||||
}
|
||||
}
|
||||
|
||||
send(): void {
|
||||
const value = this.values.shift() ?? ''
|
||||
queueMicrotask(() => {
|
||||
if (value instanceof Error) {
|
||||
this.errorListener?.(value)
|
||||
return
|
||||
}
|
||||
this.messageListener?.(Buffer.from(JSON.stringify({ id: 1, result: { result: { value } } })))
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
export class FakeCdpSocket {
|
||||
evaluations: {
|
||||
id: number
|
||||
params: { expression: string; returnByValue: boolean }
|
||||
}[] = []
|
||||
private readonly values: string[]
|
||||
private messageListener: ((data: Buffer) => void) | undefined
|
||||
|
||||
constructor(values: string[]) {
|
||||
this.values = [...values]
|
||||
}
|
||||
|
||||
once(event: string, listener: (value?: unknown) => void): void {
|
||||
if (event === 'open') {
|
||||
queueMicrotask(listener)
|
||||
}
|
||||
}
|
||||
|
||||
on(event: string, listener: (data: Buffer) => void): void {
|
||||
if (event === 'message') {
|
||||
this.messageListener = listener
|
||||
}
|
||||
}
|
||||
|
||||
send(payload: string): void {
|
||||
this.evaluations.push(JSON.parse(payload))
|
||||
const value = this.values.shift() ?? ''
|
||||
queueMicrotask(() => {
|
||||
this.messageListener?.(Buffer.from(JSON.stringify({ id: 1, result: { result: { value } } })))
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
export class FakeProcessTerminationSocket {
|
||||
command: unknown
|
||||
private messageListener: ((data: Buffer) => void) | undefined
|
||||
|
||||
once(event: string, listener: (value?: unknown) => void): void {
|
||||
if (event === 'open') {
|
||||
queueMicrotask(listener)
|
||||
}
|
||||
}
|
||||
|
||||
on(event: string, listener: (data: Buffer) => void): void {
|
||||
if (event === 'message') {
|
||||
this.messageListener = listener
|
||||
}
|
||||
}
|
||||
|
||||
send(payload: string): void {
|
||||
this.command = JSON.parse(payload)
|
||||
queueMicrotask(() => {
|
||||
this.messageListener?.(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
method: 'Inspector.detached',
|
||||
params: { reason: 'Render process gone.' }
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
export function fakeCdpConstructor(socket: FakeCdpSocket | FakeProcessTerminationSocket) {
|
||||
return class {
|
||||
constructor() {
|
||||
return socket
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { dispatchHostedWebViewLongPress } from '../../scripts/hosted-webview-long-press.mjs'
|
||||
|
||||
describe('hosted WebView long press', () => {
|
||||
it('holds the unchanged RNW control after native touch grants shell authority', async () => {
|
||||
const evaluate = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce('hosted-long-press-123')
|
||||
.mockResolvedValueOnce('hosted-long-press-123')
|
||||
const wait = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await dispatchHostedWebViewLongPress(
|
||||
{ href: 'orca-mobile-web://build/session/workspace' },
|
||||
'Attach a photo',
|
||||
{ evaluate, now: () => 123, wait }
|
||||
)
|
||||
|
||||
expect(evaluate.mock.calls[0]?.[1]).toContain(
|
||||
"element.dispatchEvent(pointerEvent(element, 'mousedown', 1))"
|
||||
)
|
||||
expect(evaluate.mock.calls[0]?.[1]).toContain('document.getSelection()?.removeAllRanges()')
|
||||
expect(wait).toHaveBeenCalledWith(600)
|
||||
expect(evaluate.mock.calls[1]?.[1]).toContain("new MouseEvent('mouseup'")
|
||||
})
|
||||
|
||||
it('fails closed when the hosted control cannot be located', async () => {
|
||||
await expect(
|
||||
dispatchHostedWebViewLongPress({}, 'Attach a photo', {
|
||||
evaluate: vi.fn().mockResolvedValue(''),
|
||||
now: () => 123,
|
||||
wait: vi.fn()
|
||||
})
|
||||
).rejects.toThrow('Hosted WebView control was not found')
|
||||
})
|
||||
})
|
||||
@@ -1,100 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { verifyHostedWebViewPrivacyIsolation } from '../../scripts/hosted-webview-privacy-isolation.mjs'
|
||||
|
||||
describe('hosted WebView privacy isolation', () => {
|
||||
it('accepts an empty private-origin page state', async () => {
|
||||
const socket = new FakeCdpSocket({
|
||||
href: 'orca-mobile-web://session-a/h/paired-orca-desktop',
|
||||
htmlBytes: 4096,
|
||||
domMarkers: [],
|
||||
historyMarkers: [],
|
||||
localStorageEntries: 0,
|
||||
sessionStorageEntries: 0,
|
||||
cookieBytes: 0
|
||||
})
|
||||
|
||||
await expect(
|
||||
verifyHostedWebViewPrivacyIsolation({
|
||||
document: { webSocketDebuggerUrl: 'ws://localhost/page' },
|
||||
WebSocketCtor: fakeCdpConstructor(socket)
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
privateOrigin: true,
|
||||
credentialedUrl: false,
|
||||
domCredentialMarkers: 0
|
||||
})
|
||||
expect(socket.expression).toContain('document.documentElement?.outerHTML')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ domMarkers: ['devicetoken'] },
|
||||
{ historyMarkers: ['publickeyb64'] },
|
||||
{ localStorageEntries: 1 },
|
||||
{ sessionStorageEntries: 1 },
|
||||
{ cookieBytes: 1 },
|
||||
{ href: 'https://user:secret@orca-mobile-web.invalid/' },
|
||||
{ href: 'https://orca-mobile-web.invalid/?credential=secret' }
|
||||
])('rejects privileged page state: %j', async (overrides) => {
|
||||
const socket = new FakeCdpSocket({
|
||||
href: 'https://orca-mobile-web.invalid/#session-a',
|
||||
htmlBytes: 4096,
|
||||
domMarkers: [],
|
||||
historyMarkers: [],
|
||||
localStorageEntries: 0,
|
||||
sessionStorageEntries: 0,
|
||||
cookieBytes: 0,
|
||||
...overrides
|
||||
})
|
||||
|
||||
await expect(
|
||||
verifyHostedWebViewPrivacyIsolation({
|
||||
document: { webSocketDebuggerUrl: 'ws://localhost/page' },
|
||||
WebSocketCtor: fakeCdpConstructor(socket)
|
||||
})
|
||||
).rejects.toThrow('privacy isolation failed')
|
||||
})
|
||||
})
|
||||
|
||||
class FakeCdpSocket {
|
||||
expression = ''
|
||||
private messageListener: ((data: Buffer) => void) | undefined
|
||||
|
||||
constructor(private readonly value: unknown) {}
|
||||
|
||||
once(event: string, listener: () => void): void {
|
||||
if (event === 'open') {
|
||||
queueMicrotask(listener)
|
||||
}
|
||||
}
|
||||
|
||||
on(event: string, listener: (data: Buffer) => void): void {
|
||||
if (event === 'message') {
|
||||
this.messageListener = listener
|
||||
}
|
||||
}
|
||||
|
||||
send(payload: string): void {
|
||||
const request = JSON.parse(payload)
|
||||
this.expression = request.params.expression
|
||||
queueMicrotask(() => {
|
||||
this.messageListener?.(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
result: { result: { value: JSON.stringify(this.value) } }
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
function fakeCdpConstructor(socket: FakeCdpSocket) {
|
||||
return class {
|
||||
constructor() {
|
||||
return socket
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { evaluate } = vi.hoisted(() => ({ evaluate: vi.fn() }))
|
||||
|
||||
vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({
|
||||
evaluateHostedDocumentWithRetry: evaluate
|
||||
}))
|
||||
|
||||
import {
|
||||
installHostedWebViewRouteExceptionCapture,
|
||||
readHostedWebViewRouteExceptionEvidence
|
||||
} from '../../scripts/hosted-webview-route-exception-evidence.mjs'
|
||||
|
||||
describe('hosted WebView route exception evidence', () => {
|
||||
beforeEach(() => {
|
||||
evaluate.mockReset()
|
||||
})
|
||||
|
||||
it('installs a bounded error-only capture in the hosted document', async () => {
|
||||
evaluate.mockResolvedValue('installed')
|
||||
|
||||
const WebSocketCtor = class {}
|
||||
await installHostedWebViewRouteExceptionCapture({ targetId: 'hosted' }, WebSocketCtor)
|
||||
|
||||
expect(evaluate).toHaveBeenCalledWith(
|
||||
{ targetId: 'hosted' },
|
||||
expect.stringContaining("append('unhandled-rejection'"),
|
||||
WebSocketCtor
|
||||
)
|
||||
expect(evaluate.mock.calls[0][1]).toContain('entries.length > 24')
|
||||
expect(evaluate.mock.calls[0][1]).toContain('.slice(0, 4096)')
|
||||
})
|
||||
|
||||
it('returns only bounded exception evidence entries', async () => {
|
||||
evaluate.mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{ kind: 'console-error', text: 'TypeError: failed' },
|
||||
{ kind: 'other', text: 'ignored' },
|
||||
{ kind: 'window-error', text: 'x'.repeat(4_097) }
|
||||
])
|
||||
)
|
||||
|
||||
const WebSocketCtor = class {}
|
||||
await expect(
|
||||
readHostedWebViewRouteExceptionEvidence({ targetId: 'hosted' }, WebSocketCtor)
|
||||
).resolves.toEqual([{ kind: 'console-error', text: 'TypeError: failed' }])
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { navigateHostedWebViewRoute } from '../../scripts/hosted-webview-route-navigation.mjs'
|
||||
|
||||
describe('hosted WebView route navigation', () => {
|
||||
it('moves within the authenticated hosted SPA without a document navigation', async () => {
|
||||
const socket = cdpSocket('/h/host/review/workspace')
|
||||
|
||||
await expect(
|
||||
navigateHostedWebViewRoute(
|
||||
{ webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/1' },
|
||||
'/h/host/review/workspace?scope=all',
|
||||
socket.WebSocket
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(socket.expression()).toContain('history.pushState')
|
||||
expect(socket.expression()).toContain("dispatchEvent(new PopStateEvent('popstate'")
|
||||
})
|
||||
|
||||
it('rejects routes outside the hosted app graph', async () => {
|
||||
const socket = cdpSocket('')
|
||||
|
||||
await expect(
|
||||
navigateHostedWebViewRoute(
|
||||
{ webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/1' },
|
||||
'https://example.test/',
|
||||
socket.WebSocket
|
||||
)
|
||||
).rejects.toThrow('route is invalid')
|
||||
expect(socket.expression()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
function cdpSocket(value: string) {
|
||||
let expression = ''
|
||||
class FakeWebSocket {
|
||||
private message: ((data: Buffer) => void) | undefined
|
||||
|
||||
once(event: string, listener: (error?: Error) => void) {
|
||||
if (event === 'open') {
|
||||
queueMicrotask(listener)
|
||||
}
|
||||
}
|
||||
|
||||
on(event: string, listener: (data: Buffer) => void) {
|
||||
if (event === 'message') {
|
||||
this.message = listener
|
||||
}
|
||||
}
|
||||
|
||||
send(payload: string) {
|
||||
expression = JSON.parse(payload).params.expression
|
||||
queueMicrotask(() => {
|
||||
this.message?.(Buffer.from(JSON.stringify({ id: 1, result: { result: { value } } })))
|
||||
})
|
||||
}
|
||||
|
||||
close() {}
|
||||
}
|
||||
|
||||
return {
|
||||
WebSocket: FakeWebSocket as never,
|
||||
expression: vi.fn(() => expression)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveHostedWebViewRuntimeDirectory } from '../../scripts/hosted-webview-runtime-directory.mjs'
|
||||
|
||||
describe('hosted WebView runtime directory', () => {
|
||||
it('isolates each run while keeping the socket path short', () => {
|
||||
const first = resolveHostedWebViewRuntimeDirectory({
|
||||
worktree: '/repo/mobile-rearch',
|
||||
runNonce: 'run-one'
|
||||
})
|
||||
const second = resolveHostedWebViewRuntimeDirectory({
|
||||
worktree: '/repo/mobile-rearch',
|
||||
runNonce: 'run-two'
|
||||
})
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
expect(first).toMatch(/^\/tmp\/orca-mw-[a-f0-9]{8}-run-one$/)
|
||||
expect(
|
||||
path.join(first, 'emulator-control/userData/daemon/daemon-v27.sock').length
|
||||
).toBeLessThan(104)
|
||||
})
|
||||
|
||||
it('honors an explicit evidence directory', () => {
|
||||
expect(
|
||||
resolveHostedWebViewRuntimeDirectory({
|
||||
worktree: '/repo/mobile-rearch',
|
||||
override: './evidence',
|
||||
runNonce: 'ignored'
|
||||
})
|
||||
).toBe(path.resolve('./evidence'))
|
||||
})
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseHostedWebViewSimulatorE2eOptions } from '../../scripts/hosted-webview-simulator-e2e-options.mjs'
|
||||
|
||||
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,
|
||||
filesPreviewOnly: false,
|
||||
isolationOnly: false,
|
||||
nativeSettingsOnly: false,
|
||||
photosRevocationOnly: false,
|
||||
reuseNativeInstall: false,
|
||||
securityOnly: false,
|
||||
skipNativeBuild: false,
|
||||
sourceControlOnly: false,
|
||||
timeoutMs: 180_000
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses an installed app without selecting the cached reinstall path', () => {
|
||||
expect(parseHostedWebViewSimulatorE2eOptions(['--reuse-native-install'])).toMatchObject({
|
||||
reuseNativeInstall: true,
|
||||
skipNativeBuild: false
|
||||
})
|
||||
expect(() =>
|
||||
parseHostedWebViewSimulatorE2eOptions(['--reuse-native-install', '--skip-native-build'])
|
||||
).toThrow('mutually exclusive')
|
||||
})
|
||||
|
||||
it('accepts only canonical expected build identities', () => {
|
||||
const buildId = 'a'.repeat(64)
|
||||
expect(parseHostedWebViewSimulatorE2eOptions(['--expected-build', buildId])).toMatchObject({
|
||||
expectedBuild: buildId
|
||||
})
|
||||
expect(() =>
|
||||
parseHostedWebViewSimulatorE2eOptions(['--expected-build', 'A'.repeat(64)])
|
||||
).toThrow('lowercase SHA-256')
|
||||
})
|
||||
|
||||
it('parses the focused cached-app journey', () => {
|
||||
expect(
|
||||
parseHostedWebViewSimulatorE2eOptions([
|
||||
'--',
|
||||
'--device',
|
||||
'simulator-id',
|
||||
'--timeout-ms',
|
||||
'60000',
|
||||
'--native-settings-only',
|
||||
'--skip-native-build'
|
||||
])
|
||||
).toMatchObject({
|
||||
device: 'simulator-id',
|
||||
nativeSettingsOnly: true,
|
||||
skipNativeBuild: true,
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
})
|
||||
|
||||
it('maps the clipboard-image journey onto focused security setup', () => {
|
||||
expect(parseHostedWebViewSimulatorE2eOptions(['--clipboard-image-only'])).toMatchObject({
|
||||
clipboardImageOnly: true,
|
||||
securityOnly: true
|
||||
})
|
||||
})
|
||||
|
||||
it('maps the Photos revocation journey onto focused security setup', () => {
|
||||
expect(parseHostedWebViewSimulatorE2eOptions(['--photos-revocation-only'])).toMatchObject({
|
||||
photosRevocationOnly: true,
|
||||
securityOnly: true
|
||||
})
|
||||
})
|
||||
|
||||
it('maps WebView isolation onto focused security setup', () => {
|
||||
expect(parseHostedWebViewSimulatorE2eOptions(['--isolation-only'])).toMatchObject({
|
||||
isolationOnly: true,
|
||||
securityOnly: true
|
||||
})
|
||||
})
|
||||
|
||||
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'])
|
||||
).toThrow('mutually exclusive')
|
||||
expect(() =>
|
||||
parseHostedWebViewSimulatorE2eOptions(['--clipboard-image-only', '--security-only'])
|
||||
).toThrow('mutually exclusive')
|
||||
expect(() =>
|
||||
parseHostedWebViewSimulatorE2eOptions(['--clipboard-image-only', '--photos-revocation-only'])
|
||||
).toThrow('mutually exclusive')
|
||||
expect(() =>
|
||||
parseHostedWebViewSimulatorE2eOptions(['--isolation-only', '--security-only'])
|
||||
).toThrow('mutually exclusive')
|
||||
})
|
||||
|
||||
it('keeps the real chat/session journey exclusive from OTA and adversarial runs', () => {
|
||||
expect(
|
||||
parseHostedWebViewSimulatorE2eOptions(['--chat-only', '--reuse-native-install'])
|
||||
).toMatchObject({ chatOnly: true, reuseNativeInstall: true })
|
||||
expect(() => parseHostedWebViewSimulatorE2eOptions(['--chat-only', '--ota-only'])).toThrow(
|
||||
'mutually exclusive'
|
||||
)
|
||||
expect(() =>
|
||||
parseHostedWebViewSimulatorE2eOptions(['--chat-only', '--adversarial-content'])
|
||||
).toThrow('mutually exclusive')
|
||||
})
|
||||
|
||||
it('rejects invalid timeouts and unknown arguments', () => {
|
||||
expect(() => parseHostedWebViewSimulatorE2eOptions(['--timeout-ms', '9999'])).toThrow(
|
||||
'at least 10000'
|
||||
)
|
||||
expect(() => parseHostedWebViewSimulatorE2eOptions(['--unknown'])).toThrow('Unknown argument')
|
||||
})
|
||||
})
|
||||
@@ -1,162 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { activateHostedWorkspaceRow } from '../../scripts/hosted-webview-workspace-activation.mjs'
|
||||
|
||||
describe('hosted WebView workspace activation', () => {
|
||||
it('prefers the existing accessible workspace-row label', async () => {
|
||||
const activate = vi.fn().mockResolvedValue(undefined)
|
||||
const document = { href: 'orca-mobile-web://build/' }
|
||||
|
||||
await activateHostedWorkspaceRow(document, 'mobile-rearch', activate)
|
||||
|
||||
expect(activate).toHaveBeenCalledOnce()
|
||||
expect(activate).toHaveBeenCalledWith(document, {
|
||||
kind: 'label',
|
||||
value: 'Open mobile-rearch',
|
||||
reveal: true
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
.mockRejectedValueOnce(missing)
|
||||
.mockRejectedValueOnce(missing)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
await activateHostedWorkspaceRow({}, 'mobile-rearch', activate)
|
||||
|
||||
expect(activate).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{},
|
||||
{
|
||||
kind: 'text',
|
||||
value: 'mobile-rearch',
|
||||
ignoreCase: true,
|
||||
occurrence: 1,
|
||||
reveal: true
|
||||
}
|
||||
)
|
||||
expect(activate).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
{},
|
||||
{
|
||||
kind: 'text',
|
||||
value: 'mobile-rearch',
|
||||
ignoreCase: true,
|
||||
reveal: true
|
||||
}
|
||||
)
|
||||
expect(activate).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
{},
|
||||
{
|
||||
kind: 'text',
|
||||
value: 'mobile-rearch',
|
||||
ignoreCase: true,
|
||||
occurrence: 1,
|
||||
reveal: true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('reacquires a replaced WebKit document before retrying', async () => {
|
||||
const stale = new Error('WebKit CDP connection closed')
|
||||
const activate = vi.fn().mockRejectedValueOnce(stale).mockResolvedValueOnce(undefined)
|
||||
const replacement = { href: 'orca-mobile-web://replacement/' }
|
||||
const resolveDocument = vi.fn().mockResolvedValue(replacement)
|
||||
|
||||
await activateHostedWorkspaceRow(
|
||||
{ href: 'orca-mobile-web://stale/' },
|
||||
'mobile-rearch',
|
||||
activate,
|
||||
1_000,
|
||||
resolveDocument
|
||||
)
|
||||
|
||||
expect(resolveDocument).toHaveBeenCalledOnce()
|
||||
expect(activate).toHaveBeenLastCalledWith(replacement, {
|
||||
kind: 'label',
|
||||
value: 'Open mobile-rearch',
|
||||
reveal: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reacquires an Android target replaced during its inspector handshake', async () => {
|
||||
const stale = new Error('Unexpected server response: 500')
|
||||
const activate = vi.fn().mockRejectedValueOnce(stale).mockResolvedValueOnce(undefined)
|
||||
const replacement = { href: 'https://orca-mobile-web.invalid/' }
|
||||
const resolveDocument = vi.fn().mockResolvedValue(replacement)
|
||||
|
||||
await activateHostedWorkspaceRow(
|
||||
{ href: 'https://orca-mobile-web.invalid/' },
|
||||
'mobile-rearch',
|
||||
activate,
|
||||
1_000,
|
||||
resolveDocument
|
||||
)
|
||||
|
||||
expect(resolveDocument).toHaveBeenCalledOnce()
|
||||
expect(activate).toHaveBeenLastCalledWith(replacement, {
|
||||
kind: 'label',
|
||||
value: 'Open mobile-rearch',
|
||||
reveal: true
|
||||
})
|
||||
})
|
||||
|
||||
it('retries after a grouped row is not visible immediately', 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)
|
||||
.mockRejectedValueOnce(missing)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
await activateHostedWorkspaceRow({}, 'mobile-rearch', activate, 1_000)
|
||||
|
||||
expect(activate).toHaveBeenCalledTimes(6)
|
||||
expect(activate).toHaveBeenLastCalledWith({}, expect.objectContaining({ reveal: true }))
|
||||
})
|
||||
|
||||
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
|
||||
.fn()
|
||||
.mockRejectedValueOnce(missing)
|
||||
.mockRejectedValueOnce(missing)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(stale)
|
||||
|
||||
await activateHostedWorkspaceRow({}, 'mobile-rearch', activate)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,248 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z, ZodType } from 'zod'
|
||||
import {
|
||||
MOBILE_WEB_BRIDGE_OPERATIONS,
|
||||
MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
parseMobileWebBridgeShellMessage,
|
||||
type MobileWebBridgePageMessage,
|
||||
type MobileWebBridgeShellMessage
|
||||
} from '../../../src/shared/mobile-web/bridge-contract'
|
||||
import { MobileWebBridgeSubscriptionClient } from '../../../src/mobile-web/src/mobile-web-bridge-subscription-client'
|
||||
import type { MobileWebBridgeSubscriptionSetup } from '../../../src/mobile-web/src/mobile-web-bridge-subscription-setup'
|
||||
import { MobileWebOneShotRequestClient } from '../../../src/mobile-web/src/mobile-web-one-shot-request-client'
|
||||
import { MobileWebHostResultSchema } from '../../../src/shared/mobile-web/host-rpc-contract'
|
||||
import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants'
|
||||
|
||||
const CONTEXT = {
|
||||
shellSessionId: 'S'.repeat(43),
|
||||
buildId: 'a'.repeat(64)
|
||||
}
|
||||
const REQUEST_ID = 'R'.repeat(22)
|
||||
const SUBSCRIPTION_ID = 'U'.repeat(22)
|
||||
const RESPONSE_PAYLOAD_CORPUS: unknown[] = [
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
1.5,
|
||||
'',
|
||||
'payload',
|
||||
[],
|
||||
{},
|
||||
{ unexpected: true }
|
||||
]
|
||||
const contractModules = import.meta.glob(
|
||||
[
|
||||
'../../../src/shared/mobile-web/*-contract.ts',
|
||||
'!../../../src/shared/mobile-web/bridge-operation-contract.ts'
|
||||
],
|
||||
{ eager: true }
|
||||
)
|
||||
|
||||
type NamedSchema = { name: string; schema: ZodType }
|
||||
|
||||
describe('mobile web shell response schema corpus', () => {
|
||||
const resultSchemas = namedSchemas('ResultSchema')
|
||||
const eventSchemas = namedSchemas('EventSchema')
|
||||
|
||||
it('keeps the result and event schema corpus aligned with production grant kinds', () => {
|
||||
const registeredOperations = Object.values(MOBILE_WEB_BRIDGE_OPERATIONS).flatMap((operations) =>
|
||||
Object.keys(operations)
|
||||
)
|
||||
const oneShotGrants = MOBILE_WEB_PRODUCTION_GRANTS.filter(
|
||||
(grant) => grant.operation !== 'subscribe'
|
||||
)
|
||||
const subscriptionGrants = MOBILE_WEB_PRODUCTION_GRANTS.filter(
|
||||
(grant) => grant.operation === 'subscribe'
|
||||
)
|
||||
|
||||
expect(resultSchemas.length).toBeGreaterThanOrEqual(150)
|
||||
expect(oneShotGrants.length).toBeGreaterThan(100)
|
||||
expect(eventSchemas.length).toBeGreaterThanOrEqual(subscriptionGrants.length)
|
||||
expect(MOBILE_WEB_PRODUCTION_GRANTS).toHaveLength(registeredOperations.length)
|
||||
expect(new Set(resultSchemas.map(({ name }) => name))).toHaveLength(resultSchemas.length)
|
||||
expect(new Set(eventSchemas.map(({ name }) => name))).toHaveLength(eventSchemas.length)
|
||||
})
|
||||
|
||||
it('rejects invalid success payloads through every one-shot result schema', async () => {
|
||||
let caseCount = 0
|
||||
for (const { name, schema } of resultSchemas) {
|
||||
// Domain payloads on the generic lane are interpreted by the matching hosted page.
|
||||
if (schema === MobileWebHostResultSchema) {
|
||||
continue
|
||||
}
|
||||
const rejected = RESPONSE_PAYLOAD_CORPUS.filter((payload) => pageRejects(schema, payload))
|
||||
expect(rejected.length, name).toBeGreaterThanOrEqual(8)
|
||||
for (const payload of rejected) {
|
||||
await expectOneShotRejection(schema, payload, name)
|
||||
caseCount += 1
|
||||
}
|
||||
}
|
||||
expect(caseCount).toBeGreaterThan(1_200)
|
||||
})
|
||||
|
||||
it('keeps generic host domain results opaque to the shell contract', () => {
|
||||
expect(resultSchemas.filter(({ schema }) => schema === MobileWebHostResultSchema)).toHaveLength(
|
||||
1
|
||||
)
|
||||
for (const payload of RESPONSE_PAYLOAD_CORPUS) {
|
||||
expect(MobileWebHostResultSchema.parse(payload)).toEqual(payload)
|
||||
}
|
||||
})
|
||||
|
||||
it('retires every subscription after an invalid event payload', async () => {
|
||||
let caseCount = 0
|
||||
for (const { name, schema } of eventSchemas) {
|
||||
const rejected = RESPONSE_PAYLOAD_CORPUS.filter((payload) => pageRejects(schema, payload))
|
||||
expect(rejected.length, name).toBeGreaterThanOrEqual(8)
|
||||
for (const payload of rejected) {
|
||||
await expectSubscriptionRejection(schema, payload, name)
|
||||
caseCount += 1
|
||||
}
|
||||
}
|
||||
expect(caseCount).toBeGreaterThanOrEqual(64)
|
||||
})
|
||||
})
|
||||
|
||||
function pageRejects(schema: ZodType, payload: unknown): boolean {
|
||||
return !schema.safeParse(payload).success
|
||||
}
|
||||
|
||||
function namedSchemas(suffix: 'ResultSchema' | 'EventSchema'): NamedSchema[] {
|
||||
const schemas: NamedSchema[] = []
|
||||
for (const [path, module] of Object.entries(contractModules)) {
|
||||
for (const [name, schema] of Object.entries(module as Record<string, unknown>)) {
|
||||
if (name.endsWith(suffix) && schema instanceof ZodType) {
|
||||
schemas.push({ name: `${path}:${name}`, schema })
|
||||
}
|
||||
}
|
||||
}
|
||||
return schemas.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
async function expectOneShotRejection(
|
||||
resultSchema: ZodType,
|
||||
payload: unknown,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
const client = new MobileWebOneShotRequestClient({
|
||||
getGrant: () => grant('snapshot'),
|
||||
postMessage: () => true,
|
||||
envelope: () => ({ version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, ...CONTEXT }),
|
||||
createRequestId: () => REQUEST_ID,
|
||||
otherPendingCount: () => 0
|
||||
})
|
||||
const result = client.request('workspace', 'snapshot', {}, z.object({}).strict(), resultSchema)
|
||||
const rejection = expect(result, `${label}: ${JSON.stringify(payload)}`).rejects.toMatchObject({
|
||||
code: 'invalid_message',
|
||||
retryable: false
|
||||
})
|
||||
|
||||
expect(client.receive(parsedShellMessage(successResponse(payload)))).toBe(true)
|
||||
await rejection
|
||||
expect(client.pendingCount()).toBe(0)
|
||||
}
|
||||
|
||||
async function expectSubscriptionRejection(
|
||||
eventSchema: ZodType,
|
||||
payload: unknown,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
const messages: MobileWebBridgePageMessage[] = []
|
||||
const onEvent = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const ids = [REQUEST_ID, SUBSCRIPTION_ID]
|
||||
const client = new MobileWebBridgeSubscriptionClient({
|
||||
getGrant: () => grant('subscribe'),
|
||||
postMessage: (message) => {
|
||||
messages.push(message)
|
||||
return true
|
||||
},
|
||||
envelope: () => ({ version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, ...CONTEXT }),
|
||||
createMessageId: () => ids.shift() ?? 'Z'.repeat(22),
|
||||
otherPendingCount: () => 0
|
||||
})
|
||||
const subscription = exposeSubscribeWith(client)({
|
||||
capability: 'workspace',
|
||||
payload: {},
|
||||
payloadSchema: z.object({}).strict(),
|
||||
eventSchema,
|
||||
onEvent,
|
||||
onError
|
||||
})
|
||||
client.receive(parsedShellMessage(successResponse(null)))
|
||||
await subscription.ready
|
||||
|
||||
expect(client.receive(parsedShellMessage(eventMessage(payload)))).toBe(true)
|
||||
expect(onEvent, label).not.toHaveBeenCalled()
|
||||
expect(onError, label).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: 'invalid_message', retryable: false })
|
||||
)
|
||||
expect(messages.at(-1)).toMatchObject({
|
||||
type: 'cancel',
|
||||
target: 'subscription',
|
||||
id: SUBSCRIPTION_ID
|
||||
})
|
||||
}
|
||||
|
||||
function exposeSubscribeWith(client: MobileWebBridgeSubscriptionClient): (
|
||||
setup: MobileWebBridgeSubscriptionSetup
|
||||
) => {
|
||||
ready: Promise<void>
|
||||
unsubscribe: () => void
|
||||
subscriptionId: string
|
||||
} {
|
||||
return (
|
||||
client as unknown as {
|
||||
subscribeWith: ReturnType<typeof exposeSubscribeWith>
|
||||
}
|
||||
).subscribeWith.bind(client)
|
||||
}
|
||||
|
||||
function grant(operation: 'snapshot' | 'subscribe') {
|
||||
return {
|
||||
capability: 'workspace',
|
||||
operation,
|
||||
limits: {
|
||||
maxRequestBytes: 1024,
|
||||
maxResponseBytes: 1024,
|
||||
maxConcurrent: 2,
|
||||
rateCapacity: 4,
|
||||
rateRefillPerSecond: 1
|
||||
}
|
||||
} as const
|
||||
}
|
||||
|
||||
function successResponse(
|
||||
payload: unknown
|
||||
): Extract<MobileWebBridgeShellMessage, { type: 'response' }> {
|
||||
return {
|
||||
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
...CONTEXT,
|
||||
type: 'response',
|
||||
requestId: REQUEST_ID,
|
||||
status: 'success',
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
function eventMessage(payload: unknown): Extract<MobileWebBridgeShellMessage, { type: 'event' }> {
|
||||
return {
|
||||
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
...CONTEXT,
|
||||
type: 'event',
|
||||
subscriptionId: SUBSCRIPTION_ID,
|
||||
sequence: 0,
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
function parsedShellMessage(message: MobileWebBridgeShellMessage): MobileWebBridgeShellMessage {
|
||||
const parsed = parseMobileWebBridgeShellMessage(JSON.stringify(message), CONTEXT)
|
||||
if (!parsed.ok) {
|
||||
throw new Error(`Corpus produced ${parsed.error}`)
|
||||
}
|
||||
return parsed.value
|
||||
}
|
||||
@@ -43,8 +43,6 @@ describe('mobile web session screen binding', () => {
|
||||
expect(hostedSessionRoute).toContain('lastConnectedAt={shell.lastConnectedAt}')
|
||||
expect(hostedSessionRoute).toContain('<SessionScreen')
|
||||
expect(nativeSessionRoute).toContain('export function SessionScreen(')
|
||||
// The native-only API bans moved to hosted-route-native-api-census.test.ts, which walks
|
||||
// every module the hosted session route reaches rather than this fixed file list.
|
||||
expect(nativeSessionRoute).toContain('sessionDeviceOperations?.openTerminalSettings()')
|
||||
expect(nativeSessionRoute).toContain(
|
||||
'sessionDeviceOperations?.loadTerminalAccessoryPreferences()'
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MOBILE_WEB_BRIDGE_OPERATIONS } from './bridge-operation-registry'
|
||||
|
||||
const PAGE_DIR = resolve(__dirname, '..', '..', 'mobile-web', 'src')
|
||||
|
||||
/** Result fields each page request client compares against its own request before resolving. A
|
||||
* shell that answers one workspace's request with another workspace's payload is caught here, so
|
||||
* losing a field silently re-opens that swap. */
|
||||
const EXPECTED_ECHO_FIELDS: Record<string, readonly string[]> = {
|
||||
'account.consumeResetCredit': ['scope'],
|
||||
'file.markdownDraftRead': ['relativePath', 'tabId', 'workspaceId'],
|
||||
'file.markdownRead': ['relativePath', 'tabId', 'workspaceId'],
|
||||
'file.markdownSave': ['relativePath', 'tabId', 'workspaceId'],
|
||||
'file.readTerminalArtifactChunk': ['offset', 'tabId', 'token', 'workspaceId'],
|
||||
'file.resolveTerminalPath': ['workspaceId'],
|
||||
'file.write': ['byteLength', 'relativePath', 'revision', 'workspaceId'],
|
||||
'provider.manageReview': ['action', 'provider', 'reviewNumber', 'workspaceId'],
|
||||
'provider.mutateReview': ['provider', 'reviewNumber', 'workspaceId'],
|
||||
'provider.review': ['branch', 'observedHead', 'workspaceId'],
|
||||
'provider.reviewCreate': ['provider', 'workspaceId'],
|
||||
'provider.reviewCreationEligibility': ['branch', 'observedHead', 'workspaceId'],
|
||||
'provider.reviewDiff': [
|
||||
'branch',
|
||||
'observedHead',
|
||||
'path',
|
||||
'provider',
|
||||
'reviewHead',
|
||||
'reviewNumber',
|
||||
'workspaceId'
|
||||
],
|
||||
'provider.reviewGenerateFields': ['workspaceId'],
|
||||
'provider.reviewQuery': ['provider', 'query', 'reviewNumber', 'workspaceId'],
|
||||
'provider.submitReview': [
|
||||
'action',
|
||||
'expectedReviewHead',
|
||||
'provider',
|
||||
'reviewNumber',
|
||||
'submissionId',
|
||||
'submittedCommentIds',
|
||||
'workspaceId'
|
||||
],
|
||||
'sourceControl.abort': ['operation', 'previousBranch', 'previousHead', 'workspaceId'],
|
||||
'sourceControl.branch': ['branch', 'operation', 'previousBranch', 'previousHead', 'workspaceId'],
|
||||
'sourceControl.branchCompare': ['baseRef', 'offset', 'revision', 'workspaceId'],
|
||||
'sourceControl.branches': ['workspaceId'],
|
||||
'sourceControl.cancelCommitMessageGeneration': ['workspaceId'],
|
||||
'sourceControl.commit': ['previousHead', 'workspaceId'],
|
||||
'sourceControl.commitCompare': ['commitId', 'workspaceId'],
|
||||
'sourceControl.discard': ['operation', 'relativePaths.length', 'workspaceId'],
|
||||
'sourceControl.fetch': ['operation', 'previousBranch', 'previousHead', 'workspaceId'],
|
||||
'sourceControl.generateCommitMessage': ['previousHead', 'workspaceId'],
|
||||
'sourceControl.history': ['limit', 'workspaceId'],
|
||||
'sourceControl.pull': ['operation', 'previousBranch', 'previousHead', 'workspaceId'],
|
||||
'sourceControl.push': ['operation', 'previousBranch', 'previousHead', 'workspaceId'],
|
||||
'sourceControl.rebase': ['operation', 'previousBranch', 'previousHead', 'workspaceId'],
|
||||
'sourceControl.reviewDiff': ['relativePath', 'scope', 'workspaceId'],
|
||||
'sourceControl.reviewLink': ['workspaceId'],
|
||||
'sourceControl.reviewLinkUpdate': ['workspaceId'],
|
||||
'sourceControl.reviewMetadata': ['workspaceId'],
|
||||
'sourceControl.reviewMetadataUpdate': ['workspaceId'],
|
||||
'sourceControl.stage': ['operation', 'relativePaths.length', 'workspaceId'],
|
||||
'sourceControl.unstage': ['operation', 'relativePaths.length', 'workspaceId'],
|
||||
'sourceControl.upstream': ['workspaceId'],
|
||||
'task.loadLinearDetail': ['issue.targetId'],
|
||||
'task.loadLinearIssue': ['issue.targetId'],
|
||||
'task.projectTable': ['project', 'selectedView.id'],
|
||||
'task.resolveProjectRef': ['host'],
|
||||
'workspace.activate': ['workspaceId'],
|
||||
'workspace.creationSaveSparsePreset': ['directories', 'id', 'name', 'repoId'],
|
||||
'workspace.creationSparsePresets': ['repoId'],
|
||||
'workspace.creationSshConnect': ['targetId'],
|
||||
'workspace.creationSshState': ['targetId'],
|
||||
'workspace.remove': ['workspaceId'],
|
||||
'workspace.update': ['workspaceId']
|
||||
}
|
||||
|
||||
/** Echo helpers shared across request clients, and the result fields each one compares. Resolved
|
||||
* by name because the derivation below only walks helpers defined in the same file. */
|
||||
const SHARED_ECHO_HELPERS: Record<string, readonly string[]> = {
|
||||
requireEchoedWorkspaceId: ['workspaceId']
|
||||
}
|
||||
|
||||
function balancedBody(text: string, from: number): string | null {
|
||||
let depth = 0
|
||||
let index = from
|
||||
let opened = false
|
||||
for (; index < text.length; index++) {
|
||||
const character = text[index]
|
||||
if (character === '(') {
|
||||
depth++
|
||||
opened = true
|
||||
} else if (character === ')') {
|
||||
depth--
|
||||
if (opened && depth === 0) {
|
||||
index++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const start = text.indexOf('{', index)
|
||||
if (start === -1) {
|
||||
return null
|
||||
}
|
||||
let braces = 0
|
||||
for (let cursor = start; cursor < text.length; cursor++) {
|
||||
if (text[cursor] === '{') {
|
||||
braces++
|
||||
} else if (text[cursor] === '}') {
|
||||
braces--
|
||||
if (braces === 0) {
|
||||
return text.slice(start, cursor + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type Method = { name: string; body: string }
|
||||
|
||||
function readClient(name: string): { text: string; methods: Method[]; helpers: Method[] } {
|
||||
const text = readFileSync(join(PAGE_DIR, name), 'utf8')
|
||||
const lines = text.split('\n')
|
||||
const offsets: number[] = []
|
||||
let offset = 0
|
||||
for (const line of lines) {
|
||||
offsets.push(offset)
|
||||
offset += line.length + 1
|
||||
}
|
||||
const methods: Method[] = []
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const match =
|
||||
/^ {2}(?:(?:private|protected|public|async)\s+)*([A-Za-z][A-Za-z0-9]*)\s*[(<]/.exec(
|
||||
lines[index]!
|
||||
)
|
||||
if (!match || /^\s*(?:if|for|while|switch|catch|return)\b/.test(lines[index]!)) {
|
||||
continue
|
||||
}
|
||||
const body = balancedBody(text, offsets[index]!)
|
||||
if (!body) {
|
||||
continue
|
||||
}
|
||||
methods.push({ name: match[1]!, body })
|
||||
index += body.split('\n').length - 1
|
||||
}
|
||||
const helpers: Method[] = []
|
||||
for (const match of text.matchAll(/\nfunction ([A-Za-z0-9_]+)\s*[<(]/g)) {
|
||||
const body = balancedBody(text, match.index + match[0].length - 1)
|
||||
if (body) {
|
||||
helpers.push({ name: match[1]!, body })
|
||||
}
|
||||
}
|
||||
return { text, methods, helpers }
|
||||
}
|
||||
|
||||
/** Echo comparisons today, plus any `echo: [...]` declaration a factory-built method carries, so a
|
||||
* declarative rewrite that drops a field still shrinks this set. */
|
||||
function echoFields(body: string, helpers: Method[], seen: Set<string>): Set<string> {
|
||||
const fields = new Set<string>()
|
||||
for (const match of body.matchAll(
|
||||
/\b(?:result|preset|value)\.((?:[A-Za-z0-9_]+\.)*[A-Za-z0-9_]+)\s*!==/g
|
||||
)) {
|
||||
fields.add(match[1]!)
|
||||
}
|
||||
for (const match of body.matchAll(
|
||||
/!same[A-Za-z0-9_]*\(\s*(?:result|preset|value)\.([A-Za-z0-9_]+)/g
|
||||
)) {
|
||||
fields.add(match[1]!)
|
||||
}
|
||||
for (const match of body.matchAll(/\becho:\s*\[([^\]]*)\]/g)) {
|
||||
for (const field of match[1]!.matchAll(/'([A-Za-z0-9_.]+)'/g)) {
|
||||
fields.add(field[1]!)
|
||||
}
|
||||
}
|
||||
for (const [helper, declared] of Object.entries(SHARED_ECHO_HELPERS)) {
|
||||
if (new RegExp(`\\b${helper}\\(`).test(body)) {
|
||||
for (const field of declared) {
|
||||
fields.add(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const match of body.matchAll(/\b((?:matching|same)[A-Za-z0-9_]*)\s*\(/g)) {
|
||||
const helper = helpers.find((candidate) => candidate.name === match[1])
|
||||
if (!helper || seen.has(helper.name)) {
|
||||
continue
|
||||
}
|
||||
seen.add(helper.name)
|
||||
for (const field of echoFields(helper.body, helpers, seen)) {
|
||||
fields.add(field)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
/** Operations a method names directly in a `request` call. */
|
||||
function directOperations(body: string): string[] {
|
||||
return [...body.matchAll(/\.request\(\s*'([A-Za-z]+)',\s*'([A-Za-z0-9]+)'/g)].map(
|
||||
(match) => `${match[1]}.${match[2]}`
|
||||
)
|
||||
}
|
||||
|
||||
/** Operations a method hands to a private helper that forwards a variable operation on. */
|
||||
function forwardedOperations(body: string, forwarders: Map<string, string>): string[] {
|
||||
const operations: string[] = []
|
||||
for (const [helper, capability] of forwarders) {
|
||||
for (const match of body.matchAll(
|
||||
new RegExp(`\\bthis\\.${helper}(?:<[^(]*>)?\\(\\s*'([A-Za-z0-9]+)'`, 'g')
|
||||
)) {
|
||||
operations.push(`${capability}.${match[1]}`)
|
||||
}
|
||||
}
|
||||
return operations
|
||||
}
|
||||
|
||||
function derivedEchoCensus(): Record<string, string[]> {
|
||||
const census: Record<string, Set<string>> = {}
|
||||
const files = readdirSync(PAGE_DIR).filter(
|
||||
(name) => name.endsWith('-request-client.ts') && !name.includes('.test.')
|
||||
)
|
||||
for (const name of files) {
|
||||
const { methods, helpers } = readClient(name)
|
||||
const forwarders = new Map<string, string>()
|
||||
for (const method of methods) {
|
||||
const match = /\.request\(\s*'([A-Za-z]+)',\s*(?!')[A-Za-z]/.exec(method.body)
|
||||
if (match) {
|
||||
forwarders.set(method.name, match[1]!)
|
||||
}
|
||||
}
|
||||
const forwarderFields = new Map<string, Set<string>>()
|
||||
for (const method of methods) {
|
||||
const fields = echoFields(method.body, helpers, new Set())
|
||||
if (forwarders.has(method.name)) {
|
||||
forwarderFields.set(method.name, fields)
|
||||
continue
|
||||
}
|
||||
if (fields.size === 0) {
|
||||
continue
|
||||
}
|
||||
for (const operation of [
|
||||
...directOperations(method.body),
|
||||
...forwardedOperations(method.body, forwarders)
|
||||
]) {
|
||||
census[operation] = new Set([...(census[operation] ?? []), ...fields])
|
||||
}
|
||||
}
|
||||
for (const [helper, fields] of forwarderFields) {
|
||||
if (fields.size === 0) {
|
||||
continue
|
||||
}
|
||||
for (const method of methods) {
|
||||
if (method.name === helper) {
|
||||
continue
|
||||
}
|
||||
for (const operation of forwardedOperations(
|
||||
method.body,
|
||||
new Map([[helper, forwarders.get(helper)!]])
|
||||
)) {
|
||||
census[operation] = new Set([...(census[operation] ?? []), ...fields])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(census).map(([operation, fields]) => [operation, [...fields].sort()])
|
||||
)
|
||||
}
|
||||
|
||||
describe('mobile web bridge operation echo census', () => {
|
||||
const derived = derivedEchoCensus()
|
||||
|
||||
it('checks the recorded echo fields on every operation that guards its own identity', () => {
|
||||
expect(derived).toEqual(
|
||||
Object.fromEntries(
|
||||
Object.entries(EXPECTED_ECHO_FIELDS).map(([operation, fields]) => [operation, [...fields]])
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps every shared echo helper checking the fields it is credited with', () => {
|
||||
const text = readFileSync(join(PAGE_DIR, 'mobile-web-result-echo.ts'), 'utf8')
|
||||
|
||||
for (const [helper, declared] of Object.entries(SHARED_ECHO_HELPERS)) {
|
||||
expect(text).toContain(`export function ${helper}`)
|
||||
for (const field of declared) {
|
||||
expect(text).toContain(`result.${field} !==`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('records echo fields only for registered operations', () => {
|
||||
const registered = new Set(
|
||||
Object.entries(MOBILE_WEB_BRIDGE_OPERATIONS).flatMap(([capability, operations]) =>
|
||||
Object.keys(operations).map((operation) => `${capability}.${operation}`)
|
||||
)
|
||||
)
|
||||
|
||||
expect(Object.keys(EXPECTED_ECHO_FIELDS).filter((key) => !registered.has(key))).toEqual([])
|
||||
expect(Object.keys(EXPECTED_ECHO_FIELDS).length).toBeGreaterThanOrEqual(49)
|
||||
})
|
||||
|
||||
it('guards the page workspace handle on every workspace-scoped echo it records', () => {
|
||||
const workspaceScoped = Object.entries(EXPECTED_ECHO_FIELDS).filter(([, fields]) =>
|
||||
fields.some((field) => field === 'workspaceId')
|
||||
)
|
||||
|
||||
expect(workspaceScoped.length).toBeGreaterThanOrEqual(40)
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MOBILE_WEB_BRIDGE_OPERATIONS } from './bridge-operation-registry'
|
||||
|
||||
const REPO_ROOT = resolve(__dirname, '..', '..', '..')
|
||||
const PAGE_DIR = join(REPO_ROOT, 'src', 'mobile-web', 'src')
|
||||
const SHELL_DIR = join(REPO_ROOT, 'mobile', 'src', 'mobile-web')
|
||||
|
||||
const registered = new Set(
|
||||
Object.entries(MOBILE_WEB_BRIDGE_OPERATIONS).flatMap(([capability, operations]) =>
|
||||
Object.keys(operations).map((operation) => `${capability}.${operation}`)
|
||||
)
|
||||
)
|
||||
|
||||
function sources(dir: string, keep: (name: string) => boolean): { name: string; text: string }[] {
|
||||
return readdirSync(dir)
|
||||
.filter((name) => name.endsWith('.ts') && !name.includes('.test.') && keep(name))
|
||||
.map((name) => ({ name, text: readFileSync(join(dir, name), 'utf8') }))
|
||||
}
|
||||
|
||||
describe('mobile web bridge operation registry census', () => {
|
||||
it('registers every capability and operation pair a page request client names', () => {
|
||||
const files = sources(PAGE_DIR, () => true)
|
||||
const named = new Set<string>()
|
||||
for (const { text } of files) {
|
||||
for (const match of text.matchAll(/\.request\(\s*'([A-Za-z]+)',\s*'([A-Za-z]+)'/g)) {
|
||||
named.add(`${match[1]}.${match[2]}`)
|
||||
}
|
||||
}
|
||||
|
||||
expect(files.length).toBeGreaterThanOrEqual(40)
|
||||
expect(named.size).toBeGreaterThanOrEqual(126)
|
||||
expect([...named].filter((pair) => !registered.has(pair))).toEqual([])
|
||||
})
|
||||
|
||||
// A bare `string` here is how `workspace.creationRetiredNames` shipped unregistered: the helper
|
||||
// erased the operation name before the compiler could check it against the registry.
|
||||
it('types every page request client operation parameter against the registry', () => {
|
||||
const files = sources(PAGE_DIR, (name) => name.endsWith('-request-client.ts'))
|
||||
const untyped = files
|
||||
.filter(({ text }) => /\boperation\??:\s*string\b/.test(text))
|
||||
.map(({ name }) => name)
|
||||
|
||||
expect(files.map(({ name }) => name)).toContain('mobile-web-one-shot-request-client.ts')
|
||||
expect(files.length).toBeGreaterThanOrEqual(20)
|
||||
expect(untyped).toEqual([])
|
||||
})
|
||||
|
||||
// A page client that reaches one operation through two different schema pairs has an arm nobody
|
||||
// reviews; a factory rewrite that swaps a schema shows up here as a second pair.
|
||||
it('binds every directly named operation to exactly one payload and result schema', () => {
|
||||
const pairs = new Map<string, Set<string>>()
|
||||
for (const { text } of sources(PAGE_DIR, () => true)) {
|
||||
for (const match of text.matchAll(
|
||||
/\.request(?:<[^(]*>)?\(\s*'([A-Za-z]+)',\s*'([A-Za-z0-9]+)',([\s\S]{0,400}?)\n\s*\)/g
|
||||
)) {
|
||||
const schemas = [...match[3]!.matchAll(/\b([A-Za-z0-9_]*Schema)\b/g)].map((name) => name[1])
|
||||
const key = `${match[1]}.${match[2]}`
|
||||
pairs.set(key, (pairs.get(key) ?? new Set()).add(schemas.join('|')))
|
||||
}
|
||||
}
|
||||
|
||||
expect(pairs.size).toBeGreaterThanOrEqual(126)
|
||||
expect([...pairs].filter(([, schemas]) => schemas.size !== 1).map(([key]) => key)).toEqual([])
|
||||
expect([...pairs.keys()].filter((key) => !registered.has(key))).toEqual([])
|
||||
expect(
|
||||
[...pairs]
|
||||
.filter(([, schemas]) => [...schemas].some((pair) => pair.split('|').length !== 2))
|
||||
.map(([key]) => key)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
// Capability-level routing is proven against the real table in the shell's dispatch census. This
|
||||
// covers the step inside an arm: the operation name has to be matched somewhere, not merely
|
||||
// quoted, which a stray comment or an unrelated string used to satisfy.
|
||||
it('matches every registered operation at a shell dispatch position', () => {
|
||||
const shell = sources(SHELL_DIR, (name) => !name.startsWith('mobile-web-production-'))
|
||||
const undispatched = [...registered].filter((pair) => {
|
||||
const operation = pair.slice(pair.indexOf('.') + 1)
|
||||
const patterns = [
|
||||
new RegExp(`operation === '${operation}'`),
|
||||
new RegExp(`case '${operation}':`),
|
||||
new RegExp(`'${operation}'(?=[,\\]])`),
|
||||
new RegExp(`^\\s*'${operation}',?$`, 'm'),
|
||||
new RegExp(`\\bstartsWith\\('${operation}'\\)`),
|
||||
new RegExp(`\\b${operation}:\\s`)
|
||||
]
|
||||
return !shell.some(({ text }) => patterns.some((pattern) => pattern.test(text)))
|
||||
})
|
||||
|
||||
expect(shell.length).toBeGreaterThanOrEqual(100)
|
||||
expect(undispatched).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user