test(e2e): add a paired phone client helper and phone-mirror baseline specs

Later phone-mirror slices need one oracle that drives a real mobile-scoped device with the
phone's own capabilities against a serve host or a paired desktop. Pins #26022 across a serve
restart and today's gap: a desktop's phone does not list the desktop's server workspaces.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-10-07 23:25:05 -04:00
parent 4f4f021f8a
commit 0642cfede8
4 changed files with 263 additions and 1 deletions
@@ -96,6 +96,8 @@ export async function launchHeadlessPairedRuntimeHost(
options: {
agentBrowserSocketParent?: string
executablePath?: string
/** `mobile` makes `offer` a phone pairing (`orca serve --mobile-pairing`); a desktop cannot use it. */
pairingScope?: 'runtime' | 'mobile'
/** Bind a stable loopback port so `restartServeProcess` can reclaim it. */
pinnedServePort?: boolean
userDataParent?: string
@@ -141,7 +143,8 @@ export async function launchHeadlessPairedRuntimeHost(
'--serve-port',
String(servePort),
'--serve-pairing-address',
'127.0.0.1'
'127.0.0.1',
...(options.pairingScope === 'mobile' ? ['--serve-mobile-pairing'] : [])
],
env: isolation.env
})
+106
View File
@@ -0,0 +1,106 @@
import type { Page } from '@stablyai/playwright-test'
import { MOBILE_RUNTIME_CLIENT_CAPABILITIES } from '../../../mobile/src/transport/mobile-runtime-client-capabilities'
import { decodePairingOffer, type PairingOffer } from '../../../src/shared/pairing'
import {
sendRemoteRuntimeRequest,
subscribeRemoteRuntimeRequest,
type RemoteRuntimeSubscription,
type RemoteRuntimeSubscriptionCallbacks
} from '../../../src/shared/remote-runtime-client'
import type { RuntimeRpcResponse } from '../../../src/shared/runtime-rpc-envelope'
const DEFAULT_TIMEOUT_MS = 15_000
/**
* A phone paired to one runtime: the mobile-scoped device token and the phone app's own
* capability list, over the same E2EE transport the app uses.
*/
export type PairedMobileClient = {
pairing: PairingOffer
/** Raw response, for asserting a refusal. */
request: <T>(
method: string,
params?: unknown,
timeoutMs?: number
) => Promise<RuntimeRpcResponse<T>>
/** Result of a call that must succeed; throws with the host's error code otherwise. */
call: <T>(method: string, params?: unknown, timeoutMs?: number) => Promise<T>
subscribe: <T>(
method: string,
params: unknown,
callbacks: RemoteRuntimeSubscriptionCallbacks<T>,
timeoutMs?: number
) => Promise<RemoteRuntimeSubscription>
/** Closes every subscription this client opened. */
dispose: () => void
}
export function pairMobileClient(offer: { pairingUrl: string }): PairedMobileClient {
const pairing = decodePairingOffer(offer.pairingUrl)
// Why: a runtime-scope token skips the mobile allowlist, so the oracle would not be a phone.
if (pairing.scope !== 'mobile') {
throw new Error(`Expected a mobile-scoped pairing offer, got scope ${String(pairing.scope)}`)
}
const capabilities = [...MOBILE_RUNTIME_CLIENT_CAPABILITIES]
const open = new Set<RemoteRuntimeSubscription>()
const request = <T>(
method: string,
params: unknown = {},
timeoutMs = DEFAULT_TIMEOUT_MS
): Promise<RuntimeRpcResponse<T>> =>
sendRemoteRuntimeRequest<T>(
pairing,
method,
params,
timeoutMs,
undefined,
undefined,
capabilities
)
return {
pairing,
request,
call: async <T>(method: string, params?: unknown, timeoutMs?: number): Promise<T> => {
const response = await request<T>(method, params, timeoutMs)
if (!response.ok) {
throw new Error(`${method} failed: ${response.error.code}: ${response.error.message}`)
}
return response.result
},
subscribe: async (method, params, callbacks, timeoutMs = DEFAULT_TIMEOUT_MS) => {
const subscription = await subscribeRemoteRuntimeRequest(
pairing,
method,
params,
timeoutMs,
callbacks,
{ clientCapabilities: capabilities }
)
open.add(subscription)
return subscription
},
dispose: () => {
for (const subscription of open) {
subscription.close()
}
open.clear()
}
}
}
/** The phone QR a desktop shows in Settings, minted LAN-only so no Relay is involved. */
export async function createDesktopMobilePairingOffer(
desktopPage: Page
): Promise<{ pairingUrl: string }> {
return desktopPage.evaluate(async () => {
const offer = await window.api.mobile.getPairingQR({
address: '127.0.0.1',
connectionMode: 'local-only',
rotate: true
})
if (!offer.available) {
throw new Error(`Desktop did not mint a mobile pairing offer: ${JSON.stringify(offer)}`)
}
return { pairingUrl: offer.pairingUrl }
})
}
@@ -0,0 +1,71 @@
/**
* Phone paired with a desktop that is itself a client of an `orca serve` host.
*
* BASELINE, not the goal: today the phone sees only the desktop's own workspaces, because the
* desktop main process never holds its paired servers' workspaces. The mirror slice (S2) flips the
* second assertion; update it there instead of deleting it.
*/
import { mkdirSync, realpathSync, writeFileSync } from 'node:fs'
import { expect, test } from './helpers/orca-app'
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
import { launchPairedElectronClient } from './helpers/paired-electron-client'
import {
createDesktopMobilePairingOffer,
pairMobileClient,
type PairedMobileClient
} from './helpers/paired-mobile-client'
import { RuntimeClient } from '../../src/cli/runtime/client'
import type { RuntimeWorktreePsSummary } from '../../src/shared/runtime-worktree-contracts'
async function phoneWorkspacePaths(phone: PairedMobileClient): Promise<string[]> {
const { worktrees } = await phone.call<{ worktrees: RuntimeWorktreePsSummary[] }>('worktree.ps', {
limit: 1_000
})
return worktrees.map((row) => realpathSync(row.path))
}
test('baseline until S2: phone paired with a desktop lists its local workspace but not its server workspace', async ({
testRepoPath
}, testInfo) => {
test.setTimeout(120_000)
const serverFolder = testInfo.outputPath('server-folder')
mkdirSync(serverFolder, { recursive: true })
writeFileSync(`${serverFolder}/README.md`, 'server workspace\n')
const host = await launchHeadlessPairedRuntimeHost()
let phone: PairedMobileClient | null = null
try {
await host.client.call('repo.add', { path: serverFolder, kind: 'folder' })
const desktop = await launchPairedElectronClient(host.offer, testInfo, 'mirror-desktop')
try {
await new RuntimeClient(desktop.userDataDir, 5_000).call('repo.add', {
path: testRepoPath,
kind: 'git'
})
// Precondition: the desktop window does show the server's workspace.
await expect
.poll(() =>
desktop.page.evaluate(
(folder) =>
window.__store
?.getState()
.allWorktrees()
.some((worktree) => worktree.path === folder) ?? false,
serverFolder
)
)
.toBe(true)
phone = pairMobileClient(await createDesktopMobilePairingOffer(desktop.page))
const paths = await phoneWorkspacePaths(phone)
expect(paths).toContain(realpathSync(testRepoPath))
// BASELINE (S2 flips to toContain): the server's workspace is missing from the phone.
expect(paths).not.toContain(realpathSync(serverFolder))
} finally {
phone?.dispose()
await desktop.dispose()
}
} finally {
await host.dispose()
}
})
@@ -0,0 +1,82 @@
/**
* A phone paired directly with `orca serve` still lists a `terminal.create` terminal after the
* serve process restarts (an update does this). Regression oracle for #26022: the cold-start
* hydrate used to keep only serve-minted PTYs, so CLI and agent terminals vanished from the phone.
*/
import { expect, test } from './helpers/orca-app'
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
import { pairMobileClient, type PairedMobileClient } from './helpers/paired-mobile-client'
import type { RuntimeWorktreePsSummary } from '../../src/shared/runtime-worktree-contracts'
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-session-contracts'
async function listedPtyIds(phone: PairedMobileClient, worktreeId: string): Promise<string[]> {
const listed = await phone.request<RuntimeMobileSessionTabsResult>('session.tabs.list', {
worktree: `id:${worktreeId}`
})
return listed.ok ? terminalPtyIds(listed.result) : [`refused:${listed.error.code}`]
}
function terminalPtyIds(snapshot: RuntimeMobileSessionTabsResult): string[] {
return snapshot.tabs.flatMap((tab) => (tab.type === 'terminal' && tab.ptyId ? [tab.ptyId] : []))
}
test('phone paired with orca serve keeps a terminal.create terminal across a serve restart', async ({
testRepoPath
}) => {
test.setTimeout(120_000)
const host = await launchHeadlessPairedRuntimeHost({
pairingScope: 'mobile',
pinnedServePort: true
})
const phone = pairMobileClient(host.offer)
try {
await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
const { worktrees } = await phone.call<{ worktrees: RuntimeWorktreePsSummary[] }>(
'worktree.ps',
{ limit: 1_000 }
)
const worktreeId = worktrees.find((row) => row.path === testRepoPath)?.worktreeId ?? ''
expect(worktreeId, 'the phone lists the serve host workspace').not.toBe('')
// Why both: the bug kept serve-minted PTYs (the phone's own create) and dropped daemon-minted ones.
await phone.call('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
activate: false,
select: true,
navigation: 'caller'
})
const created = await host.client.call<{ terminal: { ptyId?: string | null } }>(
'terminal.create',
{ worktree: `path:${testRepoPath}`, title: 'cli-created' }
)
const cliPtyId = created.result.terminal.ptyId ?? ''
expect(cliPtyId, 'terminal.create reports its PTY').not.toBe('')
await expect.poll(() => listedPtyIds(phone, worktreeId)).toHaveLength(2)
const beforeRestart = await listedPtyIds(phone, worktreeId)
expect(beforeRestart).toContain(cliPtyId)
await host.restartServeProcess()
await expect
.poll(async () => (await listedPtyIds(phone, worktreeId)).sort(), { timeout: 30_000 })
.toEqual([...beforeRestart].sort())
// The phone's session strip is built from this stream's first snapshot.
let snapshotPtyIds: string[] | null = null
await phone.subscribe<{ type: string } & RuntimeMobileSessionTabsResult>(
'session.tabs.subscribe',
{ worktree: `id:${worktreeId}` },
{
onResponse: (response) => {
if (response.ok && response.result.type === 'snapshot' && !snapshotPtyIds) {
snapshotPtyIds = terminalPtyIds(response.result)
}
},
onError: () => {}
}
)
await expect.poll(() => snapshotPtyIds?.sort() ?? null).toEqual([...beforeRestart].sort())
} finally {
phone.dispose()
await host.dispose()
}
})