mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
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:
@@ -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
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user