// @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mountShell, type FrameDocumentSessionOptions, type FrameHostMessage } from './plugin-frame-shell-mount.test-fixture' type Content = { kind: 'text'; text: string } | { kind: 'bytes'; bytes: ArrayBuffer } const hello = { orca: 1, kind: 'evt', event: 'hello', payload: { theme: { colorScheme: 'dark', tokens: '' }, locale: 'en' } } const doc = (version: number, text: string, readOnly = false) => ({ orca: 1, kind: 'evt', event: 'editor.document', payload: { version, readOnly, content: { kind: 'text', text } } }) const bytesDoc = (version: number, bytes: number[]) => ({ orca: 1, kind: 'evt', event: 'editor.document', payload: { version, readOnly: false, content: { kind: 'bytes', bytes: new Uint8Array(bytes).buffer } } }) const accept = (id: number) => ({ orca: 1, kind: 'res', id, result: null }) const refuse = (id: number, code: string) => ({ orca: 1, kind: 'res', id, error: { code, message: code } }) const settle = () => vi.advanceTimersByTimeAsync(0) function isContent(value: unknown): value is Content { if (typeof value !== 'object' || value === null || !('kind' in value)) { return false } return value.kind === 'text' ? 'text' in value && typeof value.text === 'string' : value.kind === 'bytes' && 'bytes' in value && value.bytes instanceof ArrayBuffer } /** The content an `editor.applyEdit` message carries. */ function sentContent(message: Record | undefined): Content { const params = message?.params const content = typeof params === 'object' && params !== null && 'content' in params ? params.content : null if (!isContent(content)) { throw new Error('not an editor edit') } return content } const sentText = (message: Record): string | undefined => { const content = sentContent(message) return content.kind === 'text' ? content.text : undefined } const sentBytes = (message: Record | undefined): number[] => { const content = sentContent(message) return content.kind === 'bytes' ? [...new Uint8Array(content.bytes)] : [] } async function open(options?: FrameDocumentSessionOptions, first: FrameHostMessage = doc(1, 'a')) { const shell = mountShell('editor') fromHostAll(shell.fromHost, [hello, first]) const session = await shell.orca.document!.session(options) const replaced = vi.fn() session.onReplace(replaced) const edits = () => shell.posted().filter((message) => message.method === 'editor.applyEdit') return { ...shell, session, replaced, edits } } function fromHostAll(fromHost: (data: unknown) => unknown, messages: unknown[]): void { messages.forEach((message) => fromHost(message)) } beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) describe('orca.document.session', () => { it('is feature-detectable and resolves only after hello and the first document', async () => { const { orca, fromHost } = mountShell('editor') expect('session' in orca.document!).toBe(true) const started = vi.fn() void orca.document!.session().then(started) fromHost(doc(1, 'a')) await settle() expect(started).not.toHaveBeenCalled() fromHost(hello) await settle() expect(started).toHaveBeenCalledWith(expect.objectContaining({ value: 'a', readOnly: false })) }) it('coalesces changes in the debounce window into one edit', async () => { const { session, edits } = await open() session.change('b') session.change('bc') await vi.advanceTimersByTimeAsync(249) expect(edits()).toHaveLength(0) await vi.advanceTimersByTimeAsync(1) expect(edits()).toEqual([ expect.objectContaining({ params: { baseVersion: 1, content: { kind: 'text', text: 'bc' } } }) ]) expect(session.value).toBe('bc') }) it('keeps one edit in flight and sends the newest change once it settles', async () => { const { session, edits, fromHost } = await open({ debounceMs: 0 }) session.change('b') await settle() session.change('c') session.change('d') await settle() expect(edits()).toHaveLength(1) fromHost(accept(0)) await settle() expect(edits().map(sentText)).toEqual(['b', 'd']) }) it('moves the baseline only on accept and keeps a refused draft', async () => { const { session, fromHost, edits } = await open({ debounceMs: 0 }) session.change('b') const flushed = session.flush() fromHost(refuse(0, 'too_large')) await expect(flushed).rejects.toMatchObject({ code: 'too_large' }) expect(session.value).toBe('b') const retried = session.flush() expect(edits()).toHaveLength(2) fromHost(accept(1)) await expect(retried).resolves.toBeUndefined() await expect(session.flush()).resolves.toBeUndefined() expect(edits()).toHaveLength(2) }) it('never reports its own accepted edits as replacements', async () => { const { session, fromHost, replaced, edits } = await open({ debounceMs: 0 }) session.change('b') await settle() fromHost(accept(0)) await settle() // The same text again, at a new version (a read-only flip would look like this). fromHost(doc(2, 'b')) expect(replaced).not.toHaveBeenCalled() fromHost(doc(3, 'outside')) expect(replaced).toHaveBeenCalledWith('outside') expect(session.value).toBe('outside') session.change('next') await settle() expect(edits().at(-1)).toMatchObject({ params: { baseVersion: 3 } }) }) it('merges a conflict once against the last accepted value and resubmits', async () => { const merge = vi.fn( (base: string, outside: string, mine: string) => `${base}|${outside}|${mine}` ) const { session, fromHost, replaced, edits } = await open({ debounceMs: 0, merge }) session.change('x') await settle() fromHost(accept(0)) await settle() session.change('mine') fromHost(doc(2, 'outside')) await settle() expect(replaced).not.toHaveBeenCalled() fromHost(refuse(1, 'conflict')) await settle() expect(merge).toHaveBeenCalledTimes(1) expect(merge).toHaveBeenCalledWith('x', 'outside', 'mine') expect(replaced).toHaveBeenCalledWith('x|outside|mine') expect(edits().at(-1)).toMatchObject({ params: { baseVersion: 2, content: { kind: 'text', text: 'x|outside|mine' } } }) }) it('lets the outside document win a conflict without merge', async () => { const { session, fromHost, replaced, edits } = await open({ debounceMs: 0 }) session.change('mine') fromHost(doc(2, 'outside')) const flushed = session.flush() fromHost(refuse(0, 'conflict')) await expect(flushed).rejects.toMatchObject({ code: 'conflict' }) expect(session.value).toBe('outside') expect(replaced).toHaveBeenCalledWith('outside') // The draft is gone, so nothing overwrites the outside write. await expect(session.flush()).resolves.toBeUndefined() expect(edits()).toHaveLength(1) session.change('next') await settle() expect(edits().at(-1)).toMatchObject({ params: { baseVersion: 2 } }) }) it('answers editor.flush only once the change it posted is accepted', async () => { const { session, fromHost, posted } = await open() session.change('b') const flushRequest = { orca: 1, kind: 'req', id: 9, method: 'editor.flush' } fromHost(flushRequest) await settle() expect(posted().at(-1)).toMatchObject({ method: 'editor.applyEdit' }) fromHost(accept(0)) await settle() expect(posted().at(-1)).toEqual({ orca: 1, kind: 'res', id: 9, result: null }) }) it('keeps one edit in flight through a save, so the baseline never goes back', async () => { const { orca, session, fromHost, edits, posted } = await open({ debounceMs: 0 }) session.change('a1') await settle() // `a1` is held by Orca's rate limit; the save must not post `b` beside it. session.change('b') fromHost({ orca: 1, kind: 'req', id: 9, method: 'editor.flush' }) await settle() expect(edits()).toHaveLength(1) fromHost(accept(0)) await settle() expect(edits().at(-1)).toMatchObject({ params: { content: { text: 'b' } } }) fromHost(accept(1)) await settle() expect(posted().at(-1)).toMatchObject({ kind: 'res', id: 9, result: null }) await expect(orca.document!.get()).resolves.toMatchObject({ content: { text: 'b' } }) }) it('answers a save only after a conflict merge commits', async () => { const merge = (_base: string, outside: string, mine: string) => `${outside}+${mine}` const { session, fromHost, edits, posted } = await open({ debounceMs: 0, merge }) session.change('mine') await settle() fromHost(doc(2, 'outside')) fromHost({ orca: 1, kind: 'req', id: 9, method: 'editor.flush' }) fromHost(refuse(0, 'conflict')) await settle() expect(edits().at(-1)).toMatchObject({ params: { content: { text: 'outside+mine' } } }) expect(posted().some((message) => message.kind === 'res' && message.id === 9)).toBe(false) fromHost(accept(1)) await settle() expect(posted().at(-1)).toMatchObject({ kind: 'res', id: 9, result: null }) }) it('counts a reused buffer as a new change and sends what it held when sent', async () => { const buffer = new Uint8Array([3]) const { session, fromHost, edits } = await open( { debounceMs: 0, decode: () => buffer, encode: (value: Uint8Array) => ({ kind: 'bytes', bytes: value.buffer }) }, { ...doc(1, ''), payload: { version: 1, readOnly: false, content: { kind: 'bytes', bytes: new ArrayBuffer(1) } } } ) session.change(buffer) await settle() buffer[0] = 4 session.change(buffer) fromHost(accept(0)) await settle() const sent = edits().map(sentBytes) expect(sent).toEqual([[3], [4]]) }) it('rejects a waiting flush with conflict when the outside document wins', async () => { const { session, fromHost } = await open({ debounceMs: 0 }) session.change('mine') await settle() const flushed = session.flush() fromHost(doc(2, 'outside')) fromHost(refuse(0, 'conflict')) await expect(flushed).rejects.toMatchObject({ code: 'conflict' }) expect(session.value).toBe('outside') }) it('keeps a flush going until a change typed during it commits too', async () => { const { session, fromHost, edits } = await open() session.change('b') const flushed = vi.fn() void session.flush().then(flushed) session.change('c') fromHost(accept(0)) await settle() expect(flushed).not.toHaveBeenCalled() expect(edits().map(sentText)).toEqual(['b', 'c']) fromHost(accept(1)) await settle() expect(flushed).toHaveBeenCalled() }) it('rejects a waiting flush when a conflict drops a newer draft too', async () => { const { session, fromHost } = await open({ debounceMs: 0 }) session.change('mine') await settle() session.change('mine, more') const flushed = session.flush() fromHost(doc(2, 'outside')) fromHost(refuse(0, 'conflict')) await expect(flushed).rejects.toMatchObject({ code: 'conflict' }) expect(session.value).toBe('outside') }) it("keeps merge's base apart from a value buffer reused after it was accepted", async () => { const merge = vi.fn((base: Uint8Array, outside: Uint8Array, mine: Uint8Array) => [ ...base, ...outside, ...mine ]) const buffer = new Uint8Array([1]) const { session, fromHost } = await open( { debounceMs: 0, merge, decode: (content: Content) => content.kind === 'bytes' ? new Uint8Array(content.bytes) : new Uint8Array(), encode: (value: Uint8Array | number[]) => ({ kind: 'bytes', bytes: Uint8Array.from(value).buffer }) }, bytesDoc(1, [0]) ) session.change(buffer) await settle() fromHost(accept(0)) await settle() buffer[0] = 9 session.change(buffer) await settle() fromHost(bytesDoc(2, [5])) fromHost(refuse(1, 'conflict')) await settle() expect([...(merge.mock.calls[0]?.[0] ?? [])]).toEqual([1]) }) it('neither merges nor sends after dispose', async () => { const merge = vi.fn((_base: string, outside: string) => outside) const { session, fromHost, edits } = await open({ debounceMs: 0, merge }) session.change('mine') await settle() fromHost(doc(2, 'outside')) session.dispose() fromHost(refuse(0, 'conflict')) await settle() expect(merge).not.toHaveBeenCalled() expect(edits()).toHaveLength(1) }) it('resends a kept draft on editor.flush, but not one still in flight', async () => { const { session, fromHost, edits } = await open({ debounceMs: 0 }) session.change('b') await settle() fromHost({ orca: 1, kind: 'req', id: 8, method: 'editor.flush' }) await settle() expect(edits()).toHaveLength(1) fromHost(refuse(0, 'too_large')) await settle() fromHost({ orca: 1, kind: 'req', id: 9, method: 'editor.flush' }) await settle() expect(edits()).toHaveLength(2) expect(session.value).toBe('b') }) it('ignores changes while read-only', async () => { const { session, edits } = await open({}, doc(1, 'a', true)) expect(session.readOnly).toBe(true) session.change('b') await vi.advanceTimersByTimeAsync(500) expect(session.value).toBe('a') expect(edits()).toHaveLength(0) }) it('needs decode and encode for bytes, and sends what encode returns', async () => { const bytesDoc = (version: number, bytes: number[]) => ({ orca: 1, kind: 'evt', event: 'editor.document', payload: { version, readOnly: false, content: { kind: 'bytes', bytes: new Uint8Array(bytes).buffer } } }) const shell = mountShell('editor') fromHostAll(shell.fromHost, [hello, bytesDoc(1, [1, 2])]) await expect(shell.orca.document!.session()).rejects.toThrow(/decode and encode/) const session = await shell.orca.document!.session({ debounceMs: 0, decode: (content: Content) => content.kind === 'bytes' ? [...new Uint8Array(content.bytes)] : [], encode: (value: number[]) => ({ kind: 'bytes', bytes: new Uint8Array(value).buffer }) }) expect(session.value).toEqual([1, 2]) session.change([3]) await settle() const edit = shell.posted().find((message) => message.method === 'editor.applyEdit') expect(sentBytes(edit)).toEqual([3]) }) it('stops sending and reporting after dispose', async () => { const { session, fromHost, replaced, edits, posted } = await open({ debounceMs: 0 }) session.change('b') session.dispose() await settle() session.change('c') fromHost(doc(2, 'outside')) fromHost({ orca: 1, kind: 'req', id: 4, method: 'editor.flush' }) await settle() expect(edits()).toHaveLength(0) expect(replaced).not.toHaveBeenCalled() expect(posted().at(-1)).toEqual({ orca: 1, kind: 'res', id: 4, result: null }) }) })