mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
* fix(ui): let toast actions work above a modal instead of looking clickable but dead Radix modals dismiss/refocus on outside pointerdown, so the Toaster already swallowed pointerdown inside toasts; it additionally swallowed click for every control except the close button, making actions like 'Update skill' look live while doing nothing. Keep the pointerdown shield (modal stays open, focus/input intact) and let the click run. * test(e2e): keep a toast mounted and close Settings after the over-modal action * fix(ui): default-deny toast actions above a modal, visibly disabled, with a modal-safe opt-in Restores #32065's lock (close always works; actions that navigate or change settings don't bypass the modal) and fixes the clickable-but-dead look: blocked controls get aria-disabled, a 'Close the dialog to use this' title, and disabled styling, updated live as modals open and close. Background/toast-local actions opt in via modalSafeToastActionLabel or data-toast-modal-safe. * fix(ui): return focus to the modal draft after a toast-opened dialog closes A modal-safe action that opens a trigger-less dialog (Update skill) left focus on body after close, minimize or Escape. captureDialogReturnFocus records the focused element before opening and restores it via onCloseAutoFocus. Also harden Dialog Escape: Radix arms a just-opened sibling dialog's Escape a render late, so an early Escape closed the dialog beneath and dropped its draft. A dialog now ignores Escape while a later-opened modal is open. * fix(ui): hide toast actions above a modal instead of leaving them clickable-looking but dead Replaces the modal-safe opt-in design. Main's close-only capture gate stays as is; a single CSS rule hides every toast control except close while a modal overlay is open, so the message and dismissal remain and actions return once the modal closes. * fix(ui): type the sheet fixture's effect cleanup * test(e2e): dismiss an older star-nag toast over the dialog instead of re-raising one id * fix(ui): toasts sit behind a modal's backdrop until pressed or newly raised Older toasts drop behind an open Dialog/Sheet backdrop, dimmed and inert, so nothing looks clickable that isn't. A press on one, or a toast raised while the modal is open, brings the stack forward; close and actions then work above the modal with its focus and draft intact. Actions run that way are an escape hatch and emit toast_action_over_modal. * test(e2e): read recorded telemetry without a type assertion * test(e2e): read recorded telemetry through a typed global * test(e2e): check the raised Later action once by mouse; keep #32065's per-pointer Dismiss as on main
364 lines
16 KiB
TypeScript
364 lines
16 KiB
TypeScript
import path from 'node:path'
|
|
import type { ElectronApplication, Locator, Page, TestInfo } from '@stablyai/playwright-test'
|
|
import { eventSchemas } from '../../src/shared/telemetry-events'
|
|
import { test, expect } from './helpers/orca-app'
|
|
import { openSidebarProjectDialog } from './helpers/sidebar-project-dialog'
|
|
import {
|
|
createRuntimeDesktopPairingOffer,
|
|
launchPairedElectronClient
|
|
} from './helpers/paired-electron-client'
|
|
|
|
test.use({
|
|
seedTestRepo: false,
|
|
testRepoPath: '',
|
|
orcaAppExtraEnv: { ORCA_BACKGROUND_LAUNCH: '1' }
|
|
})
|
|
|
|
async function assertHiddenIsolation(app: ElectronApplication, testInfo: TestInfo, name: string) {
|
|
const isolation = await app.evaluate(({ app, BrowserWindow }) => ({
|
|
home: app.getPath('home'),
|
|
expectedHome: process.env.ORCA_E2E_HOME_DIR,
|
|
appPath: app.getAppPath(),
|
|
background: process.env.ORCA_BACKGROUND_LAUNCH,
|
|
windows: BrowserWindow.getAllWindows().map((window) => ({
|
|
visible: window.isVisible(),
|
|
focused: window.isFocused()
|
|
}))
|
|
}))
|
|
expect(isolation.home).toBe(isolation.expectedHome)
|
|
expect(isolation.background).toBe('1')
|
|
expect(isolation.appPath).toContain(process.cwd())
|
|
expect(isolation.windows.every((window) => !window.visible && !window.focused)).toBe(true)
|
|
await testInfo.attach(name, { body: JSON.stringify(isolation), contentType: 'application/json' })
|
|
return isolation.home
|
|
}
|
|
|
|
for (const theme of ['dark', 'light'] as const) {
|
|
test(`invalid host project path stays readable over its dialog (${theme})`, async ({
|
|
electronApp,
|
|
orcaPage
|
|
}, testInfo) => {
|
|
const hostHome = await assertHiddenIsolation(electronApp, testInfo, 'host-isolation')
|
|
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
|
|
const client = await launchPairedElectronClient(offer, testInfo, 'Disposable host', {
|
|
extraEnv: { ORCA_BACKGROUND_LAUNCH: '1' }
|
|
})
|
|
try {
|
|
await assertHiddenIsolation(client.app, testInfo, 'client-isolation')
|
|
const page = client.page
|
|
await page.evaluate(async (theme) => {
|
|
await window.__store?.getState().updateSettings({ theme })
|
|
}, theme)
|
|
await openSidebarProjectDialog(page)
|
|
const dialog = page.getByRole('dialog', { name: /Add a project/i })
|
|
const hostPicker = dialog.getByRole('combobox')
|
|
await hostPicker.click()
|
|
await page.locator('[cmdk-item]').filter({ hasText: 'Disposable host' }).click()
|
|
await dialog.getByRole('button', { name: /Browse folder|Browse host/i }).click()
|
|
await page
|
|
.getByRole('dialog', { name: /Browse host filesystem/i })
|
|
.getByRole('button', { name: 'Cancel', exact: true })
|
|
.click()
|
|
const pathField = page.locator('#server-project-path')
|
|
const missingPath = path.join(hostHome, 'missing-project')
|
|
await pathField.fill(missingPath)
|
|
await page.getByRole('button', { name: 'Add Git Project' }).click()
|
|
const toast = page
|
|
.locator('[data-sonner-toast]')
|
|
.filter({ hasText: 'Cannot open folder on selected runtime' })
|
|
.first()
|
|
await expect(toast).toBeVisible()
|
|
await expect(pathField).toHaveValue(missingPath)
|
|
await expect(page.getByRole('dialog', { name: /Open host project/i })).toBeVisible()
|
|
await page.screenshot({
|
|
path: testInfo.outputPath(`toast-over-dialog-${theme}.png`),
|
|
animations: 'disabled'
|
|
})
|
|
const layers = await toast.evaluate((element) => {
|
|
const overlay = document.querySelector('[data-slot="dialog-overlay"]')
|
|
const toaster = element.closest('[data-sonner-toaster]')
|
|
if (!overlay || !toaster) {
|
|
throw new Error('Missing modal/toast layers')
|
|
}
|
|
return {
|
|
toast: Number(getComputedStyle(toaster).zIndex),
|
|
overlay: Number(getComputedStyle(overlay).zIndex),
|
|
pointerEvents: getComputedStyle(element).pointerEvents
|
|
}
|
|
})
|
|
await testInfo.attach('layers', {
|
|
body: JSON.stringify(layers),
|
|
contentType: 'application/json'
|
|
})
|
|
expect(layers.toast, 'dialog overlay covers the project error').toBeGreaterThan(
|
|
layers.overlay
|
|
)
|
|
expect(layers.pointerEvents).toBe('auto')
|
|
await pathField.click()
|
|
await toast.locator('[data-close-button]').click()
|
|
await expect(toast).toBeHidden()
|
|
await expect(page.getByRole('dialog', { name: /Open host project/i })).toBeVisible()
|
|
await expect(pathField).toHaveValue(missingPath)
|
|
expect(await pathField.evaluate((input) => input === document.activeElement)).toBe(true)
|
|
|
|
for (const pointerType of ['mouse', 'touch'] as const) {
|
|
await client.app.evaluate(({ BrowserWindow }) => {
|
|
BrowserWindow.getAllWindows()[0].webContents.send('star-nag:show', {
|
|
mode: 'web',
|
|
surface: 'toast'
|
|
})
|
|
})
|
|
const customToast = page
|
|
.locator('[data-sonner-toast]')
|
|
.filter({ hasText: 'Onboarding completed!' })
|
|
await expect(customToast).toBeVisible()
|
|
const clickToastControl = async (control: Locator): Promise<void> => {
|
|
if (pointerType === 'mouse') {
|
|
await control.click()
|
|
return
|
|
}
|
|
const cdp = await page.context().newCDPSession(page)
|
|
try {
|
|
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true })
|
|
await control.click({ trial: true })
|
|
const bounds = await control.boundingBox()
|
|
if (!bounds) {
|
|
throw new Error('Missing toast control target')
|
|
}
|
|
await cdp.send('Input.dispatchTouchEvent', {
|
|
type: 'touchStart',
|
|
touchPoints: [{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }]
|
|
})
|
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
|
} finally {
|
|
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: false })
|
|
await cdp.detach()
|
|
}
|
|
}
|
|
await pathField.click()
|
|
await clickToastControl(customToast.getByRole('button', { name: 'Dismiss', exact: true }))
|
|
await expect(customToast).toBeHidden()
|
|
await expect(page.getByRole('dialog', { name: /Open host project/i })).toBeVisible()
|
|
await expect(pathField).toHaveValue(missingPath)
|
|
expect(await pathField.evaluate((input) => input === document.activeElement)).toBe(true)
|
|
}
|
|
// A toast raised while the modal is open comes forward, so its actions run above it.
|
|
await client.app.evaluate(({ BrowserWindow }) => {
|
|
BrowserWindow.getAllWindows()[0].webContents.send('star-nag:show', {
|
|
mode: 'web',
|
|
surface: 'toast'
|
|
})
|
|
})
|
|
const raisedToast = page
|
|
.locator('[data-sonner-toast]')
|
|
.filter({ hasText: 'Onboarding completed!' })
|
|
await expect(raisedToast).toBeVisible()
|
|
await pathField.click()
|
|
await raisedToast.getByRole('button', { name: 'Later', exact: true }).click()
|
|
await expect(raisedToast).toBeHidden()
|
|
await expect(page.getByRole('dialog', { name: /Open host project/i })).toBeVisible()
|
|
await expect(pathField).toHaveValue(missingPath)
|
|
expect(await pathField.evaluate((input) => input === document.activeElement)).toBe(true)
|
|
await page.screenshot({
|
|
path: testInfo.outputPath(`toast-dismissed-modal-preserved-${theme}.png`),
|
|
animations: 'disabled'
|
|
})
|
|
|
|
// A real action-toast renderer, fed a synthetic notification in this disposable client only.
|
|
await client.app.evaluate(({ ipcMain, BrowserWindow }) => {
|
|
ipcMain.removeHandler('mobile:consumePendingUnpairedDeviceAuthFailure')
|
|
ipcMain.handle('mobile:consumePendingUnpairedDeviceAuthFailure', () => true)
|
|
BrowserWindow.getAllWindows()[0].webContents.send('mobile:unpairedDeviceAuthFailure')
|
|
})
|
|
const actionToast = page
|
|
.locator('[data-sonner-toast]')
|
|
.filter({ hasText: 'A device tried to connect but is not paired' })
|
|
const action = actionToast.getByRole('button', { name: 'Open Mobile Settings' })
|
|
await expect(action).toBeVisible()
|
|
await actionToast.locator('[data-title]').click()
|
|
await expect(actionToast).toBeVisible()
|
|
await expect(page.getByRole('dialog', { name: /Open host project/i })).toBeVisible()
|
|
await expect(page.getByPlaceholder('Search settings')).toBeHidden()
|
|
await expect(pathField).toHaveValue(missingPath)
|
|
expect(await pathField.evaluate((input) => input === document.activeElement)).toBe(true)
|
|
|
|
// Credential prompts deliberately use a higher tier; this visibility fix does not cross it.
|
|
await page.evaluate(() =>
|
|
window.__store?.getState().enqueueSshCredentialRequest({
|
|
requestId: 'toast-layer-fixture',
|
|
targetId: 'Simulated SSH prompt',
|
|
kind: 'password',
|
|
detail: 'Layering fixture only'
|
|
})
|
|
)
|
|
await expect(page.getByRole('dialog', { name: 'SSH Password' })).toBeVisible()
|
|
await page.screenshot({
|
|
path: testInfo.outputPath(`higher-modal-residual-${theme}.png`),
|
|
animations: 'disabled'
|
|
})
|
|
expect(
|
|
await page
|
|
.locator('[data-slot="dialog-overlay"]')
|
|
.evaluateAll((overlays) =>
|
|
Math.max(...overlays.map((overlay) => Number(getComputedStyle(overlay).zIndex)))
|
|
)
|
|
).toBe(140)
|
|
await expect(
|
|
actionToast.locator('[data-close-button]').click({ trial: true, timeout: 300 })
|
|
).rejects.toThrow()
|
|
await expect(page.getByRole('dialog', { name: 'SSH Password' })).toBeVisible()
|
|
await page.evaluate(() =>
|
|
window.__store?.getState().removeSshCredentialRequest('toast-layer-fixture')
|
|
)
|
|
await expect(pathField).toHaveValue(missingPath)
|
|
await page
|
|
.getByRole('dialog', { name: /Open host project/i })
|
|
.getByRole('button', { name: 'Close', exact: true })
|
|
.click()
|
|
await expect(page.locator('[data-slot="dialog-overlay"]')).toHaveCount(0)
|
|
const toaster = page.locator('[data-sonner-toaster]')
|
|
await expect(toaster).toHaveCSS('z-index', '47')
|
|
|
|
await page.evaluate(async () => {
|
|
await window.__store?.getState().updateSettings({ experimentalAgentDashboardPopout: true })
|
|
})
|
|
await page.evaluate(() => window.__store?.getState().setAgentDashboardDrawerOpen(true))
|
|
await expect(page.locator('[data-agent-dashboard-sheet]')).toBeVisible()
|
|
await expect(page.locator('[data-slot="sheet-overlay"]')).toHaveCount(0)
|
|
await expect(toaster).toHaveCSS('z-index', '47')
|
|
await page.screenshot({
|
|
path: testInfo.outputPath(`nonmodal-drawer-${theme}.png`),
|
|
animations: 'disabled'
|
|
})
|
|
await page.evaluate(() => window.__store?.getState().setAgentDashboardDrawerOpen(false))
|
|
// Scroll-lock alone must not lift notifications over nonmodal surfaces.
|
|
await page.evaluate(() => document.body.setAttribute('data-scroll-locked', '1'))
|
|
await expect(toaster).toHaveCSS('z-index', '47')
|
|
await page.evaluate(() => document.body.removeAttribute('data-scroll-locked'))
|
|
await action.click()
|
|
await expect(page.getByPlaceholder('Search settings')).toBeVisible()
|
|
await assertHiddenIsolation(client.app, testInfo, 'client-final-isolation')
|
|
await assertHiddenIsolation(electronApp, testInfo, 'host-final-isolation')
|
|
expect(await client.getDirectSshAttemptTargetIds()).toEqual([])
|
|
expect(await orcaPage.evaluate(() => window.api.repos.list())).toEqual([])
|
|
} finally {
|
|
await client.dispose()
|
|
}
|
|
})
|
|
}
|
|
|
|
async function showUnpairedDeviceToast(app: ElectronApplication): Promise<void> {
|
|
// A real persistent action toast, fed a synthetic notification.
|
|
await app.evaluate(({ ipcMain, BrowserWindow }) => {
|
|
ipcMain.removeHandler('mobile:consumePendingUnpairedDeviceAuthFailure')
|
|
ipcMain.handle('mobile:consumePendingUnpairedDeviceAuthFailure', () => true)
|
|
BrowserWindow.getAllWindows()[0].webContents.send('mobile:unpairedDeviceAuthFailure')
|
|
})
|
|
}
|
|
|
|
type RecordedTelemetryEvent = { name: unknown; props: unknown }
|
|
|
|
declare global {
|
|
// Test-only recorder installed in the Electron main process by recordTelemetry.
|
|
var __toastTelemetry: RecordedTelemetryEvent[] | undefined
|
|
}
|
|
|
|
async function recordTelemetry(app: ElectronApplication): Promise<void> {
|
|
await app.evaluate(({ ipcMain }) => {
|
|
const events: RecordedTelemetryEvent[] = []
|
|
globalThis.__toastTelemetry = events
|
|
ipcMain.removeHandler('telemetry:track')
|
|
ipcMain.handle('telemetry:track', (_event, name: unknown, props: unknown) => {
|
|
events.push({ name, props })
|
|
return true
|
|
})
|
|
})
|
|
}
|
|
|
|
async function recordedToastActions(app: ElectronApplication): Promise<unknown[]> {
|
|
const events = await app.evaluate(() => globalThis.__toastTelemetry ?? [])
|
|
return events
|
|
.filter((event) => event.name === 'toast_action_over_modal')
|
|
.map((event) => event.props)
|
|
}
|
|
|
|
async function toasterZIndex(page: Page): Promise<number> {
|
|
return page
|
|
.locator('[data-sonner-toaster]')
|
|
.evaluate((toaster) => Number(getComputedStyle(toaster).zIndex))
|
|
}
|
|
|
|
async function pressAt(page: Page, target: Locator): Promise<void> {
|
|
const box = await target.boundingBox()
|
|
if (!box) {
|
|
throw new Error('Missing press target')
|
|
}
|
|
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2)
|
|
}
|
|
|
|
for (const theme of ['dark', 'light'] as const) {
|
|
test(`toasts behind a settings dialog come forward on a press and keep its draft (${theme})`, async ({
|
|
electronApp,
|
|
orcaPage: page
|
|
}) => {
|
|
await recordTelemetry(electronApp)
|
|
await page.evaluate(async (theme) => {
|
|
const store = window.__store!.getState()
|
|
await store.updateSettings({ theme })
|
|
store.openSettingsPage()
|
|
store.openSettingsTarget({ pane: 'browser', repoId: null })
|
|
}, theme)
|
|
const unpairedToast = page
|
|
.locator('[data-sonner-toast]')
|
|
.filter({ hasText: 'A device tried to connect but is not paired' })
|
|
const action = unpairedToast.getByRole('button', { name: 'Open Mobile Settings' })
|
|
const mobilePane = page.getByText('Control terminals and agents from your phone.')
|
|
const dialog = page.getByRole('dialog', { name: 'New Browser Profile' })
|
|
const nameField = dialog.getByRole('textbox')
|
|
|
|
// An older toast drops behind the modal's backdrop: visible but dimmed, and nothing in it is live.
|
|
await showUnpairedDeviceToast(electronApp)
|
|
await expect(action).toBeVisible()
|
|
await page.getByRole('button', { name: 'Add Profile', exact: true }).click()
|
|
await expect(dialog).toBeVisible()
|
|
await nameField.fill('Draft profile name')
|
|
await expect.poll(() => toasterZIndex(page)).toBe(49)
|
|
await expect(
|
|
unpairedToast.locator('[data-close-button]').click({ trial: true, timeout: 300 })
|
|
).rejects.toThrow()
|
|
|
|
// A press on the dimmed toast brings it forward without dismissing the modal or moving focus.
|
|
await pressAt(page, unpairedToast.locator('[data-title]'))
|
|
await expect.poll(() => toasterZIndex(page)).toBe(60)
|
|
await expect(dialog).toBeVisible()
|
|
await expect(nameField).toHaveValue('Draft profile name')
|
|
await expect(nameField).toBeFocused()
|
|
await unpairedToast.locator('[data-close-button]').click()
|
|
await expect(unpairedToast).toHaveCount(0)
|
|
await expect(dialog).toBeVisible()
|
|
await expect(nameField).toHaveValue('Draft profile name')
|
|
await expect(nameField).toBeFocused()
|
|
|
|
// Each modal starts lowered; a toast raised while it is open comes forward by itself.
|
|
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click()
|
|
await expect(dialog).toBeHidden()
|
|
await page.getByRole('button', { name: 'Add Profile', exact: true }).click()
|
|
await expect(dialog).toBeVisible()
|
|
await nameField.fill('Second draft')
|
|
await showUnpairedDeviceToast(electronApp)
|
|
await expect(action).toBeVisible()
|
|
await expect.poll(() => toasterZIndex(page)).toBe(60)
|
|
await expect(nameField).toBeFocused()
|
|
|
|
// The escape hatch: a navigating action runs above the modal, and is counted.
|
|
expect(await recordedToastActions(electronApp)).toEqual([])
|
|
await action.click()
|
|
await expect(mobilePane).toBeVisible()
|
|
const tracked = await recordedToastActions(electronApp)
|
|
expect(tracked).toEqual([
|
|
{ toast_id: 'unpaired-device-auth-failure', modal_kind: 'dialog', raised_by: 'new_toast' }
|
|
])
|
|
expect(eventSchemas.toast_action_over_modal.safeParse(tracked[0]).success).toBe(true)
|
|
})
|
|
}
|