Files
orca/tests/e2e/add-project-error-toast-stacking.spec.ts
Neil b30ab35992 fix(ui): toasts sit behind a modal's backdrop until pressed, instead of looking live but dead (#36888)
* 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
2026-11-01 01:54:07 -07:00

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)
})
}