test: remove fake menu and lowlight parity suites (#38383)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-04 21:27:38 -08:00
committed by GitHub
co-authored by OrcaWin
parent 34350b70f3
commit f447fd1c9b
12 changed files with 7 additions and 3183 deletions
-3
View File
@@ -6970,12 +6970,10 @@
"src/renderer/src/components/editor/MonacoEditor.owner-view-state.test.tsx": 7073,
"src/renderer/src/components/editor/MonacoEditor.system-theme.test.tsx": 2864,
"src/renderer/src/components/editor/MonacoGutterContextMenu.owner.test.tsx": 1224,
"src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.test.tsx": 278,
"src/renderer/src/components/editor/PdfFind.test.tsx": 581,
"src/renderer/src/components/editor/PluginBytesCustomEditorSurface.test.tsx": 641,
"src/renderer/src/components/editor/PluginEditorNeedsDesktopNotice.test.tsx": 2107,
"src/renderer/src/components/editor/PluginFileTypeSuggestion.test.tsx": 2062,
"src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.test.tsx": 722,
"src/renderer/src/components/editor/rich-markdown/instance/RichMarkdownEditor.focus-leave.test.tsx": 4927,
"src/renderer/src/components/editor/rich-markdown/instance/RichMarkdownErrorBoundary.lazy-chunk.test.tsx": 367,
"src/renderer/src/components/editor/ai-vault-session-log-content.test.tsx": 1899,
@@ -7141,7 +7139,6 @@
"src/renderer/src/components/editor/rich-markdown/lists/rich-markdown-list-outdent.test.ts": 2695,
"src/renderer/src/components/editor/rich-markdown/images/rich-markdown-local-image.test.ts": 2627,
"src/renderer/src/components/editor/rich-markdown/code-blocks/rich-markdown-lowlight-ownership.test.ts": 312,
"src/renderer/src/components/editor/rich-markdown/code-blocks/rich-markdown-lowlight.test.ts": 2369,
"src/renderer/src/components/editor/rich-markdown/lists/rich-markdown-mixed-task-lists.test.ts": 2110,
"src/renderer/src/components/editor/rich-markdown/schema/rich-markdown-normalize.test.ts": 176,
"src/renderer/src/components/editor/rich-markdown/lists/rich-markdown-ordered-list.test.ts": 2571,
@@ -1,552 +0,0 @@
import React from 'react'
import type * as ReactModule from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildNotesSendTargetModeId, NotesSendMenu } from './NotesSendMenu'
import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments'
import { resetNotesInFlightForTests } from '@/lib/notes-send-in-flight'
type ReactElementLike = {
type: unknown
props: Record<string, unknown>
}
type TestNote = DiffCommentDeliverySnapshot
function note(id: string): TestNote {
return { id, body: `body of ${id}`, filePath: 'README.md', lineNumber: 1 }
}
const hookRuntime = vi.hoisted(() => ({
states: [] as unknown[],
index: 0,
cleanups: [] as (() => void)[]
}))
const storeMocks = vi.hoisted(() => ({
openAgentSendPopoverTargetMode: vi.fn(),
closeAgentSendPopoverTargetMode: vi.fn(),
state: {
agentSendPopoverTargetMode: null as { id: string } | null
}
}))
vi.mock('react', async () => {
const actual = await vi.importActual<typeof ReactModule>('react')
return {
...actual,
useCallback<T extends (...args: never[]) => unknown>(callback: T): T {
return callback
},
useEffect(effect: () => void | (() => void)): void {
const cleanup = effect()
if (typeof cleanup === 'function') {
hookRuntime.cleanups.push(cleanup)
}
},
useMemo<T>(factory: () => T): T {
return factory()
},
useSyncExternalStore<T>(_subscribe: unknown, getSnapshot: () => T): T {
return getSnapshot()
},
useState<T>(initial: T | (() => T)) {
const stateIndex = hookRuntime.index++
if (!(stateIndex in hookRuntime.states)) {
hookRuntime.states[stateIndex] =
typeof initial === 'function' ? (initial as () => T)() : initial
}
const setState = (next: T | ((previous: T) => T)): void => {
hookRuntime.states[stateIndex] =
typeof next === 'function'
? (next as (previous: T) => T)(hookRuntime.states[stateIndex] as T)
: next
}
return [hookRuntime.states[stateIndex] as T, setState] as const
}
}
})
vi.mock('@/store/index', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
openAgentSendPopoverTargetMode: storeMocks.openAgentSendPopoverTargetMode,
closeAgentSendPopoverTargetMode: storeMocks.closeAgentSendPopoverTargetMode,
agentSendPopoverTargetMode: storeMocks.state.agentSendPopoverTargetMode,
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {}
})
}))
vi.mock('zustand/react/shallow', () => ({
useShallow: (selector: unknown) => selector
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: function DropdownMenu(props: Record<string, unknown>) {
return { type: 'DropdownMenu', props }
},
DropdownMenuContent: function DropdownMenuContent(props: Record<string, unknown>) {
return { type: 'DropdownMenuContent', props }
},
DropdownMenuItem: function DropdownMenuItem(props: Record<string, unknown>) {
return { type: 'DropdownMenuItem', props }
},
DropdownMenuLabel: function DropdownMenuLabel(props: Record<string, unknown>) {
return { type: 'DropdownMenuLabel', props }
},
DropdownMenuSeparator: function DropdownMenuSeparator(props: Record<string, unknown>) {
return { type: 'DropdownMenuSeparator', props }
},
DropdownMenuSub: function DropdownMenuSub(props: Record<string, unknown>) {
return { type: 'DropdownMenuSub', props }
},
DropdownMenuSubContent: function DropdownMenuSubContent(props: Record<string, unknown>) {
return { type: 'DropdownMenuSubContent', props }
},
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: Record<string, unknown>) {
return { type: 'DropdownMenuSubTrigger', props }
},
DropdownMenuTrigger: function DropdownMenuTrigger(props: Record<string, unknown>) {
return { type: 'DropdownMenuTrigger', props }
}
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: function Tooltip(props: Record<string, unknown>) {
return { type: 'Tooltip', props }
},
TooltipContent: function TooltipContent(props: Record<string, unknown>) {
return { type: 'TooltipContent', props }
},
TooltipTrigger: function TooltipTrigger(props: Record<string, unknown>) {
return { type: 'TooltipTrigger', props }
}
}))
vi.mock('@/components/tab-bar/QuickLaunchButton', () => ({
QuickLaunchAgentMenuItems: function QuickLaunchAgentMenuItems(props: Record<string, unknown>) {
return { type: 'QuickLaunchAgentMenuItems', props }
}
}))
vi.mock('./ReviewNotesSendMenuContent', () => ({
ReviewNotesSendMenuContent: function ReviewNotesSendMenuContent(props: Record<string, unknown>) {
return { type: 'ReviewNotesSendMenuContent', props }
}
}))
vi.mock('@/lib/active-agent-note-send', () => ({
activeAgentNotesSendFailureMessage: (status: string) => status,
getActiveTerminalNoteTarget: () => null,
sendNotesToActiveAgentSession: vi.fn()
}))
vi.mock('sonner', () => ({
toast: {
dismiss: vi.fn(),
error: vi.fn(),
loading: vi.fn(),
message: vi.fn(),
success: vi.fn()
}
}))
function resetHookRuntime(): void {
hookRuntime.states = []
hookRuntime.index = 0
hookRuntime.cleanups = []
}
function expand(node: unknown): unknown {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return node
}
if (Array.isArray(node)) {
return node.map((entry) => expand(entry))
}
if (!React.isValidElement(node)) {
if (typeof node === 'object' && 'props' in node) {
const element = node as ReactElementLike
return {
...element,
props: {
...element.props,
children: expand(element.props.children)
}
}
}
return node
}
const element = node as React.ReactElement<Record<string, unknown>>
if (typeof element.type === 'function') {
const Component = element.type as (props: Record<string, unknown>) => unknown
return expand(Component(element.props))
}
return {
type: element.type,
props: {
...element.props,
children: expand(element.props.children)
}
}
}
function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return
}
if (Array.isArray(node)) {
node.forEach((entry) => visit(entry, cb))
return
}
const element = node as ReactElementLike
cb(element)
if (element.props?.children) {
visit(element.props.children, cb)
}
}
function findAllByType(node: unknown, type: unknown): ReactElementLike[] {
const found: ReactElementLike[] = []
visit(node, (entry) => {
if (entry.type === type) {
found.push(entry)
}
})
return found
}
function findByType(node: unknown, type: unknown): ReactElementLike {
const found = findAllByType(node, type)[0]
if (!found) {
throw new Error(`element not found: ${String(type)}`)
}
return found
}
function renderMenu(
overrides: Partial<React.ComponentProps<typeof NotesSendMenu<TestNote>>> = {}
): unknown {
hookRuntime.index = 0
return expand(
<NotesSendMenu<TestNote>
owner={{
kind: 'worktree',
worktreeId: 'wt-1',
authority: { endpoint: { kind: 'self' }, at: 'local' }
}}
worktreeId="wt-1"
groupId="group-1"
modeIdParts={['markdown-notes', 'wt-1', 'README.md', 'rail']}
scopes={[
{
id: 'all',
label: 'All unsent notes',
notes: [note('note-1')],
formatPrompt: () => 'prompt-all'
}
]}
onDelivered={vi.fn()}
{...overrides}
/>
)
}
describe('buildNotesSendTargetModeId', () => {
it('uses part boundaries so adjacent values cannot collide', () => {
expect(buildNotesSendTargetModeId(['markdown-notes', 'ab', 'c'])).not.toBe(
buildNotesSendTargetModeId(['markdown-notes', 'a', 'bc'])
)
})
})
describe('NotesSendMenu', () => {
beforeEach(() => {
resetHookRuntime()
storeMocks.openAgentSendPopoverTargetMode.mockReset()
storeMocks.closeAgentSendPopoverTargetMode.mockReset()
storeMocks.state.agentSendPopoverTargetMode = null
resetNotesInFlightForTests()
})
it('disables the trigger when no scope has deliverable notes', () => {
const tree = renderMenu({
scopes: [{ id: 'all', label: 'All unsent notes', notes: [], formatPrompt: () => '' }]
})
expect(findByType(tree, 'button').props.disabled).toBe(true)
expect(findByType(tree, 'button').props.title).toBe('All notes sent')
expect(storeMocks.openAgentSendPopoverTargetMode).not.toHaveBeenCalled()
})
it('disables the trigger without claiming the notes were sent while their copy is unknown', () => {
const tree = renderMenu({
owner: null,
scopes: [{ id: 'all', label: 'All unsent notes', notes: [note('1')], formatPrompt: () => '' }]
})
expect(findByType(tree, 'button').props.disabled).toBe(true)
expect(findByType(tree, 'button').props.title).toBeUndefined()
expect(findAllByType(tree, 'TooltipContent')).toHaveLength(0)
})
it('opens and closes target mode with the default scope', () => {
const onDelivered = vi.fn()
const tree = renderMenu({ onDelivered })
expect(findByType(tree, 'button').props.title).toBe('Send notes to an agent')
const dropdown = findByType(tree, 'DropdownMenu')
;(dropdown.props.onOpenChange as (open: boolean) => void)(true)
expect(storeMocks.openAgentSendPopoverTargetMode).toHaveBeenCalledWith(
expect.objectContaining({
id: buildNotesSendTargetModeId(['markdown-notes', 'wt-1', 'README.md', 'rail']),
worktreeId: 'wt-1',
source: 'diff-notes',
prompt: 'prompt-all',
label: 'All unsent notes',
launchSource: 'notes_send'
})
)
const delivered = storeMocks.openAgentSendPopoverTargetMode.mock.calls[0][0]
.onPromptDelivered as () => void
delivered()
expect(onDelivered).toHaveBeenCalledWith([note('note-1')])
;(dropdown.props.onOpenChange as (open: boolean) => void)(false)
expect(storeMocks.closeAgentSendPopoverTargetMode).toHaveBeenCalledWith(
buildNotesSendTargetModeId(['markdown-notes', 'wt-1', 'README.md', 'rail'])
)
})
it('switches running-agent target mode when a different scope is focused', () => {
const tree = renderMenu({
defaultScopeId: 'file',
scopes: [
{
id: 'file',
label: 'This file',
notes: [note('file-note')],
formatPrompt: () => 'prompt-file'
},
{
id: 'all',
label: 'All unsent notes',
notes: [note('all-note')],
formatPrompt: () => 'prompt-all'
}
]
})
const [fileTrigger, allTrigger] = findAllByType(tree, 'DropdownMenuSubTrigger')
;(fileTrigger.props.onFocus as () => void)()
;(allTrigger.props.onPointerEnter as () => void)()
expect(storeMocks.openAgentSendPopoverTargetMode).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ prompt: 'prompt-file', label: 'This file' })
)
expect(storeMocks.openAgentSendPopoverTargetMode).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ prompt: 'prompt-all', label: 'All unsent notes' })
)
})
it('opens and reports handled when an open request arrives with deliverable notes', () => {
const onOpenRequestHandled = vi.fn()
renderMenu({ openRequestNonce: 1, onOpenRequestHandled })
expect(storeMocks.openAgentSendPopoverTargetMode).toHaveBeenCalledWith(
expect.objectContaining({ prompt: 'prompt-all', label: 'All unsent notes' })
)
expect(onOpenRequestHandled).toHaveBeenCalledTimes(1)
})
it('reports the open request handled without opening when nothing is deliverable', () => {
const onOpenRequestHandled = vi.fn()
renderMenu({
openRequestNonce: 1,
onOpenRequestHandled,
scopes: [{ id: 'all', label: 'All unsent notes', notes: [], formatPrompt: () => '' }]
})
expect(storeMocks.openAgentSendPopoverTargetMode).not.toHaveBeenCalled()
expect(onOpenRequestHandled).toHaveBeenCalledTimes(1)
})
it('drops an expired open request without opening the menu', () => {
const onOpenRequestHandled = vi.fn()
vi.useFakeTimers()
vi.setSystemTime(600_000)
try {
// Expired, unconsumed requests must clear without opening.
renderMenu({ openRequestNonce: 1, openRequestExpiresAt: 5_000, onOpenRequestHandled })
} finally {
vi.useRealTimers()
}
expect(storeMocks.openAgentSendPopoverTargetMode).not.toHaveBeenCalled()
expect(onOpenRequestHandled).toHaveBeenCalledTimes(1)
})
it('opens for an open request still inside its deadline', () => {
const onOpenRequestHandled = vi.fn()
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
renderMenu({ openRequestNonce: 1, openRequestExpiresAt: 6_000, onOpenRequestHandled })
} finally {
vi.useRealTimers()
}
expect(storeMocks.openAgentSendPopoverTargetMode).toHaveBeenCalledWith(
expect.objectContaining({ prompt: 'prompt-all', label: 'All unsent notes' })
)
expect(onOpenRequestHandled).toHaveBeenCalledTimes(1)
})
it('ignores a null open request', () => {
const onOpenRequestHandled = vi.fn()
renderMenu({ openRequestNonce: null, onOpenRequestHandled })
expect(storeMocks.openAgentSendPopoverTargetMode).not.toHaveBeenCalled()
expect(onOpenRequestHandled).not.toHaveBeenCalled()
})
it('closes when another target mode becomes active and cleans up on unmount', () => {
hookRuntime.states[0] = true
storeMocks.state.agentSendPopoverTargetMode = { id: 'some-other-menu' }
const tree = renderMenu()
expect(hookRuntime.states[0]).toBe(false)
expect(findByType(tree, 'DropdownMenu').props.open).toBe(false)
for (const cleanup of hookRuntime.cleanups) {
cleanup()
}
expect(storeMocks.closeAgentSendPopoverTargetMode).toHaveBeenCalledWith(
buildNotesSendTargetModeId(['markdown-notes', 'wt-1', 'README.md', 'rail'])
)
})
})
describe('NotesSendMenu notes in flight', () => {
const noteA = note('note-a')
const noteB = note('note-b')
const scopeOf = (notes: TestNote[]) => [
{
id: 'all',
label: 'All unsent notes',
notes,
formatPrompt: (sent: readonly TestNote[]) => sent.map((entry) => entry.id).join('+')
}
]
const invoke = (props: Record<string, unknown>, name: string, ...args: unknown[]): unknown => {
const callback = props[name]
if (typeof callback !== 'function') {
throw new Error(`${name} is not a function`)
}
return callback(...args)
}
const contentProps = (tree: unknown) => {
const props = findByType(tree, 'ReviewNotesSendMenuContent').props
return {
prompt: props.prompt,
onPromptDelivered: () => invoke(props, 'onPromptDelivered'),
onPromptHandedOff: (delivered: Promise<unknown>) =>
invoke(props, 'onPromptHandedOff', delivered)
}
}
beforeEach(() => {
resetHookRuntime()
storeMocks.openAgentSendPopoverTargetMode.mockReset()
storeMocks.state.agentSendPopoverTargetMode = null
resetNotesInFlightForTests()
})
it('sends only a note added while an earlier send to a new agent is still on its way', async () => {
const onDelivered = vi.fn()
let deliverA!: (result: { delivered: boolean }) => void
const first = contentProps(renderMenu({ scopes: scopeOf([noteA]), onDelivered }))
first.onPromptHandedOff(new Promise((resolve) => (deliverA = resolve)))
const second = contentProps(renderMenu({ scopes: scopeOf([noteA, noteB]), onDelivered }))
expect(second.prompt).toBe('note-b')
second.onPromptHandedOff(new Promise(() => undefined))
first.onPromptDelivered()
deliverA({ delivered: true })
second.onPromptDelivered()
await Promise.resolve()
expect(onDelivered).not.toHaveBeenCalledWith([noteA, noteB])
expect(onDelivered).toHaveBeenCalledWith([noteA])
expect(onDelivered).toHaveBeenCalledWith([noteB])
})
it('leaves the notes out of the running-agent target mode too', () => {
contentProps(renderMenu({ scopes: scopeOf([noteA]) })).onPromptHandedOff(
new Promise(() => undefined)
)
const tree = renderMenu({ scopes: scopeOf([noteA, noteB]) })
invoke(findByType(tree, 'DropdownMenu').props, 'onOpenChange', true)
expect(storeMocks.openAgentSendPopoverTargetMode).toHaveBeenCalledWith(
expect.objectContaining({ prompt: 'note-b', onPromptHandedOff: expect.any(Function) })
)
})
it.each([
['an undelivered result', () => Promise.resolve({ delivered: false, failureNotified: true })],
['a failed start', () => Promise.reject(new Error('refused'))]
])('puts the notes back for the next send after %s', async (_name, deliver) => {
const delivered = deliver()
contentProps(renderMenu({ scopes: scopeOf([noteA]) })).onPromptHandedOff(delivered)
expect(contentProps(renderMenu({ scopes: scopeOf([noteA]) })).prompt).toBe('')
await delivered.catch(() => undefined)
await Promise.resolve()
expect(contentProps(renderMenu({ scopes: scopeOf([noteA]) })).prompt).toBe('note-a')
})
it('offers no send once every note is on its way', () => {
contentProps(renderMenu({ scopes: scopeOf([noteA]) })).onPromptHandedOff(
new Promise(() => undefined)
)
const tree = renderMenu({ scopes: scopeOf([noteA]) })
expect(findByType(tree, 'button').props.disabled).toBe(true)
invoke(findByType(tree, 'DropdownMenu').props, 'onOpenChange', true)
expect(storeMocks.openAgentSendPopoverTargetMode).not.toHaveBeenCalled()
})
it('says the notes are on their way, not sent, while every note is held', () => {
contentProps(
renderMenu({ scopes: scopeOf([noteA]), disabledTooltip: 'Note already sent' })
).onPromptHandedOff(new Promise(() => undefined))
const tree = renderMenu({ scopes: scopeOf([noteA]), disabledTooltip: 'Note already sent' })
expect(findByType(tree, 'button').props.title).toBe('Sending…')
})
// Retry can report delivery after the original send callback is gone.
it('clears notes whose send reports delivery later', async () => {
const onDelivered = vi.fn()
const delivered = Promise.resolve({ delivered: true })
contentProps(renderMenu({ scopes: scopeOf([noteA]), onDelivered })).onPromptHandedOff(delivered)
await delivered
await Promise.resolve()
expect(onDelivered).toHaveBeenCalledWith([noteA])
})
})
@@ -1,385 +0,0 @@
import { vi, type Mock } from 'vitest'
import type {
AgentStatusEntry,
AgentStatusState
} from '../../../../../shared/agent-status/contracts/agent-status-types'
import type { TuiAgent } from '../../../../../shared/agent-launch/catalog/tui-agent'
import type { NotesSendAgentTarget } from '@/lib/notes-send-agent-targets'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { ReviewNotesSendMenuContent } from './ReviewNotesSendMenuContent'
import React from 'react'
import type * as ReactModule from 'react'
type ReactElementLike = {
type: unknown
props: Record<string, unknown>
}
const TAB_A = 'tab-a'
const TAB_B = 'tab-b'
const LEAF_A = '11111111-1111-4111-8111-111111111111'
const LEAF_B = '22222222-2222-4222-8222-222222222222'
const hookRuntime = vi.hoisted(() => ({
states: [] as unknown[],
index: 0,
cleanups: [] as (() => void)[]
}))
type NotesSendMenuFixtureHarness = {
storeState: Record<string, unknown>
sendMessageToAgent: Mock
track: Mock
toastMessage: Mock
worktreeAgentRows: DashboardAgentRowData[]
noteTargets: NotesSendAgentTarget[]
now: number
}
const harness: NotesSendMenuFixtureHarness = vi.hoisted(() => ({
storeState: {} as Record<string, unknown>,
sendMessageToAgent: vi.fn(),
track: vi.fn(),
toastMessage: vi.fn(),
worktreeAgentRows: [] as DashboardAgentRowData[],
noteTargets: new Array<NotesSendAgentTarget>(),
now: 600_000
}))
vi.mock('react', async () => {
const actual = await vi.importActual<typeof ReactModule>('react')
return {
...actual,
useCallback<T extends (...args: never[]) => unknown>(callback: T): T {
return callback
},
useMemo<T>(factory: () => T): T {
return factory()
},
useSyncExternalStore<T>(_subscribe: unknown, getSnapshot: () => T): T {
return getSnapshot()
},
useEffect(effect: () => void | (() => void)): void {
const cleanup = effect()
if (typeof cleanup === 'function') {
hookRuntime.cleanups.push(cleanup)
}
},
useState<T>(initial: T | (() => T)) {
const stateIndex = hookRuntime.index++
if (!(stateIndex in hookRuntime.states)) {
hookRuntime.states[stateIndex] =
typeof initial === 'function' ? (initial as () => T)() : initial
}
const setState = (next: T | ((previous: T) => T)): void => {
hookRuntime.states[stateIndex] =
typeof next === 'function'
? (next as (previous: T) => T)(hookRuntime.states[stateIndex] as T)
: next
}
return [hookRuntime.states[stateIndex] as T, setState] as const
}
}
})
vi.mock('@/store/index', () => ({
useAppStore: Object.assign(
(selector: (state: Record<string, unknown>) => unknown) => selector(harness.storeState),
{
getState: () => harness.storeState
}
)
}))
vi.mock('zustand/react/shallow', () => ({
useShallow: (selector: unknown) => selector
}))
vi.mock('@/runtime/structured-chat-name-observation', () => ({
useStructuredChatTabNames: () => new Map(),
getStructuredChatTabNames: () => new Map()
}))
vi.mock('@/lib/active-agent-note-send', () => ({
activeAgentNotesSendFailureMessage: (
status: string,
options: { explicitTarget?: boolean } = {}
) => (options.explicitTarget ? `selected:${status}` : status)
}))
vi.mock('@/lib/active-agent-note-send-result', () => ({
activeAgentNotesSendFailureMessage: (
status: string,
options: { explicitTarget?: boolean } = {}
) => (options.explicitTarget ? `selected:${status}` : status)
}))
vi.mock('@/lib/agent-message-send', () => ({
sendMessageToAgent: harness.sendMessageToAgent
}))
vi.mock('@/lib/notes-send-agent-targets', () => ({
deriveNotesSendAgentTargets: () => harness.noteTargets
}))
vi.mock('@/lib/telemetry', () => ({
track: harness.track
}))
vi.mock('@/hooks/use-now', () => ({
useNow: () => harness.now
}))
vi.mock('@/components/sidebar/workspace-card/activity/useWorktreeAgentRows', () => ({
useWorktreeAgentRows: () => harness.worktreeAgentRows
}))
vi.mock('@/components/AgentStateDot', () => ({
AgentStateDot: function AgentStateDot(props: Record<string, unknown>) {
return { type: 'AgentStateDot', props }
},
agentStateLabel: (state: string) => {
switch (state) {
case 'working':
return 'Working'
case 'blocked':
return 'Blocked'
case 'waiting':
return 'Waiting for input'
case 'failed':
return 'Failed'
case 'interrupted':
return 'Interrupted'
case 'done':
return 'Done'
case 'idle':
return 'Idle'
default:
return state
}
}
}))
vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: function AgentIcon(props: Record<string, unknown>) {
return { type: 'AgentIcon', props }
}
}))
vi.mock('@/components/tab-bar/QuickLaunchButton', () => ({
QuickLaunchAgentMenuItems: function QuickLaunchAgentMenuItems(props: Record<string, unknown>) {
return { type: 'QuickLaunchAgentMenuItems', props }
}
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuItem: function DropdownMenuItem(props: Record<string, unknown>) {
return { type: 'DropdownMenuItem', props }
},
DropdownMenuLabel: function DropdownMenuLabel(props: Record<string, unknown>) {
return { type: 'DropdownMenuLabel', props }
},
DropdownMenuSeparator: function DropdownMenuSeparator(props: Record<string, unknown>) {
return { type: 'DropdownMenuSeparator', props }
}
}))
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
focusTerminalTabSurface: vi.fn()
}))
// Keep real Sonner state to verify a settled toast’s type.
vi.mock(import('sonner'), async (importOriginal) => {
const actual = await importOriginal()
const realMessage = actual.toast.message
const message: typeof realMessage = (...args) => {
harness.toastMessage(...args)
return realMessage(...args)
}
return { ...actual, toast: Object.assign(actual.toast, { message }) }
})
function agentEntry(
paneKey: string,
agentType: TuiAgent,
state: AgentStatusState = 'done',
stateStartedAt = harness.now
): AgentStatusEntry {
return {
paneKey,
state,
prompt: '',
updatedAt: stateStartedAt,
stateStartedAt,
agentType,
stateHistory: []
}
}
function agentRow({
paneKey,
tabId,
title,
agentType = 'claude',
state = 'done',
startedAt = harness.now,
prompt = ''
}: {
paneKey: string
tabId: string
title: string
agentType?: TuiAgent
prompt?: string
state?: AgentStatusState | 'idle'
startedAt?: number
}): DashboardAgentRowData {
const entryState: AgentStatusState = state === 'idle' ? 'working' : state
return {
paneKey,
entry: { ...agentEntry(paneKey, agentType, entryState, startedAt), prompt },
tab: tab(tabId, { title }) as DashboardAgentRowData['tab'],
agentType,
state,
startedAt
}
}
function tab(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
worktreeId: 'wt-1',
ptyId: null,
title: id,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
...overrides
}
}
function leafLayout(leafId: string, ptyId: string) {
return {
root: { type: 'leaf', leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: ptyId }
}
}
function setStore(overrides: Record<string, unknown> = {}): void {
harness.storeState = {
activeWorktreeId: 'wt-1',
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
tabsByWorktree: { 'wt-1': [] },
terminalLayoutsByTabId: {},
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {},
...overrides
}
}
function expand(node: unknown): unknown {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return node
}
if (Array.isArray(node)) {
return node.map((entry) => expand(entry))
}
if (!React.isValidElement(node)) {
if (typeof node === 'object' && 'props' in node) {
const element = node as ReactElementLike
return { ...element, props: { ...element.props, children: expand(element.props.children) } }
}
return node
}
const element = node as React.ReactElement<Record<string, unknown>>
if (typeof element.type === 'function') {
const Component = element.type as (props: Record<string, unknown>) => unknown
return expand(Component(element.props))
}
return {
type: element.type,
props: { ...element.props, children: expand(element.props.children) }
}
}
function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return
}
if (Array.isArray(node)) {
node.forEach((entry) => visit(entry, cb))
return
}
const element = node as ReactElementLike
cb(element)
if (element.props?.children) {
visit(element.props.children, cb)
}
}
function findAllByType(node: unknown, type: unknown): ReactElementLike[] {
const found: ReactElementLike[] = []
visit(node, (entry) => {
if (entry.type === type) {
found.push(entry)
}
})
return found
}
function findByType(node: unknown, type: unknown): ReactElementLike {
const found = findAllByType(node, type)[0]
if (!found) {
throw new Error(`element not found: ${String(type)}`)
}
return found
}
function collectText(node: unknown): string {
if (node == null || typeof node === 'boolean') {
return ''
}
if (typeof node === 'string') {
return node
}
if (typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
return node.map(collectText).join('')
}
const element = node as ReactElementLike
return collectText(element.props?.children)
}
function render(props: Record<string, unknown> = {}): unknown {
hookRuntime.index = 0
return expand(
<ReviewNotesSendMenuContent worktreeId="wt-1" groupId="group-1" prompt="my notes" {...props} />
)
}
async function flushMicrotasks(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
export {
TAB_A,
TAB_B,
LEAF_A,
LEAF_B,
hookRuntime,
harness,
agentEntry,
agentRow,
tab,
leafLayout,
setStore,
findAllByType,
findByType,
collectText,
render,
flushMicrotasks
}
@@ -1,505 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id'
import type { NotesSendAgentTarget } from '@/lib/notes-send-agent-targets'
import {
TAB_A,
TAB_B,
LEAF_A,
LEAF_B,
hookRuntime,
harness,
agentRow,
tab,
leafLayout,
setStore,
findAllByType,
findByType,
collectText,
render,
flushMicrotasks
} from './ReviewNotesSendMenuContent.test-fixture'
describe('ReviewNotesSendMenuContent', () => {
beforeEach(() => {
hookRuntime.states = []
hookRuntime.index = 0
hookRuntime.cleanups = []
harness.sendMessageToAgent.mockReset()
harness.sendMessageToAgent.mockResolvedValue({ status: 'sent' })
harness.track.mockReset()
harness.toastMessage.mockReset()
harness.worktreeAgentRows = []
harness.noteTargets = []
harness.now = 600_000
setStore()
})
it('orders send targets by the current worktree agent rows and shows status timing', () => {
const paneKeyA = makePaneKey(TAB_A, LEAF_A)
const paneKeyB = makePaneKey(TAB_B, LEAF_B)
harness.worktreeAgentRows = [
agentRow({
paneKey: paneKeyB,
tabId: TAB_B,
title: 'Second session',
agentType: 'codex',
startedAt: harness.now - 120_000
}),
agentRow({
paneKey: paneKeyA,
tabId: TAB_A,
title: 'First session',
agentType: 'claude',
startedAt: harness.now - 60_000
})
]
setStore({
tabsByWorktree: {
'wt-1': [tab(TAB_A, { title: 'First session' }), tab(TAB_B, { title: 'Second session' })]
},
terminalLayoutsByTabId: {
[TAB_A]: leafLayout(LEAF_A, 'pty-a'),
[TAB_B]: leafLayout(LEAF_B, 'pty-b')
}
})
harness.noteTargets = [
{
paneKey: paneKeyA,
tabId: TAB_A,
messageTarget: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A },
agentType: 'claude',
tabTitle: 'First session',
status: 'eligible'
},
{
paneKey: paneKeyB,
tabId: TAB_B,
messageTarget: { kind: 'terminal', tabId: TAB_B, leafId: LEAF_B },
agentType: 'codex',
tabTitle: 'Second session',
status: 'eligible'
}
]
const tree = render()
const items = findAllByType(tree, 'DropdownMenuItem')
expect(items).toHaveLength(2)
expect(collectText(items[0])).toContain('Codex')
expect(collectText(items[0])).toContain('Done')
expect(collectText(items[0])).toContain('2m ago')
expect(collectText(items[0])).toContain('Second session')
expect(collectText(items[1])).toContain('Claude')
})
it('leads each row with the name its sidebar row shows, so same-type agents differ', () => {
const paneKeyA = makePaneKey(TAB_A, LEAF_A)
const paneKeyB = makePaneKey(TAB_B, LEAF_B)
harness.worktreeAgentRows = [
agentRow({ paneKey: paneKeyA, tabId: TAB_A, title: 'Claude Code' }),
agentRow({ paneKey: paneKeyB, tabId: TAB_B, title: 'Claude Code', prompt: 'Fix the login' })
]
setStore({
tabsByWorktree: {
'wt-1': [
tab(TAB_A, { title: 'Claude Code', customTitle: 'Payments' }),
tab(TAB_B, { title: 'Claude Code' })
]
}
})
harness.noteTargets = [
[paneKeyA, TAB_A, LEAF_A],
[paneKeyB, TAB_B, LEAF_B]
].map(([paneKey, tabId, leafId]): NotesSendAgentTarget => ({
paneKey,
tabId,
messageTarget: { kind: 'terminal', tabId, leafId },
agentType: 'claude',
tabTitle: 'Claude Code',
status: 'eligible'
}))
const rowLines = findAllByType(render(), 'DropdownMenuItem').map((item) =>
findAllByType(item, 'span')
.filter((span) => String(span.props.className).includes('truncate'))
.map(collectText)
)
expect(rowLines).toEqual([
['Payments', 'Claude · Done · just now'],
['Fix the login', 'Claude · Done · just now']
])
})
it('shows a chat whose turn failed as failed, as its sidebar row does', () => {
const chatPaneKey = 'structured-agent-session-claude_1:chat'
const failedRow = agentRow({
paneKey: chatPaneKey,
tabId: 'structured-agent-session-claude_1',
title: 'Claude Chat',
agentType: 'claude'
})
failedRow.entry.mainAgent = { state: 'done', outcome: 'failure', stateStartedAt: harness.now }
harness.worktreeAgentRows = [failedRow]
harness.noteTargets = [
{
paneKey: chatPaneKey,
tabId: 'structured-agent-session-claude_1',
messageTarget: { kind: 'structured-session', sessionId: 'claude_1' },
agentType: 'claude',
tabTitle: 'Claude Chat',
status: 'eligible'
}
]
const item = findByType(render(), 'DropdownMenuItem')
expect(findByType(item, 'AgentStateDot').props.state).toBe('failed')
expect(collectText(item)).toContain('Failed')
expect(collectText(item)).not.toContain('Done')
})
it('shows a stopped terminal agent as interrupted, as its sidebar row does', () => {
const paneKey = makePaneKey(TAB_A, LEAF_A)
const stoppedRow = agentRow({ paneKey, tabId: TAB_A, title: 'Terminal 1', agentType: 'claude' })
stoppedRow.entry.interrupted = true
harness.worktreeAgentRows = [stoppedRow]
harness.noteTargets = [
{
paneKey,
tabId: TAB_A,
messageTarget: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A },
agentType: 'claude',
tabTitle: 'Terminal 1',
status: 'eligible'
}
]
const item = findByType(render(), 'DropdownMenuItem')
expect(findByType(item, 'AgentStateDot').props.state).toBe('interrupted')
expect(collectText(item)).toContain('Interrupted')
})
it('does not target title-detected rows skipped by target derivation', async () => {
const paneKey = makePaneKey(TAB_B, LEAF_B)
harness.worktreeAgentRows = [
agentRow({
paneKey,
tabId: TAB_B,
title: 'Codex',
agentType: 'codex',
state: 'idle',
startedAt: harness.now
})
]
setStore({
tabsByWorktree: { 'wt-1': [tab(TAB_B, { title: 'Codex' })] },
terminalLayoutsByTabId: { [TAB_B]: leafLayout(LEAF_B, 'pty-b') },
ptyIdsByTabId: { [TAB_B]: ['pty-b'] }
})
const tree = render()
const items = findAllByType(tree, 'DropdownMenuItem')
expect(items).toHaveLength(0)
expect(harness.sendMessageToAgent).not.toHaveBeenCalled()
})
it('disables title-detected dashboard rows when target derivation reports permission', () => {
const paneKey = makePaneKey(TAB_B, LEAF_B)
harness.worktreeAgentRows = [
agentRow({
paneKey,
tabId: TAB_B,
title: 'Codex',
agentType: 'codex',
state: 'blocked',
startedAt: harness.now
})
]
setStore({
tabsByWorktree: { 'wt-1': [tab(TAB_B, { title: 'Codex' })] },
terminalLayoutsByTabId: { [TAB_B]: leafLayout(LEAF_B, 'pty-b') },
ptyIdsByTabId: { [TAB_B]: ['pty-b'] }
})
harness.noteTargets = [
{
paneKey,
tabId: TAB_B,
messageTarget: { kind: 'terminal', tabId: TAB_B, leafId: LEAF_B },
agentType: 'codex',
tabTitle: 'Codex',
status: 'disabled',
disabledReason: 'Agent needs permission'
}
]
const tree = render()
const item = findByType(tree, 'DropdownMenuItem')
const stateDot = findByType(item, 'AgentStateDot')
expect(item.props.disabled).toBe(true)
expect(item.props.title).toBe('Agent needs permission')
expect(stateDot.props.title).toBeNull()
;(item.props.onSelect as () => void)()
expect(harness.sendMessageToAgent).not.toHaveBeenCalled()
})
it('sends notes to the chosen agent and tracks the send once it succeeds', async () => {
const statusPaneKey = makePaneKey(TAB_A, LEAF_A)
const onPromptDelivered = vi.fn()
const onPromptHandedOff = vi.fn()
setStore({
tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] },
terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') }
})
harness.noteTargets = [
{
paneKey: statusPaneKey,
tabId: TAB_A,
messageTarget: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A },
agentType: 'claude',
tabTitle: 'Terminal 1',
status: 'eligible'
}
]
const tree = render({ onPromptDelivered, onPromptHandedOff })
;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)()
await flushMicrotasks()
expect(harness.sendMessageToAgent).toHaveBeenCalledWith({
worktreeId: 'wt-1',
prompt: 'my notes',
target: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A }
})
expect(onPromptDelivered).toHaveBeenCalledTimes(1)
expect(harness.track).toHaveBeenCalledWith('agent_prompt_sent', {
agent_kind: 'claude-code',
launch_source: 'notes_send',
request_kind: 'followup'
})
expect(onPromptHandedOff).toHaveBeenCalledOnce()
await onPromptHandedOff.mock.calls[0][0]
expect(onPromptDelivered).toHaveBeenCalledTimes(1)
})
it('keeps selected-target note failures undelivered and uses selected wording', async () => {
const statusPaneKey = makePaneKey(TAB_A, LEAF_A)
const onPromptDelivered = vi.fn()
harness.sendMessageToAgent.mockResolvedValue({ status: 'not-ready' })
setStore({
tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] },
terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') }
})
harness.noteTargets = [
{
paneKey: statusPaneKey,
tabId: TAB_A,
messageTarget: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A },
agentType: 'claude',
tabTitle: 'Terminal 1',
status: 'eligible'
}
]
const tree = render({ onPromptDelivered })
;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)()
await flushMicrotasks()
expect(onPromptDelivered).not.toHaveBeenCalled()
expect(harness.track).not.toHaveBeenCalled()
// Failure must settle the loading toast without leaving its spinner.
const settled = toast
.getToasts()
.find((entry) => 'title' in entry && entry.title === 'selected:not-ready')
expect(settled && 'type' in settled ? settled.type : undefined).toBe('info')
})
it.each(['terminal', 'structured-session'] as const)(
'settles unknown %s notes without clearing them or claiming delivery',
async (kind) => {
harness.sendMessageToAgent.mockResolvedValue({ status: 'unconfirmed' })
const onPromptDelivered = vi.fn()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0)
return 0
})
const dismiss = vi.spyOn(toast, 'dismiss')
setStore({
tabsByWorktree: { 'wt-1': [tab(TAB_A)] },
terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') }
})
harness.noteTargets = [
{
paneKey: makePaneKey(TAB_A, LEAF_A),
tabId: TAB_A,
messageTarget:
kind === 'terminal'
? { kind, tabId: TAB_A, leafId: LEAF_A }
: { kind, sessionId: 'chat' },
agentType: 'codex',
tabTitle: 'Agent',
status: 'eligible'
}
]
const before = new Set(toast.getHistory().map((entry) => entry.id))
const tree = render({ onPromptDelivered })
;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)()
await flushMicrotasks()
expect(onPromptDelivered).not.toHaveBeenCalled()
expect(harness.track).not.toHaveBeenCalled()
const current = toast.getToasts().filter((entry) => !before.has(entry.id))
if (kind === 'structured-session') {
expect(dismiss).toHaveBeenCalled()
expect(current).toEqual([])
} else {
expect(
current.some((entry) => 'title' in entry && entry.title === 'selected:unconfirmed')
).toBe(true)
}
dismiss.mockRestore()
vi.unstubAllGlobals()
}
)
it('keeps selected-target thrown send errors undelivered', async () => {
const statusPaneKey = makePaneKey(TAB_A, LEAF_A)
const onPromptDelivered = vi.fn()
harness.sendMessageToAgent.mockRejectedValue(new Error('runtime unavailable'))
setStore({
tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] },
terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') }
})
harness.noteTargets = [
{
paneKey: statusPaneKey,
tabId: TAB_A,
messageTarget: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A },
agentType: 'claude',
tabTitle: 'Terminal 1',
status: 'eligible'
}
]
const tree = render({ onPromptDelivered })
;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)()
await flushMicrotasks()
expect(onPromptDelivered).not.toHaveBeenCalled()
expect(harness.track).not.toHaveBeenCalled()
})
it('revalidates the chosen target at click time and refuses stale rows', async () => {
const statusPaneKey = makePaneKey(TAB_A, LEAF_A)
setStore({
tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] },
terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') },
ptyIdsByTabId: { [TAB_A]: ['pty-a'] }
})
harness.noteTargets = [
{
paneKey: statusPaneKey,
tabId: TAB_A,
messageTarget: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A },
agentType: 'claude',
tabTitle: 'Terminal 1',
status: 'eligible'
}
]
const tree = render()
harness.noteTargets = [
{
paneKey: statusPaneKey,
tabId: TAB_A,
messageTarget: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A },
agentType: 'claude',
tabTitle: 'Terminal 1',
status: 'disabled',
disabledReason: 'Agent status is stale'
}
]
;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)()
expect(harness.sendMessageToAgent).not.toHaveBeenCalled()
expect(harness.toastMessage).toHaveBeenCalledWith('Agent status is stale')
})
it('refuses stale menu targets that disappear from target derivation before click', () => {
const statusPaneKey = makePaneKey(TAB_A, LEAF_A)
setStore({
tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] },
terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') },
ptyIdsByTabId: { [TAB_A]: ['pty-a'] }
})
harness.noteTargets = [
{
paneKey: statusPaneKey,
tabId: TAB_A,
messageTarget: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A },
agentType: 'claude',
tabTitle: 'Terminal 1',
status: 'eligible'
}
]
const tree = render()
harness.noteTargets = []
;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)()
expect(harness.sendMessageToAgent).not.toHaveBeenCalled()
expect(harness.toastMessage).toHaveBeenCalledWith('Terminal is no longer available')
})
it('keeps a working agent selectable and preserves the Working state text', async () => {
const statusPaneKey = makePaneKey(TAB_A, LEAF_A)
setStore({
tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] },
terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') },
ptyIdsByTabId: { [TAB_A]: ['pty-a'] }
})
harness.noteTargets = [
{
paneKey: statusPaneKey,
tabId: TAB_A,
messageTarget: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A },
agentType: 'claude',
tabTitle: 'Terminal 1',
status: 'eligible'
}
]
harness.worktreeAgentRows = [
agentRow({
paneKey: statusPaneKey,
tabId: TAB_A,
title: 'Terminal 1',
agentType: 'claude',
state: 'working'
})
]
const tree = render()
const item = findByType(tree, 'DropdownMenuItem')
expect(item.props.disabled).toBe(false)
expect(collectText(item)).toContain('Working')
;(item.props.onSelect as () => void)()
await flushMicrotasks()
expect(harness.sendMessageToAgent).toHaveBeenCalledWith({
worktreeId: 'wt-1',
prompt: 'my notes',
target: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A }
})
})
// Notes held by other sends leave no prompt; do not launch an empty agent.
it('disables the new-agent launcher when there is nothing left to send', () => {
expect(findByType(render({ prompt: '' }), 'QuickLaunchAgentMenuItems').props.disabled).toBe(
true
)
expect(findByType(render(), 'QuickLaunchAgentMenuItems').props.disabled).toBe(false)
})
})
@@ -2,38 +2,21 @@
import { Editor } from '@tiptap/core'
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
import { describe, expect, it, vi } from 'vitest'
import { marked } from 'marked'
import { describe, expect, it } from 'vitest'
import { encodeRawMarkdownHtmlForRichEditor } from '../raw-source/raw-markdown-html'
import { createRichMarkdownExtensions } from '../schema/rich-markdown-extensions'
import {
createRichMarkdownEditorCodec,
createRichMarkdownSourceTransport
} from '../document/rich-markdown-source-transport'
import {
classifyHtmlSuperscriptLinkAction,
createRichMarkdownHtmlSuperscriptLinkContext
} from './rich-markdown-html-superscript-link-context'
import {
HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT,
matchHtmlSuperscriptLinkSource,
parseHtmlSuperscriptLinkSource
} from './rich-markdown-html-superscript-link-source'
import { projectMarkdownHrefForClipboard } from '../../markdown/link-routing/markdown-internal-links'
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import { findRichMarkdownSearchMatches } from '../search/rich-markdown-search'
import { findRichMarkdownSelectedTextRanges } from '../review/rich-markdown-review-text-ranges'
import { getRichMarkdownVisibleText } from '../review/rich-markdown-visible-text-map'
import { handleRichMarkdownCut } from '../clipboard/rich-markdown-cut-handler'
import { getSelectedHtmlSuperscriptLinkStatus } from '../links/rich-markdown-selected-link-actions'
import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
import { resolveRichMarkdownWorktreeRoot } from './useRichMarkdownSuperscriptLinkSetup'
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import type { AppState } from '@/store/types'
import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types'
import {
inspectRichMarkdownSourceOwningSlice,
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
} from '../clipboard/rich-markdown-source-owning-slice'
const TEST_KEY = '0123456789abcdef0123456789abcdef'
@@ -68,24 +51,6 @@ function nodeNames(editor: Editor): string[] {
}
describe('rich Markdown HTML superscript links', () => {
it('parses the reported fragment and preserves its exact source', () => {
const source = '<sup><a href="https://example.com/source">[12]</a></sup>'
const editor = createEditor(`研究结果${source}。`)
try {
expect(nodeNames(editor)).toContain('richMarkdownHtmlSuperscriptLink')
expect(editor.getMarkdown()).toBe(`研究结果${source}。`)
const citation = editor.state.doc.firstChild?.child(1)
expect(citation?.attrs).toMatchObject({
source,
href: 'https://example.com/source',
label: '[12]',
title: null
})
} finally {
editor.destroy()
}
})
it('retains casing, whitespace, quote style, entities, title, and adjacency', () => {
const first =
"<SUP ><A title='A &amp; B' href='https://example.com/?a=1&amp;b=2'>[&notit;]</A ></SUP >"
@@ -154,52 +119,6 @@ describe('rich Markdown HTML superscript links', () => {
}
})
it('keeps sequential codecs isolated and exposes the complete marked surface', () => {
const first = createRichMarkdownEditorCodec(TEST_KEY)
const second = createRichMarkdownEditorCodec('fedcba9876543210fedcba9876543210')
expect(Object.getOwnPropertyNames(first.marked).sort()).toEqual(
Object.getOwnPropertyNames(marked).sort()
)
const authoredFirstToken = first.transport.create('inline-html', '<b>K1</b>')
const editor = createEditor(authoredFirstToken, second.transport.key)
try {
expect(editor.getMarkdown()).toBe(authoredFirstToken)
expect(nodeNames(editor)).not.toContain('rawMarkdownHtmlInline')
expect(nodeNames(editor)).not.toContain('markdownDocLink')
} finally {
editor.destroy()
}
})
it('keeps source matching linear across many rejected candidates', () => {
const input = '<sup>'.repeat(2_000)
let transitions = 0
for (let index = 0; index < input.length; index += 5) {
const stats = { transitions: 0 }
expect(matchHtmlSuperscriptLinkSource(input, index, stats)).toBeNull()
transitions += stats.transitions
}
expect(transitions).toBeLessThan(input.length * 4)
})
it('projects only browser-safe clipboard hrefs', () => {
expect(projectMarkdownHrefForClipboard(' https://example.com ')).toBe('https://example.com')
expect(projectMarkdownHrefForClipboard('#section')).toBe('#section')
expect(projectMarkdownHrefForClipboard('./guide.md')).toBe('./guide.md')
expect(projectMarkdownHrefForClipboard('javascript:alert(1)')).toBeNull()
expect(projectMarkdownHrefForClipboard('java\nscript:alert(1)')).toBeNull()
expect(projectMarkdownHrefForClipboard('C:\\repo\\guide.md')).toBeNull()
expect(projectMarkdownHrefForClipboard('\\\\server\\share\\guide.md')).toBeNull()
expect(projectMarkdownHrefForClipboard('//example.com/path')).toBeNull()
})
it('rejects duplicate attributes and parses quoted greater-than characters', () => {
expect(
parseHtmlSuperscriptLinkSource('<sup><a href="https://example.com/?q=>">[1]</a></sup>')?.href
).toBe('https://example.com/?q=>')
expect(parseHtmlSuperscriptLinkSource('<sup><a href="a" href="b">[1]</a></sup>')).toBeNull()
})
it('renders inert live DOM while exposing link semantics through Orca context', () => {
const host = document.createElement('div')
document.body.appendChild(host)
@@ -250,74 +169,6 @@ describe('rich Markdown HTML superscript links', () => {
}
})
it('keeps whitespace and remote outside-root destinations non-actionable', () => {
const local = {
version: 0,
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' as const }
}
expect(classifyHtmlSuperscriptLinkAction(' ', local)).toBe(false)
expect(classifyHtmlSuperscriptLinkAction('file:///etc/passwd', local)).toBe(true)
expect(
classifyHtmlSuperscriptLinkAction('file:///etc/passwd', {
...local,
sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }
})
).toBe(false)
expect(
classifyHtmlSuperscriptLinkAction('file:///etc/passwd', {
...local,
sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'runtime-1' }
})
).toBe(false)
})
it('scopes citation Tab focus to the selected editor bubble', () => {
const editor = createEditor('<sup><a href="https://example.com">[12]</a></sup>')
const firstBubble = document.createElement('div')
firstBubble.dataset.richMarkdownLinkBubbleOwner = 'first-owner'
const firstButton = document.createElement('button')
firstBubble.appendChild(firstButton)
const secondBubble = document.createElement('div')
secondBubble.dataset.richMarkdownLinkBubbleOwner = 'second-owner'
const secondButton = document.createElement('button')
secondBubble.appendChild(secondButton)
document.body.append(firstBubble, secondBubble)
try {
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
const event = {
key: 'Tab',
shiftKey: false,
isComposing: false,
preventDefault: () => {}
} as KeyboardEvent
expect(
handleRichMarkdownCitationKey({
editor,
event,
linkBubbleOwnerId: 'second-owner'
})
).toBe(true)
expect(document.activeElement).toBe(secondButton)
} finally {
editor.destroy()
firstBubble.remove()
secondBubble.remove()
}
})
it('uses a folder workspace path as the citation source root', () => {
const state = {
folderWorkspaces: [{ id: 'folder-1', folderPath: '/workspace/platform' } as FolderWorkspace],
worktreesByRepo: {}
} as Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>
expect(resolveRichMarkdownWorktreeRoot(state, folderWorkspaceKey('folder-1'))).toBe(
'/workspace/platform'
)
})
it('self-validates clipboard HTML and rejects a forged semantic mismatch', () => {
const source = '<sup><a title="Source" href="./guide.md">[12]</a></sup>'
const host = document.createElement('div')
@@ -365,23 +216,6 @@ describe('rich Markdown HTML superscript links', () => {
}
})
it('rejects oversized clipboard source before UTF-8 encoding', () => {
const editor = createEditor('')
const oversizedSource = 'x'.repeat(HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT + 1)
const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode')
try {
editor.commands.setContent(
`<sup data-rich-markdown-html-superscript-link="1" data-orca-superscript-link-source="${oversizedSource}"><a>x</a></sup>`,
{ contentType: 'html' }
)
expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
expect(encodeSpy.mock.calls.some(([value]) => value === oversizedSource)).toBe(false)
} finally {
encodeSpy.mockRestore()
editor.destroy()
}
})
it('maps citation labels into search, review, and empty-selection Cut', () => {
const source = 'Before <sup><a href="https://example.com">[12]</a></sup> after'
const editor = createEditor(source)
@@ -436,136 +270,6 @@ describe('rich Markdown HTML superscript links', () => {
}
})
it('enforces aggregate UTF-8 bounds even when oversized text precedes the citation', () => {
const citation = '<sup><a href="https://example.com">[1]</a></sup>'
const exactText = 'a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 3)
const exact = createEditor(`${exactText}${citation}`)
try {
expect(
inspectRichMarkdownSourceOwningSlice(exact.state.doc.slice(0, exact.state.doc.content.size))
).toEqual({ containsSourceOwningNode: true, canPreserve: true })
} finally {
exact.destroy()
}
const over = createEditor(`${exactText}a${citation}`)
try {
expect(
inspectRichMarkdownSourceOwningSlice(over.state.doc.slice(0, over.state.doc.content.size))
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
} finally {
over.destroy()
}
const astralOver = createEditor(
`${'😀'.repeat(Math.floor(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT / 4))}${citation}`
)
try {
expect(
inspectRichMarkdownSourceOwningSlice(
astralOver.state.doc.slice(0, astralOver.state.doc.content.size)
)
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
} finally {
astralOver.destroy()
}
})
it('counts visible leaf serializers in source-owning slice bounds', () => {
const editor = createEditor('')
try {
const schema = editor.state.schema
const hardBreak = schema.nodes.hardBreak
const docLink = schema.nodes.markdownDocLink
const citation = schema.nodes.richMarkdownHtmlSuperscriptLink
if (!hardBreak || !docLink || !citation) {
throw new Error('Expected rich Markdown leaf node types')
}
const citationAttrs = {
source: '<sup><a href="x">[1]</a></sup>',
href: 'x',
label: '[1]',
title: null
}
const exactDoc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.text('a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 4)),
hardBreak.create(),
citation.create(citationAttrs)
])
])
expect(
inspectRichMarkdownSourceOwningSlice(exactDoc.slice(0, exactDoc.content.size))
).toEqual({ containsSourceOwningNode: true, canPreserve: true })
const overHardBreakDoc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.text('a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 3)),
hardBreak.create(),
citation.create(citationAttrs)
])
])
expect(
inspectRichMarkdownSourceOwningSlice(
overHardBreakDoc.slice(0, overHardBreakDoc.content.size)
)
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
const oversizedLeafDoc = schema.node('doc', null, [
schema.node('paragraph', null, [
docLink.create({ target: 'Guide', label: 'x'.repeat(300_000) }),
citation.create(citationAttrs)
])
])
expect(
inspectRichMarkdownSourceOwningSlice(
oversizedLeafDoc.slice(0, oversizedLeafDoc.content.size)
)
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
} finally {
editor.destroy()
}
})
it('remaps review text across adjacent citation atoms without inventing spaces', () => {
const editor = createEditor(
'<sup><a href="https://one.example">[1]</a></sup><sup><a href="https://two.example">[2]</a></sup>'
)
try {
const ranges = findRichMarkdownSelectedTextRanges({ editor, selectedText: '[1][2]' })
expect(ranges).toHaveLength(1)
expect(ranges[0]).toEqual({ from: 1, to: 3 })
} finally {
editor.destroy()
}
})
it('preserves block separators in visible text and review remapping', () => {
const editor = createEditor('foo\n\nbar')
try {
expect(getRichMarkdownVisibleText(editor.state.doc)).toBe('foo\nbar')
expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'foo bar' })).toEqual([
{ from: 1, to: 4 },
{ from: 6, to: 9 }
])
} finally {
editor.destroy()
}
})
it('maps dense search matches with a monotonic segment walk', () => {
const citation = '<sup><a href="https://example.com">[1]</a></sup>'
const editor = createEditor(`x${citation}`.repeat(200))
try {
const stats = { segmentVisits: 0 }
expect(findRichMarkdownSearchMatches(editor.state.doc, 'x', undefined, stats)).toHaveLength(
200
)
expect(stats.segmentVisits).toBeLessThan(1_000)
} finally {
editor.destroy()
}
})
it('does not let search bridge omitted atoms or block boundaries', () => {
const docLink = createEditor('foo[[Guide]]bar')
try {
@@ -182,20 +182,6 @@ it.each(['first', 'second', 'interleaved'] as const)(
}
)
it('groups native composition updates without a pending paste', () => {
vi.spyOn(Date, 'now').mockReturnValue(10000)
const editor = createProductionEditor()
const position = editor.state.doc.child(0).nodeSize + 1
editor.commands.setTextSelection(position)
editor.view.dispatch(editor.state.tr.insertText('n').setMeta('composition', 7))
editor.view.dispatch(
editor.state.tr.insertText('你', position, position + 1).setMeta('composition', 7)
)
expect(paragraphs(editor)[1]).toBe('你second paragraph')
expect(editor.commands.undo()).toBe(true)
expect(paragraphs(editor)[1]).toBe('second paragraph')
})
it.each(['typing', 'composition'] as const)(
'preserves native %s Undo grouping during a paused paste',
async (mode) => {
@@ -333,24 +319,6 @@ it.each(['typing', 'composition'] as const)(
}
)
it('rejects the actual 16 MiB limit plus one emoji without changing the document or retaining plugins', async () => {
const editor = createProductionEditor()
const initial = editor.state.doc
const initialPlugins = editor.state.plugins.length
const payload = '😀'.repeat(4 * 1024 * 1024 + 1)
expect(
handleRichMarkdownLargeTextPaste(editor, pasteEvent(payload), {
yieldToEventLoop: async () => {}
})
).toBe(true)
await flush(1000)
expect(toast.error).toHaveBeenCalledExactlyOnceWith('Paste is too large.')
expect(toast.info).not.toHaveBeenCalled()
expect(editor.state.doc.eq(initial)).toBe(true)
expect(editor.state.plugins.length).toBe(initialPlugins)
editor.state.doc.check()
})
it.each(['earlier image', 'later image', 'ordinary typing'] as const)(
'orders a pending text paste against %s without changing the live caret',
async (counterpart) => {
@@ -58,19 +58,6 @@ async function flush() {
}
}
function textPosition(editor: Editor, text: string): number {
let position: number | undefined
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text === text) {
position = pos
}
})
if (position === undefined) {
throw new Error(`Missing text ${text}`)
}
return position
}
afterEach(() => {
editors.splice(0).forEach((editor) => editor.destroy())
document.body.replaceChildren()
@@ -84,23 +71,6 @@ afterEach(() => {
})
describe('large Markdown paste selection during yields', () => {
it('replaces the original selection while preserving a caret moved during production-size measurement', async () => {
const editor = createEditor()
const delay = pause()
const yieldToEventLoop = vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined)
expect(handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop })).toBe(true)
expect(yieldToEventLoop).toHaveBeenCalledOnce()
editor.commands.setTextSelection(14)
delay.resume()
await flush()
expect(editor.state.doc.child(0).textContent === `hello ${largeText}`).toBe(true)
expect(editor.state.doc.child(1).textContent).toBe('second paragraph')
expect(editor.state.selection.from).toBe(editor.state.doc.child(0).nodeSize + 1)
expect(document.activeElement).toBe(editor.view.dom)
expect(toast.info).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
})
it('keeps every remaining production-size chunk at its insertion end after the caret moves', async () => {
const editor = createEditor()
const delay = pause()
@@ -215,59 +185,6 @@ describe('large Markdown paste selection during yields', () => {
}
)
it('uses native multi-cell replacement before continuing in the first inserted cell', async () => {
const content =
'<table><tbody><tr><td>first</td><td>second</td></tr><tr><td>third</td><td>fourth</td></tr></tbody></table><p>outside</p>'
const editor = createEditor(content)
const cellPositions: number[] = []
editor.state.doc.descendants((node, position) => {
if (node.type.spec.tableRole === 'cell') {
cellPositions.push(position)
}
})
editor.view.dispatch(
editor.state.tr.setSelection(
CellSelection.create(editor.state.doc, cellPositions[0]!, cellPositions[3]!)
)
)
const original = editor.state.doc
const native = editor.state.tr.insertText(largeText).doc
const delay = pause()
const yieldToEventLoop = vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined)
handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop })
const outside = textPosition(editor, 'outside')
editor.commands.setTextSelection(outside + 2)
delay.resume()
await flush()
expect(editor.state.doc.eq(native)).toBe(true)
expect(editor.state.doc.child(0).childCount).toBe(original.child(0).childCount)
expect(editor.state.selection.from).toBe(textPosition(editor, 'outside') + 2)
})
it('releases successful paste listeners and undoes all chunks together', async () => {
const editor = createEditor()
const original = editor.getJSON()
const off = vi.spyOn(editor, 'off')
handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop: async () => {} })
await flush()
expect(editor.commands.undo()).toBe(true)
expect(editor.getJSON()).toEqual(original)
expect(off.mock.calls.map(([event]) => event)).toContain('transaction')
expect(off.mock.calls.map(([event]) => event)).toContain('destroy')
})
it('keeps the pending paste before ordinary typing at its original collapsed caret', async () => {
const editor = createEditor()
editor.commands.setTextSelection(7)
const delay = pause()
const yieldToEventLoop = vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined)
handleRichMarkdownLargeTextPaste(editor, pasteEvent(), { yieldToEventLoop })
editor.commands.insertContent('typed ')
delay.resume()
await flush()
expect(editor.state.doc.child(0).textContent === `hello ${largeText}typed world`).toBe(true)
expect(editor.state.selection.from).toBe(7 + largeText.length + 'typed '.length)
})
it('excludes later typing at both ends of a reversed pending selection', async () => {
const editor = createEditor()
editor.commands.setTextSelection({ from: 12, to: 7 })
@@ -420,20 +337,6 @@ describe('large Markdown paste selection during yields', () => {
})
expect(pastedMarks).toEqual(['bold'])
})
it('preserves armed marks at a moved live caret while writing the captured range', async () => {
const editor = createEditor()
const delay = pause()
handleRichMarkdownLargeTextPaste(editor, pasteEvent(), {
yieldToEventLoop: vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined)
})
editor.commands.setTextSelection(14)
editor.commands.toggleItalic()
delay.resume()
await flush()
expect(editor.state.storedMarks?.some((mark) => mark.type.name === 'italic')).toBe(true)
expect(editor.state.selection.from).toBe(editor.state.doc.child(0).nodeSize + 1)
})
it.each(['adjacent', 'other paragraph'] as const)(
'keeps typing at %s separate between chronological paste segments',
async (position) => {
@@ -474,33 +377,6 @@ describe('large Markdown paste selection during yields', () => {
}
)
it('closes partial paste history after focus cancellation without reclaiming focus', async () => {
const editor = createEditor()
const initialPlugins = editor.state.plugins
const delay = pause()
const yieldToEventLoop = vi.fn().mockReturnValueOnce(delay.promise).mockResolvedValue(undefined)
handleRichMarkdownLargeTextPaste(editor, pasteEvent('ABCDE'), {
directMaxBytes: 1,
chunkMaxBytes: 2,
yieldToEventLoop
})
await flush()
expect(editor.state.doc.child(0).textContent).toBe('hello AB')
const input = document.createElement('input')
document.body.append(input)
input.focus()
delay.resume()
await flush()
expect(document.activeElement).toBe(input)
expect(toast.info).toHaveBeenCalledExactlyOnceWith('Large paste stopped before it finished.')
expect(toast.error).not.toHaveBeenCalled()
expect(editor.state.plugins).toEqual(initialPlugins)
editor.view.dom.focus()
editor.commands.insertContent('T')
expect(editor.commands.undo()).toBe(true)
expect(editor.state.doc.child(0).textContent).toBe('hello AB')
})
it('cancels a full-document selection when a new paragraph appears outside its original range', async () => {
const editor = createEditor()
editor.view.dispatch(editor.state.tr.setSelection(new AllSelection(editor.state.doc)))
@@ -87,76 +87,6 @@ afterEach(() => {
})
describe('rich markdown large text paste', () => {
it('ignores small, empty, default-prevented, and missing-editor paste events', () => {
const { editor, chunks } = makeEditor()
const small = makePasteEvent('small')
const empty = makePasteEvent('')
const handled = makePasteEvent('x'.repeat(128))
handled.preventDefault()
expect(handleRichMarkdownLargeTextPaste(null, makePasteEvent('text'))).toBe(false)
expect(handleRichMarkdownLargeTextPaste(editor, small, { directMaxBytes: 128 })).toBe(false)
expect(handleRichMarkdownLargeTextPaste(editor, empty, { directMaxBytes: 1 })).toBe(false)
expect(handleRichMarkdownLargeTextPaste(editor, handled, { directMaxBytes: 8 })).toBe(false)
expect(small.defaultPrevented).toBe(false)
expect(chunks).toEqual([])
expect(toast.info).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
})
it('inserts large plain text through chunked ProseMirror transactions', async () => {
const { editor, chunks } = makeEditor()
const text = 'ab😀cd\n'.repeat(6)
const event = makePasteEvent(text)
const yieldToEventLoop = vi.fn(async () => {})
expect(
handleRichMarkdownLargeTextPaste(editor, event, {
directMaxBytes: 8,
chunkMaxBytes: 10,
yieldToEventLoop
})
).toBe(true)
await flushPromises()
expect(event.defaultPrevented).toBe(true)
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.join('')).toBe(text)
expect(chunks.some((chunk) => /[\uD800-\uDBFF]$/.test(chunk))).toBe(false)
expect(yieldToEventLoop).toHaveBeenCalledTimes(chunks.length - 1)
expect(toast.info).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
})
it('claims large plain-text paste before yielded preflight inserts editor content', async () => {
const { editor, chunks } = makeEditor()
const text = 'x'.repeat(32)
const event = makePasteEvent(text)
const yieldToEventLoop = vi.fn(async () => {})
const codePointAt = vi.spyOn(String.prototype, 'codePointAt')
expect(
handleRichMarkdownLargeTextPaste(editor, event, {
directMaxBytes: 4,
chunkMaxBytes: 64,
maxBytes: 64,
measureYieldAfterCodeUnits: 8,
yieldToEventLoop
})
).toBe(true)
expect(event.defaultPrevented).toBe(true)
expect(chunks).toEqual([])
expect(codePointAt.mock.calls.length).toBeLessThan(text.length)
await flushPromises()
expect(chunks.join('')).toBe(text)
expect(yieldToEventLoop).toHaveBeenCalled()
expect(toast.info).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
})
it('falls back to plain text when rich HTML is too large for synchronous parsing', async () => {
const { editor, chunks } = makeEditor()
const text = 'safe fallback'
@@ -241,25 +171,6 @@ describe('rich markdown large text paste', () => {
expect(toast.info).not.toHaveBeenCalled()
})
it('rejects oversized multibyte rich-editor paste before inserting content', async () => {
const { editor, chunks } = makeEditor()
const event = makePasteEvent('😀'.repeat(8))
expect(
handleRichMarkdownLargeTextPaste(editor, event, {
directMaxBytes: 2,
maxBytes: 7
})
).toBe(true)
await flushPromises()
expect(event.defaultPrevented).toBe(true)
expect(chunks).toEqual([])
expect(toast.error).toHaveBeenCalledWith('Paste is too large.')
expect(toast.info).not.toHaveBeenCalled()
})
it('stops chunking when the editor is destroyed between chunks', async () => {
const { editor, chunks, setDestroyed } = makeEditor()
const text = 'abcdef'.repeat(6)
@@ -279,25 +190,4 @@ describe('rich markdown large text paste', () => {
expect(toast.info).toHaveBeenCalledExactlyOnceWith('Large paste stopped before it finished.')
expect(toast.error).not.toHaveBeenCalled()
})
it('stops chunking when focus leaves the original editor target', async () => {
const { editor, chunks, setFocused } = makeEditor()
const text = 'abcdef'.repeat(6)
const event = makePasteEvent(text)
handleRichMarkdownLargeTextPaste(editor, event, {
directMaxBytes: 8,
chunkMaxBytes: 6,
yieldToEventLoop: async () => {
setFocused(false)
}
})
await flushPromises()
expect(event.defaultPrevented).toBe(true)
expect(chunks).toEqual(['abcdef'])
expect(toast.info).toHaveBeenCalledExactlyOnceWith('Large paste stopped before it finished.')
expect(toast.error).not.toHaveBeenCalled()
expect(document.activeElement).not.toBe(editor.view.dom)
})
})
@@ -1,467 +0,0 @@
// @vitest-environment happy-dom
import { Editor, type JSONContent } from '@tiptap/core'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import { TextSelection, type Plugin } from '@tiptap/pm/state'
import type { Decoration, DecorationSet } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit'
import { common, createLowlight } from 'lowlight'
import { afterEach, describe, expect, it } from 'vitest'
import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight'
type Lowlight = ReturnType<typeof createLowlight>
type Counters = { highlight: number; highlightAuto: number; listLanguages: number }
type RuntimeKeyedPlugin = Plugin & { key?: string }
type DecorationWithAttrs = Decoration & {
type: { attrs?: { class?: string } }
}
function observedLowlight(counters: Counters): Lowlight {
const base = createLowlight(common)
return {
...base,
highlight: (...args) => {
counters.highlight += 1
return base.highlight(...args)
},
highlightAuto: (...args) => {
counters.highlightAuto += 1
return base.highlightAuto(...args)
},
listLanguages: () => {
counters.listLanguages += 1
return base.listLanguages()
}
}
}
function documentWithCodeBlocks(count = 3): JSONContent {
return {
type: 'doc',
content: Array.from({ length: count }, (_, index) => [
{ type: 'paragraph', content: [{ type: 'text', text: `paragraph ${index}` }] },
{
type: 'codeBlock',
attrs: { language: index === 1 ? 'js' : index === 2 ? 'unknown' : 'typescript' },
content: [{ type: 'text', text: `const value${index}: number = ${index}` }]
}
]).flat()
}
}
function createEditor({
incremental,
counters,
content = documentWithCodeBlocks(),
defaultLanguage = null
}: {
incremental: boolean
counters: Counters
content?: JSONContent
defaultLanguage?: string | null
}): Editor {
const extension = incremental ? RichMarkdownCodeBlockLowlight : CodeBlockLowlight
return new Editor({
element: document.createElement('div'),
extensions: [
StarterKit.configure({ codeBlock: false, trailingNode: false }),
extension.configure({ lowlight: observedLowlight(counters), defaultLanguage })
],
content
})
}
function nodePositions(editor: Editor, name: string): number[] {
const positions: number[] = []
editor.state.doc.descendants((node, pos) => {
if (node.type.name === name) {
positions.push(pos)
}
})
return positions
}
function decorationSnapshot(editor: Editor, incremental: boolean): unknown[] {
const keyPrefix = incremental ? 'richMarkdownLowlight$' : 'lowlight$'
const plugin = editor.state.plugins.find((candidate) =>
(candidate as RuntimeKeyedPlugin).key?.startsWith(keyPrefix)
)
const decorations = plugin?.getState(editor.state) as DecorationSet | undefined
if (!decorations) {
const keys = editor.state.plugins.map((candidate) => (candidate as RuntimeKeyedPlugin).key)
throw new Error(`Missing ${keyPrefix} plugin: ${JSON.stringify(keys)}`)
}
return decorations.find().map((decoration) => {
const typed = decoration as DecorationWithAttrs
return [decoration.from, decoration.to, typed.type.attrs?.class]
})
}
function expectParity(stock: Editor, incremental: Editor): void {
expect(incremental.state.doc.toJSON()).toEqual(stock.state.doc.toJSON())
expect(decorationSnapshot(incremental, true)).toEqual(decorationSnapshot(stock, false))
}
function resetCounters(counters: Counters): void {
counters.highlight = 0
counters.highlightAuto = 0
counters.listLanguages = 0
}
describe('incremental rich markdown lowlight', () => {
const editors: Editor[] = []
afterEach(() => {
for (const editor of editors) {
editor.destroy()
}
editors.length = 0
})
function createPair(content?: JSONContent, defaultLanguage: string | null = null) {
const stockCounters = { highlight: 0, highlightAuto: 0, listLanguages: 0 }
const incrementalCounters = { highlight: 0, highlightAuto: 0, listLanguages: 0 }
const stock = createEditor({
incremental: false,
counters: stockCounters,
content,
defaultLanguage
})
const incremental = createEditor({
incremental: true,
counters: incrementalCounters,
content,
defaultLanguage
})
editors.push(stock, incremental)
return { stock, incremental, stockCounters, incrementalCounters }
}
it('matches stock decorations for named, aliased, and unknown languages', () => {
const { stock, incremental, stockCounters, incrementalCounters } = createPair()
expectParity(stock, incremental)
expect(stockCounters).toEqual({ highlight: 2, highlightAuto: 1, listLanguages: 3 })
expect(incrementalCounters).toEqual({ highlight: 2, highlightAuto: 1, listLanguages: 1 })
})
it('matches stock decorations when a default language is configured', () => {
const content = documentWithCodeBlocks(1)
const codeBlock = content.content?.[1]
if (!codeBlock?.attrs) {
throw new Error('Missing code block')
}
codeBlock.attrs.language = null
const { stock, incremental, stockCounters, incrementalCounters } = createPair(
content,
'typescript'
)
expectParity(stock, incremental)
expect(stockCounters.highlight).toBe(1)
expect(incrementalCounters.highlight).toBe(1)
})
it('maps decorations without highlighting after a prose edit', () => {
const { stock, incremental, stockCounters, incrementalCounters } = createPair()
resetCounters(stockCounters)
resetCounters(incrementalCounters)
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'paragraph')[0] + 1
editor.view.dispatch(editor.state.tr.insertText('x', pos))
}
expectParity(stock, incremental)
expect(stockCounters.highlight + stockCounters.highlightAuto).toBe(0)
expect(incrementalCounters.highlight + incrementalCounters.highlightAuto).toBe(0)
})
it('highlights only the edited code block', () => {
const { stock, incremental, stockCounters, incrementalCounters } = createPair()
resetCounters(stockCounters)
resetCounters(incrementalCounters)
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'codeBlock')[0] + 1
const tr = editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))
editor.view.dispatch(tr.insertText('x'))
}
expectParity(stock, incremental)
expect(stockCounters.highlight + stockCounters.highlightAuto).toBe(3)
expect(incrementalCounters.highlight + incrementalCounters.highlightAuto).toBe(1)
})
it('highlights only the code blocks touched by a multi-step transaction', () => {
const { stock, incremental, stockCounters, incrementalCounters } = createPair()
resetCounters(stockCounters)
resetCounters(incrementalCounters)
for (const editor of [stock, incremental]) {
const [first, , third] = nodePositions(editor, 'codeBlock').map((pos) => pos + 1)
const tr = editor.state.tr.insertText('z', third).insertText('a', first)
tr.setSelection(TextSelection.create(tr.doc, first))
editor.view.dispatch(tr)
}
expectParity(stock, incremental)
expect(stockCounters.highlight + stockCounters.highlightAuto).toBe(3)
expect(incrementalCounters.highlight + incrementalCounters.highlightAuto).toBe(2)
})
it('falls back safely for a language attribute change', () => {
const { stock, incremental } = createPair()
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'codeBlock')[0]
const node = editor.state.doc.nodeAt(pos)
const tr = editor.state.tr.setNodeMarkup(pos, undefined, {
...node?.attrs,
language: 'javascript'
})
tr.setSelection(TextSelection.create(tr.doc, pos + 1))
editor.view.dispatch(tr)
}
expectParity(stock, incremental)
})
it('preserves parity when code blocks are inserted and deleted', () => {
const { stock, incremental } = createPair()
for (const editor of [stock, incremental]) {
const end = editor.state.doc.content.size
const codeBlock = editor.schema.nodes.codeBlock.create(
{ language: 'typescript' },
editor.schema.text('let inserted = true')
)
editor.view.dispatch(editor.state.tr.insert(end, codeBlock))
}
expectParity(stock, incremental)
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'codeBlock')[1]
const node = editor.state.doc.nodeAt(pos)
if (!node) {
throw new Error('Missing code block')
}
editor.view.dispatch(editor.state.tr.delete(pos, pos + node.nodeSize))
}
expectParity(stock, incremental)
})
it('preserves parity through undo and redo', () => {
const { stock, incremental } = createPair()
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'codeBlock')[0] + 1
const tr = editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))
editor.view.dispatch(tr.insertText('undoable'))
}
expectParity(stock, incremental)
expect(stock.commands.undo()).toBe(true)
expect(incremental.commands.undo()).toBe(true)
expectParity(stock, incremental)
expect(stock.commands.redo()).toBe(true)
expect(incremental.commands.redo()).toBe(true)
expectParity(stock, incremental)
})
it('preserves parity when paragraphs and code blocks are converted', () => {
const { stock, incremental } = createPair()
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'paragraph')[0] + 1
editor.view.dispatch(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))
)
expect(editor.commands.setCodeBlock({ language: 'typescript' })).toBe(true)
}
expectParity(stock, incremental)
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'codeBlock')[0] + 1
editor.view.dispatch(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))
)
expect(editor.commands.setParagraph()).toBe(true)
}
expectParity(stock, incremental)
})
it('preserves parity for paste and deletion inside a code block', () => {
const { stock, incremental } = createPair()
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'codeBlock')[0] + 2
editor.view.dispatch(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))
)
expect(editor.view.pasteText('pasted text')).toBe(true)
}
expectParity(stock, incremental)
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'codeBlock')[0] + 1
editor.view.dispatch(editor.state.tr.delete(pos, pos + 6))
}
expectParity(stock, incremental)
})
it('preserves parity when a code block is split and joined', () => {
const { stock, incremental } = createPair()
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'codeBlock')[0] + 8
editor.view.dispatch(editor.state.tr.split(pos))
}
expectParity(stock, incremental)
for (const editor of [stock, incremental]) {
const joinPos = nodePositions(editor, 'codeBlock')[1]
editor.view.dispatch(editor.state.tr.join(joinPos))
}
expectParity(stock, incremental)
})
it('preserves parity when a code block moves in one transaction', () => {
const { stock, incremental } = createPair()
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'codeBlock')[0]
const node = editor.state.doc.nodeAt(pos)
if (!node) {
throw new Error('Missing code block')
}
const tr = editor.state.tr.delete(pos, pos + node.nodeSize)
const insertedAt = tr.doc.content.size
tr.insert(insertedAt, node)
tr.setSelection(TextSelection.create(tr.doc, insertedAt + 1))
editor.view.dispatch(tr)
}
expectParity(stock, incremental)
})
it('preserves parity for code blocks nested in list items', () => {
const content: JSONContent = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'item' }] },
{
type: 'codeBlock',
attrs: { language: 'typescript' },
content: [{ type: 'text', text: 'const nested = true' }]
}
]
}
]
},
{ type: 'paragraph', content: [{ type: 'text', text: 'after' }] }
]
}
const { stock, incremental } = createPair(content)
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'codeBlock')[0] + 1
const tr = editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))
editor.view.dispatch(tr.insertText('x'))
}
expectParity(stock, incremental)
})
it('preserves parity after whole-document replacement', () => {
const { stock, incremental } = createPair()
expect(stock.commands.setContent(documentWithCodeBlocks(5))).toBe(true)
expect(incremental.commands.setContent(documentWithCodeBlocks(5))).toBe(true)
expectParity(stock, incremental)
})
it('does not highlight for selection-only transactions', () => {
const { stock, incremental, stockCounters, incrementalCounters } = createPair()
resetCounters(stockCounters)
resetCounters(incrementalCounters)
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'codeBlock')[0] + 1
editor.view.dispatch(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))
)
}
expectParity(stock, incremental)
expect(stockCounters.highlight + stockCounters.highlightAuto).toBe(0)
expect(incrementalCounters.highlight + incrementalCounters.highlightAuto).toBe(0)
})
it('replaces the stock plugin without dropping the code-block paste handler', () => {
const counters = { highlight: 0, highlightAuto: 0, listLanguages: 0 }
const editor = createEditor({ incremental: true, counters })
editors.push(editor)
const keys = editor.state.plugins.map((plugin) => (plugin as RuntimeKeyedPlugin).key)
expect(RichMarkdownCodeBlockLowlight.options).toEqual(CodeBlockLowlight.options)
expect(keys.some((key) => key?.startsWith('richMarkdownLowlight$'))).toBe(true)
expect(keys.some((key) => key?.startsWith('lowlight$'))).toBe(false)
expect(keys.some((key) => key?.startsWith('codeBlockVSCodeHandler$'))).toBe(true)
})
it('preserves parity at adjacent code-block boundaries', () => {
const content: JSONContent = {
type: 'doc',
content: documentWithCodeBlocks(3).content?.filter((node) => node.type === 'codeBlock')
}
const { stock, incremental } = createPair(content)
for (const editor of [stock, incremental]) {
const firstPos = nodePositions(editor, 'codeBlock')[0]
const first = editor.state.doc.nodeAt(firstPos)
if (!first) {
throw new Error('Missing first code block')
}
editor.view.dispatch(editor.state.tr.insertText('tail', firstPos + first.nodeSize - 1))
}
expectParity(stock, incremental)
for (const editor of [stock, incremental]) {
const firstPos = nodePositions(editor, 'codeBlock')[0]
const first = editor.state.doc.nodeAt(firstPos)
if (!first) {
throw new Error('Missing first code block')
}
editor.view.dispatch(editor.state.tr.delete(firstPos, firstPos + first.nodeSize))
}
expectParity(stock, incremental)
})
it('rehighlights one of 533 code blocks after a local code edit', () => {
const { stock, incremental, stockCounters, incrementalCounters } = createPair(
documentWithCodeBlocks(533)
)
resetCounters(stockCounters)
resetCounters(incrementalCounters)
for (const editor of [stock, incremental]) {
const pos = nodePositions(editor, 'codeBlock')[0] + 1
const tr = editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))
editor.view.dispatch(tr.insertText('x'))
}
expectParity(stock, incremental)
expect(stockCounters.highlight + stockCounters.highlightAuto).toBe(533)
expect(incrementalCounters.highlight + incrementalCounters.highlightAuto).toBe(1)
expect(incrementalCounters.listLanguages).toBe(0)
})
})
@@ -151,105 +151,6 @@ function emptyTopLevelOrderedList(): JSONContent {
}
describe('rich markdown key handler', () => {
it('flushes and saves an unmarked save shortcut while the editor is composing', () => {
const editor = createEditor(emptyTopLevelOrderedList())
try {
Object.defineProperty(editor.view, 'composing', { value: true, configurable: true })
const ctx = createContext(editor, false)
const event = keyEvent('s', { metaKey: true, code: 'KeyS' })
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true)
expect(event.preventDefault).toHaveBeenCalledOnce()
expect(ctx.flushPendingSerialization).toHaveBeenCalledOnce()
expect(ctx.onSaveRef.current).toHaveBeenCalledOnce()
} finally {
editor.destroy()
}
})
it.each([{ isComposing: true }, { keyCode: 229 }])(
'does not save when the shortcut event belongs to the IME: %j',
(marker) => {
const editor = createEditor(emptyTopLevelOrderedList())
try {
const ctx = createContext(editor, false)
const event = keyEvent('s', { metaKey: true, code: 'KeyS', ...marker })
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(false)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(ctx.flushPendingSerialization).not.toHaveBeenCalled()
expect(ctx.onSaveRef.current).not.toHaveBeenCalled()
} finally {
editor.destroy()
}
}
)
it('opens the review-note composer on the add-review-note shortcut', () => {
const editor = createEditor(emptyTopLevelOrderedList())
try {
const ctx = createContext(editor, false)
const event = keyEvent('a', { metaKey: true, shiftKey: true, code: 'KeyA' })
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true)
expect(event.preventDefault).toHaveBeenCalled()
expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledWith(true)
} finally {
editor.destroy()
}
})
it('leaves the add-review-note chord unconsumed when no composer opens', () => {
const editor = createEditor(emptyTopLevelOrderedList())
try {
const ctx = createContext(editor, false)
ctx.openAnnotationPopoverRef.current = vi.fn(() => false)
const event = keyEvent('a', { metaKey: true, shiftKey: true, code: 'KeyA' })
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(false)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledTimes(1)
} finally {
editor.destroy()
}
})
it('ignores OS key-repeat for the add-review-note shortcut', () => {
const editor = createEditor(emptyTopLevelOrderedList())
try {
const ctx = createContext(editor, false)
const event = keyEvent('a', { metaKey: true, shiftKey: true, code: 'KeyA', repeat: true })
// The mounted composer guard owns repeats; the editor path must leave them unconsumed.
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(false)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(ctx.openAnnotationPopoverRef.current).not.toHaveBeenCalled()
} finally {
editor.destroy()
}
})
it('delegates a fresh add-review-note chord to openAnnotationPopover and consumes it', () => {
const editor = createEditor(emptyTopLevelOrderedList())
try {
const ctx = createContext(editor, false)
// openAnnotationPopover owns selection flushing; this handler delegates with requireLiveSelection.
ctx.openAnnotationPopoverRef.current = vi.fn(() => true)
const event = keyEvent('a', { metaKey: true, shiftKey: true, code: 'KeyA' })
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true)
expect(event.preventDefault).toHaveBeenCalled()
expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledTimes(1)
expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledWith(true)
} finally {
editor.destroy()
}
})
it('preserves a typed empty ordered-list shortcut on Enter', () => {
const editor = createEditor(emptyTopLevelOrderedList())
@@ -267,21 +168,6 @@ describe('rich markdown key handler', () => {
}
})
it('leaves toolbar-created empty ordered lists to the default Enter behavior', () => {
const editor = createEditor(emptyTopLevelOrderedList())
try {
editor.commands.setTextSelection(3)
const event = keyEvent('Enter')
expect(createRichMarkdownKeyHandler(createContext(editor, false))(null, event)).toBe(false)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(editor.state.doc.toJSON()).toEqual(emptyTopLevelOrderedList())
} finally {
editor.destroy()
}
})
it('exits loaded trailing empty ordered-list items on Enter', () => {
const editor = createEditor({
type: 'doc',
@@ -328,123 +214,6 @@ describe('rich markdown key handler', () => {
}
})
it.each([{ isComposing: true }, { keyCode: 229 }])(
'does not rewrite IME list input: %j',
(marker) => {
const editor = createEditor(emptyTopLevelOrderedList())
try {
editor.commands.setTextSelection(3)
const event = keyEvent('Enter', marker)
expect(createRichMarkdownKeyHandler(createContext(editor, true))(null, event)).toBe(false)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(editor.state.doc.toJSON()).toEqual(emptyTopLevelOrderedList())
} finally {
editor.destroy()
}
}
)
it.each([{ isComposing: true }, { keyCode: 229 }])(
'keeps IME candidate keys out of the slash menu: %j',
(marker) => {
const editor = createEditor(emptyTopLevelOrderedList())
try {
const ctx = createContext(editor, false)
ctx.slashMenuRef.current = { query: '', from: 1, to: 1, left: 0, top: 0 }
const run = vi.fn()
ctx.filteredSlashCommandsRef.current = [
{
id: 'heading-1',
label: 'Heading',
aliases: [],
icon: { kind: 'text', value: 'H' },
group: 'Basic blocks',
description: 'Heading',
run
}
]
for (const key of ['Enter', 'Escape', 'ArrowDown']) {
const event = keyEvent(key, marker)
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(false)
expect(event.preventDefault).not.toHaveBeenCalled()
}
expect(run).not.toHaveBeenCalled()
expect(ctx.setSlashMenu).not.toHaveBeenCalled()
expect(ctx.setSelectedCommandIndex).not.toHaveBeenCalled()
expect(createRichMarkdownKeyHandler(ctx)(null, keyEvent('Enter'))).toBe(true)
expect(run).toHaveBeenCalledOnce()
} finally {
editor.destroy()
}
}
)
it('lets slash-menu filter input fall through to document input', () => {
const editor = createEditor({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: '/' }] }]
})
try {
editor.commands.setTextSelection(2)
let slashMenu = { query: '', from: 1, to: 2, left: 0, top: 0 }
const ctx = createContext(editor, false)
ctx.slashMenuRef.current = slashMenu
ctx.filteredSlashCommandsRef.current = [{ id: 'heading-1' } as never]
ctx.setSlashMenu = vi.fn((next) => {
slashMenu = typeof next === 'function' ? next(slashMenu) : next
ctx.slashMenuRef.current = slashMenu
})
const event = keyEvent('h')
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(false)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(editor.getText()).toBe('/')
expect(ctx.slashMenuRef.current?.query).toBe('')
} finally {
editor.destroy()
}
})
it('runs the selected slash command on Enter instead of the table cell-below move', () => {
const editor = createTableEditor()
try {
editor.commands.setTextSelection(caretAtText(editor, 'a1'))
const ctx = createContext(editor, false)
const from = editor.state.selection.from
const run = vi.fn()
ctx.slashMenuRef.current = { query: '', from, to: from, left: 0, top: 0 }
ctx.filteredSlashCommandsRef.current = [{ id: 'heading-1', run } as never]
const event = keyEvent('Enter')
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true)
expect(run).toHaveBeenCalledWith(editor)
expect(editor.state.selection.$from.parent.textContent).toBe('a1')
} finally {
editor.destroy()
}
})
it.each([true, false])('leaves the primary-modifier table Enter to Tiptap (Mac: %s)', (isMac) => {
const editor = createTableEditor()
try {
editor.commands.setTextSelection(caretAtText(editor, 'a1') + 1)
const ctx = createContext(editor, false)
ctx.isMac = isMac
const event = keyEvent('Enter', isMac ? { metaKey: true } : { ctrlKey: true })
const before = editor.state
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(false)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(editor.state).toBe(before)
} finally {
editor.destroy()
}
})
it('keeps Shift+Enter navigation for a multi-cell selection that cannot take a break', () => {
const editor = createTableEditor()
try {
@@ -526,26 +295,4 @@ describe('rich markdown key handler', () => {
}
}
)
it('dismisses the slash menu on Escape even when search has no matches', () => {
const editor = createEditor({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: '/zzz' }] }]
})
try {
editor.commands.setTextSelection(5)
const ctx = createContext(editor, false)
ctx.slashMenuRef.current = { query: 'zzz', from: 1, to: 5, left: 0, top: 0 }
ctx.filteredSlashCommandsRef.current = []
ctx.setSlashMenu = vi.fn()
const event = keyEvent('Escape')
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true)
expect(event.preventDefault).toHaveBeenCalled()
expect(ctx.setSlashMenu).toHaveBeenCalledWith(null)
} finally {
editor.destroy()
}
})
})
@@ -35,41 +35,6 @@ function createMarkdownEditor(markdown: string): Editor {
}
/** Use the node shapes HTML paste produces because this DOM-less fixture cannot parse the reported pasted HTML. */
function createNodeEditor(content: JSONContent): Editor {
return new Editor({
element: null,
extensions: createRichMarkdownExtensions({
codec: createRichMarkdownEditorCodec()
}),
content
})
}
function para(text: string): JSONContent {
return { type: 'paragraph', content: [{ type: 'text', text }] }
}
function bullets(...items: JSONContent[][]): JSONContent {
return {
type: 'bulletList',
content: items.map((content) => ({ type: 'listItem', content }))
}
}
function tasks(...items: JSONContent[][]): JSONContent {
return {
type: 'taskList',
content: items.map((content) => ({
type: 'taskItem',
attrs: { checked: false },
content
}))
}
}
function doc(...content: JSONContent[]): JSONContent {
return { type: 'doc', content }
}
function textPosition(editor: Editor, text: string): number {
let position: number | null = null
@@ -167,27 +132,6 @@ function createContext(editor: Editor): KeyHandlerContext {
}
}
function bulletListDocument(): JSONContent {
return {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Alpha' }] }]
},
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Beta' }] }]
}
]
}
]
}
}
function parentAndFixesDocument(): JSONContent {
return {
type: 'doc',
@@ -252,35 +196,6 @@ function taskListDocument(): JSONContent {
}
describe('rich markdown Tab key handler', () => {
it('flushes pending ProseMirror DOM selection before indenting lists', () => {
const calls: string[] = []
const editor = {
view: {
composing: false,
domObserver: {
currentSelection: {
set: vi.fn(() => calls.push('reset-selection'))
},
flush: vi.fn(() => calls.push('flush'))
}
},
state: { selection: { $from: { depth: 0 } } },
commands: {
sinkListItem: vi.fn((type) => {
calls.push(`sink:${String(type)}`)
return true
}),
liftListItem: vi.fn(),
insertContent: vi.fn()
},
isActive: vi.fn(() => false)
} as unknown as Editor
const event = keyEvent('Tab')
expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true)
expect(calls).toEqual(['reset-selection', 'flush', 'sink:listItem'])
})
it('indents FixA under a preceding parent item on Tab', () => {
const editor = createEditor(parentAndFixesDocument())
@@ -295,35 +210,6 @@ describe('rich markdown Tab key handler', () => {
}
})
it('indents second bullet list item on Tab', () => {
const editor = createEditor(bulletListDocument())
try {
editor.commands.setTextSelection(textPosition(editor, 'Beta'))
const event = keyEvent('Tab')
expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true)
expect(editor.getMarkdown()).toBe('- Alpha\n - Beta')
} finally {
editor.destroy()
}
})
it('keeps first list item Tab consumed without changing the document', () => {
const editor = createEditor(bulletListDocument())
try {
editor.commands.setTextSelection(textPosition(editor, 'Alpha'))
const event = keyEvent('Tab')
expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true)
expect(event.preventDefault).toHaveBeenCalled()
expect(editor.getMarkdown()).toBe('- Alpha\n- Beta')
} finally {
editor.destroy()
}
})
it('indents second task item through the taskItem fallback', () => {
const editor = createEditor(taskListDocument())
@@ -338,20 +224,6 @@ describe('rich markdown Tab key handler', () => {
}
})
it('outdents a bullet nested inside a task item into the enclosing task list', () => {
const editor = createMarkdownEditor('- [ ] Task A\n - Bullet B')
try {
editor.commands.setTextSelection(textPosition(editor, 'Bullet B'))
const event = keyEvent('Tab', { shiftKey: true })
expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true)
expect(editor.getMarkdown()).toBe('- [ ] Task A\n- [ ] Bullet B')
} finally {
editor.destroy()
}
})
it('outdents a task nested inside a bullet item into the enclosing bullet list', () => {
const editor = createMarkdownEditor('- Bullet A\n - [ ] Task B')
@@ -394,34 +266,6 @@ describe('rich markdown Tab key handler', () => {
}
})
it('outdents a nested bullet list item on Shift-Tab', () => {
const editor = createMarkdownEditor('- Alpha\n - Beta')
try {
editor.commands.setTextSelection(textPosition(editor, 'Beta'))
const event = keyEvent('Tab', { shiftKey: true })
expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true)
expect(editor.getMarkdown()).toBe('- Alpha\n- Beta')
} finally {
editor.destroy()
}
})
it('removes one indent step from a code block line on Shift-Tab', () => {
const editor = createMarkdownEditor('```js\n const x = 1\n```')
try {
editor.commands.setTextSelection(codeBlockPosition(editor, 'const x'))
const event = keyEvent('Tab', { shiftKey: true })
expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true)
expect(editor.getMarkdown()).toBe('```js\n const x = 1\n```')
} finally {
editor.destroy()
}
})
it('outdents every code block line the selection touches', () => {
const editor = createMarkdownEditor('```js\n a\n b\n c\n```')
@@ -540,62 +384,4 @@ describe('rich markdown Tab key handler', () => {
editor.destroy()
}
})
it('leaves an unindented code block untouched on Shift-Tab', () => {
const editor = createMarkdownEditor('```js\nconst x = 1\n```')
try {
editor.commands.setTextSelection(codeBlockPosition(editor, 'const x'))
const event = keyEvent('Tab', { shiftKey: true })
expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true)
expect(editor.getMarkdown()).toBe('```js\nconst x = 1\n```')
} finally {
editor.destroy()
}
})
it('outdents a nested bullet that arrived as a pasted list node tree', () => {
const editor = createNodeEditor(doc(bullets([para('One'), bullets([para('Two')])])))
try {
editor.commands.setTextSelection(textPosition(editor, 'Two'))
const event = keyEvent('Tab', { shiftKey: true })
expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true)
expect(editor.getMarkdown()).toBe('- One\n- Two')
} finally {
editor.destroy()
}
})
it('outdents a bullet nested under a pasted task item', () => {
const editor = createNodeEditor(doc(tasks([para('Task A'), bullets([para('Bullet B')])])))
try {
editor.commands.setTextSelection(textPosition(editor, 'Bullet B'))
const event = keyEvent('Tab', { shiftKey: true })
expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true)
expect(editor.getMarkdown()).toBe('- [ ] Task A\n- [ ] Bullet B')
} finally {
editor.destroy()
}
})
it('keeps pasted sibling bullets behind when outdenting out of a task item', () => {
const editor = createNodeEditor(
doc(tasks([para('Task A'), bullets([para('Bullet X')], [para('Bullet Y')])]))
)
try {
editor.commands.setTextSelection(textPosition(editor, 'Bullet Y'))
const event = keyEvent('Tab', { shiftKey: true })
expect(createRichMarkdownKeyHandler(createContext(editor))(null, event)).toBe(true)
expect(editor.getMarkdown()).toBe('- [ ] Task A\n - Bullet X\n- [ ] Bullet Y')
} finally {
editor.destroy()
}
})
})
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { Editor, type JSONContent } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import {
@@ -10,7 +10,7 @@ import { createRichMarkdownEditorCodec } from '../document/rich-markdown-source-
import { commitRichMarkdownSerialization } from '../document/rich-markdown-serialization-commit'
import { encodeRawMarkdownHtmlForRichEditor } from '../raw-source/raw-markdown-html'
import { createRichMarkdownHtmlSuperscriptLinkContext } from '../citations/rich-markdown-html-superscript-link-context'
import { reconcileRichMarkdownBlockSource } from '../document/rich-markdown-block-source'
import { normalizeEmptyListItems } from '../schema/rich-markdown-normalize'
{
@@ -44,24 +44,6 @@ import { normalizeEmptyListItems } from '../schema/rich-markdown-normalize'
return position
}
function firstTextEndPosition(editor: Editor, text: string): number {
let position: number | null = null
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text === text) {
position = pos + text.length
return false
}
return true
})
if (position === null) {
throw new Error(`Expected text: ${text}`)
}
return position
}
function selectionHasAncestor(editor: Editor, typeName: string): boolean {
const { $from } = editor.state.selection
for (let depth = $from.depth; depth >= 0; depth -= 1) {
@@ -118,37 +100,6 @@ import { normalizeEmptyListItems } from '../schema/rich-markdown-normalize'
}
})
it('does not hijack backspace when an empty first toggle body line has content after it', () => {
const editor = createEditor({
type: 'doc',
content: [
{
type: 'details',
attrs: { open: true, variant: null },
content: [
{ type: 'detailsSummary', content: [{ type: 'text', text: 'Toggle' }] },
{
type: 'detailsContent',
content: [
{ type: 'paragraph' },
{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }
]
}
]
}
]
})
try {
editor.commands.setTextSelection(firstDetailsBodyCursorPosition(editor))
expect(moveFromEmptyDetailsBodyToSummary(editor)).toBe(false)
expect(editor.state.selection.$from.parent.type.name).toBe('paragraph')
} finally {
editor.destroy()
}
})
it.each([
['text toggle', '<details><summary>Toggle</summary><p></p></details>'],
[
@@ -169,109 +120,6 @@ import { normalizeEmptyListItems } from '../schema/rich-markdown-normalize'
editor.destroy()
}
})
it('creates another paragraph inside a non-empty toggle body on Enter', () => {
const editor = createEditor('<details><summary>Toggle</summary><p>Body</p></details>')
try {
editor.commands.setTextSelection(firstTextEndPosition(editor, 'Body'))
expect(exitEmptyDetailsBody(editor)).toBe(false)
expect(editor.commands.splitBlock()).toBe(true)
expect(editor.state.selection.$from.parent.type.name).toBe('paragraph')
expect(selectionHasAncestor(editor, 'detailsContent')).toBe(true)
expect(firstDetailsContent(editor).childCount).toBe(2)
} finally {
editor.destroy()
}
})
it('inserts a soft break inside a toggle body on Shift+Enter', () => {
const editor = createEditor('<details><summary>Toggle</summary><p>Body</p></details>')
try {
editor.commands.setTextSelection(firstTextEndPosition(editor, 'Body'))
expect(editor.commands.setHardBreak()).toBe(true)
expect(selectionHasAncestor(editor, 'detailsContent')).toBe(true)
expect(firstDetailsContent(editor).firstChild?.firstChild?.type.name).toBe('text')
expect(firstDetailsContent(editor).firstChild?.child(1).type.name).toBe('hardBreak')
} finally {
editor.destroy()
}
})
})
}
{
it.each(['details', 'Details', 'DETAILS', 'dEtAiLs'])(
'opens %s after prose as an editable toggle',
(tag) => {
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: `Before\n<${tag}><summary>Title</summary>\n\nBody\n\n</${tag}>`,
contentType: 'markdown'
})
try {
expect(editor.state.doc.child(0).textContent).toBe('Before')
const toggle = editor.state.doc.child(1)
expect(toggle.type.name).toBe('details')
expect(toggle.child(0).textContent).toBe('Title')
expect(toggle.child(1).textContent).toBe('Body')
} finally {
editor.destroy()
}
}
)
}
{
function create(source: string) {
return new Editor({
element: null,
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: source,
contentType: 'markdown'
})
}
it('proves the assembled document without reparsing every unchanged source block', () => {
const source = `${Array.from(
{ length: 1000 },
(_, index) => `Paragraph ${index} with _emphasis_.\n\n> [!NOTE]\n> - Callout ${index}\n\n`
).join('')}End\n`
const editor = create(source)
const refs = {
originalSourceRef: { current: source },
baseCanonicalRef: { current: editor.getMarkdown() },
lastCommittedMarkdownRef: { current: source }
}
const parse = vi.spyOn(editor.markdown!, 'parse')
let expected = source
try {
for (let edit = 0; edit < 3; edit++) {
parse.mockClear()
editor.commands.insertContentAt(1, { type: 'text', text: 'x' })
expected = `x${expected}`
const result = commitRichMarkdownSerialization(editor, refs, (markdown) => {
const reopened = create(markdown)
try {
return reopened.getMarkdown()
} finally {
reopened.destroy()
}
})
expect(result.markdown).toBe(expected)
expect(parse.mock.calls.filter(([markdown]) => markdown.length > 50_000)).toHaveLength(
edit === 0 ? 2 : 1
)
expect(parse.mock.calls.length).toBeLessThanOrEqual(3)
}
} finally {
parse.mockRestore()
editor.destroy()
}
})
}
@@ -300,16 +148,6 @@ import { normalizeEmptyListItems } from '../schema/rich-markdown-normalize'
}
describe('rich markdown inline images inside a paragraph', () => {
it('parses an inline image into a schema-valid paragraph', () => {
const editor = createRichMarkdownEditorFromSource(CRASH_SOURCE)
try {
expect(() => editor.state.doc.check()).not.toThrow()
} finally {
editor.destroy()
}
})
it('survives an ordinary edit in a paragraph that holds an inline image', () => {
const editor = createRichMarkdownEditorFromSource(CRASH_SOURCE)
@@ -332,33 +170,6 @@ import { normalizeEmptyListItems } from '../schema/rich-markdown-normalize'
editor.destroy()
}
})
it('keeps a standalone image inside a paragraph rather than directly under the doc', () => {
// Hoisting a standalone inline image would leave it directly under doc, violating the schema.
const editor = createRichMarkdownEditorFromSource('Intro\n\n![shot](shot.png)\n\nOutro\n')
try {
expect(() => editor.state.doc.check()).not.toThrow()
expect(editor.state.doc.child(1).type.name).toBe('paragraph')
} finally {
editor.destroy()
}
})
it('keeps a markdown inline image as an inline node', () => {
const editor = createRichMarkdownEditorFromSource(CRASH_SOURCE)
try {
const paragraph = editor.state.doc.child(0)
const imageIndex = [...Array(paragraph.childCount).keys()].find(
(index) => paragraph.child(index).type.name === 'image'
)
expect(imageIndex).toBeDefined()
expect(paragraph.child(imageIndex!).type.isInline).toBe(true)
} finally {
editor.destroy()
}
})
})
}
@@ -459,50 +270,4 @@ import { normalizeEmptyListItems } from '../schema/rich-markdown-normalize'
}
)
})
describe('block source reconciliation when a whole-document patch cannot be applied', () => {
it.each([
[
'\n\n_Keep_\n\n\n\nEnd\n\n',
'\n\n_Keep_\n\n\n\nChanged\n\n',
'\n\n_Keep_\n\n\n\nChanged\n\n'
],
[
'_Keep_\n\n> [!NOTE]\n> - a\n',
'_Keep_\n\n> [!NOTE]\n> - a\n\nAdded paragraph\n',
'_Keep_\n\n> [!NOTE]\n> - a\n\nAdded paragraph'
],
['_Keep_\n', '_Keep_\n\nAdded paragraph', '_Keep_\n\nAdded paragraph'],
[
'_Keep_\n\nRemove this\n\n> [!NOTE]\n> - a\n',
'_Keep_\n\n> [!NOTE]\n> - a\n',
'_Keep_\n\n> [!NOTE]\n> - a\n'
],
[
'_Keep_\n\nRepeat\n\nRepeat\n\nEnd\n',
'_Keep_\n\nRepeat\n\nChanged\n\nEnd\n',
'_Keep_\n\nRepeat\n\nChanged\n\nEnd\n'
],
['_Keep_\r\n\r\nEnd\r\n', '_Keep_\n\nChanged', '_Keep_\r\n\r\nChanged\r\n'],
[
'_Keep_\n\n```text\n[] \\_ ~ ` *\n```\n\nEnd\n',
'_Keep_\n\n```text\n[] \\_ ~ ` *\n```\n\nChanged\n',
'_Keep_\n\n```text\n[] \\_ ~ ` *\n```\n\nChanged\n'
]
])('preserves source through structural changes (%#)', (source, changed, expected) => {
const editor = createEditor(changed)
try {
const result = reconcileRichMarkdownBlockSource(
editor,
source,
editor.getMarkdown(),
serialize
)
expect(result).toBe(expected)
expect(serialize(result!)).toBe(editor.getMarkdown())
} finally {
editor.destroy()
}
})
})
}