Files
stablyai--orca/tests/e2e/droid-notification.spec.ts
2026-07-13 13:05:33 +08:00

447 lines
14 KiB
TypeScript

import { test, expect } from './helpers/orca-app'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { getRendererTitleLog, installRendererTitleLog } from './helpers/terminal-title-log'
import {
sendToTerminal,
waitForActivePaneHookDescriptor,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
emitCodexHookStatus,
emitGrokHookPayload,
readHookEndpoint
} from './helpers/agent-hook-endpoint'
type NotificationDispatch = {
source?: string
terminalTitle?: string
paneKey?: string
isActiveWorktree?: boolean
agentType?: string
agentPrompt?: string
agentLastAssistantMessage?: string
}
type AgentStatusSummary = {
paneKey: string
state: string
agentType?: string
prompt?: string
lastAssistantMessage?: string
}
async function emitOscTitle(page: Page, ptyId: string, title: string) {
await sendToTerminal(page, ptyId, `printf '\\033]0;${title}\\007'\r`)
}
async function installMainProcessNotificationDispatchSpy(app: ElectronApplication): Promise<void> {
await app.evaluate(({ ipcMain }) => {
const g = globalThis as unknown as {
__notificationDispatchLog?: NotificationDispatch[]
__notificationDispatchSpyInstalled?: boolean
}
if (g.__notificationDispatchSpyInstalled) {
return
}
g.__notificationDispatchLog = []
g.__notificationDispatchSpyInstalled = true
ipcMain.removeHandler('notifications:dispatch')
ipcMain.handle('notifications:dispatch', (_event: unknown, args: NotificationDispatch) => {
g.__notificationDispatchLog!.push(args)
return { delivered: true }
})
})
}
async function getNotificationDispatches(
app: ElectronApplication
): Promise<NotificationDispatch[]> {
return app.evaluate(() => {
const g = globalThis as unknown as { __notificationDispatchLog?: NotificationDispatch[] }
return g.__notificationDispatchLog ?? []
})
}
async function switchToOtherExistingWorktree(page: Page): Promise<string> {
return page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
}
const state = store.getState()
const activeWorktreeId = state.activeWorktreeId
if (!activeWorktreeId) {
throw new Error('No active worktree')
}
const activeWorktree = Object.values(state.worktreesByRepo)
.flat()
.find((worktree) => worktree.id === activeWorktreeId)
if (!activeWorktree) {
throw new Error(`Active worktree ${activeWorktreeId} not found`)
}
const otherWorktree =
Object.values(state.worktreesByRepo)
.flat()
.find(
(worktree) =>
worktree.repoId === activeWorktree.repoId &&
worktree.id !== activeWorktreeId &&
worktree.branch.replace(/^refs\/heads\//, '') === 'e2e-secondary'
) ??
Object.values(state.worktreesByRepo)
.flat()
.find(
(worktree) =>
worktree.repoId === activeWorktree.repoId && worktree.id !== activeWorktreeId
)
if (!otherWorktree) {
throw new Error(`No inactive worktree found for repo ${activeWorktree.repoId}`)
}
state.setActiveWorktree(otherWorktree.id)
return otherWorktree.id
})
}
async function getAgentStatuses(page: Page): Promise<AgentStatusSummary[]> {
return page.evaluate(() => {
const store = window.__store
if (!store) {
return []
}
return Object.values(store.getState().agentStatusByPaneKey ?? {}).map((entry) => ({
paneKey: entry.paneKey,
state: entry.state,
agentType: entry.agentType,
prompt: entry.prompt,
lastAssistantMessage: entry.lastAssistantMessage
}))
})
}
async function getCachedAgentStatuses(page: Page): Promise<AgentStatusSummary[]> {
return page.evaluate(async () => {
const snapshot = await window.api.agentStatus.getSnapshot()
return snapshot.map((entry) => ({
paneKey: entry.paneKey,
state: entry.state,
agentType: entry.agentType,
prompt: entry.prompt,
lastAssistantMessage: entry.lastAssistantMessage
}))
})
}
async function getRendererOrCachedAgentStatuses(page: Page): Promise<AgentStatusSummary[]> {
const [rendererStatuses, cachedStatuses] = await Promise.all([
getAgentStatuses(page),
getCachedAgentStatuses(page)
])
return [...rendererStatuses, ...cachedStatuses]
}
async function isWorktreeUnread(page: Page, worktreeId: string): Promise<boolean> {
return page.evaluate((targetWorktreeId) => {
const store = window.__store
if (!store) {
return false
}
return (
Object.values(store.getState().worktreesByRepo)
.flat()
.find((worktree) => worktree.id === targetWorktreeId)?.isUnread === true
)
}, worktreeId)
}
test.describe('Droid notifications', () => {
test('Codex hook completion dispatches while its worktree is inactive', async ({
orcaPage,
electronApp
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await installMainProcessNotificationDispatchSpy(electronApp)
const endpoint = await readHookEndpoint(electronApp)
// Why: the synthetic hook bypasses the shell startup path; wait for a
// responsive PTY so the notification liveness gate can observe the turn.
const ptyId = await waitForActivePanePtyId(orcaPage)
const readyMarker = `__CODEX_HOOK_NOTIFY_READY_${Date.now()}__`
await sendToTerminal(orcaPage, ptyId, `printf '${readyMarker}\\n'\r`)
await waitForTerminalOutput(orcaPage, readyMarker)
const { paneKey, worktreeId } = await waitForActivePaneHookDescriptor(orcaPage)
const prompt = `codex-hook-notify-${Date.now()}`
await emitCodexHookStatus(endpoint, {
paneKey,
worktreeId,
state: 'working',
prompt
})
await expect
.poll(
async () =>
(await getRendererOrCachedAgentStatuses(orcaPage)).some(
(status) =>
status.agentType === 'codex' && status.state === 'working' && status.prompt === prompt
),
{
timeout: 30_000,
// Why: this synthetic hook posts directly to main; main's cache is
// the durable source used to recover renderer startup/listener races.
message: 'Codex UserPromptSubmit hook did not reach agent status cache'
}
)
.toBe(true)
await switchToOtherExistingWorktree(orcaPage)
const finalMessage = `Codex hook completed ${Date.now()}`
await emitCodexHookStatus(endpoint, {
paneKey,
worktreeId,
state: 'done',
prompt,
lastAssistantMessage: finalMessage
})
await expect
.poll(
async () =>
(await getAgentStatuses(orcaPage)).some(
(status) =>
status.agentType === 'codex' &&
status.state === 'done' &&
status.prompt === prompt &&
status.lastAssistantMessage === finalMessage
),
{
timeout: 30_000,
message: 'Codex Stop hook did not reach renderer agent status'
}
)
.toBe(true)
await expect
.poll(
async () => {
const dispatches = await getNotificationDispatches(electronApp)
return dispatches.filter((dispatch) => dispatch.source === 'agent-task-complete')
},
{
timeout: 30_000,
message: 'Codex hook Stop did not dispatch task-complete while worktree was inactive'
}
)
.toEqual([
expect.objectContaining({
source: 'agent-task-complete',
terminalTitle: 'codex',
isActiveWorktree: false,
agentType: 'codex',
agentPrompt: prompt,
agentLastAssistantMessage: finalMessage
})
])
await expect
.poll(async () => isWorktreeUnread(orcaPage, worktreeId), {
timeout: 10_000,
message: 'Codex hook Stop did not mark the inactive worktree unread'
})
.toBe(true)
})
test('Grok routine permission prompt hooks stay working and do not notify', async ({
orcaPage,
electronApp
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await installMainProcessNotificationDispatchSpy(electronApp)
const endpoint = await readHookEndpoint(electronApp)
const ptyId = await waitForActivePanePtyId(orcaPage)
const readyMarker = `__GROK_HOOK_NOTIFY_READY_${Date.now()}__`
await sendToTerminal(orcaPage, ptyId, `printf '${readyMarker}\\n'\r`)
await waitForTerminalOutput(orcaPage, readyMarker)
const { paneKey, worktreeId } = await waitForActivePaneHookDescriptor(orcaPage)
const prompt = `grok-hook-notify-${Date.now()}`
await emitGrokHookPayload(endpoint, {
paneKey,
worktreeId,
payload: {
hookEventName: 'user_prompt_submit',
prompt
}
})
await expect
.poll(
async () =>
(await getAgentStatuses(orcaPage)).some(
(status) =>
status.agentType === 'grok' && status.state === 'working' && status.prompt === prompt
),
{
timeout: 30_000,
message: 'Grok UserPromptSubmit hook did not reach renderer agent status'
}
)
.toBe(true)
await emitGrokHookPayload(endpoint, {
paneKey,
worktreeId,
payload: {
hookEventName: 'pre_tool_use',
toolName: 'Shell',
toolInput: { command: 'echo hi' }
}
})
await emitGrokHookPayload(endpoint, {
paneKey,
worktreeId,
payload: {
hookEventName: 'notification',
notificationType: 'permission_prompt',
message: 'Tool permission requested',
level: 'info'
}
})
await orcaPage.waitForTimeout(500)
expect(
(await getAgentStatuses(orcaPage)).some(
(status) =>
status.agentType === 'grok' && status.prompt === prompt && status.state === 'waiting'
)
).toBe(false)
expect(
(await getNotificationDispatches(electronApp)).filter(
(dispatch) => dispatch.source === 'agent-task-complete'
)
).toEqual([])
const finalMessage = `Grok hook completed ${Date.now()}`
await emitGrokHookPayload(endpoint, {
paneKey,
worktreeId,
payload: {
hookEventName: 'stop',
lastAssistantMessage: finalMessage
}
})
await expect
.poll(
async () =>
(await getAgentStatuses(orcaPage)).some(
(status) =>
status.agentType === 'grok' &&
status.state === 'done' &&
status.prompt === prompt &&
status.lastAssistantMessage === finalMessage
),
{
timeout: 30_000,
message: 'Grok Stop hook did not reach renderer agent status'
}
)
.toBe(true)
})
test('recognized agent title completion dispatches one task-complete notification', async ({
orcaPage,
electronApp
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
// Why: contextBridge freezes window.api, so notification invokes must be
// observed in Electron's main process rather than monkey-patched renderer-side.
await installMainProcessNotificationDispatchSpy(electronApp)
await installRendererTitleLog(orcaPage)
const ptyId = await waitForActivePanePtyId(orcaPage)
const marker = `__CODEX_NOTIFY_READY_${Date.now()}__`
await sendToTerminal(orcaPage, ptyId, `printf '${marker}\\n'\r`)
await waitForTerminalOutput(orcaPage, marker)
await emitOscTitle(orcaPage, ptyId, 'Codex working')
await expect
.poll(async () => (await getRendererTitleLog(orcaPage)).includes('Codex working'), {
timeout: 10_000,
message: 'Codex working title did not reach the renderer before completion'
})
.toBe(true)
await emitOscTitle(orcaPage, ptyId, 'Codex done')
await expect
.poll(async () => (await getRendererTitleLog(orcaPage)).includes('Codex done'), {
timeout: 10_000,
message: 'Codex done title did not reach the renderer'
})
.toBe(true)
await expect
.poll(
async () => {
const dispatches = await getNotificationDispatches(electronApp)
return dispatches.filter((dispatch) => dispatch.source === 'agent-task-complete')
},
{
timeout: 10_000,
message: 'Codex working->done title transition did not dispatch task-complete'
}
)
.toEqual([
expect.objectContaining({ source: 'agent-task-complete', terminalTitle: 'Codex done' })
])
})
test('Factory Droid needs-input native title does not dispatch a task-complete notification', async ({
orcaPage,
electronApp
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
// Why: contextBridge freezes window.api, so notification invokes must be
// observed in Electron's main process rather than monkey-patched renderer-side.
await installMainProcessNotificationDispatchSpy(electronApp)
await installRendererTitleLog(orcaPage)
const ptyId = await waitForActivePanePtyId(orcaPage)
const marker = `__DROID_NOTIFY_READY_${Date.now()}__`
await sendToTerminal(orcaPage, ptyId, `printf '${marker}\\n'\r`)
await waitForTerminalOutput(orcaPage, marker)
await emitOscTitle(orcaPage, ptyId, '⠋ Droid')
await emitOscTitle(orcaPage, ptyId, 'Factory Droid needs input')
await expect
.poll(
async () => (await getRendererTitleLog(orcaPage)).includes('Factory Droid needs input'),
{
timeout: 10_000,
message: 'Factory Droid marker title did not land'
}
)
.toBe(true)
// Why: Factory Droid can show this title while Execute is still running
// (for example `sleep 180`); hook events own Droid status, not this title.
await orcaPage.waitForTimeout(500)
const dispatches = await getNotificationDispatches(electronApp)
expect(dispatches).toEqual([])
})
})