From 0642cfede8d26f02bb25e8f4bbcd1f9a89d6487a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 7 Oct 2026 23:25:05 -0400 Subject: [PATCH] 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 --- .../helpers/headless-paired-runtime-host.ts | 5 +- tests/e2e/helpers/paired-mobile-client.ts | 106 ++++++++++++++++++ ...red-mobile-desktop-mirror-baseline.spec.ts | 71 ++++++++++++ ...mobile-serve-restart-terminal-tabs.spec.ts | 82 ++++++++++++++ 4 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/helpers/paired-mobile-client.ts create mode 100644 tests/e2e/paired-mobile-desktop-mirror-baseline.spec.ts create mode 100644 tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts diff --git a/tests/e2e/helpers/headless-paired-runtime-host.ts b/tests/e2e/helpers/headless-paired-runtime-host.ts index bdf84015ebd..31c9da52154 100644 --- a/tests/e2e/helpers/headless-paired-runtime-host.ts +++ b/tests/e2e/helpers/headless-paired-runtime-host.ts @@ -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 }) diff --git a/tests/e2e/helpers/paired-mobile-client.ts b/tests/e2e/helpers/paired-mobile-client.ts new file mode 100644 index 00000000000..9c37aeba435 --- /dev/null +++ b/tests/e2e/helpers/paired-mobile-client.ts @@ -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: ( + method: string, + params?: unknown, + timeoutMs?: number + ) => Promise> + /** Result of a call that must succeed; throws with the host's error code otherwise. */ + call: (method: string, params?: unknown, timeoutMs?: number) => Promise + subscribe: ( + method: string, + params: unknown, + callbacks: RemoteRuntimeSubscriptionCallbacks, + timeoutMs?: number + ) => Promise + /** 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() + const request = ( + method: string, + params: unknown = {}, + timeoutMs = DEFAULT_TIMEOUT_MS + ): Promise> => + sendRemoteRuntimeRequest( + pairing, + method, + params, + timeoutMs, + undefined, + undefined, + capabilities + ) + return { + pairing, + request, + call: async (method: string, params?: unknown, timeoutMs?: number): Promise => { + const response = await request(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 } + }) +} diff --git a/tests/e2e/paired-mobile-desktop-mirror-baseline.spec.ts b/tests/e2e/paired-mobile-desktop-mirror-baseline.spec.ts new file mode 100644 index 00000000000..05359c0fa01 --- /dev/null +++ b/tests/e2e/paired-mobile-desktop-mirror-baseline.spec.ts @@ -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 { + 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() + } +}) diff --git a/tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts b/tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts new file mode 100644 index 00000000000..68b0873840b --- /dev/null +++ b/tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts @@ -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 { + const listed = await phone.request('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() + } +})