snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
This commit is contained in:
509
packages/ai-toolkit/src/streaming-reveal.test.ts
Normal file
509
packages/ai-toolkit/src/streaming-reveal.test.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { Collaboration } from '@tiptap/extension-collaboration'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import { AiInsertReveal } from './streaming-reveal.js'
|
||||
|
||||
const AI_CLIENT_ID = 111111
|
||||
const HUMAN_CLIENT_ID = 222222
|
||||
|
||||
/** The awareness payload the Tiptap AI server publishes while it streams. */
|
||||
const AI_USER = { name: 'AI', color: '#8B5CF6', aiInstanceId: 'ai-instance-1' }
|
||||
|
||||
/** Stands in for a collab provider, exposing only the awareness surface we read. */
|
||||
function createProvider() {
|
||||
const states = new Map<number, Record<string, any>>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
return {
|
||||
awareness: {
|
||||
getStates: () => states,
|
||||
on: (_event: string, listener: () => void) => listeners.add(listener),
|
||||
off: (_event: string, listener: () => void) => listeners.delete(listener),
|
||||
},
|
||||
announce(clientId: number, user: Record<string, any>) {
|
||||
states.set(clientId, { user })
|
||||
listeners.forEach(listener => listener())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates an editor with {@link AiInsertReveal} but no collaboration (no y-sync plugin). */
|
||||
function createEditor(): Promise<Editor> {
|
||||
return new Promise(resolve => {
|
||||
const editor = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [StarterKit, AiInsertReveal.configure({ provider: createProvider() })],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }],
|
||||
},
|
||||
onCreate: () => {
|
||||
resolve(editor)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** A remote peer that keeps its clientID across writes. */
|
||||
function createPeer(ydoc: Y.Doc, clientID: number): Y.Doc {
|
||||
const peer = new Y.Doc()
|
||||
peer.clientID = clientID
|
||||
Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc))
|
||||
return peer
|
||||
}
|
||||
|
||||
/** Creates a collaborative editor seeded with one `Hello` paragraph, plus two remote peers. */
|
||||
function createCollabEditor(options?: {
|
||||
durationMs?: number
|
||||
}): Promise<{ editor: Editor; ydoc: Y.Doc; ai: Y.Doc; human: Y.Doc }> {
|
||||
const ydoc = new Y.Doc()
|
||||
const provider = createProvider()
|
||||
provider.announce(AI_CLIENT_ID, AI_USER)
|
||||
provider.announce(HUMAN_CLIENT_ID, { name: 'Someone else', color: '#0EA5E9' })
|
||||
|
||||
return new Promise(resolve => {
|
||||
new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [
|
||||
StarterKit.configure({ undoRedo: false }),
|
||||
Collaboration.configure({ document: ydoc }),
|
||||
AiInsertReveal.configure({
|
||||
provider,
|
||||
...(options?.durationMs === undefined ? {} : { durationMs: options.durationMs }),
|
||||
}),
|
||||
],
|
||||
onCreate: ({ editor }) => {
|
||||
// Collaboration ignores the `content` prop (the empty Y.Doc wins when the
|
||||
// y-sync plugin binds), so seed the shared doc with a local edit instead.
|
||||
editor.commands.setContent('<p>Hello</p>')
|
||||
resolve({
|
||||
editor,
|
||||
ydoc,
|
||||
ai: createPeer(ydoc, AI_CLIENT_ID),
|
||||
human: createPeer(ydoc, HUMAN_CLIENT_ID),
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Applies an insert from `peer`, so it lands as a remote transaction authored by it. */
|
||||
function remoteInsert(peer: Y.Doc, ydoc: Y.Doc, index: number, text: string): void {
|
||||
Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(peer)))
|
||||
const paragraph = peer.getXmlFragment('default').get(0) as Y.XmlElement
|
||||
const xmlText = paragraph.get(0) as Y.XmlText
|
||||
xmlText.insert(index, text)
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ydoc)))
|
||||
}
|
||||
|
||||
/** Applies a whole new block from `peer`, the way the AI writes a fresh paragraph. */
|
||||
function remoteInsertBlock(peer: Y.Doc, ydoc: Y.Doc, text: string): void {
|
||||
Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(peer)))
|
||||
const fragment = peer.getXmlFragment('default')
|
||||
const block = new Y.XmlElement('paragraph')
|
||||
const blockText = new Y.XmlText()
|
||||
block.insert(0, [blockText])
|
||||
fragment.insert(0, [block])
|
||||
if (text.length > 0) blockText.insert(0, text)
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ydoc)))
|
||||
}
|
||||
|
||||
/** Collects the extension's current reveal decorations, resolved against `state`. */
|
||||
function revealDecorations(
|
||||
editor: Editor,
|
||||
state: unknown = editor.state,
|
||||
): Array<{ from: number; to: number; style: string }> {
|
||||
for (const plugin of editor.state.plugins) {
|
||||
const set = (plugin as any).props?.decorations?.call(plugin, state)
|
||||
const found = (set?.find?.() ?? []).filter(
|
||||
(d: any) => d.type?.attrs?.class === 'ai-insert-reveal',
|
||||
)
|
||||
if (found.length > 0) {
|
||||
return found.map((d: any) => ({ from: d.from, to: d.to, style: d.type.attrs.style ?? '' }))
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
describe('AiInsertReveal', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('is a named Tiptap extension', () => {
|
||||
expect(AiInsertReveal.name).toBe('aiInsertReveal')
|
||||
})
|
||||
|
||||
it('warns and degrades to a no-op when no collaboration y-sync plugin is present', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const editor = await createEditor()
|
||||
|
||||
expect(editor.extensionManager.extensions.some(e => e.name === 'aiInsertReveal')).toBe(true)
|
||||
// Without a y-sync plugin the decorations source resolves to nothing, so the
|
||||
// editor renders normally rather than throwing.
|
||||
expect(editor.getText()).toBe('Hello')
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Collaboration extension'))
|
||||
|
||||
warn.mockRestore()
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('applies configured className and durationMs', async () => {
|
||||
const editor = await new Promise<Editor>(resolve => {
|
||||
const created = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [
|
||||
StarterKit,
|
||||
AiInsertReveal.configure({
|
||||
className: 'custom-reveal',
|
||||
durationMs: 300,
|
||||
provider: createProvider(),
|
||||
}),
|
||||
],
|
||||
onCreate: () => resolve(created),
|
||||
})
|
||||
})
|
||||
|
||||
const reveal = editor.extensionManager.extensions.find(e => e.name === 'aiInsertReveal')
|
||||
expect(reveal?.options).toMatchObject({ className: 'custom-reveal', durationMs: 300 })
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals a remote insert as a decoration over exactly the inserted run', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsert(ai, ydoc, 5, ' WORLD')
|
||||
|
||||
expect(editor.getText()).toBe('Hello WORLD')
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe(' WORLD'.length)
|
||||
// The age-seeded animation-delay is present so the fade survives re-renders.
|
||||
expect(decorations[0].style).toMatch(/animation-delay: -\d+ms/)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals a block that arrives as a whole new node', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsertBlock(ai, ydoc, 'A brand new title')
|
||||
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe('A brand new title'.length)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals text streamed into a block that was created empty', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsertBlock(ai, ydoc, '')
|
||||
remoteInsert(ai, ydoc, 0, 'Streamed title')
|
||||
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe('Streamed title'.length)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('merges touching runs that share an animation offset into one decoration', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
// Only the clock is faked: the editor is created through a real `setTimeout`.
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
|
||||
remoteInsert(ai, ydoc, 5, ' one')
|
||||
remoteInsert(ai, ydoc, 9, ' two')
|
||||
|
||||
expect(editor.getText()).toBe('Hello one two')
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe(' one two'.length)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals an insert at the very end of the document', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsert(ai, ydoc, 5, '!')
|
||||
|
||||
expect(editor.getText()).toBe('Hello!')
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe(1)
|
||||
expect(decorations[0].to).toBeLessThanOrEqual(editor.state.doc.content.size)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('clamps a decoration that resolves past the end of the document', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsert(ai, ydoc, 5, ' WORLD')
|
||||
expect(revealDecorations(editor)).toHaveLength(1)
|
||||
|
||||
// Y.Doc can be ahead of the PM doc mid-sync, so resolve against a shorter doc.
|
||||
const shortDoc = editor.schema.nodeFromJSON({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello!' }] }],
|
||||
})
|
||||
const decorations = revealDecorations(editor, { ...editor.state, doc: shortDoc })
|
||||
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to).toBe(shortDoc.content.size)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it("does not reveal another collaborator's remote insert", async () => {
|
||||
const { editor, ydoc, human } = await createCollabEditor()
|
||||
|
||||
remoteInsert(human, ydoc, 5, ' WORLD')
|
||||
|
||||
// The insert lands like any remote edit, it just must not fade.
|
||||
expect(editor.getText()).toBe('Hello WORLD')
|
||||
expect(revealDecorations(editor)).toHaveLength(0)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals the AI while a collaborator types alongside it', async () => {
|
||||
const { editor, ydoc, ai, human } = await createCollabEditor()
|
||||
|
||||
remoteInsert(human, ydoc, 5, ' HUMAN')
|
||||
remoteInsert(ai, ydoc, 11, ' AI')
|
||||
|
||||
expect(editor.getText()).toBe('Hello HUMAN AI')
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe(' AI'.length)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals a run that arrived before awareness identified the AI', async () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const provider = createProvider()
|
||||
|
||||
const editor = await new Promise<Editor>(resolve => {
|
||||
new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [
|
||||
StarterKit.configure({ undoRedo: false }),
|
||||
Collaboration.configure({ document: ydoc }),
|
||||
AiInsertReveal.configure({ provider }),
|
||||
],
|
||||
onCreate: ({ editor: created }) => {
|
||||
created.commands.setContent('<p>Hello</p>')
|
||||
resolve(created)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// The AI's first tokens can land before its awareness entry does.
|
||||
remoteInsert(createPeer(ydoc, AI_CLIENT_ID), ydoc, 5, ' WORLD')
|
||||
expect(revealDecorations(editor)).toHaveLength(0)
|
||||
|
||||
provider.announce(AI_CLIENT_ID, AI_USER)
|
||||
|
||||
expect(revealDecorations(editor)).toHaveLength(1)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals a multi-block structure the AI writes as one node', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
Y.applyUpdate(ai, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(ai)))
|
||||
const list = new Y.XmlElement('bulletList')
|
||||
for (let item = 0; item < 3; item++) {
|
||||
const listItem = new Y.XmlElement('listItem')
|
||||
const paragraph = new Y.XmlElement('paragraph')
|
||||
const text = new Y.XmlText()
|
||||
paragraph.insert(0, [text])
|
||||
listItem.insert(0, [paragraph])
|
||||
list.insert(list.length, [listItem])
|
||||
text.insert(0, `Item ${item}`)
|
||||
}
|
||||
ai.getXmlFragment('default').insert(0, [list])
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ai, Y.encodeStateVector(ydoc)))
|
||||
|
||||
// One structure, so the block cap must not mistake it for a document sync.
|
||||
expect(revealDecorations(editor)).toHaveLength(3)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('reveals nothing when a whole AI-authored document arrives at once', async () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const provider = createProvider()
|
||||
provider.announce(AI_CLIENT_ID, AI_USER)
|
||||
|
||||
const editor = await new Promise<Editor>(resolve => {
|
||||
const created = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [
|
||||
StarterKit.configure({ undoRedo: false }),
|
||||
Collaboration.configure({ document: ydoc }),
|
||||
AiInsertReveal.configure({ provider }),
|
||||
],
|
||||
onCreate: () => resolve(created),
|
||||
})
|
||||
})
|
||||
|
||||
// Joining a room hands over everything the AI wrote before, in one update.
|
||||
const server = new Y.Doc()
|
||||
server.clientID = AI_CLIENT_ID
|
||||
const fragment = server.getXmlFragment('default')
|
||||
for (let block = 0; block < 5; block++) {
|
||||
const element = new Y.XmlElement('paragraph')
|
||||
const text = new Y.XmlText()
|
||||
element.insert(0, [text])
|
||||
fragment.insert(fragment.length, [element])
|
||||
text.insert(0, `Paragraph ${block} written earlier by the AI.`)
|
||||
}
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(server))
|
||||
|
||||
expect(editor.getText().length).toBeGreaterThan(0)
|
||||
expect(revealDecorations(editor)).toHaveLength(0)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('warns when the provider has no awareness', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const editor = await new Promise<Editor>(resolve => {
|
||||
const created = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [StarterKit, AiInsertReveal.configure({ provider: {} })],
|
||||
onCreate: () => resolve(created),
|
||||
})
|
||||
})
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('no awareness'))
|
||||
|
||||
warn.mockRestore()
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('warns when durationMs cannot hold a reveal', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const editor = await new Promise<Editor>(resolve => {
|
||||
const created = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [
|
||||
StarterKit,
|
||||
AiInsertReveal.configure({ provider: createProvider(), durationMs: 0 }),
|
||||
],
|
||||
onCreate: () => resolve(created),
|
||||
})
|
||||
})
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('durationMs'))
|
||||
|
||||
warn.mockRestore()
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('warns and reveals nothing when no provider is configured', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
const editor = await new Promise<Editor>(resolve => {
|
||||
new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [
|
||||
StarterKit.configure({ undoRedo: false }),
|
||||
Collaboration.configure({ document: ydoc }),
|
||||
AiInsertReveal,
|
||||
],
|
||||
onCreate: ({ editor: created }) => {
|
||||
created.commands.setContent('<p>Hello</p>')
|
||||
resolve(created)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
remoteInsert(createPeer(ydoc, AI_CLIENT_ID), ydoc, 5, ' WORLD')
|
||||
|
||||
// Without awareness the AI cannot be identified, so it fails closed.
|
||||
expect(editor.getText()).toBe('Hello WORLD')
|
||||
expect(revealDecorations(editor)).toHaveLength(0)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('"provider" option is required'))
|
||||
|
||||
warn.mockRestore()
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it("does not reveal the local user's own typing", async () => {
|
||||
const { editor } = await createCollabEditor()
|
||||
|
||||
// A local transaction (transaction.local === true) must be ignored.
|
||||
editor.commands.insertContentAt(6, 'X')
|
||||
|
||||
expect(editor.getText()).toBe('HelloX')
|
||||
expect(revealDecorations(editor)).toHaveLength(0)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('drops the reveal once its duration has elapsed', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor({ durationMs: 30 })
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
|
||||
remoteInsert(ai, ydoc, 5, ' WORLD')
|
||||
expect(revealDecorations(editor)).toHaveLength(1)
|
||||
|
||||
vi.setSystemTime(Date.now() + 60)
|
||||
expect(revealDecorations(editor)).toHaveLength(0)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('clamps a run longer than the max reveal range to that many characters', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsert(ai, ydoc, 5, 'x'.repeat(401))
|
||||
|
||||
expect(editor.getText()).toBe(`Hello${'x'.repeat(401)}`)
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
// The whole run inserts; only the fade is capped (MAX_REVEAL_RANGE = 400).
|
||||
expect(decorations[0].to - decorations[0].from).toBe(400)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('drops a run whose resolved span no longer matches its inserted length', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
|
||||
remoteInsert(ai, ydoc, 5, ' WORLD')
|
||||
// 'XYZ' lands inside the first run, so its span drifts from 6 to 9 and drops as stale.
|
||||
remoteInsert(ai, ydoc, 8, 'XYZ')
|
||||
|
||||
const decorations = revealDecorations(editor)
|
||||
expect(decorations).toHaveLength(1)
|
||||
expect(decorations[0].to - decorations[0].from).toBe(3)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('tears down cleanly after a reveal without throwing', async () => {
|
||||
const { editor, ydoc, ai } = await createCollabEditor()
|
||||
remoteInsert(ai, ydoc, 5, ' WORLD')
|
||||
expect(revealDecorations(editor)).toHaveLength(1)
|
||||
|
||||
expect(() => editor.destroy()).not.toThrow()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user