mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
* feat(web): add Copy reference to session context menu Refs tiann/hapi#950 Adds a More actions item that copies a cross-session citation (see session "title" (/sessions/id) for context) instead of a bare share URL. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): sanitize session titles in copy-reference text JSON-escape titles and collapse whitespace so arbitrary session names cannot inject prompt text into cross-session citations. Addresses Codex review on tiann/hapi#951. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
172 lines
5.9 KiB
TypeScript
172 lines
5.9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
import { I18nProvider } from '@/lib/i18n-context'
|
|
import { SessionActionMenu } from '@/components/SessionActionMenu'
|
|
|
|
vi.mock('@/hooks/usePlatform', () => ({
|
|
usePlatform: () => ({
|
|
haptic: { notification: vi.fn(), impact: vi.fn() },
|
|
}),
|
|
}))
|
|
|
|
afterEach(() => cleanup())
|
|
|
|
function renderMenu(overrides: Partial<React.ComponentProps<typeof SessionActionMenu>> = {}) {
|
|
const defaults: React.ComponentProps<typeof SessionActionMenu> = {
|
|
isOpen: true,
|
|
onClose: vi.fn(),
|
|
sessionId: 'sess-123',
|
|
sessionTitle: 'Test session',
|
|
sessionActive: false,
|
|
onRename: vi.fn(),
|
|
onArchive: vi.fn(),
|
|
onReopen: vi.fn(),
|
|
onDelete: vi.fn(),
|
|
anchorPoint: { x: 0, y: 0 },
|
|
}
|
|
const merged = { ...defaults, ...overrides }
|
|
return {
|
|
...render(
|
|
<I18nProvider>
|
|
<SessionActionMenu {...merged} />
|
|
</I18nProvider>
|
|
),
|
|
props: merged
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('SessionActionMenu - Reopen action', () => {
|
|
it('renders the Reopen item on inactive sessions when onReopen is provided', () => {
|
|
renderMenu({ sessionActive: false })
|
|
|
|
expect(screen.getByRole('menuitem', { name: /Reopen/ })).toBeInTheDocument()
|
|
})
|
|
|
|
it('does not render the Reopen item on active sessions', () => {
|
|
renderMenu({ sessionActive: true })
|
|
|
|
expect(screen.queryByRole('menuitem', { name: /Reopen/ })).toBeNull()
|
|
})
|
|
|
|
it('does not render the Reopen item when onReopen is omitted (back-compat)', () => {
|
|
renderMenu({ sessionActive: false, onReopen: undefined })
|
|
|
|
expect(screen.queryByRole('menuitem', { name: /Reopen/ })).toBeNull()
|
|
// Delete item is still present for inactive sessions.
|
|
expect(screen.getByRole('menuitem', { name: /Delete/ })).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders a disabled Reopen item with an explanation when resume data is missing', () => {
|
|
const onClose = vi.fn()
|
|
renderMenu({
|
|
sessionActive: false,
|
|
onReopen: undefined,
|
|
reopenDisabledReason: 'Cursor chat data is no longer available on this machine.',
|
|
onClose,
|
|
})
|
|
|
|
const reopen = screen.getByRole('menuitem', { name: /Reopen/ })
|
|
expect(reopen).toHaveAttribute('aria-disabled', 'true')
|
|
expect(screen.getByRole('tooltip')).toHaveTextContent('Cursor chat data is no longer available')
|
|
|
|
fireEvent.click(reopen)
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('fires onReopen and closes the menu when the Reopen item is clicked', () => {
|
|
const onReopen = vi.fn()
|
|
const onClose = vi.fn()
|
|
renderMenu({ sessionActive: false, onReopen, onClose })
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /Reopen/ }))
|
|
|
|
expect(onReopen).toHaveBeenCalledTimes(1)
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('renders Reopen alongside Delete for inactive sessions', () => {
|
|
renderMenu({ sessionActive: false })
|
|
|
|
expect(screen.getByRole('menuitem', { name: /Reopen/ })).toBeInTheDocument()
|
|
expect(screen.getByRole('menuitem', { name: /Delete/ })).toBeInTheDocument()
|
|
// Archive should not show up for inactive sessions (it is the active-session destructive).
|
|
expect(screen.queryByRole('menuitem', { name: /Archive/ })).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('SessionActionMenu - Codex sync action', () => {
|
|
it('renders Sync from Codex only when a handler is provided', () => {
|
|
const { rerender } = renderMenu({ onSyncCodex: undefined })
|
|
|
|
expect(screen.queryByRole('menuitem', { name: /Sync from Codex/ })).toBeNull()
|
|
|
|
rerender(
|
|
<I18nProvider>
|
|
<SessionActionMenu
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
sessionId="sess-123"
|
|
sessionTitle="Test session"
|
|
sessionActive={false}
|
|
onRename={vi.fn()}
|
|
onExport={vi.fn()}
|
|
onSyncCodex={vi.fn()}
|
|
onArchive={vi.fn()}
|
|
onReopen={vi.fn()}
|
|
onDelete={vi.fn()}
|
|
anchorPoint={{ x: 0, y: 0 }}
|
|
/>
|
|
</I18nProvider>
|
|
)
|
|
|
|
expect(screen.getByRole('menuitem', { name: /Sync from Codex/ })).toBeInTheDocument()
|
|
})
|
|
|
|
it('fires onSyncCodex and closes the menu when clicked', () => {
|
|
const onSyncCodex = vi.fn()
|
|
const onClose = vi.fn()
|
|
renderMenu({ onSyncCodex, onClose })
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /Sync from Codex/ }))
|
|
|
|
expect(onSyncCodex).toHaveBeenCalledTimes(1)
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe('SessionActionMenu - Copy reference action', () => {
|
|
it('renders the Copy reference item', () => {
|
|
renderMenu()
|
|
|
|
expect(screen.getByRole('menuitem', { name: /Copy reference/ })).toBeInTheDocument()
|
|
})
|
|
|
|
it('copies a session citation and closes the menu when Copy reference is clicked', async () => {
|
|
const writeText = vi.fn().mockResolvedValue(undefined)
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
value: { writeText },
|
|
configurable: true,
|
|
})
|
|
|
|
const onClose = vi.fn()
|
|
renderMenu({
|
|
sessionId: 'abc-def',
|
|
sessionTitle: 'upstream issue/pr discovery',
|
|
onClose,
|
|
})
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /Copy reference/ }))
|
|
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
await vi.waitFor(() => {
|
|
expect(writeText).toHaveBeenCalledWith(
|
|
'See session "upstream issue/pr discovery" (/sessions/abc-def) for context'
|
|
)
|
|
})
|
|
})
|
|
})
|