orca/tests/e2e/mobile-banner.spec.ts

575 lines
22 KiB
TypeScript

import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getActiveWorktreeId,
getAllWorktreeIds,
switchToWorktree,
waitForSessionReady,
waitForActiveWorktree
} from './helpers/store'
import {
splitActiveTerminalPane,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
// Why: regression coverage for the mobile-presence-lock UX (PR #1532). Strong
// DOM assertions guard the "doesn't mount / doesn't dismiss" regression class;
// visual assertions keep the notice from reintroducing a full-pane scrim/blur
// over the live terminal stream. Screenshots ride in the playwright-traces
// artifact upload so reviewers can eyeball the rendering on a failed run.
//
// Drives the renderer by sending the same IPC events main fires in production
// (runtime:terminalFitOverrideChanged, runtime:terminalDriverChanged — wired in
// useIpcEvents.ts). No production-code test backdoor; the spec exercises the
// renderer-side IPC listener → state mirror → banner JSX chain.
test.describe.configure({ mode: 'serial' })
test('mobile subscribe mounts overlay; collapse → chip; Take back dismisses', async ({
orcaPage,
electronApp
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage)
const ptyId = await waitForActivePanePtyId(orcaPage)
await installRestoreTerminalFitRecorder(electronApp)
const overlay = orcaPage.locator('.mobile-driver-banner')
await expect(overlay).toHaveCount(0)
// Fire the IPC events main emits when a mobile client subscribes in 'auto'
// mode (handleMobileSubscribe in src/main/runtime/orca-runtime.ts). The
// renderer's listener calls setFitOverride + setDriverForPty, the banner
// observes the change, and MobileDriverOverlay mounts in loud mode.
await sendMobileSubscribeIpc(electronApp, { ptyId, cols: 45, rows: 20 })
await expect(overlay).toBeVisible({ timeout: 15_000 })
await expect(overlay).toContainText(/from your phone/i)
await expect(overlay).toContainText(/your phone is in control/i)
await expectExpandedOverlayLeavesPaneReadable(orcaPage, ptyId)
const takeBackThisTerminal = overlay.getByRole('button', { name: /take back this terminal/i })
const takeBackAllTerminals = overlay.getByRole('button', { name: /take back all terminals/i })
const collapse = overlay.getByRole('button', { name: /^collapse$/i })
await expect(takeBackThisTerminal).toBeVisible()
await expect(takeBackAllTerminals).toBeVisible()
await expect(collapse).toBeVisible()
await captureAttachment(orcaPage, testInfo, 'overlay-loud.png')
// Click Collapse → loud overlay swaps to the corner chip while the lock stays
// engaged. The user can keep watching live mobile output while the chip
// remains a one-click escape hatch back to desktop control.
await collapse.click()
await expect(overlay).toContainText(/phone driving/i)
await expect(overlay.getByRole('button', { name: /take back/i })).toBeVisible()
await expect(overlay).not.toContainText(/your phone is in control/i)
await expectChipIsCompactInPane(orcaPage, ptyId)
await captureAttachment(orcaPage, testInfo, 'overlay-collapsed.png')
await overlay.getByRole('button', { name: /phone driving/i }).click()
await expect(overlay).toContainText(/your phone is in control/i)
await expectExpandedOverlayLeavesPaneReadable(orcaPage, ptyId)
await collapse.click()
await expect(overlay).not.toContainText(/your phone is in control/i)
// Take back from the chip dismisses the overlay. The button calls
// runtime.restoreTerminalFit via IPC; main responds with desktop-fit + desktop
// driver events that we mirror here so the renderer state lands on the
// post-take-back terminal state.
await overlay.getByRole('button', { name: /take back/i }).click()
await expectRestoreTerminalFitCalls(electronApp, [ptyId])
await sendDesktopRestoreIpc(electronApp, { ptyId })
await expect(overlay).toBeHidden({ timeout: 15_000 })
})
test('held phone-fit state mounts restore overlay without collapse', async ({
orcaPage,
electronApp
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage)
const ptyId = await waitForActivePanePtyId(orcaPage)
await installRestoreTerminalFitRecorder(electronApp)
const overlay = orcaPage.locator('.mobile-driver-banner')
await expect(overlay).toHaveCount(0)
// Held-fit is the post-mobile-disconnect state: the phone-fit override remains
// while the driver returns to idle. It should stay loud because there is no
// active mobile output to keep watching behind a collapsed chip.
await sendHeldPhoneFitIpc(electronApp, { ptyId, cols: 45, rows: 20 })
await expect(overlay).toBeVisible({ timeout: 15_000 })
await expect(overlay).toContainText(/from your phone/i)
await expect(overlay).toContainText(/your phone left this at phone size/i)
await expect(overlay).toContainText(/all terminals your phone left at phone size/i)
await expect(overlay.getByRole('button', { name: /restore this terminal/i })).toBeVisible()
await expect(overlay.getByRole('button', { name: /restore all terminals/i })).toBeVisible()
await expect(overlay.getByRole('button', { name: /^collapse$/i })).toHaveCount(0)
await expect(overlay.getByRole('button', { name: /take back/i })).toHaveCount(0)
await expectExpandedOverlayLeavesPaneReadable(orcaPage, ptyId)
await captureAttachment(orcaPage, testInfo, 'overlay-held-fit.png')
await overlay.getByRole('button', { name: /restore this terminal/i }).click()
await expectRestoreTerminalFitCalls(electronApp, [ptyId])
await sendDesktopRestoreIpc(electronApp, { ptyId })
await expect(overlay).toBeHidden({ timeout: 15_000 })
})
test('restore this terminal refits the active restored pane', async ({ orcaPage, electronApp }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage)
const ptyId = await waitForActivePanePtyId(orcaPage)
await installRestoreTerminalFitAutoRestoreRecorder(electronApp)
await sendHeldPhoneFitIpc(electronApp, { ptyId, cols: 1, rows: 20 })
await expect(orcaPage.locator('.mobile-driver-banner')).toHaveCount(1, { timeout: 15_000 })
await expect
.poll(() => getPaneTerminalCols(orcaPage, ptyId), {
message: 'test harness should hold the active pane in the bad narrow state'
})
.toBeLessThanOrEqual(2)
await orcaPage
.locator(`[data-pty-id="${ptyId}"] .mobile-driver-banner`)
.getByRole('button', { name: /restore this terminal/i })
.click()
await expectRestoreTerminalFitCalls(electronApp, [ptyId])
await expect
.poll(() => getPaneTerminalCols(orcaPage, ptyId), {
timeout: 5_000,
message: 'Restore this terminal should refit the active restored pane'
})
.toBeGreaterThan(20)
})
test('restore all refits non-focused restored terminal panes', async ({
orcaPage,
electronApp
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage)
await splitActiveTerminalPane(orcaPage, 'vertical')
const ptyIds = await waitForVisiblePanePtyIds(orcaPage, 2)
const focusPtyId = await waitForActivePanePtyId(orcaPage)
const inactivePtyId = ptyIds.find((ptyId) => ptyId !== focusPtyId)
if (!inactivePtyId || !focusPtyId) {
throw new Error('Expected two visible terminal panes with PTY bindings')
}
await installRestoreTerminalFitAutoRestoreRecorder(electronApp)
await sendHeldPhoneFitIpc(electronApp, { ptyId: inactivePtyId, cols: 45, rows: 20 })
await sendHeldPhoneFitIpc(electronApp, { ptyId: focusPtyId, cols: 45, rows: 20 })
await expect(orcaPage.locator('.mobile-driver-banner')).toHaveCount(2, { timeout: 15_000 })
await forcePaneToOneColumn(orcaPage, inactivePtyId)
await expect
.poll(() => getPaneTerminalCols(orcaPage, inactivePtyId), {
message: 'test harness should force the non-focused pane into the bad narrow state'
})
.toBeLessThanOrEqual(2)
await orcaPage
.locator(`[data-pty-id="${focusPtyId}"] .mobile-driver-banner`)
.getByRole('button', { name: /restore all terminals/i })
.click()
await expectRestoreTerminalFitCallSet(electronApp, [inactivePtyId, focusPtyId])
await expect
.poll(() => getPaneTerminalCols(orcaPage, inactivePtyId), {
timeout: 5_000,
message: 'Restore all should refit the non-focused restored pane'
})
.toBeGreaterThan(20)
})
test('restore all recovers a hidden workspace held at narrow terminal geometry', async ({
orcaPage,
electronApp
}) => {
await waitForSessionReady(orcaPage)
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
(worktreeId) => worktreeId !== firstWorktreeId
)
test.skip(!secondWorktreeId, 'hidden-workspace restore repro needs the seeded secondary worktree')
if (!secondWorktreeId) {
return
}
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage)
const hiddenWorkspacePtyId = await waitForActivePanePtyId(orcaPage)
await sendHeldPhoneFitIpc(electronApp, { ptyId: hiddenWorkspacePtyId, cols: 45, rows: 20 })
await expect(orcaPage.locator('.mobile-driver-banner')).toHaveCount(1, { timeout: 15_000 })
await forcePaneToOneColumnAndSwitchWorktree(orcaPage, hiddenWorkspacePtyId, secondWorktreeId)
await expect
.poll(() => getActiveWorktreeId(orcaPage), {
timeout: 5_000,
message: 'second worktree should become active before restore-all'
})
.toBe(secondWorktreeId)
await expect
.poll(() => getPaneTerminalCols(orcaPage, hiddenWorkspacePtyId), {
message: 'test harness should hold workspace 1 in the bad narrow state'
})
.toBeLessThanOrEqual(2)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage)
const activeWorkspacePtyId = await waitForActivePanePtyId(orcaPage)
await installRestoreTerminalFitAutoRestoreRecorder(electronApp)
await sendHeldPhoneFitIpc(electronApp, { ptyId: activeWorkspacePtyId, cols: 45, rows: 20 })
await expect(
orcaPage.locator(`[data-pty-id="${activeWorkspacePtyId}"] .mobile-driver-banner`)
).toBeVisible({ timeout: 15_000 })
await orcaPage
.locator(`[data-pty-id="${activeWorkspacePtyId}"] .mobile-driver-banner`)
.getByRole('button', { name: /restore all terminals/i })
.click()
await expectRestoreTerminalFitCallSet(electronApp, [hiddenWorkspacePtyId, activeWorkspacePtyId])
await switchToWorktree(orcaPage, firstWorktreeId)
await expect
.poll(() => getActiveWorktreeId(orcaPage), {
timeout: 5_000,
message: 'first worktree should become active after restore-all'
})
.toBe(firstWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage)
await expect
.poll(() => getPaneTerminalCols(orcaPage, hiddenWorkspacePtyId), {
timeout: 5_000,
message: 'Restore all should refit the hidden workspace when it becomes visible'
})
.toBeGreaterThan(20)
})
async function sendMobileSubscribeIpc(
electronApp: ElectronApplication,
args: { ptyId: string; cols: number; rows: number }
): Promise<void> {
await electronApp.evaluate(({ BrowserWindow }, payload) => {
const wins = BrowserWindow.getAllWindows()
for (const win of wins) {
win.webContents.send('runtime:terminalFitOverrideChanged', {
ptyId: payload.ptyId,
mode: 'mobile-fit',
cols: payload.cols,
rows: payload.rows
})
win.webContents.send('runtime:terminalDriverChanged', {
ptyId: payload.ptyId,
driver: { kind: 'mobile', clientId: 'fake-phone-1' }
})
}
}, args)
}
async function sendHeldPhoneFitIpc(
electronApp: ElectronApplication,
args: { ptyId: string; cols: number; rows: number }
): Promise<void> {
await electronApp.evaluate(({ BrowserWindow }, payload) => {
const wins = BrowserWindow.getAllWindows()
for (const win of wins) {
win.webContents.send('runtime:terminalFitOverrideChanged', {
ptyId: payload.ptyId,
mode: 'mobile-fit',
cols: payload.cols,
rows: payload.rows
})
win.webContents.send('runtime:terminalDriverChanged', {
ptyId: payload.ptyId,
driver: { kind: 'idle' }
})
}
}, args)
}
async function sendDesktopRestoreIpc(
electronApp: ElectronApplication,
args: { ptyId: string }
): Promise<void> {
await electronApp.evaluate(({ BrowserWindow }, payload) => {
const wins = BrowserWindow.getAllWindows()
for (const win of wins) {
win.webContents.send('runtime:terminalFitOverrideChanged', {
ptyId: payload.ptyId,
mode: 'desktop-fit',
cols: 0,
rows: 0
})
win.webContents.send('runtime:terminalDriverChanged', {
ptyId: payload.ptyId,
driver: { kind: 'idle' }
})
}
}, args)
}
async function installRestoreTerminalFitRecorder(electronApp: ElectronApplication): Promise<void> {
await electronApp.evaluate(({ ipcMain }) => {
const testGlobal = globalThis as typeof globalThis & {
__mobileBannerRestoreCalls?: string[]
}
testGlobal.__mobileBannerRestoreCalls = []
// Why: the renderer state is driven by production IPC events in this spec,
// so the runtime has no real mobile subscriber to reclaim. Replace only the
// main-process handler inside the test process to prove the button invokes
// the production channel and payload before we mirror the resulting events.
ipcMain.removeHandler('runtime:restoreTerminalFit')
ipcMain.handle('runtime:restoreTerminalFit', (_event, args: { ptyId: string }) => {
testGlobal.__mobileBannerRestoreCalls?.push(args.ptyId)
return { restored: true }
})
})
}
async function installRestoreTerminalFitAutoRestoreRecorder(
electronApp: ElectronApplication
): Promise<void> {
await electronApp.evaluate(({ BrowserWindow, ipcMain }) => {
const testGlobal = globalThis as typeof globalThis & {
__mobileBannerRestoreCalls?: string[]
}
testGlobal.__mobileBannerRestoreCalls = []
// Why: the production restore path sets desktop control after clearing the
// fit override, so this harness mirrors both renderer-facing events.
ipcMain.removeHandler('runtime:restoreTerminalFit')
ipcMain.handle('runtime:restoreTerminalFit', (_event, args: { ptyId: string }) => {
testGlobal.__mobileBannerRestoreCalls?.push(args.ptyId)
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('runtime:terminalFitOverrideChanged', {
ptyId: args.ptyId,
mode: 'desktop-fit',
cols: 0,
rows: 0
})
win.webContents.send('runtime:terminalDriverChanged', {
ptyId: args.ptyId,
driver: { kind: 'desktop' }
})
}
return { restored: true }
})
})
}
async function expectRestoreTerminalFitCalls(
electronApp: ElectronApplication,
expected: string[]
): Promise<void> {
await expect
.poll(
() =>
electronApp.evaluate(
() =>
(
globalThis as typeof globalThis & {
__mobileBannerRestoreCalls?: string[]
}
).__mobileBannerRestoreCalls ?? []
),
{ message: 'restoreTerminalFit should be invoked through the production IPC channel' }
)
.toEqual(expected)
}
async function expectRestoreTerminalFitCallSet(
electronApp: ElectronApplication,
expected: string[]
): Promise<void> {
await expect
.poll(
() =>
electronApp.evaluate(
() =>
(
globalThis as typeof globalThis & {
__mobileBannerRestoreCalls?: string[]
}
).__mobileBannerRestoreCalls ?? []
),
{ message: 'restore all should invoke the production restore channel for each PTY' }
)
.toEqual(expect.arrayContaining(expected))
}
async function waitForVisiblePanePtyIds(page: Page, expectedCount: number): Promise<string[]> {
let ptyIds: string[] = []
await expect
.poll(
async () => {
ptyIds = await page.evaluate(() => {
const state = window.__store?.getState()
const tabId = state?.activeTabId
const manager = tabId ? window.__paneManagers?.get(tabId) : null
return (manager?.getPanes?.() ?? [])
.map((pane) => pane.container?.dataset?.ptyId ?? null)
.filter((ptyId): ptyId is string => Boolean(ptyId))
})
return ptyIds.length
},
{
timeout: 15_000,
message: `Expected ${expectedCount} visible panes with PTY bindings`
}
)
.toBe(expectedCount)
return ptyIds
}
async function forcePaneToOneColumn(page: Page, ptyId: string): Promise<void> {
await page.evaluate((targetPtyId) => {
for (const manager of window.__paneManagers?.values?.() ?? []) {
const pane = manager
.getPanes?.()
.find((candidate) => candidate.container.dataset.ptyId === targetPtyId)
if (pane) {
pane.terminal.resize(1, Math.max(8, pane.terminal.rows))
pane.terminal.refresh(0, pane.terminal.rows - 1)
return
}
}
throw new Error(`No pane found for PTY ${targetPtyId}`)
}, ptyId)
}
async function forcePaneToOneColumnAndSwitchWorktree(
page: Page,
ptyId: string,
worktreeId: string
): Promise<void> {
await page.evaluate(
({ targetPtyId, targetWorktreeId }) => {
for (const manager of window.__paneManagers?.values?.() ?? []) {
const pane = manager
.getPanes?.()
.find((candidate) => candidate.container.dataset.ptyId === targetPtyId)
if (pane) {
// Why: the repro needs the desktop surface to inherit a phone-sized
// xterm layout after the workspace is no longer visible.
pane.terminal.resize(1, Math.max(8, pane.terminal.rows))
pane.terminal.refresh(0, pane.terminal.rows - 1)
window.__store?.getState().setActiveWorktree(targetWorktreeId)
return
}
}
throw new Error(`No pane found for PTY ${targetPtyId}`)
},
{ targetPtyId: ptyId, targetWorktreeId: worktreeId }
)
}
async function getPaneTerminalCols(page: Page, ptyId: string): Promise<number> {
return page.evaluate((targetPtyId) => {
for (const manager of window.__paneManagers?.values?.() ?? []) {
const pane = manager
.getPanes?.()
.find((candidate) => candidate.container.dataset.ptyId === targetPtyId)
if (pane) {
return pane.terminal.cols
}
}
return 0
}, ptyId)
}
async function expectExpandedOverlayLeavesPaneReadable(page: Page, ptyId: string): Promise<void> {
await expect
.poll(
() =>
page.evaluate((targetPtyId) => {
const pane = Array.from(document.querySelectorAll<HTMLElement>('[data-pty-id]')).find(
(node) => node.dataset.ptyId === targetPtyId
)
const overlay = document.querySelector<HTMLElement>('.mobile-driver-banner')
if (!pane || !overlay) {
return false
}
const style = getComputedStyle(overlay)
const hasTransparentBackground =
style.backgroundColor === 'rgba(0, 0, 0, 0)' || style.backgroundColor === 'transparent'
const webkitBackdropFilter = (
style as CSSStyleDeclaration & { webkitBackdropFilter?: string }
).webkitBackdropFilter
const hasNoBackdropFilter =
(style.backdropFilter === 'none' || style.backdropFilter === '') &&
(webkitBackdropFilter === undefined ||
webkitBackdropFilter === '' ||
webkitBackdropFilter === 'none')
const paneBox = pane.getBoundingClientRect()
const overlayBox = overlay.getBoundingClientRect()
return (
hasTransparentBackground &&
hasNoBackdropFilter &&
Math.abs(overlayBox.left - paneBox.left) <= 2 &&
Math.abs(overlayBox.top - paneBox.top) <= 2 &&
overlayBox.width >= paneBox.width - 2 &&
overlayBox.height >= paneBox.height - 2
)
}, ptyId),
{ message: 'expanded overlay should not dim or blur the terminal pane' }
)
.toBe(true)
}
async function expectChipIsCompactInPane(page: Page, ptyId: string): Promise<void> {
await expect
.poll(
() =>
page.evaluate((targetPtyId) => {
const pane = Array.from(document.querySelectorAll<HTMLElement>('[data-pty-id]')).find(
(node) => node.dataset.ptyId === targetPtyId
)
const chip = document.querySelector<HTMLElement>('.mobile-driver-banner')
if (!pane || !chip) {
return false
}
const paneBox = pane.getBoundingClientRect()
const chipBox = chip.getBoundingClientRect()
const rightInset = paneBox.right - chipBox.right
const topInset = chipBox.top - paneBox.top
return (
chipBox.width < paneBox.width * 0.6 &&
chipBox.height <= 40 &&
rightInset >= 6 &&
rightInset <= 16 &&
topInset >= 6 &&
topInset <= 16
)
}, ptyId),
{ message: 'collapsed chip should stay compact in the terminal pane corner' }
)
.toBe(true)
}
// Why: writing the screenshot to testInfo.outputPath() lands the file in the
// per-test output dir that ships in the playwright-traces artifact uploaded by
// .github/workflows/e2e.yml on failure. The `body` form of testInfo.attach
// didn't reliably persist for the `list` reporter; the path round-trip does.
async function captureAttachment(page: Page, testInfo: TestInfo, fileName: string): Promise<void> {
const dest = testInfo.outputPath(fileName)
await page.screenshot({ path: dest, fullPage: true })
await testInfo.attach(fileName, { path: dest, contentType: 'image/png' })
}