Files
upstream-tiptap/packages/ai-toolkit/src/streaming-reveal.test.ts
upstream-mirror 083a46e93b
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
snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:11:02 +05:00

510 lines
17 KiB
TypeScript

// @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()
})
})