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

293 lines
12 KiB
TypeScript

import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForSessionReady, waitForActiveWorktree } from './helpers/store'
import { 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(/mobile is driving this terminal/i)
await expect(overlay).toContainText(/your keyboard is paused/i)
await expectExpandedOverlayLeavesPaneReadable(orcaPage, ptyId)
const takeBack = overlay.getByRole('button', { name: /take back/i })
const collapse = overlay.getByRole('button', { name: /^collapse$/i })
await expect(takeBack).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(/mobile driving/i)
await expect(overlay.getByRole('button', { name: /take back/i })).toBeVisible()
await expect(overlay).not.toContainText(/your keyboard is paused/i)
await expectChipIsCompactInPane(orcaPage, ptyId)
await captureAttachment(orcaPage, testInfo, 'overlay-collapsed.png')
await overlay.getByRole('button', { name: /mobile driving/i }).click()
await expect(overlay).toContainText(/your keyboard is paused/i)
await expectExpandedOverlayLeavesPaneReadable(orcaPage, ptyId)
await collapse.click()
await expect(overlay).not.toContainText(/your keyboard is paused/i)
// Take back from the chip dismisses the overlay. The button calls
// runtime.restoreTerminalFit via IPC; main responds with desktop-fit + idle
// 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(/held at phone size/i)
await expect(overlay).toContainText(/restore to use it on your desktop/i)
await expect(overlay.getByRole('button', { name: /restore desktop size/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 desktop size/i }).click()
await expectRestoreTerminalFitCalls(electronApp, [ptyId])
await sendDesktopRestoreIpc(electronApp, { ptyId })
await expect(overlay).toBeHidden({ timeout: 15_000 })
})
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 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 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' })
}