From f447fd1c9b97afd75d201bcdd7da5b4a41eb8912 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 4 Nov 2026 21:27:38 -0800 Subject: [PATCH] test: remove fake menu and lowlight parity suites (#38383) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/scripts/ci-shard-timings.json | 3 - .../NotesSendMenu.test.tsx | 552 ------------------ ...eviewNotesSendMenuContent.test-fixture.tsx | 385 ------------ .../ReviewNotesSendMenuContent.test.tsx | 505 ---------------- ...ich-markdown-html-superscript-link.test.ts | 306 +--------- ...markdown-large-text-paste.fidelity.test.ts | 32 - ...arkdown-large-text-paste.selection.test.ts | 124 ---- .../rich-markdown-large-text-paste.test.ts | 110 ---- .../rich-markdown-lowlight.test.ts | 467 --------------- .../rich-markdown-key-handler.test.ts | 253 -------- .../rich-markdown-tab-key-handler.test.ts | 214 ------- .../rich-markdown-details-keyboard.test.ts | 239 +------- 12 files changed, 7 insertions(+), 3183 deletions(-) delete mode 100644 src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.test.tsx delete mode 100644 src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.test-fixture.tsx delete mode 100644 src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.test.tsx delete mode 100644 src/renderer/src/components/editor/rich-markdown/code-blocks/rich-markdown-lowlight.test.ts diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 4d9de4fc5fd..065dc9e8d64 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -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, diff --git a/src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.test.tsx deleted file mode 100644 index f11ab959289..00000000000 --- a/src/renderer/src/components/editor/review-note-delivery/NotesSendMenu.test.tsx +++ /dev/null @@ -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 -} - -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('react') - return { - ...actual, - useCallback unknown>(callback: T): T { - return callback - }, - useEffect(effect: () => void | (() => void)): void { - const cleanup = effect() - if (typeof cleanup === 'function') { - hookRuntime.cleanups.push(cleanup) - } - }, - useMemo(factory: () => T): T { - return factory() - }, - useSyncExternalStore(_subscribe: unknown, getSnapshot: () => T): T { - return getSnapshot() - }, - useState(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) { - return { type: 'DropdownMenu', props } - }, - DropdownMenuContent: function DropdownMenuContent(props: Record) { - return { type: 'DropdownMenuContent', props } - }, - DropdownMenuItem: function DropdownMenuItem(props: Record) { - return { type: 'DropdownMenuItem', props } - }, - DropdownMenuLabel: function DropdownMenuLabel(props: Record) { - return { type: 'DropdownMenuLabel', props } - }, - DropdownMenuSeparator: function DropdownMenuSeparator(props: Record) { - return { type: 'DropdownMenuSeparator', props } - }, - DropdownMenuSub: function DropdownMenuSub(props: Record) { - return { type: 'DropdownMenuSub', props } - }, - DropdownMenuSubContent: function DropdownMenuSubContent(props: Record) { - return { type: 'DropdownMenuSubContent', props } - }, - DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: Record) { - return { type: 'DropdownMenuSubTrigger', props } - }, - DropdownMenuTrigger: function DropdownMenuTrigger(props: Record) { - return { type: 'DropdownMenuTrigger', props } - } -})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: function Tooltip(props: Record) { - return { type: 'Tooltip', props } - }, - TooltipContent: function TooltipContent(props: Record) { - return { type: 'TooltipContent', props } - }, - TooltipTrigger: function TooltipTrigger(props: Record) { - return { type: 'TooltipTrigger', props } - } -})) - -vi.mock('@/components/tab-bar/QuickLaunchButton', () => ({ - QuickLaunchAgentMenuItems: function QuickLaunchAgentMenuItems(props: Record) { - return { type: 'QuickLaunchAgentMenuItems', props } - } -})) - -vi.mock('./ReviewNotesSendMenuContent', () => ({ - ReviewNotesSendMenuContent: function ReviewNotesSendMenuContent(props: Record) { - 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> - if (typeof element.type === 'function') { - const Component = element.type as (props: Record) => 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>> = {} -): unknown { - hookRuntime.index = 0 - return expand( - - 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, 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) => - 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]) - }) -}) diff --git a/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.test-fixture.tsx b/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.test-fixture.tsx deleted file mode 100644 index fa5f234b0a7..00000000000 --- a/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.test-fixture.tsx +++ /dev/null @@ -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 -} - -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 - sendMessageToAgent: Mock - track: Mock - toastMessage: Mock - worktreeAgentRows: DashboardAgentRowData[] - noteTargets: NotesSendAgentTarget[] - now: number -} - -const harness: NotesSendMenuFixtureHarness = vi.hoisted(() => ({ - storeState: {} as Record, - sendMessageToAgent: vi.fn(), - track: vi.fn(), - toastMessage: vi.fn(), - worktreeAgentRows: [] as DashboardAgentRowData[], - noteTargets: new Array(), - now: 600_000 -})) - -vi.mock('react', async () => { - const actual = await vi.importActual('react') - return { - ...actual, - useCallback unknown>(callback: T): T { - return callback - }, - useMemo(factory: () => T): T { - return factory() - }, - useSyncExternalStore(_subscribe: unknown, getSnapshot: () => T): T { - return getSnapshot() - }, - useEffect(effect: () => void | (() => void)): void { - const cleanup = effect() - if (typeof cleanup === 'function') { - hookRuntime.cleanups.push(cleanup) - } - }, - useState(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) => 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) { - 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) { - return { type: 'AgentIcon', props } - } -})) - -vi.mock('@/components/tab-bar/QuickLaunchButton', () => ({ - QuickLaunchAgentMenuItems: function QuickLaunchAgentMenuItems(props: Record) { - return { type: 'QuickLaunchAgentMenuItems', props } - } -})) - -vi.mock('@/components/ui/dropdown-menu', () => ({ - DropdownMenuItem: function DropdownMenuItem(props: Record) { - return { type: 'DropdownMenuItem', props } - }, - DropdownMenuLabel: function DropdownMenuLabel(props: Record) { - return { type: 'DropdownMenuLabel', props } - }, - DropdownMenuSeparator: function DropdownMenuSeparator(props: Record) { - 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 = {}) { - 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 = {}): 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> - if (typeof element.type === 'function') { - const Component = element.type as (props: Record) => 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 = {}): unknown { - hookRuntime.index = 0 - return expand( - - ) -} - -async function flushMicrotasks(): Promise { - 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 -} diff --git a/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.test.tsx deleted file mode 100644 index fafed2d0a37..00000000000 --- a/src/renderer/src/components/editor/review-note-delivery/ReviewNotesSendMenuContent.test.tsx +++ /dev/null @@ -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) - }) -}) diff --git a/src/renderer/src/components/editor/rich-markdown/citations/rich-markdown-html-superscript-link.test.ts b/src/renderer/src/components/editor/rich-markdown/citations/rich-markdown-html-superscript-link.test.ts index 6ab4231815c..64701c8db7a 100644 --- a/src/renderer/src/components/editor/rich-markdown/citations/rich-markdown-html-superscript-link.test.ts +++ b/src/renderer/src/components/editor/rich-markdown/citations/rich-markdown-html-superscript-link.test.ts @@ -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 = '[12]' - 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 = "[¬it;]" @@ -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', 'K1') - 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 = ''.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('[1]')?.href - ).toBe('https://example.com/?q=>') - expect(parseHtmlSuperscriptLinkSource('[1]')).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('[12]') - 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 - expect(resolveRichMarkdownWorktreeRoot(state, folderWorkspaceKey('folder-1'))).toBe( - '/workspace/platform' - ) - }) - it('self-validates clipboard HTML and rejects a forged semantic mismatch', () => { const source = '[12]' 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( - `x`, - { 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 [12] 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 = '[1]' - 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: '[1]', - 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( - '[1][2]' - ) - 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 = '[1]' - 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 { diff --git a/src/renderer/src/components/editor/rich-markdown/clipboard/rich-markdown-large-text-paste.fidelity.test.ts b/src/renderer/src/components/editor/rich-markdown/clipboard/rich-markdown-large-text-paste.fidelity.test.ts index 92438de3c56..4928f49c94b 100644 --- a/src/renderer/src/components/editor/rich-markdown/clipboard/rich-markdown-large-text-paste.fidelity.test.ts +++ b/src/renderer/src/components/editor/rich-markdown/clipboard/rich-markdown-large-text-paste.fidelity.test.ts @@ -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) => { diff --git a/src/renderer/src/components/editor/rich-markdown/clipboard/rich-markdown-large-text-paste.selection.test.ts b/src/renderer/src/components/editor/rich-markdown/clipboard/rich-markdown-large-text-paste.selection.test.ts index 5eca66f33dc..c1847880b6c 100644 --- a/src/renderer/src/components/editor/rich-markdown/clipboard/rich-markdown-large-text-paste.selection.test.ts +++ b/src/renderer/src/components/editor/rich-markdown/clipboard/rich-markdown-large-text-paste.selection.test.ts @@ -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 = - '
firstsecond
thirdfourth

outside

' - 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))) diff --git a/src/renderer/src/components/editor/rich-markdown/clipboard/rich-markdown-large-text-paste.test.ts b/src/renderer/src/components/editor/rich-markdown/clipboard/rich-markdown-large-text-paste.test.ts index 6300dc706f5..dbe0f800900 100644 --- a/src/renderer/src/components/editor/rich-markdown/clipboard/rich-markdown-large-text-paste.test.ts +++ b/src/renderer/src/components/editor/rich-markdown/clipboard/rich-markdown-large-text-paste.test.ts @@ -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) - }) }) diff --git a/src/renderer/src/components/editor/rich-markdown/code-blocks/rich-markdown-lowlight.test.ts b/src/renderer/src/components/editor/rich-markdown/code-blocks/rich-markdown-lowlight.test.ts deleted file mode 100644 index 4800a430acf..00000000000 --- a/src/renderer/src/components/editor/rich-markdown/code-blocks/rich-markdown-lowlight.test.ts +++ /dev/null @@ -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 -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) - }) -}) diff --git a/src/renderer/src/components/editor/rich-markdown/commands/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown/commands/rich-markdown-key-handler.test.ts index 53fc3da07fb..9293ae57213 100644 --- a/src/renderer/src/components/editor/rich-markdown/commands/rich-markdown-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown/commands/rich-markdown-key-handler.test.ts @@ -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() - } - }) }) diff --git a/src/renderer/src/components/editor/rich-markdown/commands/rich-markdown-tab-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown/commands/rich-markdown-tab-key-handler.test.ts index 487f8d7b45d..fa12ce295e4 100644 --- a/src/renderer/src/components/editor/rich-markdown/commands/rich-markdown-tab-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown/commands/rich-markdown-tab-key-handler.test.ts @@ -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() - } - }) }) diff --git a/src/renderer/src/components/editor/rich-markdown/details/rich-markdown-details-keyboard.test.ts b/src/renderer/src/components/editor/rich-markdown/details/rich-markdown-details-keyboard.test.ts index f1e4d52b8ce..308ae2feec3 100644 --- a/src/renderer/src/components/editor/rich-markdown/details/rich-markdown-details-keyboard.test.ts +++ b/src/renderer/src/components/editor/rich-markdown/details/rich-markdown-details-keyboard.test.ts @@ -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', '
Toggle

'], [ @@ -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('
Toggle

Body

') - - 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('
Toggle

Body

') - - 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}>Title\n\nBody\n\n`, - contentType: 'markdown' - }) - try { - expect(editor.state.doc.child(0).textContent).toBe('Before') - const toggle = editor.state.doc.child(1) - expect(toggle.type.name).toBe('details') - expect(toggle.child(0).textContent).toBe('Title') - expect(toggle.child(1).textContent).toBe('Body') - } finally { - editor.destroy() - } - } - ) -} - -{ - function create(source: string) { - return new Editor({ - element: null, - extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }), - content: source, - contentType: 'markdown' - }) - } - - it('proves the assembled document without reparsing every unchanged source block', () => { - const source = `${Array.from( - { length: 1000 }, - (_, index) => `Paragraph ${index} with _emphasis_.\n\n> [!NOTE]\n> - Callout ${index}\n\n` - ).join('')}End\n` - const editor = create(source) - const refs = { - originalSourceRef: { current: source }, - baseCanonicalRef: { current: editor.getMarkdown() }, - lastCommittedMarkdownRef: { current: source } - } - const parse = vi.spyOn(editor.markdown!, 'parse') - let expected = source - try { - for (let edit = 0; edit < 3; edit++) { - parse.mockClear() - editor.commands.insertContentAt(1, { type: 'text', text: 'x' }) - expected = `x${expected}` - const result = commitRichMarkdownSerialization(editor, refs, (markdown) => { - const reopened = create(markdown) - try { - return reopened.getMarkdown() - } finally { - reopened.destroy() - } - }) - expect(result.markdown).toBe(expected) - expect(parse.mock.calls.filter(([markdown]) => markdown.length > 50_000)).toHaveLength( - edit === 0 ? 2 : 1 - ) - expect(parse.mock.calls.length).toBeLessThanOrEqual(3) - } - } finally { - parse.mockRestore() - editor.destroy() - } }) } @@ -300,16 +148,6 @@ import { normalizeEmptyListItems } from '../schema/rich-markdown-normalize' } describe('rich markdown inline images inside a paragraph', () => { - it('parses an inline image into a schema-valid paragraph', () => { - const editor = createRichMarkdownEditorFromSource(CRASH_SOURCE) - - try { - expect(() => editor.state.doc.check()).not.toThrow() - } finally { - editor.destroy() - } - }) - it('survives an ordinary edit in a paragraph that holds an inline image', () => { const editor = createRichMarkdownEditorFromSource(CRASH_SOURCE) @@ -332,33 +170,6 @@ import { normalizeEmptyListItems } from '../schema/rich-markdown-normalize' editor.destroy() } }) - - it('keeps a standalone image inside a paragraph rather than directly under the doc', () => { - // Hoisting a standalone inline image would leave it directly under doc, violating the schema. - const editor = createRichMarkdownEditorFromSource('Intro\n\n![shot](shot.png)\n\nOutro\n') - - try { - expect(() => editor.state.doc.check()).not.toThrow() - expect(editor.state.doc.child(1).type.name).toBe('paragraph') - } finally { - editor.destroy() - } - }) - - it('keeps a markdown inline image as an inline node', () => { - const editor = createRichMarkdownEditorFromSource(CRASH_SOURCE) - - try { - const paragraph = editor.state.doc.child(0) - const imageIndex = [...Array(paragraph.childCount).keys()].find( - (index) => paragraph.child(index).type.name === 'image' - ) - expect(imageIndex).toBeDefined() - expect(paragraph.child(imageIndex!).type.isInline).toBe(true) - } finally { - editor.destroy() - } - }) }) } @@ -459,50 +270,4 @@ import { normalizeEmptyListItems } from '../schema/rich-markdown-normalize' } ) }) - - describe('block source reconciliation when a whole-document patch cannot be applied', () => { - it.each([ - [ - '\n\n_Keep_\n\n\n\nEnd\n\n', - '\n\n_Keep_\n\n\n\nChanged\n\n', - '\n\n_Keep_\n\n\n\nChanged\n\n' - ], - [ - '_Keep_\n\n> [!NOTE]\n> - a\n', - '_Keep_\n\n> [!NOTE]\n> - a\n\nAdded paragraph\n', - '_Keep_\n\n> [!NOTE]\n> - a\n\nAdded paragraph' - ], - ['_Keep_\n', '_Keep_\n\nAdded paragraph', '_Keep_\n\nAdded paragraph'], - [ - '_Keep_\n\nRemove this\n\n> [!NOTE]\n> - a\n', - '_Keep_\n\n> [!NOTE]\n> - a\n', - '_Keep_\n\n> [!NOTE]\n> - a\n' - ], - [ - '_Keep_\n\nRepeat\n\nRepeat\n\nEnd\n', - '_Keep_\n\nRepeat\n\nChanged\n\nEnd\n', - '_Keep_\n\nRepeat\n\nChanged\n\nEnd\n' - ], - ['_Keep_\r\n\r\nEnd\r\n', '_Keep_\n\nChanged', '_Keep_\r\n\r\nChanged\r\n'], - [ - '_Keep_\n\n```text\n[] \\_ ~ ` *\n```\n\nEnd\n', - '_Keep_\n\n```text\n[] \\_ ~ ` *\n```\n\nChanged\n', - '_Keep_\n\n```text\n[] \\_ ~ ` *\n```\n\nChanged\n' - ] - ])('preserves source through structural changes (%#)', (source, changed, expected) => { - const editor = createEditor(changed) - try { - const result = reconcileRichMarkdownBlockSource( - editor, - source, - editor.getMarkdown(), - serialize - ) - expect(result).toBe(expected) - expect(serialize(result!)).toBe(editor.getMarkdown()) - } finally { - editor.destroy() - } - }) - }) }