From d0e42bf331d5e052ef2c472ecc610bc787b49fef Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 6 Oct 2026 00:39:50 -0400 Subject: [PATCH] test(e2e): phone subscribe to an idle relaunched terminal fits the phone Relaunches the app over a live daemon PTY running an idle program, hides its pane behind another tab, and subscribes as a mobile client at 47x40. Before the host-side hydrate the first snapshot came back at the hidden pane's size (135x60 here). Claude-Session: lane-host-hydrate --- ...ile-subscribe-idle-hidden-pane-fit.spec.ts | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tests/e2e/mobile-subscribe-idle-hidden-pane-fit.spec.ts diff --git a/tests/e2e/mobile-subscribe-idle-hidden-pane-fit.spec.ts b/tests/e2e/mobile-subscribe-idle-hidden-pane-fit.spec.ts new file mode 100644 index 00000000000..77d903173b1 --- /dev/null +++ b/tests/e2e/mobile-subscribe-idle-hidden-pane-fit.spec.ts @@ -0,0 +1,189 @@ +/** + * A phone subscribing to an idle terminal after a relaunch must get its first snapshot on its grid. + * + * After a relaunch the daemon PTY is reattached with no host model, and an idle program never + * paints a byte that would hydrate one. With the pane hidden behind another tab, the host used to + * serve the pane's desktop-sized screen, and the phone gave up after three mismatched frames. + * + * Run: + * npx playwright test tests/e2e/mobile-subscribe-idle-hidden-pane-fit.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { createRuntimeDesktopPairingOffer } from './helpers/paired-electron-client' +import { decodePairingOffer } from '../../src/shared/pairing' +import { + sendRemoteRuntimeRequest, + subscribeRemoteRuntimeRequest +} from '../../src/shared/remote-runtime-client' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson +} from '../../src/shared/terminal-stream-protocol' + +const PHONE = { cols: 47, rows: 40 } + +function writeIdleProgram(scriptPath: string): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + [ + "process.stdout.write('A'.repeat(120) + '\\r\\nIDLE_READY')", + 'process.stdin.resume()', + '' + ].join('\n') + ) +} + +async function paneGrid(page: Page, ptyId: string) { + return page.evaluate((ptyId) => { + for (const manager of window.__paneManagers?.values() ?? []) { + const pane = manager + .getPanes?.() + .find((candidate) => candidate.container.dataset.ptyId === ptyId) + if (pane) { + return { + cols: pane.terminal.cols, + rows: pane.terminal.rows, + visible: pane.container.getBoundingClientRect().width > 0, + content: pane.serializeAddon.serialize() + } + } + } + return null + }, ptyId) +} + +async function findRestoredTabForPty(page: Page, ptyId: string): Promise { + return page.evaluate((ptyId) => { + const layouts = window.__store?.getState().terminalLayoutsByTabId ?? {} + for (const [tabId, layout] of Object.entries(layouts)) { + if (Object.values(layout.ptyIdsByLeafId ?? {}).includes(ptyId)) { + return tabId + } + } + return null + }, ptyId) +} + +async function hideBehindNewTab(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + const worktreeId = store?.getState().activeWorktreeId + if (!store || !worktreeId) { + throw new Error('Store unavailable') + } + const tab = store.getState().createTab(worktreeId, undefined, undefined, { activate: true }) + store.getState().setActiveTab(tab.id) + }) +} + +test('phone subscribe to an idle relaunched terminal behind another tab fits the phone', async (// oxlint-disable-next-line no-empty-pattern -- This restart test owns both app launches. +{}, testInfo) => { + test.setTimeout(240_000) + test.skip(process.platform === 'win32', 'The idle fixture is launched from a POSIX shell') + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo') + const scriptPath = testInfo.outputPath('idle-program.mjs') + writeIdleProgram(scriptPath) + + const session = createRestartSession(testInfo) + let app: ElectronApplication | null = null + try { + const first = await session.launch() + app = first.app + await attachRepoAndOpenTerminal(first.page, repoPath) + await waitForSessionReady(first.page) + await ensureTerminalVisible(first.page) + await waitForActiveTerminalManager(first.page, 30_000) + const ptyId = await waitForActivePanePtyId(first.page) + await execInTerminal(first.page, ptyId, `node ${JSON.stringify(scriptPath)}`) + await expect + .poll(async () => (await paneGrid(first.page, ptyId))?.content ?? '', { timeout: 20_000 }) + .toContain('IDLE_READY') + await session.close(first.app) + app = null + + const second = await session.launch() + app = second.app + await waitForSessionReady(second.page) + await ensureTerminalVisible(second.page) + await expect + .poll(() => findRestoredTabForPty(second.page, ptyId), { timeout: 20_000 }) + .not.toBeNull() + await expect + .poll(async () => (await paneGrid(second.page, ptyId))?.content ?? '', { timeout: 20_000 }) + .toContain('IDLE_READY') + await hideBehindNewTab(second.page) + await expect.poll(async () => (await paneGrid(second.page, ptyId))?.visible).toBe(false) + const hiddenPane = await paneGrid(second.page, ptyId) + + const offer = await createRuntimeDesktopPairingOffer(second.page) + const pairing = decodePairingOffer(offer.pairingUrl) + const list = await sendRemoteRuntimeRequest<{ + terminals: { handle: string; ptyId: string | null }[] + }>(pairing, 'terminal.list', {}, 15_000) + if (!list.ok) { + throw new Error(`terminal.list failed: ${JSON.stringify(list)}`) + } + const handle = list.result.terminals.find((terminal) => terminal.ptyId === ptyId)?.handle + expect(handle, 'relaunched terminal is not listed').toBeTruthy() + + const seen: { snapshot: { cols: number; rows: number } | null } = { snapshot: null } + const subscription = await subscribeRemoteRuntimeRequest( + pairing, + 'terminal.subscribe', + { + terminal: handle, + client: { id: 'e2e-phone', type: 'mobile' }, + viewport: PHONE, + capabilities: { terminalBinaryStream: 1 } + }, + 30_000, + { + onResponse: () => {}, + onBinary: (bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + if (frame?.opcode === TerminalStreamOpcode.SnapshotStart && !seen.snapshot) { + seen.snapshot = decodeTerminalStreamJson<{ cols: number; rows: number }>(frame.payload) + } + }, + onError: () => {} + } + ) + try { + await expect.poll(() => seen.snapshot, { timeout: 15_000 }).not.toBeNull() + } finally { + subscription.close() + } + const observed = seen.snapshot + testInfo.annotations.push({ + type: 'evidence', + description: JSON.stringify({ + hiddenPane: hiddenPane && { cols: hiddenPane.cols, rows: hiddenPane.rows }, + firstSnapshot: observed && { cols: observed.cols, rows: observed.rows } + }) + }) + expect(hiddenPane?.cols, 'the hidden pane must stay off the phone grid to reproduce').not.toBe( + PHONE.cols + ) + expect(observed && { cols: observed.cols, rows: observed.rows }).toEqual(PHONE) + } finally { + if (app) { + await session.close(app) + } + await session.dispose() + } +})