mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
test: remove fake menu and lowlight parity suites (#38383)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -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])
|
||||
})
|
||||
})
|
||||
-385
@@ -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
|
||||
}
|
||||
-505
@@ -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)
|
||||
})
|
||||
})
|
||||
+5
-301
@@ -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 & B' href='https://example.com/?a=1&b=2'>[¬it;]</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 {
|
||||
|
||||
-32
@@ -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) => {
|
||||
|
||||
-124
@@ -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)))
|
||||
|
||||
-110
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
-467
@@ -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)
|
||||
})
|
||||
})
|
||||
-253
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
-214
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+2
-237
@@ -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\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()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user