mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
test: remove low-value dashboard and preview tests (#38362)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -289,7 +289,6 @@
|
||||
1 test-no-real-time-wait:src/main/wsl/orcad/wsl-orcad-stdio-bridge.test.ts
|
||||
1 test-no-real-time-wait:src/renderer/src/components/UpdateCard.error-card.test.tsx
|
||||
1 test-no-real-time-wait:src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx
|
||||
1 test-no-real-time-wait:src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx
|
||||
1 test-no-real-time-wait:src/renderer/src/components/editor/DiagramViewerModal.test.tsx
|
||||
1 test-no-real-time-wait:src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-stale-analysis-work-budget.test.tsx
|
||||
1 test-no-real-time-wait:src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx
|
||||
|
||||
@@ -6898,7 +6898,6 @@
|
||||
"src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts": 208,
|
||||
"src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx": 425,
|
||||
"src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx": 1004,
|
||||
"src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx": 841,
|
||||
"src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx": 935,
|
||||
"src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx": 2983,
|
||||
"src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx": 2678,
|
||||
@@ -6917,7 +6916,6 @@
|
||||
"src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx": 432,
|
||||
"src/renderer/src/components/dashboard/AgentDashboardDrawer.focus.test.tsx": 2567,
|
||||
"src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx": 1981,
|
||||
"src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx": 2350,
|
||||
"src/renderer/src/components/dashboard/agent-dashboard-open-mode.test.ts": 48,
|
||||
"src/renderer/src/components/dashboard/agent-destination-navigation.integration.test.tsx": 3232,
|
||||
"src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts": 107,
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
|
||||
import type { RepoIcon } from '../../../../shared/repo-icon'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { AgentKanbanCard } from './AgentKanbanCard'
|
||||
|
||||
const agentIconRender = vi.fn()
|
||||
const agentStateDotRender = vi.fn()
|
||||
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
AgentIcon: () => {
|
||||
agentIconRender()
|
||||
return <span data-testid="agent-icon" />
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/AgentStateDot', () => ({
|
||||
AgentStateDot: ({ state }: { state: string }) => {
|
||||
agentStateDotRender(state)
|
||||
return <span data-testid="state-dot" />
|
||||
}
|
||||
}))
|
||||
|
||||
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
|
||||
return {
|
||||
paneKey: 'tab:leaf',
|
||||
ptyId: 'pty-1',
|
||||
agentType: 'claude',
|
||||
bucket: 'working',
|
||||
dotState: 'working',
|
||||
task: 'Review the change',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'worktree-1',
|
||||
tabId: 'tab',
|
||||
leafId: 'leaf',
|
||||
repoName: 'Orca',
|
||||
worktreeName: 'dashboard-review',
|
||||
startedAt: 1_000,
|
||||
finishedAt: null,
|
||||
stateChangedAt: 1_000,
|
||||
unseen: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard(props: {
|
||||
card: DashboardCard
|
||||
now: number
|
||||
repoIcon?: RepoIcon | null
|
||||
onOpenTerminal?: () => void
|
||||
}): ReturnType<typeof render> {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={props.card}
|
||||
repoIcon={props.repoIcon}
|
||||
now={props.now}
|
||||
onOpenTerminal={props.onOpenTerminal ?? vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AgentKanbanCard', () => {
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('rerenders on a verdict-only change without a clean completion tint', () => {
|
||||
const initial = card({ dotState: 'done', bucket: 'idle' })
|
||||
const onOpenTerminal = vi.fn()
|
||||
const { container, rerender } = renderCard({ card: initial, now: 2000, onOpenTerminal })
|
||||
for (const verdict of ['interrupted', 'failed', 'unconfirmed'] as const) {
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={{ ...initial, verdict }}
|
||||
now={2000}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(agentStateDotRender).toHaveBeenLastCalledWith(verdict)
|
||||
expect(container.firstElementChild?.className).not.toContain('emerald')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not render an invented age when the start time is unknown', () => {
|
||||
renderCard({ card: card({ startedAt: 0 }), now: 2_000_000_000 })
|
||||
|
||||
expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the question glyph once when a summary is available', () => {
|
||||
const attentionCard = card({
|
||||
bucket: 'attention',
|
||||
dotState: 'waiting',
|
||||
askSummary: 'Approve deploy?',
|
||||
verdict: 'failed'
|
||||
})
|
||||
const { container, rerender } = renderCard({ card: attentionCard, now: 2_000 })
|
||||
|
||||
expect(screen.queryByTestId('state-dot')).not.toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.lucide-message-circle-question-mark')).toHaveLength(1)
|
||||
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={{ ...attentionCard, askSummary: undefined }}
|
||||
now={2_000}
|
||||
onOpenTerminal={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(screen.getByTestId('state-dot')).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-message-circle-question-mark')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the saved SSH host beside the repository metadata', () => {
|
||||
const { container } = renderCard({
|
||||
card: card({
|
||||
hostKind: 'ssh',
|
||||
executionHostId: 'ssh:opaque-target',
|
||||
hostLabel: 'openclaw'
|
||||
}),
|
||||
now: 2_000
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('SSH host · openclaw')).toHaveAttribute(
|
||||
'data-dashboard-host-badge',
|
||||
'ssh'
|
||||
)
|
||||
expect(container.querySelector('.lucide-server')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows review metadata and expands grouped subagents without opening the terminal', () => {
|
||||
const onOpenTerminal = vi.fn()
|
||||
renderCard({
|
||||
card: card({
|
||||
review: { number: 11012, state: 'open' },
|
||||
subagents: [
|
||||
{ id: 'child-1', name: 'Review loop', dotState: 'working' },
|
||||
{ id: 'child-2', name: 'Smoke tests', dotState: 'done' }
|
||||
]
|
||||
}),
|
||||
now: 2_000,
|
||||
onOpenTerminal
|
||||
})
|
||||
|
||||
expect(screen.getByText('#11012')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'Open review #11012' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Review loop')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 subagents' }))
|
||||
expect(screen.getByText('Review loop')).toBeInTheDocument()
|
||||
expect(screen.getByText('Smoke tests')).toBeInTheDocument()
|
||||
expect(onOpenTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the terminal from the footer while keeping subagent disclosure isolated', () => {
|
||||
const onOpenTerminal = vi.fn()
|
||||
renderCard({
|
||||
card: card({
|
||||
conversationName: 'Dashboard review',
|
||||
review: { number: 11042, state: 'open' },
|
||||
subagents: [{ id: 'child-1', name: 'Review loop', dotState: 'working' }]
|
||||
}),
|
||||
now: 61_000,
|
||||
onOpenTerminal
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('#11042'))
|
||||
expect(onOpenTerminal).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '1 subagent' }))
|
||||
expect(onOpenTerminal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('labels one subagent accessibly and never renders a workspace-status dot', () => {
|
||||
renderCard({
|
||||
card: card({
|
||||
workspaceStatusId: 'in-review',
|
||||
workspaceStatusLabel: 'In review',
|
||||
workspaceStatusColor: 'emerald',
|
||||
subagents: [{ id: 'child-1', name: 'Review loop', dotState: 'working' }]
|
||||
}),
|
||||
now: 2_000
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: '1 subagent' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('img', { name: 'In review' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tints unseen Done green and keeps acknowledged Done neutral as Idle', () => {
|
||||
const { container: attention } = renderCard({
|
||||
card: card({ bucket: 'attention', dotState: 'waiting' }),
|
||||
now: 2_000
|
||||
})
|
||||
expect(attention.firstElementChild?.className).toContain('border-agent-question/40')
|
||||
|
||||
cleanup()
|
||||
const { container: done } = renderCard({
|
||||
card: card({ bucket: 'done', dotState: 'done', unseen: true }),
|
||||
now: 2_000
|
||||
})
|
||||
expect(done.firstElementChild?.className).toContain('border-emerald-500/40')
|
||||
|
||||
cleanup()
|
||||
const { container: idle } = renderCard({
|
||||
card: card({ bucket: 'idle', dotState: 'done', unseen: false }),
|
||||
now: 2_000
|
||||
})
|
||||
const idleClassName = idle.firstElementChild?.className ?? ''
|
||||
expect(idleClassName).toContain('border-border/60')
|
||||
expect(idleClassName).not.toContain('emerald')
|
||||
expect(idleClassName).not.toContain('agent-question')
|
||||
})
|
||||
|
||||
it('heads the card with the conversation name and drops the worktree to the footer', () => {
|
||||
const { container } = renderCard({
|
||||
card: card({ lastUserMessage: 'ship it', conversationName: 'Sparse-checkout parser' }),
|
||||
now: 2_000
|
||||
})
|
||||
|
||||
const cardElement = container.firstElementChild!
|
||||
const header = cardElement.querySelector('button')!.firstElementChild!
|
||||
const footer = cardElement.lastElementChild!
|
||||
expect(header).toHaveTextContent('Sparse-checkout parser')
|
||||
expect(header).not.toHaveTextContent('dashboard-review')
|
||||
expect(footer).toHaveTextContent('dashboard-review')
|
||||
expect(screen.getByText('You')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('heads the card with the worktree when no name resolves, without repeating it', () => {
|
||||
const { container } = renderCard({ card: card({ lastUserMessage: 'ship it' }), now: 2_000 })
|
||||
|
||||
expect(screen.getAllByText('dashboard-review')).toHaveLength(1)
|
||||
expect(container.querySelector('button')!.firstElementChild).toHaveTextContent(
|
||||
'dashboard-review'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the repo as an icon labelled with its name instead of inline text', () => {
|
||||
renderCard({
|
||||
card: card(),
|
||||
now: 2_000,
|
||||
repoIcon: { type: 'emoji', emoji: '🐳' }
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('Orca')).toBeInTheDocument()
|
||||
expect(screen.getByText('🐳')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips structured-clone rerenders until visible card data or its age changes', () => {
|
||||
const onOpenTerminal = vi.fn()
|
||||
const initial = card({
|
||||
startedAt: 1_000,
|
||||
subagents: [{ id: 'child-1', name: 'Review loop', dotState: 'working' }]
|
||||
})
|
||||
const repoIcon: RepoIcon = { type: 'lucide', name: 'Rocket' }
|
||||
const { rerender } = render(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={initial}
|
||||
repoIcon={repoIcon}
|
||||
now={61_500}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(agentIconRender).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText('1m')).toBeInTheDocument()
|
||||
|
||||
// A fresh structured clone of identical data — including the repo icon.
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={{ ...initial, subagents: initial.subagents?.map((subagent) => ({ ...subagent })) }}
|
||||
repoIcon={{ ...repoIcon }}
|
||||
now={62_000}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(agentIconRender).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={{ ...initial, subagents: initial.subagents?.map((subagent) => ({ ...subagent })) }}
|
||||
repoIcon={{ ...repoIcon }}
|
||||
now={121_500}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(agentIconRender).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByText('2m')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rerenders when a working card enters monitoring', () => {
|
||||
const initial = card()
|
||||
const { rerender } = renderCard({ card: initial, now: 2_000 })
|
||||
expect(agentStateDotRender).toHaveBeenLastCalledWith('working')
|
||||
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={{ ...initial, workingMode: 'monitoring' }}
|
||||
now={2_000}
|
||||
onOpenTerminal={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
expect(agentStateDotRender).toHaveBeenLastCalledWith('monitoring')
|
||||
})
|
||||
|
||||
it('rerenders when the repo icon changes', () => {
|
||||
const onOpenTerminal = vi.fn()
|
||||
const initial = card({ startedAt: 1_000 })
|
||||
const { rerender } = render(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={initial}
|
||||
repoIcon={{ type: 'lucide', name: 'Rocket' }}
|
||||
now={61_500}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(agentIconRender).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={{ ...initial }}
|
||||
repoIcon={{ type: 'lucide', name: 'Database' }}
|
||||
now={61_500}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(agentIconRender).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('updates the relative age when the UI language changes', async () => {
|
||||
renderCard({ card: card({ startedAt: 1_000 }), now: 121_500 })
|
||||
expect(screen.getByText('2m')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage('ja')
|
||||
})
|
||||
|
||||
expect(screen.getByText('2分')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -37,15 +37,6 @@ vi.mock('./AgentTerminalPreview', () => ({
|
||||
)
|
||||
}))
|
||||
|
||||
const TERMINAL_INPUT: DashboardCardTerminalInput = {
|
||||
hostPlatform: 'win32',
|
||||
localWindowsConpty: true,
|
||||
osRelease: '10.0.22631',
|
||||
windowsShiftEnterEncoding: 'csi-u',
|
||||
ctrlEnterCsiU: false,
|
||||
kittyKeyboardAdvertised: false
|
||||
}
|
||||
|
||||
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
|
||||
return {
|
||||
paneKey: 'tab1:leaf1',
|
||||
@@ -74,121 +65,6 @@ afterEach(() => {
|
||||
|
||||
// This prop is the only path carrying relayed host-input facts into the preview; omission silently falls back to client-OS routing.
|
||||
describe('AgentTerminalDialog', () => {
|
||||
it("hands the card's relayed host-input profile to the preview terminal", () => {
|
||||
render(
|
||||
<AgentTerminalDialog
|
||||
card={card({ terminalInput: TERMINAL_INPUT })}
|
||||
onOpenChange={() => {}}
|
||||
onReveal={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('preview')).toHaveAttribute(
|
||||
'data-terminal-input',
|
||||
JSON.stringify(TERMINAL_INPUT)
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['interrupted', 'Interrupted'],
|
||||
['failed', 'Failed'],
|
||||
['unconfirmed', 'Couldn’t confirm']
|
||||
] as const)('keeps an acknowledged %s verdict in the terminal header', (verdict, label) => {
|
||||
const statusCard = { ...card({ dotState: 'done', bucket: 'idle', unseen: false }), verdict }
|
||||
render(<AgentTerminalPanel card={statusCard} onOpenChange={() => {}} onReveal={() => {}} />)
|
||||
expect(screen.getByText(new RegExp(label))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes null when the card carries no profile, so the preview routes by client OS', () => {
|
||||
render(<AgentTerminalDialog card={card()} onOpenChange={() => {}} onReveal={() => {}} />)
|
||||
|
||||
expect(screen.getByTestId('preview')).toHaveAttribute('data-terminal-input', 'null')
|
||||
})
|
||||
|
||||
it('does not claim a remote pane closed when the card carries no live pty', () => {
|
||||
render(
|
||||
<AgentTerminalDialog
|
||||
card={card({ ptyId: null, hostKind: 'ssh' })}
|
||||
onOpenChange={() => {}}
|
||||
onReveal={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
// Loss of contact with an SSH host is `unverifiable`, never `exited`.
|
||||
expect(screen.getByText(/remote session/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/pane has closed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a local agent openable without claiming its missing preview means closure', () => {
|
||||
render(
|
||||
<AgentTerminalDialog
|
||||
card={card({ ptyId: null, hostKind: 'local' })}
|
||||
onOpenChange={() => {}}
|
||||
onReveal={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText(/No preview available/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/pane has closed/)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Open worktree' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('labels acknowledged completions idle without review or pin controls', () => {
|
||||
render(
|
||||
<AgentTerminalDialog
|
||||
card={card({ bucket: 'idle', dotState: 'done', finishedAt: 100, unseen: false })}
|
||||
onOpenChange={() => {}}
|
||||
onReveal={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText(/Claude · Idle/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Keep visible' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Mark reviewed' })).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('preview')).toHaveAttribute('data-pty-id', 'pty-1')
|
||||
})
|
||||
|
||||
it('labels unseen completions done', () => {
|
||||
render(
|
||||
<AgentTerminalDialog
|
||||
card={card({ bucket: 'done', dotState: 'done', finishedAt: 100, unseen: true })}
|
||||
onOpenChange={() => {}}
|
||||
onReveal={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText(/Claude · Done/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves the execution host when revealing a colliding worktree ID', () => {
|
||||
const onReveal = vi.fn()
|
||||
render(
|
||||
<AgentTerminalDialog
|
||||
card={card({ executionHostId: 'runtime:env-1' })}
|
||||
onOpenChange={() => {}}
|
||||
onReveal={onReveal}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open worktree' }))
|
||||
expect(onReveal).toHaveBeenCalledWith({
|
||||
repoId: 'r1',
|
||||
worktreeId: 'w1',
|
||||
executionHostId: 'runtime:env-1',
|
||||
tabId: 'tab1',
|
||||
leafId: 'leaf1'
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses the terminal surface as a non-modal adjacent panel', () => {
|
||||
render(<AgentTerminalPanel card={card()} onOpenChange={() => {}} onReveal={() => {}} />)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'wt' })).toHaveAttribute('data-state', 'open')
|
||||
expect(screen.getByTestId('preview')).toHaveClass('min-h-0', 'flex-1')
|
||||
expect(document.querySelector('[data-slot="dialog-overlay"]')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'wt' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets a nested Radix layer consume Escape before closing the panel', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(
|
||||
|
||||
-240
@@ -14,7 +14,6 @@ import {
|
||||
BRACKETED_PASTE_START
|
||||
} from '../../../../shared/terminal/input/terminal-bracketed-paste-text'
|
||||
import { dispatchAppMenuPasteEvent } from '@/lib/app-menu-paste'
|
||||
import { dispatchAppMenuSelectionAction } from '@/lib/app-menu-selection-actions'
|
||||
|
||||
const terminalHarness = vi.hoisted(() => ({
|
||||
instances: [] as {
|
||||
@@ -216,245 +215,6 @@ describe('AgentTerminalPreview clipboard routes', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('claims the app-menu paste event and pastes while the preview owns focus', async () => {
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
focusInsidePreview(view.container)
|
||||
|
||||
let claimed = false
|
||||
act(() => {
|
||||
claimed = dispatchAppMenuPasteEvent()
|
||||
})
|
||||
|
||||
// The claim is what keeps App-level paste out of xterm's hidden textarea.
|
||||
expect(claimed).toBe(true)
|
||||
await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text'))
|
||||
expect(input).toHaveBeenCalledWith('pty-1', 'clip-text')
|
||||
})
|
||||
|
||||
it('encodes a leading newline for a remote Windows Codex preview without submitting', async () => {
|
||||
readClipboardText.mockResolvedValueOnce('\nsecond line')
|
||||
const view = render(
|
||||
<AgentTerminalPreview
|
||||
ptyId="remote:windows-box@@pty-1"
|
||||
terminalInput={{
|
||||
hostPlatform: 'win32',
|
||||
localWindowsConpty: false,
|
||||
windowsShiftEnterEncoding: 'alt-enter',
|
||||
windowsInputRecordPasteNewline: 'alt-enter',
|
||||
ctrlEnterCsiU: false,
|
||||
kittyKeyboardAdvertised: false
|
||||
}}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
focusInsidePreview(view.container)
|
||||
|
||||
act(() => {
|
||||
dispatchAppMenuPasteEvent()
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(input).toHaveBeenCalledWith('remote:windows-box@@pty-1', '\x1b\rsecond line')
|
||||
)
|
||||
expect(terminal.input).toHaveBeenCalledWith('\x1b\rsecond line')
|
||||
expect(terminal.paste).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('claims app-menu selection actions while the preview owns focus', async () => {
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
terminal.selectionText = 'selected text'
|
||||
const focusTarget = focusInsidePreview(view.container, 'textarea')
|
||||
focusTarget.className = 'xterm-helper-textarea'
|
||||
|
||||
let selectAllClaimed = false
|
||||
let copyClaimed = false
|
||||
act(() => {
|
||||
selectAllClaimed = dispatchAppMenuSelectionAction('select-all')
|
||||
})
|
||||
act(() => {
|
||||
copyClaimed = dispatchAppMenuSelectionAction('copy')
|
||||
})
|
||||
|
||||
expect(selectAllClaimed).toBe(true)
|
||||
expect(copyClaimed).toBe(true)
|
||||
expect(terminal.selectAll).toHaveBeenCalledOnce()
|
||||
await waitFor(() => expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text'))
|
||||
})
|
||||
|
||||
it('leaves app-menu selection actions unclaimed for text controls inside the preview', async () => {
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
focusInsidePreview(view.container)
|
||||
|
||||
let claimed = true
|
||||
act(() => {
|
||||
claimed = dispatchAppMenuSelectionAction('select-all')
|
||||
})
|
||||
|
||||
// Unclaimed so the App-level handler performs the native selection action.
|
||||
expect(claimed).toBe(false)
|
||||
expect(terminal.selectAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a copy action with no terminal selection unclaimed', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
|
||||
let claimed = true
|
||||
act(() => {
|
||||
claimed = dispatchAppMenuSelectionAction('copy')
|
||||
})
|
||||
|
||||
expect(claimed).toBe(false)
|
||||
expect(writeTerminalClipboardText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the app-menu paste event unclaimed when focus is outside the preview', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
|
||||
let claimed = true
|
||||
await act(async () => {
|
||||
claimed = dispatchAppMenuPasteEvent()
|
||||
})
|
||||
|
||||
expect(claimed).toBe(false)
|
||||
expect(readClipboardText).not.toHaveBeenCalled()
|
||||
expect(terminalHarness.instances[0]!.paste).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops claiming app-menu clipboard events once unmounted', async () => {
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
focusInsidePreview(view.container)
|
||||
view.unmount()
|
||||
|
||||
expect(dispatchAppMenuPasteEvent()).toBe(false)
|
||||
expect(dispatchAppMenuSelectionAction('select-all')).toBe(false)
|
||||
})
|
||||
|
||||
it('pastes on plain Ctrl+V on Windows, where no Edit-menu accelerator ever fires', async () => {
|
||||
platformState.value = 'win32'
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
focusInsidePreview(view.container)
|
||||
|
||||
const plain = new KeyboardEvent('keydown', {
|
||||
key: 'v',
|
||||
code: 'KeyV',
|
||||
ctrlKey: true,
|
||||
cancelable: true
|
||||
})
|
||||
expect(terminal.customKeyHandler!(plain)).toBe(false)
|
||||
expect(plain.defaultPrevented).toBe(true)
|
||||
await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text'))
|
||||
|
||||
// The repeat is swallowed without a second clipboard read, and so is the keyup.
|
||||
expect(
|
||||
terminal.customKeyHandler!(
|
||||
new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', ctrlKey: true, repeat: true })
|
||||
)
|
||||
).toBe(false)
|
||||
expect(terminal.customKeyHandler!(new KeyboardEvent('keyup', { key: 'v', code: 'KeyV' }))).toBe(
|
||||
false
|
||||
)
|
||||
expect(readClipboardText).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('handles the shifted paste chord on Linux', async () => {
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
focusInsidePreview(view.container)
|
||||
|
||||
const shifted = new KeyboardEvent('keydown', {
|
||||
key: 'V',
|
||||
code: 'KeyV',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
cancelable: true
|
||||
})
|
||||
expect(terminal.customKeyHandler!(shifted)).toBe(false)
|
||||
expect(shifted.defaultPrevented).toBe(true)
|
||||
await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text'))
|
||||
expect(readClipboardText).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leaves plain Cmd+V to the macOS Edit-menu accelerator', async () => {
|
||||
platformState.value = 'darwin'
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
focusInsidePreview(view.container)
|
||||
|
||||
// macOS Cmd+V uses its native accelerator; handling it here too would paste twice.
|
||||
const plain = terminal.customKeyHandler!(
|
||||
new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', metaKey: true })
|
||||
)
|
||||
expect(plain).toBe(true)
|
||||
expect(readClipboardText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pastes on terminal-style right-click when the setting is on', async () => {
|
||||
storeState.settings = { terminalRightClickToPaste: true }
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
focusInsidePreview(view.container)
|
||||
const host = view.container.querySelector<HTMLElement>('.origin-bottom-left')!
|
||||
|
||||
const menuEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
|
||||
act(() => {
|
||||
host.dispatchEvent(menuEvent)
|
||||
})
|
||||
|
||||
expect(menuEvent.defaultPrevented).toBe(true)
|
||||
await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith('clip-text'))
|
||||
})
|
||||
|
||||
it('copies the selection on terminal-style right-click instead of pasting', async () => {
|
||||
storeState.settings = { terminalRightClickToPaste: true }
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
terminal.selectionText = 'selected text'
|
||||
focusInsidePreview(view.container)
|
||||
const host = view.container.querySelector<HTMLElement>('.origin-bottom-left')!
|
||||
|
||||
act(() => {
|
||||
host.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text'))
|
||||
expect(readClipboardText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves right-click to the native menu when the setting is off', async () => {
|
||||
storeState.settings = { terminalRightClickToPaste: false }
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
focusInsidePreview(view.container)
|
||||
const host = view.container.querySelector<HTMLElement>('.origin-bottom-left')!
|
||||
|
||||
const menuEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
|
||||
act(() => {
|
||||
host.dispatchEvent(menuEvent)
|
||||
})
|
||||
|
||||
expect(menuEvent.defaultPrevented).toBe(false)
|
||||
expect(readClipboardText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels an async paste when the preview loses focus', async () => {
|
||||
let resolveClipboard!: (text: string) => void
|
||||
readClipboardText.mockReturnValueOnce(
|
||||
|
||||
-68
@@ -158,74 +158,6 @@ async function settleGeometry(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
updates: { terminalFontSize: 18 },
|
||||
cols: 50,
|
||||
rows: 15,
|
||||
scale: 'scale(0.625)',
|
||||
anchor: 'top left'
|
||||
},
|
||||
{
|
||||
updates: { terminalFontFamily: 'Fira Code' },
|
||||
cols: 50,
|
||||
rows: 15,
|
||||
scale: 'scale(0.625)',
|
||||
anchor: 'top left'
|
||||
},
|
||||
{
|
||||
updates: { terminalFontWeight: 900 },
|
||||
cols: 50,
|
||||
rows: 15,
|
||||
scale: 'scale(0.625)',
|
||||
anchor: 'top left'
|
||||
},
|
||||
{
|
||||
updates: { terminalFontWeightBold: 900 },
|
||||
cols: 50,
|
||||
rows: 15,
|
||||
scale: 'scale(0.625)',
|
||||
anchor: 'top left'
|
||||
},
|
||||
{
|
||||
updates: { terminalLineHeight: 1.5 },
|
||||
cols: 60,
|
||||
rows: 10,
|
||||
scale: 'scale(0.75)',
|
||||
anchor: 'bottom left'
|
||||
},
|
||||
{
|
||||
updates: { terminalLigatures: 'on' },
|
||||
cols: 60,
|
||||
rows: 15,
|
||||
scale: 'scale(0.75)',
|
||||
anchor: 'top left'
|
||||
}
|
||||
] satisfies {
|
||||
updates: Partial<GlobalSettings>
|
||||
cols: number
|
||||
rows: number
|
||||
scale: string
|
||||
anchor: string
|
||||
}[])(
|
||||
'recreates the owner and fits the new geometry for $updates',
|
||||
async ({ updates, cols, rows, scale, anchor }) => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await settleGeometry()
|
||||
expect(fit).toHaveBeenLastCalledWith('pty-1', 60, 15)
|
||||
await act(async () => useAppStore.getState().updateSettings(updates))
|
||||
await settleGeometry()
|
||||
|
||||
expect(fit).toHaveBeenCalledTimes(2)
|
||||
expect(fit).toHaveBeenLastCalledWith('pty-1', cols, rows)
|
||||
expect(harness.instances).toHaveLength(2)
|
||||
expect(connect).toHaveBeenCalledTimes(2)
|
||||
expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(harness.instances[1]?.container?.style.transform).toBe(scale)
|
||||
expect(harness.instances[1]?.container?.style.transformOrigin).toBe(anchor)
|
||||
}
|
||||
)
|
||||
|
||||
it('ignores a pending connection retired by a metric change', async () => {
|
||||
const gate = Promise.withResolvers<TerminalPreviewConnectResult>()
|
||||
connect.mockReturnValueOnce(gate.promise)
|
||||
|
||||
+2
-116
@@ -6,7 +6,7 @@ import type { ITerminalOptions } from '@xterm/xterm'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { TerminalPreviewConnectResult } from '../../../../shared/terminal/replay/terminal-preview'
|
||||
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
|
||||
import { makeCustomTerminalThemeSelection } from '../../../../shared/terminal-themes/terminal-custom-themes'
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import { AgentTerminalPreview } from './AgentTerminalPreview'
|
||||
|
||||
@@ -79,17 +79,13 @@ const initial = useAppStore.getInitialState()
|
||||
const connect = vi.fn<Window['api']['terminalPreview']['connect']>()
|
||||
const unsubscribe = vi.fn(async () => {})
|
||||
const offData = vi.fn()
|
||||
const onData = vi.fn((listener: Parameters<Window['api']['terminalPreview']['onData']>[0]) => {
|
||||
emitData = listener
|
||||
return offData
|
||||
})
|
||||
const onData = vi.fn(() => offData)
|
||||
const connection: TerminalPreviewConnectResult = {
|
||||
snapshot: { data: 'agent prompt', cols: 80, rows: 24, seq: 1 },
|
||||
replay: []
|
||||
}
|
||||
let settings: GlobalSettings
|
||||
let originalApi: PropertyDescriptor | undefined
|
||||
let emitData: Parameters<Window['api']['terminalPreview']['onData']>[0] | undefined
|
||||
|
||||
async function updateSettings(updates: Partial<GlobalSettings>): Promise<void> {
|
||||
await act(async () => useAppStore.getState().updateSettings(updates))
|
||||
@@ -107,7 +103,6 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
harness.instances.length = 0
|
||||
harness.systemPrefersDark = false
|
||||
emitData = undefined
|
||||
settings = createGlobalSettingsFixture({ theme: 'dark', activeRuntimeEnvironmentId: null })
|
||||
connect.mockReset().mockResolvedValue(connection)
|
||||
originalApi = Object.getOwnPropertyDescriptor(window, 'api')
|
||||
@@ -158,80 +153,6 @@ describe('preview terminal settings lifetime', () => {
|
||||
expect(unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates cursor options on the existing terminal', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await act(async () => {})
|
||||
await updateSettings({
|
||||
terminalCursorStyle: 'bar',
|
||||
terminalCursorBlink: false
|
||||
})
|
||||
expect(connect).toHaveBeenCalledOnce()
|
||||
expect(terminal().options).toMatchObject({
|
||||
cursorStyle: 'bar',
|
||||
cursorBlink: false
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ terminalThemeDark: 'Builtin Tango Light' },
|
||||
{ terminalColorOverrides: { background: '#123456' } },
|
||||
{ terminalBackgroundOpacity: 0.5 },
|
||||
{ terminalCursorOpacity: 0.5 },
|
||||
{ terminalMinimumContrastRatio: 7 }
|
||||
] satisfies Partial<GlobalSettings>[])(
|
||||
'replaces the terminal when theme or contrast changes: %o',
|
||||
async (updates) => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await act(async () => {})
|
||||
await updateSettings(updates)
|
||||
expect(connect).toHaveBeenCalledTimes(2)
|
||||
expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
expect(terminal().dispose).toHaveBeenCalledOnce()
|
||||
expect(harness.instances).toHaveLength(2)
|
||||
if ('terminalMinimumContrastRatio' in updates) {
|
||||
expect(terminal(1).options.minimumContrastRatio).toBe(7)
|
||||
} else {
|
||||
expect(terminal(1).options.theme).not.toEqual(terminal().options.theme)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a cloned imported theme but reconnects when its selected colors change', async () => {
|
||||
const custom = {
|
||||
id: 'manual:preview',
|
||||
name: 'Preview',
|
||||
source: 'manual' as const,
|
||||
mode: 'dark' as const,
|
||||
terminal: { background: '#123456', foreground: '#eeeeee', red: '#ff0000' },
|
||||
importedAt: '2026-09-25'
|
||||
}
|
||||
await updateSettings({
|
||||
terminalCustomThemes: [custom],
|
||||
terminalThemeDark: makeCustomTerminalThemeSelection(custom.id)
|
||||
})
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await act(async () => {})
|
||||
expect(terminal().options.theme?.background).toBe('#123456')
|
||||
await updateSettings({ editorAutoSaveDelayMs: 1234 })
|
||||
expect(connect).toHaveBeenCalledOnce()
|
||||
await updateSettings({
|
||||
terminalCustomThemes: [{ ...custom, terminal: { ...custom.terminal, background: '#654321' } }]
|
||||
})
|
||||
expect(connect).toHaveBeenCalledTimes(2)
|
||||
expect(terminal(1).options.theme?.background).toBe('#654321')
|
||||
})
|
||||
|
||||
it('reconnects when the effective OS theme changes', async () => {
|
||||
await updateSettings({ theme: 'system' })
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await act(async () => {})
|
||||
harness.systemPrefersDark = true
|
||||
view.rerender(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await act(async () => {})
|
||||
expect(connect).toHaveBeenCalledTimes(2)
|
||||
expect(terminal(1).options.theme).not.toEqual(terminal().options.theme)
|
||||
})
|
||||
|
||||
it('uses current live options when an unrelated update arrives during connect', async () => {
|
||||
const gate = Promise.withResolvers<TerminalPreviewConnectResult>()
|
||||
connect.mockReturnValueOnce(gate.promise)
|
||||
@@ -243,18 +164,6 @@ describe('preview terminal settings lifetime', () => {
|
||||
expect(terminal().options.cursorStyle).toBe('bar')
|
||||
})
|
||||
|
||||
it('ignores an old pending connection after a relevant theme change', async () => {
|
||||
const gate = Promise.withResolvers<TerminalPreviewConnectResult>()
|
||||
connect.mockReturnValueOnce(gate.promise)
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await updateSettings({ terminalMinimumContrastRatio: 7 })
|
||||
expect(connect).toHaveBeenCalledTimes(2)
|
||||
expect(harness.instances).toHaveLength(1)
|
||||
await act(async () => gate.resolve(connection))
|
||||
expect(harness.instances).toHaveLength(1)
|
||||
expect(terminal().options.minimumContrastRatio).toBe(7)
|
||||
})
|
||||
|
||||
it('does not install a terminal after unmounting a pending connection', async () => {
|
||||
const gate = Promise.withResolvers<TerminalPreviewConnectResult>()
|
||||
connect.mockReturnValueOnce(gate.promise)
|
||||
@@ -266,27 +175,4 @@ describe('preview terminal settings lifetime', () => {
|
||||
expect(connect).toHaveBeenCalledOnce()
|
||||
expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('ssh:host@@pty-1')
|
||||
})
|
||||
|
||||
it('keeps a hidden mounted preview connected through unrelated settings updates', async () => {
|
||||
render(
|
||||
<div hidden>
|
||||
<AgentTerminalPreview ptyId="pty-1" />
|
||||
</div>
|
||||
)
|
||||
await act(async () => {})
|
||||
await updateSettings({ editorAutoSaveDelayMs: 1234 })
|
||||
expect(connect).toHaveBeenCalledOnce()
|
||||
expect(unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still resyncs the same terminal after unrelated settings updates', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await act(async () => {})
|
||||
await updateSettings({ editorAutoSaveDelayMs: 1234 })
|
||||
await act(async () => emitData?.({ type: 'resync', ptyId: 'pty-1' }))
|
||||
expect(connect).toHaveBeenCalledTimes(2)
|
||||
expect(harness.instances).toHaveLength(1)
|
||||
expect(terminal().reset).toHaveBeenCalledOnce()
|
||||
expect(unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -253,277 +253,6 @@ describe('AgentTerminalPreview', () => {
|
||||
expect(ack).toHaveBeenCalledWith('pty-1', 4)
|
||||
})
|
||||
|
||||
it('installs the macOS IME native-text forwarder and lets its claims bypass chord handling', async () => {
|
||||
platformState.value = 'darwin'
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
expect(imeHarness.forwarders).toHaveLength(1)
|
||||
expect(imeHarness.trackers).toHaveLength(1)
|
||||
|
||||
imeHarness.forwarders[0]!.sendInput('。')
|
||||
expect(terminal.input).toHaveBeenCalledOnce()
|
||||
expect(input).toHaveBeenCalledOnce()
|
||||
expect(input).toHaveBeenCalledWith('pty-1', '。')
|
||||
|
||||
// A claimed native-text key bypasses xterm AND the clipboard chords.
|
||||
imeHarness.claimResult = true
|
||||
terminal.selectionText = 'selected text'
|
||||
const handled = terminal.customKeyHandler!(
|
||||
new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true })
|
||||
)
|
||||
expect(handled).toBe(false)
|
||||
expect(writeClipboardText).not.toHaveBeenCalled()
|
||||
expect(writeTerminalClipboardText).not.toHaveBeenCalled()
|
||||
|
||||
// Unclaimed events still reach the chord handling.
|
||||
imeHarness.claimResult = false
|
||||
const copied = terminal.customKeyHandler!(
|
||||
new KeyboardEvent('keydown', { key: 'c', code: 'KeyC', metaKey: true })
|
||||
)
|
||||
expect(copied).toBe(false)
|
||||
expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text')
|
||||
expect(writeClipboardText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not install the IME native-text forwarder on Windows', async () => {
|
||||
platformState.value = 'win32'
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
await waitFor(() => expect(terminalHarness.instances[0]!.customKeyHandler).not.toBeNull())
|
||||
expect(imeHarness.forwarders).toHaveLength(0)
|
||||
expect(imeHarness.trackers).toHaveLength(0)
|
||||
})
|
||||
|
||||
// fcitx4 delivers a single-character pick as the Space/digit keypress's text (#28213).
|
||||
it('installs the IME native-text forwarder on desktop Linux', async () => {
|
||||
platformState.value = 'linux'
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1))
|
||||
})
|
||||
|
||||
it('hands the forwarder the live mirror seeded from the snapshot flags', async () => {
|
||||
platformState.value = 'darwin'
|
||||
connect.mockResolvedValue({
|
||||
snapshot: { data: '', cols: 80, rows: 24, seq: 1, kittyKeyboardFlags: 8 },
|
||||
replay: []
|
||||
})
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1))
|
||||
await waitFor(() => expect(imeHarness.forwarders[0]!.getKittyKeyboardFlags()).toBe(8))
|
||||
// The popout xterm gets the same flags, so its encoder agrees with the mirror.
|
||||
expect(terminalHarness.instances[0]!.write).toHaveBeenCalledWith(
|
||||
'\x1b[<99u\x1b[=8u',
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
// Live output keeps advancing the same mirror the forwarder reads.
|
||||
act(() => {
|
||||
emitData?.({ type: 'data', ptyId: 'pty-1', data: '\x1b[<u', bytes: 4 })
|
||||
})
|
||||
expect(imeHarness.forwarders[0]!.getKittyKeyboardFlags()).toBe(0)
|
||||
})
|
||||
|
||||
it('disposes the IME bridge on unmount', async () => {
|
||||
platformState.value = 'darwin'
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1))
|
||||
view.unmount()
|
||||
expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledTimes(1)
|
||||
expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('disposes the IME bridge once when the PTY disappears', async () => {
|
||||
platformState.value = 'darwin'
|
||||
connect.mockResolvedValueOnce({
|
||||
snapshot: { data: '', cols: 80, rows: 24, seq: 1 },
|
||||
replay: []
|
||||
})
|
||||
connect.mockResolvedValueOnce({ snapshot: null, replay: [] })
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1))
|
||||
|
||||
act(() => emitData?.({ type: 'resync', ptyId: 'pty-1' }))
|
||||
await waitFor(() => expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledOnce())
|
||||
expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledOnce()
|
||||
|
||||
view.unmount()
|
||||
expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledOnce()
|
||||
expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('copies the terminal selection on the copy chord and blocks xterm handling', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
|
||||
terminal.selectionText = 'selected text'
|
||||
const keydown = new KeyboardEvent('keydown', {
|
||||
key: 'C',
|
||||
code: 'KeyC',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
cancelable: true
|
||||
})
|
||||
const handled = terminal.customKeyHandler!(keydown)
|
||||
const keyupHandled = terminal.customKeyHandler!(
|
||||
new KeyboardEvent('keyup', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true })
|
||||
)
|
||||
expect(handled).toBe(false)
|
||||
expect(keyupHandled).toBe(false)
|
||||
expect(keydown.defaultPrevented).toBe(true)
|
||||
expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text')
|
||||
expect(writeClipboardText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an empty copy chord from leaking terminal input', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
|
||||
const handled = terminal.customKeyHandler!(
|
||||
new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true })
|
||||
)
|
||||
expect(handled).toBe(false)
|
||||
expect(writeTerminalClipboardText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves bare Ctrl+C available to the terminal without a selection', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
|
||||
const handled = terminal.customKeyHandler!(
|
||||
new KeyboardEvent('keydown', { key: 'c', code: 'KeyC', ctrlKey: true })
|
||||
)
|
||||
expect(handled).toBe(true)
|
||||
expect(writeTerminalClipboardText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hands unselected Cmd+C to a kitty app and copies a selection instead', async () => {
|
||||
platformState.value = 'darwin'
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
const cmdC = (type: string): KeyboardEvent =>
|
||||
new KeyboardEvent(type, { key: 'c', code: 'KeyC', metaKey: true, cancelable: true })
|
||||
|
||||
// A plain shell's unselected Cmd+C sends nothing.
|
||||
expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(false)
|
||||
expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(false)
|
||||
|
||||
act(() => {
|
||||
emitData?.({ type: 'data', ptyId: 'pty-1', data: '\x1b[>1u', bytes: 5 })
|
||||
})
|
||||
expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(true)
|
||||
expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(true)
|
||||
|
||||
// A highlight of blank cells copies no text but is still Orca's selection, as in the pane.
|
||||
Object.assign(terminal, { hasSelection: () => true })
|
||||
expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(false)
|
||||
expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(false)
|
||||
expect(writeTerminalClipboardText).not.toHaveBeenCalled()
|
||||
|
||||
terminal.selectionText = 'selected text'
|
||||
expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(false)
|
||||
expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(false)
|
||||
expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text')
|
||||
})
|
||||
|
||||
it('selects all terminal text on Cmd+A and blocks xterm handling', async () => {
|
||||
platformState.value = 'darwin'
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
|
||||
const keydown = new KeyboardEvent('keydown', {
|
||||
key: 'a',
|
||||
code: 'KeyA',
|
||||
metaKey: true,
|
||||
cancelable: true
|
||||
})
|
||||
expect(terminal.customKeyHandler!(keydown)).toBe(false)
|
||||
expect(keydown.defaultPrevented).toBe(true)
|
||||
|
||||
const repeat = new KeyboardEvent('keydown', {
|
||||
key: 'a',
|
||||
code: 'KeyA',
|
||||
metaKey: true,
|
||||
repeat: true,
|
||||
cancelable: true
|
||||
})
|
||||
expect(terminal.customKeyHandler!(repeat)).toBe(false)
|
||||
expect(repeat.defaultPrevented).toBe(true)
|
||||
expect(terminal.selectAll).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('sends the word-kill byte on Ctrl+Backspace and blocks xterm handling', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
|
||||
const keydown = new KeyboardEvent('keydown', {
|
||||
key: 'Backspace',
|
||||
code: 'Backspace',
|
||||
ctrlKey: true,
|
||||
cancelable: true
|
||||
})
|
||||
const handled = terminal.customKeyHandler!(keydown)
|
||||
|
||||
expect(handled).toBe(false)
|
||||
expect(keydown.defaultPrevented).toBe(true)
|
||||
expect(terminal.input).toHaveBeenCalledWith('\x17')
|
||||
await waitFor(() => expect(input).toHaveBeenCalledWith('pty-1', '\x17'))
|
||||
})
|
||||
|
||||
it('swallows a pane-scoped chord instead of leaking its control byte to the agent', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
|
||||
// Ctrl+Shift+D splits a pane on Linux; xterm would otherwise send Ctrl+D.
|
||||
const keydown = new KeyboardEvent('keydown', {
|
||||
key: 'D',
|
||||
code: 'KeyD',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
cancelable: true
|
||||
})
|
||||
const handled = terminal.customKeyHandler!(keydown)
|
||||
|
||||
expect(handled).toBe(false)
|
||||
expect(keydown.defaultPrevented).toBe(true)
|
||||
expect(terminal.input).not.toHaveBeenCalled()
|
||||
expect(input).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dismisses the hosting dialog on the pane-close chord', async () => {
|
||||
const onRequestClose = vi.fn()
|
||||
render(<AgentTerminalPreview ptyId="pty-1" onRequestClose={onRequestClose} />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
|
||||
const keydown = new KeyboardEvent('keydown', {
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
ctrlKey: true,
|
||||
cancelable: true
|
||||
})
|
||||
expect(terminal.customKeyHandler!(keydown)).toBe(false)
|
||||
expect(keydown.defaultPrevented).toBe(true)
|
||||
expect(onRequestClose).toHaveBeenCalledOnce()
|
||||
expect(input).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a native input-source chord from inserting text into the preview', async () => {
|
||||
storeState.keybindings = { 'terminal.switchInputSource': ['Shift+Space'] }
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
@@ -573,27 +302,6 @@ describe('AgentTerminalPreview', () => {
|
||||
view.unmount()
|
||||
})
|
||||
|
||||
it('defers Option chords to xterm once the TUI negotiates kitty keyboard mode', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
|
||||
const altBackspace = (): KeyboardEvent =>
|
||||
new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', altKey: true })
|
||||
expect(terminal.customKeyHandler!(altBackspace())).toBe(false)
|
||||
expect(terminal.input).toHaveBeenCalledWith('\x1b\x7f')
|
||||
|
||||
// The agent's TUI pushes kitty flags (CSI > 1 u) on the live stream.
|
||||
act(() => {
|
||||
emitData?.({ type: 'data', ptyId: 'pty-1', data: '\x1b[>1u', bytes: 5 })
|
||||
})
|
||||
terminal.input.mockClear()
|
||||
|
||||
expect(terminal.customKeyHandler!(altBackspace())).toBe(true)
|
||||
expect(terminal.input).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Snapshot replay can redeliver a kitty push; it must not leave stale flags after the TUI’s single pop.
|
||||
it('does not let a redelivered kitty push outlive the TUI pop', async () => {
|
||||
connect.mockResolvedValueOnce({
|
||||
@@ -618,36 +326,6 @@ describe('AgentTerminalPreview', () => {
|
||||
expect(terminal.input).toHaveBeenCalledWith('\x1b\x7f')
|
||||
})
|
||||
|
||||
it('scrolls the viewport on the macOS scrollback chord', async () => {
|
||||
platformState.value = 'darwin'
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
|
||||
const handled = terminal.customKeyHandler!(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowUp', code: 'ArrowUp', metaKey: true })
|
||||
)
|
||||
|
||||
expect(handled).toBe(false)
|
||||
expect(terminal.scrollToTop).toHaveBeenCalled()
|
||||
expect(terminal.input).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves an unmodified Backspace to xterm', async () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
|
||||
const handled = terminal.customKeyHandler!(
|
||||
new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', cancelable: true })
|
||||
)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(terminal.input).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the existing terminal visible while a resync snapshot is captured', async () => {
|
||||
let resolveRefresh!: (value: TerminalPreviewConnectResult) => void
|
||||
connect
|
||||
@@ -704,56 +382,6 @@ describe('AgentTerminalPreview', () => {
|
||||
expect(unsubscribe).toHaveBeenCalledWith('pty-1')
|
||||
})
|
||||
|
||||
it('does not claim a remote pane closed when no snapshot can exist for it', async () => {
|
||||
connect.mockResolvedValueOnce({ snapshot: null, replay: [] })
|
||||
const view = render(<AgentTerminalPreview ptyId="ssh:devbox@@pty-3" />)
|
||||
|
||||
await waitFor(() => expect(view.getByText(/remote session/)).toBeInTheDocument())
|
||||
expect(view.queryByText(/pane has closed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('connects a replacement pty after the previous pty was gone', async () => {
|
||||
connect.mockResolvedValueOnce({ snapshot: null, replay: [] }).mockResolvedValueOnce({
|
||||
snapshot: { data: 'replacement', cols: 80, rows: 24, seq: 1 },
|
||||
replay: []
|
||||
})
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-gone" />)
|
||||
await waitFor(() => expect(view.getByText(/No preview available/)).toBeInTheDocument())
|
||||
|
||||
view.rerender(<AgentTerminalPreview ptyId="pty-live" />)
|
||||
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
expect(connect).toHaveBeenLastCalledWith('pty-live', { scrollbackRows: 24 })
|
||||
expect(view.queryByText(/No preview available/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('claims a grid sized to the dialog box and never re-requests an unchanged target', async () => {
|
||||
vi.useFakeTimers()
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await vi.waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
|
||||
const host = view.container.querySelector<HTMLElement>('.origin-bottom-left')!
|
||||
const box = host.parentElement!
|
||||
Object.defineProperty(box, 'clientWidth', { configurable: true, value: 900 })
|
||||
Object.defineProperty(box, 'clientHeight', { configurable: true, value: 480 })
|
||||
// 80×24 grid rendered at 800×384 → 10×16 cells → the box holds 90×30.
|
||||
const screen = document.createElement('div')
|
||||
screen.className = 'xterm-screen'
|
||||
Object.defineProperty(screen, 'offsetWidth', { configurable: true, value: 800 })
|
||||
Object.defineProperty(screen, 'offsetHeight', { configurable: true, value: 384 })
|
||||
host.appendChild(screen)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(fit).toHaveBeenCalledTimes(1)
|
||||
expect(fit).toHaveBeenCalledWith('pty-1', 90, 30)
|
||||
|
||||
// Unchanged target geometry must not reclaim the grid on reconnect and fight the host.
|
||||
act(() => emitData?.({ type: 'resync', ptyId: 'pty-1' }))
|
||||
await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(2))
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(fit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('delays repeated capture after an overflow and cancels the retry on unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
connect.mockResolvedValue({
|
||||
@@ -777,40 +405,4 @@ describe('AgentTerminalPreview', () => {
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(connect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("previews a paired host's pane from that host, not this app's main process", async () => {
|
||||
render(<AgentTerminalPreview ptyId="remote:env-1@@term-7" />)
|
||||
|
||||
await waitFor(() => expect(multiplexerHarness.subscriptions).toHaveLength(1))
|
||||
expect(multiplexerHarness.environments).toEqual(['env-1'])
|
||||
expect(multiplexerHarness.subscriptions[0]!.terminal).toBe('term-7')
|
||||
expect(connect).not.toHaveBeenCalled()
|
||||
|
||||
act(() =>
|
||||
multiplexerHarness.subscriptions[0]!.callbacks.onSnapshot('agent screen', {
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
seq: 5
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
|
||||
const terminal = terminalHarness.instances[0]!
|
||||
expect(terminal.write).toHaveBeenCalledWith('agent screen', expect.any(Function))
|
||||
|
||||
act(() => multiplexerHarness.subscriptions[0]!.callbacks.onData('live', { seq: 9 }))
|
||||
expect(terminal.write).toHaveBeenCalledWith('live', expect.any(Function))
|
||||
act(() => {
|
||||
terminalHarness.userInputListener?.()
|
||||
terminal.onDataListener?.('y')
|
||||
})
|
||||
expect(multiplexerHarness.sendInput).toHaveBeenCalledWith('y')
|
||||
})
|
||||
|
||||
it('shows the unavailable message instead of a blank dialog when the bridge is missing', async () => {
|
||||
// The web client's fallback proxy resolves every missing bridge call to undefined.
|
||||
Object.assign(window.api.terminalPreview, { connect: async () => undefined })
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
|
||||
await waitFor(() => expect(view.getByText(/No preview available/)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
+4
-356
@@ -16,12 +16,11 @@ import {
|
||||
mountPreviewLinkInteraction
|
||||
} from './dashboard-preview-dom-test-fixture'
|
||||
import { verifyPreviewLinkPopulation } from './dashboard-preview-link-population-test-fixture'
|
||||
import { verifyPopoutDefaultLinkPath } from './dashboard-preview-popout-default-test-fixture'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { runtimeStatuses } from '@/store/slices/browser-slice-test-harness'
|
||||
import { openStandaloneTerminal } from '../standalone-terminal/open-standalone-terminal'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker'
|
||||
import type { openStandaloneTerminal } from '../standalone-terminal/open-standalone-terminal'
|
||||
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness'
|
||||
import {
|
||||
@@ -37,8 +36,7 @@ import { openWorkspaceBrowserTabForLink } from '@/lib/workspace-browser-tab-open
|
||||
import { AgentTerminalPreview } from './AgentTerminalPreview'
|
||||
import { terminalPreviewApiFor } from './terminal-preview-api'
|
||||
import { AgentKanbanBoard } from './AgentKanbanBoard'
|
||||
import { AgentTerminalPanel } from './AgentTerminalDialog'
|
||||
import { handleTerminalHttpLink } from '@/components/terminal-pane/terminal-url-link-hit-testing'
|
||||
|
||||
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
@@ -214,42 +212,6 @@ async function output(data: string, ptyId = 'pty-1') {
|
||||
}
|
||||
const osc = `\x1b]8;;${URL_TO_OPEN}\x07hidden label\x1b]8;;\x07`
|
||||
|
||||
it('uses the real Dashboard constructor, OSC primer/provider and genuine claim; context Copy and live destinations share the same URI', async () => {
|
||||
const foreground = vi.fn()
|
||||
const view = render(
|
||||
<AgentTerminalPreview ptyId="pty-1" linkSource={source} onBrowserForeground={foreground} />
|
||||
)
|
||||
await waitFor(() => screenFor(view.container))
|
||||
await output(`\x1b[?1000h\x1b[?1006h${osc}`)
|
||||
const screen = screenFor(view.container)
|
||||
// No hover is delivered: actual capture priming must discover the hidden OSC label.
|
||||
pointer(screen, 'mousedown')
|
||||
pointer(screen, 'mouseup')
|
||||
await waitFor(() => expect(document.querySelector('[data-link-action-popover]')).not.toBeNull())
|
||||
expect(document.querySelector('[data-terminal-link-destination]')?.textContent).toBe(URL_TO_OPEN)
|
||||
expect(
|
||||
input.mock.calls.flatMap(([, data]) => data).filter((data) => data.startsWith('\x1b[<'))
|
||||
).toEqual([])
|
||||
fireEvent.click(view.getByRole('button', { name: 'Copy link' }))
|
||||
await waitFor(() => expect(window.api.ui.writeClipboardText).toHaveBeenCalledWith(URL_TO_OPEN))
|
||||
fireEvent.click(view.getByRole('button', { name: /System Browser/ }))
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(URL_TO_OPEN))
|
||||
expect(foreground).not.toHaveBeenCalled()
|
||||
|
||||
store.setState((state) => ({ settings: { ...state.settings!, openLinksInApp: true } }))
|
||||
view.rerender(
|
||||
<AgentTerminalPreview ptyId="pty-1" linkSource={source} onBrowserForeground={foreground} />
|
||||
)
|
||||
pointer(screen, 'mousemove')
|
||||
pointer(screen, 'contextmenu', { button: 2, buttons: 0 })
|
||||
await waitFor(() => expect(view.getByRole('button', { name: /Orca Browser/ })).toBeTruthy())
|
||||
fireEvent.click(view.getByRole('button', { name: /Orca Browser/ }))
|
||||
await waitFor(() => expect(foreground).toHaveBeenCalledOnce())
|
||||
expect(store.getState().browserPagesByWorkspace).not.toEqual({})
|
||||
view.unmount()
|
||||
expect(document.querySelector('[data-link-action-popover]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['actions', 'open'] as const)(
|
||||
'retains the provider-owned hidden OSC target across unrelated live row output for %s and context',
|
||||
async (behavior) => {
|
||||
@@ -319,56 +281,6 @@ function seedRemoteWorkspace(
|
||||
}))
|
||||
}
|
||||
|
||||
it.each([false, true])(
|
||||
'keeps paired SSH OSC actions system-only with exact Copy despite the in-app preference (streaming=%s)',
|
||||
async (streaming) => {
|
||||
const hostId = 'ssh:private-target'
|
||||
const ptyId = 'remote:hub-a@@handle-1'
|
||||
const linkSource = {
|
||||
...source,
|
||||
executionHostId: hostId,
|
||||
ptyId
|
||||
} satisfies DashboardPreviewLinkSource
|
||||
const foreground = vi.fn()
|
||||
seedRemoteWorkspace(hostId, 'hub-a', streaming)
|
||||
const props = { ptyId, linkSource, onBrowserForeground: foreground }
|
||||
const connect = vi.spyOn(terminalPreviewApiFor(ptyId), 'connect')
|
||||
const view = render(<AgentTerminalPreview {...props} />)
|
||||
await waitFor(() => screenFor(view.container))
|
||||
await output(osc, ptyId)
|
||||
const screen = screenFor(view.container)
|
||||
pointer(screen, 'mousedown')
|
||||
pointer(screen, 'mouseup')
|
||||
const system = await view.findByRole('button', { name: /System Browser/ })
|
||||
expect(view.queryByRole('button', { name: /Orca Browser|Open in new tab/ })).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: 'Copy link' }))
|
||||
await waitFor(() => expect(window.api.ui.writeClipboardText).toHaveBeenCalledWith(URL_TO_OPEN))
|
||||
fireEvent.click(system)
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledExactlyOnceWith(URL_TO_OPEN))
|
||||
pointer(screen, 'mousedown', { ctrlKey: true })
|
||||
pointer(screen, 'mouseup', { ctrlKey: true })
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledTimes(2))
|
||||
expect(foreground).not.toHaveBeenCalled()
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
const wrongPtyId = 'remote:hub-b@@handle-1'
|
||||
view.rerender(
|
||||
<AgentTerminalPreview
|
||||
{...props}
|
||||
ptyId={wrongPtyId}
|
||||
linkSource={{ ...linkSource, ptyId: wrongPtyId }}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(connect).toHaveBeenCalledTimes(2))
|
||||
await output(osc, wrongPtyId)
|
||||
const wrongScreen = screenFor(view.container)
|
||||
pointer(wrongScreen, 'mousedown', { ctrlKey: true })
|
||||
pointer(wrongScreen, 'mouseup', { ctrlKey: true })
|
||||
pointer(wrongScreen, 'contextmenu', { button: 2 })
|
||||
expect(document.querySelector('[data-link-action-popover]')).toBeNull()
|
||||
expect(openUrl).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
)
|
||||
|
||||
it('gives one useful refusal when a real saved chooser action loses runtime browser eligibility', async () => {
|
||||
const hostId = 'runtime:e'
|
||||
const ptyId = 'remote:e@@handle-1'
|
||||
@@ -398,126 +310,6 @@ it('gives one useful refusal when a real saved chooser action loses runtime brow
|
||||
expect(input.mock.calls.map(([, data]) => data)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps None/middle/drag/nonlink reports and ordinary input live, refuses parser queries, and leaves native/paste context alone', async () => {
|
||||
store.setState((state) => ({
|
||||
settings: { ...state.settings!, terminalLinkClickBehavior: 'none' }
|
||||
}))
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" linkSource={source} />)
|
||||
await waitFor(() => screenFor(view.container))
|
||||
await output(`\x1b[?1000h\x1b[?1006h${URL_TO_OPEN}`)
|
||||
const screen = screenFor(view.container)
|
||||
pointer(screen, 'mousedown')
|
||||
pointer(screen, 'mouseup')
|
||||
pointer(screen, 'mousedown', { button: 1, buttons: 4 })
|
||||
pointer(screen, 'mouseup', { button: 1 })
|
||||
await waitFor(() =>
|
||||
expect(input.mock.calls.some(([, data]) => data.startsWith('\x1b[<0;'))).toBe(true)
|
||||
)
|
||||
expect(input.mock.calls.map(([, data]) => data)).toEqual([
|
||||
'\x1b[<0;1;1M',
|
||||
'\x1b[<0;1;1m',
|
||||
'\x1b[<1;1;1M',
|
||||
'\x1b[<1;1;1m'
|
||||
])
|
||||
expect(document.querySelector('[data-link-action-popover]')).toBeNull()
|
||||
expect(openUrl).not.toHaveBeenCalled()
|
||||
const prior = input.mock.calls.length
|
||||
await output('\x1b[6n')
|
||||
expect(input).toHaveBeenCalledTimes(prior)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
fireEvent.keyDown(textarea, { key: 'x', code: 'KeyX', keyCode: 88 })
|
||||
fireEvent.input(textarea, { data: 'x', inputType: 'insertText' })
|
||||
await waitFor(() => expect(input).toHaveBeenCalledWith('pty-1', 'x'))
|
||||
const native = pointer(screen, 'contextmenu', { button: 2, ctrlKey: true })
|
||||
expect(native.defaultPrevented).toBe(false)
|
||||
store.setState((state) => ({ settings: { ...state.settings!, terminalRightClickToPaste: true } }))
|
||||
view.rerender(<AgentTerminalPreview ptyId="pty-1" linkSource={source} />)
|
||||
textarea.focus()
|
||||
pointer(screen, 'contextmenu', { button: 2 })
|
||||
await waitFor(() => expect(window.api.ui.readClipboardText).toHaveBeenCalled())
|
||||
expect(document.querySelector('[data-link-action-popover]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves standalone normal/SSH defaults and sole-emulator query policy (answersQueries=%s)',
|
||||
async (answersQueries) => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const send = vi.fn(() => true)
|
||||
const instance = openStandaloneTerminal({
|
||||
container,
|
||||
options: { cols: 80, rows: 8, allowProposedApi: true },
|
||||
kittyKeyboardModes: new TerminalKittyKeyboardModeTracker({ kittyKeyboard: true }),
|
||||
sessionKey: 'plain-ssh:test',
|
||||
sendInput: send,
|
||||
answersQueries,
|
||||
getSettings: () => store.getState().settings,
|
||||
getMacOptionAsAlt: () => 'false',
|
||||
getTerminalInput: () => null
|
||||
})
|
||||
instances.push(instance)
|
||||
if (!instance) {
|
||||
throw new Error('Real constructor refused test layout')
|
||||
}
|
||||
const terminal = instance.terminal
|
||||
await new Promise<void>((resolve) =>
|
||||
instance.write(`${osc}\r\n${URL_TO_OPEN}\x1b[6n`, { live: true, onDone: resolve })
|
||||
)
|
||||
expect(send.mock.calls.length > 0).toBe(answersQueries)
|
||||
terminal.input('ordinary')
|
||||
expect(send).toHaveBeenLastCalledWith('ordinary')
|
||||
const screen = screenFor(container)
|
||||
pointer(screen, 'mousemove', { buttons: 0 })
|
||||
await waitFor(() => expect(terminal.element?.getAttribute('title')).toBe(URL_TO_OPEN))
|
||||
pointer(screen, 'mousedown', { ctrlKey: true })
|
||||
pointer(screen, 'mouseup', { ctrlKey: true })
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(URL_TO_OPEN))
|
||||
expect(terminal.hasSelection()).toBe(false)
|
||||
expect(terminal.element?.getAttribute('title')).toBe(URL_TO_OPEN)
|
||||
pointer(screen, 'mouseleave')
|
||||
expect(terminal.element?.getAttribute('title')).toBeNull()
|
||||
// The same default callback is captured by the detected-URL provider, not only OSC.
|
||||
pointer(screen, 'mousemove', { clientY: 27 })
|
||||
pointer(screen, 'mousedown', { ctrlKey: true, clientY: 27 })
|
||||
pointer(screen, 'mouseup', { ctrlKey: true, clientY: 27 })
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledTimes(2))
|
||||
expect(openUrl.mock.calls.map(([url]) => url)).toEqual([URL_TO_OPEN, URL_TO_OPEN])
|
||||
pointer(screen, 'mousedown', { clientY: 27 })
|
||||
pointer(screen, 'mouseup', { clientY: 27 })
|
||||
expect(openUrl).toHaveBeenCalledTimes(2)
|
||||
const injected = mountedInteraction()
|
||||
const injectedInteraction = injected.install()
|
||||
const raw = 'https:example.com'
|
||||
for (const current of [instance, injected.instance]) {
|
||||
const term = current.terminal
|
||||
const target = screenFor(term.element!.parentElement!)
|
||||
pointer(target, 'mouseleave')
|
||||
await new Promise<void>((resolve) =>
|
||||
current.write(`\x1b[2J\x1b[H\x1b]8;;${raw}\x07Link\x1b]8;;\x07`, {
|
||||
live: true,
|
||||
onDone: resolve
|
||||
})
|
||||
)
|
||||
pointer(target, 'mousemove', { buttons: 0 })
|
||||
await waitFor(() => expect(term.element?.getAttribute('title')).toBe(raw))
|
||||
term.select(0, 0, 4)
|
||||
const clear = vi.spyOn(term, 'clearSelection')
|
||||
pointer(target, 'mousedown', { ctrlKey: true })
|
||||
pointer(target, 'mouseup', { ctrlKey: true })
|
||||
await waitFor(() => expect(openUrl).toHaveBeenLastCalledWith(raw))
|
||||
expect(clear).toHaveBeenCalled()
|
||||
expect(term.hasSelection()).toBe(false)
|
||||
clear.mockRestore()
|
||||
}
|
||||
expect(openUrl.mock.calls.map(([url]) => url)).toEqual([URL_TO_OPEN, URL_TO_OPEN, raw, raw])
|
||||
expect(injected.request).not.toHaveBeenCalled()
|
||||
expect(document.querySelector('[data-link-action-popover]')).toBeNull()
|
||||
injectedInteraction.dispose()
|
||||
instance.dispose()
|
||||
container.remove()
|
||||
}
|
||||
)
|
||||
|
||||
function mountedInteraction() {
|
||||
return mountPreviewLinkInteraction(
|
||||
source,
|
||||
@@ -592,88 +384,6 @@ function cards(count: number): DashboardCard[] {
|
||||
}))
|
||||
}
|
||||
|
||||
it('adds no provider or preview connection when the real board grows from 10 to 100 cards without an open preview', async () => {
|
||||
const provider = vi.spyOn(Terminal.prototype, 'registerLinkProvider')
|
||||
const view = render(<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: cards(10) }} />)
|
||||
view.rerender(<AgentKanbanBoard snapshot={{ generatedAt: 2, cards: cards(100) }} />)
|
||||
expect(provider).not.toHaveBeenCalled()
|
||||
expect(window.api.terminalPreview.connect).not.toHaveBeenCalled()
|
||||
view.unmount()
|
||||
await verifyPopoutDefaultLinkPath({
|
||||
source,
|
||||
card: { ...cards(1)[0]!, ...source },
|
||||
provider,
|
||||
osc,
|
||||
readSettings: () => store.getState().settings,
|
||||
trackInstance: (instance) => instances.push(instance),
|
||||
pointer,
|
||||
screenFor,
|
||||
output,
|
||||
input,
|
||||
openUrl,
|
||||
subscribeToRouter: mocks.subscribe
|
||||
})
|
||||
})
|
||||
|
||||
it('honors primary/alternate and Open on real literal URLs, keeps background context, and refuses unqualified/mismatched hosts before feedback', async () => {
|
||||
store.setState((state) => ({
|
||||
settings: { ...state.settings!, openLinksInApp: true, terminalLinkClickBehavior: 'open' }
|
||||
}))
|
||||
const foreground = vi.fn()
|
||||
const view = render(
|
||||
<AgentTerminalPreview ptyId="pty-1" linkSource={source} onBrowserForeground={foreground} />
|
||||
)
|
||||
await waitFor(() => screenFor(view.container))
|
||||
await output(URL_TO_OPEN)
|
||||
const screen = screenFor(view.container)
|
||||
// First literal click exercises the fallback when the provider has not finished hover discovery.
|
||||
pointer(screen, 'mousedown')
|
||||
pointer(screen, 'mouseup')
|
||||
await waitFor(() => expect(foreground).toHaveBeenCalledOnce())
|
||||
expect(document.querySelector('[data-link-action-popover]')).toBeNull()
|
||||
expect(openUrl).not.toHaveBeenCalled()
|
||||
pointer(screen, 'mousedown', { ctrlKey: true, shiftKey: true })
|
||||
pointer(screen, 'mouseup', { ctrlKey: true, shiftKey: true })
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledExactlyOnceWith(URL_TO_OPEN))
|
||||
expect(foreground).toHaveBeenCalledOnce()
|
||||
store.setState((state) => ({
|
||||
settings: { ...state.settings!, openLinksInAppInBackground: true }
|
||||
}))
|
||||
view.rerender(
|
||||
<AgentTerminalPreview ptyId="pty-1" linkSource={source} onBrowserForeground={foreground} />
|
||||
)
|
||||
const observed = mocks.subscribe.mock.calls.length
|
||||
pointer(screen, 'mousedown', { ctrlKey: true })
|
||||
pointer(screen, 'mouseup', { ctrlKey: true })
|
||||
await waitFor(() => expect(mocks.subscribe.mock.results[observed]?.value).toHaveBeenCalledOnce())
|
||||
expect(foreground).toHaveBeenCalledOnce()
|
||||
view.rerender(
|
||||
<AgentTerminalPreview
|
||||
ptyId="pty-1"
|
||||
linkSource={{ ...source, executionHostId: undefined }}
|
||||
onBrowserForeground={foreground}
|
||||
/>
|
||||
)
|
||||
pointer(screen, 'mousedown')
|
||||
pointer(screen, 'mouseup')
|
||||
expect(document.querySelector('[data-link-action-popover]')).toBeNull()
|
||||
expect(openUrl).toHaveBeenCalledTimes(1)
|
||||
const remotePty = 'ssh:other@@pty-1'
|
||||
view.rerender(
|
||||
<AgentTerminalPreview
|
||||
ptyId={remotePty}
|
||||
linkSource={{ ...source, ptyId: remotePty, executionHostId: 'ssh:other' }}
|
||||
onBrowserForeground={foreground}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(window.api.terminalPreview.connect).toHaveBeenCalledTimes(2))
|
||||
await output(URL_TO_OPEN, remotePty)
|
||||
pointer(screenFor(view.container), 'contextmenu', { button: 2 })
|
||||
// This real-shaped remote card has no eligible managed-browser route in the local-only workspace.
|
||||
await waitFor(() => expect(view.getByRole('button', { name: /System Browser/ })).toBeTruthy())
|
||||
expect(view.queryByRole('button', { name: /Orca Browser/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses real selected/drag/nonlink reports and retains the real in-window Board chooser and Settings handoff', async () => {
|
||||
const preview = mountedInteraction()
|
||||
const interaction = preview.install()
|
||||
@@ -728,65 +438,3 @@ it('refuses real selected/drag/nonlink reports and retains the real in-window Bo
|
||||
expect(store.getState().activeView).toBe('settings')
|
||||
await waitFor(() => expect(document.querySelector('.xterm')).toBeNull())
|
||||
})
|
||||
|
||||
it('hands Settings through the actual in-window frame and shared popover without leaving the covering panel open', async () => {
|
||||
const close = vi.fn()
|
||||
const handoff = vi.fn()
|
||||
const card = {
|
||||
...cards(1)[0]!,
|
||||
...source,
|
||||
paneKey: 'pane-1',
|
||||
leafId: 'leaf-1',
|
||||
worktreeName: 'Workspace'
|
||||
}
|
||||
const view = render(
|
||||
<AgentTerminalPanel
|
||||
card={card}
|
||||
onOpenChange={close}
|
||||
onReveal={vi.fn()}
|
||||
onBrowserForeground={handoff}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => screenFor(view.container))
|
||||
await output(osc)
|
||||
const screen = screenFor(view.container)
|
||||
pointer(screen, 'mousedown')
|
||||
pointer(screen, 'mouseup')
|
||||
await waitFor(() =>
|
||||
expect(view.getByRole('button', { name: 'Terminal link settings' })).toBeTruthy()
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: 'Terminal link settings' }))
|
||||
expect(close).toHaveBeenCalledExactlyOnceWith(false)
|
||||
expect(handoff).toHaveBeenCalledOnce()
|
||||
expect(store.getState().activeView).toBe('settings')
|
||||
})
|
||||
|
||||
it.each(['local', 'unknown'] as const)(
|
||||
'keeps the common pane route and real Dashboard route destination/refusal equivalent for source=%s',
|
||||
async (owner) => {
|
||||
store.setState((state) => ({ settings: { ...state.settings!, openLinksInApp: false } }))
|
||||
handleTerminalHttpLink(URL_TO_OPEN, new MouseEvent('mouseup', { button: 0, ctrlKey: true }), {
|
||||
worktreeId: WORKSPACE,
|
||||
sourceTabId: 'source',
|
||||
sourceOwner: owner === 'local' ? { kind: 'local' } : { kind: 'unknown' },
|
||||
actionDestinations: { primary: 'system' }
|
||||
})
|
||||
const expected = owner === 'local' ? [URL_TO_OPEN] : []
|
||||
expect(openUrl.mock.calls.map(([url]) => url)).toEqual(expected)
|
||||
openUrl.mockClear()
|
||||
const view = render(
|
||||
<AgentTerminalPreview
|
||||
ptyId="pty-1"
|
||||
linkSource={{ ...source, executionHostId: owner === 'local' ? 'local' : undefined }}
|
||||
onBrowserForeground={vi.fn()}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => screenFor(view.container))
|
||||
await output(osc)
|
||||
const screen = screenFor(view.container)
|
||||
pointer(screen, 'mousedown', { ctrlKey: true })
|
||||
pointer(screen, 'mouseup', { ctrlKey: true })
|
||||
expect(openUrl.mock.calls.map(([url]) => url)).toEqual(expected)
|
||||
expect(document.querySelector('[data-link-action-popover]')).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
import { act, fireEvent, render as renderWithoutApp, waitFor } from '@testing-library/react'
|
||||
import { expect, vi, type Mock, type MockInstance } from 'vitest'
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { toast } from 'sonner'
|
||||
import { URL_TO_OPEN } from '@/lib/browser-link-open.test-fixture'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
|
||||
import * as standalone from '../standalone-terminal/open-standalone-terminal'
|
||||
import type { DashboardPreviewLinkSource } from './dashboard-preview-http-actions'
|
||||
import {
|
||||
mountPreviewLinkInteraction,
|
||||
observePreviewListeners
|
||||
} from './dashboard-preview-dom-test-fixture'
|
||||
import { DashboardPopoutRoot } from './DashboardPopoutRoot'
|
||||
|
||||
export async function verifyPopoutDefaultLinkPath({
|
||||
source,
|
||||
card,
|
||||
provider,
|
||||
osc,
|
||||
readSettings,
|
||||
trackInstance,
|
||||
pointer,
|
||||
screenFor,
|
||||
output,
|
||||
input,
|
||||
openUrl,
|
||||
subscribeToRouter
|
||||
}: {
|
||||
source: DashboardPreviewLinkSource
|
||||
card: DashboardCard
|
||||
provider: MockInstance<Terminal['registerLinkProvider']>
|
||||
osc: string
|
||||
readSettings: () => GlobalSettings | null
|
||||
trackInstance: (instance: standalone.StandaloneTerminal | null) => void
|
||||
pointer: (screen: HTMLElement, type: string, options?: MouseEventInit) => MouseEvent
|
||||
screenFor: (container: HTMLElement) => HTMLElement
|
||||
output: (data: string, ptyId?: string) => Promise<void>
|
||||
input: Mock<(pty: string, data: string) => Promise<boolean>>
|
||||
openUrl: Mock<(url: string) => Promise<void>>
|
||||
subscribeToRouter: Mock
|
||||
}): Promise<void> {
|
||||
const bridges = new Set<object>()
|
||||
const subscribe = () => {
|
||||
const owner = {}
|
||||
bridges.add(owner)
|
||||
return () => bridges.delete(owner)
|
||||
}
|
||||
let snapshot: ((value: DashboardSnapshot) => void) | undefined
|
||||
Object.assign(window.api, {
|
||||
dashboard: {
|
||||
...window.api.dashboard,
|
||||
onSnapshot: (listener: typeof snapshot) => {
|
||||
snapshot = listener
|
||||
return subscribe()
|
||||
},
|
||||
requestSnapshot: vi.fn(async () => {})
|
||||
},
|
||||
agentStatus: { onSet: subscribe, onClear: subscribe },
|
||||
ui: {
|
||||
...window.api.ui,
|
||||
onAppMenuPaste: subscribe,
|
||||
onEditableContextPaste: subscribe,
|
||||
onAppMenuSelectionAction: subscribe
|
||||
},
|
||||
telemetryGetConsentState: async () => ({ effective: 'disabled', reason: 'ci' })
|
||||
})
|
||||
const constructor = vi.spyOn(standalone, 'openStandaloneTerminal')
|
||||
const baseline = mountPreviewLinkInteraction(source, readSettings, trackInstance, false)
|
||||
expect(constructor.mock.calls[0]![0].linkHandlers).toBeUndefined()
|
||||
expect(provider).toHaveBeenCalledTimes(1)
|
||||
baseline.instance.dispose()
|
||||
baseline.container.remove()
|
||||
const census = observePreviewListeners([])
|
||||
const popout = renderWithoutApp(<DashboardPopoutRoot />)
|
||||
act(() => snapshot?.({ generatedAt: 3, cards: [card] }))
|
||||
expect(bridges.size).toBe(6)
|
||||
const rootOwners = Array.from(census.active)
|
||||
fireEvent.click(popout.getByRole('button', { name: /Workspace 0/ }))
|
||||
expect(window.api.dashboard.ackAgent).toHaveBeenCalledExactlyOnceWith('pane-0', 'explicit')
|
||||
await waitFor(() => screenFor(document.body))
|
||||
const terminal = constructor.mock.results[1]!.value!.terminal
|
||||
expect(constructor.mock.calls[1]![0].linkHandlers).toBeUndefined()
|
||||
expect(provider).toHaveBeenCalledTimes(2)
|
||||
const raw = 'https:example.com'
|
||||
await output(
|
||||
`\x1b[?1000h\x1b[?1006h${osc.replace(URL_TO_OPEN, raw)}\r\n${URL_TO_OPEN}\r\n/tmp/plain.txt`
|
||||
)
|
||||
const screen = screenFor(document.body)
|
||||
pointer(screen, 'mousemove', { buttons: 0 })
|
||||
await waitFor(() => expect(terminal.element?.getAttribute('title')).toBe(raw))
|
||||
pointer(screen, 'mousedown')
|
||||
pointer(screen, 'mouseup')
|
||||
pointer(screen, 'mousedown', { button: 1, buttons: 4 })
|
||||
pointer(screen, 'mouseup', { button: 1 })
|
||||
expect(pointer(screen, 'contextmenu', { button: 2, ctrlKey: true }).defaultPrevented).toBe(false)
|
||||
await waitFor(() =>
|
||||
expect(input.mock.calls.map(([, data]) => data)).toEqual([
|
||||
'\x1b[<0;1;1M',
|
||||
'\x1b[<0;1;1m',
|
||||
'\x1b[<1;1;1M',
|
||||
'\x1b[<1;1;1m'
|
||||
])
|
||||
)
|
||||
expect(openUrl).not.toHaveBeenCalled()
|
||||
openUrl.mockRejectedValueOnce(new Error('System unavailable'))
|
||||
for (const [clientY, uri] of [
|
||||
[7, raw],
|
||||
[27, URL_TO_OPEN]
|
||||
] as const) {
|
||||
pointer(screen, 'mousemove', { buttons: 0, clientY })
|
||||
await waitFor(() => expect(terminal.element?.getAttribute('title')).toBe(uri))
|
||||
pointer(screen, 'mousedown', { ctrlKey: true, shiftKey: true, clientY })
|
||||
pointer(screen, 'mouseup', { ctrlKey: true, shiftKey: true, clientY })
|
||||
}
|
||||
await waitFor(() => expect(openUrl.mock.calls.map(([uri]) => uri)).toEqual([raw, URL_TO_OPEN]))
|
||||
expect(terminal.hasSelection()).toBe(false)
|
||||
pointer(screen, 'mouseleave')
|
||||
expect(terminal.element?.getAttribute('title')).toBeNull()
|
||||
expect(document.querySelector('[data-link-action-popover], [data-sonner-toaster]')).toBeNull()
|
||||
expect(
|
||||
popout.queryByRole('button', { name: /Copy link|System Browser|Terminal link settings/ })
|
||||
).toBeNull()
|
||||
expect(window.api.ui.writeClipboardText).not.toHaveBeenCalled()
|
||||
expect(subscribeToRouter).not.toHaveBeenCalled()
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
fireEvent.click(popout.getByRole('button', { name: 'Close' }))
|
||||
await waitFor(() => expect(document.querySelector('.xterm')).toBeNull())
|
||||
expect(Array.from(census.active)).toEqual(rootOwners)
|
||||
popout.unmount()
|
||||
expect(census.active.size).toBe(0)
|
||||
census.restore()
|
||||
expect(bridges.size).toBe(0)
|
||||
expect(window.api.terminalPreview.unsubscribe).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
}
|
||||
-26
@@ -1,5 +1,4 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Exercise the real forwarder’s exact commit/release bytes; wiring mocks cannot verify negotiated CSI-u or macOS event ordering.
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installPreviewImeBridge } from './preview-terminal-ime-bridge'
|
||||
@@ -19,7 +18,6 @@ type Session = {
|
||||
commit: (text: string) => void
|
||||
/** An input event the text system produced without committing text. */
|
||||
swallow: () => void
|
||||
xtermKittyFlags: () => number
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
@@ -78,12 +76,6 @@ function open(initialFlags: number): Session {
|
||||
keyup: (init = {}) => key('keyup', init),
|
||||
commit: (text) => input(text),
|
||||
swallow: () => input(null),
|
||||
xtermKittyFlags: () =>
|
||||
(
|
||||
terminal as unknown as {
|
||||
_core?: { coreService?: { kittyKeyboard?: { flags?: number } } }
|
||||
}
|
||||
)._core?.coreService?.kittyKeyboard?.flags ?? 0,
|
||||
dispose: () => {
|
||||
bridge.dispose()
|
||||
terminal.dispose()
|
||||
@@ -102,15 +94,6 @@ describe('preview IME bridge outbound bytes', () => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('encodes the commit from the flags proven before any live output', () => {
|
||||
const session = open(8)
|
||||
session.keydown()
|
||||
session.commit(',')
|
||||
session.keyup()
|
||||
expect(session.emitted.join('')).toBe('\x1b[44u')
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['keydown -> insertText -> keyup', false],
|
||||
// Some macOS input sources commit after keyup; preserve press ownership until then.
|
||||
@@ -140,15 +123,6 @@ describe('preview IME bridge outbound bytes', () => {
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('owns the release even though xterm holds no kitty state', () => {
|
||||
const session = open(10)
|
||||
// Keep this harness’s xterm flags at zero to prove the forwarder emits releases without xterm’s help.
|
||||
expect(session.xtermKittyFlags()).toBe(0)
|
||||
type(session)
|
||||
expect(session.emitted.join('')).toBe('\x1b[44u\x1b[44;1:3u')
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('reads the mutable getter exactly once, at commit time', () => {
|
||||
const session = open(0)
|
||||
session.keydown()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
|
||||
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status/contracts/agent-status-freshness'
|
||||
|
||||
import type {
|
||||
AgentStatusClearIpcPayload,
|
||||
AgentStatusIpcPayload
|
||||
@@ -58,37 +58,12 @@ const startViewTransition = vi.fn((cb: () => void) => {
|
||||
})
|
||||
|
||||
const originalVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState')
|
||||
const UNHANDLED_REJECTION_SETTLE_MS = 20
|
||||
|
||||
async function collectUnhandledRejections(run: () => void): Promise<unknown[]> {
|
||||
const reasons: unknown[] = []
|
||||
const onUnhandledRejection = (reason: unknown): void => {
|
||||
reasons.push(reason)
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', onUnhandledRejection)
|
||||
try {
|
||||
run()
|
||||
await new Promise((resolve) => setTimeout(resolve, UNHANDLED_REJECTION_SETTLE_MS))
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandledRejection)
|
||||
}
|
||||
|
||||
return reasons
|
||||
}
|
||||
|
||||
function hideDocument(): void {
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true })
|
||||
}
|
||||
|
||||
/** Mimic Radix’s open-dialog attributes so the top-layer conflict guard recognizes the terminal. */
|
||||
function openTerminalDialog(): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
el.setAttribute('role', 'dialog')
|
||||
el.setAttribute('data-state', 'open')
|
||||
document.body.appendChild(el)
|
||||
return el
|
||||
}
|
||||
|
||||
describe('useDashboardSnapshot', () => {
|
||||
beforeEach(() => {
|
||||
@@ -127,156 +102,6 @@ describe('useDashboardSnapshot', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('runs a view transition when a card changes column', () => {
|
||||
const { result } = renderHook(() => useDashboardSnapshot())
|
||||
act(() => apply(snapshot([card({ bucket: 'idle' })])))
|
||||
startViewTransition.mockClear() // ignore the initial populate
|
||||
|
||||
act(() => apply(snapshot([card({ bucket: 'working' })])))
|
||||
expect(startViewTransition).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.cards[0].bucket).toBe('working')
|
||||
})
|
||||
|
||||
it('skips the view transition — but still applies the update — while the terminal dialog is open', () => {
|
||||
const { result } = renderHook(() => useDashboardSnapshot())
|
||||
act(() => apply(snapshot([card({ bucket: 'idle' })])))
|
||||
startViewTransition.mockClear() // ignore the initial populate
|
||||
openTerminalDialog()
|
||||
|
||||
act(() => apply(snapshot([card({ bucket: 'working' })])))
|
||||
// View-transition snapshots paint above any z-index, including the open terminal dialog.
|
||||
expect(startViewTransition).not.toHaveBeenCalled()
|
||||
expect(result.current.cards[0].bucket).toBe('working')
|
||||
})
|
||||
|
||||
it('skips the view transition — but still applies the update — while the window is hidden', () => {
|
||||
const { result } = renderHook(() => useDashboardSnapshot())
|
||||
act(() => apply(snapshot([card({ bucket: 'idle' })])))
|
||||
startViewTransition.mockClear()
|
||||
hideDocument()
|
||||
|
||||
act(() => apply(snapshot([card({ bucket: 'working' })])))
|
||||
expect(startViewTransition).not.toHaveBeenCalled()
|
||||
expect(result.current.cards[0].bucket).toBe('working')
|
||||
})
|
||||
|
||||
it('swallows the ready rejection when the window is occluded mid-transition', async () => {
|
||||
const { result } = renderHook(() => useDashboardSnapshot())
|
||||
act(() => apply(snapshot([card({ bucket: 'idle' })])))
|
||||
readyRejection = new DOMException(
|
||||
'Transition was aborted because of invalid state',
|
||||
'InvalidStateError'
|
||||
)
|
||||
hideDocumentAfterTransitionStart = true
|
||||
|
||||
const reasons = await collectUnhandledRejections(() => {
|
||||
act(() => apply(snapshot([card({ bucket: 'working' })])))
|
||||
})
|
||||
|
||||
expect(startViewTransition).toHaveBeenCalled()
|
||||
expect(reasons).toEqual([])
|
||||
expect(result.current.cards[0].bucket).toBe('working')
|
||||
})
|
||||
|
||||
it('reports an unrelated ready error when the window becomes hidden', async () => {
|
||||
const { result } = renderHook(() => useDashboardSnapshot())
|
||||
act(() => apply(snapshot([card({ bucket: 'idle' })])))
|
||||
const readyError = new TypeError('Transition callback failed')
|
||||
readyRejection = readyError
|
||||
hideDocumentAfterTransitionStart = true
|
||||
|
||||
const reasons = await collectUnhandledRejections(() => {
|
||||
act(() => apply(snapshot([card({ bucket: 'working' })])))
|
||||
})
|
||||
|
||||
expect(startViewTransition).toHaveBeenCalled()
|
||||
expect(reasons).toEqual([readyError])
|
||||
expect(result.current.cards[0].bucket).toBe('working')
|
||||
})
|
||||
|
||||
it('reports an InvalidStateError when the visible transition fails', async () => {
|
||||
const { result } = renderHook(() => useDashboardSnapshot())
|
||||
act(() => apply(snapshot([card({ bucket: 'idle' })])))
|
||||
const readyError = new DOMException(
|
||||
'Duplicate transition names are not allowed',
|
||||
'InvalidStateError'
|
||||
)
|
||||
readyRejection = readyError
|
||||
|
||||
const reasons = await collectUnhandledRejections(() => {
|
||||
act(() => apply(snapshot([card({ bucket: 'working' })])))
|
||||
})
|
||||
|
||||
expect(startViewTransition).toHaveBeenCalled()
|
||||
expect(reasons).toEqual([readyError])
|
||||
expect(result.current.cards[0].bucket).toBe('working')
|
||||
})
|
||||
|
||||
// Throttled snapshots omit unchanged icons; retain the previous map rather than losing icons on the next tick.
|
||||
it('retains the last repo icons when a republish omits them', () => {
|
||||
const icons = {
|
||||
r1: { type: 'image' as const, src: 'data:image/png;base64,AAAA', source: 'upload' as const }
|
||||
}
|
||||
const { result } = renderHook(() => useDashboardSnapshot())
|
||||
|
||||
act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: icons }))
|
||||
expect(result.current.repoIconsByRepoId).toEqual(icons)
|
||||
|
||||
act(() => apply(snapshot([card({ task: 'moved on' })])))
|
||||
expect(result.current.repoIconsByRepoId).toEqual(icons)
|
||||
expect(result.current.cards[0].task).toBe('moved on')
|
||||
})
|
||||
|
||||
it('replaces retained icons when the bridge sends a new map', () => {
|
||||
const first = {
|
||||
r1: { type: 'image' as const, src: 'data:image/png;base64,AAAA', source: 'upload' as const }
|
||||
}
|
||||
const second = { r1: { type: 'emoji' as const, emoji: '🦑' } }
|
||||
const { result } = renderHook(() => useDashboardSnapshot())
|
||||
|
||||
act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: first }))
|
||||
act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: second }))
|
||||
expect(result.current.repoIconsByRepoId).toEqual(second)
|
||||
|
||||
// An omitted map must retain the NEW icons, not resurrect the old ones.
|
||||
act(() => apply(snapshot([card({})])))
|
||||
expect(result.current.repoIconsByRepoId).toEqual(second)
|
||||
})
|
||||
|
||||
// An explicit empty map clears icons; only omission means retention.
|
||||
it('honours an explicitly empty icon map', () => {
|
||||
const icons = { r1: { type: 'emoji' as const, emoji: '🦑' } }
|
||||
const { result } = renderHook(() => useDashboardSnapshot())
|
||||
|
||||
act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: icons }))
|
||||
act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: {} }))
|
||||
expect(result.current.repoIconsByRepoId).toEqual({})
|
||||
})
|
||||
|
||||
it('applies known hook status without requesting a rebuilt snapshot', () => {
|
||||
const { result } = renderHook(() => useDashboardSnapshot())
|
||||
act(() => apply(snapshot([card({ statusUpdatedAt: 100 })])))
|
||||
requestSnapshot.mockClear()
|
||||
|
||||
act(() =>
|
||||
applyStatus({
|
||||
paneKey: 'pk',
|
||||
state: 'blocked',
|
||||
prompt: 'Choose',
|
||||
connectionId: null,
|
||||
receivedAt: 300,
|
||||
stateStartedAt: 250
|
||||
})
|
||||
)
|
||||
|
||||
expect(requestSnapshot).not.toHaveBeenCalled()
|
||||
expect(result.current.cards[0]).toMatchObject({
|
||||
bucket: 'attention',
|
||||
task: 'Choose',
|
||||
statusUpdatedAt: 300
|
||||
})
|
||||
})
|
||||
|
||||
it('trailing-debounces an unknown-pane burst into one topology refresh', () => {
|
||||
vi.useFakeTimers()
|
||||
renderHook(() => useDashboardSnapshot())
|
||||
@@ -333,31 +158,4 @@ describe('useDashboardSnapshot', () => {
|
||||
expect(requestSnapshot).toHaveBeenCalledTimes(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('refreshes when a monitoring card crosses the stale boundary', () => {
|
||||
vi.useFakeTimers()
|
||||
const statusUpdatedAt = 1_000
|
||||
vi.setSystemTime(statusUpdatedAt)
|
||||
renderHook(() => useDashboardSnapshot())
|
||||
act(() =>
|
||||
apply(
|
||||
snapshot([
|
||||
card({
|
||||
dotState: 'working',
|
||||
workingMode: 'monitoring',
|
||||
statusUpdatedAt
|
||||
})
|
||||
])
|
||||
)
|
||||
)
|
||||
requestSnapshot.mockClear()
|
||||
|
||||
act(() => vi.advanceTimersByTime(AGENT_STATUS_STALE_AFTER_MS))
|
||||
expect(requestSnapshot).not.toHaveBeenCalled()
|
||||
act(() => vi.advanceTimersByTime(250))
|
||||
expect(requestSnapshot).not.toHaveBeenCalled()
|
||||
act(() => vi.advanceTimersByTime(1))
|
||||
expect(requestSnapshot).toHaveBeenCalledTimes(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
|
||||
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import DashboardAgentRow from './DashboardAgentRow'
|
||||
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
|
||||
|
||||
const NOW = 120_000
|
||||
|
||||
function makeAgent(
|
||||
overrides: Partial<DashboardAgentRowData> = {},
|
||||
entryOverrides: Partial<AgentStatusEntry> = {}
|
||||
): DashboardAgentRowData {
|
||||
const paneKey = overrides.paneKey ?? 'tab-1:leaf-1'
|
||||
const tab: TerminalTab = {
|
||||
id: 'tab-1',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-1',
|
||||
title: 'Terminal 1',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
const entry: AgentStatusEntry = {
|
||||
state: 'working',
|
||||
prompt: 'Fix hover scope',
|
||||
updatedAt: 60_000,
|
||||
stateStartedAt: 60_000,
|
||||
agentType: 'codex',
|
||||
paneKey,
|
||||
stateHistory: [],
|
||||
...entryOverrides
|
||||
}
|
||||
|
||||
return {
|
||||
paneKey,
|
||||
entry,
|
||||
tab,
|
||||
agentType: entry.agentType ?? 'codex',
|
||||
state: entry.state,
|
||||
startedAt: entry.stateStartedAt,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderRow(agent: DashboardAgentRowData): string {
|
||||
return renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<DashboardAgentRow
|
||||
agent={agent}
|
||||
onDismiss={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
now={NOW}
|
||||
hideIdentityIcon
|
||||
hideExpand
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function renderSendTargetRow(
|
||||
props: Pick<
|
||||
ComponentProps<typeof DashboardAgentRow>,
|
||||
'sendTargetStatus' | 'sendTargetDisabledReason'
|
||||
>
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<DashboardAgentRow
|
||||
agent={makeAgent()}
|
||||
onDismiss={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
now={NOW}
|
||||
hideIdentityIcon
|
||||
hideExpand
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function classAttributes(markup: string): string[] {
|
||||
return Array.from(markup.matchAll(/class="([^"]*)"/g), (match) => match[1])
|
||||
}
|
||||
|
||||
function classTokens(markup: string): string[] {
|
||||
return classAttributes(markup).flatMap((className) => className.split(/\s+/).filter(Boolean))
|
||||
}
|
||||
|
||||
function hoverSwapClasses(markup: string): string[] {
|
||||
return classAttributes(markup).filter(
|
||||
(className) =>
|
||||
className.includes('group-hover') || className.includes('focus-visible:opacity-100')
|
||||
)
|
||||
}
|
||||
|
||||
function dismissButtonClass(markup: string): string {
|
||||
const match = markup.match(/<button\b(?=[^>]*aria-label="Dismiss agent")[^>]*class="([^"]*)"/)
|
||||
if (!match) {
|
||||
throw new Error('Expected dismiss agent button in rendered markup')
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
function dismissButtonClassTokens(markup: string): string[] {
|
||||
return dismissButtonClass(markup).split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
function tokenCount(markup: string, token: string): number {
|
||||
return classTokens(markup).filter((classToken) => classToken === token).length
|
||||
}
|
||||
|
||||
function classTokensForTaggedElement(markup: string, dataAttribute: string): string[] {
|
||||
const tagMatch = markup.match(new RegExp(`<[^>]+${dataAttribute}[^>]*>`))
|
||||
if (!tagMatch) {
|
||||
throw new Error(`Expected tagged element for ${dataAttribute}`)
|
||||
}
|
||||
const classMatch = tagMatch[0].match(/class="([^"]*)"/)
|
||||
if (!classMatch) {
|
||||
throw new Error(`Expected class attribute for ${dataAttribute}`)
|
||||
}
|
||||
return classMatch[1].split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
describe('DashboardAgentRow', () => {
|
||||
it('renders orchestration task preview instead of the raw dispatch preamble prompt', () => {
|
||||
const markup = renderRow(
|
||||
makeAgent(
|
||||
{},
|
||||
{
|
||||
prompt: 'You are working inside Orca, a multi-agent IDE.',
|
||||
orchestration: {
|
||||
taskId: 'task-1',
|
||||
dispatchId: 'ctx-1',
|
||||
taskTitle: 'Checkout race',
|
||||
displayName: 'Fix checkout race'
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(markup).toContain('Fix checkout race')
|
||||
expect(markup).not.toContain('You are working inside Orca')
|
||||
})
|
||||
|
||||
it('shows the active model beside the agent label', () => {
|
||||
const markup = renderRow(makeAgent({}, { model: 'gpt-5.4-mini' }))
|
||||
|
||||
expect(markup).toContain('title="gpt-5.4-mini"')
|
||||
expect(markup).toContain('>gpt-5.4-mini</span>')
|
||||
expect(classTokens(markup)).toContain('font-mono')
|
||||
})
|
||||
|
||||
it('uses the hover background as the focused-pane row highlight', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<DashboardAgentRow
|
||||
agent={makeAgent()}
|
||||
onDismiss={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
now={NOW}
|
||||
hideIdentityIcon
|
||||
hideExpand
|
||||
isFocusedPane
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
expect(markup).toContain('data-focused-agent-pane="true"')
|
||||
expect(classTokens(markup)).toContain('worktree-agent-row-hover')
|
||||
})
|
||||
|
||||
it('marks eligible send-target rows with an inline send target button', () => {
|
||||
const markup = renderSendTargetRow({ sendTargetStatus: 'eligible' })
|
||||
const tokens = classTokens(markup)
|
||||
|
||||
expect(markup).toContain('data-agent-send-target="eligible"')
|
||||
expect(tokens).not.toContain('worktree-agent-send-target')
|
||||
expect(tokens).not.toContain('ring-offset-sidebar')
|
||||
expect(tokens).toContain('worktree-agent-row-hover')
|
||||
expect(markup).toContain('aria-label="Send to this agent"')
|
||||
expect(tokens).toContain('worktree-agent-send-target-button')
|
||||
expect(tokens).toContain('h-5')
|
||||
expect(markup).toContain('lucide-send')
|
||||
expect(markup).not.toContain('aria-label="Dismiss agent"')
|
||||
})
|
||||
|
||||
it('marks disabled send-target rows as muted without an eligibility ring', () => {
|
||||
const markup = renderSendTargetRow({
|
||||
sendTargetStatus: 'disabled',
|
||||
sendTargetDisabledReason: 'Terminal is no longer available'
|
||||
})
|
||||
const tokens = classTokens(markup)
|
||||
|
||||
expect(markup).toContain('data-agent-send-target="disabled"')
|
||||
expect(markup).toContain('title="Terminal is no longer available • started 1m ago"')
|
||||
expect(tokens).toContain('cursor-default')
|
||||
expect(tokens).toContain('opacity-60')
|
||||
expect(tokens).not.toContain('worktree-agent-send-target')
|
||||
expect(tokens).not.toContain('ring-offset-sidebar')
|
||||
expect(markup).not.toContain('aria-label="Send to this agent"')
|
||||
})
|
||||
|
||||
it('marks sending rows with a non-clickable progress treatment', () => {
|
||||
const markup = renderSendTargetRow({
|
||||
sendTargetStatus: 'sending',
|
||||
sendTargetDisabledReason: 'Sending...'
|
||||
})
|
||||
const tokens = classTokens(markup)
|
||||
|
||||
expect(markup).toContain('data-agent-send-target="sending"')
|
||||
expect(markup).toContain('title="Sending... • started 1m ago"')
|
||||
expect(tokens).not.toContain('worktree-agent-send-target')
|
||||
expect(tokens).not.toContain('ring-offset-sidebar')
|
||||
expect(tokens).toContain('worktree-agent-send-target-button')
|
||||
expect(tokens).toContain('cursor-progress')
|
||||
expect(tokens).toContain('opacity-75')
|
||||
expect(markup).toContain('aria-label="Send to this agent"')
|
||||
expect(markup).toContain('disabled=""')
|
||||
})
|
||||
|
||||
it('scopes the timestamp and dismiss hover swap to the row-owned group', () => {
|
||||
const markup = renderRow(makeAgent())
|
||||
const classes = hoverSwapClasses(markup)
|
||||
const tokens = classTokens(markup)
|
||||
|
||||
expect(tokens).toContain('group/agent-row')
|
||||
expect(tokens).toContain('group-hover/agent-row:opacity-0')
|
||||
expect(dismissButtonClassTokens(markup)).toContain('group-hover/agent-row:opacity-100')
|
||||
expect(dismissButtonClassTokens(markup)).toContain('focus-visible:opacity-100')
|
||||
expect(classes.every((className) => !/\bgroup-hover:/.test(className))).toBe(true)
|
||||
})
|
||||
|
||||
it('uses the row-owned group for the standalone dismiss control without timestamps', () => {
|
||||
const markup = renderRow(
|
||||
makeAgent({ startedAt: 0 }, { updatedAt: 0, stateStartedAt: 0, stateHistory: [] })
|
||||
)
|
||||
const classes = hoverSwapClasses(markup)
|
||||
|
||||
expect(dismissButtonClassTokens(markup)).toContain('group-hover/agent-row:opacity-100')
|
||||
expect(dismissButtonClassTokens(markup)).toContain('focus-visible:opacity-100')
|
||||
expect(classes.every((className) => !/\bgroup-hover:/.test(className))).toBe(true)
|
||||
})
|
||||
|
||||
it('renders waiting rows with the shared question glyph', () => {
|
||||
const markup = renderRow(makeAgent({}, { state: 'waiting' }))
|
||||
const tokens = classTokens(markup)
|
||||
|
||||
expect(markup).toContain('aria-label="Waiting for input"')
|
||||
expect(markup).toContain('lucide-message-circle-question-mark')
|
||||
expect(tokens).toContain('text-agent-question')
|
||||
expect(tokens).not.toContain('text-amber-500')
|
||||
expect(tokens).not.toContain('bg-red-500')
|
||||
})
|
||||
|
||||
it('keeps blocked rows red', () => {
|
||||
const markup = renderRow(makeAgent({}, { state: 'blocked' }))
|
||||
const tokens = classTokens(markup)
|
||||
|
||||
expect(markup).toContain('aria-label="Blocked"')
|
||||
expect(tokens).toContain('bg-red-500')
|
||||
expect(tokens).not.toContain('bg-amber-500')
|
||||
})
|
||||
|
||||
it('keeps each row hover boundary inside an anonymous ancestor group', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<div className="group">
|
||||
<DashboardAgentRow
|
||||
agent={makeAgent({ paneKey: 'tab-1:leaf-1' })}
|
||||
onDismiss={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
now={NOW}
|
||||
hideIdentityIcon
|
||||
hideExpand
|
||||
/>
|
||||
<DashboardAgentRow
|
||||
agent={makeAgent({ paneKey: 'tab-1:leaf-2' })}
|
||||
onDismiss={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
now={NOW}
|
||||
hideIdentityIcon
|
||||
hideExpand
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
const classes = hoverSwapClasses(markup)
|
||||
|
||||
expect(markup).toContain('class="group"')
|
||||
expect(tokenCount(markup, 'group/agent-row')).toBe(2)
|
||||
expect(tokenCount(markup, 'group-hover/agent-row:opacity-100')).toBe(2)
|
||||
expect(tokenCount(markup, 'group-hover/agent-row:opacity-0')).toBe(2)
|
||||
expect(classes.every((className) => !/\bgroup-hover:/.test(className))).toBe(true)
|
||||
})
|
||||
|
||||
function renderEndedRow(ending: Partial<AgentStatusEntry>): string {
|
||||
return renderRow(
|
||||
makeAgent(
|
||||
{ state: 'done', startedAt: 1_000 },
|
||||
{
|
||||
state: 'done',
|
||||
prompt: 'Give me a quick update',
|
||||
updatedAt: 2_000,
|
||||
stateStartedAt: 2_000,
|
||||
stateHistory: [{ state: 'working', prompt: 'Give me a quick update', startedAt: 1_000 }],
|
||||
...ending
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
it('renders a crash-cut row as failed, with the red dot and no interrupted tag', () => {
|
||||
const markup = renderEndedRow({
|
||||
mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: 2_000 }
|
||||
})
|
||||
|
||||
expect(markup).toContain('aria-label="Failed"')
|
||||
expect(markup).toContain('bg-red-500')
|
||||
expect(markup).not.toContain('>interrupted<')
|
||||
expect(markup).not.toContain('lucide-circle-check')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['recorded', { mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 2_000 } }],
|
||||
["an old host's flag", { interrupted: true }]
|
||||
] as const)(
|
||||
"renders a user's Stop (%s) as interrupted, with plain text on the secondary line",
|
||||
(_, ending) => {
|
||||
const markup = renderEndedRow(ending)
|
||||
const promptIndex = markup.indexOf('Give me a quick update')
|
||||
const interruptedIndex = markup.indexOf('>interrupted<')
|
||||
|
||||
expect(markup).toContain('data-slot="tooltip-trigger"')
|
||||
expect(markup).toContain('aria-label="Interrupted by user"')
|
||||
expect(markup).not.toContain('data-slot="badge"')
|
||||
expect(interruptedIndex).toBeGreaterThan(promptIndex)
|
||||
expect(markup).toContain('bg-muted-foreground')
|
||||
expect(markup).not.toContain('bg-red-500')
|
||||
expect(markup).not.toContain('lucide-circle-check')
|
||||
}
|
||||
)
|
||||
|
||||
it('renders a turn a newer request replaced as interrupted, naming no one', () => {
|
||||
const markup = renderEndedRow({
|
||||
mainAgent: { state: 'done', outcome: 'superseded', stateStartedAt: 2_000 }
|
||||
})
|
||||
|
||||
expect(markup).toContain('aria-label="Interrupted"')
|
||||
expect(markup).not.toContain('Interrupted by user')
|
||||
expect(markup).toContain('>interrupted<')
|
||||
expect(markup).toContain('bg-muted-foreground')
|
||||
})
|
||||
|
||||
it('reserves a real working tool line before tool metadata arrives', () => {
|
||||
const emptyToolMarkup = renderRow(makeAgent())
|
||||
const activeToolMarkup = renderRow(
|
||||
makeAgent({}, { toolName: 'ListDir', toolInput: '/Users/nwparker/orca' })
|
||||
)
|
||||
|
||||
// Antigravity working hooks can omit tool metadata; preserve the second-line slot to prevent height jumps.
|
||||
expect(emptyToolMarkup).toContain('data-agent-row-tool-slot=""')
|
||||
expect(activeToolMarkup).toContain('data-agent-row-tool-slot=""')
|
||||
expect(
|
||||
classTokensForTaggedElement(emptyToolMarkup, 'data-agent-row-tool-placeholder="true"')
|
||||
).toContain('h-[1lh]')
|
||||
expect(
|
||||
classTokensForTaggedElement(activeToolMarkup, 'data-agent-row-tool-header="true"')
|
||||
).toContain('h-[1lh]')
|
||||
expect(emptyToolMarkup).not.toContain('lucide-wrench')
|
||||
expect(activeToolMarkup).toContain('lucide-wrench')
|
||||
expect(activeToolMarkup).toContain('ListDir')
|
||||
})
|
||||
|
||||
it("says Stopping in place of the tool line while a person's Stop ends the turn", () => {
|
||||
const markup = renderRow(
|
||||
makeAgent(
|
||||
{},
|
||||
{
|
||||
toolName: 'Bash',
|
||||
toolInput: 'pnpm test',
|
||||
mainAgent: { state: 'working', stopping: true, stateStartedAt: 60_000 }
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(markup).toContain('Stopping…')
|
||||
expect(markup).not.toContain('lucide-wrench')
|
||||
expect(markup).toContain('data-agent-spinner')
|
||||
})
|
||||
|
||||
it('renders monitoring without a spinner or active tool line', () => {
|
||||
const markup = renderRow(
|
||||
makeAgent(
|
||||
{},
|
||||
{
|
||||
workingMode: 'monitoring',
|
||||
prompt: '',
|
||||
toolName: 'Bash',
|
||||
toolInput: 'pnpm dev'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(markup).toContain('Monitoring background tasks')
|
||||
expect(markup).toContain('lucide-activity')
|
||||
expect(classTokens(markup)).toContain('text-agent-working')
|
||||
expect(markup).not.toContain('data-agent-spinner')
|
||||
expect(markup).not.toContain('data-agent-row-tool-slot')
|
||||
})
|
||||
|
||||
it('names what a blocked approval is waiting on', () => {
|
||||
const markup = renderRow(
|
||||
makeAgent(
|
||||
{ state: 'waiting' },
|
||||
{ state: 'waiting', toolName: 'bash', toolInput: 'rm -rf build/' }
|
||||
)
|
||||
)
|
||||
|
||||
// Waiting approvals need the tool line to explain what the amber state asks the reader to approve.
|
||||
expect(markup).toContain('lucide-wrench')
|
||||
expect(markup).toContain('bash')
|
||||
expect(markup).toContain('rm -rf build/')
|
||||
})
|
||||
|
||||
it('leaves a wait without tool metadata unchanged', () => {
|
||||
const markup = renderRow(makeAgent({ state: 'waiting' }, { state: 'waiting' }))
|
||||
|
||||
// Question waits expect no tool metadata; reserve height only for imminent working details.
|
||||
expect(markup).not.toContain('data-agent-row-tool-slot=""')
|
||||
expect(markup).not.toContain('lucide-wrench')
|
||||
})
|
||||
|
||||
it('keeps a resolved tool off a finished row', () => {
|
||||
for (const state of ['done', 'blocked', 'idle'] as const) {
|
||||
const markup = renderRow(
|
||||
makeAgent(
|
||||
{ state },
|
||||
// Why: 'idle' is a row-only state; the entry it is derived from still reports 'done'.
|
||||
{ state: state === 'idle' ? 'done' : state, toolName: 'bash', toolInput: 'rm -rf build/' }
|
||||
)
|
||||
)
|
||||
|
||||
expect(markup).not.toContain('lucide-wrench')
|
||||
expect(markup).not.toContain('rm -rf build/')
|
||||
}
|
||||
})
|
||||
|
||||
it('renders orchestration child rows with a connector and tree level', () => {
|
||||
const markup = renderRow(
|
||||
makeAgent({
|
||||
lineage: {
|
||||
depth: 1,
|
||||
isFirstSibling: true,
|
||||
isLastSibling: true,
|
||||
childCount: 0
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(markup).toContain('data-agent-lineage-connector="last"')
|
||||
expect(markup).toContain('role="treeitem"')
|
||||
expect(markup).toContain('aria-level="2"')
|
||||
expect(classTokens(markup)).toContain('pl-5')
|
||||
expect(classTokens(markup)).toContain('left-[13px]')
|
||||
expect(classTokens(markup)).toContain('top-[0.7rem]')
|
||||
expect(classTokens(markup)).toContain('w-1.5')
|
||||
expect(classTokens(markup)).toContain('border-l-[1.5px]')
|
||||
expect(classTokens(markup)).toContain('border-t-[1.5px]')
|
||||
expect(classTokens(markup)).toContain('border-muted-foreground/45')
|
||||
})
|
||||
|
||||
it('annotates parent identity icon when it dispatched children', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<DashboardAgentRow
|
||||
agent={makeAgent({
|
||||
lineage: {
|
||||
depth: 0,
|
||||
isFirstSibling: true,
|
||||
isLastSibling: true,
|
||||
childCount: 2
|
||||
}
|
||||
})}
|
||||
onDismiss={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
now={NOW}
|
||||
hideExpand
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
expect(markup).toContain('title="Codex - dispatched 2 agents"')
|
||||
expect(markup).toContain('data-agent-lineage-parent-connector="true"')
|
||||
expect(classTokens(markup)).toContain('left-[13px]')
|
||||
expect(markup).toContain('aria-level="1"')
|
||||
})
|
||||
|
||||
it('marks child-disclosure rows as lineage manager rows', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<DashboardAgentRow
|
||||
agent={makeAgent()}
|
||||
onDismiss={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
now={NOW}
|
||||
hideIdentityIcon
|
||||
hideExpand
|
||||
childAgentCount={2}
|
||||
childAgentsExpanded={false}
|
||||
onToggleChildAgents={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
expect(classTokens(markup)).toContain('worktree-agent-lineage-parent-row')
|
||||
expect(markup).toContain('aria-label="Show 2 child agents"')
|
||||
})
|
||||
|
||||
describe('with an OSC 7501 program status', () => {
|
||||
it('says a block the program reports in place of the tool step', () => {
|
||||
const markup = renderRow(
|
||||
makeAgent(
|
||||
{},
|
||||
{
|
||||
toolName: 'Bash',
|
||||
toolInput: 'pnpm test',
|
||||
programStatus: {
|
||||
state: 'blocked',
|
||||
kind: 'auth',
|
||||
message: 'Session expired',
|
||||
observedAt: 1
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(markup).toContain('Sign-in required: Session expired')
|
||||
expect(markup).not.toContain('lucide-wrench')
|
||||
})
|
||||
|
||||
it('says nothing for an approval the program reports after the turn finished', () => {
|
||||
const markup = renderRow(
|
||||
makeAgent(
|
||||
{},
|
||||
{
|
||||
state: 'done',
|
||||
programStatus: {
|
||||
state: 'blocked',
|
||||
kind: 'permission',
|
||||
message: 'Approve plan',
|
||||
observedAt: 1
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(markup).not.toContain('Approve plan')
|
||||
expect(markup).not.toContain('Needs approval')
|
||||
})
|
||||
|
||||
it("falls back to the program's message when the row names no tool", () => {
|
||||
const markup = renderRow(
|
||||
makeAgent(
|
||||
{},
|
||||
{ programStatus: { state: 'working', message: 'Running tests', observedAt: 1 } }
|
||||
)
|
||||
)
|
||||
|
||||
expect(markup).toContain('data-agent-row-tool-input="true"')
|
||||
expect(markup).toContain('Running tests')
|
||||
})
|
||||
|
||||
it("shows a blocked program row's message, which has no tool step to ride", () => {
|
||||
const markup = renderRow(
|
||||
makeAgent(
|
||||
{},
|
||||
{
|
||||
state: 'blocked',
|
||||
agentType: 'gh',
|
||||
programStatus: {
|
||||
state: 'blocked',
|
||||
kind: 'auth',
|
||||
message: 'Run gh auth login',
|
||||
observedAt: 1
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect(markup).toContain('Sign-in required: Run gh auth login')
|
||||
})
|
||||
|
||||
it("keeps a real agent's input without a tool name off the row", () => {
|
||||
const markup = renderRow(makeAgent({}, { toolInput: 'src/app.ts' }))
|
||||
|
||||
expect(markup).not.toContain('src/app.ts')
|
||||
expect(markup).toContain('data-agent-row-tool-placeholder="true"')
|
||||
})
|
||||
|
||||
it('labels the progress ring with the same percentage the tooltip shows', () => {
|
||||
const markup = renderRow(
|
||||
makeAgent({}, { programStatus: { state: 'working', progress: 42, observedAt: 1 } })
|
||||
)
|
||||
|
||||
expect(markup).toContain('role="progressbar"')
|
||||
expect(markup).toContain('aria-label="Working · 42%"')
|
||||
expect(markup).not.toContain('>42%<')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user